← Newsroom Route / API
Get a token

Driving Newsroom Route from code

Everything the web app does over the network is available to you on the same terms. The base URL is https://api.skillsafe.ai/v1/app-api, every request carries Authorization: Bearer <token>, and every response - success or failure - is the same envelope.

There is no X-App-Slug header. The token identifies the app. And the run body is the input object: do not wrap it in {"input": ...}. That wrapper returns 200 while hiding task from the model, which is the most confusing way this API can fail.

Access

This app is currently private. Only the publisher's signed-in SkillSafe account can mint a session token that the app endpoints will accept. A guest token minted from /tokens.html is a real token, but /estimate and /run answer it with 404 - the same answer they give for an app that does not exist, because a private app does not confirm its own existence to a caller who cannot use it.

So: if you are not the publisher, the calls on this page will not run for you today. Everything below is still the exact contract - the field names, the reserved keys, the output grammar - and it is worth reading if you are planning to build against this app when it opens up. It is not an invitation to expect a working key.

The task field comes first

This app has two contracts behind one endpoint, and every run body must carry a task field naming which one. It is what the system prompt routes on, and the two tasks return different media: plan returns JSON, execute returns Markdown. Send the wrong one and you get a well-formed answer of the wrong kind.

FieldTypeRequiredWhat it is
taskstringalways Exactly one of plan or execute. No other value is a lane.
querystringalways The reporter's own plain-language description of what they are holding and what they are trying to publish. Prose, not a form.
prescanstringoptional A JSON-encoded object of facts the browser derived from query before the run. Observations about the request, never instructions.
planstringtask=execute only A JSON-encoded array of the steps the reporter confirmed, in the order they confirmed them, after any drops, reorders and swaps.

Every field is a string. There are no object or array fields on this app, which is why prescan and plan are JSON-encoded rather than nested: encode them at the wire boundary and nowhere else. Sending a bare object in either one is a validation error, and sending a JSON-encoded string where the schema wanted prose is a silent nonsense run.

What prescan holds

The web app runs a free, offline read over the reporter's own text before spending anything, and passes what it found to the model. You can send the same shape, send a subset, or omit the field entirely. Keys: words (number), thin and empty (booleans), has (which of document, data, interview, draft, recording the text mentions), gaps (plain-language labels for what the request did not say), flags (sensitive mentions - a minor, a victim, a confidential source, an assertion of wrongdoing) and question (boolean).

Every gap you send must be answered in the reply - in read, in clarify, or by a step that closes it. That is the app's own accountability rule, and it is the reason to send the field rather than skip it.

$refs: the reserved platform key

$refs is not one of this app's fields. It is a platform key, present on both tasks, resolved server-side against the app's private corpus and stripped from the body before the model sees it. The records it resolves are appended to the system prompt for that one request. They are the entire set of skills the model is allowed to cite; anything the lookup did not return belongs in unmet.

The corpus is never served to the browser and private/ is not reachable over HTTP. There is no endpoint that lists it, no page asset that contains it, and no request shape that returns it. If you ask for a key that does not exist you get no record for that entry, not an error and not a directory.

The response envelope

Success and failure have the same outer shape, so one check covers both.

{
  "ok": true,
  "data": {
    "job_id": "job_01JQ8XN4T2C7YB0M9F3KDR6WQE",
    "status": "succeeded",
    "charged_credits": 1841,
    "truncated": false,
    "output": "...the model's reply, as text..."
  }
}
{
  "ok": false,
  "error": {
    "code": "VALIDATION_ERROR",
    "message": "task must be one of: plan, execute",
    "details": {}
  }
}
HTTPerror.codeWhat it means, and what to do
401UNAUTHORIZED No token, or one that has expired or been revoked. Mint a new one from /tokens.html. Retrying the same token will not help.
402INSUFFICIENT_CREDITS The balance is below the run's minimum. Call /estimate first and compare hold_credits against the credits from /me; a 402 after submitting is avoidable.
404NOT_FOUND Unknown app, or a private app declining to answer this token. On this app today that is the expected reply to every token but the publisher's - see the Access note above. It is also what a job id belonging to a different token returns.
422VALIDATION_ERROR The body was not a JSON object, task was missing or not one of the two lanes, or a declared field arrived with the wrong type. Sending prescan or plan as a nested object instead of a JSON-encoded string lands here.
429RATE_LIMITED Too many requests. Back off with a delay. Do not tight-loop, and do not retry a run without reusing its Idempotency-Key.

