← Process Desk / API
Tokens

Drive Process Desk from your own code

Everything the web page does is available over HTTP: send the step table, the user’s account of how the process works and the measurements you computed, and get back the same improvement read — or the same standard operating procedure. The natural use is a pipeline that re-measures a process whenever the step table changes in your own system, or a job that regenerates every SOP in a folder after a redesign.

One thing to be clear about before the first call: the model never computes a number. Lead time, process time, the activity ratio, rolled %C&A, handoffs, the bottleneck, the longest wait, items in process from Little’s law and the waste candidates are all computed by the caller and sent as facts. The model’s job is judgement over those facts and over the user’s own words — which waste is real, which change is worth making, what the SOP has to say. See computing the facts yourself; the engine the web page uses ships as a plain browser script (/flow.js) you can run in node with a stubbed window.

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

The token is minted for this app (the guest endpoint takes {"slug":"process-desk"} in its body), so no slug header is needed afterwards — send your token as Authorization: Bearer … on every call.

StatusCodeMeaning
400validation_errorThe body is not a JSON object, or a declared required field (task, steps, description, facts) is missing.
401unauthorizedNo token, or a stale one. Mint a guest token or sign in again.
402insufficient_creditsThe balance is under min_credits. Price with /estimate first.
403forbiddenA guest token tried to run: running is metered and needs a personal token.
404not_foundUnknown job id.
429rate_limitedBack off and retry.

1. Get a token

A guest token is free and enough for /me and /estimate. Running a lane needs a personal token — sign in on the token page and copy it from there.

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

2. A tiny client

One helper, one envelope. The samples below reuse it.

curl -s -X GET https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json"

3. Check the session and the balance

GET /me returns subject_type (user or guest), subject_id and credits. A real user’s first run should not 402: compare credits with the estimate’s hold_credits before running.

curl -s -X GET https://api.skillsafe.ai/v1/app-api/me \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json"

4. Price the run — free

POST /estimate with the exact run body returns model (gpt-5.6-terra), model_alias (gpt-terra), markup_bps (1000), hold_credits (reserved, not the price) and min_credits. No job, no charge. The body must be a JSON object — an {"input": {…}} wrapper is accepted by the transport and then hides every field from the model, so send the fields at the top level exactly as below.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "improve", "process_name": "Purchase-order approval", "description": "A buyer drafts the purchase order in the ERP and emails it to their line manager, who approves by replying to the email. Anything over 5,000 also goes to finance, who check the budget line in a shared spreadsheet, and spend over 25,000 needs the finance director. Once approved the buyer re-keys the PO number into the vendor portal by hand because the portal is not connected to the ERP, and the ERP then sends the PO. Orders get stuck for days when the manager travels, roughly one in ten come back from finance because the cost centre is wrong, and the portal re-keying produces typos that vendors query. We want the routine orders to go out the same day without losing the controls on big spend.", "steps": "step | owner | process time | wait before | %C&A | kind\nDraft the purchase order in the ERP | Buyer | 30m | 0 | 90% | manual\nEmail the PO to the line manager | Buyer | 5m | 0 | 100% | manual\nApprove the PO | Line manager | 10m | 2d | 100% | approval\nCheck the budget line in the spreadsheet | Finance | 20m | 1d | 95% | manual\nApprove spend over the threshold | Finance director | 10m | 3d | 100% | approval\nRe-key the PO number into the vendor portal | Buyer | 15m | 4h | 85% | manual\nSend the PO to the vendor | ERP | 1m | 0 | 100% | automated", "demand_per_week": "40", "facts": "<JSON string from Flow.facts(...) - see the facts section>"}'

The fields both lanes take

task first: it chooses the lane, and the two lanes have different bodies and different verdict enums.

