← Change Desk / API
Your token

Driving Change Desk from your own code

Everything the web page does is one HTTP API. The base URL is https://api.skillsafe.ai/v1/app-api, every call carries Authorization: Bearer <token>, and every response is the same envelope.

The task field comes first

Change Desk is one app with three lanes over the same change. Every request must carry a task field; it is what routes the run. The three values are:

task What you get Derived from
impactThe change request: blast radius, risk assessment, the rollback plan, the approvals to request.@anthropics/change-request
checklistThe pre-deploy verification checklist: gates in time order, each with an owner and an observable pass criterion.@anthropics/deploy-checklist
runbookThe operational runbook: preconditions, the numbered procedure with commands, verification, rollback, escalation.@anthropics/runbook

If task is missing or unrecognised the model picks the closest lane and names its choice in lane and in the first sentence of summary - it never blends two lanes into one answer. Read lane off the reply rather than assuming it echoes what you sent.

The input fields

Field Type Required Meaning
taskstringyesThe lane: impact, checklist or runbook.
changestringyesThe change itself. The web page sends a parsed digest (see below); the API accepts either that or a raw diff.
intentstringnoWhy the change exists, in the author's words. Its absence is itself a finding.
environmentstringnoproduction, staging-then-production, multi-region or customer-managed.
windowstringnobusiness-hours, low-traffic, maintenance-window, freeze or emergency. A freeze or an emergency changes the answer.
audiencestringnoapprover, author or oncall.
contextstringnoTable sizes, traffic shape, deploy duration, ownership - anything the diff cannot show.
prescan_factsobjectno{risk_tier, flags[], resources[], stats}. Facts from a parser. Every flags[].id you send must come back exactly once in coverage_check.
upstreamstringnoA previous lane's result, when you are chaining lanes. The next lane builds on it instead of starting again.
retry_notestringnoOnly when a previous reply was malformed. The web page sets this on its single automatic reformat retry.

What change should contain

The browser parses the paste and sends a digest rather than the raw text: file headers with their classification, per-statement lock and reversibility lines, and the diff bodies. You can send a raw unified diff instead and it will work, but you lose the statement classification the model is asked to reason against - so if you are automating this, run your own parse and send the header lines too. This is the shape the web page sends:

# change: 1 file, +3 / -0, 1 area
# areas: db
# migrations: 1 file, 2 statements, 1 destructive, 2 table-blocking
# rollback block in the migration: ABSENT
# prescan risk tier: severe

# --- stated intent (the author's own words)
Retire orders.legacy_ref now that status_v2 has been dual-written for six weeks.

# --- file 1: db/migrations/0042_drop_legacy_ref.sql [database migration, new file] +3/-0
#   statement 1 [drop-column] lock=ACCESS EXCLUSIVE reversible=irreversible DESTRUCTIVE table=orders
#   statement 2 [create-index] lock=SHARE reversible=reversible table=orders
+ALTER TABLE orders DROP COLUMN legacy_ref;
+CREATE INDEX idx_orders_status_v2 ON orders (status_v2);

The envelope

Every response is {"ok":true,"data":{...}} or {"ok":false,"error":{"code":"...","message":"..."}}. Check ok before reading data; the HTTP status mirrors it but the code is what you branch on.

HTTP code What it means here
400VALIDATION_ERRORThe body was not the shape the endpoint expects. Usually a missing task or a non-string change.
401UNAUTHORIZEDNo token, or a token that has expired. Mint a new one on the token page.
402INSUFFICIENT_CREDITSThe balance is below min_credits. Call /estimate first and compare against /me - the web page disables its run button rather than letting this happen.
403FORBIDDENA guest token on a metered run. Sign in for a personal token.
404NOT_FOUNDA job id that does not exist, or a path that is not part of this API.
429RATE_LIMITEDBack off. Do not tight-loop; the budget is shared across every caller from your IP.
503UNAVAILABLEThe model tier is briefly unavailable. Retry with the same idempotency key.

Step 1 — get a token

Open the token page, sign in, and press Copy shell export. That gives you CD_TOKEN in your shell. A guest token is enough for /me and /estimate; running a lane is metered and needs a personal token. Never paste a token into source control - read it from your own secret store and keep the placeholder "YOUR_TOKEN" in the samples below.

