Drive Directory Desk from your own code
The page does its free work in the browser: it reads your product facts and your answers to the skill's nine
readiness questions, works out the readiness verdict with its hard and soft blocks, the page's signal for each of the
13 directory tiers, the Product Hunt prep calendar dated from the launch day, the review window and the next G2 cut-off,
with flags DS-01..DS-12 (a hard block, a soft block, unconfirmed items, a launch day that is not Tuesday to Thursday, a
launch under 21 days away, no launch day, no competitors, fewer than 20 users, an email list under 100, no domain rating
baseline, a past launch day, a paid submission service). A paid run sends that reading with your facts, with
"task" set to "plan" or "copy", and the model replies with one JSON object. The
easiest way to build the input is the page's own
DirKit.buildInput(lane, DirKit.analyze(you, {product, launch, question, ready, surfaces}), {planHandoff})
in dirkit.js (with catalog.js loaded first), 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 |
|---|---|
"plan" | The skill's directory plan: the readiness verdict (not ready, ready with soft blocks, ready) with every block and its fix, a submit or skip decision for each of the 13 tiers, the batches in order with directory names from the catalog, the destination_pages to build first, the product_hunt decision and its eight dated steps, the reviews drive and 3-6 KPI targets. |
"copy" | The skill's positioning variants: one listing per surface in surfaces, each with its lead, a tagline under 10 words, a short description of at most 60 characters, a long description of 90-170 words and 5-8 tags; a founder_story; the product_hunt description and first comment. |
Input fields (all strings)
| field | when | content |
|---|---|---|
task | required | plan or copy |
product | required | The product's name (may be empty; the page then reads it from the first line of you). |
launch_date | optional | The launch day, YYYY-MM-DD. |
you | required | The product facts: what it is, who it is for, why it is different, and labelled lines such as Competitors:, Integrations:, Beta users:, Email list:, DR:, Referring domains:, Launch date:. Past 9,000 characters the page cuts the middle and leaves a [directory-desk: ...] marker. |
question | optional | What you want to know. |
surfaces | copy only | Comma-separated, at most 4: startup, saas, ai, agent-mcp, no-code, dev, b2b-review. |
facts | required | A JSON string: product, today, launch_date, competitors[], integrations[], beta_users, email_list, dr, referring_domains, signals{ai, mcp, agent, no_code, dev, b2b, local, vertical}, readiness{verdict, items[{n, item, answer}], hard[], soft[], open[]}, tiers[{tier, name, fit, why}], calendar{launch_date, weekday, good_day, days_until, steps[{day, date, what}], review_window_end, g2_cutoff, cutoff_in_window} or null, flags[{id, severity, text}], notes[]; plus catalog{tier: [names]} for a plan or surfaces[{surface, lead}] for copy. |
plan_handoff | copy only | A JSON string of the plan the copy follows: readiness, submit_tiers[], surfaces[]. |
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: "plan"
readiness{verdict: not ready|ready with soft blocks|ready, blocks[{item, kind: hard|soft|unconfirmed, fix}]}, tiers[{tier, decision: submit|skip, why, evidence[]}] (exactly 13, in order), batches[{when: before submitting|launch week|week 2|week 3|rolling, tiers[], directories[], note}] (1 to 6, in that order), destination_pages[{page, why}] (0 to 8), product_hunt{decision: launch|later|skip, why, steps[{day, date, do}]} (days -21, -14, -10, -7, -3, -1, 0, 1), reviews{ask, by, note}, targets[{metric, day_30, day_90}] (3 to 6 rows of the skill's KPI table).
task: "copy"
source (plan handoff|from the input), variants[{surface, lead, tagline, short, long, tags[]}] (one per surface asked for, in order; leads: startup outcome, saas alternative framing, ai AI-first architecture, agent-mcp agent/MCP angle, no-code ease + power, dev technical depth, b2b-review ROI + use case), founder_story, product_hunt{description, first_comment}.
Worked examples
These are the page's own bundled examples (invented products, people and numbers), with the input as app.js builds it and the saved model reply the page replays for free. One worked example per readiness branch and per lane, and the plan-to-copy handoff.
Plan: AI meeting notes for agencies, launching on a Tuesday (ready with soft blocks)
Input (facts shortened here):
{
"task": "plan",
"product": "Recapwyne",
"you": "Recapwyne - AI meeting notes for client-service agencies\nWhat it does: joins Zoom and Google Meet calls, writes the notes, and turns every client request it hears into a task with an owner.\nWho it is for: account managers and project leads at agencies with 10-80 people.\nWhy it is different: it separates what the client asked for from what the agency promised, and drafts the recap email in the agency's own tone.\nCompe... (1037 characters in all)",
"facts": "(a JSON string; decoded: product Recapwyne, launch 2026-10-27 a Tuesday, 26 days away, competitors Otter, Fireflies, Fathom, integrations Slack, HubSpot, Asana, users 34, email list 1200, DR 12; readiness ready with soft blocks (hard none, soft template gallery or lead magnet, open none); tiers with a signal 1, 2, 3, 6, 7, 8, 10, 11, 12; calendar of 8 dated Product Hunt steps, review window to 2026-11-26, G2 cut-off 2027-04-28; catalog of 101 directory names by tier; flags DS-02)",
"launch_date": "2026-10-27",
"question": "Which directories should we do in launch week, and what can wait?"
}
Reply:
{
"task": "plan",
"readiness": {
"verdict": "ready with soft blocks",
"blocks": [
{
"item": "template gallery or lead magnet",
"kind": "soft",
"fix": "Build a template gallery or a lead magnet, such as client recap email templates, so directory traffic has something to take away."
}
]
},
"tiers": [
{
"tier": 1,
"decision": "submit",
"why": "Flagship launch platforms belong in launch week only, around the 2026-10-27 launch.",
"evidence": [
"Launch date: 2026-10-27"
]
},
{
"tier": 2,
"decision": "submit",
"why": "Recapwyne is a SaaS product with beta agencies who can leave reviews on startup, SaaS and review directories.",
"evidence": [
"Beta users: 34 agencies, 9 of them paying",
"Pricing: free for 5 meetings a month, $18 per seat per month after that."
]
},
{
"tier": 3,
"decision": "submit",
"why": "Recapwyne is an AI product, so AI tool directories are a genuine fit.",
"evidence": [
"AI meeting notes for client-service agencies"
]
},
{
"tier": 4,
"decision": "skip",
"why": "The input names no MCP server or agent capability.",
"evidence": []
},
{
"tier": 5,
"decision": "skip",
"why": "Recapwyne is not a no-code platform or builder tool.",
"evidence": []
},
{
"tier": 6,
"decision": "submit",
"why": "Outreach to \"best of\" meeting notes posts on DR 40+ sites, where the named competitors already appear.",
"evidence": [
"Competitors: Otter, Fireflies and Fathom"
]
},
{
"tier": 7,
"decision": "submit",
"why": "The named integrations give a path into their marketplaces.",
"evidence": [
"Integrations: Slack, HubSpot, Asana"
]
},
{
"tier": 8,
"decision": "submit",
"why": "Profiles and conte
... (shortened here; the page shows it whole)
Copy: the same product, carrying the plan
Input (with the plan handed over):
{
"task": "copy",
"product": "Recapwyne",
"you": "Recapwyne - AI meeting notes for client-service agencies\nWhat it does: joins Zoom and Google Meet calls, writes the notes, and turns every client request it hears into a task with an owner.\nWho it is for: account managers and project leads at agencies with 10-80 people.\nWhy it is different: it separates what the client asked for from what the agency promised, and drafts the recap email in the agency's own tone.\nCompe... (1037 characters in all)",
"facts": "(a JSON string; decoded: product Recapwyne, launch 2026-10-27 a Tuesday, 26 days away, competitors Otter, Fireflies, Fathom, integrations Slack, HubSpot, Asana, users 34, email list 1200, DR 12; readiness ready with soft blocks (hard none, soft template gallery or lead magnet, open none); tiers with a signal 1, 2, 3, 6, 7, 8, 10, 11, 12; calendar of 8 dated Product Hunt steps, review window to 2026-11-26, G2 cut-off 2027-04-28; surfaces startup (outcome), saas (alternative framing), ai (AI-first architecture), b2b-review (ROI + use case); flags DS-02)",
"launch_date": "2026-10-27",
"question": "Which directories should we do in launch week, and what can wait?",
"surfaces": "startup,saas,ai,b2b-review",
"plan_handoff": "{\"readiness\":\"ready with soft blocks\",\"submit_tiers\":[1,2,3,6,7,8,10,11,12],\"surfaces\":[\"startup\",\"saas\",\"ai\",\"b2b-review\"]}"
}
Reply:
{
"task": "copy",
"source": "plan handoff",
"variants": [
{
"surface": "startup",
"lead": "outcome",
"tagline": "Client calls in, owned tasks out.",
"short": "Recapwyne turns client calls into tasks with owners.",
"long": "Recapwyne is the easiest way to turn client calls into owned tasks for account managers and project leads at agencies. It joins your Zoom and Google Meet calls and writes the notes. Every client request it hears becomes a task with an owner. It keeps what the client asked for apart from what your agency promised. It also drafts the recap email in your agency's own tone. Recapwyne is built for client-service agencies with 10-80 people. It works with Slack, HubSpot and Asana. Start free for 5 meetings a month, then pay $18 per seat per month.",
"tags": [
"meeting notes",
"agencies",
"client calls",
"task capture",
"Zoom",
"Google Meet"
]
},
{
"surface": "saas",
"lead": "alternative framing",
"tagline": "The client-service alternative to Otter, Fireflies and Fathom.",
"short": "Recapwyne, an agency alternative to Otter and Fireflies",
"long": "Recapwyne is the client-service alternative to Otter, Fireflies and Fathom, made for agencies with 10-80 people. It is AI meeting notes built for client-service agencies. Its difference: it separates what the client asked for from what the agency promised. It drafts each recap email in the agency's own tone. Recapwyne joins Zoom and Google Meet calls, writes the notes and turns each client request it hears into a task with an owner. Account managers and project leads can connect it to Slack, HubSpot and Asana. Pricing is free for 5 meetings a month and $18 per seat per month after that.",
"tags": [
"Otter alternative",
"Fireflies altern
... (shortened here; the page shows it whole)
Plan: an open-source MCP server with no pricing page and a Saturday launch (not ready)
Input:
{
"task": "plan",
"product": "Tablorne",
"you": "Tablorne - an open-source MCP server that lets AI agents query a Postgres database safely\nWhat it does: exposes read-only SQL, schema browsing and query explain plans as MCP tools, with a row limit and a deny-list for sensitive columns.\nWho it is for: developers wiring Claude, Cursor or their own agents to a production database.\nLicense: Apache-2.0, self-hostable, written in Go.\nUsers: 8 teams trying it from the GitH... (737 characters in all)",
"facts": "(a JSON string; decoded: product Tablorne, launch 2026-10-10 a Saturday, 9 days away, competitors none, integrations none, users 8, email list 60, DR None; readiness not ready (hard pricing page, privacy policy and terms, screenshots and demo video, alternative and use-case pages, soft template gallery or lead magnet, 20 beta users for reviews, open logo assets, GEO-ready landing pages); tiers with a signal 1, 2, 3, 4, 6, 8, 10, 11, 12; calendar of 8 dated Product Hunt steps, review window to 2026-11-09, G2 cut-off 2027-04-28; catalog of 101 directory names by tier; flags DS-01, DS-02, DS-03, DS-04, DS-05, DS-07, DS-08, DS-09, DS-10, DS-12)",
"launch_date": "2026-10-10"
}
Reply:
{
"task": "plan",
"readiness": {
"verdict": "not ready",
"blocks": [
{
"item": "pricing page",
"kind": "hard",
"fix": "Publish a pricing page, even one that states the open-source version is free and the hosted version is coming."
},
{
"item": "privacy policy and terms",
"kind": "hard",
"fix": "Publish a privacy policy and terms of service before any listing points at the site."
},
{
"item": "screenshots and demo video",
"kind": "hard",
"fix": "Capture 5-8 screenshots and a 60-90 second demo video of an agent querying Postgres through Tablorne."
},
{
"item": "alternative and use-case pages",
"kind": "hard",
"fix": "Build 3-5 alternative pages and 3-5 use-case pages so directory traffic lands somewhere that converts."
},
{
"item": "template gallery or lead magnet",
"kind": "soft",
"fix": "Add a lead magnet or a gallery of example configs so listings keep compounding."
},
{
"item": "20 beta users for reviews",
"kind": "soft",
"fix": "Grow from the 8 teams on the repo toward 20 users you can ask for reviews."
},
{
"item": "logo assets",
"kind": "unconfirmed",
"fix": "Confirm you have a square logo and a wordmark ready in the sizes directories ask for."
},
{
"item": "GEO-ready landing pages",
"kind": "unconfirmed",
"fix": "Confirm the docs site has landing pages that state plainly what Tablorne is and who it is for, so AI engines can cite them."
}
]
},
"tiers": [
{
"tier": 1,
"decision": "submit",
"why": "Launch platforms fit an open-source developer tool, but only in launch week and only once the hard blocks are fixed.",
"evidence": [
"Launch date: 2026-10-10"
]
},
{
"tier": 2,
"decision": "
... (shortened here; the page shows it whole)
Copy: a no-code clinic intake form builder, from the input
Input:
{
"task": "copy",
"product": "Frontliq",
"you": "Frontliq - patient intake forms for small clinics, built without code\nWhat it does: drag-and-drop intake and consent forms that pre-fill from the last visit, collect signatures on a tablet in the waiting room, and send the answers to the clinic's records system.\nWho it is for: front-desk managers at physiotherapy, dental and chiropractic clinics with 1-10 practitioners.\nWhy it is different: the forms remember returni... (810 characters in all)",
"facts": "(a JSON string; decoded: product Frontliq, launch none, competitors Jotform, Typeform, IntakeQ, integrations Google Sheets, Jane App, users 41, email list None, DR None; readiness unconfirmed (hard none, soft none, open publicly accessible, pricing page, privacy policy and terms, logo assets, screenshots and demo video, GEO-ready landing pages, alternative and use-case pages, template gallery or lead magnet); tiers with a signal 1, 2, 5, 6, 7, 8, 10, 11, 12, 13; no calendar; surfaces startup (outcome), saas (alternative framing), no-code (ease + power), b2b-review (ROI + use case); flags DS-03, DS-06, DS-10)",
"surfaces": "startup,saas,no-code,b2b-review"
}
Reply:
{
"task": "copy",
"source": "from the input",
"variants": [
{
"surface": "startup",
"lead": "outcome",
"tagline": "Patient intake forms for small clinics, built without code",
"short": "Frontliq: patient intake forms for small clinics",
"long": "Frontliq is the easiest way to run patient intake for small clinics with 1-10 practitioners. Front-desk managers build intake and consent forms by drag and drop, with no code. Patients sign on a tablet in the waiting room. When a patient comes back, the form pre-fills from the last visit, so a regular fills in only what changed. Answers go to the clinic's records system, and Frontliq connects with Google Sheets and Jane App. It is in use at 41 clinics. Pricing is $29 per clinic per month, with a 14-day free trial. Frontliq was built by Ivra Telk and Ezro Vambel after three years running the front desk of a family physiotherapy clinic.",
"tags": [
"patient intake",
"small clinics",
"healthcare",
"forms",
"consent forms",
"startup"
]
},
{
"surface": "saas",
"lead": "alternative framing",
"tagline": "The intake form alternative that remembers returning patients",
"short": "Frontliq: a Jotform and IntakeQ alternative for clinics",
"long": "Frontliq is the returning-patient alternative to Jotform, Typeform and IntakeQ for small clinics. Its forms remember returning patients and pre-fill from the last visit, so a regular fills in only what changed. It is built for front-desk managers at physiotherapy, dental and chiropractic clinics with 1-10 practitioners. Intake and consent forms are built by drag and drop, without code. Signatures are collected on a tablet in the waiting room. Answers are sent to the clinic's records system, with integrations for Google Sheets and
... (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 plan, the readiness verdict follows the page's (never ready while an item is unconfirmed) and the blocks are exactly the items answered no or left open; all 13 tiers in order, a tier with no signal submitted only with a quote; batches in order, the fixes first when not ready, every submitted tier batched, Tier 1 only in launch week, every directory from the catalog the page sent and under its own tier; alternative and use-case pages when they are missing; the eight Product Hunt steps dated exactly as the page computed them; the review deadline; KPI rows exactly as the skill's table writes them. For copy, one listing per surface asked for, in order, with that surface's lead; a tagline under 10 words, a short description of at most 60 characters naming the product, a long description of 90 to 170 words; no two openings, sentences or tag sets alike; the SaaS listing names a competitor; no vague claims; a Product Hunt description of at most 260 characters and a first comment that asks for feedback, never upvotes. In both, quotes are checked word for word and no figure, date or duration may be invented. 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 input 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://directory-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://directory-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"]
// directory-desk.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "paste-your-token-here"; // from https://directory-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://directory-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
}
// DirectoryDesk.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 DirectoryDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "paste-your-token-here"; // from https://directory-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://directory-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://directory-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://directory-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":"directory-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": "directory-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: "directory-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": "directory-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", "directory-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: "directory-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" => "directory-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 = "directory-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": "plan", "product": "...", "launch_date": "2026-10-27", "you": "...", "facts": "..."}
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": "plan", "product": "...", "launch_date": "2026-10-27", "you": "...", "facts": "...", ...}
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, product, launch_date, you, question, surfaces, facts, ...}
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 directory-desk:<lane>:<inputHash>:a<attempt>. The job's output.output is the reply text; truncated: true means the balance cut it short.
KEY="directory-desk:plan:$(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 = "directory-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 = `directory-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 := "directory-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 = "directory-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 = "directory-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 = "directory-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 = $"directory-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 (copy)
The same run as Server-Sent Events. copy.json is a copy-lane input, with plan_handoff when it follows a plan.
# 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: directory-desk:copy:$(shasum -a 256 copy.json | cut -c1-16):a1" --data @copy.json
rev = json.load(open("copy.json")) # {"task": "copy", "product": "...", "you": "...", "surfaces": "startup,saas", "facts": "...", "plan_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": "directory-desk:copy:" + 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("copy.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": `directory-desk:copy:${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("copy.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", "directory-desk:copy:"+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("copy.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", "directory-desk:copy:" + 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("copy.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "directory-desk:copy:#{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("copy.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: directory-desk:copy:" . 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("copy.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", $"directory-desk:copy:{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);