Route a build brief to the right cookbook recipes, from your own scripts
Send one build brief — what you are building with the Claude API, the data involved, the volume, the constraints — and get back a single JSON object: the shape of the system, the recipes from the Anthropic Claude Cookbook that apply, in the order you should wire them, each tied to the phrase in your brief that justifies it and the failure mode to watch; the recipes deliberately ruled out and why; a reconciliation of everything the free prescan spotted; an eval plan, cost notes, the open questions that would change the routing, and the next steps. Everything this app does goes through the SkillSafe App API — plain JSON over HTTPS — so the router can sit wherever briefs are written: in a design-doc pipeline, in an intake bot, or in the scaffolding step that turns a one-paragraph idea into a repo. Pick a language once and the whole page follows.
Basics
Base URL: https://api.skillsafe.ai/v1/app-api, app slug
cookbook-router. Every request sends
Authorization: Bearer <token>, where the token is an
aut_… app token, and JSON bodies with
Content-Type: application/json. Every response is wrapped in the same envelope:
{"ok": true, "data": { … }} on success and
{"ok": false, "error": {"code": "…", "message": "…"}} on failure, so a
client can branch on ok and never has to guess whether the body it just parsed is
a result or a complaint. Estimates are free; runs are metered against your credit balance.
There is a single run task — one brief in, one routing plan out, no follow-up calls and
no session state to carry between them. Nothing is executed on your behalf: the service reads
the brief you send and writes down which recipes to reach for.
| Endpoint | What it does | Costs credits |
|---|---|---|
POST /guest | Mint an anonymous guest token for this app. | No |
GET /me | Who the token belongs to and what it can spend. | No |
POST /estimate | Model binding and the worst-case hold for one run. | No |
POST /run | Start a routing run; returns a job_id. | Yes |
GET /jobs/{job_id} | Poll one job until it is succeeded or failed. | No |
POST /run-stream | The same run, delivered as server-sent events. | Yes |
Browsers enforce CORS for this API, so run these examples from a server, a script or a
terminal — not from another website's frontend. The app's own page is the one exception:
it is served from this origin and talks to the API through the vendored SDK at
/sdk.js.
Step 1 — Get a token
Open the token page and sign in with SkillSafe.
That page has three buttons: Reveal shows the token this browser is using,
Copy token puts the bare aut_… string on your clipboard, and
Copy shell export puts export SKILLSAFE_TOKEN="aut_…" there instead
— which is the form every example below reads. That is the whole flow; you never need to
open developer tools, and you should not go looking for the token in a console.
Treat it like a password: it can spend your credits.
For a fully headless script that has no browser at all, POST /guest mints a guest
token, answering {"data": {"token": "aut_…"}}. A guest can read
/me and call /estimate for free, which is enough to wire and test an
integration end to end. It cannot run the model unless the publisher has
switched on sponsored usage — and this app is metered, so the working path for real runs
is a signed-in account with credits. Check sponsor_enabled in the estimate
(step 4) if you are unsure which situation you are in.
export API="https://api.skillsafe.ai/v1/app-api"
# The normal path: paste what the token page's "Copy shell export" gave you.
export TOKEN="YOUR_TOKEN"
# The headless path: a guest token, no browser involved.
export TOKEN=$(curl -s -X POST "$API/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"cookbook-router"}' | jq -r '.data.token')
echo "${TOKEN:0:8}..." # aut_...
import requests
API = "https://api.skillsafe.ai/v1/app-api"
# The normal path: paste the token from https://cookbook-router.skillsafe.ai/tokens.html
TOKEN = "YOUR_TOKEN"
# The headless path: mint a guest token instead (free calls only).
guest = requests.post(API + "/guest", json={"slug": "cookbook-router"}).json()
print(guest["ok"], guest["data"]["token"][:8] + "...") # True aut_...
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
// The normal path: paste the token from the token page.
const TOKEN = "YOUR_TOKEN";
// The headless path: mint a guest token instead (free calls only).
const res = await fetch(API + "/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "cookbook-router" }),
});
const guest = await res.json();
console.log(guest.ok, guest.data.token.slice(0, 8) + "..."); // true aut_...
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const API = "https://api.skillsafe.ai/v1/app-api"
// The normal path: export SKILLSAFE_TOKEN="aut_..." from the token page.
var token = os.Getenv("SKILLSAFE_TOKEN")
// The headless path: mint a guest token instead (free calls only).
func guestToken() (string, error) {
body, _ := json.Marshal(map[string]string{"slug": "cookbook-router"})
res, err := http.Post(API+"/guest", "application/json", bytes.NewReader(body))
if err != nil {
return "", err
}
defer res.Body.Close()
var env struct {
OK bool `json:"ok"`
Data struct {
Token string `json:"token"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
fmt.Println(env.OK, env.Data.Token[:8]+"...")
return env.Data.Token, nil
}
// Java 17+, no dependencies.
// The normal path: export SKILLSAFE_TOKEN="aut_..." from the token page.
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN");
// The headless path: mint a guest token instead (free calls only).
var req = HttpRequest.newBuilder(URI.create(API + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"cookbook-router\"}"))
.build();
var res = HttpClient.newHttpClient().send(req, HttpResponse.BodyHandlers.ofString());
// envelope: {"ok":true,"data":{"token":"aut_..."}} — read data.token with your JSON library
require "net/http"
require "json"
API = "https://api.skillsafe.ai/v1/app-api"
# The normal path: export SKILLSAFE_TOKEN="aut_..." from the token page.
TOKEN = ENV.fetch("SKILLSAFE_TOKEN")
# The headless path: mint a guest token instead (free calls only).
uri = URI(API + "/guest")
res = Net::HTTP.post(uri, { slug: "cookbook-router" }.to_json,
"Content-Type" => "application/json")
guest = JSON.parse(res.body)
puts "#{guest["ok"]} #{guest.dig("data", "token")[0, 8]}..."
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
// The normal path: export SKILLSAFE_TOKEN="aut_..." from the token page.
$TOKEN = getenv("SKILLSAFE_TOKEN");
// The headless path: mint a guest token instead (free calls only).
$ch = curl_init(API . "/guest");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => "cookbook-router"]),
]);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo substr($guest["data"]["token"], 0, 8) . "...\n";
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;
const string Api = "https://api.skillsafe.ai/v1/app-api";
// The normal path: export SKILLSAFE_TOKEN="aut_..." from the token page.
var token = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN");
// The headless path: mint a guest token instead (free calls only).
using var http = new HttpClient();
var res = await http.PostAsJsonAsync(Api + "/guest", new { slug = "cookbook-router" });
var env = await res.Content.ReadFromJsonAsync<JsonElement>();
token = env.GetProperty("data").GetProperty("token").GetString();
Console.WriteLine(token![..8] + "...");
The app stores this browser's token under the localStorage key
skillsafe_app_token:cookbook-router, on the app's own origin. The
token page reads, refreshes and replaces it for you. A guest token
minted from a script is a different subject from the one in your browser: it has its own
(empty) wallet and its own job history, so a job started by one is a 404 to the
other.
Step 2 — A tiny client
Every task below is a single HTTP call, so start with a short helper that sets the base URL,
adds the bearer header, sends JSON, and unwraps the envelope by raising on
ok: false. Everything after this step reuses it, and none of the later samples
repeats the error handling.
export API="https://api.skillsafe.ai/v1/app-api"
export TOKEN="YOUR_TOKEN" # see step 1
# every call looks like:
# curl -s "$API/..." -H "Authorization: Bearer $TOKEN" [-d '{json}']
# jq pulls fields out of the {"ok":true,"data":{...}} envelope below.
# a shell-side unwrapper: fail loudly instead of piping an error object onward
api() { # usage: api METHOD PATH [JSON_FILE]
local method="$1" path="$2" file="$3"
local out
out=$(curl -s -X "$method" "$API$path" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
${file:+-d @"$file"})
if [ "$(echo "$out" | jq -r '.ok')" != "true" ]; then
echo "$out" | jq -r '"[" + .error.code + "] " + .error.message' >&2
return 1
fi
echo "$out" | jq '.data'
}
import json, requests
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # see step 1
class ApiError(RuntimeError):
def __init__(self, code, message):
super().__init__(f"[{code}] {message}")
self.code = code
def api(method, path, body=None, **headers):
res = requests.request(method, API + path, json=body,
headers={"Authorization": f"Bearer {TOKEN}", **headers})
payload = res.json()
if not payload.get("ok", res.ok):
err = payload.get("error", {})
raise ApiError(err.get("code", str(res.status_code)),
err.get("message", res.reason))
return payload["data"]
// Node 18+ (built-in fetch)
const API = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN"; // see step 1
class ApiError extends Error {
constructor(code, message) {
super(`[${code}] ${message}`);
this.code = code;
}
}
async function api(method, path, body, extraHeaders = {}) {
const res = await fetch(API + path, {
method,
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
...extraHeaders,
},
body: body === undefined ? undefined : JSON.stringify(body),
});
const json = await res.json();
if (!json.ok) throw new ApiError(json.error?.code ?? res.status, json.error?.message ?? res.statusText);
return json.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"net/http"
"os"
)
const API = "https://api.skillsafe.ai/v1/app-api"
var token = os.Getenv("SKILLSAFE_TOKEN") // see step 1
type apiError struct{ Code, Message string }
func (e *apiError) Error() string { return "[" + e.Code + "] " + e.Message }
func call(method, path string, body, out any, headers ...[2]string) error {
var buf bytes.Buffer
if body != nil {
json.NewEncoder(&buf).Encode(body)
}
req, _ := http.NewRequest(method, API+path, &buf)
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
for _, h := range headers {
req.Header.Set(h[0], h[1])
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return err
}
defer res.Body.Close()
var env struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *apiError `json:"error"`
}
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return err
}
if !env.OK {
if env.Error != nil {
return env.Error
}
return fmt.Errorf("api %s %s: http %d", method, path, res.StatusCode)
}
if out == nil {
return nil
}
return json.Unmarshal(env.Data, out)
}
// Java 17+, no dependencies. Pair with your JSON library (Jackson, Gson...)
// to read fields out of the returned envelope.
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
public class SkillSafe {
static final String API = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = System.getenv("SKILLSAFE_TOKEN"); // see step 1
static final HttpClient HTTP = HttpClient.newHttpClient();
static String api(String method, String path, String jsonBody, String... headerPairs)
throws Exception {
var b = HttpRequest.newBuilder(URI.create(API + path))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.method(method, jsonBody == null
? HttpRequest.BodyPublishers.noBody()
: HttpRequest.BodyPublishers.ofString(jsonBody));
for (int i = 0; i + 1 < headerPairs.length; i += 2) b.header(headerPairs[i], headerPairs[i + 1]);
var res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// envelope: {"ok":true,"data":{...}} or {"ok":false,"error":{"code","message"}}
if (res.statusCode() >= 400) throw new RuntimeException(res.body());
return res.body();
}
}
require "net/http"
require "json"
API = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN") # see step 1
class ApiError < StandardError
attr_reader :code
def initialize(code, message)
@code = code
super("[#{code}] #{message}")
end
end
def api(method, path, body = nil, headers = {})
uri = URI(API + path)
req = Net::HTTP.const_get(method.capitalize).new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
headers.each { |k, v| req[k] = v }
req.body = body.to_json if body
res = Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
unless payload["ok"]
raise ApiError.new(payload.dig("error", "code") || res.code,
payload.dig("error", "message") || res.message)
end
payload["data"]
end
<?php
const API = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = getenv("SKILLSAFE_TOKEN"); // see step 1
function api(string $method, string $path, ?array $body = null, array $headers = []): mixed {
global $TOKEN;
$ch = curl_init(API . $path);
curl_setopt_array($ch, [
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => array_merge([
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
], $headers),
CURLOPT_POSTFIELDS => $body === null ? null : json_encode($body),
]);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
$code = $payload["error"]["code"] ?? "INTERNAL";
throw new Exception("[$code] " . ($payload["error"]["message"] ?? "request failed"));
}
return $payload["data"];
}
// .NET 8+
using System.Net.Http.Json;
using System.Text.Json;
static class SkillSafe
{
const string Api = "https://api.skillsafe.ai/v1/app-api";
static readonly HttpClient Http = new();
static SkillSafe() =>
Http.DefaultRequestHeaders.Authorization =
new("Bearer", Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN")); // see step 1
public static async Task<JsonElement> ApiAsync(HttpMethod method, string path,
object? body = null, (string, string)? header = null)
{
var req = new HttpRequestMessage(method, Api + path);
if (body != null) req.Content = JsonContent.Create(body);
if (header is { } h) req.Headers.Add(h.Item1, h.Item2);
var res = await Http.SendAsync(req);
var json = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!json.GetProperty("ok").GetBoolean())
{
var err = json.GetProperty("error");
throw new Exception($"[{err.GetProperty("code")}] {err.GetProperty("message")}");
}
return json.GetProperty("data");
}
}
Step 3 — Check who you are and your balance
Returns subject_type ("user" or "guest"),
subject_id and your credits balance. Credits are integer,
nanodollar-style units: 10,000 credits is one US dollar, so a run costing a
few cents shows up as a few hundred credits and integer arithmetic never loses a fraction.
Check this before a batch of briefs goes through: a guest subject can estimate but not spend,
and a run started below the estimate's min_credits comes back
INSUFFICIENT_CREDITS.
api GET /me
# => {"subject_type":"user","subject_id":"usr_...","credits":184213}
api GET /me | jq -r '"\(.subject_type): \(.credits) credits = $\(.credits / 10000)"'
me = api("GET", "/me")
print(me["subject_type"], me["credits"], "credits = $%.2f" % (me["credits"] / 10000))
if me["subject_type"] == "guest":
print("guest subject: /estimate is free, /run needs a sponsored app or a signed-in account")
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits, "credits = $" + (me.credits / 10000).toFixed(2));
if (me.subject_type === "guest") {
console.log("guest subject: /estimate is free, /run needs credits or a sponsoring publisher");
}
var me struct {
SubjectType string `json:"subject_type"`
SubjectID string `json:"subject_id"`
Credits int64 `json:"credits"`
}
if err := call("GET", "/me", nil, &me); err != nil {
log.Fatal(err)
}
fmt.Printf("%s %d credits = $%.2f\n", me.SubjectType, me.Credits, float64(me.Credits)/10000)
String envelope = api("GET", "/me", null);
// data.subject_type, data.subject_id, data.credits
// credits are integer units: 10000 credits == $1.00, so
// double dollars = credits / 10000.0;
me = api("GET", "/me")
puts format("%s: %d credits = $%.2f", me["subject_type"], me["credits"], me["credits"] / 10_000.0)
$me = api("GET", "/me");
printf("%s: %d credits = $%.2f\n", $me["subject_type"], $me["credits"], $me["credits"] / 10000);
var me = await SkillSafe.ApiAsync(HttpMethod.Get, "/me");
var credits = me.GetProperty("credits").GetInt64();
Console.WriteLine($"{me.GetProperty("subject_type")}: {credits} credits = ${credits / 10000.0:F2}");
Step 4 — Estimate the cost, and see the model binding
Send exactly the input object you would send to /run. The response carries
model, model_alias, markup_bps,
hold_credits, min_credits and sponsor_enabled.
Nothing is charged and no job is created, so estimating is free and safe to call on every
brief before you decide whether to route it. Present hold_credits as
reserved, never as the price: the hold covers the full output cap, and the settled
charged_credits after a run is usually far lower. If you assert the model binding
in your own tests, read model_alias and markup_bps from this call
rather than hard-coding what you remember — the estimate is the cheapest place to catch a
binding change, because it costs nothing.
| Estimate field | Type | Meaning |
|---|---|---|
model | string | The concrete model the run will use. |
model_alias | string | The stable alias this app is bound to. Assert on this, not on model, which can move underneath an alias. |
markup_bps | int | The publisher's markup in basis points, applied on top of the underlying model cost. |
hold_credits | int | Worst case, reserved while the job runs and released at settlement. Not the price. |
min_credits | int | The floor below which /run refuses to start. Compare it against /me's credits before you start a batch. |
sponsor_enabled | bool | True when the publisher is paying for runs, which is the only way a guest token can run this app. False here means bring your own credits. |
The input object
The same object is the body of /estimate, /run and
/run-stream. Only brief is required.
| Input field | Type | Notes |
|---|---|---|
brief | string, required | The build brief: what you are building with the Claude API, the data involved, the volume, the constraints. This is the thing being routed, and it is the only source of the signal quotes in the result — the more concretely it names inputs (PDFs, screenshots, a Postgres instance, a ticket queue) and numbers (requests per day, p95 budget), the more of the routing is grounded rather than guessed. A one-line brief gets a one-line answer plus a long open_questions list. |
constraints | string, optional | Latency budget, cost ceiling, accuracy bar, compliance limits, and what you have already tried. This is where a routing decision is usually made or unmade: "p95 under 8 seconds" rules out a multi-hop agent, "we already tried one mega-prompt and it hallucinated URLs" pushes toward retrieval with citations, and "nothing leaves our region" changes which storage recipes are even eligible. |
stage | string | greenfield | prototype | production | scaling — where the work is today. Earlier stages get fewer, larger moves; production and scaling pull evaluation and caching recipes forward, because at that point the risk is regression and unit cost rather than getting anything working at all. |
posture | string | ship-fast | balanced | max-quality — what to trade away. ship-fast prefers a smaller set of core recipes and marks more of the rest optional; max-quality is what pulls evaluation, moderation and multi-step decomposition into the core path. |
prescan_facts | object, optional | What the browser's free prescan already matched, as {"signals": [{id, label, evidence}], "gaps": [{id, label}]}. Each signal is a recipe-shaped pattern the scanner found in the brief, with the sentence that triggered it; each gap is something the brief does not say. Every signal id you send comes back in coverage_check exactly once. See the note below for what an empty object changes. |
API callers may send {"signals": [], "gaps": []} — that is
the ordinary case for a script, and it is allowed. What changes is narrow and worth knowing:
coverage_check comes back empty, because there is nothing to reconcile, and no
recipe is pre-nominated by the scanner, so the routing rests entirely on the prose of the
brief. Nothing else moves — the recipes, the ruled-out list, the eval plan and the next
steps are all produced the same way. If you have your own upstream classifier, you can fill
signals from it and get the same reconciliation the web UI gets: every id you send
comes back marked addressed or not, with a note saying why, which is a cheap way
to find out that your classifier and the router disagree.
cat > brief.txt <<'BRIEF'
We take about 4,000 support emails a day. I want to auto-tag each one by product
area, pull the answer out of our help-centre docs, and draft a reply that a human
agent approves before it goes out. Attachments are mostly PDF invoices and the odd
screenshot of an error dialog. We already have Postgres with pgvector in the same
region as the app.
BRIEF
jq -n --rawfile brief brief.txt \
'{brief: $brief,
constraints: "p95 under 8s, ceiling $0.02 per email, no customer data leaves the region. We tried one big prompt and it invented doc URLs.",
stage: "prototype",
posture: "balanced",
prescan_facts: {
signals: [
{id: "rag", label: "Retrieval augmented generation", evidence: "pull the answer out of our help-centre docs"},
{id: "classification", label: "Classification", evidence: "auto-tag each one by product area"},
{id: "pdf", label: "PDF documents in the input", evidence: "attachments are mostly PDF invoices"},
{id: "vision", label: "Images in the input", evidence: "the odd screenshot of an error dialog"}
],
gaps: [
{id: "eval-bar", label: "No accuracy target for the tagging step"},
{id: "peak-volume", label: "No peak-hour volume given"}
]
}}' > input.json
api POST /estimate input.json > est.json
jq -r '"model \(.model) (alias \(.model_alias), \(.markup_bps) bps)
reserve up to \(.hold_credits) credits, floor \(.min_credits), sponsored: \(.sponsor_enabled)"' est.json
BRIEF = """We take about 4,000 support emails a day. I want to auto-tag each one by
product area, pull the answer out of our help-centre docs, and draft a reply that a
human agent approves before it goes out. Attachments are mostly PDF invoices and the
odd screenshot of an error dialog. We already have Postgres with pgvector in the same
region as the app."""
payload = {
"brief": BRIEF,
"constraints": ("p95 under 8s, ceiling $0.02 per email, no customer data leaves the "
"region. We tried one big prompt and it invented doc URLs."),
"stage": "prototype",
"posture": "balanced",
"prescan_facts": {
"signals": [
{"id": "rag", "label": "Retrieval augmented generation",
"evidence": "pull the answer out of our help-centre docs"},
{"id": "classification", "label": "Classification",
"evidence": "auto-tag each one by product area"},
{"id": "pdf", "label": "PDF documents in the input",
"evidence": "attachments are mostly PDF invoices"},
{"id": "vision", "label": "Images in the input",
"evidence": "the odd screenshot of an error dialog"},
],
"gaps": [
{"id": "eval-bar", "label": "No accuracy target for the tagging step"},
{"id": "peak-volume", "label": "No peak-hour volume given"},
],
},
}
est = api("POST", "/estimate", payload)
print(f'{est["model"]} (alias {est["model_alias"]}, {est["markup_bps"]} bps)')
print("reserve up to $%.4f" % (est["hold_credits"] / 10000),
"- floor", est["min_credits"], "- sponsored:", est["sponsor_enabled"])
if me["credits"] < est["min_credits"] and not est["sponsor_enabled"]:
raise SystemExit("balance below the model minimum - top up before running")
const brief = [
"We take about 4,000 support emails a day. I want to auto-tag each one by product",
"area, pull the answer out of our help-centre docs, and draft a reply that a human",
"agent approves before it goes out. Attachments are mostly PDF invoices and the odd",
"screenshot of an error dialog. We already have Postgres with pgvector in the same",
"region as the app.",
].join("\n");
const payload = {
brief,
constraints:
"p95 under 8s, ceiling $0.02 per email, no customer data leaves the region. " +
"We tried one big prompt and it invented doc URLs.",
stage: "prototype",
posture: "balanced",
prescan_facts: {
signals: [
{ id: "rag", label: "Retrieval augmented generation", evidence: "pull the answer out of our help-centre docs" },
{ id: "classification", label: "Classification", evidence: "auto-tag each one by product area" },
{ id: "pdf", label: "PDF documents in the input", evidence: "attachments are mostly PDF invoices" },
{ id: "vision", label: "Images in the input", evidence: "the odd screenshot of an error dialog" },
],
gaps: [
{ id: "eval-bar", label: "No accuracy target for the tagging step" },
{ id: "peak-volume", label: "No peak-hour volume given" },
],
},
};
const est = await api("POST", "/estimate", payload);
console.log(`${est.model} (alias ${est.model_alias}, ${est.markup_bps} bps)`);
console.log("reserve up to $" + (est.hold_credits / 10000).toFixed(4),
"- floor", est.min_credits, "- sponsored:", est.sponsor_enabled);
const brief = `We take about 4,000 support emails a day. I want to auto-tag each one by
product area, pull the answer out of our help-centre docs, and draft a reply that a
human agent approves before it goes out. Attachments are mostly PDF invoices and the
odd screenshot of an error dialog. We already have Postgres with pgvector in the same
region as the app.`
type signal struct {
ID string `json:"id"`
Label string `json:"label"`
Evidence string `json:"evidence"`
}
type gap struct {
ID string `json:"id"`
Label string `json:"label"`
}
payload := map[string]any{
"brief": brief,
"constraints": "p95 under 8s, ceiling $0.02 per email, no customer data leaves the " +
"region. We tried one big prompt and it invented doc URLs.",
"stage": "prototype",
"posture": "balanced",
"prescan_facts": map[string]any{
"signals": []signal{
{"rag", "Retrieval augmented generation", "pull the answer out of our help-centre docs"},
{"classification", "Classification", "auto-tag each one by product area"},
{"pdf", "PDF documents in the input", "attachments are mostly PDF invoices"},
{"vision", "Images in the input", "the odd screenshot of an error dialog"},
},
"gaps": []gap{
{"eval-bar", "No accuracy target for the tagging step"},
{"peak-volume", "No peak-hour volume given"},
},
},
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int64 `json:"hold_credits"`
MinCredits int64 `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
}
if err := call("POST", "/estimate", payload, &est); err != nil {
log.Fatal(err)
}
fmt.Printf("%s (alias %s, %d bps): reserve up to $%.4f, floor %d\n",
est.Model, est.ModelAlias, est.MarkupBps, float64(est.HoldCredits)/10000, est.MinCredits)
String brief = """
We take about 4,000 support emails a day. I want to auto-tag each one by product
area, pull the answer out of our help-centre docs, and draft a reply that a human
agent approves before it goes out. Attachments are mostly PDF invoices and the odd
screenshot of an error dialog. We already have Postgres with pgvector in the same
region as the app.
""";
// Build the body with your JSON library; toJsonString() escapes a Java string.
String jsonPayload = """
{"brief": %s,
"constraints": "p95 under 8s, ceiling $0.02 per email, no customer data leaves the region.",
"stage": "prototype",
"posture": "balanced",
"prescan_facts": {
"signals": [
{"id":"rag","label":"Retrieval augmented generation","evidence":"pull the answer out of our help-centre docs"},
{"id":"classification","label":"Classification","evidence":"auto-tag each one by product area"},
{"id":"pdf","label":"PDF documents in the input","evidence":"attachments are mostly PDF invoices"},
{"id":"vision","label":"Images in the input","evidence":"the odd screenshot of an error dialog"}
],
"gaps": [
{"id":"eval-bar","label":"No accuracy target for the tagging step"},
{"id":"peak-volume","label":"No peak-hour volume given"}
]
}}
""".formatted(toJsonString(brief));
String envelope = api("POST", "/estimate", jsonPayload);
// read data.model, data.model_alias, data.markup_bps, data.hold_credits,
// data.min_credits and data.sponsor_enabled before spending anything.
BRIEF = <<~TEXT
We take about 4,000 support emails a day. I want to auto-tag each one by product
area, pull the answer out of our help-centre docs, and draft a reply that a human
agent approves before it goes out. Attachments are mostly PDF invoices and the odd
screenshot of an error dialog. We already have Postgres with pgvector in the same
region as the app.
TEXT
payload = {
brief: BRIEF,
constraints: "p95 under 8s, ceiling $0.02 per email, no customer data leaves the " \
"region. We tried one big prompt and it invented doc URLs.",
stage: "prototype",
posture: "balanced",
prescan_facts: {
signals: [
{ id: "rag", label: "Retrieval augmented generation", evidence: "pull the answer out of our help-centre docs" },
{ id: "classification", label: "Classification", evidence: "auto-tag each one by product area" },
{ id: "pdf", label: "PDF documents in the input", evidence: "attachments are mostly PDF invoices" },
{ id: "vision", label: "Images in the input", evidence: "the odd screenshot of an error dialog" }
],
gaps: [
{ id: "eval-bar", label: "No accuracy target for the tagging step" },
{ id: "peak-volume", label: "No peak-hour volume given" }
]
}
}
est = api("POST", "/estimate", payload)
puts "#{est["model"]} (alias #{est["model_alias"]}, #{est["markup_bps"]} bps)"
puts format("reserve up to $%.4f - floor %d - sponsored: %s",
est["hold_credits"] / 10_000.0, est["min_credits"], est["sponsor_enabled"])
$brief = <<<'TEXT'
We take about 4,000 support emails a day. I want to auto-tag each one by product
area, pull the answer out of our help-centre docs, and draft a reply that a human
agent approves before it goes out. Attachments are mostly PDF invoices and the odd
screenshot of an error dialog. We already have Postgres with pgvector in the same
region as the app.
TEXT;
$payload = [
"brief" => $brief,
"constraints" => "p95 under 8s, ceiling \$0.02 per email, no customer data leaves the region.",
"stage" => "prototype",
"posture" => "balanced",
"prescan_facts" => [
"signals" => [
["id" => "rag", "label" => "Retrieval augmented generation", "evidence" => "pull the answer out of our help-centre docs"],
["id" => "classification", "label" => "Classification", "evidence" => "auto-tag each one by product area"],
["id" => "pdf", "label" => "PDF documents in the input", "evidence" => "attachments are mostly PDF invoices"],
["id" => "vision", "label" => "Images in the input", "evidence" => "the odd screenshot of an error dialog"],
],
"gaps" => [
["id" => "eval-bar", "label" => "No accuracy target for the tagging step"],
["id" => "peak-volume", "label" => "No peak-hour volume given"],
],
],
];
$est = api("POST", "/estimate", $payload);
printf("%s (alias %s, %d bps): reserve up to $%.4f, floor %d\n",
$est["model"], $est["model_alias"], $est["markup_bps"],
$est["hold_credits"] / 10000, $est["min_credits"]);
var brief = """
We take about 4,000 support emails a day. I want to auto-tag each one by product
area, pull the answer out of our help-centre docs, and draft a reply that a human
agent approves before it goes out. Attachments are mostly PDF invoices and the odd
screenshot of an error dialog. We already have Postgres with pgvector in the same
region as the app.
""";
var payload = new
{
brief,
constraints = "p95 under 8s, ceiling $0.02 per email, no customer data leaves the region.",
stage = "prototype",
posture = "balanced",
prescan_facts = new
{
signals = new object[]
{
new { id = "rag", label = "Retrieval augmented generation", evidence = "pull the answer out of our help-centre docs" },
new { id = "classification", label = "Classification", evidence = "auto-tag each one by product area" },
new { id = "pdf", label = "PDF documents in the input", evidence = "attachments are mostly PDF invoices" },
new { id = "vision", label = "Images in the input", evidence = "the odd screenshot of an error dialog" },
},
gaps = new object[]
{
new { id = "eval-bar", label = "No accuracy target for the tagging step" },
new { id = "peak-volume", label = "No peak-hour volume given" },
},
},
};
var est = await SkillSafe.ApiAsync(HttpMethod.Post, "/estimate", payload);
Console.WriteLine($"{est.GetProperty("model")} (alias {est.GetProperty("model_alias")}, " +
$"{est.GetProperty("markup_bps")} bps), hold {est.GetProperty("hold_credits")}, " +
$"floor {est.GetProperty("min_credits")}");
Step 5 — Route the brief and wait for the plan
/run takes the same input object as /estimate, places the credit
hold and answers {"data": {"job_id": "job_…"}}. Poll
/jobs/{job_id} every one or two seconds until status is
succeeded or failed; a routing plan usually takes 30 to 90 seconds,
because every recipe carries its own justification, the quoted signal from your brief, the
wiring notes and the failure mode. The model's text is at
data.output.output, as a JSON string — parse it, then read it with
the contract in step 7.
Always send an Idempotency-Key header, and make every retry of
the same input reuse the same key. A network blip between your process and the API is
indistinguishable, from your side, from a run that never started — the key is what makes
the difference safe: the second request with a key the server has already seen replays the
first job instead of starting a second, double-charged one. Derive it from the input rather
than from the clock (a hash of the serialised body is ideal), so a retried job in a queue or a
re-run pipeline step lands on the same key by construction.
| Job field | Meaning |
|---|---|
job_id | The handle you poll. Readable only by the token that created it. |
status | queued | running | succeeded | failed. Only the last two are terminal. |
output.output | The model's reply: one JSON object, as a string. This is the routing plan. |
charged_credits | What the run actually cost once settled — normally well under hold_credits. |
error | Present when status is failed. The hold is released; you are not charged for a failed run. |
# derive the key from the input, not the clock: a retry of the same brief replays
KEY="cr-$(shasum -a 256 input.json | cut -c1-32)"
JOB_ID=$(curl -s -X POST "$API/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d @input.json | jq -r '.data.job_id')
while :; do
JOB=$(curl -s "$API/jobs/$JOB_ID" -H "Authorization: Bearer $TOKEN")
STATUS=$(echo "$JOB" | jq -r '.data.status')
[ "$STATUS" = "succeeded" ] || [ "$STATUS" = "failed" ] && break
sleep 2
done
[ "$STATUS" = "failed" ] && { echo "$JOB" | jq -r '.data.error' >&2; exit 1; }
# unwrap the plan once, then read it
echo "$JOB" | jq -r '.data.output.output' > plan.json
jq -r '
"\(.brief_name) [\(.shape)]: \(.verdict)",
"",
"RECIPES",
(.recipes[] | " \(.id) [\(.role)] \(.recipe)\n why: \(.why)\n signal: \"\(.signal)\"\n watch out: \(.watch_out)"),
"",
"RULED OUT",
(.ruled_out[] | " \(.recipe): \(.why)"),
"",
"COVERAGE",
(.coverage_check[] | " \(.id): \(if .addressed then "addressed" else "SET ASIDE" end) - \(.note)"),
"",
"NEXT",
(.next_steps[] | " \(.step). \(.action) -> \(.output)")' plan.json
import hashlib, json, time
key = "cr-" + hashlib.sha256(json.dumps(payload, sort_keys=True).encode()).hexdigest()[:32]
job_id = api("POST", "/run", payload, **{"Idempotency-Key": key})["job_id"]
while True:
job = api("GET", f"/jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
if job["status"] == "failed":
raise RuntimeError(job.get("error", "run failed"))
raw = job["output"]
if isinstance(raw, dict) and "output" in raw:
raw = raw["output"]
plan = json.loads(raw) if isinstance(raw, str) else raw
print(f'{plan["brief_name"]} [{plan["shape"]}]: {plan["verdict"]}')
print(plan["exec_summary"], "\n")
for r in plan["recipes"]:
print(f' {r["id"]} [{r["role"]:<10}] {r["recipe"]}')
print(f' why: {r["why"]}')
print(f' signal: "{r["signal"]}"')
print(f' wire: {r["implementation"]}')
print(f' watch out: {r["watch_out"]}')
for r in plan["ruled_out"]:
print(f' ruled out {r["recipe"]}: {r["why"]}')
for c in plan["coverage_check"]:
print(f' {c["id"]}: {"addressed" if c["addressed"] else "SET ASIDE"} - {c["note"]}')
for s in plan["next_steps"]:
print(f' {s["step"]}. {s["action"]} -> {s["output"]}')
print(plan["summary"])
print("charged:", job.get("charged_credits"), "credits")
with open("plan.json", "w", encoding="utf-8") as fh:
json.dump(plan, fh, indent=2)
import { createHash } from "node:crypto";
import { writeFileSync } from "node:fs";
const key = "cr-" + createHash("sha256").update(JSON.stringify(payload)).digest("hex").slice(0, 32);
const { job_id } = await api("POST", "/run", payload, { "Idempotency-Key": key });
let job;
do {
await new Promise((r) => setTimeout(r, 1500));
job = await api("GET", `/jobs/${job_id}`);
} while (job.status !== "succeeded" && job.status !== "failed");
if (job.status === "failed") throw new Error(job.error ?? "run failed");
const raw = job.output?.output ?? job.output;
const plan = typeof raw === "string" ? JSON.parse(raw) : raw;
console.log(`${plan.brief_name} [${plan.shape}]: ${plan.verdict}`);
console.log(plan.exec_summary, "\n");
for (const r of plan.recipes) {
console.log(` ${r.id} [${r.role}] ${r.recipe}`);
console.log(` why: ${r.why}`);
console.log(` signal: "${r.signal}"`);
console.log(` wire: ${r.implementation}`);
console.log(` watch out: ${r.watch_out}`);
}
for (const r of plan.ruled_out) console.log(` ruled out ${r.recipe}: ${r.why}`);
for (const c of plan.coverage_check) {
console.log(` ${c.id}: ${c.addressed ? "addressed" : "SET ASIDE"} - ${c.note}`);
}
for (const s of plan.next_steps) console.log(` ${s.step}. ${s.action} -> ${s.output}`);
console.log(plan.summary);
console.log("charged:", job.charged_credits, "credits");
writeFileSync("plan.json", JSON.stringify(plan, null, 2));
raw, _ := json.Marshal(payload)
sum := sha256.Sum256(raw)
key := "cr-" + hex.EncodeToString(sum[:])[:32]
var started struct {
JobID string `json:"job_id"`
}
if err := call("POST", "/run", payload, &started, [2]string{"Idempotency-Key", key}); err != nil {
log.Fatal(err)
}
var job struct {
Status string `json:"status"`
Error string `json:"error"`
ChargedCredits int64 `json:"charged_credits"`
Output json.RawMessage `json:"output"`
}
for {
if err := call("GET", "/jobs/"+started.JobID, nil, &job); err != nil {
log.Fatal(err)
}
if job.Status == "succeeded" || job.Status == "failed" {
break
}
time.Sleep(1500 * time.Millisecond)
}
if job.Status == "failed" {
log.Fatal(job.Error)
}
// job.Output is {"output": "<json string>"} — unwrap, then unmarshal:
type Recipe struct {
ID string `json:"id"`
Recipe string `json:"recipe"`
Role string `json:"role"`
Why string `json:"why"`
Signal string `json:"signal"`
Implementation string `json:"implementation"`
WatchOut string `json:"watch_out"`
}
type Plan struct {
BriefName string `json:"brief_name"`
Shape string `json:"shape"`
Verdict string `json:"verdict"`
ExecSummary string `json:"exec_summary"`
Recipes []Recipe `json:"recipes"`
RuledOut []struct {
Recipe string `json:"recipe"`
Why string `json:"why"`
} `json:"ruled_out"`
CoverageCheck []struct {
ID string `json:"id"`
Addressed bool `json:"addressed"`
Note string `json:"note"`
} `json:"coverage_check"`
EvalPlan []struct {
Step int `json:"step"`
Check string `json:"check"`
PassesWhen string `json:"passes_when"`
} `json:"eval_plan"`
CostNotes []string `json:"cost_notes"`
OpenQuestions []string `json:"open_questions"`
NextSteps []struct {
Step int `json:"step"`
Action string `json:"action"`
Output string `json:"output"`
} `json:"next_steps"`
Summary string `json:"summary"`
}
var wrapper struct {
Output string `json:"output"`
}
json.Unmarshal(job.Output, &wrapper)
var plan Plan
if err := json.Unmarshal([]byte(wrapper.Output), &plan); err != nil {
log.Fatal(err)
}
fmt.Printf("%s [%s]: %s\n", plan.BriefName, plan.Shape, plan.Verdict)
for _, r := range plan.Recipes {
fmt.Printf(" %s [%s] %s\n why: %s\n signal: %q\n watch out: %s\n",
r.ID, r.Role, r.Recipe, r.Why, r.Signal, r.WatchOut)
}
for _, s := range plan.NextSteps {
fmt.Printf(" %d. %s -> %s\n", s.Step, s.Action, s.Output)
}
os.WriteFile("plan.json", []byte(wrapper.Output), 0o644)
// Derive the key from the body so a retry of the same brief replays the first job.
var digest = java.security.MessageDigest.getInstance("SHA-256")
.digest(jsonPayload.getBytes(java.nio.charset.StandardCharsets.UTF_8));
String key = "cr-" + java.util.HexFormat.of().formatHex(digest).substring(0, 32);
String envelope = api("POST", "/run", jsonPayload, "Idempotency-Key", key);
String jobId = /* data.job_id via your JSON library */;
String job;
while (true) {
job = api("GET", "/jobs/" + jobId, null);
String status = /* data.status */;
if (status.equals("succeeded") || status.equals("failed")) break;
Thread.sleep(1500);
}
// The plan is at data.output.output as a JSON string — parse it again, then read
// brief_name, shape, verdict, exec_summary, recipes[]
// (id/recipe/role/why/signal/implementation/watch_out), ruled_out[] (recipe/why),
// coverage_check[] (id/addressed/note), eval_plan[] (step/check/passes_when),
// cost_notes[], open_questions[], next_steps[] (step/action/output) and summary.
// Files.writeString(Path.of("plan.json"), planJson);
require "digest"
key = "cr-" + Digest::SHA256.hexdigest(payload.to_json)[0, 32]
started = api("POST", "/run", payload, { "Idempotency-Key" => key })
job = nil
loop do
job = api("GET", "/jobs/#{started["job_id"]}")
break if %w[succeeded failed].include?(job["status"])
sleep 1.5
end
raise (job["error"] || "run failed") if job["status"] == "failed"
raw = job["output"].is_a?(Hash) ? job["output"].fetch("output", job["output"]) : job["output"]
plan = raw.is_a?(String) ? JSON.parse(raw) : raw
puts "#{plan["brief_name"]} [#{plan["shape"]}]: #{plan["verdict"]}"
puts plan["exec_summary"], ""
plan["recipes"].each do |r|
puts " #{r["id"]} [#{r["role"]}] #{r["recipe"]}"
puts " why: #{r["why"]}"
puts " signal: \"#{r["signal"]}\""
puts " wire: #{r["implementation"]}"
puts " watch out: #{r["watch_out"]}"
end
plan["ruled_out"].each { |r| puts " ruled out #{r["recipe"]}: #{r["why"]}" }
plan["coverage_check"].each { |c| puts " #{c["id"]}: #{c["addressed"] ? "addressed" : "SET ASIDE"} - #{c["note"]}" }
plan["next_steps"].each { |s| puts " #{s["step"]}. #{s["action"]} -> #{s["output"]}" }
puts plan["summary"]
File.write("plan.json", JSON.pretty_generate(plan))
$key = "cr-" . substr(hash("sha256", json_encode($payload)), 0, 32);
$started = api("POST", "/run", $payload, ["Idempotency-Key: $key"]);
do {
sleep(2);
$job = api("GET", "/jobs/" . $started["job_id"]);
} while (!in_array($job["status"], ["succeeded", "failed"], true));
if ($job["status"] === "failed") {
throw new Exception($job["error"] ?? "run failed");
}
$raw = is_array($job["output"]) ? ($job["output"]["output"] ?? $job["output"]) : $job["output"];
$plan = is_string($raw) ? json_decode($raw, true) : $raw;
echo "{$plan['brief_name']} [{$plan['shape']}]: {$plan['verdict']}\n{$plan['exec_summary']}\n\n";
foreach ($plan["recipes"] as $r) {
echo " {$r['id']} [{$r['role']}] {$r['recipe']}\n";
echo " why: {$r['why']}\n";
echo " signal: \"{$r['signal']}\"\n";
echo " wire: {$r['implementation']}\n";
echo " watch out: {$r['watch_out']}\n";
}
foreach ($plan["ruled_out"] as $r) {
echo " ruled out {$r['recipe']}: {$r['why']}\n";
}
foreach ($plan["coverage_check"] as $c) {
echo " {$c['id']}: " . ($c["addressed"] ? "addressed" : "SET ASIDE") . " - {$c['note']}\n";
}
foreach ($plan["next_steps"] as $s) {
echo " {$s['step']}. {$s['action']} -> {$s['output']}\n";
}
echo $plan["summary"] . "\n";
file_put_contents("plan.json", json_encode($plan, JSON_PRETTY_PRINT));
using System.Security.Cryptography;
using System.Text;
var bodyText = JsonSerializer.Serialize(payload);
var key = "cr-" + Convert.ToHexString(SHA256.HashData(Encoding.UTF8.GetBytes(bodyText)))[..32];
var started = await SkillSafe.ApiAsync(HttpMethod.Post, "/run", payload, ("Idempotency-Key", key));
var jobId = started.GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await SkillSafe.ApiAsync(HttpMethod.Get, $"/jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status is "succeeded") break;
if (status is "failed") throw new Exception(job.GetProperty("error").GetString());
await Task.Delay(1500);
}
var rawText = job.GetProperty("output").GetProperty("output").GetString();
using var doc = JsonDocument.Parse(rawText!);
var plan = doc.RootElement;
Console.WriteLine($"{plan.GetProperty("brief_name")} [{plan.GetProperty("shape")}]: " +
$"{plan.GetProperty("verdict")}");
foreach (var r in plan.GetProperty("recipes").EnumerateArray())
{
Console.WriteLine($" {r.GetProperty("id")} [{r.GetProperty("role")}] {r.GetProperty("recipe")}");
Console.WriteLine($" why: {r.GetProperty("why")}");
Console.WriteLine($" signal: {r.GetProperty("signal")}");
}
foreach (var s in plan.GetProperty("next_steps").EnumerateArray())
Console.WriteLine($" {s.GetProperty("step")}. {s.GetProperty("action")}");
await File.WriteAllTextAsync("plan.json", rawText!);
The model is asked for one JSON object and nothing else, but a stray code fence or a preamble
is always possible. Strip a leading ```json fence, take the text between the
first { and the last }, and only then parse. Doing this defensively
costs three lines and saves you a run.
Step 6 — Stream the plan as it is written
/run-stream takes exactly the same body as /run and answers with
server-sent events, so you can show progress instead of a spinner — worth it here,
because a full plan with a justification, a quoted signal, wiring notes and a failure mode per
recipe is a long reply. The app's own progress panel is this endpoint. Events are separated by
a blank line; each has an event: line and a data: line carrying JSON:
event: job
data: {"job_id":"job_...","status":"running"}
event: delta
data: {"text":"{\"brief_name\":\"support email"}
event: delta
data: {"text":" triage and reply drafting\",\"shape\":\"workflow\""}
event: done
data: {"job_id":"job_...","status":"succeeded","charged_credits":610,"output":{"output":"{...}"}}
| Event | Payload | Meaning |
|---|---|---|
job | {job_id, status} | Sent once, when the job is accepted. Show "starting" and keep the id: if the connection dies you can still poll /jobs/{job_id} for the same run. |
delta | {text} | A chunk of the reply, in order. Append it to an accumulator; the accumulated length is your only progress signal, since the total is not known in advance. The app advances its step list by watching for the "shape", "recipes", "ruled_out", "coverage_check", "eval_plan" and "next_steps" keys as they arrive in the accumulated text. |
done | {job_id, status, charged_credits, output} | The final, authoritative result. Read the plan from output.output rather than trusting the concatenated deltas, and the settled price from charged_credits. |
error | {code, message} | Replaces done when the run fails. The code is one of the codes in the table further down. |
# -N disables buffering so events print as they arrive
curl -N -s -X POST "$API/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d @input.json \
| while IFS= read -r line; do
case "$line" in
"event:"*) EVENT="${line#event:}" ;;
"data:"*)
DATA="${line#data:}"
case "$EVENT" in
*delta) printf '.' ;; # live progress
*done) echo "$DATA" | jq -r '.output.output' > plan.json
echo; echo "$DATA" | jq -r '"charged \(.charged_credits) credits"' ;;
*error) echo "$DATA" >&2; exit 1 ;;
esac ;;
esac
done
jq -r '"\(.brief_name) [\(.shape)]", (.recipes[] | " \(.id) [\(.role)] \(.recipe)")' plan.json
import json, requests
acc, result = [], None
with requests.post(
API + "/run-stream",
headers={"Authorization": f"Bearer {TOKEN}", "Idempotency-Key": key},
json=payload,
stream=True,
) as r:
r.raise_for_status()
event = None
for line in r.iter_lines(decode_unicode=True):
if not line:
continue
if line.startswith("event:"):
event = line[len("event:"):].strip()
elif line.startswith("data:"):
data = json.loads(line[len("data:"):].strip())
if event == "delta":
acc.append(data["text"]) # accumulate the deltas
print(".", end="", flush=True) # live progress
elif event == "done":
result = data
elif event == "error":
raise RuntimeError(f'[{data.get("code")}] {data.get("message")}')
# the authoritative text is on the done event; "".join(acc) is the same string
plan = json.loads(result["output"]["output"])
print("\ncharged:", result["charged_credits"], "-", plan["brief_name"], plan["shape"])
for r in plan["recipes"]:
print(f' {r["id"]} [{r["role"]}] {r["recipe"]}')
with open("plan.json", "w", encoding="utf-8") as fh:
json.dump(plan, fh, indent=2)
const res = await fetch(API + "/run-stream", {
method: "POST",
headers: {
Authorization: `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key,
},
body: JSON.stringify(payload),
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buf = "", acc = "", done = null;
for (;;) {
const chunk = await reader.read();
if (chunk.done) break;
buf += decoder.decode(chunk.value, { stream: true });
const frames = buf.split("\n\n");
buf = frames.pop();
for (const frame of frames) {
const name = /^event:\s*(.+)$/m.exec(frame)?.[1];
const body = /^data:\s*(.+)$/m.exec(frame)?.[1];
if (!name || !body) continue;
const data = JSON.parse(body);
if (name === "delta") acc += data.text; // accumulate the deltas
if (name === "done") done = data;
if (name === "error") throw new Error(`[${data.code}] ${data.message}`);
}
}
const plan = JSON.parse(done.output.output); // authoritative
console.log(`${done.charged_credits} credits - ${plan.brief_name} [${plan.shape}]`);
for (const r of plan.recipes) console.log(` ${r.id} [${r.role}] ${r.recipe}`);
writeFileSync("plan.json", JSON.stringify(plan, null, 2));
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", API+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, err := http.DefaultClient.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
var event string
var acc strings.Builder
var final map[string]any
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 0, 64*1024), 4*1024*1024)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(strings.TrimPrefix(line, "event:"))
case strings.HasPrefix(line, "data:"):
var data map[string]any
json.Unmarshal([]byte(strings.TrimPrefix(line, "data:")), &data)
switch event {
case "delta":
acc.WriteString(data["text"].(string)) // accumulate the deltas
fmt.Print(".") // live progress
case "done":
final = data
case "error":
log.Fatalf("[%v] %v", data["code"], data["message"])
}
}
}
text := final["output"].(map[string]any)["output"].(string) // authoritative
var plan Plan // the struct from step 5
if err := json.Unmarshal([]byte(text), &plan); err != nil {
log.Fatal(err)
}
fmt.Printf("\n%s [%s]\n", plan.BriefName, plan.Shape)
os.WriteFile("plan.json", []byte(text), 0o644)
// Java 17+ — read the stream line by line instead of buffering the whole body.
var req = HttpRequest.newBuilder(URI.create(API + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(jsonPayload))
.build();
var res = HTTP.send(req, HttpResponse.BodyHandlers.ofLines());
var acc = new StringBuilder();
String event = null, done = null;
for (String line : (Iterable<String>) res.body()::iterator) {
if (line.startsWith("event:")) {
event = line.substring(6).trim();
} else if (line.startsWith("data:")) {
String data = line.substring(5).trim();
if ("delta".equals(event)) {
acc.append(/* data.text via your JSON library */ ""); // accumulate
System.out.print("."); // live progress
} else if ("done".equals(event)) {
done = data;
} else if ("error".equals(event)) {
throw new RuntimeException(data);
}
}
}
// parse `done`, then parse data.output.output again — it is a JSON string holding
// brief_name, shape, verdict, recipes[], ruled_out[], coverage_check[],
// eval_plan[], cost_notes[], open_questions[], next_steps[] and summary.
require "net/http"
require "json"
uri = URI(API + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = payload.to_json
event = nil
acc = +""
done = nil
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.strip
if line.start_with?("event:")
event = line.delete_prefix("event:").strip
elsif line.start_with?("data:")
data = JSON.parse(line.delete_prefix("data:").strip)
case event
when "delta" then acc << data["text"]; print "." # accumulate + progress
when "done" then done = data
when "error" then raise "[#{data["code"]}] #{data["message"]}"
end
end
end
end
end
end
plan = JSON.parse(done["output"]["output"]) # authoritative
puts "\n#{done["charged_credits"]} credits - #{plan["brief_name"]} [#{plan["shape"]}]"
plan["recipes"].each { |r| puts " #{r["id"]} [#{r["role"]}] #{r["recipe"]}" }
File.write("plan.json", JSON.pretty_generate(plan))
$event = null;
$acc = "";
$done = null;
$ch = curl_init(API . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer $TOKEN",
"Content-Type: application/json",
"Idempotency-Key: $key",
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$event, &$acc, &$done) {
foreach (explode("\n", $chunk) as $line) {
$line = trim($line);
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:")) {
$data = json_decode(trim(substr($line, 5)), true);
if ($event === "delta") { $acc .= $data["text"]; echo "."; }
elseif ($event === "done") { $done = $data; }
elseif ($event === "error") {
throw new Exception("[{$data['code']}] {$data['message']}");
}
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$plan = json_decode($done["output"]["output"], true); // authoritative
echo "\n{$done['charged_credits']} credits - {$plan['brief_name']} [{$plan['shape']}]\n";
foreach ($plan["recipes"] as $r) {
echo " {$r['id']} [{$r['role']}] {$r['recipe']}\n";
}
file_put_contents("plan.json", json_encode($plan, JSON_PRETTY_PRINT));
var req = new HttpRequestMessage(HttpMethod.Post, Api + "/run-stream")
{
Content = JsonContent.Create(payload),
};
req.Headers.Add("Idempotency-Key", key);
using var res = await Http.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var acc = new StringBuilder();
string? evt = null, done = null;
while (await reader.ReadLineAsync() is { } line)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:"))
{
var data = line[5..].Trim();
if (evt == "delta")
{
using var d = JsonDocument.Parse(data);
acc.Append(d.RootElement.GetProperty("text").GetString()); // accumulate
Console.Write("."); // live progress
}
else if (evt == "done") done = data;
else if (evt == "error") throw new Exception(data);
}
}
using var final = JsonDocument.Parse(done!);
var text = final.RootElement.GetProperty("output").GetProperty("output").GetString();
using var planDoc = JsonDocument.Parse(text!);
var plan = planDoc.RootElement;
Console.WriteLine($"\n{plan.GetProperty("brief_name")} [{plan.GetProperty("shape")}]");
foreach (var r in plan.GetProperty("recipes").EnumerateArray())
Console.WriteLine($" {r.GetProperty("id")} [{r.GetProperty("role")}] {r.GetProperty("recipe")}");
await File.WriteAllTextAsync("plan.json", text!);
In a browser, the native EventSource only speaks GET and this endpoint is a POST
— read the fetch response body incrementally, as the JavaScript sample
does. On an idempotent replay the server may answer with a plain JSON envelope instead of an
event stream, so check the Content-Type before you start splitting frames. And
keep the accumulator even though done is authoritative: if a stream dies
mid-flight, the accumulated prefix is often still repairable — closing the open string
and arrays and re-parsing keeps whatever recipes arrived instead of throwing the run away.
Step 7 — Reading the result
The model returns one JSON object, always the same shape, and every array is
present even when it is empty. The routing is grounded in the brief you sent: every
signal is a phrase from your own text, and the recipe values are
drawn from a closed list of 21 canonical cookbook ids — anything outside that list is
not a routing decision, it is a hallucination, and your client should reject it (there is a
validator in the code group below).
| Field | Type | Meaning |
|---|---|---|
brief_name | string | A short name for the brief, usable as a title or a filename stem. |
shape | string | single-call | workflow | agent | batch | hybrid — the architecture the brief implies. This is the field to branch on: a single-call answer means the whole thing is one prompt and most of the recipe list is optional; agent means a loop with tools and a stopping condition; batch means throughput and unit cost dominate. |
verdict | string | One line: the routing call, in the form a reviewer could disagree with. |
exec_summary | string | A few short paragraphs separated by blank lines: what the brief is asking for, what shape it implies, and the reasoning behind the core recipes. |
recipes | array | The routing itself — at least one entry. Columns below. If this array is empty the result is malformed; see the invariants. |
ruled_out | array | {recipe, why} — recipes a reader would plausibly reach for that this brief does not need, each with the reason. Read this before you argue with the routing: the recipe you expected is usually here with an explanation. |
coverage_check | array | {id, addressed, note} — one entry per prescan_facts.signals id you sent, each appearing exactly once. addressed: false is a real answer, not an omission: it means the signal was seen and deliberately set aside, and the note says why. Empty when you sent no signals. |
eval_plan | array | {step, check, passes_when} — ordered checks that would tell you the built system works. passes_when is the bar, stated concretely enough to code against. |
cost_notes | string[] | What will drive the bill at your stated volume, and the levers that move it. Caching, context size and the number of hops per request usually show up here. |
open_questions | string[] | What the brief did not say that would change the routing. The gaps you sent in prescan_facts usually resurface here in a sharper form. |
next_steps | array | {step, action, output} — ordered actions, each with the artifact it should produce. output is what "done" looks like for that step. |
summary | string | A closing paragraph you could paste into a design doc. |
Each entry in recipes:
| Column | Meaning |
|---|---|
id | A sequential handle: R-001, R-002, and so on, in the order the recipes should be wired. |
recipe | The canonical cookbook recipe id, from the closed list of 21 below. Reject anything else. |
role | core | supporting | optional. core is the load-bearing path; supporting is what makes it reliable; optional is what you would add if the constraints changed. The app renders every core recipe first — see the invariants. |
why | Why this recipe, for this brief. Not a description of the recipe in the abstract. |
signal | The quoted phrase from your brief that justifies it. This is the field to check first when you disagree with a routing decision: if the quote is not about what you meant, the brief was ambiguous, not the router. |
implementation | How to wire it in: what it sits between, what it consumes and what it emits. |
watch_out | The failure mode — the way this recipe goes wrong in production once the happy path works. |
The two client-side invariants
The app applies two rules after parsing, before it renders anything. If you build your own view of the plan, apply the same two or your output will disagree with the app's.
| Rule | Effect |
|---|---|
| Core first | Every recipe with role == "core" is rendered before any supporting or optional one, preserving the model's relative order within each group. A plan is read top-down and the load-bearing path has to be at the top. |
Empty recipes is malformed | If recipes is empty the whole result is rejected as malformed rather than shown as "no recipes needed". A brief that genuinely needs nothing from the cookbook is still a routing answer — it belongs in verdict and ruled_out, with at least one recipe carrying the single-call path. An empty array means the reply did not survive the round trip. |
A real result for the brief in step 4, trimmed for length:
{
"brief_name": "support email triage and reply drafting",
"shape": "workflow",
"verdict": "A three-stage workflow - classify, retrieve, draft - not an agent: every step is known in advance, so nothing here needs a model deciding what to do next.",
"exec_summary": "You have a fixed pipeline wearing an agent's clothes. Each email needs a label, a set of relevant doc chunks, and a drafted reply that a human approves. The order never changes and no step depends on a model's choice of tool, which is what separates a workflow from an agent and buys you the 8 second p95.\n\nThe two core recipes are classification for the tag and retrieval-augmented-generation for the answer. Embeddings and vector-databases are the machinery underneath the retrieval step, and you already have pgvector in-region, which settles the storage question and the data-residency constraint at the same time.\n\nPDFs and screenshots are a second, narrower path: only the minority of emails that carry an attachment pay for it, so keep it off the hot path and out of the p95 budget.",
"recipes": [
{ "id": "R-001", "recipe": "classification", "role": "core",
"why": "The tag decides which doc set is searched and which reply template applies, so it runs first and everything downstream depends on it.",
"signal": "auto-tag each one by product area",
"implementation": "One short call over the subject and first ~500 words, returning a label from a closed enum plus a confidence. Route anything below the threshold to a human queue instead of guessing.",
"watch_out": "An open-ended label set drifts within a week. Freeze the enum, version it, and treat a new product area as a deploy, not a prompt edit." },
{ "id": "R-002", "recipe": "retrieval-augmented-generation", "role": "core",
"why": "The reply has to come from your help-centre docs, and the mega-prompt attempt invented doc URLs - that is the exact failure retrieval exists to fix.",
"signal": "pull the answer out of our help-centre docs",
"implementation": "Retrieve 5-8 chunks filtered by the R-001 label, pass them with the email, and require the draft to cite chunk ids. Drop any reply whose citations do not resolve.",
"watch_out": "Citations that look right but point at the wrong chunk. Verify ids against the retrieved set programmatically - do not eyeball them." },
{ "id": "R-003", "recipe": "embeddings", "role": "supporting",
"why": "R-002 needs vectors, and the chunking and embedding choices decide retrieval quality more than the generation prompt does.",
"signal": "pull the answer out of our help-centre docs",
"implementation": "Chunk the help centre by section heading with a small overlap, embed once, re-embed only on doc change.",
"watch_out": "Chunks that span two headings answer neither question well." },
{ "id": "R-004", "recipe": "vector-databases", "role": "supporting",
"why": "You already run Postgres with pgvector in-region, which satisfies the residency constraint without a new vendor.",
"signal": "we already have Postgres with pgvector in the same region as the app",
"implementation": "One table with the chunk, its embedding and the product-area label; filter by label before the vector search, not after.",
"watch_out": "Filtering after the search silently starves the candidate set at high recall settings." },
{ "id": "R-005", "recipe": "pdf-upload", "role": "supporting",
"why": "Invoice PDFs carry the amounts and dates the reply has to be right about, and they are unreadable to the text path.",
"signal": "attachments are mostly PDF invoices",
"implementation": "Branch on attachment type before R-002; feed the PDF alongside the email so the draft can quote it.",
"watch_out": "A 40-page attachment blows both the latency budget and the per-email ceiling. Cap pages and fall back to a human." },
{ "id": "R-006", "recipe": "vision", "role": "optional",
"why": "Error-dialog screenshots contain the message text that decides the answer, but they are a minority of the volume.",
"signal": "the odd screenshot of an error dialog",
"implementation": "Same attachment branch as R-005; ask for the literal on-screen text before any interpretation.",
"watch_out": "Low-resolution phone photos of screens read as confident nonsense. Return an explicit 'unreadable' path." },
{ "id": "R-007", "recipe": "prompt-caching", "role": "supporting",
"why": "At 4,000 emails a day the doc preamble and the label enum are re-sent constantly, and that repetition is most of the bill.",
"signal": "about 4,000 support emails a day",
"implementation": "Cache the static prefix - instructions, enum, reply template - and keep the per-email text after it.",
"watch_out": "A cosmetic edit to the prefix invalidates the cache for everyone. Version prefixes deliberately." },
{ "id": "R-008", "recipe": "automated-evaluations", "role": "supporting",
"why": "You will change the prompt weekly and have no way to tell an improvement from a regression without a scored set.",
"signal": "auto-tag each one by product area",
"implementation": "Freeze 200 emails with human labels and approved replies; score label accuracy and citation validity on every prompt change.",
"watch_out": "An eval set drawn only from resolved tickets hides every case the pipeline currently fails." }
],
"ruled_out": [
{ "recipe": "customer-service-agent",
"why": "An agent loop chooses its own steps and its own number of hops, which is exactly what an 8 second p95 cannot absorb. Your pipeline is fixed - keep it fixed." },
{ "recipe": "sub-agents",
"why": "Nothing here decomposes into parallel independent work. Sub-agents would multiply cost per email against a $0.02 ceiling for no accuracy gain." },
{ "recipe": "sql-queries",
"why": "Postgres is here as a vector store, not as a question-answering surface. No part of the brief asks a question of your relational data." },
{ "recipe": "moderation-filter",
"why": "Every reply is approved by a human agent before it goes out, so the review step already covers what a moderation pass would." }
],
"coverage_check": [
{ "id": "rag", "addressed": true, "note": "R-002, with R-003 and R-004 as the machinery under it." },
{ "id": "classification", "addressed": true, "note": "R-001, first in the pipeline because the label routes everything after it." },
{ "id": "pdf", "addressed": true, "note": "R-005, on the attachment branch rather than the hot path." },
{ "id": "vision", "addressed": true, "note": "R-006, marked optional: real but a small share of volume, and it is the most expensive per email." }
],
"eval_plan": [
{ "step": 1, "check": "Label accuracy against the 200 frozen human-labelled emails.",
"passes_when": "Agreement above your stated bar, with per-label recall reported - a strong average hides a dead label." },
{ "step": 2, "check": "Every citation in a draft resolves to a chunk that was actually retrieved.",
"passes_when": "Zero unresolvable citations across the eval set. This is a hard gate, not a percentage." },
{ "step": 3, "check": "End-to-end p95 latency at peak concurrency, attachments included.",
"passes_when": "Under 8 seconds with the attachment branch exercised at its real rate." },
{ "step": 4, "check": "Cost per email at the measured cache hit rate.",
"passes_when": "Under $0.02 including the retrieval and attachment paths." }
],
"cost_notes": [
"The static prefix is the biggest single lever: at this volume prompt caching moves the bill more than any model choice.",
"Retrieval width is a cost dial - 5-8 chunks is a starting point, and every extra chunk is paid 4,000 times a day.",
"The attachment branch is the expensive path. Measure what fraction of emails take it before you budget from an average."
],
"open_questions": [
"What accuracy bar makes the tagging step useful? Without it, step 1 of the eval plan has no pass condition.",
"What is peak-hour volume, not the daily average? The p95 target is decided by the peak, not by 4,000 divided by 24.",
"Does the human approving the reply see the retrieved chunks? If not, the citation check is the only defence you have."
],
"next_steps": [
{ "step": 1, "action": "Freeze the product-area enum and label 200 real emails by hand.",
"output": "A versioned enum and a scored eval set both R-001 and step 1 of the eval plan can run against." },
{ "step": 2, "action": "Chunk and embed the help centre into pgvector with the label as a filter column.",
"output": "A queryable table and a recall number for the frozen questions." },
{ "step": 3, "action": "Wire R-001 into R-002 with citation ids required in the draft.",
"output": "An end-to-end draft for one email, with resolvable citations." },
{ "step": 4, "action": "Add the attachment branch behind a type check and measure its real rate.",
"output": "Latency and cost for the attachment path, separated from the hot path." }
],
"summary": "This is a fixed three-stage workflow, and treating it as one is what keeps it inside 8 seconds and $0.02. Classification routes, retrieval grounds the reply against the docs that already exist, and the attachment path stays off the hot path. The two things standing between this plan and a build are a frozen label enum and a scored eval set - both are hand work, both are cheap, and everything else is downstream of them."
}
The code group below does the three things a client must do with that object: reject a
recipe outside the canonical list, reject an empty recipes array, and
order core first.
CANON='["classification","retrieval-augmented-generation","summarization","tool-use","customer-service-agent","calculator-tool","sql-queries","vector-databases","wikipedia-search","web-page-reading","embeddings","vision","chart-interpretation","form-extraction","image-generation","sub-agents","pdf-upload","automated-evaluations","json-mode","moderation-filter","prompt-caching"]'
# invariant (b): an empty recipes array is malformed, not "nothing to do"
jq -e '(.recipes | length) > 0' plan.json > /dev/null \
|| { echo "malformed plan: no recipes" >&2; exit 1; }
# reject any recipe id outside the closed list
UNKNOWN=$(jq -r --argjson canon "$CANON" \
'[.recipes[].recipe, .ruled_out[].recipe] - $canon | .[]' plan.json)
[ -n "$UNKNOWN" ] && { echo "unknown recipe id(s): $UNKNOWN" >&2; exit 1; }
# invariant (a): core first, original order preserved inside each group
jq -r '
["core","supporting","optional"] as $order
| .recipes
| sort_by($order | index(.role) // 99)
| .[] | "\(.id) [\(.role)] \(.recipe) - \(.why)"' plan.json
CANONICAL = {
"classification", "retrieval-augmented-generation", "summarization", "tool-use",
"customer-service-agent", "calculator-tool", "sql-queries", "vector-databases",
"wikipedia-search", "web-page-reading", "embeddings", "vision",
"chart-interpretation", "form-extraction", "image-generation", "sub-agents",
"pdf-upload", "automated-evaluations", "json-mode", "moderation-filter",
"prompt-caching",
}
ROLE_ORDER = {"core": 0, "supporting": 1, "optional": 2}
def validate(plan):
# (b) an empty recipes array is malformed, not "nothing to do"
if not plan.get("recipes"):
raise ValueError("malformed plan: recipes is empty")
for r in plan["recipes"] + plan.get("ruled_out", []):
if r["recipe"] not in CANONICAL:
raise ValueError(f'unknown recipe id: {r["recipe"]}')
return plan
def ordered(plan):
# (a) core first; sorted() is stable, so relative order survives inside a role
return sorted(plan["recipes"], key=lambda r: ROLE_ORDER.get(r["role"], 99))
validate(plan)
for r in ordered(plan):
print(f'{r["id"]} [{r["role"]}] {r["recipe"]}')
print(f' {r["why"]}')
print(f' signal: "{r["signal"]}"')
for c in plan["coverage_check"]:
if not c["addressed"]:
print("set aside:", c["id"], "-", c["note"])
for s in plan["eval_plan"]:
print(f'eval {s["step"]}: {s["check"]} - passes when {s["passes_when"]}')
for note in plan["cost_notes"]:
print("cost:", note)
const CANONICAL = new Set([
"classification", "retrieval-augmented-generation", "summarization", "tool-use",
"customer-service-agent", "calculator-tool", "sql-queries", "vector-databases",
"wikipedia-search", "web-page-reading", "embeddings", "vision",
"chart-interpretation", "form-extraction", "image-generation", "sub-agents",
"pdf-upload", "automated-evaluations", "json-mode", "moderation-filter",
"prompt-caching",
]);
const ROLE_ORDER = { core: 0, supporting: 1, optional: 2 };
function validate(plan) {
// (b) an empty recipes array is malformed, not "nothing to do"
if (!plan.recipes?.length) throw new Error("malformed plan: recipes is empty");
for (const r of [...plan.recipes, ...(plan.ruled_out ?? [])]) {
if (!CANONICAL.has(r.recipe)) throw new Error(`unknown recipe id: ${r.recipe}`);
}
return plan;
}
// (a) core first; Array#sort is stable, so order inside a role survives
const ordered = (plan) =>
[...plan.recipes].sort((a, b) => (ROLE_ORDER[a.role] ?? 99) - (ROLE_ORDER[b.role] ?? 99));
validate(plan);
for (const r of ordered(plan)) {
console.log(`${r.id} [${r.role}] ${r.recipe}`);
console.log(` ${r.why}`);
console.log(` signal: "${r.signal}"`);
}
for (const c of plan.coverage_check.filter((c) => !c.addressed)) {
console.log("set aside:", c.id, "-", c.note);
}
for (const s of plan.eval_plan) console.log(`eval ${s.step}: ${s.check} - passes when ${s.passes_when}`);
for (const note of plan.cost_notes) console.log("cost:", note);
var canonical = map[string]bool{
"classification": true, "retrieval-augmented-generation": true, "summarization": true,
"tool-use": true, "customer-service-agent": true, "calculator-tool": true,
"sql-queries": true, "vector-databases": true, "wikipedia-search": true,
"web-page-reading": true, "embeddings": true, "vision": true,
"chart-interpretation": true, "form-extraction": true, "image-generation": true,
"sub-agents": true, "pdf-upload": true, "automated-evaluations": true,
"json-mode": true, "moderation-filter": true, "prompt-caching": true,
}
var roleOrder = map[string]int{"core": 0, "supporting": 1, "optional": 2}
func validate(p Plan) error {
// (b) an empty recipes array is malformed, not "nothing to do"
if len(p.Recipes) == 0 {
return errors.New("malformed plan: recipes is empty")
}
for _, r := range p.Recipes {
if !canonical[r.Recipe] {
return fmt.Errorf("unknown recipe id: %s", r.Recipe)
}
}
for _, r := range p.RuledOut {
if !canonical[r.Recipe] {
return fmt.Errorf("unknown recipe id: %s", r.Recipe)
}
}
return nil
}
if err := validate(plan); err != nil {
log.Fatal(err)
}
// (a) core first — SliceStable keeps the model's order inside each role
ordered := append([]Recipe(nil), plan.Recipes...)
sort.SliceStable(ordered, func(i, j int) bool {
oi, ok := roleOrder[ordered[i].Role]
if !ok {
oi = 99
}
oj, ok := roleOrder[ordered[j].Role]
if !ok {
oj = 99
}
return oi < oj
})
for _, r := range ordered {
fmt.Printf("%s [%s] %s\n %s\n signal: %q\n", r.ID, r.Role, r.Recipe, r.Why, r.Signal)
}
static final java.util.Set<String> CANONICAL = java.util.Set.of(
"classification", "retrieval-augmented-generation", "summarization", "tool-use",
"customer-service-agent", "calculator-tool", "sql-queries", "vector-databases",
"wikipedia-search", "web-page-reading", "embeddings", "vision",
"chart-interpretation", "form-extraction", "image-generation", "sub-agents",
"pdf-upload", "automated-evaluations", "json-mode", "moderation-filter",
"prompt-caching");
static final java.util.List<String> ROLE_ORDER =
java.util.List.of("core", "supporting", "optional");
// With the plan parsed into a record list by your JSON library:
// (b) if recipes.isEmpty() -> throw; an empty array is a malformed reply,
// not a plan that says "nothing to do".
// for each recipe and each ruled_out entry:
// if (!CANONICAL.contains(r.recipe())) throw new IllegalStateException(r.recipe());
// (a) core first, stable within a role:
// recipes.sort(java.util.Comparator.comparingInt(r -> {
// int i = ROLE_ORDER.indexOf(r.role());
// return i < 0 ? 99 : i;
// }));
// List.sort is a stable merge sort, so the model's ordering inside each role
// survives — which is what keeps R-001, R-002 next to each other.
CANONICAL = %w[
classification retrieval-augmented-generation summarization tool-use
customer-service-agent calculator-tool sql-queries vector-databases
wikipedia-search web-page-reading embeddings vision chart-interpretation
form-extraction image-generation sub-agents pdf-upload automated-evaluations
json-mode moderation-filter prompt-caching
].freeze
ROLE_ORDER = { "core" => 0, "supporting" => 1, "optional" => 2 }.freeze
def validate!(plan)
# (b) an empty recipes array is malformed, not "nothing to do"
raise "malformed plan: recipes is empty" if plan["recipes"].to_a.empty?
(plan["recipes"] + plan.fetch("ruled_out", [])).each do |r|
raise "unknown recipe id: #{r["recipe"]}" unless CANONICAL.include?(r["recipe"])
end
plan
end
# (a) core first, stable inside each role
def ordered(plan)
plan["recipes"].each_with_index
.sort_by { |r, i| [ROLE_ORDER.fetch(r["role"], 99), i] }
.map(&:first)
end
validate!(plan)
ordered(plan).each do |r|
puts "#{r["id"]} [#{r["role"]}] #{r["recipe"]}"
puts " #{r["why"]}"
puts " signal: \"#{r["signal"]}\""
end
plan["coverage_check"].reject { |c| c["addressed"] }
.each { |c| puts "set aside: #{c["id"]} - #{c["note"]}" }
const CANONICAL = [
"classification", "retrieval-augmented-generation", "summarization", "tool-use",
"customer-service-agent", "calculator-tool", "sql-queries", "vector-databases",
"wikipedia-search", "web-page-reading", "embeddings", "vision",
"chart-interpretation", "form-extraction", "image-generation", "sub-agents",
"pdf-upload", "automated-evaluations", "json-mode", "moderation-filter",
"prompt-caching",
];
const ROLE_ORDER = ["core" => 0, "supporting" => 1, "optional" => 2];
function validate_plan(array $plan): array {
// (b) an empty recipes array is malformed, not "nothing to do"
if (empty($plan["recipes"])) {
throw new Exception("malformed plan: recipes is empty");
}
foreach (array_merge($plan["recipes"], $plan["ruled_out"] ?? []) as $r) {
if (!in_array($r["recipe"], CANONICAL, true)) {
throw new Exception("unknown recipe id: {$r['recipe']}");
}
}
return $plan;
}
validate_plan($plan);
// (a) core first — decorate with the index so the sort stays stable
$rows = [];
foreach ($plan["recipes"] as $i => $r) {
$rows[] = [ROLE_ORDER[$r["role"]] ?? 99, $i, $r];
}
usort($rows, fn($a, $b) => [$a[0], $a[1]] <=> [$b[0], $b[1]]);
foreach ($rows as [$_, $__, $r]) {
echo "{$r['id']} [{$r['role']}] {$r['recipe']}\n";
echo " {$r['why']}\n";
echo " signal: \"{$r['signal']}\"\n";
}
static readonly HashSet<string> Canonical = new()
{
"classification", "retrieval-augmented-generation", "summarization", "tool-use",
"customer-service-agent", "calculator-tool", "sql-queries", "vector-databases",
"wikipedia-search", "web-page-reading", "embeddings", "vision",
"chart-interpretation", "form-extraction", "image-generation", "sub-agents",
"pdf-upload", "automated-evaluations", "json-mode", "moderation-filter",
"prompt-caching",
};
static int RoleRank(string? role) => role switch
{
"core" => 0, "supporting" => 1, "optional" => 2, _ => 99,
};
var recipes = plan.GetProperty("recipes").EnumerateArray().ToList();
// (b) an empty recipes array is malformed, not "nothing to do"
if (recipes.Count == 0) throw new Exception("malformed plan: recipes is empty");
foreach (var r in recipes.Concat(plan.GetProperty("ruled_out").EnumerateArray()))
{
var id = r.GetProperty("recipe").GetString();
if (!Canonical.Contains(id!)) throw new Exception($"unknown recipe id: {id}");
}
// (a) core first — OrderBy is a stable sort in LINQ to Objects
foreach (var r in recipes.OrderBy(r => RoleRank(r.GetProperty("role").GetString())))
{
Console.WriteLine($"{r.GetProperty("id")} [{r.GetProperty("role")}] {r.GetProperty("recipe")}");
Console.WriteLine($" {r.GetProperty("why")}");
Console.WriteLine($" signal: {r.GetProperty("signal")}");
}
The 21 canonical recipe ids
recipes[].recipe and ruled_out[].recipe are restricted to this closed
list, which mirrors the Anthropic Claude Cookbook the source skill is derived from.
A caller should reject any value outside it — an id you have never seen
is not a new recipe, it is a reply that drifted, and the routing built on it is worth nothing.
The validator in step 7 is the whole check.
| Recipe id | What it is for |
|---|---|
classification | Assigning an input to one of a fixed set of labels, with a confidence you can threshold on. |
retrieval-augmented-generation | Answering from your own documents by retrieving the relevant passages first and generating against them. |
summarization | Compressing long text to a shorter form that keeps what the reader needs. |
tool-use | Letting the model call functions you define and read the results back. |
customer-service-agent | A multi-turn agent that holds a conversation, uses tools and knows when to hand off. |
calculator-tool | Delegating arithmetic to real code instead of trusting a language model with numbers. |
sql-queries | Turning a question into SQL against a schema you supply, and reading the rows back. |
vector-databases | Storing and searching embeddings: the retrieval half of a RAG system. |
wikipedia-search | Grounding an answer in an external reference corpus rather than in your own documents. |
web-page-reading | Fetching a live page and using its content as context. |
embeddings | Turning text into vectors: chunking, embedding and the similarity search on top. |
vision | Reading images: screenshots, photographs, scans, diagrams. |
chart-interpretation | Reading the values and the claim out of a chart image. |
form-extraction | Pulling structured fields out of documents and forms into a fixed schema. |
image-generation | Producing images as part of the pipeline rather than consuming them. |
sub-agents | Decomposing work across several model calls that run independently and report back. |
pdf-upload | Passing PDFs directly as input, including the pages a text extractor mangles. |
automated-evaluations | Scoring outputs against a frozen set so you can tell an improvement from a regression. |
json-mode | Getting reliably parseable structured output instead of prose you have to scrape. |
moderation-filter | Screening inputs or outputs against a policy before they go anywhere. |
prompt-caching | Reusing a stable prompt prefix across many calls to cut latency and cost at volume. |
The list is closed on purpose. If a brief needs something outside it, that belongs in
open_questions or in the prose of exec_summary — never as an
invented recipe id, because the whole value of the field is that a downstream
system can map it to a real cookbook page without a human in between.
Error codes
Failures arrive as {"ok": false, "error": {"code": "…", "message": "…"}}
with a matching HTTP status. Branch on code, never on the message text — the
message is written for a human reading a log and can be reworded; the code is the contract.
| Code | HTTP | What causes it | What to do |
|---|---|---|---|
UNAUTHORIZED | 401 |
Missing, malformed or expired token, or a token minted for a different app. | Get a fresh one from the token page, or mint a new guest token with POST /guest, and retry once. Do not retry in a loop — a bad token does not become good. |
INSUFFICIENT_CREDITS | 402 |
The balance is below the estimate's min_credits, so the hold cannot be placed. |
Top up, or run as a subject that has credits. Call /estimate and /me before a batch so you find out once, up front, instead of once per brief. |
VALIDATION_ERROR | 400 |
The body did not match the input schema: brief missing or empty, a bad enum in stage or posture, or a prescan_facts entry that is not shaped like {id, label}. |
Read the message — it names the field. Fix the payload and resend; retrying the same body will fail identically. |
RATE_LIMITED | 429 |
Too many requests in flight for this subject. | Back off and retry with exponential delay and jitter, reusing the same Idempotency-Key so the retry cannot start a second, double-charged run. |
NOT_FOUND | 404 |
Unknown job id, a job belonging to a different subject, or a wrong app slug on /guest. |
Check the id and the slug (cookbook-router). Job ids are readable only by the token that created them, so a job started with your browser's token is a 404 to a guest token from a script. |
INTERNAL | 5xx |
A transient platform error. | Retry with backoff, again with the same Idempotency-Key. If a run failed after the hold was placed, the hold is released — you are not charged for a failed run. |
Two habits make an integration boring in the good way. Send an Idempotency-Key
derived from the input on every /run and /run-stream, so any retry
anywhere in your stack replays instead of re-billing. And treat
VALIDATION_ERROR as terminal while treating RATE_LIMITED and
INTERNAL as retryable — a client that retries a schema error is just a
slower way of failing.