There is one endpoint that names the app rather than inferring it from the token: POST /guest, whose body is {"slug":"change-desk"}. Every other call in this document takes its app identity from the token, so there is no slug in the path.

Step 2 — check the token with /me

GET /me is free. It tells you whether the token is a guest or a person, and what the balance is - which is what the web page uses to disable the run button before a 402 can happen. Every later sample reuses the call() helper defined here.

# Every call carries the token. Keep it in your shell, never in source control.
export CD_TOKEN="YOUR_TOKEN"        # from https://change-desk.skillsafe.ai/tokens.html

curl -s "https://api.skillsafe.ai/v1/app-api/me" \
  -H "Authorization: Bearer $CD_TOKEN" 

Step 3 — price the run with /estimate

POST /estimate is free, starts no job and charges nothing. It returns model, model_alias, markup_bps, hold_credits, min_credits and sponsor_enabled. Estimate the lane you are about to run: the three lanes have different prompts and output caps, so hold_credits differs between them and lane A's price is not lane B's.

hold_credits is a reservation, not a price. It reserves the full output cap; the settled charged_credits is usually far lower.

# free: no job is started and nothing is charged
curl -s "https://api.skillsafe.ai/v1/app-api/estimate" \
  -H "Authorization: Bearer $CD_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "impact", "change": "# change: 1 file, +3 / -0, 1 area\n# areas: db\n# migrations: 1 file, 2 statements, 1 destructive, 2 table-blocking\n# rollback block in the migration: ABSENT\n# prescan risk tier: severe\n\n# --- stated intent (the author's own words)\nRetire orders.legacy_ref now that status_v2 has been dual-written for six weeks.\n\n# --- file 1: db/migrations/0042_drop_legacy_ref.sql [database migration, new file] +3/-0\n#   statement 1 [drop-column] lock=ACCESS EXCLUSIVE reversible=irreversible DESTRUCTIVE table=orders\n#   statement 2 [create-index] lock=SHARE reversible=reversible table=orders\n+ALTER TABLE orders DROP COLUMN legacy_ref;\n+CREATE INDEX idx_orders_status_v2 ON orders (status_v2);", "intent": "Retire orders.legacy_ref now that status_v2 has been dual-written for six weeks.", "environment": "production", "window": "low-traffic", "audience": "approver", "context": "orders is 41 million rows. A deploy takes 6 minutes to roll all pods.", "prescan_facts": {"risk_tier": "severe", "flags": [{"id": "CD-DESTRUCTIVE", "severity": "critical", "detail": "1 statement destroys data and cannot be undone by re-running the migration (ALTER TABLE ... DROP COLUMN)"}, {"id": "CD-INDEX-BLOCKING", "severity": "high", "detail": "1 CREATE INDEX without CONCURRENTLY"}, {"id": "CD-LOCK", "severity": "high", "detail": "2 statements take a table-blocking lock (ACCESS EXCLUSIVE, SHARE)"}, {"id": "CD-NO-ROLLBACK", "severity": "high", "detail": "No down-migration or rollback block found in 1 migration file"}], "resources": [], "stats": {"files": 1, "adds": 3, "dels": 0, "areas": 1, "statements": 2, "destructive": 1, "blocking": 2, "migrations": 1, "flags": 4, "blockers": 4}}}' 

Step 4 — run a lane and poll the job

POST /run returns {"job_id":"job_..."} immediately. Poll GET /jobs/{job_id} until status is succeeded or failed. The reply text is at data.output.output and is the JSON object the next section describes.

Send an idempotency key. Put it in the Idempotency-Key header and derive it from the lane plus a hash of the input, exactly as the web page does (change-desk:impact:<hash>:a1). Two lanes over the same change are two distinct runs and must not share a key; a retry after a network blip must reuse the same key, or you pay twice for one answer.

# metered: this one charges credits
JOB=$(curl -s "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $CD_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: change-desk:impact:$(echo -n "$CHANGE" | shasum | cut -c1-16):a1" \
  -d @request.json | python3 -c "import json,sys;print(json.load(sys.stdin)['data']['job_id'])")