FieldTypeMeaning
taskstring, requiredimprove or document. improve reads where the time goes and proposes a future state as operations the browser applies; document writes the standard operating procedure. A missing or unknown value makes the model pick the closer lane and name it in lane — it never blends the two.
stepsstring, requiredThe step table as text, one step per line: step | owner | process time | wait before | %C&A | kind, with an optional header row. Tab and semicolon separators work, as does numbered prose with durations and @owner. kind is manual, automated, approval or decision. Send the same text you measured.
descriptionstring, requiredHow the process works today, in the user’s words: triggers, exceptions, what goes wrong, what they want. The model quotes it and is forbidden to invent a system, a rule, a team or a volume it does not name.
factsstring, requiredA JSON string (not an object): JSON.stringify(Flow.facts(…)). Every figure in the reply must come from here or from your own text.
process_namestringThe user’s name for the process. Also carried inside facts.
demand_per_weekstringItems per week, or "". When it is set, facts carries Little’s law and per-step utilisation, and the improve lane reads them.
future_notestringDocument lane only, optional: the changes an earlier improve run applied to reach this table. Its presence tells the SOP to say it supersedes the earlier version.
retry_notestringOptional. What went wrong on a previous attempt, for a retry of the same input.

Computing the facts yourself

/flow.js is a plain browser script with no imports: load it in node with a stubbed window and call the same two functions the page calls. Flow.parseSteps reads the table text (it never throws on user input — it returns warnings and errors instead), and Flow.facts measures it.

global.window = {}; require("vm").runInThisContext(require("fs").readFileSync("flow.js", "utf8"));
const Flow = window.Flow;

const table = `step | owner | process time | wait before | %C&A | kind
Draft the purchase order in the ERP | Buyer | 30m | 0 | 90% | manual
Approve the PO | Line manager | 10m | 2d | 100% | approval`;

const calendar = { hours_per_day: 8, days_per_week: 5 };   // what a working day and week mean
const parsed = Flow.parseSteps(table, calendar);           // { steps, warnings, errors, header, format, calendar }
const facts  = Flow.facts(parsed, { process_name: "Purchase-order approval", demand_per_week: 40 });

// facts.metrics.total_lt, .activity_ratio_pct, .rolled_ca_pct, .handoff_count, .longest_wait,
// .bottleneck, .littles_law, .posture, .posture_reasons, .waste_candidates ...
JSON.stringify(facts)   // this string is the `facts` field

The parsed object carries, besides process_name, calendar, demand_per_week, table_format, header_row, parse_warnings, operations_allowed and mermaid, a metrics object:

GroupKeys
per stepsteps[] = {n, name, owner, kind, pt_h, wait_h, lt_h, pt, wait, lt, ca_pct, rejects_per_100, people, group, util_pct, lt_share_pct, notes[]}. Durations come twice: as hours (pt_h) and as text (pt, "6d 5h 31m"). Either form may be quoted; neither may be converted.
totalsstep_count, block_count, parallel_groups, total_pt_h, total_wait_h, total_lt_h, total_pt, total_wait, total_lt, activity_ratio_pct, rolled_ca_pct, queue_share_pct.
flowhandoffs[] {from, to, before_step}, handoff_count, ping_pong[] {owner, via, steps}, roles[] {name, steps, pt_h, pt, pt_share_pct}, role_count, counts {manual, automated, approval, decision}.
constraintslongest_wait {step, name, hours, text, share_of_lt_pct}, bottleneck {step, name, owner, pt_h, pt, people, util_pct, capacity_per_week, basis}, overloaded_steps[], littles_law {demand_per_week, lt_weeks, items_in_process, labour_hours_per_week} or null.
judgement aidsposture (flowing | sticky | stalled), posture_reasons[], waste_candidates[] {category, steps, evidence} — candidates the model must confirm or dismiss.

The improve request body, in full

Every field the page sends for the improve lane, with facts abbreviated to one line:

