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.
| task | what it does |
|---|---|
plan | The 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. |
draft | One 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
| code | status | what to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed or expired. Get a new one from the token page. |
payment_required | 402 | The balance is below min_credits. Call /estimate first and top up. |
forbidden | 403 | The token is valid but not for this app, or a guest token tried a metered run. |
not_found | 404 | Unknown job id, unknown collection, or the app slug does not exist. |
conflict | 409 | The same Idempotency-Key was replayed with a different body. Change the key or send the original input. |
validation_error | 422 | A field is the wrong type. Every field is a string: brief, item and plan must be JSON-encoded strings, not objects. |
rate_limited | 429 | Too many requests. Back off and retry; do not tight-loop. |
internal | 5xx | A 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"}}
# Open https://campaign-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest", data=b'{"slug": "campaign-desk"}', method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Open https://campaign-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "campaign-desk" }),
});
const TOKEN = (await res.json()).data.token;
// Open https://campaign-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
guestReq, _ := http.NewRequest(http.MethodPost,
"https://api.skillsafe.ai/v1/app-api/guest", bytes.NewReader([]byte(`{"slug":"campaign-desk"}`)))
guestReq.Header.Set("Content-Type", "application/json")
guestRes, err := http.DefaultClient.Do(guestReq)
if err != nil {
panic(err)
}
defer guestRes.Body.Close()
var guest struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
_ = json.NewDecoder(guestRes.Body).Decode(&guest)
fmt.Println(guest.Data.Token)
// Open https://campaign-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
var http = HttpClient.newHttpClient();
var guestReq = HttpRequest.newBuilder(URI.create("https://api.skillsafe.ai/v1/app-api/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"campaign-desk\"}"))
.build();
HttpResponse<String> guest = http.send(guestReq, HttpResponse.BodyHandlers.ofString());
System.out.println(guest.body()); // {"ok":true,"data":{"token":"…","subject_type":"guest"}}
# Open https://campaign-desk.skillsafe.ai/tokens.html and press "Copy token",
# or mint a guest token here. A guest token can call /me and /estimate but
# cannot start a metered run.
require "json"
require "net/http"
require "uri"
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.generate({ slug: "campaign-desk" })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
// Open https://campaign-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
$ch = curl_init("https://api.skillsafe.ai/v1/app-api/guest");
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode(["slug" => "campaign-desk"]));
curl_setopt($ch, CURLOPT_HTTPHEADER, ["Content-Type: application/json"]);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$guest = json_decode(curl_exec($ch), true);
curl_close($ch);
echo $guest["data"]["token"];
// Open https://campaign-desk.skillsafe.ai/tokens.html and press "Copy token",
// or mint a guest token here. A guest token can call /me and /estimate but
// cannot start a metered run.
using var http = new HttpClient();
var guestReq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/guest");
guestReq.Content = new StringContent("{\"slug\":\"campaign-desk\"}", Encoding.UTF8, "application/json");
var guestRes = await http.SendAsync(guestReq);
var guest = await guestRes.Content.ReadFromJsonAsync<JsonElement>();
Console.WriteLine(guest.GetProperty("data").GetProperty("token").GetString());
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
}
import json, os, urllib.error, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "campaign-desk"
TOKEN = os.environ.get("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://campaign-desk.skillsafe.ai/tokens.html
def call(path, body=None):
"""Returns the unwrapped `data`, or raises with the API error code."""
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(f"{BASE}/{path}", data=data, method="POST" if body is not None else "GET")
req.add_header("Authorization", f"Bearer {TOKEN}")
if body is not None:
req.add_header("Content-Type", "application/json")
try:
with urllib.request.urlopen(req) as r:
payload = json.load(r)
except urllib.error.HTTPError as e:
payload = json.load(e)
if not payload.get("ok"):
err = payload.get("error", {})
raise RuntimeError(f"{err.get('code')}: {err.get('message')}")
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "campaign-desk";
const TOKEN = "YOUR_TOKEN"; // from https://campaign-desk.skillsafe.ai/tokens.html
async function call(path, body) {
const res = await fetch(`${BASE}/${path}`, {
method: body ? "POST" : "GET",
headers: {
Authorization: `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {}),
},
body: body ? JSON.stringify(body) : undefined,
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bufio"
"bytes"
"crypto/sha256"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "campaign-desk"
)
var token = os.Getenv("SKILLSAFE_TOKEN") // from https://campaign-desk.skillsafe.ai/tokens.html
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(path string, body any) (json.RawMessage, error) {
method := http.MethodGet
var rdr io.Reader
if body != nil {
method = http.MethodPost
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+"/"+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class CampaignDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "campaign-desk";
static final String TOKEN = System.getenv().getOrDefault("SKILLSAFE_TOKEN", "YOUR_TOKEN");
static final HttpClient HTTP = HttpClient.newHttpClient();
static String call(String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder(URI.create(BASE + "/" + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody != null) {
b.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody));
} else {
b.GET();
}
HttpResponse<String> res = HTTP.send(b.build(), HttpResponse.BodyHandlers.ofString());
// The envelope is always {"ok":true,"data":...} or {"ok":false,"error":...}.
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "campaign-desk"
TOKEN = ENV.fetch("SKILLSAFE_TOKEN", "YOUR_TOKEN") # from https://campaign-desk.skillsafe.ai/tokens.html
def call(path, body = nil)
uri = URI("#{BASE}/#{path}")
req = body ? Net::HTTP::Post.new(uri) : Net::HTTP::Get.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload['error']['code']}: #{payload['error']['message']}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "campaign-desk";
define("TOKEN", getenv("SKILLSAFE_TOKEN") ?: "YOUR_TOKEN"); // from /tokens.html
function call(string $path, ?array $body = null) {
$ch = curl_init(BASE . "/" . $path);
$headers = ["Authorization: Bearer " . TOKEN];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
curl_setopt($ch, CURLOPT_POST, true);
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$payload = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http.Json;
using System.Text.Json;
static class CampaignDesk
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "campaign-desk";
static readonly string Token =
Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Http = new();
public static async Task<JsonElement> Call(string path, object? body = null)
{
var req = new HttpRequestMessage(body is null ? HttpMethod.Get : HttpMethod.Post, $"{Base}/{path}");
req.Headers.Add("Authorization", $"Bearer {Token}");
if (body is not null) req.Content = JsonContent.Create(body);
var res = await Http.SendAsync(req);
var payload = await res.Content.ReadFromJsonAsync<JsonElement>();
if (!payload.GetProperty("ok").GetBoolean())
{
var e = payload.GetProperty("error");
throw new Exception($"{e.GetProperty("code")}: {e.GetProperty("message")}");
}
return payload.GetProperty("data");
}
}
3. Check the session and the balance
call me
# {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
print(me["subject_type"], me.get("credits"))
const me = await call("me");
console.log(me.subject_type, me.credits);
raw, err := call("me", nil)
if err != nil {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
_ = json.Unmarshal(raw, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(call("me", null));
// {"ok":true,"data":{"subject_type":"user","username":"you","credits":51234}}
me = call("me")
puts "#{me['subject_type']} #{me['credits']}"
<?php
$me = call("me");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await CampaignDesk.Call("me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
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.
INPUT = json.load(open("body.json")) # built by make-body.js below
assert isinstance(INPUT, dict) and INPUT.get("task") in ("plan", "draft")
assert all(isinstance(v, str) for v in INPUT.values()) # every field is a string
assert all(INPUT.get(k, "").strip() for k in ("campaign", "product", "goal", "brief"))
assert isinstance(json.loads(INPUT["brief"]), dict) # brief is a JSON STRING
if INPUT["task"] == "draft":
assert isinstance(json.loads(INPUT["item"]), dict) # so is item
est = call("estimate", INPUT)
print(est["model"], est["model_alias"], est["hold_credits"], est.get("warnings"))
me = call("me")
if me.get("credits", 0) < est["min_credits"]:
raise SystemExit("top up first: balance is below min_credits")
import { readFileSync } from "node:fs";
const INPUT = JSON.parse(readFileSync("body.json", "utf8")); // built by make-body.js below
if (!INPUT || typeof INPUT !== "object" || !["plan", "draft"].includes(INPUT.task)) throw new Error("bad task");
for (const [k, v] of Object.entries(INPUT)) if (typeof v !== "string") throw new Error(k + " must be a string");
for (const k of ["campaign", "product", "goal", "brief"]) if (!INPUT[k]) throw new Error(k + " is required");
if (INPUT.task === "draft" && !INPUT.item) throw new Error("the draft lane needs item");
const est = await call("estimate", INPUT);
console.log(est.model, est.model_alias, est.hold_credits, est.warnings);
const me = await call("me");
if ((me.credits ?? 0) < est.min_credits) throw new Error("top up first");
raw, _ := os.ReadFile("body.json") // built by make-body.js below
var input map[string]string // every field is a string, brief and item included
if err := json.Unmarshal(raw, &input); err != nil {
panic("body.json must be an object of strings: " + err.Error())
}
lane := input["task"] // "plan" or "draft"
if lane != "plan" && lane != "draft" {
panic("task must be plan or draft")
}
for _, k := range []string{"campaign", "product", "goal", "brief"} {
if strings.TrimSpace(input[k]) == "" {
panic(k + " is required")
}
}
est, err := call("estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(est)) // model, model_alias, markup_bps, hold_credits, min_credits
String input = Files.readString(Path.of("body.json")); // built by make-body.js below
String lane = input.replaceAll("(?s).*\"task\"\\s*:\\s*\"(plan|draft)\".*", "$1");
if (!lane.equals("plan") && !lane.equals("draft")) throw new IllegalStateException("task must be plan or draft");
String est = call("estimate", input);
System.out.println(est); // model, model_alias, markup_bps, hold_credits, min_credits
INPUT = JSON.parse(File.read("body.json")) # built by make-body.js below
raise "bad task" unless %w[plan draft].include?(INPUT["task"])
INPUT.each { |k, v| raise "#{k} must be a string" unless v.is_a?(String) }
%w[campaign product goal brief].each { |k| raise "#{k} is required" if INPUT[k].to_s.strip.empty? }
raise "the draft lane needs item" if INPUT["task"] == "draft" && INPUT["item"].to_s.empty?
est = call("estimate", INPUT)
puts est["model"], est["model_alias"], est["hold_credits"]
<?php
$input = json_decode(file_get_contents("body.json"), true); // built by make-body.js below
if (!is_array($input) || !in_array($input["task"] ?? "", ["plan", "draft"], true)) { throw new Exception("bad task"); }
foreach ($input as $k => $v) { if (!is_string($v)) { throw new Exception("$k must be a string"); } }
foreach (["campaign", "product", "goal", "brief"] as $k) { if (trim($input[$k] ?? "") === "") { throw new Exception("$k is required"); } }
$est = call("estimate", $input);
echo $est["model"], " ", $est["model_alias"], " ", $est["hold_credits"], PHP_EOL;
var input = File.ReadAllText("body.json"); // built by make-body.js below
using var doc = JsonDocument.Parse(input);
var lane = doc.RootElement.GetProperty("task").GetString();
if (lane != "plan" && lane != "draft") throw new Exception("task must be plan or draft");
foreach (var p in doc.RootElement.EnumerateObject())
if (p.Value.ValueKind != JsonValueKind.String) throw new Exception($"{p.Name} must be a string");
var est = await CampaignDesk.Call("estimate", doc.RootElement);
Console.WriteLine(est); // model, model_alias, markup_bps, hold_credits, min_credits
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):
| field | required | what it holds |
|---|---|---|
task | yes | plan or draft. |
campaign | yes | The campaign name (the product when no name is given). |
product | yes | What is being marketed, in a few words. |
goal | yes | launch, leadgen, awareness or event. |
brief | yes | JSON 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. |
item | draft only | JSON 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. |
plan | no (draft only) | JSON string from an earlier plan: {core, pillars, audience, row: {ref, title, angle, cta}}. See the handoff. |
extra | no (draft only) | Your own instruction for this draft, up to 1,000 characters (for example "Keep it to three posts."). |
retry_note | no | Only 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:
| channel | fields and limits | kind |
|---|---|---|
x | posts: 1-6, at most 280 characters each, link included | hard |
linkedin | body: at most 3,000 characters | hard |
instagram | body: at most 2,200 characters; hashtags: at most 5 | hard |
paid_search | headlines: 3-15, at most 30 characters; descriptions: 2-4, at most 90 | hard |
email, webinar | subject lines 60, preheader 100 (email), body 60-250 words | house guideline |
paid_social | headlines 40, primary text 500 | house guideline |
blog | SEO title 60, meta description 155, article 400-1,400 words | house guideline |
landing | hero headline 70, SEO title 60, meta description 155, copy 150-900 words | house guideline |
pr | release 250-700 words | house 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
import hashlib, time
lane = INPUT["task"] # "plan" or "draft"
digest = hashlib.sha256(json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16]
key = f"campaign-desk:{lane}:{digest}:a1"
req = urllib.request.Request(f"{BASE}/run", data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", f"Bearer {TOKEN}")
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.load(r)["data"]["job_id"]
while True:
job = call(f"jobs/{job_id}")
if job["status"] in ("succeeded", "failed"):
break
time.sleep(2)
if job["status"] == "failed":
raise RuntimeError(job.get("error"))
reply = json.loads(job["output"]["output"])
print(reply["lane"], reply.get("headline") or reply.get("ref"))
print("charged", job.get("charged_credits"), "truncated", job.get("truncated"))
import { createHash } from "node:crypto";
const lane = INPUT.task; // "plan" or "draft"
const digest = createHash("sha256").update(JSON.stringify(INPUT)).digest("hex").slice(0, 16);
const key = `campaign-desk:${lane}:${digest}:a1`;
const started = await fetch(`${BASE}/run`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key },
body: JSON.stringify(INPUT),
}).then((r) => r.json());
let job = started.data;
while (job.status !== "succeeded" && job.status !== "failed") {
await new Promise((r) => setTimeout(r, 2000));
job = await call(`jobs/${job.job_id}`);
}
if (job.status === "failed") throw new Error(JSON.stringify(job.error));
const reply = JSON.parse(job.output.output);
console.log(reply.lane, reply.headline ?? reply.ref, job.charged_credits, job.truncated);
body, _ := json.Marshal(input)
sum := sha256.Sum256(body)
key := fmt.Sprintf("campaign-desk:%s:%x:a1", lane, sum[:8])
req, _ := http.NewRequest(http.MethodPost, base+"/run", 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 {
panic(err)
}
var started struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
_ = json.NewDecoder(res.Body).Decode(&started)
res.Body.Close()
var jobOutput string
for {
raw, err := call("jobs/"+started.Data.JobID, nil)
if err != nil {
panic(err)
}
var job struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
Charged int `json:"charged_credits"`
Truncated bool `json:"truncated"`
}
_ = json.Unmarshal(raw, &job)
if job.Status == "succeeded" {
jobOutput = job.Output.Output
fmt.Println(job.Charged, job.Truncated)
break
}
if job.Status == "failed" {
panic(string(raw))
}
time.Sleep(2 * time.Second)
}
// lane was read and checked in step 4: "plan" or "draft".
String key = "campaign-desk:" + lane + ":" + sha256Hex(input).substring(0, 16) + ":a1";
HttpRequest run = HttpRequest.newBuilder(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
String started = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
String jobId = started.replaceAll(".*\"job_id\":\"([^\"]+)\".*", "$1");
while (true) {
String job = call("jobs/" + jobId, null);
if (job.contains("\"status\":\"succeeded\"")) { System.out.println(job); break; }
if (job.contains("\"status\":\"failed\"")) throw new RuntimeException(job);
Thread.sleep(2000);
}
// Parse data.output.output (a string holding the reply JSON) with your JSON library.
// sha256Hex: HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256").digest(input.getBytes(UTF_8)))
require "digest"
lane = INPUT["task"]
key = "campaign-desk:#{lane}:#{Digest::SHA256.hexdigest(JSON.generate(INPUT))[0, 16]}:a1"
uri = URI("#{BASE}/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = key
req.body = JSON.generate(INPUT)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
until %w[succeeded failed].include?(job["status"])
sleep 2
job = call("jobs/#{job['job_id']}")
end
raise job.inspect if job["status"] == "failed"
reply = JSON.parse(job["output"]["output"])
puts reply["lane"], reply["headline"] || reply["ref"]
<?php
$key = "campaign-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key],
CURLOPT_RETURNTRANSFER => true,
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
while (!in_array($job["status"], ["succeeded", "failed"], true)) {
sleep(2);
$job = call("jobs/" . $job["job_id"]);
}
$reply = json_decode($job["output"]["output"], true);
echo $reply["lane"], " ", $reply["headline"] ?? $reply["ref"], PHP_EOL;
using System.Security.Cryptography;
var json = JsonSerializer.Serialize(doc.RootElement);
var key = $"campaign-desk:{lane}:" + Convert.ToHexString(SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(json)))[..16].ToLower() + ":a1";
var req = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run");
req.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
req.Headers.Add("Idempotency-Key", key);
req.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
var started = await (await new HttpClient().SendAsync(req)).Content.ReadFromJsonAsync<JsonElement>();
var jobId = started.GetProperty("data").GetProperty("job_id").GetString();
JsonElement job;
while (true)
{
job = await CampaignDesk.Call($"jobs/{jobId}");
var status = job.GetProperty("status").GetString();
if (status == "succeeded") break;
if (status == "failed") throw new Exception(job.ToString());
await Task.Delay(2000);
}
var output = job.GetProperty("output").GetProperty("output").GetString()!;
Console.WriteLine($"{lane} charged {job.GetProperty("charged_credits")}");
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}
req = urllib.request.Request(f"{BASE}/run-stream", data=json.dumps(INPUT).encode(), method="POST")
for h, v in (("Authorization", f"Bearer {TOKEN}"), ("Content-Type", "application/json"),
("Idempotency-Key", key), ("Accept", "text/event-stream")):
req.add_header(h, v)
raw, done, event = "", {}, None
with urllib.request.urlopen(req) as stream:
for line in stream:
line = line.decode().rstrip("\n")
if line.startswith("event: "):
event = line[7:]
elif line.startswith("data: ") and event == "delta":
raw += json.loads(line[6:]).get("text", "")
elif line.startswith("data: ") and event == "done":
done = json.loads(line[6:])
print(done.get("status"), done.get("charged_credits"), done.get("truncated"))
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: { Authorization: `Bearer ${TOKEN}`, "Content-Type": "application/json", "Idempotency-Key": key, Accept: "text/event-stream" },
body: JSON.stringify(INPUT),
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null, done = null;
for (;;) {
const { value, done: end } = await reader.read();
if (end) break;
buf += dec.decode(value, { stream: true });
let i;
while ((i = buf.indexOf("\n")) >= 0) {
const line = buf.slice(0, i); buf = buf.slice(i + 1);
if (line.startsWith("event: ")) event = line.slice(7);
else if (line.startsWith("data: ") && event === "delta") raw += JSON.parse(line.slice(6)).text || "";
else if (line.startsWith("data: ") && event === "done") done = JSON.parse(line.slice(6));
}
}
console.log(done, raw.length);
req, _ = http.NewRequest(http.MethodPost, base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, err = http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var raw strings.Builder
event := ""
sc := bufio.NewScanner(res.Body)
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event: "):
event = line[7:]
case strings.HasPrefix(line, "data: ") && event == "delta":
var d struct{ Text string `json:"text"` }
_ = json.Unmarshal([]byte(line[6:]), &d)
raw.WriteString(d.Text)
case strings.HasPrefix(line, "data: ") && event == "done":
fmt.Println("done:", line[6:])
}
}
HttpRequest stream = HttpRequest.newBuilder(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(input)).build();
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines()).body().forEach(line -> {
// "event: delta" lines are followed by "data: {\"text\":...}"; "event: done" by the status.
if (line.startsWith("data: ")) System.out.println(line.substring(6));
});
uri = URI("#{BASE}/run-stream")
req = Net::HTTP::Post.new(uri)
{ "Authorization" => "Bearer #{TOKEN}", "Content-Type" => "application/json",
"Idempotency-Key" => key, "Accept" => "text/event-stream" }.each { |k, v| req[k] = v }
req.body = JSON.generate(INPUT)
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |h|
h.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
line = line.chomp
if line.start_with?("event: ") then event = line[7..]
elsif line.start_with?("data: ") && event == "delta" then raw << JSON.parse(line[6..])["text"].to_s
elsif line.start_with?("data: ") && event == "done" then puts line[6..]
end
end
end
end
end
<?php
$raw = ""; $event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => json_encode($input),
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN, "Content-Type: application/json", "Idempotency-Key: " . $key, "Accept: text/event-stream"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event: ")) $event = substr($line, 7);
elseif (str_starts_with($line, "data: ") && $event === "delta") $raw .= json_decode(substr($line, 6), true)["text"] ?? "";
elseif (str_starts_with($line, "data: ") && $event === "done") echo substr($line, 6), PHP_EOL;
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
var sreq = new HttpRequestMessage(HttpMethod.Post, "https://api.skillsafe.ai/v1/app-api/run-stream");
sreq.Headers.Add("Authorization", $"Bearer {Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN") ?? "YOUR_TOKEN"}");
sreq.Headers.Add("Idempotency-Key", key);
sreq.Headers.Add("Accept", "text/event-stream");
sreq.Content = new StringContent(json, System.Text.Encoding.UTF8, "application/json");
using var sres = await new HttpClient().SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var sr = new StreamReader(await sres.Content.ReadAsStreamAsync());
var raw = new System.Text.StringBuilder(); string? ev = null, line;
while ((line = await sr.ReadLineAsync()) != null)
{
if (line.StartsWith("event: ")) ev = line[7..];
else if (line.StartsWith("data: ") && ev == "delta") raw.Append(JsonSerializer.Deserialize<JsonElement>(line[6..]).GetProperty("text").GetString());
else if (line.StartsWith("data: ") && ev == "done") Console.WriteLine(line[6..]);
}
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
text = job["output"]["output"]
reply = json.loads(text[text.index("{"):text.rindex("}") + 1])
if reply.get("lane") == "draft":
print(reply["ref"], reply["cta"], reply["posts"] or reply["body"][:120])
print([(c["ref"], c["text"]) for c in reply["claims"]])
else:
print(reply["messaging"]["core"])
print([(c["ref"], c["title"]) for c in reply["calendar"]])
print(sum(l["amount"] for l in reply["budget"]["lines"]))
const text = job.output.output;
const reply = JSON.parse(text.slice(text.indexOf("{"), text.lastIndexOf("}") + 1));
if (reply.lane === "draft") console.log(reply.ref, reply.cta, reply.posts.length ? reply.posts : reply.body, reply.claims);
else console.log(reply.messaging.core, reply.calendar.map((c) => [c.ref, c.title]), reply.budget.lines.reduce((s, l) => s + l.amount, 0));
start, end := strings.Index(jobOutput, "{"), strings.LastIndex(jobOutput, "}")
var reply map[string]any
if err := json.Unmarshal([]byte(jobOutput[start:end+1]), &reply); err != nil {
panic(err)
}
if reply["lane"] == "draft" {
fmt.Println(reply["ref"], reply["channel"], reply["cta"], reply["posts"], reply["claims"])
} else {
fmt.Println(reply["headline"], reply["calendar"], reply["budget"])
}
// output is data.output.output from step 5: a string holding the reply JSON.
String json = output.substring(output.indexOf('{'), output.lastIndexOf('}') + 1);
// With Jackson: JsonNode r = new ObjectMapper().readTree(json);
// plan: r.get("messaging").get("core"), r.get("calendar") (one entry per C row), r.get("budget").get("lines")
// draft: r.get("ref"), r.get("posts") or r.get("body"), r.get("claims") - each claim carries an N ref
System.out.println(json);
text = job["output"]["output"]
reply = JSON.parse(text[text.index("{")..text.rindex("}")])
if reply["lane"] == "draft"
puts reply["ref"], reply["cta"], reply["claims"].map { |c| "#{c['ref']} #{c['text']}" }
else
puts reply["headline"], reply["calendar"].map { |c| "#{c['ref']} #{c['title']}" }
end
<?php
$text = $job["output"]["output"];
$reply = json_decode(substr($text, strpos($text, "{"), strrpos($text, "}") - strpos($text, "{") + 1), true);
if (($reply["lane"] ?? "") === "draft") {
foreach ($reply["claims"] as $c) { echo $c["ref"], " ", $c["text"], PHP_EOL; }
} else {
foreach ($reply["calendar"] as $c) { echo $c["ref"], " ", $c["title"], PHP_EOL; }
}
var text = output; // data.output.output from step 5
var body = text.Substring(text.IndexOf('{'), text.LastIndexOf('}') - text.IndexOf('{') + 1);
using var reply = JsonDocument.Parse(body);
var r = reply.RootElement;
if (r.GetProperty("lane").GetString() == "draft")
foreach (var c in r.GetProperty("claims").EnumerateArray()) Console.WriteLine($"{c.GetProperty("ref")} {c.GetProperty("text")}");
else
foreach (var c in r.GetProperty("calendar").EnumerateArray()) Console.WriteLine($"{c.GetProperty("ref")} {c.GetProperty("title")}");
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.
- Plan: exactly one
calendarentry perbrief.calendarrow - everyCid filled once, none added, dropped or duplicated - each with atitleand acta. - Plan: one
channelsentry per chosen channel inbrief.channelsand none that were not chosen (opsis not a channel entry). - Plan: every KPI appears in
metricswith itskpi_ref, and itstargetcarries the brief's number (target_num). - Plan: the
budget.linesamounts add up to no more thanbrief.budget; with no budget, no line allocates money. - Plan: every pillar
proof_refand objectivekpi_refexists in the brief; a pillar with no proof is a warning, not an error. - Draft:
refequalsitem.idandchannelequalsitem.channel; every field named initem.limitsis filled. - Draft: the hard platform limits are met - X 280 characters per post (1-6 posts), LinkedIn 3,000, Instagram 2,200 characters and at most 5 hashtags (hashtags in the caption count too), Google responsive search ad headlines 30 (3-15 of them) and descriptions 90 (2-4). Count characters as Unicode code points (
Array.from(s).lengthin JavaScript,len(s)in Python). House guidelines are warnings. - Draft: the only URL anywhere in the copy is the supplied tracked link
item.link(the barelanding_urlis tolerated); with an empty link the draft writes[link]. - Draft: every claim has a
refto anNproof point that exists; a draft from a brief with no proof points makes no claims. - Both: every figure in the prose is in the input (facts, KPIs, budget, dates) or simple arithmetic on it - anything else is an invented statistic; every prescan flag is answered exactly once, and no response names a flag that was not sent.
# 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.