API, version 1
Base URL: https://ladder.ankitu.com. Every /v1 call and /mcp needs the workspace key:
Authorization: Bearer alk_live_…
Keys are stored only as SHA-256 hashes. /v1 and /mcp are outside the sign-in; every page is behind it. Times are ISO 8601. Errors come back as {"error": "plain sentence"} with a 4xx status.
Send work in
POST /v1/events
Body: {"events": [ ... ]} or a JSON array, up to 20,000 events, each in the log format. Every event is checked against the JSON Schema; invalid ones are listed and skipped, valid ones are stored. Idempotent on event id: an id already stored is counted as a duplicate and ignored, so a failed upload can be sent again as it is.
{
"accepted": 1260, "duplicates": 0,
"rejected": [{ "index": 7, "id": "bad", "errors": ["event.actor.kind must be one of operator, agent"] }],
"lane_changes": [ ... ], "alerts": [ ... ], "signoff_requests": [ ... ],
"closed_week": 2928, "week_number": 3, "waiting_settlement": 4
}
Work on a step must come after a queue.defined event for it (earlier, or earlier in the same batch).
The Ladder's clock is the data: when an event dated in a new week arrives, the weeks before it are closed and every step is reviewed (promotions happen only at a week close; demotions can happen at any moment). Workspaces that run on the wall clock are also closed by the cron a day after each week ends.
POST /v1/events/csv
Body: the flat CSV layout as text/csv. Same answer as above, plus csv_errors by line.
POST /v1/import/{adapter}
Adapters map an export you already have. Body: {"mapping": {...}, "data": "<the export as text>", "dry_run": false}. With dry_run: true the mapped events come back without being stored.
| Adapter | Takes | Mapping |
|---|---|---|
generic-csv | Any sheet with one row per piece of work | Which column is the item, step, actor, finish time, start time, decision, output fields and item features; how to tell an agent row from a person's (actor_kind + agentValues, or agents names); a step-name map. Example: samples/generic-bills.mapping.json. |
servicenow | A task table export (sc_task or task) as CSV from a list view, or Table API / JSONv2 JSON | steps (short_description or u_step to step id), agents (assigned_to names, prefix* allowed; a filled u_agent_version also marks an agent), fields, features. Item = parent; time = closed_at; decision = close_code; only closed states. Example: samples/servicenow-sc_task.mapping.json. |
salesforce | A Case export (Data Loader CSV or REST query JSON) | steps (Step__c or Type), agents (owner names; Agent_Version__c also marks an agent), fields, features, decisionField (default Reason). Item = ParentId; time = ClosedDate; only closed cases. Example: samples/salesforce-cases.mapping.json. |
Event ids are built from the source row (ServiceNow sys_id, Salesforce Id), so re-importing the same export stores nothing twice.
Ask before acting
POST /v1/route
An agent asks before it acts on an item:
{ "queue": "bills", "step": "decide_hold", "item": "VB-4100000003", "features": { "doc_format": "vtrw-pdf-v4" } }
Answer:
{
"route_id": "rt_…", "decision": "act_alone", "lane": "auto", "audit": true, "audit_rate": 0.1,
"reason": "Decide hold or release runs in Auto: act alone. This item is in the 10% audit sample, so a person will check it. Audits in the last 2 weeks: 98.8% right on 80; …",
"queue": "bills", "step": "decide_hold", "item": "VB-4100000003", "checked_at": "…"
}
decision | Lane | What the agent must do |
|---|---|---|
human_only | Shadow | Not act. A person does the item. The agent may send its own output as step.done with role shadow (that is how it earns promotion). |
propose | Assist | Send its output as role proposal; a person confirms (role confirm) before anything happens. |
act_alone | Auto | Act, and send role act. If audit is true a person will check this item and send an audit.result. |
The audit pick is seeded and reproducible: audit = fnv32("<audit seed>|<queue>|<step>|<item>") / 2^32 < audit_rate (FNV-1a 32-bit with the murmur3 finaliser, src/core/stats.ts), so a client or an auditor can check every pick. In Auto, an item whose watched input value never appeared while the step earned Auto can be sent to a person instead (the novel-input guard, a rule setting).
Batch form: {"items": [ {...}, {...} ], "at": "…"} answers {"answers": [...]} (up to 2,000). The optional at replays a request at a past or simulated time (it also advances the data clock).
Read the lanes
| Call | Answers |
|---|---|
GET /v1/lanes?queue= | Every step: lane, since which week, plain reason, latest week's figures (items seen, agreed, disagreements by severity, settled agent-right / operator-right / both-wrong, estimated accuracy, Wilson 95% lower bound, rolling 2- and 4-week windows, operator error rate), the last lane change with its rule and numbers, any sign-off waiting, and links to the evidence. |
GET /v1/steps/{queue}/{step} | The same for one step. |
GET /v1/steps/{queue}/{step}/evidence | The evidence pack: every week's figures and input mix, every lane change, the rules in force, the outcome test's state, and the latest disagreements and audits with their verdicts. |
GET /v1/ledger?queue= | Every step-week row of the ledger. |
GET /v1/status | Events received (total, today), route calls today, the last webhook, settlements waiting, the data clock. |
GET /v1/settle-queue?limit= | Disagreements waiting for a settlement, with both outputs and the differing fields. |
POST /v1/weeks/close | {"through": "<time>"} closes every week ending before it now, instead of waiting for data or the cron. |
Webhooks
POST /v1/webhooks with {"url": "https://…", "events": ["lane.changed", "drift.alert", "signoff.requested"]} answers 201 with the hook's id and its signing secret (shown once; it is derived from a Worker secret, so nothing is stored). GET /v1/webhooks lists hooks, DELETE /v1/webhooks/{id} stops one, GET /v1/webhooks/deliveries is the delivery log.
Every delivery is a POST with JSON {"id", "type", "created_at", "workspace", "queue", "step", "at", "data"} and these headers:
Ladder-Signature: t=1791563488,v1=<hex HMAC-SHA256(secret, "1791563488.<raw body>")>
Ladder-Event: lane.changed
Ladder-Delivery: dl_…
Ladder-Webhook-Id: wh_…
Check the signature on the raw body and reject a t more than five minutes off (verifySignature in src/worker/crypto.ts does both). Any 2xx counts as delivered. Otherwise it is retried 1, 5, 30, 120 and 360 minutes later, then marked failed; every attempt is in the log.
| Event | When | data |
|---|---|---|
lane.changed | Any promotion or demotion | from, to, the rule that fired, the numbers behind it, week |
drift.alert | Input shift (PSI), outcome drift (CUSUM) or a critical audit error | signal, value, threshold, plain detail, the lane it was demoted to |
signoff.requested | A money-moving step has earned Auto | request_id, the numbers, and the exact signoff event to send back |
Settlement
Disagreements (material or critical differences) wait in the settle queue until a settlement event arrives. The live settler (Claude Haiku 5.5, prompt caching, a $1 a day cap and a kill switch) is built and off: it runs only when the ANTHROPIC_API_KEY Worker secret exists and the settler_on switch is 1, and stops at the cap or when settler_kill is 1. GET /v1/settler shows its state; POST /v1/settler/run {"limit": 10} runs it once. Its verdicts go through the same ingest path as any other settlement.
MCP
POST /mcp (JSON-RPC 2.0, Streamable HTTP, stateless; same key). Tools: which_steps_run_alone, step_evidence, route_item. See gateway.md.
Limits
20,000 events or 50 MB per request; 2,000 items per route call. Event ids, queue ids and step ids: letters, digits and . _ : / # @ -, up to 128. People, agents and items: any printable text up to 128.