← Campaign Desk / API
Tokens

Drive Campaign Desk from your own code

Everything the web page does is available over HTTP. Send one campaign's fact sheet - goal, objective, audience, key message, proof points, KPIs, channels, dates, budget and landing URL - with the calendar the browser builds from it, and get the campaign plan back: objectives, audience, messaging pillars tied to proof points, a role per channel, a title, angle and call to action for every calendar row, success metrics, budget lines and risks. Or send one calendar row and get the channel-ready asset for it: an email, a LinkedIn post, an X thread, an Instagram caption, a Google responsive search ad, a blog article, landing-page copy or a press release, written inside that platform's limits. The natural use is a pipeline: plan the campaign once, then draft every row of the calendar into your content tool.

The model never does the arithmetic. The business-day calendar (phases, dates, dependencies), the UTM-tagged link for every row that drives traffic, weekly budget pacing, the prescan flags and the per-channel limits are all worked out by campaignkit.js, the same file the web page loads, and sent as brief (and, for a draft, item), each a JSON string. See building the body.

Two lanes: the task field

Every request names its lane in task, and one system prompt routes on it.

taskwhat it does
planThe campaign brief (campaign-plan): headline, summary, 1-3 SMART objectives (kpi_ref, target, by), audience (primary, secondary, pains, motivations, where), messaging (core, 2-4 pillars each with a proof_ref, proof_gaps), one channels entry per chosen channel, exactly one calendar entry per calendar row (ref, title, angle, cta, owner), metrics (every KPI with its target, plus leading indicators), budget lines, 2-4 risks and one prescan_responses entry per prescan flag.
draftOne channel-ready asset (draft-content) for the calendar row in item: ref, channel, asset, then the fields the row's limits name - headline_options, body, posts (X), headlines and descriptions (paid search), preheader, meta_title, meta_description, hashtags - and always cta, claims (each tied to an N proof point), notes and the prescan responses.

The lanes chain: a plan titles every calendar row, and its core message, pillars, audience and that row's title, angle and call to action travel as the draft's optional plan field. See handing the plan on. A missing or unknown task is answered as the closest lane (an input with an item is a draft) and the reply's lane says which. Always send task.

Worked example: plan

The Quorvane example from the page: a B2B SaaS launch with six proof points, three KPIs, five channels, an 18,000 budget and a clean brief (no prescan flags). The input, with brief cut short:

{
 "task": "plan",
 "campaign": "Quorvane launch",
 "product": "Quorvane - invoice capture and approval automation for mid-size finance teams",
 "goal": "launch",
 "brief": "{\"objective\":\"Generate 400 demo requests from finance teams at 50-500 person companies between the launch on 2026-10-20 and ..."
}

What that brief string holds, parsed (lists shortened; the real calendar has 22 rows, C1-C22):

