Drive Pitch Desk from your own code
The page does its free work in the browser: it reads a story (notes, numbers, quotes or a draft pitch email) and
optional notes on the journalist against the seven-item pitch quality bar (beat match, news hook, a complete story,
a specific subject line, under 150 words, no banned words, a clear ask), counts the signals for seven story angles,
measures a draft's subject line and body, and raises flags PD-01..PD-18. Its two paid lanes are available over HTTP.
You send one JSON object with task set to "angle" or "pitch", and the model
replies with one JSON object. The easiest way to build the facts string is the page itself:
Pitchkit.buildInput(lane, Pitchkit.analyze(story, {target, ask}), {angle, angleHandoff}) in
pitchkit.js, which also runs under Node.
Base URL: https://api.skillsafe.ai/v1/app-api. Every call sends
Content-Type: application/json and, once you have a token,
Authorization: Bearer <token>. Both lanes run on gpt-terra with a 10%
publisher markup. A run needs a signed-in (user) token and is metered: the platform holds
the estimate's hold_credits while the run is in flight and then charges only
what it used (charged_credits). /estimate is free. A plain-text summary is at
/llms.txt.
The response envelope
Every response is {"data": ..., "error": null} on success or {"data": null, "error": {"code", "message"}} on failure.
Error codes
| HTTP | code | meaning |
|---|---|---|
| 400 | VALIDATION_ERROR | The body is not a JSON object, or a field is the wrong type. Every field of the run input is a string. |
| 401 | UNAUTHORIZED | No token, or an expired one: get a new one (step 2). |
| 402 | INSUFFICIENT_CREDITS | The balance is below the run's minimum. Top up at skillsafe.ai/account/billing/. |
| 403 | FORBIDDEN | A guest token tried a metered run: sign in for a personal token. |
| 404 | NOT_FOUND | Unknown job id. |
| 429 | RATE_LIMITED | Back off and retry with the same Idempotency-Key. |
The task field: two lanes
| task | what the model does |
|---|---|
"angle" | The story check: the story restated in plain language; a verdict (pitch it, not yet or skip PR for now); the angle it bends toward (founding, goliath, enemy, data, customer, newsjack, trend, or none) with quotes; the hook; why it matters to the journalist's readers; the seven-item quality bar graded pass, fail or unknown with quotes; what is missing and how to get it this week; the pitch template to use; and two or three subject lines under 50 characters. |
"pitch" | The pitch email: a subject under 50 characters that predicts the headline, a body of 150 words or fewer from greeting to sign-off with [placeholders] for what only the sender knows, one clear ask (copied from the body), the story facts it rests on, follow-ups on day 3 and day 7 that each bring something new, and what to check before sending. |
Input fields (all strings)
| field | when | content |
|---|---|---|
task | required | angle or pitch |
story | required | The story, verbatim: notes, numbers, quotes, a draft pitch. Past 12,000 characters the page cuts the middle on a line boundary and leaves a [pitch-desk: ...] marker. |
facts | required | A JSON string: summary, has_pitch, subject (text, chars, deleted, prefix), body_words, bar (id, status: met|unclear|not met, why, evidence with line, label, quote, where), angles (id, hits, evidence), angle_hint, signals, banned, killers, target (given, name, recent_pieces, no_pitches), numbers, flags (uid, id, sev, ref, name), notes. It is Pitchkit.factsOf(A). |
target | optional | Notes on the journalist, up to 4,000 characters: name, outlet, beat, recent pieces one per line. |
ask | optional | interview, exclusive, embargo, quote, oped, data or call. |
angle | pitch only | One of the seven angle ids. Without it the run pitches the story check's angle, or the strongest the story supports. |
angle_handoff | pitch only | A JSON string of a story check's verdict, angle{id}, hook{text}, reader_why, template and missing[{item}], for the same story. |
retry_note | optional | Only on a reformat retry: what was wrong with the previous reply. |
The reply contracts
Both lanes share an envelope: task, headline, questions[], summary.
task: "angle"
story_plain{what, who, when, proof}, verdict (pitch it|not yet|skip PR for now), verdict_why, angle{id, why, evidence[]}, hook{text, evidence[]}, reader_why, bar[{id, status: pass|fail|unknown, evidence[], note}] (seven, in order: beat, hook, complete, subject, length, vocabulary, ask), missing[{item, why, get}], template (data|exclusive|oped|customer|trend|newsjack), subject_ideas[].
task: "pitch"
angle, template, source (given|angle handoff|picked from the story), subject, body, ask{type, text}, facts_used[{quote, as}], follow_ups[{day: 3|7, text, new}], before_sending[].
Worked examples
These are the page's own bundled examples (invented companies, people and outlets), with the input as app.js builds it and the saved model reply the page replays for free.
Story check: a deploy-data report with a reporter in mind
Input (story and facts shortened here):
{
"task": "angle",
"story": "# Story notes: the Friday deploy\n\nQuenbrook CI is a four-person company that runs build pipelines for small engineering teams.\n\nLast month we analyzed 41,212 production deploys from 1,180 teams that use Quenbrook (anonymized, opt-in).\n\nWhat we found:\n- 31% of all production deploys happen on a Friday, more than any other day.\n- Friday deploys are rolled back 2.4 times as often as Tuesday deploys.\n- Teams with fewer than 10 engineers ship 44% of their deploys after 3pm on Fridays.\n\nOur report, \"T... (836 characters in all)",
"facts": "(a JSON string; decoded: has_pitch false, bar beat met; hook met; complete met; subject unclear; length unclear; vocabulary met; ask met, angle hint data, journalist named true with 3 recent pieces, flags none)",
"target": "Dorsa Kelvane, DevOps reporter at The Brakewell Ledger\nRecent pieces:\n- \"Why platform teams are quietly killing the on-call rotation\" (Feb 27)\n- \"The hidden cost of flaky tests, by the numbers\" (Feb 12)\n- \"Inside the four-person startups selling to engineers\" (Jan 30)"
}
Reply:
{
"task": "angle",
"story_plain": {
"what": "Quenbrook CI analyzed production deploys from its customers and is publishing a report on when teams deploy and how often Friday deploys are rolled back.",
"who": "Quenbrook CI, a four-person company; Maelis Vandrot, co-founder and CTO, who ran the analysis.",
"when": "The report goes live on March 18; the analysis was done last month.",
"proof": "41,212 production deploys from 1,180 teams: 31% happen on a Friday, Friday deploys are rolled back 2.4 times as often as Tuesday deploys, and teams with fewer than 10 engineers ship 44% of their deploys after 3pm on Fridays."
},
"verdict": "pitch it",
"verdict_why": "This is a proprietary data story with a publish date, a named analyst willing to explain the method and a quotable line that cuts against the obvious reading. The reporter's recent pieces sit on this beat.",
"angle": {
"id": "data",
"why": "The story is proprietary numbers on a habit every engineering team argues about, packaged with a method and raw counts; Quenbrook is the source, not the headline.",
"evidence": [
"Last month we analyzed 41,212 production deploys from 1,180 teams that use Quenbrook (anonymized, opt-in).",
"31% of all production deploys happen on a Friday, more than any other day."
]
},
"hook": {
"text": "The Friday Deploy report publishes on March 18 with its method and raw counts, so a reporter can run the numbers first.",
"evidence": [
"Our report, \"The Friday Deploy\", goes live on March 18 with the method and the raw counts."
]
},
"reader_why": "DevOps readers live with the \"never deploy on Friday\" rule; hard numbers on how often it is broken, what it costs in rollbacks and why small teams do it anyway give them a data point for their own release policy.",
... (shortened here; the page shows it whole)
Pitch: the same story, carrying the check
Input (with the story check handed over):
{
"task": "pitch",
"story": "# Story notes: the Friday deploy\n\nQuenbrook CI is a four-person company that runs build pipelines for small engineering teams.\n\nLast month we analyzed 41,212 production deploys from 1,180 teams that use Quenbrook (anonymized, opt-in).\n\nWhat we found:\n- 31% of all production deploys happen on a Friday, more than any other day.\n- Friday deploys are rolled back 2.4 times as often as Tuesday deploys.\n- Teams with fewer than 10 engineers ship 44% of their deploys after 3pm on Fridays.\n\nOur report, \"T... (836 characters in all)",
"facts": "(a JSON string; decoded: has_pitch false, bar beat met; hook met; complete met; subject unclear; length unclear; vocabulary met; ask met, angle hint data, journalist named true with 3 recent pieces, flags none)",
"target": "Dorsa Kelvane, DevOps reporter at The Brakewell Ledger\nRecent pieces:\n- \"Why platform teams are quietly killing the on-call rotation\" (Feb 27)\n- \"The hidden cost of flaky tests, by the numbers\" (Feb 12)\n- \"Inside the four-person startups selling to engineers\" (Jan 30)",
"angle_handoff": "{\"verdict\":\"pitch it\",\"angle\":{\"id\":\"data\"},\"hook\":{\"text\":\"The Friday Deploy report publishes on March 18 with its method and raw counts, so a reporter can run the numbers first.\"},\"reader_why\":\"DevOps readers live with the \\\"never deploy on Friday\\\" rule; hard numbers on how often it is broken, what it costs in rollb..."
}
Reply:
{
"task": "pitch",
"angle": "data",
"template": "data",
"source": "angle handoff",
"subject": "Data: 31% of deploys happen on a Friday",
"body": "Hi Dorsa,\n\nYour piece \"The hidden cost of flaky tests, by the numbers\" is why I'm writing: we have numbers on another release habit.\n\nQuenbrook CI analyzed 41,212 production deploys from 1,180 teams. 31% of all production deploys happen on a Friday, and Friday deploys are rolled back 2.4 times as often as Tuesday deploys.\n\nFor your readers, that is the never-deploy-on-Friday rule tested against real pipelines, and broken more than any other day.\n\nMaelis Vandrot, our co-founder and CTO, ran the analysis: \"Small teams aren't reckless. They ship on Friday because it's the only day nobody books meetings.\"\n\nMethod: [the method in two lines]. Our report, \"The Friday Deploy\", goes live on March 18 with the raw counts, and I can send it under embargo until [day, time, timezone].\n\nCould Maelis walk you through the method before March 18?\n\n[your name]\nQuenbrook CI",
"ask": {
"type": "interview",
"text": "Could Maelis walk you through the method before March 18?"
},
"facts_used": [
{
"quote": "Our report, \"The Friday Deploy\", goes live on March 18 with the method and the raw counts.",
"as": "hook"
},
{
"quote": "Last month we analyzed 41,212 production deploys from 1,180 teams that use Quenbrook (anonymized, opt-in).",
"as": "method"
},
{
"quote": "31% of all production deploys happen on a Friday, more than any other day.",
"as": "proof"
},
{
"quote": "Friday deploys are rolled back 2.4 times as often as Tuesday deploys.",
"as": "proof"
},
{
"quote": "Small teams aren't reckless. They ship on Friday because it's the only day nobody books meetings,",
"as": "quote"
... (shortened here; the page shows it whole)
Pitch: a clinic customer story, asking for an interview
Input:
{
"task": "pitch",
"story": "Slatenib makes patient intake forms that work by text message for small medical and dental practices.\n\nFenwhistle Dental, a three-chair practice in Dayton, Ohio, switched from paper clipboards to Slatenib in June. In the four months before, 18% of appointments were no-shows. In the four months after, the rate was 7%.\n\nDr. Odalys Ferrandeu, owner of Fenwhistle Dental, is willing to talk on the record and can share her monthly no-show counts.\n\n\"I assumed no-shows were a patient problem. Half of them ... (776 characters in all)",
"facts": "(a JSON string; decoded: has_pitch false, bar beat met; hook met; complete met; subject unclear; length unclear; vocabulary met; ask met, angle hint customer, journalist named true with 3 recent pieces, flags none)",
"target": "Pirelle Ransdane, health-business reporter at Ohrvale Health Wire\nRecent pieces:\n- \"Small practices are drowning in front-desk paperwork\" (Sep 22)\n- \"The no-show problem costs clinics more than they think\" (Sep 3)\n- \"What the new digital-records rule means for dentists\" (Aug 19)",
"ask": "interview"
}
Reply:
{
"task": "pitch",
"angle": "customer",
"template": "customer",
"source": "picked from the story",
"subject": "Dentist cut no-shows from 18% to 7%",
"body": "Hi Pirelle,\n\nYour Sep 3 piece, \"The no-show problem costs clinics more than they think,\" made me think you would want this one.\n\nFenwhistle Dental, a three-chair practice in Dayton, Ohio, switched from paper clipboards to text-message intake forms in June. In the four months before, 18% of appointments were no-shows. In the four months after, the rate was 7%.\n\nThe owner, Dr. Odalys Ferrandeu, puts it plainly: \"I assumed no-shows were a patient problem. Half of them were a paperwork problem.\"\n\nThe state dental board's new rule on digital patient records takes effect on November 1, and most small practices in Ohio still use paper, so your readers are weighing this switch right now.\n\nWould you like to interview Dr. Ferrandeu before the November 1 rule takes effect? She is willing to talk on the record.\n\n[your name]\nSlatenib, a team of six in Columbus",
"ask": {
"type": "interview",
"text": "Would you like to interview Dr. Ferrandeu before the November 1 rule takes effect?"
},
"facts_used": [
{
"quote": "The state dental board's new rule on digital patient records takes effect on November 1",
"as": "hook"
},
{
"quote": "Fenwhistle Dental, a three-chair practice in Dayton, Ohio, switched from paper clipboards to Slatenib in June.",
"as": "customer"
},
{
"quote": "In the four months before, 18% of appointments were no-shows.",
"as": "proof"
},
{
"quote": "In the four months after, the rate was 7%.",
"as": "proof"
},
{
"quote": "I assumed no-shows were a patient problem. Half of them were a paperwork problem",
"as": "quote"
},
{
"quote": "Dr. Odalys
... (shortened here; the page shows it whole)
The page re-checks the reply; the API does not
The page holds every reply to account (recon.js): for a story check, all seven checks graded once, a pass only with a quote, beat match unknown when no journalist is named, length and banned words matching the page's counts, no pass on a subject that is missing or too long, "pitch it" only with an angle, a hook and a complete story, every quote in the story or notes word for word, and subject ideas under 50 characters; for a pitch, a subject under 50 characters with no pattern that gets deleted, a body of 150 words or fewer, the page's rules re-run over it (banned words, pitch killers, emoji), the ask copied from the body, no quotation that is not in the story, two follow-ups on days 3 and 7 that never bump, and no invented or rounded figures. Over the API you get the model's reply as it is; run the same checks yourself with Recon.reconcile in recon.js, or paste the same story into the page.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the envelope.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="paste-your-token-here" # from https://pitch-desk.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://pitch-desk.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// pitch-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://pitch-desk.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "paste-your-token-here" // from https://pitch-desk.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// PitchDesk.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class PitchDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://pitch-desk.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "paste-your-token-here" # from https://pitch-desk.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "paste-your-token-here"; // from https://pitch-desk.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "paste-your-token-here"; // from https://pitch-desk.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"pitch-desk"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "pitch-desk"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "pitch-desk" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "pitch-desk"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "pitch-desk"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "pitch-desk" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "pitch-desk"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "pitch-desk" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself (not wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model reads gpt-5.6-terra and model_alias gpt-terra.
# input.json: the run input object - {"task": "angle", "story": "...", "facts": "...", "target": "..."}
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "angle", "story": "...", "facts": "...", "target": "...", ...}
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task, story, facts, target, ask, ...}
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses pitch-desk:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="pitch-desk:angle:$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the lane's contract
echo "$J" | jq '.data | {charged_credits, truncated}'
import hashlib, time
key = "pitch-desk:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the lane's JSON object
print(j["charged_credits"], j.get("truncated"), reply.get("verdict"))
import { createHash } from "node:crypto";
const key = `pitch-desk:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.verdict);
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "pitch-desk:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
fmt.Println(j["charged_credits"], j["truncated"], j["output"].(map[string]any)["output"])
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "pitch-desk:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply);
require "digest"
key = "pitch-desk:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["verdict"]].inspect
$key = "pitch-desk:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["verdict"], "\n";
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"pitch-desk:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>());
Console.WriteLine($"{j["charged_credits"]} {reply}");
Step 6 — POST /run-stream (pitch)
The same run as Server-Sent Events. pitch.json is a pitch input, with angle_handoff when it follows a story check.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: pitch-desk:pitch:$(shasum -a 256 pitch.json | cut -c1-16):a1" --data @pitch.json
rev = json.load(open("pitch.json")) # {"task": "pitch", "story": "...", "facts": "...", "target": "...", "ask": "...", "angle_handoff": "..."}
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "pitch-desk:pitch:" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("pitch.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `pitch-desk:pitch:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("pitch.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "pitch-desk:pitch:"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("pitch.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "pitch-desk:pitch:" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("pitch.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "pitch-desk:pitch:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("pitch.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: pitch-desk:pitch:" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("pitch.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"pitch-desk:pitch:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);