One more worth knowing: a replay of an Idempotency-Key whose body differs from the original request is refused with 409 CONFLICT. Same key plus same body returns the stored result and is not charged twice.

What comes back, per task

task=plan returns one fenced JSON block

The reply is a single fenced json block and nothing outside it. Parse it fence-aware and be forgiving: real models occasionally add a sentence either side, so falling back to the first { through the last } is worth the few lines.

KeyTypeWhat it carries
goalstringOne sentence: what the reporter is trying to publish, restated by the desk rather than echoed back.
readstringWhat was inferred that the reporter did not say, including anything prescan flagged.
stepsarrayThree to six ordered steps. Fields below.
alternatesarrayAt most three swaps, each with replaces_n, skill_id, skill_name, title, why. Zero or one per step, never for an optional step.
unmetarray of stringsWhat this route cannot do. An honest unmet is the sign the lookup did not cover part of the goal.
clarifyarray of stringsAt most two questions that would sharpen the route.
Step fieldTypeConstraint
nintRuns 1..N with no gaps.
skill_idstringAn id from the records $refs returned for this request. Never invented, never remembered from an earlier run.
skill_namestringThat record's display name.
titlestringThe step as an action, at most eight words.
whystringWhy this step for this reporting, at most twenty-five words, referencing the reporter's own detail.
producesstringThe artifact the step yields, at most eight words.
riskstringOne of none, legal, ethical, source-safety.
optionalboolTrue when the route holds together without the step.

task=execute returns Markdown

One ## Step N - <title> section per confirmed step, numbered and ordered exactly as confirmed, then a final ## Where this stops section. No preamble before the first heading. Each section is the work itself - the actual questions, the actual request letter, the actual claim grid - not a description of the work.

So parsing is a split on /^## /, done fence-aware, because a drafted letter can legitimately contain a line starting with ##. Count the sections you got against the number of steps you confirmed, plus one: a short count means the run was truncated, not that the contract changed.

1. A token, and the tiny client that carries it

Open /tokens.html in a browser: it shows the token this browser already holds for newsroom-route, reveals it, and copies the shell export line, with no developer console. Read the Access note above first - on this app a guest token is minted freely and refused by /estimate and /run with a 404.

Keep the token in an environment variable rather than in source. Every later step on this page reuses the one small helper defined here, so the interesting part of each sample is the body, not the plumbing.

# The token this browser already holds is on
# https://newsroom-route.skillsafe.ai/tokens.html - reveal and copy it there.
export SKILLSAFE_TOKEN="YOUR_TOKEN"
export SS_BASE="https://api.skillsafe.ai/v1/app-api"

# Every call below is this shape: a method, a path, an optional JSON body.
ss() {
  curl -sS -X "$1" "$SS_BASE$2" \
    -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
    -H "Content-Type: application/json" \
    -H "Accept: application/json" \
    ${3:+-d "$3"}
}

2. Check the session and the balance

GET /me is free and carries no body. It returns exactly three fields: subject_type, subject_id and credits. There is no username and no email to test, so signed in means subject_type == "user"; a guest session reports subject_type == "guest".

On this app /me answers a guest token normally - it is the app endpoints, not the session endpoint, that decline. A healthy /me followed by a 404 from /estimate is the private-app refusal, not a broken token.

ss GET /me

# {"ok":true,"data":{"subject_type":"user","subject_id":"usr_01J...","credits":48210}}

3. Price the run before you make it

POST /estimate costs nothing, creates no job and returns the worst-case cost of running that exact body. Compare hold_credits against the balance from step 2 before you submit. hold_credits is a reservation priced at the full output cap; the actual charge is usually well below it.

