← Entry Desk / API
Get a token

Driving Entry Desk from your own code

Everything the web app does is available over HTTP. Paste a register, pick a lane, and get back one JSON object. The deterministic arithmetic the browser does for free is not done server-side, so if you drive the API directly you should send your own prescan facts — that is what the model is held accountable to.

Base URL and headers

https://api.skillsafe.ai/v1/app-api

One header on every request:

The token is app-scoped, so the slug is not a header. There is no X-App-Slug header — a token minted for this app addresses this app and nothing else. The slug appears in exactly one place: the body of POST /guest, which is how you get a token in the first place.

curl -sS -X POST https://api.skillsafe.ai/v1/app-api/guest \
  -H "Content-Type: application/json" \
  -d '{"slug": "entry-desk"}'

# {"ok":true,"data":{"token":"aut_...","guest_id":"gst_...","expires_at":"..."}}

A guest token is enough for /me and /estimate. Running a lane is metered and needs a personal token, which comes from signing in on the token page.

The body of /estimate, /run and /run-stream is the input object itself, not wrapped in an input key. Its fields are listed under the input fields below.

The response envelope

Every response has the same two shapes. Branch on error.code, never on the message text — messages are for humans and will change.

// success
{"ok": true, "data": { ... }}

// failure
{"ok": false, "error": {"code": "VALIDATION_ERROR",
                     "message": "human-readable",
                     "details": { ... }}}

Error codes

codeHTTPWhat it means and what to do
UNAUTHORIZED401No token, a malformed token, or a token for a different app. Mint a new one from the token page.
FORBIDDEN403A guest token on a metered lane. Sign in for a personal token, or ask the publisher to enable sponsorship.
NOT_FOUND404The job id does not exist, or the token belongs to a different app.
VALIDATION_ERROR400The input failed validation. error.details names the offending field - usually task set to something outside the three lanes.
PAYMENT_REQUIRED402The balance is below min_credits. Never let a user reach this: compare hold_credits against /me first.
RATE_LIMITED429Too many requests. Back off and retry with a growing delay; the app-api budget is shared across your whole account.
INTERNAL500A platform fault. Retry once with the same Idempotency-Key so you are not billed twice.

1. A tiny client helper

Two headers on every call: the bearer token and the app slug. Success is always {"ok": true, "data": {...}}; a failure carries error.code, so branch on the code and not on the message text.

2. Who am I, and can I afford it

GET /me is free. subject_type is user for a personal token and guest for an anonymous one. Only a personal token can run a metered lane, and credits is the balance you compare the hold against.

3. Price it before you run it

POST /estimate is free and creates no job. It returns the model binding and hold_credits - the amount reserved, which is almost always more than the settled charge because the hold prices the full output cap. The hold differs per lane, so re-estimate whenever you change task.

4. Run it, and poll to terminal

POST /run returns a job_id. Send an Idempotency-Key on every run, derived from the task, the register, the period and an attempt counter - a retry after a network blip then returns the first job instead of billing a second one. Poll GET /jobs/{id} with a growing delay; never tight-loop.

5. Or stream it

POST /run-stream takes the same body over Server-Sent Events and is what the app itself uses, so the progress card can advance as sections arrive. Concatenate every delta.text in order to rebuild the single JSON object. If the stream dies, keep what arrived - the sections that completed are still usable.

The input fields

Taken from app.js, which is what actually builds the payload — not from intent. task is documented first because it is the only field that changes the shape of what comes back.

fieldtypemeaning
taskstring, requiredThe lane. One of triage, entries or schedule. Documented first because everything else is shared: it is the only field that changes what comes back.
registerstring, requiredThe register of unbooked items - one row per item, delimited by tab, comma, semicolon, pipe or aligned whitespace, with or without a header row. This is the one work object all three lanes read.
periodstring, requiredThe close period as YYYY-MM. The app also accepts Jul-2026 and July 2026 in the UI and normalises them before sending.
coastring, optionalYour chart of accounts: one account per line as code, name and optionally its kind (expense, asset, liability, revenue). Supplying it is what lets account codes be validated rather than merely echoed.
conventionstring, optionaldays (default) or months - how a term-dated item is prorated across the period.
materialitystring, optionalA two-decimal amount. Items under it are flagged, never dropped, and a systematic schedule is never suspended by it.
prescanobject, optional but strongly recommendedThe deterministic facts the browser computed: every item typed and priced, each one's computed period portion and method, its full schedule, and the flags array. Sending it is what makes the model accountable - it must return one reconciliation entry per flag entry, and its amounts are checked against these.
triage_verdictsobject, optionalOnly meaningful on the entries lane: a map of register row number to the verdict a previous triage run gave it. With it, only rows marked book get an entry. This is the handoff.
clip_notestring, optionalSet when the register was longer than one run can carry, telling the model how many rows were profiled locally versus sent so it does not invent the rest.
retry_notestring, optionalSet only on the automatic reformat retry, describing what was wrong with the previous reply. The retry reuses an idempotency key derived from the same input.