# poll until it reaches a terminal state
until [ "$(curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $CD_TOKEN" \
      | python3 -c "import json,sys;print(json.load(sys.stdin)['data']['status'])")" != "running" ]; do
  sleep 2
done

curl -s "https://api.skillsafe.ai/v1/app-api/jobs/$JOB" -H "Authorization: Bearer $CD_TOKEN" \
  | python3 -c "import json,sys;print(json.load(sys.stdin)['data']['output']['output'])"

Step 5 — stream it instead

POST /run-stream is the same request with an SSE response, which is what the web page uses so the progress card can advance on real signal. Events arrive as data: {"delta":"..."} lines, then a terminal event carrying the job. Accumulate the deltas: the concatenation is the same JSON object /run would have returned. If the stream breaks midway, the accumulated text is still worth parsing - the web page closes the JSON at the last structurally sound point and renders whatever sections arrived.

curl -N -s "https://api.skillsafe.ai/v1/app-api/run-stream" \
  -H "Authorization: Bearer $CD_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -H "Idempotency-Key: change-desk:runbook:$(echo -n "$CHANGE" | shasum | cut -c1-16):a1" \
  -d @request.json

The output contract

The reply is one JSON object. The shape is identical in all three lanes - only steps, detail_table and artifact carry lane-specific content. These are the fields the web page's renderer actually reads; anything else in the object is ignored.

Field Type Meaning
lanestringThe lane that was answered. Read this rather than assuming it echoes task.
titlestringA short name for the change.
headlinestringOne sentence an approver could read alone.
verdictstringready, needs-work or blocked.
risk_tierstringlow, moderate, high or severe. The model's own judgement, not a copy of the prescan's.
summarystringTwo to five sentences.
checks[]array{id, name, status, evidence, requirement}. status is pass, warn, fail or unknown.
findings[]array{id, severity, area, problem, evidence, fix}. severity is critical, high, medium or low.
steps[]array{n, phase, title, detail, owner, command, expected, abort}. Lane-specific meaning - see below.
detail_tableobject{title, columns[], rows[][]}. Rows are arrays of strings in column order.
artifactobject{format, filename, title, content}. content is the complete document in Markdown.
coverage_check[]array{flag_id, status, note}, one per prescan flag you sent. status is confirmed, cleared or not-applicable.
assumptions[]arrayStrings.
open_questions[]arrayStrings.

What the lane-specific parts mean:

Lane <code>steps[]</code> are <code>steps[].phase</code> <code>detail_table</code> is <code>artifact.filename</code>
impactthe rollback plandetectdeciderevertverifythe blast radius, columns [Area, What changes, Who notices first, Evidence]CHANGE-REQUEST.md
checklistthe gates, in time orderT-24h, T-1h, T-15m, T-0, T+15m, T+1h, T+24hthe sign-offs, columns [Role, Signing off on, Before which gate]DEPLOY-CHECKLIST.md
runbookthe procedure, numberedpreconditionexecuteverifyrollbackthe escalation path, columns [If this happens, Escalate to, How, Within]RUNBOOK.md

Two contract details worth automating against. In the checklist lane every gate carries an expected that is observable - a gate without one is a defect, not a style choice. In the runbook lane exactly one step's abort marks the point of no return; if the change contains nothing irreversible, the prompt says so explicitly instead.

Worked example: each lane over the same change

The three requests below differ only in task and audience. That is the whole point of the lane router: one work object, three documents.

Lane impact — the change request