{
 "objective": "Generate 400 demo requests from finance teams at 50-500 person companies between the launch on 2026-10-20 and 2026-11-20.",
 "audience": "Finance operations managers and controllers at 50-500 person companies who still key supplier invoices by hand and chase approvals over email at month end.",
 "key_message": "Close the month without chasing a single invoice: Quorvane reads, codes and routes supplier invoices for approval on its own.",
 "brand_voice": "Confident and practical, written for finance people: concrete numbers, no hype, short sentences.",
 "landing_url": "https://quorvane.example.com/launch",
 "utm_campaign": "quorvane-launch",
 "facts": [
  {
   "id": "N1",
   "text": "In a 14-customer pilot, invoice processing time fell by 62% on average."
  },
  {
   "id": "N2",
   "text": "Pilot customers approved 91% of invoices within 48 hours, against 54% before."
  },
  "... 4 more"
 ],
 "kpis": [
  {
   "id": "K1",
   "name": "Demo requests",
   "target": "400",
   "target_num": 400
  },
  {
   "id": "K2",
   "name": "Landing page conversion rate",
   "target": "6%",
   "target_num": 6
  },
  {
   "id": "K3",
   "name": "Paid search cost per demo",
   "target": "$45",
   "target_num": 45
  }
 ],
 "channels": [
  {
   "id": "landing",
   "label": "Landing page"
  },
  {
   "id": "blog",
   "label": "Blog"
  },
  {
   "id": "email",
   "label": "Email"
  },
  {
   "id": "linkedin",
   "label": "LinkedIn"
  },
  {
   "id": "paid_search",
   "label": "Paid search"
  }
 ],
 "dates": {
  "start": "2026-10-05",
  "launch": "2026-10-20",
  "launch_weekday": "Tuesday",
  "end": "2026-11-20",
  "prelaunch_business_days": 11,
  "total_business_days": 35,
  "weeks": 7
 },
 "budget": 18000,
 "pacing": {
  "total": 18000,
  "weeks": 7,
  "per_week": 2571.43,
  "paid_business_days": 24,
  "per_paid_day": 750
 },
 "calendar": [
  {
   "id": "C1",
   "date": "2026-10-05",
   "weekday": "Monday",
   "week": 1,
   "phase": "pre",
   "channel": "ops",
   "asset": "Campaign brief signed off",
   "purpose": "Owners, approvals and the tracking plan agreed before anything is made.",
   "depends_on": []
  },
  {
   "id": "C2",
   "date": "2026-10-05",
   "weekday": "Monday",
   "week": 1,
   "phase": "pre",
   "channel": "linkedin",
   "asset": "Teaser post",
   "purpose": "Tease the problem or the date.",
   "depends_on": []
  },
  {
   "id": "C3",
   "date": "2026-10-12",
   "weekday": "Monday",
   "week": 2,
   "phase": "pre",
   "channel": "landing",
   "asset": "Landing page live",
   "purpose": "The destination every link points to; conversion event and UTMs tested before the first send.",
   "depends_on": []
  },
  "... 19 more"
 ],
 "prescan_flags": []
}

The saved model reply for that input (lists shortened to two entries, long strings cut):