Estimate the two tasks separately. Their prompts, their reference lookups and their output caps all differ, so their holds do. The reply also carries input_checked: when it is false, the platform did not recognise your body against the app's declared input schema, which almost always means a misspelled field name or an {"input": ...} wrapper.

The body you estimate is the body you run. Build it once; steps 4 and 5 send the same object to /run.

QUERY='A council press officer sent me a 40-page budget PDF and said it was already public. I think the leisure centre closure was decided months before the consultation.'

# What a free in-browser read of that same text found. Sent as a STRING.
PRESCAN='{"words":28,"thin":false,"empty":false,"has":["document","data"],"gaps":["no deadline given","no outlet or audience named","no named subject or organisation","no obstacle stated"],"flags":[],"question":false}'

# jq assembles the body so the JSON-encoded string stays escaped exactly once.
PLAN_BODY=$(jq -n --arg q "$QUERY" --arg p "$PRESCAN" '{
  task:    "plan",
  query:   $q,
  prescan: $p,
  "$refs": [ { path: "private/skills.jsonl", q: $q, limit: 10 } ]
}')

ss POST /estimate "$PLAN_BODY"

# {"ok":true,"data":{"hold_credits":2652,"min_credits":420,"input_checked":true,
#                    "model":"gpt-terra","markup_bps":0}}

4. Plan a route (task=plan)

POST /run with the body you just estimated. Always send an Idempotency-Key: a network blip that replays the same request must not bill twice. Same key plus same body returns the stored result free; same key plus a different body is a 409. Derive the key from a hash of the body plus an attempt counter, so a retry reuses it and a deliberate re-run gets a fresh one.