{
  "task": "improve",
  "process_name": "Purchase-order approval",
  "description": "A buyer drafts the purchase order in the ERP and emails it to their line manager, who approves by replying to the email. Anything over 5,000 also goes to finance, who check the budget line in a shared spreadsheet, and spend over 25,000 needs the finance director. Once approved the buyer re-keys the PO number into the vendor portal by hand because the portal is not connected to the ERP, and the ERP then sends the PO. Orders get stuck for days when the manager travels, roughly one in ten come back from finance because the cost centre is wrong, and the portal re-keying produces typos that vendors query. We want the routine orders to go out the same day without losing the controls on big spend.",
  "steps": "step | owner | process time | wait before | %C&A | kind\nDraft the purchase order in the ERP | Buyer | 30m | 0 | 90% | manual\nEmail the PO to the line manager | Buyer | 5m | 0 | 100% | manual\nApprove the PO | Line manager | 10m | 2d | 100% | approval\nCheck the budget line in the spreadsheet | Finance | 20m | 1d | 95% | manual\nApprove spend over the threshold | Finance director | 10m | 3d | 100% | approval\nRe-key the PO number into the vendor portal | Buyer | 15m | 4h | 85% | manual\nSend the PO to the vendor | ERP | 1m | 0 | 100% | automated",
  "demand_per_week": "40",
  "facts": "<JSON string from Flow.facts(...) - see the facts section>"
}

The document request body, in full

The same shape, a different process and a different lane. demand_per_week is optional here: the SOP does not price flow.

{
  "task": "document",
  "process_name": "New vendor onboarding",
  "description": "Onboarding a new supplier so that we can raise purchase orders against them. Procurement collects the vendor's registration form, tax certificate and bank letter; compliance screens the company and its directors against the sanctions list; legal reviews our standard terms or negotiates exceptions, which is where most of the time goes; the category manager approves the vendor for their spend category; accounts payable creates the vendor record and bank details in the ERP and verifies the bank details by calling a number taken from an independent source, never from the vendor's email; then the ERP notifies procurement. Exceptions: a vendor that fails screening is rejected and procurement told why; a sole trader has no directors to screen; a vendor already in the ERP under another entity is merged rather than created; bank detail changes later follow the same call-back rule. This process is audited annually, and the auditors want to see the call-back evidence.",
  "steps": "step | owner | process time | wait before | %C&A | kind\nCollect the registration form, tax certificate and bank letter | Procurement | 45m | 0 | 80% | manual\nScreen the vendor and its directors against the sanctions list | Compliance | 20m | 1d | 100% | manual\nReview the standard terms or negotiate exceptions | Legal | 2h | 3d | 95% | manual\nApprove the vendor for the category | Category manager | 15m | 2d | 100% | approval\nCreate the vendor record and bank details in the ERP | Accounts payable | 30m | 1d | 90% | manual\nVerify the bank details by call-back to an independent number | Accounts payable | 15m | 4h | 100% | manual\nNotify procurement that the vendor is active | ERP | 1m | 0 | 100% | automated",
  "demand_per_week": "6",
  "facts": "<JSON string from Flow.facts(...) - see the facts section>"
}

Worked example: the document lane

Priced free with /estimate; swap the path for /run to run it.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/estimate \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "document", "process_name": "New vendor onboarding", "description": "Onboarding a new supplier so that we can raise purchase orders against them. Procurement collects the vendor'\''s registration form, tax certificate and bank letter; compliance screens the company and its directors against the sanctions list; legal reviews our standard terms or negotiates exceptions, which is where most of the time goes; the category manager approves the vendor for their spend category; accounts payable creates the vendor record and bank details in the ERP and verifies the bank details by calling a number taken from an independent source, never from the vendor'\''s email; then the ERP notifies procurement. Exceptions: a vendor that fails screening is rejected and procurement told why; a sole trader has no directors to screen; a vendor already in the ERP under another entity is merged rather than created; bank detail changes later follow the same call-back rule. This process is audited annually, and the auditors want to see the call-back evidence.", "steps": "step | owner | process time | wait before | %C&A | kind\nCollect the registration form, tax certificate and bank letter | Procurement | 45m | 0 | 80% | manual\nScreen the vendor and its directors against the sanctions list | Compliance | 20m | 1d | 100% | manual\nReview the standard terms or negotiate exceptions | Legal | 2h | 3d | 95% | manual\nApprove the vendor for the category | Category manager | 15m | 2d | 100% | approval\nCreate the vendor record and bank details in the ERP | Accounts payable | 30m | 1d | 90% | manual\nVerify the bank details by call-back to an independent number | Accounts payable | 15m | 4h | 100% | manual\nNotify procurement that the vendor is active | ERP | 1m | 0 | 100% | automated", "demand_per_week": "6", "facts": "<JSON string from Flow.facts(...) - see the facts section>"}'