{
 "lane": "plan",
 "headline": "Quorvane's launch campaign targets finance operations managers and controllers at 50-500 person companies to generate 400 demo requests between the 2026-10-20 ...",
 "summary": "The campaign leads with the pain finance teams already feel at month end - keying invoices by hand and chasing approvals over email - and proves the fix with pilot ...",
 "objectives": [
  {
   "text": "Generate 400 demo requests from finance teams at 50-500 person companies between the 2026-10-20 launch and the 2026-11-20 close.",
   "kpi_ref": "K1",
   "target": "400",
   "by": "2026-11-20"
  },
  {
   "text": "Convert landing page visitors into demo requests at a 6% rate across the campaign.",
   "kpi_ref": "K2",
   "target": "6%",
   "by": "2026-11-20"
  },
  "... 1 more"
 ],
 "audience": {
  "primary": "Finance operations managers and controllers at 50-500 person companies who still key supplier invoices by hand and chase approvals over email at month end.",
  "secondary": "",
  "pains": [
   "Keying supplier invoice data by hand takes time away from higher-value work.",
   "Chasing colleagues over email for invoice approvals, especially at month end.",
   "... 1 more"
  ],
  "motivations": [
   "Close the month without manually chasing invoices.",
   "Free up time currently spent on manual data entry.",
   "... 1 more"
  ],
  "where": [
   "Landing page",
   "Blog",
   "... 3 more"
  ]
 },
 "messaging": {
  "core": "Close the month without chasing a single invoice: Quorvane reads, codes and routes supplier invoices for approval on its own.",
  "pillars": [
   {
    "name": "Faster processing",
    "text": "In a 14-customer pilot, invoice processing time fell by 62% on average.",
    "proof_ref": "N1"
   },
   {
    "name": "Faster approvals",
    "text": "Pilot customers approved 91% of invoices within 48 hours, against 54% before.",
    "proof_ref": "N2"
   },
   "... 2 more"
  ],
  "proof_gaps": [
   "No case study or named customer yet to attach to the pilot results.",
   "No proof point in dollar savings, only processing time and approval-rate figures."
  ]
 },
 "channels": [
  {
   "channel": "landing",
   "role": "The single destination every link drives to; takes and converts demo requests.",
   "why": "Landing page conversion rate is K2, so this is where the message has to close.",
   "cadence": "Live from 2026-10-12 (C3), running through the campaign close on 2026-11-20."
  },
  {
   "channel": "blog",
   "role": "Frames the problem before launch, hosts the canonical launch announcement, and adds proof depth afterward.",
   "why": "Carries the fuller argument that shorter formats can't, and other channels link back to it.",
   "cadence": "Teaser 2026-10-13 (C5); launch post 2026-10-20 (C8); proof posts 2026-11-02 and 2026-11-16 (C14, C18)."
  },
  "... 3 more"
 ],
 "calendar": [
  {
   "ref": "C1",
   "title": "Campaign brief sign-off",
   "angle": "Owners, approvals and the tracking plan for the Quorvane launch agreed before anything is built.",
   "cta": "Sign-off from marketing, sales and product on scope, owners and tracking plan.",
   "owner": "campaign lead"
  },
  {
   "ref": "C2",
   "title": "Teaser: the month-end chase",
   "angle": "Hints at the pain of chasing invoice approvals over email at month end, without naming the product.",
   "cta": "Follow for what's coming.",
   "owner": "content lead"
  },
  "... 20 more"
 ],
 "metrics": [
  {
   "kpi_ref": "K1",
   "name": "Demo requests",
   "target": "400",
   "type": "lagging",
   "source": "Demo request form submissions on the landing page, by utm_campaign=quorvane-launch."
  },
  {
   "kpi_ref": "K2",
   "name": "Landing page conversion rate",
   "target": "6%",
   "type": "lagging",
   "source": "Landing page analytics: demo requests divided by visits."
  },
  "... 3 more"
 ],
 "budget": {
  "summary": "The full $18,000 budget funds the paid search flight, paced at about $750 across each of the 24 business days the flight runs, to stay near the $45 cost-per-demo target; ...",
  "lines": [
   {
    "item": "Paid search media spend",
    "amount": 18000,
    "why": "Paces the full budget across the 24 business days of the flight (2026-10-20 to 2026-11-20) at $750/day, in service of the $45 cost-per-demo target (K3)."
   }
  ]
 },
 "risks": [
  {
   "risk": "The landing page (C3) is the dependency for the launch post, launch email and paid search flight; if it slips, C8, C9 and C11 all slip with it.",
   "mitigation": "Build and QA the landing page by 2026-10-12, a full week ahead of the 2026-10-20 launch, so there is slack before the dependent items."
  },
  {
   "risk": "Proof rests on one 14-customer pilot (N1, N2), with no named customer or case study yet.",
   "mitigation": "Lead with the aggregate pilot numbers rather than a named customer, and treat securing a citable case study as a priority for the sustain phase."
  },
  "... 2 more"
 ],
 "prescan_responses": []
}

Worked example: draft

The same campaign's launch email, calendar row C9. The brief is the plan lane's without the calendar; the row, its tracked link and its limits travel in item. This input sends no plan field; see the handoff for one that does.

{
 "task": "draft",
 "campaign": "Quorvane launch",
 "product": "Quorvane - invoice capture and approval automation for mid-size finance teams",
 "goal": "launch",
 "item": "{\"id\":\"C9\",\"date\":\"2026-10-20\",\"weekday\":\"Tuesday\",\"phase\":\"launch\",\"channel\":\"email\",\"channel_label\":\"Email\",\"asset\":\"Launch email\",\"purpose\":\"Announce to the list and drive to the landing page.\",\"link\":\"https://quorvane.example.com/launch?utm_source=newsletter&utm_medium=email&utm_campaign=quorvane-launch&utm_content=c9-launch-email\",\"limits\":[{\"field\":\"headline_options\",\"label\":\"Subject line\",\"hard\":false,\"max\":60},{\"field\":\"preheader\",\"label\":\"Preheader\",\"hard\":false,\"max\":100},{\"field\":\"body\",\"label\":\"Body\",\"hard\":false,\"minWords\":60,\"maxWords\":250}]}",
 "brief": "{\"objective\":\"Generate 400 demo requests from finance teams at 50-500 person companies between the launch on 2026-10-20 and ..."
}