The output contract

One JSON object, no prose and no code fence. The envelope is identical across all three lanes; only body differs. Every array is present even when empty, and every amount is a plain two-decimal string with no currency symbol and no thousands separator.

{
  "lane": "triage",
  "lane_inferred": false,
  "period": "2026-07",
  "title": "one line naming the batch and the period",
  "posture": "ready-to-review | queries-outstanding | blocked",
  "verdict": "one sentence a controller could read on its own",
  "summary": "3-6 sentences",
  "item_count": 8,
  "assumptions": ["..."],
  "open_questions": ["..."],
  "findings": [
    {"id": "ED-001", "row": 6, "severity": "critical | high | medium | low",
     "title": "...", "why": "...", "fix": "..."}
  ],
  "reconciliation": [
    {"flag_id": "JE-DUPE", "row": 7,
     "status": "confirmed | noted | set-aside | superseded",
     "finding_id": "ED-001", "note": "..."}
  ],
  "next_lane": {"lane": "entries", "reason": "..."},
  "body": { }
}

A note on skipped_rows[].this_period_not_booked_here in the entries lane: it is that row's own computed period figure, so that totals.debit plus the sum of those values equals prescan.total_this_period. That identity is a completeness check — is every row either booked or explicitly listed — and not an expected posting amount. It sums every row, so a duplicated row inflates it by construction. Do not show it to a user as a target the batch failed to reach. The field is named for the row rather than the period because in the duplicate case the period does receive the amount, just from the other occurrence.

A note on reconciliation: it answers the prescan.flags you sent, one entry per flag entry, keyed on flag_id and row. The same flag_id legitimately appears on several rows, and the right status can differ per row — a round amount backed by an engagement letter is set-aside while the same flag on an unsupported estimate is confirmed. finding_id is many-to-one: one finding may answer several flag entries.

body — the triage lane

One entry per register row, in register order, none omitted.

"body": {
  "items": [
    {"row": 1,
     "description": "echo of the register's description",
     "type": "ap_accrual | prepaid | depreciation | payroll | deferred_revenue | reclass | lease | unknown",
     "type_agrees_with_prescan": true,
     "verdict": "book | defer | reject | query",
     "belongs_in_period": "2026-07",
     "cutoff_test": "the date that decides the period, and what it decides",
     "materiality_test": "the amount against the floor",
     "approver": "Accounting manager",
     "amount_this_period": "16000.00",
     "reasoning": "2-4 sentences",
     "confidence": "high | medium | low"}
  ],
  "counts": {"book": 6, "defer": 0, "reject": 1, "query": 1},
  "batch_total_this_period": "116478.58"
}

body — the entries lane

Debits equal credits on every entry. The app checks it in cents.

"body": {
  "entries": [
    {"row": 2,
     "title": "July release of the D&O premium",
     "basis": "120000.00",
     "basis_source": "policy POL-88231, 12-month term 2026-07-15 to 2027-07-14",
     "lines": [
       {"account": "6800", "account_name": "Insurance expense", "department": "G&A",
        "debit": "5589.04", "credit": "", "memo": "Jul 2026 release"},
       {"account": "1420", "account_name": "Prepaid expenses", "department": "G&A",
        "debit": "", "credit": "5589.04", "memo": "Jul 2026 release"}
     ],
     "total_debit": "5589.04",
     "total_credit": "5589.04",
     "memo": "audit-grade: what, whose, which period, per what support",
     "support": ["POL-88231"],
     "approver": "Accounting manager",
     "reverses": false,
     "reversal_date": "",
     "review_notes": ["..."]}
  ],
  "totals": {"debit": "21589.04", "credit": "21589.04"},
  "skipped_rows": [
    {"row": 6, "reason": "dated in the prior month", "this_period_not_booked_here": "25000.00"}
  ]
}

body — the schedule lane

The periods must sum to the basis. The app recomputes this in cents and will contradict a false claim.

"body": {
  "schedules": [
    {"row": 2,
     "accrual_name": "D&O insurance premium",
     "basis": "120000.00",
     "basis_derivation": "the contractual full-term amount, from policy POL-88231",
     "support_reference": "POL-88231",
     "convention": "days",
     "term_start": "2026-07-15",
     "term_end": "2027-07-14",
     "period_portion": "5589.04",
     "already_booked": "0.00",
     "this_period_accrual": "5589.04",
     "periods": [
       {"period": "2026-07", "amount": "5589.04", "note": "17 of 365 days, part month"},
       {"period": "2026-08", "amount": "10191.78", "note": ""}
     ],
     "sums_to_basis": true,
     "reversal_note": "does not reverse; the balance releases over the term",
     "draft_je": "Dr 6800 Insurance expense 5589.04 / Cr 1420 Prepaid expenses 5589.04 - Jul 2026 release per POL-88231"}
  ],
  "release_total_this_period": "30949.31",
  "unschedulable": [
    {"row": 1, "reason": "an AP accrual with no term - a single-period item"}
  ]
}

Rate limits and good manners

What this API will not do