curl -s "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $CD_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "impact", "change": "# change: 1 file, +3 / -0, 1 area\n# areas: db\n# migrations: 1 file, 2 statements, 1 destructive, 2 table-blocking\n# rollback block in the migration: ABSENT\n# prescan risk tier: severe\n\n# --- stated intent (the author's own words)\nRetire orders.legacy_ref now that status_v2 has been dual-written for six weeks.\n\n# --- file 1: db/migrations/0042_drop_legacy_ref.sql [database migration, new file] +3/-0\n#   statement 1 [drop-column] lock=ACCESS EXCLUSIVE reversible=irreversible DESTRUCTIVE table=orders\n#   statement 2 [create-index] lock=SHARE reversible=reversible table=orders\n+ALTER TABLE orders DROP COLUMN legacy_ref;\n+CREATE INDEX idx_orders_status_v2 ON orders (status_v2);", "intent": "Retire orders.legacy_ref now that status_v2 has been dual-written for six weeks.", "environment": "production", "window": "low-traffic", "audience": "approver", "context": "orders is 41 million rows. A deploy takes 6 minutes to roll all pods.", "prescan_facts": {"risk_tier": "severe", "flags": [{"id": "CD-DESTRUCTIVE", "severity": "critical", "detail": "1 statement destroys data and cannot be undone by re-running the migration (ALTER TABLE ... DROP COLUMN)"}, {"id": "CD-INDEX-BLOCKING", "severity": "high", "detail": "1 CREATE INDEX without CONCURRENTLY"}, {"id": "CD-LOCK", "severity": "high", "detail": "2 statements take a table-blocking lock (ACCESS EXCLUSIVE, SHARE)"}, {"id": "CD-NO-ROLLBACK", "severity": "high", "detail": "No down-migration or rollback block found in 1 migration file"}], "resources": [], "stats": {"files": 1, "adds": 3, "dels": 0, "areas": 1, "statements": 2, "destructive": 1, "blocking": 2, "migrations": 1, "flags": 4, "blockers": 4}}}' 

The reply's shape, abbreviated:

{
  "lane": "impact",
  "title": "Drop orders.legacy_ref and index status_v2",
  "headline": "An irreversible column drop and a blocking index build on a 41-million-row table, with no down-migration.",
  "verdict": "needs-work",
  "risk_tier": "severe",
  "summary": "...",
  "checks": [
    {
      "id": "C1",
      "name": "Rollback path exists",
      "status": "fail",
      "evidence": "db/migrations/0042_drop_legacy_ref.sql carries no down block",
      "requirement": "A restore procedure, since DROP COLUMN cannot be reverted"
    }
  ],
  "findings": [
    {
      "id": "F1",
      "severity": "critical",
      "area": "db",
      "problem": "DROP COLUMN legacy_ref destroys the column's data",
      "evidence": "statement 1 [drop-column] reversible=irreversible DESTRUCTIVE",
      "fix": "Confirm the restore point before running, and deploy the code that stops reading it first"
    }
  ],
  "steps": [
    {
      "n": 1,
      "phase": "detect",
      "title": "Watch the orders read path",
      "owner": "the on-call for orders",
      "expected": "error rate returns to baseline within 5 minutes",
      "abort": "..."
    }
  ],
  "detail_table": {
    "title": "Blast radius",
    "columns": [
      "Area",
      "What changes",
      "Who notices first",
      "Evidence"
    ],
    "rows": [
      [
        "db",
        "orders loses legacy_ref",
        "any reader still selecting it",
        "statement 1"
      ]
    ]
  },
  "artifact": {
    "format": "markdown",
    "filename": "CHANGE-REQUEST.md",
    "title": "The change request",
    "content": "# Change request\\n..."
  },
  "coverage_check": [
    {
      "flag_id": "CD-DESTRUCTIVE",
      "status": "confirmed",
      "note": "drives F1"
    }
  ],
  "assumptions": [
    "..."
  ],
  "open_questions": [
    "..."
  ]
}

Lane checklist — the pre-deploy checklist

curl -s "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $CD_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "checklist", "change": "# change: 1 file, +3 / -0, 1 area\n# areas: db\n# migrations: 1 file, 2 statements, 1 destructive, 2 table-blocking\n# rollback block in the migration: ABSENT\n# prescan risk tier: severe\n\n# --- stated intent (the author's own words)\nRetire orders.legacy_ref now that status_v2 has been dual-written for six weeks.\n\n# --- file 1: db/migrations/0042_drop_legacy_ref.sql [database migration, new file] +3/-0\n#   statement 1 [drop-column] lock=ACCESS EXCLUSIVE reversible=irreversible DESTRUCTIVE table=orders\n#   statement 2 [create-index] lock=SHARE reversible=reversible table=orders\n+ALTER TABLE orders DROP COLUMN legacy_ref;\n+CREATE INDEX idx_orders_status_v2 ON orders (status_v2);", "intent": "Retire orders.legacy_ref now that status_v2 has been dual-written for six weeks.", "environment": "production", "window": "low-traffic", "audience": "author", "context": "orders is 41 million rows. A deploy takes 6 minutes to roll all pods.", "prescan_facts": {"risk_tier": "severe", "flags": [{"id": "CD-DESTRUCTIVE", "severity": "critical", "detail": "1 statement destroys data and cannot be undone by re-running the migration (ALTER TABLE ... DROP COLUMN)"}, {"id": "CD-INDEX-BLOCKING", "severity": "high", "detail": "1 CREATE INDEX without CONCURRENTLY"}, {"id": "CD-LOCK", "severity": "high", "detail": "2 statements take a table-blocking lock (ACCESS EXCLUSIVE, SHARE)"}, {"id": "CD-NO-ROLLBACK", "severity": "high", "detail": "No down-migration or rollback block found in 1 migration file"}], "resources": [], "stats": {"files": 1, "adds": 3, "dels": 0, "areas": 1, "statements": 2, "destructive": 1, "blocking": 2, "migrations": 1, "flags": 4, "blockers": 4}}}' 