The saved model reply (the body and lists cut short). Every claim quotes the draft and names the proof point it comes from, and the only URL is item.link:

{
 "lane": "draft",
 "ref": "C9",
 "channel": "email",
 "asset": "Launch email",
 "headline_options": [
  "Quorvane is live: stop chasing invoices",
  "Invoices read, coded and routed automatically",
  "... 2 more"
 ],
 "body": "Hi there,\n\nEvery month end, your team keys supplier invoices by hand and chases approvals over email. Quorvane is live today, and it changes that: it reads, codes and routes supplier invoices for approval on its own, so your close is not held up by a stack ...",
 "posts": [],
 "headlines": [],
 "descriptions": [],
 "preheader": "Quorvane cut invoice processing time 62% in a 14-customer pilot. Free 30-day trial, no card.",
 "meta_title": "",
 "meta_description": "",
 "hashtags": [],
 "cta": "Start your free 30-day trial",
 "claims": [
  {
   "text": "In a 14-customer pilot, invoice processing time fell by 62% on average",
   "ref": "N1"
  },
  {
   "text": "Pilot customers approved 91% of invoices within 48 hours, up from 54% before Quorvane",
   "ref": "N2"
  },
  "... 4 more"
 ],
 "notes": [
  "Subject lines split between a relief angle (\"stop chasing invoices\") and a result angle (62% faster); A/B test the two.",
  "Greeting uses \"Hi there\" - swap in a first-name merge tag if the list has one.",
  "... 1 more"
 ],
 "prescan_responses": []
}

A draft for a channel with hard limits looks different. The Pingfold example drafts an X launch thread (row C6, limit: 1-6 posts of at most 280 characters, and the user's extra "Keep it to three posts."). The brief has no proof points, so the reply makes no claims and confirms the two prescan flags sent with the row. Its posts measure 186, 136, 235 characters:

{
 "ref": "C6",
 "channel": "x",
 "posts": [
  "Three outages. In each one, the signal was already there - nobody was watching it. We're taking them apart live, and showing exactly what a platform engineer would have caught, and when.",
  "No highlight reel, no pitch. Engineer to engineer, on incidents that actually happened - what we'd have alerted on, and how much sooner.",
  "If you carry the pager, this one's for you. Register for the live session: https://pingfold.example.com/webinar/api-outage-autopsy?utm_source=x&utm_medium=social&utm_campaign=api-outage-autopsy-webinar&utm_content=c6-launch-day-thread"
 ],
 "hashtags": [],
 "cta": "Register for the webinar",
 "claims": [],
 "prescan_responses": [
  {
   "id": "P6",
   "status": "confirmed",
   "reason": "No facts were provided, so this draft avoids stats, customers or results and only repeats the outage count already given in the key message."
  },
  "... 1 more"
 ]
}

Base URL and the envelope

Every endpoint lives under https://api.skillsafe.ai/v1/app-api and every response uses the same envelope, so one helper covers the whole API:

{ "ok": true,  "data":  { ... } }
{ "ok": false, "error": { "code": "...", "message": "...", "status": 402, "details": { ... } } }

The token is minted for this app (the guest endpoint takes {"slug":"campaign-desk"} in its body), so no slug header is needed afterwards. Send it as Authorization: Bearer ….

The input object IS the request body. There is no {"input": …} wrapper. A wrapped body returns a 200 with an unknown field 'input' warning, and the model never sees your campaign.

Error codes

codestatuswhat to do
unauthorized401The token is missing, malformed or expired. Get a new one from the token page.
payment_required402The balance is below min_credits. Call /estimate first and top up.
forbidden403The token is valid but not for this app, or a guest token tried a metered run.
not_found404Unknown job id, unknown collection, or the app slug does not exist.
conflict409The same Idempotency-Key was replayed with a different body. Change the key or send the original input.
validation_error422A field is the wrong type. Every field is a string: brief, item and plan must be JSON-encoded strings, not objects.
rate_limited429Too many requests. Back off and retry; do not tight-loop.
internal5xxA server-side failure. Retry with the SAME Idempotency-Key so you are not billed twice.

1. Get a token

The easiest route is the token page: it shows the token this browser already holds, with Copy token and Copy shell export buttons, and a sign-in button for a personal token. A guest token can call /me and /estimate; both lanes are metered, so a run needs a personal token.

# The token page is the shortest path. It shows the token this browser holds and
# hands you a ready-made shell export:
#
#   https://campaign-desk.skillsafe.ai/tokens.html
#   export SKILLSAFE_TOKEN="..."
#
# To mint a guest token from the command line instead. A guest token is enough
# for /me and /estimate; both lanes (plan and draft) need a personal token
# from signing in.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
  -H "Content-Type: application/json" -d '{"slug":"campaign-desk"}'
# {"ok":true,"data":{"token":"…","subject_type":"guest"}}

2. A tiny client

One helper that sends the token, unwraps data and raises on ok: false.

# Every call is the same three things: the base URL, your bearer token,
# and a JSON body. Keep the token in a shell variable.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="campaign-desk"
TOKEN="$SKILLSAFE_TOKEN"   # from https://campaign-desk.skillsafe.ai/tokens.html

call() {                  # call <path> [json-body]
  if [ -n "$2" ]; then
    curl -sS -X POST "$BASE/$1" \
      -H "Authorization: Bearer $TOKEN" \
      -H "Content-Type: application/json" \
      -d "$2"
  else
    curl -sS "$BASE/$1" -H "Authorization: Bearer $TOKEN"
  fi
}

3. Check the session and the balance

call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}