The reply carries output (the model's text), charged_credits, truncated, job_id and status. If truncated is true the balance sat between min_credits and hold_credits and the reply was cut short - for this task that usually means the JSON block never closed, so treat it as a failed parse rather than a short plan.

curl -sS -X POST "$SS_BASE/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: newsroom-route:plan:$(printf %s "$PLAN_BODY" | shasum | cut -c1-16):0" \
  -d "$PLAN_BODY" \
| jq -r '.data.output'

The reply, in full

The envelope, with output abbreviated so the shape is readable:

{
  "ok": true,
  "data": {
    "job_id": "job_01JQ8XN4T2C7YB0M9F3KDR6WQE",
    "status": "succeeded",
    "charged_credits": 1841,
    "truncated": false,
    "output": "```json\n{\n  \"goal\": \"A story alleging the council had ... }\n```\n"
  }
}

And data.output itself, unescaped. This is real output from this app's own prompt against its own retrieved records - one fenced block, nothing outside it:

```json
{
  "goal": "A story alleging the council had effectively decided to close the leisure centre before it ran the public consultation on that closure.",
  "read": "No deadline or outlet was given, so the route below assumes neither is fixed yet. The claim as stated accuses the council of pre-deciding before consultation, which is an allegation against a public body that needs documentary support, not just a hunch, before it can run.",
  "steps": [
    {
      "n": 1,
      "skill_id": "angle-test",
      "skill_name": "Angle test",
      "title": "State the pre-decision claim as one sentence",
      "why": "Turns the hunch that consultation was staged into one testable claim and names the weakest link.",
      "produces": "Testable one-sentence story claim",
      "risk": "none",
      "optional": true
    },
    {
      "n": 2,
      "skill_id": "document-interrogation",
      "skill_name": "Document interrogation",
      "title": "Read the budget PDF for decision dates",
      "why": "The 40-page PDF is the only evidence so far; version markers and absences will show if timing was fixed early.",
      "produces": "Dated findings and gaps from the PDF",
      "risk": "none",
      "optional": false
    },
    {
      "n": 3,
      "skill_id": "provenance-trace",
      "skill_name": "Provenance trace",
      "title": "Verify when the PDF actually went public",
      "why": "The press officer's already-public claim is unverified; its real release date decides whether timing looks routine or staged.",
      "produces": "Release-date timeline with gaps flagged",
      "risk": "none",
      "optional": false
    },
    {
      "n": 4,
      "skill_id": "corroboration-matrix",
      "skill_name": "Corroboration matrix",
      "title": "Grid the timing claim against evidence",
      "why": "The wrongdoing claim rests on one document alone so far, which is exactly the single-source risk this grid exposes before publication.",
      "produces": "Claim-by-evidence grid flagging single-source lines",
      "risk": "legal",
      "optional": false
    },
    {
      "n": 5,
      "skill_id": "hostile-interview-prep",
      "skill_name": "Hostile interview prep",
      "title": "Put the timing allegation to the council",
      "why": "Right of reply is not optional once this accuses the council of pre-deciding; expect deflection and log the exchange.",
      "produces": "Rehearsed questions and a logging plan",
      "risk": "legal",
      "optional": false
    }
  ],
  "alternates": [
    {
      "replaces_n": 2,
      "skill_id": "dataset-shape-read",
      "skill_name": "Dataset shape read",
      "title": "Read the budget as a dataset",
      "why": "Better if the PDF is mostly budget tables, since units and denominators surface the timing gap faster than prose."
    },
    {
      "replaces_n": 5,
      "skill_id": "publication-timing",
      "skill_name": "Publication timing",
      "title": "Set a hold-or-publish window instead",
      "why": "Better if the council stonewalls the interview request, since then the safer call is timing the piece, not forcing comment."
    }
  ],
  "unmet": [
    "None of the retrieved skills covers filing a formal records request for the council's own committee minutes or decision log, which would fix the actual decision date beyond what the budget PDF alone can show."
  ],
  "clarify": [
    "Do you have the consultation's own published timeline or minutes to set against the PDF's dates, or only the PDF itself?",
    "What is your deadline, and is this running online, in print, or broadcast first?"
  ]
}
```

Two things to read off it. unmet names a records request the retrieved skills did not cover, rather than reaching for a skill that was not returned - that is the honest failure mode, not a defect. And every gap the prescan reported is answered: the deadline and outlet gaps land in clarify, the missing named subject and the unstated obstacle are taken up in read and in the steps.

A step is what the reporter confirms. Keep the ones you want, drop the optional ones you do not, apply an alternate by replacing the step whose n matches its replaces_n, renumber from 1, and that array is the plan field in step 5.

5. Carry out the confirmed route (task=execute), and poll

The second run takes the steps the reporter kept, in the order they kept them, JSON-encoded into plan - plus one $refs exact-key lookup for each distinct skill_id in that array, at most six. Renumber n from 1 after any drop or reorder: the model numbers its output sections from the array it is given.

/run usually answers with status: "succeeded" and the output inline. When it answers status: "pending" instead, the job is still working: poll GET /jobs/{job_id} at about one second until status is succeeded or failed. Poll with a delay, never in a tight loop - a 429 here costs you the result you were waiting for.

The plan field, as sent

The reporter dropped the optional first step and kept the rest, so the array is renumbered 1 to 4. This whole array is JSON-encoded into one string:

[
  {
    "n": 1,
    "skill_id": "document-interrogation",
    "skill_name": "Document interrogation",
    "title": "Read the budget PDF for decision dates",
    "why": "The 40-page PDF is the only evidence so far; version markers and absences will show if timing was fixed early.",
    "produces": "Dated findings and gaps from the PDF",
    "risk": "none",
    "optional": false
  },
  {
    "n": 2,
    "skill_id": "provenance-trace",
    "skill_name": "Provenance trace",
    "title": "Verify when the PDF actually went public",
    "why": "The press officer's already-public claim is unverified; its real release date decides whether timing looks routine or staged.",
    "produces": "Release-date timeline with gaps flagged",
    "risk": "none",
    "optional": false
  },
  {
    "n": 3,
    "skill_id": "corroboration-matrix",
    "skill_name": "Corroboration matrix",
    "title": "Grid the timing claim against evidence",
    "why": "The wrongdoing claim rests on one document alone so far, which is exactly the single-source risk this grid exposes before publication.",
    "produces": "Claim-by-evidence grid flagging single-source lines",
    "risk": "legal",
    "optional": false
  },
  {
    "n": 4,
    "skill_id": "hostile-interview-prep",
    "skill_name": "Hostile interview prep",
    "title": "Put the timing allegation to the council",
    "why": "Right of reply is not optional once this accuses the council of pre-deciding; expect deflection and log the exchange.",
    "produces": "Rehearsed questions and a logging plan",
    "risk": "legal",
    "optional": false
  }
]
# A quoted heredoc keeps the apostrophes intact.
PLAN_STEPS=$(cat <<'JSON'
[{"n":1,"skill_id":"document-interrogation","skill_name":"Document interrogation",
  "title":"Read the budget PDF for decision dates","why":"The 40-page PDF is the only evidence so far; version markers and absences will show if timing was fixed early.",
  "produces":"Dated findings and gaps from the PDF","risk":"none","optional":false},
 {"n":2,"skill_id":"provenance-trace","skill_name":"Provenance trace",
  "title":"Verify when the PDF actually went public","why":"The press officer's already-public claim is unverified; its real release date decides whether timing looks routine or staged.",
  "produces":"Release-date timeline with gaps flagged","risk":"none","optional":false},
 {"n":3,"skill_id":"corroboration-matrix","skill_name":"Corroboration matrix",
  "title":"Grid the timing claim against evidence","why":"The wrongdoing claim rests on one document alone so far, which is exactly the single-source risk this grid exposes before publication.",
  "produces":"Claim-by-evidence grid flagging single-source lines","risk":"legal","optional":false},
 {"n":4,"skill_id":"hostile-interview-prep","skill_name":"Hostile interview prep",
  "title":"Put the timing allegation to the council","why":"Right of reply is not optional once this accuses the council of pre-deciding; expect deflection and log the exchange.",
  "produces":"Rehearsed questions and a logging plan","risk":"legal","optional":false}]
JSON
)

EXEC_BODY=$(jq -n --arg q "$QUERY" --arg p "$PRESCAN" --arg plan "$PLAN_STEPS" '{
  task:    "execute",
  query:   $q,
  plan:    $plan,
  prescan: $p,
  "$refs": [
    { path: "private/skills.jsonl", key: "document-interrogation" },
    { path: "private/skills.jsonl", key: "provenance-trace" },
    { path: "private/skills.jsonl", key: "corroboration-matrix" },
    { path: "private/skills.jsonl", key: "hostile-interview-prep" }
  ]
}')

RUN=$(curl -sS -X POST "$SS_BASE/run" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: newsroom-route:execute:$(printf %s "$EXEC_BODY" | shasum | cut -c1-16):0" \
  -d "$EXEC_BODY")

JOB=$(printf %s "$RUN" | jq -r '.data.job_id')
STATUS=$(printf %s "$RUN" | jq -r '.data.status')

while [ "$STATUS" = "pending" ]; do
  sleep 1
  RUN=$(ss GET "/jobs/$JOB")
  STATUS=$(printf %s "$RUN" | jq -r '.data.status')
done

printf %s "$RUN" | jq -r '.data.output'

The reply

Markdown, one ## Step N - <title> section per confirmed step in the confirmed order, then ## Where this stops. Real output again, abridged in the middle at its own section boundaries:

## Step 1 - Read the budget PDF for decision dates

This is the reporting task that tests your hunch: does the PDF itself show the closure was fixed before the consultation ran, or does it only hint at it. I have not read your PDF, so below is the interrogation memo to run against it yourself, built from what a budget document like this can and cannot prove on its own.

Start with the version markers, since a 40-page document usually carries several:

- Check the footer or cover page for a draft/final stamp and a revision number.
- Check whether the leisure centre closure appears as a firm budget line (money already reallocated) or as a contingent option marked subject to consultation.
- Check the file's internal date or footer date against the date the consultation opened.
- Check for a distribution list or approval signature block, since that names who signed off and when.
- Check for a paragraph a numbering scheme skips, or an attachment that is named but not included, since a gap in a document this size is rarely accidental.

Fill in what you find:

| What to check | What it would prove | What you found |
|---|---|---|
| Draft/final stamp and revision number | Whether this is the version the council actually acted on | [fill in] |
| Leisure centre line: firm figure or contingent note | Whether money was already committed before consultation closed | [fill in] |
| File date or footer date vs consultation open date | Whether the budget predates the public process | [fill in] |
| Approval signature or sign-off block | Who authorised the figure, and when | [fill in] |
| Missing pages or attachments named but absent | What the council chose not to send | [fill in] |

One detail worth logging on its own: many council finance packs carry a page-footer legend such as Draft | Not for external release even after the press office calls the file public, and that mismatch belongs in the next step's provenance note, not in this one.

**If the leisure centre figure already reads as a firm, funded line rather than a contingent option, that is the single fact that makes the rest of this route worth running.**

[ Steps 2, 3 and 4 elided here - the release-date timeline, the claim-by-evidence
  grid, and the drafted questions to the council. Same section grammar. ]

## Where this stops

- This route does not check the arithmetic inside the budget itself, such as projected savings or headcount costs, and it does not identify which named officer or committee actually authorised the line - the retrieved skills cover reading and gridding the document, not a formal records request for the underlying committee papers.
- The decision that most needs a human editor: whether the "decided before consultation" framing is strong enough to publish once the Step 4 grid is filled in, or whether it should run as the safer "already budgeted for" version instead.
- The one thing that would most strengthen the story: the dated committee or cabinet minute approving the leisure centre budget line, set against the consultation's own published opening date.
- Step 4 (Grid the timing claim against evidence) carries legal risk: it grids, and folds in the right of reply on, an unproven allegation that the consultation followed a decision already made, which is a defamation exposure until the council has had a fair chance to answer.

Note what the closing section does. It names what the route did not cover, the decision that most needs a human editor, the one thing that would most strengthen the story - and then, because a confirmed step carried risk: "legal", a final line naming that step and the exposure. If your confirmed array carries a risk other than none, expect that line and do not strip it before showing the result to a reporter.

The app has not read the PDF, and neither has the model. Where a step depends on material it cannot see, it says what it needs and gives a template with the known details filled in and the rest marked - it does not invent the contents to make the artifact look finished.

6. Stream a run (SSE)

POST /run-stream is the same call with a text/event-stream response, and it is worth using for task=execute, which is the long one. One thing to know before you build on it: from a server or from cURL you get event: delta frames carrying the output as it generates; from a browser you generally get event: tick heartbeats and then one event: done with the whole output. Handle both, and treat ticks as liveness rather than progress.

Frame types: job (the job id), delta ({"text": "..."}), tick ({"t": seconds}), done (the same payload /run would have returned), pending (treat as done and poll the job) and error. An idempotent replay answers with plain JSON and no stream at all, so check the content type before you start reading frames.

curl -sS -N -X POST "$SS_BASE/run-stream" \
  -H "Authorization: Bearer $SKILLSAFE_TOKEN" \
  -H "Content-Type: application/json" \
  -H "Accept: text/event-stream" \
  -H "Idempotency-Key: newsroom-route:execute:stream:0" \
  -d "$EXEC_BODY"

# event: job
# data: {"job_id":"job_01JQ8XN4T2C7YB0M9F3KDR6WQE"}
#
# event: delta
# data: {"text":"## Step 1 - Read the budget PDF"}
#
# event: done
# data: {"job_id":"job_01J...","status":"succeeded","charged_credits":6120,"output":"## Step 1 ..."}

Rate limits and good manners

What this app will not give you

Built on ten open journalism skills published by @jamditis in claude-skills-journalism: story-pitch, data-journalism, interview-prep, foia-requests, source-verification, one-way-door, fact-check-workflow, newsroom-style, visual-explainer and ai-writing-detox. Every record in the corpus names the skill it derives from, and every step in a route names the record it applies.