5. Run it, then poll

POST /run returns {job_id}; GET /jobs/{job_id} until status is succeeded or failed. Send an Idempotency-Key header derived from the step table and the lane so a retry never double-bills. The reply’s output.output is the JSON text described in the contract below; charged_credits is the actual cost.

curl -s -X POST https://api.skillsafe.ai/v1/app-api/run \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: po-approval-improve-2026-09-23" \
  -d '{"task": "improve", "process_name": "Purchase-order approval", "description": "A buyer drafts the purchase order in the ERP and emails it to their line manager, who approves by replying to the email. Anything over 5,000 also goes to finance, who check the budget line in a shared spreadsheet, and spend over 25,000 needs the finance director. Once approved the buyer re-keys the PO number into the vendor portal by hand because the portal is not connected to the ERP, and the ERP then sends the PO. Orders get stuck for days when the manager travels, roughly one in ten come back from finance because the cost centre is wrong, and the portal re-keying produces typos that vendors query. We want the routine orders to go out the same day without losing the controls on big spend.", "steps": "step | owner | process time | wait before | %C&A | kind\nDraft the purchase order in the ERP | Buyer | 30m | 0 | 90% | manual\nEmail the PO to the line manager | Buyer | 5m | 0 | 100% | manual\nApprove the PO | Line manager | 10m | 2d | 100% | approval\nCheck the budget line in the spreadsheet | Finance | 20m | 1d | 95% | manual\nApprove spend over the threshold | Finance director | 10m | 3d | 100% | approval\nRe-key the PO number into the vendor portal | Buyer | 15m | 4h | 85% | manual\nSend the PO to the vendor | ERP | 1m | 0 | 100% | automated", "demand_per_week": "40", "facts": "<JSON string from Flow.facts(...) - see the facts section>"}'
curl -s -X GET https://api.skillsafe.ai/v1/app-api/jobs/JOB_ID \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json"

6. Or stream it

POST /run-stream is server-sent events: job, then tick heartbeats, then done with the full output. Browsers receive ticks rather than text deltas, so build progress on elapsed time and parse the output from done.

curl -N -s -X POST https://api.skillsafe.ai/v1/app-api/run-stream \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" -H "Accept: text/event-stream" \
  -H "Content-Type: application/json" -d '{"task": "improve", "process_name": "Purchase-order approval", "description": "A buyer drafts the purchase order in the ERP and emails it to their line manager, who approves by replying to the email. Anything over 5,000 also goes to finance, who check the budget line in a shared spreadsheet, and spend over 25,000 needs the finance director. Once approved the buyer re-keys the PO number into the vendor portal by hand because the portal is not connected to the ERP, and the ERP then sends the PO. Orders get stuck for days when the manager travels, roughly one in ten come back from finance because the cost centre is wrong, and the portal re-keying produces typos that vendors query. We want the routine orders to go out the same day without losing the controls on big spend.", "steps": "step | owner | process time | wait before | %C&A | kind\nDraft the purchase order in the ERP | Buyer | 30m | 0 | 90% | manual\nEmail the PO to the line manager | Buyer | 5m | 0 | 100% | manual\nApprove the PO | Line manager | 10m | 2d | 100% | approval\nCheck the budget line in the spreadsheet | Finance | 20m | 1d | 95% | manual\nApprove spend over the threshold | Finance director | 10m | 3d | 100% | approval\nRe-key the PO number into the vendor portal | Buyer | 15m | 4h | 85% | manual\nSend the PO to the vendor | ERP | 1m | 0 | 100% | automated", "demand_per_week": "40", "facts": "<JSON string from Flow.facts(...) - see the facts section>"}'
# events: job (the job id), tick (heartbeat), done (the full output). Browsers receive ticks, not deltas.