4. Price the run (free)

/estimate returns the model binding and the credits a run would reserve. It creates no job and charges nothing. It also does not validate the body, so check the shape yourself: an object whose every value is a string, task one of the two lanes, campaign, product, goal and brief present, brief a JSON string, and for a draft an item. Re-estimate per lane - a plan and a draft reserve different amounts.

# body.json is the input object itself - no {"input": ...} wrapper. Build it with
# make-body.js below. estimate does not validate it, so check the shape first:
python3 -c 'import json;b=json.load(open("body.json"));assert isinstance(b,dict) and b.get("task") in ("plan","draft") and all(isinstance(v,str) for v in b.values()) and all(b.get(k,"").strip() for k in ("campaign","product","goal","brief")) and isinstance(json.loads(b["brief"]),dict) and (b["task"]=="plan" or isinstance(json.loads(b.get("item","null")),dict))'
INPUT=$(cat body.json)
LANE=$(printf '%s' "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["task"])')

call estimate "$INPUT"
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
#   "markup_bps":1000,"hold_credits":...,"min_credits":...,"sponsor_enabled":false,
#   "input_checked":true,"warnings":[]}}
#
# estimate creates no job and charges nothing. hold_credits is RESERVED, not the
# price; charged_credits after the run is usually far lower.

Building the body

Do not hand-assemble brief or item. Load campaignkit.js (it runs unchanged in Node) and let it read your fact sheet exactly as the page does:

// make-body.js - build the run body with the SAME engine the web page uses.
// Save campaignkit.js from https://campaign-desk.skillsafe.ai/campaignkit.js next to this file.
const fs = require("fs");
const K = require("./campaignkit.js");

const A = K.analyze({
  name: "Quorvane launch",
  product: "Quorvane - invoice capture and approval automation for mid-size finance teams",
  goal: "launch",                                  // launch | leadgen | awareness | event
  objective: "Generate 400 demo requests from finance teams at 50-500 person companies between the launch on 2026-10-20 and 2026-11-20.",
  audience: "Finance operations managers and controllers at 50-500 person companies ...",
  message: "Close the month without chasing a single invoice: ...",
  facts: fs.readFileSync("facts.txt", "utf8"),     // one proof point per line -> N1, N2, ...
  kpis: "Demo requests: 400\nLanding page conversion rate: 6%\nPaid search cost per demo: $45",  // -> K1..K3
  channels: ["landing", "blog", "email", "linkedin", "paid_search"],
  start: "2026-10-05", launch: "2026-10-20", end: "2026-11-20",
  budget: "18000",
  url: "https://quorvane.example.com/launch",
  voice: "Confident and practical, written for finance people: concrete numbers, no hype, short sentences."
});
if (A.errors.length) throw new Error(A.errors.join(" "));

const lane = process.argv[2] || "plan";           // "plan" or "draft"
const row = process.argv[3] || "C9";              // draft only: the calendar row to write
const plan = fs.existsSync("handoff.json") ? fs.readFileSync("handoff.json", "utf8") : null;  // optional
const body = K.buildInput(lane, A, { item: row, plan: plan, extra: "" });
if (!body) throw new Error(lane === "draft" ? "row " + row + " is not a draftable calendar row" : "the sheet has no calendar yet");
fs.writeFileSync("body.json", JSON.stringify(K.mustBeObject(body)));
console.log(lane, A.calendar.length, "calendar rows,", A.flags.length, "prescan flags");

buildInput returns null when the sheet has errors or no calendar, and for a draft when the row is not draftable: ops milestones, the paid mid-flight review and flight end, and the live webinar session itself are calendar rows but not assets.

The input fields, every one a string (input-schema.json requires the first five):

fieldrequiredwhat it holds
taskyesplan or draft.
campaignyesThe campaign name (the product when no name is given).
productyesWhat is being marketed, in a few words.
goalyeslaunch, leadgen, awareness or event.
briefyesJSON string of the pack: objective, audience, key_message, brand_voice, landing_url (empty unless it is a valid https URL), utm_campaign, facts (proof points N1..Nn, each {id, text}), kpis (K1..Kn, each {id, name, target, target_num}), channels ({id, label} in calendar order), dates (start, launch, launch_weekday, end, prelaunch_business_days, total_business_days, weeks), budget (a number or null), pacing (per week and per paid business day, or null) and prescan_flags (P1..Pn, each {id, severity, category, ref, message}). In the plan lane it also holds calendar: rows C1..Cn, each {id, date, weekday, week, phase, channel, asset, purpose, depends_on}, with phase one of pre, launch, sustain, wrap. The plan lane sends every flag; the draft lane sends only the proof, tracking and audience flags plus any flag that names the row.
itemdraft onlyJSON string of one calendar row: id, date, weekday, phase, channel, channel_label, asset, purpose, link (the UTM-tagged URL for this row, empty for early teasers that go out before the landing page is live, or when there is no valid landing URL) and limits: the fields to fill, each {field, label, hard} plus max characters, minWords/maxWords or minItems/maxItems. hard: true is a platform limit.
planno (draft only)JSON string from an earlier plan: {core, pillars, audience, row: {ref, title, angle, cta}}. See the handoff.
extrano (draft only)Your own instruction for this draft, up to 1,000 characters (for example "Keep it to three posts.").
retry_notenoOnly on a reformat retry, when the previous reply could not be parsed.

Refs are the glue between the two halves: calendar rows C1.., proof points N1.., KPIs K1.. and prescan flags P1... The model cites only ids that are in the input, and everything it writes can be checked against them.

The limits campaignkit.js puts in item.limits, by channel:

channelfields and limitskind
xposts: 1-6, at most 280 characters each, link includedhard
linkedinbody: at most 3,000 charactershard
instagrambody: at most 2,200 characters; hashtags: at most 5hard
paid_searchheadlines: 3-15, at most 30 characters; descriptions: 2-4, at most 90hard
email, webinarsubject lines 60, preheader 100 (email), body 60-250 wordshouse guideline
paid_socialheadlines 40, primary text 500house guideline
blogSEO title 60, meta description 155, article 400-1,400 wordshouse guideline
landinghero headline 70, SEO title 60, meta description 155, copy 150-900 wordshouse guideline
prrelease 250-700 wordshouse guideline

5. Run it, then poll

POST /run returns a job_id; poll GET /jobs/{id} until it is terminal. Send an Idempotency-Key built from the lane and the input so a retried request returns the same job instead of billing a second run.

# Always send an Idempotency-Key derived from the lane and the input. A retried
# request with the same key returns the SAME job instead of billing a second run.
KEY="campaign-desk:$LANE:$(printf '%s' "$INPUT" | shasum -a 256 | cut -c1-16):a1"

JOB=$(curl -sS -X POST "$BASE/run" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -d "$INPUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["job_id"])')

while :; do
  OUT=$(call "jobs/$JOB")
  STATUS=$(printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["status"])')
  [ "$STATUS" = "succeeded" ] && break
  [ "$STATUS" = "failed" ] && echo "$OUT" && exit 1
  sleep 2
done

# {"ok":true,"data":{"job_id":"job_...","status":"succeeded",
#   "output":{"output":"{\"lane\":\"plan\",\"headline\":\"Quorvane's launch campaign targets ...\", ...}"},
#   "charged_credits":...,"truncated":false}}
printf '%s' "$OUT" | python3 -c 'import sys,json;print(json.load(sys.stdin)["data"]["output"]["output"])' > reply.json

6. Or stream it

# Server-sent events. `delta` events carry chunks of the reply; `done` carries the
# status, charged_credits and the truncated flag.
curl -N -X POST "$BASE/run-stream" \
  -H "Authorization: Bearer $TOKEN" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $KEY" \
  -H "Accept: text/event-stream" \
  -d "$INPUT"

# event: job    {"job_id":"job_..."}
# event: delta  {"text":"{\"lane\":\"plan\",\"headline\":\"Quorvane's launch campaign"}
# event: done   {"status":"succeeded","charged_credits":...,"truncated":false}

7. Parse the reply

The reply is one JSON object in data.output.output. Strip anything outside the outermost braces.

# reply.json holds data.output.output from step 5. Strip any fence, keep the object:
python3 - <<'EOF'
import json
t = open("reply.json").read()
r = json.loads(t[t.index("{"):t.rindex("}") + 1])
if r.get("lane") == "draft":
    print(r["ref"], r["channel"], r["asset"], "| cta:", r["cta"])
    for p in r["posts"] or [r["body"]]:
        print(len(p), "chars |", p[:80])
    for c in r["claims"]:
        print(c["ref"], "|", c["text"])
else:
    print(r["headline"])
    for c in r["calendar"]:
        print(c["ref"], "|", c["title"], "->", c["cta"])
    print("budget:", sum(l["amount"] for l in r["budget"]["lines"]))
EOF

Invariants worth asserting

These are the checks the web page runs on every reply (recon.js). A caller should hold a reply to them before publishing anything.

# check.py - the structural invariants, given the body you sent and the parsed reply.
import json

def check(body, reply):
    brief, bad = json.loads(body["brief"]), []
    flags = [f["id"] for f in brief["prescan_flags"]]
    answered = [p["id"] for p in reply["prescan_responses"]]
    if sorted(answered) != sorted(flags):
        bad.append("prescan flags answered %s, sent %s" % (answered, flags))
    facts = {f["id"] for f in brief["facts"]}
    if reply["lane"] == "plan":
        rows = [c["id"] for c in brief["calendar"]]
        if [c["ref"] for c in reply["calendar"]] != rows:
            bad.append("calendar does not fill every row once, in order")
        if {c["channel"] for c in reply["channels"]} != {c["id"] for c in brief["channels"]}:
            bad.append("channel plan differs from the chosen channels")
        for k in brief["kpis"]:
            if not any(m["kpi_ref"] == k["id"] for m in reply["metrics"]):
                bad.append(k["id"] + " missing from metrics")
        spent = sum(l["amount"] or 0 for l in reply["budget"]["lines"])
        if spent > (brief["budget"] or 0) * 1.005:
            bad.append("budget lines add up to %s" % spent)
    else:
        item = json.loads(body["item"])
        if reply["ref"] != item["id"]:
            bad.append("draft is for " + reply["ref"])
        for lim in item["limits"]:
            v = reply[lim["field"]]
            for s in (v if isinstance(v, list) else [v]):
                if lim.get("max") and len(s) > lim["max"] and lim["hard"]:
                    bad.append("%s over %d: %d" % (lim["field"], lim["max"], len(s)))
            if isinstance(v, list) and lim.get("maxItems") and len(v) > lim["maxItems"]:
                bad.append("%s: %d items, at most %d" % (lim["field"], len(v), lim["maxItems"]))
        for c in reply["claims"]:
            if c["ref"] not in facts:
                bad.append("claim not tied to a proof point: " + c["text"])
    return bad

Handing the plan on

To draft a row the plan has titled, send the draft body for that row with plan set to JSON.stringify({core, pillars, audience, row: {ref, title, angle, cta}}), built from the plan reply: core is messaging.core, pillars are messaging.pillars (name, text, proof_ref), audience is audience.primary, and row is that row's calendar entry without its owner - exactly what the page's "Draft this" button sends (Recon.handoff(plan, "C9")). The draft then follows the row's title, angle and call to action. For the Quorvane plan above and row C9 (pillars shortened):

{
 "core": "Close the month without chasing a single invoice: Quorvane reads, codes and routes supplier invoices for approval on its own.",
 "pillars": [
  {
   "name": "Faster processing",
   "text": "In a 14-customer pilot, invoice processing time fell by 62% on average.",
   "proof_ref": "N1"
  },
  {
   "name": "Faster approvals",
   "text": "Pilot customers approved 91% of invoices within 48 hours, against 54% before.",
   "proof_ref": "N2"
  },
  "... 2 more"
 ],
 "audience": "Finance operations managers and controllers at 50-500 person companies who still key supplier invoices by hand and chase approvals over email at month end.",
 "row": {
  "ref": "C9",
  "title": "Close the month without chasing a single invoice",
  "angle": "Announces Quorvane to the full list and drives to the landing page.",
  "cta": "Request a demo."
 }
}

Save that object as handoff.json and run node make-body.js draft C9: the body gains a plan string alongside item. Loop over the plan's calendar to draft every draftable row, one run per row.

The output contract

Every key shown is present in a reply; arrays may be empty and strings may be empty. Strings are plain text (no Markdown); a draft body may contain \n line breaks.

plan

{"lane":"plan","headline":"...","summary":"...",
 "objectives":[{"text":"...","kpi_ref":"K1","target":"400","by":"2026-11-20"}],
 "audience":{"primary":"...","secondary":"","pains":["..."],"motivations":["..."],"where":["..."]},
 "messaging":{"core":"...","pillars":[{"name":"...","text":"...","proof_ref":"N1"}],"proof_gaps":["..."]},
 "channels":[{"channel":"email","role":"...","why":"...","cadence":"..."}],
 "calendar":[{"ref":"C1","title":"...","angle":"...","cta":"...","owner":"..."}],
 "metrics":[{"kpi_ref":"K1","name":"...","target":"400","type":"leading|lagging","source":"..."}],
 "budget":{"summary":"...","lines":[{"item":"...","amount":0,"why":"..."}]},
 "risks":[{"risk":"...","mitigation":"..."}],
 "prescan_responses":[{"id":"P1","status":"confirmed|dismissed","reason":"..."}]}

draft

{"lane":"draft","ref":"C9","channel":"email","asset":"Launch email",
 "headline_options":["..."],"body":"...","posts":[],"headlines":[],"descriptions":[],
 "preheader":"...","meta_title":"","meta_description":"","hashtags":[],
 "cta":"...","claims":[{"text":"...","ref":"N1"}],"notes":["..."],
 "prescan_responses":[{"id":"P6","status":"confirmed|dismissed","reason":"..."}]}

Which draft fields are filled depends on the channel: posts only for x; headlines and descriptions only for paid_search (whose body is empty, like X's); preheader for email and webinar; meta_title and meta_description for blog and landing; hashtags 0-5 for Instagram, 0-3 for LinkedIn and X, [] elsewhere. cta, claims, notes (1-4) and prescan_responses are always filled.

Truncation and partial results

When the balance sits between min_credits and hold_credits, the run is not refused: it executes with a reduced output cap and returns truncated: true. What you hold is a prefix of the reply. The web page closes the cut-off JSON (Recon.closeJson), shows the sections that arrived and says how many it recovered - out of eleven for a plan and five (ref, cta, claims, notes, prescan_responses) for a draft. A cut-off plan usually loses its later calendar rows, metrics and risks; a cut-off draft may end mid-body, before its claims. From code, check the flag before treating a reply as complete - never publish a truncated draft - then resubmit with the attempt suffix on the Idempotency-Key incremented.