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.
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.
| Field | Type | Required | What it is |
|---|---|---|---|
task | string | always | Exactly one of plan or execute. No other value is a lane. |
query | string | always | The reporter's own plain-language description of what they are holding and what they are trying to publish. Prose, not a form. |
prescan | string | optional | A JSON-encoded object of facts the browser derived from query
before the run. Observations about the request, never instructions. |
plan | string | task=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.
task=plansends one search lookup.{"path":"private/skills.jsonl","q":<the query>,"limit":10}- a relevance search over the corpus, ten records back, which is what the desk editor chooses the route from.task=executesends one exact-key lookup per confirmed step, at most six.{"path":"private/skills.jsonl","key":<skill_id>}- one entry for each distinctskill_idin the confirmed plan, so the model gets the full method text for precisely the skills the reporter approved and for nothing else.
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": {}
}
}
| HTTP | error.code | What it means, and what to do |
|---|---|---|
401 | UNAUTHORIZED |
No token, or one that has expired or been revoked. Mint a new one from /tokens.html. Retrying the same token will not help. |
402 | INSUFFICIENT_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. |
404 | NOT_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. |
422 | VALIDATION_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. |
429 | RATE_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.
| Key | Type | What it carries |
|---|---|---|
goal | string | One sentence: what the reporter is trying to publish, restated by the desk rather than echoed back. |
read | string | What was inferred that the reporter did not say, including anything prescan flagged. |
steps | array | Three to six ordered steps. Fields below. |
alternates | array | At most three swaps, each with replaces_n, skill_id, skill_name, title, why. Zero or one per step, never for an optional step. |
unmet | array of strings | What this route cannot do. An honest unmet is the sign the lookup did not cover part of the goal. |
clarify | array of strings | At most two questions that would sharpen the route. |
| Step field | Type | Constraint |
|---|---|---|
n | int | Runs 1..N with no gaps. |
skill_id | string | An id from the records $refs returned for this request. Never invented, never remembered from an earlier run. |
skill_name | string | That record's display name. |
title | string | The step as an action, at most eight words. |
why | string | Why this step for this reporting, at most twenty-five words, referencing the reporter's own detail. |
produces | string | The artifact the step yields, at most eight words. |
risk | string | One of none, legal, ethical, source-safety. |
optional | bool | True 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"}
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from /tokens.html
class ApiError(Exception):
def __init__(self, status, code, message):
super().__init__("%s %s: %s" % (status, code, message))
self.status, self.code = status, code
def call(method, path, payload=None, headers=None):
body = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE + path, data=body, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "application/json")
# urllib sends no User-Agent by default and the edge answers that with 403.
req.add_header("User-Agent", "newsroom-route-client/1.0")
for k, v in (headers or {}).items():
req.add_header(k, v)
try:
with urllib.request.urlopen(req) as r:
return json.loads(r.read())["data"]
except urllib.error.HTTPError as e:
err = (json.loads(e.read() or b"{}") or {}).get("error") or {}
raise ApiError(e.code, err.get("code", ""), err.get("message", ""))
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // from /tokens.html
async function call(method, path, payload, extraHeaders) {
const res = await fetch(BASE + path, {
method,
headers: Object.assign({
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Accept": "application/json"
}, extraHeaders || {}),
body: payload === undefined ? undefined : JSON.stringify(payload)
});
const json = await res.json().catch(() => ({}));
if (!res.ok || json.ok === false) {
const e = new Error((json.error && json.error.message) || res.statusText);
e.status = res.status;
e.code = json.error && json.error.code;
throw e;
}
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
var token = envOr("SKILLSAFE_TOKEN", "YOUR_TOKEN") // from /tokens.html
func envOr(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}
type apiError struct {
Code string `json:"code"`
Message string `json:"message"`
}
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *apiError `json:"error"`
}
func call(method, path string, payload any, extra map[string]string) (json.RawMessage, error) {
var body io.Reader
if payload != nil {
b, _ := json.Marshal(payload)
body = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, body)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
for k, v := range extra {
req.Header.Set(k, v)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
raw, _ := io.ReadAll(res.Body)
var env envelope
json.Unmarshal(raw, &env)
if res.StatusCode >= 400 || env.Error != nil {
return nil, fmt.Errorf("%d %s", res.StatusCode, raw)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.util.Map;
public class Route {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN"; // from /tokens.html
static final HttpClient HTTP = HttpClient.newHttpClient();
/** Returns the whole {"ok":true,"data":{...}} envelope as text. */
static String call(String method, String path, String json, Map<String, String> extra)
throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Accept", "application/json");
if (extra != null) extra.forEach(b::header);
b.method(method, json == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(json));
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) {
throw new RuntimeException(res.statusCode() + " " + res.body());
}
return res.body();
}
/** Minimal JSON string quoting, so the bodies below stay readable. */
static String q(String s) {
return "\"" + s.replace("\\", "\\\\").replace("\"", "\\\"") + "\"";
}
}
require 'json'
require 'net/http'
require 'uri'
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from /tokens.html
class ApiError < StandardError
attr_reader :status, :code
def initialize(status, code, message)
@status, @code = status, code
super("#{status} #{code}: #{message}")
end
end
def call(method, path, payload = nil, extra = {})
uri = URI(BASE + path)
kind = { "GET" => Net::HTTP::Get, "POST" => Net::HTTP::Post }.fetch(method)
req = kind.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Accept"] = "application/json"
extra.each { |k, v| req[k] = v }
req.body = JSON.generate(payload) if payload
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
json = (JSON.parse(res.body) rescue {})
if res.code.to_i >= 400
e = json["error"] || {}
raise ApiError.new(res.code.to_i, e["code"], e["message"])
end
json["data"]
end
<?php
$BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"; // from /tokens.html
function ss_call(string $method, string $path, $payload = null, array $extra = []) {
global $BASE, $TOKEN;
$headers = array_merge([
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Accept: application/json",
], $extra);
$ch = curl_init($BASE . $path);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
curl_setopt($ch, CURLOPT_CUSTOMREQUEST, $method);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
if ($payload !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($payload));
}
$body = curl_exec($ch);
$status = curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
$json = json_decode($body, true) ?: [];
if ($status >= 400) {
$e = $json["error"] ?? [];
throw new RuntimeException(
$status . " " . ($e["code"] ?? "") . ": " . ($e["message"] ?? ""));
}
return $json["data"];
}
using System;
using System.Collections.Generic;
using System.Net.Http;
using System.Text;
using System.Text.Json;
using System.Threading.Tasks;
static class Route
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"; // /tokens.html
static readonly HttpClient Http = new HttpClient();
public static async Task<JsonElement> Call(
string method, string path, string json = null,
IDictionary<string, string> extra = null)
{
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token);
req.Headers.TryAddWithoutValidation("Accept", "application/json");
if (extra != null)
foreach (var kv in extra)
req.Headers.TryAddWithoutValidation(kv.Key, kv.Value);
if (json != null)
req.Content = new StringContent(json, Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req);
var body = await res.Content.ReadAsStringAsync();
if (!res.IsSuccessStatusCode)
throw new Exception((int)res.StatusCode + " " + body);
return JsonDocument.Parse(body).RootElement.GetProperty("data");
}
}
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}}
me = call("GET", "/me")
print(me["subject_type"], me["credits"])
signed_in = me["subject_type"] == "user"
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
const signedIn = me.subject_type === "user";
raw, err := call("GET", "/me", nil, nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int `json:"credits"`
}
json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
signedIn := me.SubjectType == "user"
_ = signedIn
String envelope = Route.call("GET", "/me", null, null);
System.out.println(envelope);
// {"ok":true,"data":{"subject_type":"user","subject_id":"usr_01J...","credits":48210}}
boolean signedIn = envelope.contains("\"subject_type\":\"user\"");
me = call("GET", "/me")
puts "#{me['subject_type']} #{me['credits']}"
signed_in = me["subject_type"] == "user"
$me = ss_call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
$signedIn = $me["subject_type"] === "user";
var me = await Route.Call("GET", "/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Console.WriteLine(me.GetProperty("credits").GetInt32());
bool signedIn = me.GetProperty("subject_type").GetString() == "user";
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}}
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.")
# The facts a free in-browser read derived from that same text.
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,
}
plan_input = {
"task": "plan", # required, and first
"query": QUERY, # required
"prescan": json.dumps(PRESCAN), # JSON-ENCODED string
"$refs": [{"path": "private/skills.jsonl", "q": QUERY, "limit": 10}],
}
est = call("POST", "/estimate", plan_input)
print(est["hold_credits"], est.get("input_checked"))
me = call("GET", "/me")
if me["credits"] < est["hold_credits"]:
raise SystemExit("top up before running")
const 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.";
const 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
};
const planInput = {
task: "plan", // required, and first
query: QUERY, // required
prescan: JSON.stringify(PRESCAN), // JSON-ENCODED string
"$refs": [{ path: "private/skills.jsonl", q: QUERY, limit: 10 }]
};
const est = await call("POST", "/estimate", planInput);
console.log(est.hold_credits, est.input_checked);
const 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."
prescan, _ := json.Marshal(map[string]any{
"words": 28, "thin": false, "empty": false,
"has": []string{"document", "data"},
"gaps": []string{"no deadline given", "no outlet or audience named",
"no named subject or organisation", "no obstacle stated"},
"flags": []string{},
"question": false,
})
planInput := map[string]any{
"task": "plan", // required, and first
"query": query, // required
"prescan": string(prescan), // JSON-ENCODED string, not a nested object
"$refs": []any{map[string]any{
"path": "private/skills.jsonl", "q": query, "limit": 10,
}},
}
raw, err := call("POST", "/estimate", planInput, nil)
if err != nil {
panic(err)
}
fmt.Println(string(raw))
static final String 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.";
// prescan travels as ONE JSON-encoded string, so it is escaped exactly once.
static final 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}";
static String planInput() {
return "{"
+ "\"task\":\"plan\","
+ "\"query\":" + q(QUERY) + ","
+ "\"prescan\":" + q(PRESCAN) + ","
+ "\"$refs\":[{\"path\":\"private/skills.jsonl\",\"q\":" + q(QUERY)
+ ",\"limit\":10}]"
+ "}";
}
// ...
System.out.println(Route.call("POST", "/estimate", planInput(), null));
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."
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
}
PLAN_INPUT = {
"task" => "plan", # required, and first
"query" => QUERY, # required
"prescan" => JSON.generate(PRESCAN), # JSON-ENCODED string
"$refs" => [{ "path" => "private/skills.jsonl", "q" => QUERY, "limit" => 10 }]
}
est = call("POST", "/estimate", PLAN_INPUT)
puts "#{est['hold_credits']} #{est['input_checked']}"
$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.";
$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,
];
$planInput = [
"task" => "plan", // required, and first
"query" => $QUERY, // required
"prescan" => json_encode($PRESCAN), // JSON-ENCODED string
"\$refs" => [
["path" => "private/skills.jsonl", "q" => $QUERY, "limit" => 10],
],
];
$est = ss_call("POST", "/estimate", $planInput);
echo $est["hold_credits"], " ", var_export($est["input_checked"], true), "\n";
const string 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.";
var prescan = new {
words = 28, thin = false, empty = false,
has = new[] { "document", "data" },
gaps = new[] { "no deadline given", "no outlet or audience named",
"no named subject or organisation", "no obstacle stated" },
flags = Array.Empty<string>(),
question = false
};
var planInput = new Dictionary<string, object> {
["task"] = "plan", // required, and first
["query"] = Query, // required
["prescan"] = JsonSerializer.Serialize(prescan), // JSON-ENCODED string
["$refs"] = new[] {
new { path = "private/skills.jsonl", q = Query, limit = 10 }
}
};
string planBody = JsonSerializer.Serialize(planInput);
var est = await Route.Call("POST", "/estimate", planBody);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
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'
import hashlib
body_hash = hashlib.sha256(json.dumps(plan_input, sort_keys=True).encode()).hexdigest()[:16]
attempt = 0
res = call("POST", "/run", plan_input,
{"Idempotency-Key": "newsroom-route:plan:%s:%d" % (body_hash, attempt)})
print(res["charged_credits"], res["truncated"])
raw_plan = res["output"]
// Any stable hash of the body will do; this one keeps the sample self-contained.
function keyFor(task, payload, attempt) {
const s = JSON.stringify(payload);
let h = 5381;
for (let i = 0; i < s.length; i++) h = ((h * 33) ^ s.charCodeAt(i)) >>> 0;
return `newsroom-route:${task}:${h.toString(16)}:${attempt}`;
}
const res = await call("POST", "/run", planInput,
{ "Idempotency-Key": keyFor("plan", planInput, 0) });
console.log(res.charged_credits, res.truncated);
const rawPlan = res.output;
bodyBytes, _ := json.Marshal(planInput)
sum := sha256.Sum256(bodyBytes)
key := fmt.Sprintf("newsroom-route:plan:%x:0", sum[:8])
raw, err = call("POST", "/run", planInput, map[string]string{"Idempotency-Key": key})
if err != nil {
panic(err)
}
var run struct {
JobID string `json:"job_id"`
Status string `json:"status"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
Output string `json:"output"`
}
json.Unmarshal(raw, &run)
fmt.Println(run.Output)
String body = planInput();
String key = "newsroom-route:plan:" + Integer.toHexString(body.hashCode()) + ":0";
String envelope = Route.call("POST", "/run", body, Map.of("Idempotency-Key", key));
System.out.println(envelope);
// data.output holds the fenced json block; parse it with whatever JSON
// library you already have on the classpath.
require 'digest'
key = "newsroom-route:plan:" +
Digest::SHA256.hexdigest(JSON.generate(PLAN_INPUT))[0, 16] + ":0"
res = call("POST", "/run", PLAN_INPUT, { "Idempotency-Key" => key })
puts "#{res['charged_credits']} #{res['truncated']}"
raw_plan = res["output"]
$key = "newsroom-route:plan:" . substr(hash("sha256", json_encode($planInput)), 0, 16) . ":0";
$res = ss_call("POST", "/run", $planInput, ["Idempotency-Key: $key"]);
echo $res["charged_credits"], " ", var_export($res["truncated"], true), "\n";
$rawPlan = $res["output"];
using System.Security.Cryptography;
string hash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(planBody))).Substring(0, 16).ToLowerInvariant();
string key = $"newsroom-route:plan:{hash}:0";
var res = await Route.Call("POST", "/run", planBody,
new Dictionary<string, string> { ["Idempotency-Key"] = key });
Console.WriteLine(res.GetProperty("charged_credits").GetInt32());
string rawPlan = res.GetProperty("output").GetString();
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'
import re, time
# The confirmed route: keep what the reporter kept, renumber from 1, cap at six.
plan_obj = json.loads(re.search(r"```json\s*(.*?)```", raw_plan, re.S).group(1))
kept = [s for s in plan_obj["steps"] if not s.get("optional")][:6]
for i, s in enumerate(kept, 1):
s["n"] = i
# One EXACT-KEY lookup per distinct skill in the confirmed route.
refs = [{"path": "private/skills.jsonl", "key": sid}
for sid in dict.fromkeys(s["skill_id"] for s in kept)]
exec_input = {
"task": "execute",
"query": QUERY,
"plan": json.dumps(kept), # JSON-ENCODED array of confirmed steps
"prescan": json.dumps(PRESCAN),
"$refs": refs,
}
res = call("POST", "/run", exec_input,
{"Idempotency-Key": "newsroom-route:execute:%s:0"
% hashlib.sha256(json.dumps(exec_input, sort_keys=True)
.encode()).hexdigest()[:16]})
while res.get("status") == "pending":
time.sleep(1)
res = call("GET", "/jobs/" + res["job_id"])
print(res["output"])
const planObj = JSON.parse(/```json\s*([\s\S]*?)```/.exec(rawPlan)[1]);
const kept = planObj.steps.filter(s => !s.optional).slice(0, 6)
.map((s, i) => ({ ...s, n: i + 1 }));
const refs = [...new Set(kept.map(s => s.skill_id))]
.map(key => ({ path: "private/skills.jsonl", key }));
const execInput = {
task: "execute",
query: QUERY,
plan: JSON.stringify(kept), // JSON-ENCODED array of confirmed steps
prescan: JSON.stringify(PRESCAN),
"$refs": refs
};
let run = await call("POST", "/run", execInput,
{ "Idempotency-Key": keyFor("execute", execInput, 0) });
while (run.status === "pending") {
await new Promise(r => setTimeout(r, 1000));
run = await call("GET", "/jobs/" + run.job_id, undefined);
}
console.log(run.output);
// kept: the confirmed steps, renumbered from 1 and capped at six.
type step struct {
N int `json:"n"`
SkillID string `json:"skill_id"`
SkillName string `json:"skill_name"`
Title string `json:"title"`
Why string `json:"why"`
Produces string `json:"produces"`
Risk string `json:"risk"`
Optional bool `json:"optional"`
}
planJSON, _ := json.Marshal(kept)
refs, seen := []any{}, map[string]bool{}
for _, s := range kept {
if !seen[s.SkillID] {
seen[s.SkillID] = true
refs = append(refs, map[string]any{
"path": "private/skills.jsonl", "key": s.SkillID,
})
}
}
execInput := map[string]any{
"task": "execute",
"query": query,
"plan": string(planJSON), // JSON-ENCODED array of confirmed steps
"prescan": string(prescan),
"$refs": refs,
}
raw, err = call("POST", "/run", execInput,
map[string]string{"Idempotency-Key": "newsroom-route:execute:...:0"})
if err != nil {
panic(err)
}
json.Unmarshal(raw, &run)
for run.Status == "pending" {
time.Sleep(time.Second)
raw, _ = call("GET", "/jobs/"+run.JobID, nil, nil)
json.Unmarshal(raw, &run)
}
fmt.Println(run.Output)
// `kept` is the confirmed array serialised with whatever JSON library you use;
// the shape is the block printed above this tab strip.
String planJson = serialise(kept); // -> "[{\"n\":1,...}]" as ONE string
String execBody = "{"
+ "\"task\":\"execute\","
+ "\"query\":" + q(QUERY) + ","
+ "\"plan\":" + q(planJson) + "," // JSON-ENCODED array
+ "\"prescan\":" + q(PRESCAN) + ","
+ "\"$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\"}"
+ "]}";
String key = "newsroom-route:execute:" + Integer.toHexString(execBody.hashCode()) + ":0";
String envelope = Route.call("POST", "/run", execBody, Map.of("Idempotency-Key", key));
// If data.status is "pending", poll until it is not:
// envelope = Route.call("GET", "/jobs/" + jobId, null, null); // once a second
System.out.println(envelope);
plan_obj = JSON.parse(raw_plan[/```json\s*(.*?)```/m, 1])
kept = plan_obj["steps"].reject { |s| s["optional"] }.first(6)
kept.each_with_index { |s, i| s["n"] = i + 1 }
refs = kept.map { |s| s["skill_id"] }.uniq.map do |id|
{ "path" => "private/skills.jsonl", "key" => id }
end
exec_input = {
"task" => "execute",
"query" => QUERY,
"plan" => JSON.generate(kept), # JSON-ENCODED array of confirmed steps
"prescan" => JSON.generate(PRESCAN),
"$refs" => refs
}
key = "newsroom-route:execute:" +
Digest::SHA256.hexdigest(JSON.generate(exec_input))[0, 16] + ":0"
run = call("POST", "/run", exec_input, { "Idempotency-Key" => key })
while run["status"] == "pending"
sleep 1
run = call("GET", "/jobs/#{run['job_id']}")
end
puts run["output"]
preg_match('/```json\s*(.*?)```/s', $rawPlan, $m);
$planObj = json_decode($m[1], true);
$kept = array_values(array_filter($planObj["steps"], fn($s) => empty($s["optional"])));
$kept = array_slice($kept, 0, 6);
foreach ($kept as $i => &$s) { $s["n"] = $i + 1; }
unset($s);
$refs = [];
foreach (array_unique(array_column($kept, "skill_id")) as $id) {
$refs[] = ["path" => "private/skills.jsonl", "key" => $id];
}
$execInput = [
"task" => "execute",
"query" => $QUERY,
"plan" => json_encode($kept), // JSON-ENCODED array of confirmed steps
"prescan" => json_encode($PRESCAN),
"\$refs" => $refs,
];
$key = "newsroom-route:execute:" . substr(hash("sha256", json_encode($execInput)), 0, 16) . ":0";
$run = ss_call("POST", "/run", $execInput, ["Idempotency-Key: $key"]);
while (($run["status"] ?? "") === "pending") {
sleep(1);
$run = ss_call("GET", "/jobs/" . $run["job_id"]);
}
echo $run["output"], "\n";
// `kept` is the confirmed route: optional steps dropped, renumbered from 1, max 6.
var refs = new List<object>();
foreach (var id in kept.Select(s => s.skill_id).Distinct())
refs.Add(new { path = "private/skills.jsonl", key = id });
var execInput = new Dictionary<string, object> {
["task"] = "execute",
["query"] = Query,
["plan"] = JsonSerializer.Serialize(kept), // JSON-ENCODED array
["prescan"] = JsonSerializer.Serialize(prescan),
["$refs"] = refs
};
string execBody = JsonSerializer.Serialize(execInput);
string execHash = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(execBody))).Substring(0, 16).ToLowerInvariant();
var run = await Route.Call("POST", "/run", execBody,
new Dictionary<string, string> { ["Idempotency-Key"] = $"newsroom-route:execute:{execHash}:0" });
while (run.GetProperty("status").GetString() == "pending")
{
await Task.Delay(1000);
run = await Route.Call("GET", "/jobs/" + run.GetProperty("job_id").GetString());
}
Console.WriteLine(run.GetProperty("output").GetString());
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 ..."}
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(exec_input).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Accept", "text/event-stream")
req.add_header("User-Agent", "newsroom-route-client/1.0")
with urllib.request.urlopen(req) as r:
if "text/event-stream" not in r.headers.get("Content-Type", ""):
result = json.loads(r.read())["data"] # idempotent replay, no stream
else:
event, payload = "message", ""
for line in r:
line = line.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
payload += line[5:].strip()
elif line == "":
if payload:
frame = json.loads(payload)
if event == "delta":
print(frame.get("text", ""), end="", flush=True)
elif event in ("done", "pending"):
result = frame
elif event == "error":
raise ApiError(0, frame.get("code", ""), frame.get("message", ""))
event, payload = "message", ""
const res = await fetch(BASE + "/run-stream", {
method: "POST",
headers: {
"Authorization": "Bearer " + TOKEN,
"Content-Type": "application/json",
"Accept": "text/event-stream",
"Idempotency-Key": keyFor("execute", execInput, 0)
},
body: JSON.stringify(execInput)
});
let result = null;
if (!(res.headers.get("content-type") || "").includes("text/event-stream")) {
result = (await res.json()).data; // idempotent replay, no stream
} else {
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "";
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buf += dec.decode(chunk.value, { stream: true });
let i;
while ((i = buf.indexOf("\n\n")) >= 0) {
const raw = buf.slice(0, i); buf = buf.slice(i + 2);
let name = "message", data = "";
for (const line of raw.split("\n")) {
if (line.startsWith("event:")) name = line.slice(6).trim();
else if (line.startsWith("data:")) data += line.slice(5).trim();
}
if (!data) continue;
const frame = JSON.parse(data);
if (name === "delta") process_delta(frame.text || "");
else if (name === "tick") keepAlive(frame.t); // liveness, not progress
else if (name === "done" || name === "pending") result = frame;
else if (name === "error") throw new Error(frame.message);
}
}
}
body, _ := json.Marshal(execInput)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
if !strings.Contains(res.Header.Get("Content-Type"), "text/event-stream") {
raw, _ := io.ReadAll(res.Body) // idempotent replay, plain JSON
fmt.Println(string(raw))
return
}
name, data := "message", ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
name = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:"):
data += strings.TrimSpace(line[5:])
case line == "":
if data != "" {
var frame map[string]any
json.Unmarshal([]byte(data), &frame)
if name == "delta" {
fmt.Print(frame["text"])
}
}
name, data = "message", ""
}
}
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(execBody))
.build();
HttpResponse<Stream<String>> res =
HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
String[] state = { "message", "" }; // event name, accumulated data
res.body().forEach(line -> {
if (line.startsWith("event:")) {
state[0] = line.substring(6).trim();
} else if (line.startsWith("data:")) {
state[1] += line.substring(5).trim();
} else if (line.isEmpty()) {
if (!state[1].isEmpty()) {
// state[0] is one of: job, delta, tick, done, pending, error
System.out.println(state[0] + " " + state[1]);
}
state[0] = "message";
state[1] = "";
}
});
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Accept"] = "text/event-stream"
req.body = JSON.generate(exec_input)
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
unless res["Content-Type"].to_s.include?("text/event-stream")
puts res.read_body # idempotent replay, plain JSON
next
end
name, data, buf = "message", "", ""
res.read_body do |chunk|
buf << chunk
while (i = buf.index("\n"))
line = buf.slice!(0, i + 1).chomp
if line.start_with?("event:") then name = line[6..].strip
elsif line.start_with?("data:") then data += line[5..].strip
elsif line.empty?
print JSON.parse(data)["text"] if name == "delta" && !data.empty?
name, data = "message", ""
end
end
end
end
end
$ch = curl_init($BASE . "/run-stream");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_HTTPHEADER, [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Accept: text/event-stream",
]);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($execInput));
// CURLOPT_RETURNTRANSFER is deliberately left off: the frames go straight to
// standard output as they arrive, which is all a terminal client needs.
curl_exec($ch);
curl_close($ch);
// event: job
// data: {"job_id":"job_01J..."}
//
// event: delta
// data: {"text":"## Step 1 - Read the budget PDF"}
using System.IO;
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
req.Headers.TryAddWithoutValidation("Authorization", "Bearer " + Token);
req.Headers.TryAddWithoutValidation("Accept", "text/event-stream");
req.Content = new StringContent(execBody, Encoding.UTF8, "application/json");
var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
if (res.Content.Headers.ContentType?.MediaType != "text/event-stream")
{
Console.WriteLine(await res.Content.ReadAsStringAsync()); // replay, no stream
return;
}
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
string name = "message", data = "";
string line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event:")) name = line.Substring(6).Trim();
else if (line.StartsWith("data:")) data += line.Substring(5).Trim();
else if (line.Length == 0)
{
if (data.Length > 0 && name == "delta")
Console.Write(JsonDocument.Parse(data).RootElement
.GetProperty("text").GetString());
name = "message"; data = "";
}
}
Rate limits and good manners
/meand/estimateare free. Call them as much as you reasonably need; estimate every body you intend to run.- Send an
Idempotency-Keyon every/runand/run-stream. Derive it from a hash of the body plus an attempt counter, so a retry of the same request reuses the key and a deliberate re-run gets a new one. - A
429means back off with a delay. Polling/jobs/{id}at about one second is fine; polling it in a tight loop is how you lose the run you are waiting for. - Two runs make one route. Do not skip the plan run and hand-write a
planarray from skill ids you found somewhere else: the ids that are valid are the ids the$refssearch returned for that query, and an id that was not retrieved will not be honoured. - Cap the confirmed route at six steps. The execute task will not carry more, and
$refstakes at most six exact-key lookups on that run.
What this app will not give you
- The skill corpus. It lives at
private/skills.jsonl, is resolved server-side per run, and is stripped from the body before the model sees the key. There is no endpoint that lists it, no page asset that carries it, andprivate/is not reachable over HTTP. The plan stage is also told never to output a catalogue and never to quote a record's method text. - A run without a route. There is no single-shot lane that goes from a description straight to finished artifacts. The confirmation step between the two runs is the product, not a formality.
- Anything it has not been told. It has not read your document, heard your interview or seen your spreadsheet. It plans what to do with them and drafts against what you described. It is not a lawyer and not an editor of record. Verify before you publish.
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.