The output contract

One JSON object, nothing around it — no prose, no code fence, no trailing comma. Every array in the contract is present, empty when there is nothing to say. Common keys on every reply:

{
  "lane": "improve" | "document",
  "title": "under 80 characters, names the process and the reading",
  "headline": "one sentence: the verdict and the one thing that decides it",
  "verdict": "the lane enum below",
  "summary": "three to five sentences the user reads instead of the rest",
  "notes_on_input": ["anything unreadable, contradictory or missing in the table or the description"],
  "risks": ["what could make the change fail, or what the SOP still leaves exposed"],
  "next_steps": ["ordered, concrete, each starts with a verb"]
}
LaneVerdictBody
improve flowing · sticky · stalled — copied verbatim from facts.metrics.posture current_state (two to four paragraphs; every number from facts) · wastes[] {category: waiting|rework|handoffs|over_processing|manual_work, steps[], finding, evidence} · changes[] (the operations below) · future_state (words only) · impact[] {measure: time_saved_per_cycle|error_rate|cost|employee_satisfaction, direction: better|same|worse|unknown, note}, all four measures listed · implementation_plan[] {phase, actions[], owner, checkpoint}, two to four phases, quick wins first
document audit_ready · needs_detail · not_documentable process_name · owner · review_cadence: monthly|quarterly|annually · purpose · scope {included, excluded} · raci[] {step, responsible[], accountable, consulted[], informed[]} · steps[] {step, name, who, when, how, output} · exceptions[] {scenario, action} · metrics[] {metric, target, how_to_measure} · related_documents[]

The change operations

The improve lane proposes the future state as operations, not as figures: the browser applies them to the parsed table and computes the new lead time, activity ratio and yield itself. Each entry carries a why. facts.operations_allowed lists the set the engine accepts.

{"op": "remove",   "step": n, "why": "..."}
{"op": "merge",    "steps": [n, n+1], "why": "..."}                consecutive steps become one
{"op": "parallel", "steps": [n, n+1], "why": "..."}                consecutive steps run at the same time
{"op": "automate", "step": n, "pt": "5m", "why": "..."}            the new process time as text (5m, 1h, 0)
{"op": "cut_wait", "step": n, "wait": "4h", "why": "..."}          the new wait before the step as text
{"op": "set_ca",   "step": n, "ca": 99, "why": "..."}              a higher first-pass yield, and what raises it
{"op": "reassign", "step": n, "owner": "Role", "why": "..."}       a role already in the table, unless labelled new
{"op": "add", "after": n, "why": "...",
  "step": {"name": "...", "owner": "Role", "pt": "10m", "wait": "0", "ca": "100%", "kind": "decision"}}

A cut_wait must be shorter than the current wait, an automate shorter than the current process time and a set_ca higher than the current yield; merge and parallel take consecutive steps. Anything else is rejected when the browser applies it.

What the page checks, and what you should check

Recon.reconcile in /recon.js re-reads the reply against the facts it sent and shows every disagreement. Three checks matter most:

  1. Every number must come from facts or from the user’s own text. Each figure in headline, summary, current_state, the wastes, the SOP steps, the exceptions and the metric targets is matched against the numbers the engine produced and the numbers in description, steps and process_name. Anything else is a disagreement. current_state must additionally quote the total lead time, the activity ratio and the rolled %C&A.
  2. The improve lane’s verdict must equal facts.metrics.posture, and future_state and every impact[].note must contain no numbers at all — the future state is the browser’s to price, so a figure there is flagged whatever its value. changes[] are applied by the browser, and each rejected operation counts as a disagreement with the reason the engine gave.
  3. The document lane’s steps[] must cover every step in the table exactly once, by the table’s own step numbers — a step documented twice, a step that does not exist and a step left out are each flagged — and the RACI is linted one row per step: exactly one accountable and at least one responsible, with roles drawn from the table or the description.

Do the same in a pipeline before trusting a figure: hold the facts you sent, and treat the reply as judgement rather than as arithmetic.

Derived from the agent skills @anthropics/process-optimization and @anthropics/process-doc.