Lane runbook — the runbook

curl -s "https://api.skillsafe.ai/v1/app-api/run" \
  -H "Authorization: Bearer $CD_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{"task": "runbook", "change": "# change: 1 file, +3 / -0, 1 area\n# areas: db\n# migrations: 1 file, 2 statements, 1 destructive, 2 table-blocking\n# rollback block in the migration: ABSENT\n# prescan risk tier: severe\n\n# --- stated intent (the author's own words)\nRetire orders.legacy_ref now that status_v2 has been dual-written for six weeks.\n\n# --- file 1: db/migrations/0042_drop_legacy_ref.sql [database migration, new file] +3/-0\n#   statement 1 [drop-column] lock=ACCESS EXCLUSIVE reversible=irreversible DESTRUCTIVE table=orders\n#   statement 2 [create-index] lock=SHARE reversible=reversible table=orders\n+ALTER TABLE orders DROP COLUMN legacy_ref;\n+CREATE INDEX idx_orders_status_v2 ON orders (status_v2);", "intent": "Retire orders.legacy_ref now that status_v2 has been dual-written for six weeks.", "environment": "production", "window": "low-traffic", "audience": "oncall", "context": "orders is 41 million rows. A deploy takes 6 minutes to roll all pods.", "prescan_facts": {"risk_tier": "severe", "flags": [{"id": "CD-DESTRUCTIVE", "severity": "critical", "detail": "1 statement destroys data and cannot be undone by re-running the migration (ALTER TABLE ... DROP COLUMN)"}, {"id": "CD-INDEX-BLOCKING", "severity": "high", "detail": "1 CREATE INDEX without CONCURRENTLY"}, {"id": "CD-LOCK", "severity": "high", "detail": "2 statements take a table-blocking lock (ACCESS EXCLUSIVE, SHARE)"}, {"id": "CD-NO-ROLLBACK", "severity": "high", "detail": "No down-migration or rollback block found in 1 migration file"}], "resources": [], "stats": {"files": 1, "adds": 3, "dels": 0, "areas": 1, "statements": 2, "destructive": 1, "blocking": 2, "migrations": 1, "flags": 4, "blockers": 4}}}' 

Chaining the lanes

The web page's handoff buttons do one thing: they put a compact digest of the finished lane into the next request's upstream field, keeping change identical. Do the same and the checklist builds on the change request rather than re-deriving it. Its verdict constrains the next lane's - a lane will not report ready over an upstream blocked without saying in summary what changed.

# upstream lane: impact
# verdict: needs-work ยท risk_tier: severe
# title: Drop orders.legacy_ref and index status_v2

## findings
- [critical] F1 DROP COLUMN legacy_ref destroys the column's data -> confirm the restore point first

## rollback plan
- 1. [detect] Watch the orders read path (passes when: error rate at baseline)

Rate limits and cost

The data endpoints share a 120 requests-per-minute budget per IP; /collections/{name}/similar is tighter at 30 a minute and costs roughly an order of magnitude more than a filtered query, so debounce it. /me, /estimate and /guest are free. Only /run, /run-stream and session turns are metered.

Reading the app's own contract

llms.txt is the machine-readable summary of what this app does, its lanes, its input fields and its output contract. If you are pointing an agent at Change Desk, that is the file to give it.