Drive Spec Desk from your own code
Spec Desk takes one rough product idea — the three sentences that arrived in Slack —
and runs three lanes over it: a full PRD, a Now/Next/Later placement against the roadmap you paste, and a
stakeholder update written for a named audience and cadence. The scan of the idea (word and sentence counts,
whether it carries a problem, a user and a metric signal, whether it is already structured, and the
Now/Next/Later parse of a pasted roadmap) is deterministic, free and runs client-side; the API surface here is
the metered model pass on top of it. Every request goes to
https://api.skillsafe.ai/v1/app-api and carries a bearer token.
Base URL, envelope and errors
Every response is a JSON envelope. Success carries data; failure carries error
with a stable code. Nothing else appears at the top level, so a client can branch on the presence
of error alone.
{ "ok": true, "data": { "...": "..." } }
{ "ok": false, "error": { "code": "validation_error", "message": "...", "details": { } } }
| Code | HTTP | What it means | What to do |
|---|---|---|---|
unauthorized | 401 | No token, an expired token, or a token minted for another app. | Mint a guest token or sign in; see step 1. |
payment_required | 402 | The balance is below min_credits for this run. | Top up, or use a sponsored run. /estimate is free, so check before you submit. |
validation_error | 400 | The input object is not the shape the app declares — or the guest slug was sent as a header instead of in the body. | Compare against the input object below; put slug in the JSON body. |
not_found | 404 | Unknown path, or a job_id that belongs to another subject. Every /guest call mints a new subject. | Poll with the same token that created the job. |
rate_limited | 429 | Too many requests in the window. | Back off and retry with growing delays; do not tight-loop a poll. |
internal_error | 500 | The server or the model provider failed part-way. | Retry with the SAME Idempotency-Key so a partial charge is not repeated. |
gpt-terra alias (gpt-5.6-terra today) at a
markup of 1000 basis points. There is no subscription price: the app is metered, so you pay for the run and
nothing else. /estimate is free and tells you what a given lane will hold.The task field comes first
Spec Desk is one app with three lanes over one work object — a pasted idea.
Every request must carry a task field naming the lane. The lane decides which of
the three lane objects comes back non-null; the envelope around it (lane, title,
summary, unknowns, grounding) is identical in all three.
task | Non-null object | What the lane answers | Extra input it needs |
|---|---|---|---|
spec | spec |
What are we actually building, and is this ready to scope? Problem statement, goals, non-goals, users,
R1..Rn requirements with priorities, acceptance criteria bound to those ids, success metrics,
phasing, open questions and a readiness verdict. |
Nothing beyond the shared fields. |
roadmap | roadmap |
Where does this go on the board, and what moves to make room? A Now/Next/Later placement with its rationale, the items bumped, the whole updated board, dependencies, and the risk of delaying or rushing it. | roadmap_text + roadmap_facts; optional spec_summary. |
update | update |
What do I send, to whom, right now? A subject line, a ready-to-send Markdown body in the register the audience expects, the asks, the risk callouts and when the next update is due. | audience + cadence; optional spec_summary and placement. |
The three lanes are meant to be run in that order over the same idea in one sitting: spec first,
then roadmap fed a spec_summary, then update fed both. Each also stands
alone — the handoff fields are nullable and the reviewer is told to say so in unknowns when a
lane ran without them, rather than pretending a spec existed.
If task is absent or is not one of the three, the reviewer picks the closest lane, runs only that
lane, and names the lane it chose in summary — it never blends two lanes' output shapes into
one answer. Do not rely on that: send the field.
1. Get a token
Two ways, and neither of them involves a developer console. Scripted: POST /guest
mints a guest token for this app; the slug goes in the body as
{"slug":"spec-desk"} — an X-App-Slug header returns 400.
Personal: open the token page, which shows the token this browser
holds and copies a ready-made export SKILLSAFE_TOKEN=... line for you. A guest can call
/me and /estimate; billing a metered run to your account needs the personal token.
# Scripted: a guest token, no browser and no account.
curl -sS -X POST "https://api.skillsafe.ai/v1/app-api/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"spec-desk"}'
# -> { "ok": true, "data": { "token": "...", "subject_type": "guest" } }
# Personal: open /tokens.html, press "Copy shell export", paste the line.
export SKILLSAFE_TOKEN="aut_xxxxxxxxxxxxxxxxxxxx"
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="$SKILLSAFE_TOKEN"
import json, os, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = os.environ.get("SKILLSAFE_TOKEN")
if not TOKEN: # fall back to a scripted guest
req = urllib.request.Request(
BASE + "/guest",
data=json.dumps({"slug": "spec-desk"}).encode(),
headers={"Content-Type": "application/json"},
)
with urllib.request.urlopen(req) as r:
TOKEN = json.load(r)["data"]["token"]
// Keep the token in a constant your build or your secret store injects.
// The token page at /tokens.html will show you yours and copy it for you --
// there is never a reason to fish one out of a developer console.
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
// Or mint a scripted guest token, which can call /me and /estimate:
async function guestToken() {
const res = await fetch(BASE + "/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "spec-desk" })
});
const env = await res.json();
if (!env.ok) throw new Error(env.error.code);
return env.data.token;
}
package main
import (
"bytes"
"encoding/json"
"net/http"
"os"
)
const base = "https://api.skillsafe.ai/v1/app-api"
func token() string {
if t := os.Getenv("SKILLSAFE_TOKEN"); t != "" {
return t
}
res, err := http.Post(base+"/guest", "application/json",
bytes.NewBufferString(`{"slug":"spec-desk"}`))
if err != nil {
return ""
}
defer res.Body.Close()
var env struct {
Data struct {
Token string `json:"token"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
return env.Data.Token
}
import java.net.URI;
import java.net.http.*;
public class SpecDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final HttpClient HTTP = HttpClient.newHttpClient();
static String token() throws Exception {
String t = System.getenv("SKILLSAFE_TOKEN");
if (t != null && !t.isEmpty()) return t;
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"spec-desk\"}"))
.build();
return HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
// an envelope: read data.token with the JSON library you already use
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = ENV["SKILLSAFE_TOKEN"] || begin
res = Net::HTTP.post(URI(BASE + "/guest"),
JSON.dump({ slug: "spec-desk" }),
"Content-Type" => "application/json")
JSON.parse(res.body)["data"]["token"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$token = getenv("SKILLSAFE_TOKEN");
if (!$token) { // scripted guest fallback
$ch = curl_init(BASE . "/guest");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => ["Content-Type: application/json"],
CURLOPT_POSTFIELDS => json_encode(["slug" => "spec-desk"]),
]);
$token = json_decode(curl_exec($ch), true)["data"]["token"];
curl_close($ch);
}
using System.Text;
using System.Text.Json;
var BASE = "https://api.skillsafe.ai/v1/app-api";
var TOKEN = Environment.GetEnvironmentVariable("SKILLSAFE_TOKEN");
if (string.IsNullOrEmpty(TOKEN)) // scripted guest fallback
{
var body = new StringContent("{\"slug\":\"spec-desk\"}", Encoding.UTF8, "application/json");
var res = await new HttpClient().PostAsync(BASE + "/guest", body);
var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
TOKEN = env.GetProperty("data").GetProperty("token").GetString();
}
2. A tiny client
Every call is the same three lines: a bearer token, a JSON body, and the envelope unwrapped. Write it once and the rest of this page is one-liners.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="$SKILLSAFE_TOKEN"
call() { # call <path> <json-body>
curl -sS -X POST "$BASE$1" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-d "$2"
}
get() { # get <path>
curl -sS "$BASE$1" -H "Authorization: Bearer $TOKEN"
}
def call(path, body=None, method="POST"):
data = json.dumps(body).encode() if body is not None else None
req = urllib.request.Request(BASE + path, data=data, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
env = json.load(r)
if not env.get("ok"):
raise RuntimeError(env["error"]["code"] + ": " + env["error"]["message"])
return env["data"]
async function call(path, body, method = "POST") {
const res = await fetch(BASE + path, {
method,
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json"
},
body: body === undefined ? undefined : JSON.stringify(body)
});
const env = await res.json();
if (!env.ok) throw new Error(`${env.error.code}: ${env.error.message}`);
return env.data;
}
import (
"errors"
"io"
)
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(method, path string, body any) (json.RawMessage, error) {
var r io.Reader
if body != nil {
b, _ := json.Marshal(body)
r = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, r)
req.Header.Set("Authorization", "Bearer "+token())
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, errors.New(env.Error.Code + ": " + env.Error.Message)
}
return env.Data, nil
}
static String call(String path, String jsonBody) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + path))
.header("Authorization", "Bearer " + token())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(jsonBody))
.build();
HttpResponse<String> res = HTTP.send(req, HttpResponse.BodyHandlers.ofString());
return res.body(); // a JSON envelope: { ok, data } or { ok, error }
}
static String get(String path) throws Exception {
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + path))
.header("Authorization", "Bearer " + token())
.GET()
.build();
return HTTP.send(req, HttpResponse.BodyHandlers.ofString()).body();
}
def call(path, body = nil, method = :post)
uri = URI(BASE + path)
klass = method == :get ? Net::HTTP::Get : Net::HTTP::Post
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req.body = JSON.dump(body) if body
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
env = JSON.parse(res.body)
raise "#{env["error"]["code"]}: #{env["error"]["message"]}" unless env["ok"]
env["data"]
end
<?php
function call(string $path, ?array $body = null, string $method = "POST") {
global $token;
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_CUSTOMREQUEST => $method,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . $token,
"Content-Type: application/json",
],
]);
if ($body !== null) {
curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
}
$env = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($env["ok"])) {
throw new RuntimeException($env["error"]["code"] . ": " . $env["error"]["message"]);
}
return $env["data"];
}
using System.Net.Http.Headers;
var http = new HttpClient();
http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", TOKEN);
async Task<JsonElement> Call(string path, object? body = null)
{
var content = new StringContent(JsonSerializer.Serialize(body ?? new { }),
Encoding.UTF8, "application/json");
var res = await http.PostAsync(BASE + path, content);
var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!env.GetProperty("ok").GetBoolean())
throw new Exception(env.GetProperty("error").GetProperty("code").GetString());
return env.GetProperty("data");
}
async Task<JsonElement> Get(string path)
{
var res = await http.GetAsync(BASE + path);
var env = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!env.GetProperty("ok").GetBoolean())
throw new Exception(env.GetProperty("error").GetProperty("code").GetString());
return env.GetProperty("data");
}
3. Who am I, and what is the balance
GET /me is free and is what the app uses for its credit preflight: compare credits
against the min_credits that /estimate returns and refuse to submit rather than
collecting a 402 afterwards. subject_type is guest or user; a guest
balance of zero is normal and expected. In the browser, guests can browse the app, replay the bundled examples
and run the free client-side scan — a metered lane run needs a signed-in subject with credits.
get /me
# -> { "ok": true, "data": { "subject_type": "user", "credits": 250000 } }
me = call("/me", method="GET")
print(me["subject_type"], me["credits"])
const me = await call("/me", undefined, "GET");
console.log(me.subject_type, me.credits);
data, err := call("GET", "/me", nil)
if err != nil {
log.Fatal(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
json.Unmarshal(data, &me)
fmt.Println(me.SubjectType, me.Credits)
System.out.println(get("/me"));
// { "ok": true, "data": { "subject_type": "user", "credits": 250000 } }
me = call("/me", nil, :get)
puts me["subject_type"], me["credits"]
<?php
$me = call("/me", null, "GET");
echo $me["subject_type"], " ", $me["credits"], PHP_EOL;
var me = await Get("/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
Console.WriteLine(me.GetProperty("credits").GetInt32());
The input object
This is what goes in the input field of /estimate, /run and
/run-stream. The first block is sent on every lane; the per-lane blocks below add
to it. The prescan object is the browser's own reading of the same idea text, and it is what makes
the answer checkable: the reviewer is instructed not to re-derive those counts and signals, and to say so
explicitly when its own reading disagrees rather than silently overriding them.
{
"task": "spec | roadmap | update",
"idea_text": "the pasted idea, problem statement or feature request",
"idea_was_clipped": false,
"idea_clip_note": null,
"context": "optional free text: target users, product area, known constraints - or null",
"current_datetime": "2026-08-15T09:20:00-07:00",
"retry_note": "optional - present only on the app's one reformat retry",
"prescan": {
"word_count": 61,
"sentence_count": 3,
"has_problem_signal": true,
"has_user_signal": true,
"has_metric_signal": true,
"already_structured": false,
"too_short": false,
"readiness_score": 4,
"detected_sections": []
}
}
| Field | Type | Meaning |
|---|---|---|
task | string | The lane to run: spec, roadmap or update. Required — see the lane table above. |
idea_text | string | The user's pasted idea. The browser clips at 6,000 characters, keeping the start and the end (where the ask usually lives) and marking what it dropped. |
idea_was_clipped, idea_clip_note | boolean, string|null | Whether the idea was clipped and a human-readable note saying how much came out of the middle. |
context | string|null | Target users, product area, known constraints. May be null. It counts as evidence: an idea whose idea_text lacks a user signal can still be scoped when context supplies one. |
current_datetime | string | The caller's local timestamp, ISO 8601. Used for the update lane's tense and for "next update expected". |
retry_note | string | Send this only when a previous reply was malformed or missed a required field, naming precisely what to fix. The reviewer is told to obey it exactly. |
prescan | object | The in-browser scan of idea_text. Always present in app traffic and strongly recommended over the API — see below. |
The prescan object, and why readiness_score binds
| Key | Type | Meaning |
|---|---|---|
word_count, sentence_count | number | Length of the paste, counted before any model saw it. |
has_problem_signal | boolean | The text mentions a problem, a pain, "currently", "because", or another friction word. |
has_user_signal | boolean | The text names who is affected — a role, a segment, "users", "customers". |
has_metric_signal | boolean | The text carries a number, a rate, or a named metric. |
already_structured | boolean | The paste already contains section-like markers (Goals:, Non-goals:, Users:, numbered requirements). When true, the spec lane builds on the user's own sections instead of re-inventing structure beside them. |
too_short | boolean | Fewer than 12 words. When true, phasing must be empty and the verdict must not be ready-to-scope. |
readiness_score | 0-4 | How many of has_problem_signal, has_user_signal, has_metric_signal and not too_short are true. already_structured does not count — it describes format, not substance. The readiness_verdict must agree with this in direction, or say plainly in unknowns why it disagrees. |
detected_sections | string[] | The section-like headers the scanner found verbatim in the paste. |
roadmap lane only
{
"spec_summary": {
"title": "Teammate invites during trial",
"one_liner": "Trial accounts cannot invite teammates until a card is entered.",
"requirement_count": 6,
"must_count": 3,
"phasing_present": false,
"open_question_count": 4
},
"roadmap_text": "Now:\n- Usage-based billing meters\n- SSO for enterprise pilots\nNext:\n- ...",
"roadmap_was_clipped": false,
"roadmap_facts": {
"bucket_counts": { "now": 2, "next": 2, "later": 1, "unlabeled": 0 },
"items": [ { "label": "Usage-based billing meters", "bucket": "now" } ],
"parse_confidence": "high"
}
}
spec_summaryis the priorspecrun, summarised. It may benull— a caller runningroadmapwithout a spec first is normal, and the lane must handle it and say inunknownsthat placement was judged on the idea text alone.roadmap_textis the current roadmap in whatever free-form shape the user pastes. It may be an empty string; the lane then places the idea in the abstract rather than refusing. The browser clips it at 4,000 characters and setsroadmap_was_clipped.roadmap_factsis the browser's parse of that text.items[]is{label, bucket}withlabelverbatim;bucketisnow,next,laterorunlabeled.parse_confidenceishighwhen clear Now/Next/Later-style headers were found,lowwhen the text was unreadable as a board,emptywhen nothing was pasted.
parse_confidence gates the bumped list. At
low or empty, bumped must be [] and the rationale must say the
current roadmap could not be read with confidence — inventing an existing initiative to bump is the one
failure this lane must never produce. The app checks every returned bumped[].item against
roadmap_facts.items[].label and reports any that do not match.update lane only
{
"spec_summary": { "...": "as above, or null" },
"placement": {
"bucket": "next",
"rationale_one_liner": "It unblocks trial-team evaluation but waits on the seat-billing rule.",
"bumped_count": 1
},
"audience": "exec | eng | customer | team",
"cadence": "weekly | monthly | launch | escalation"
}
placementis the priorroadmaprun, summarised, and may benull. When it is, the body must not assert a bucket as settled — it says the placement is still being decided.audiencesets the register:execgets outcomes and asks with no implementation detail,enggets the technical shape and what is blocking,customergets what changes for them and when with no internal process or team names,teamgets full detail informally.cadenceshapes length and tense, not content.escalationopens with the ask and states the risk in the first two sentences;launchannounces something done, not something planned.
4. Estimate first — it is free
/estimate creates no job, starts no run and charges nothing. Post the same
{"input": ...} body you will send to /run. Estimate per lane: a
spec run and an update run over the same idea do not cost the same, and a
roadmap run carries your whole pasted board on top of the idea.
| Field | Meaning |
|---|---|
model | The resolved model that will serve the run — gpt-5.6-terra today. |
model_alias | The alias the app asked for (gpt-terra); the resolved model can move under it. |
markup_bps | The app's markup in basis points, applied on top of provider cost. Spec Desk declares 1000. |
hold_credits | What will be reserved, priced against the full output cap. |
min_credits | The floor. Below this the run is refused with payment_required. |
sponsor_enabled | True when the app owner sponsors runs, so a subject with no balance can still run. |
Hold is not price. The hold is a reservation against the worst case; the settled
charged_credits on the finished job is usually far lower, and the difference is released. If the
balance sits between min_credits and hold_credits the run still executes with a
reduced output cap and comes back with "truncated": true — treat a truncated spec as partial
and say so, rather than shipping it as a finished PRD.
call /estimate "$(cat body.json)"
# body.json is { "input": { "task": "spec", ...the input object... } }
# -> { "ok": true, "data": { "model": "gpt-5.6-terra", "model_alias": "gpt-terra",
# "markup_bps": 1000, "hold_credits": 2100,
# "min_credits": 120, "sponsor_enabled": false } }
payload = {"input": app_input} # app_input is the object above
est = call("/estimate", payload)
print(est["model"], est["model_alias"], est["markup_bps"], est["sponsor_enabled"])
if not est["sponsor_enabled"] and me["credits"] < est["min_credits"]:
raise SystemExit("top up before running")
print("reserving up to", est["hold_credits"], "credits")
const payload = { input: appInput };
const est = await call("/estimate", payload);
if (!est.sponsor_enabled && me.credits < est.min_credits) {
throw new Error("top up before running");
}
console.log(`reserving up to ${est.hold_credits} credits on ${est.model}`);
payload := map[string]any{"input": appInput}
data, err := call("POST", "/estimate", payload)
if err != nil {
log.Fatal(err)
}
var est struct {
Model string `json:"model"`
ModelAlias string `json:"model_alias"`
MarkupBps int `json:"markup_bps"`
HoldCredits int `json:"hold_credits"`
MinCredits int `json:"min_credits"`
SponsorEnabled bool `json:"sponsor_enabled"`
}
json.Unmarshal(data, &est)
if !est.SponsorEnabled && me.Credits < est.MinCredits {
log.Fatal("top up before running")
}
String est = call("/estimate", payloadJson);
System.out.println(est);
// data: model, model_alias, markup_bps, hold_credits, min_credits, sponsor_enabled
payload = { input: app_input }
est = call("/estimate", payload)
abort("top up before running") if !est["sponsor_enabled"] && me["credits"] < est["min_credits"]
puts "reserving up to #{est["hold_credits"]} on #{est["model"]}"
<?php
$payload = ["input" => $appInput];
$est = call("/estimate", $payload);
if (!$est["sponsor_enabled"] && $me["credits"] < $est["min_credits"]) {
exit("top up before running\n");
}
echo "reserving up to {$est["hold_credits"]} on {$est["model"]}\n";
var payload = new { input = appInput };
var est = await Call("/estimate", payload);
Console.WriteLine(est.GetProperty("model").GetString());
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
if (!est.GetProperty("sponsor_enabled").GetBoolean() &&
me.GetProperty("credits").GetInt32() < est.GetProperty("min_credits").GetInt32())
{
throw new Exception("top up before running");
}
5. Run it — with an Idempotency-Key
POST /run returns a job immediately; poll GET /jobs/{id} until
status is succeeded or failed. The model's object arrives as a
string in output.output, so parse it.
A retry must reuse the same key. The app derives
spec-desk:<lane>:<hash-of-idea>:a<attempt>, and its one automatic reformat retry
reuses a key derived from the same input, so a malformed first reply can never double-bill. Do the same: a
timeout, a dropped connection or a 500 that makes you retry must not become a second charge. The lane belongs in
the key — running spec and then roadmap over the same idea are two different runs
and must not collide. Only change the attempt suffix when you are deliberately asking for a new answer.
KEY="spec-desk:spec:9f31c0ab:a1"
curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
--data @body.json
# -> { "ok": true, "data": { "job_id": "job_...", "status": "queued" } }
get /jobs/job_xxxxxxxx
# poll until status is "succeeded" or "failed"; the result is data.output.output
import time
KEY = "spec-desk:spec:9f31c0ab:a1"
req = urllib.request.Request(BASE + "/run", data=json.dumps(payload).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", KEY) # reuse this exact key on any retry
with urllib.request.urlopen(req) as r:
job = json.load(r)["data"]
while True:
j = call("/jobs/" + job["job_id"], method="GET")
if j["status"] in ("succeeded", "failed"):
break
time.sleep(1.5)
if j["status"] == "failed":
raise SystemExit(j.get("error", "the run failed"))
result = json.loads(j["output"]["output"])
print(result["lane"], "-", result["spec"]["readiness_verdict"])
const KEY = "spec-desk:spec:9f31c0ab:a1";
const job = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": KEY // reuse this exact key on any retry
},
body: JSON.stringify(payload)
}).then(r => r.json()).then(e => {
if (!e.ok) throw new Error(e.error.code);
return e.data;
});
let j;
do {
await new Promise(r => setTimeout(r, 1500));
j = await call(`/jobs/${job.job_id}`, undefined, "GET");
} while (j.status !== "succeeded" && j.status !== "failed");
if (j.status === "failed") throw new Error("the run failed");
const result = JSON.parse(j.output.output);
console.log(result.lane, result.spec.requirements.length, "requirements");
const key = "spec-desk:spec:9f31c0ab:a1"
b, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", base+"/run", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key) // reuse this exact key on any retry
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var env struct {
Data struct {
JobID string `json:"job_id"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
for {
d, err := call("GET", "/jobs/"+env.Data.JobID, nil)
if err != nil {
log.Fatal(err)
}
var j struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
json.Unmarshal(d, &j)
if j.Status == "succeeded" || j.Status == "failed" {
// json.Unmarshal([]byte(j.Output.Output), &result)
break
}
time.Sleep(1500 * time.Millisecond)
}
String KEY = "spec-desk:spec:9f31c0ab:a1";
HttpRequest run = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + token())
.header("Content-Type", "application/json")
.header("Idempotency-Key", KEY) // reuse this exact key on any retry
.POST(HttpRequest.BodyPublishers.ofString(payloadJson))
.build();
String job = HTTP.send(run, HttpResponse.BodyHandlers.ofString()).body();
// then poll GET /jobs/{job_id} until status is succeeded or failed,
// and parse data.output.output -- it is the result as a JSON *string*.
String polled = get("/jobs/" + jobId);
KEY = "spec-desk:spec:9f31c0ab:a1"
uri = URI(BASE + "/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = KEY # reuse this exact key on any retry
req.body = JSON.dump(payload)
job = JSON.parse(Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }.body)["data"]
j = nil
loop do
j = call("/jobs/#{job["job_id"]}", nil, :get)
break if %w[succeeded failed].include?(j["status"])
sleep 1.5
end
abort("the run failed") if j["status"] == "failed"
result = JSON.parse(j["output"]["output"])
puts "#{result["lane"]} - #{result["spec"]["readiness_verdict"]}"
<?php
$key = "spec-desk:spec:9f31c0ab:a1";
$ch = curl_init(BASE . "/run");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . $token,
"Content-Type: application/json",
"Idempotency-Key: " . $key, // reuse this exact key on any retry
],
CURLOPT_POSTFIELDS => json_encode($payload),
]);
$job = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
do {
sleep(2);
$j = call("/jobs/" . $job["job_id"], null, "GET");
} while (!in_array($j["status"], ["succeeded", "failed"], true));
if ($j["status"] === "failed") {
exit("the run failed\n");
}
$result = json_decode($j["output"]["output"], true);
var key = "spec-desk:spec:9f31c0ab:a1";
var runMsg = new HttpRequestMessage(HttpMethod.Post, BASE + "/run")
{
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
runMsg.Headers.Add("Idempotency-Key", key); // reuse this exact key on any retry
var runRes = await http.SendAsync(runMsg);
var jobId = JsonDocument.Parse(await runRes.Content.ReadAsStringAsync())
.RootElement.GetProperty("data").GetProperty("job_id").GetString();
JsonElement j;
string status;
do
{
await Task.Delay(1500);
j = await Get("/jobs/" + jobId);
status = j.GetProperty("status").GetString()!;
} while (status != "succeeded" && status != "failed");
if (status == "failed") throw new Exception("the run failed");
var result = JsonDocument.Parse(
j.GetProperty("output").GetProperty("output").GetString()!).RootElement;
6. Streaming, if you want the progress
POST /run-stream is the same request with a server-sent-event response. Frame names arrive on
the event: line and the payload on data:; concatenating every delta
text gives you the same JSON string that output.output would have held. The app uses this to
advance its staged progress card off real signals — each top-level field name appearing in the stream
moves it on, so a spec run visibly passes problem_statement, requirements and
readiness_verdict rather than animating a timer. The same Idempotency-Key discipline
applies.
curl -N -sS -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
--data @body.json
# event: job
# data: {"job_id":"job_..."}
# event: delta
# data: {"text":"{\"lane\":\"spec\",\"title\":\"Teammate invites"}
# event: delta
# data: {"text":" during trial\",\"summary\":\"Trial accounts cannot"}
# event: done
# data: {"status":"succeeded","charged_credits":612,"truncated":false}
req = urllib.request.Request(BASE + "/run-stream", data=json.dumps(payload).encode())
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", KEY)
raw, event = "", None
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:") and event == "delta":
raw += json.loads(line[5:])["text"]
result = json.loads(raw)
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": KEY
},
body: JSON.stringify(payload)
});
const reader = res.body.getReader();
const dec = new TextDecoder();
let buf = "", raw = "", event = null;
for (;;) {
const { done, value } = await reader.read();
if (done) break;
buf += dec.decode(value, { stream: true });
const lines = buf.split("\n");
buf = lines.pop();
for (const line of lines) {
if (line.startsWith("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:") && event === "delta") raw += JSON.parse(line.slice(5)).text;
}
}
const result = JSON.parse(raw);
req, _ = http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
res, _ = http.DefaultClient.Do(req)
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
var event, raw string
for sc.Scan() {
line := sc.Text()
switch {
case strings.HasPrefix(line, "event:"):
event = strings.TrimSpace(line[6:])
case strings.HasPrefix(line, "data:") && event == "delta":
var d struct{ Text string }
json.Unmarshal([]byte(line[5:]), &d)
raw += d.Text
}
}
// raw is now the result as JSON text
HttpRequest stream = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + token())
.header("Content-Type", "application/json")
.header("Idempotency-Key", KEY)
.POST(HttpRequest.BodyPublishers.ofString(payloadJson))
.build();
StringBuilder raw = new StringBuilder();
String[] evt = { null };
HTTP.send(stream, HttpResponse.BodyHandlers.ofLines())
.body()
.forEach(l -> {
if (l.startsWith("event:")) evt[0] = l.substring(6).trim();
else if (l.startsWith("data:") && "delta".equals(evt[0]))
raw.append(textOf(l.substring(5))); // your JSON reader, field "text"
});
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = KEY
req.body = JSON.dump(payload)
raw, event = +"", nil
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(req) do |res|
res.read_body do |chunk|
chunk.each_line do |line|
event = line[6..].strip if line.start_with?("event:")
raw << JSON.parse(line[5..])["text"] if line.start_with?("data:") && event == "delta"
end
end
end
end
result = JSON.parse(raw)
<?php
$raw = "";
$event = null;
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . $token,
"Content-Type: application/json",
"Idempotency-Key: " . $key,
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$raw, &$event) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "event:")) {
$event = trim(substr($line, 6));
} elseif (str_starts_with($line, "data:") && $event === "delta") {
$raw .= json_decode(substr($line, 5), true)["text"] ?? "";
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
$result = json_decode($raw, true);
var streamMsg = new HttpRequestMessage(HttpMethod.Post, BASE + "/run-stream")
{
Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json")
};
streamMsg.Headers.Add("Idempotency-Key", key);
var streamRes = await http.SendAsync(streamMsg, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await streamRes.Content.ReadAsStreamAsync());
var raw = new StringBuilder();
string? evt = null, line;
while ((line = await reader.ReadLineAsync()) != null)
{
if (line.StartsWith("event:")) evt = line[6..].Trim();
else if (line.StartsWith("data:") && evt == "delta")
raw.Append(JsonDocument.Parse(line[5..]).RootElement.GetProperty("text").GetString());
}
var result = JsonDocument.Parse(raw.ToString()).RootElement;
The output contract
One strict JSON object, no fence and no prose around it. The envelope is identical in all three lanes:
{
"lane": "spec | roadmap | update",
"title": "one line naming the idea and what this lane produced",
"summary": "2-4 sentences: what the idea is and the one thing that matters about this lane's result",
"unknowns": [ "what could not be confirmed from the input, and what would settle it" ],
"grounding": [ "claim - (from the input: the specific idea_text/context/prescan fact used)" ],
"spec": { "...": "..." }, // non-null only when lane is "spec"
"roadmap": null, // present as a key, with a null value
"update": null
}
- Exactly one of
spec/roadmap/updateis non-null, and it matcheslane. The other two are present as keys with anullvalue — not omitted. A client can therefore readresult[result.lane]and be done. groundingis one bullet per substantive claim, naming the input fact behind it. It is what makes the answer checkable; it is not padding and it is not optional.- An empty list is the literal
[], never a one-element array holding the string "None". - Numbers are never invented. Where the idea supplies one it is used and attributed; where it does not, the claim goes to
open_questionsorunknownsinstead of being estimated into existence.
The spec object
{
"problem_statement": "1-3 sentences, the problem as the input actually supports it",
"goals": [ "what this must achieve" ],
"non_goals": [ "what this deliberately does not attempt, and stays out of scope" ],
"users": [ "who is affected, as stated or reasonably inferred from context" ],
"requirements": [
{ "id": "R1", "text": "...", "priority": "must | should | could" }
],
"acceptance_criteria": [
{ "requirement_id": "R1", "criterion": "a testable statement of done" }
],
"success_metrics": [ "a metric and, where the input supports it, a target" ],
"phasing": [
{ "phase": "1", "scope": "...", "exit_criteria": "..." }
],
"open_questions": [ "what still needs deciding before this is buildable" ],
"readiness_verdict": "ready-to-scope | needs-more-input | too-broad-split-further"
}
| Rule | What it means for a client |
|---|---|
Requirement ids are R1, R2, ... in listed order | Every acceptance_criteria[].requirement_id is one of them. No orphaned criteria, no criterion pointing at an id that does not exist. The app counts the orphans and reports them beside the spec. |
readiness_verdict agrees with prescan.readiness_score in direction | A score of 0-1 cannot honestly produce ready-to-scope unless context supplied what idea_text lacked; a score of 3-4 is not downgraded on vibes. A deliberate disagreement is stated in unknowns, not left implicit. |
phasing is [] unless the idea is genuinely too big for one release | Phases are not invented to look thorough. When prescan.too_short is true, phasing is empty and the verdict is not ready-to-scope. |
already_structured input | The user's own sections become the basis for goals/non_goals/users, and summary says the spec was built on their structure rather than beside it. |
non_goals are real | "Not slow" is not a non-goal. Each names something plausible this could have covered and deliberately does not. |
The roadmap object
{
"placement": "now | next | later | not-now",
"rationale": "why this bucket, citing roadmap_facts and spec_summary where available",
"bumped": [
{ "item": "verbatim from roadmap_facts.items", "from_bucket": "next",
"to_bucket": "later", "why": "..." }
],
"updated_view": { "now": [ "..." ], "next": [ "..." ], "later": [ "..." ] },
"dependencies_flagged": [ "a dependency on something else on the board, or that this blocks" ],
"risk_if_delayed": "what happens if this sits",
"risk_if_rushed": "what breaks if this is forced into `now` without the groundwork"
}
| Rule | What it means for a client |
|---|---|
bumped[].item comes from roadmap_facts.items, verbatim | No invented initiatives. At parse_confidence of low or empty, bumped is [] and the rationale says the board could not be read with confidence. The app diffs every returned item against the parsed labels and names any that do not match. |
Nothing disappears from updated_view | Every item the prescan found is still on the board — in its original bucket unless it appears in bumped, in which case it is in its to_bucket — plus the new idea in whichever bucket was chosen. With no parsed items, updated_view holds only the new idea. |
placement: "now" needs evidence | Urgency is justified from a spec_summary signal (must_count, phasing_present) or a roadmap_facts gap such as an empty now bucket. later and not-now are legitimate, common answers; next is not a default chosen to look balanced. |
spec_summary: null | unknowns says placement was judged on the idea text alone, without a scoped spec, and that the bucket may change once one exists. |
The update object
{
"audience_addressed": "exec | eng | customer | team",
"cadence_addressed": "weekly | monthly | launch | escalation",
"subject_line": "a real, specific subject line - not \"Update\"",
"body_markdown": "the full update, ready to send, formatted for the audience and cadence",
"key_asks": [ "a specific, actionable ask of this audience - empty if there genuinely is none" ],
"risk_callouts": [ "anything at risk that this audience must hear - empty if none" ],
"next_update_expected": "when the next one is due, phrased for the cadence"
}
| Rule | What it means for a client |
|---|---|
audience_addressed / cadence_addressed | Echo the input values back, so a stored update carries the register it was written in. |
Register matches audience | customer bodies carry no internal process detail and no internal team names; exec carries outcomes and asks, not implementation. |
placement: null | body_markdown must not assert a roadmap bucket as settled. The app checks the body for language that reads as a confirmed bucket when no placement was supplied, and flags it. |
key_asks on a customer update | Usually []. An ask appears only when the idea genuinely requires the customer to do something — give feedback, opt in, migrate. |
| Dates | No specific promised date unless idea_text, context or spec_summary supplies one. Otherwise timing is qualitative: a phase, a quarter-relative, "after X lands". |
unknowns with a note to route it — not in a confident
go-ahead. Named individuals are never invented; roles are fine, a name only appears if it appeared in the paste.One worked example per lane
All three run over the same pasted idea, in the order the app runs them, so you can see exactly what each lane hands the next. The idea is one real paragraph of the kind that arrives in Slack:
Trial accounts can't invite teammates until someone enters a credit card. Support gets around 15 tickets a week asking for it, and about 30% of trials that stall at the invite step never come back. Sales says every evaluation is a team decision anyway, so a single-seat trial is the wrong shape for how people actually buy.
task: "spec" — request
Nothing beyond the shared fields. The prescan found a problem signal ("can't", "stall"), a user signal
("team", "teammates") and a metric signal ("30%"), and the paste clears the 12-word floor, so
readiness_score is 4 of 4.
{
"input": {
"task": "spec",
"idea_text": "Trial accounts can't invite teammates until someone enters a credit card. Support gets around 15 tickets a week asking for it, and about 30% of trials that stall at the invite step never come back. Sales says every evaluation is a team decision anyway, so a single-seat trial is the wrong shape for how people actually buy.",
"idea_was_clipped": false,
"idea_clip_note": null,
"context": "B2B analytics product, self-serve tier. Billing is seat-based, so anyone invited during a trial must not create a billable seat. The trial is 14 days.",
"current_datetime": "2026-08-15T09:20:00-07:00",
"prescan": {
"word_count": 57,
"sentence_count": 3,
"has_problem_signal": true,
"has_user_signal": true,
"has_metric_signal": true,
"already_structured": false,
"too_short": false,
"readiness_score": 4,
"detected_sections": []
}
}
}
task: "spec" — response
{
"lane": "spec",
"title": "Teammate invites during a self-serve trial",
"summary": "Trial accounts are single-seat until a card is entered, so a product bought by a team is evaluated by one person. The paste supplies both a support volume and a stall rate, and the context supplies the constraint that decides the shape of the work: invited trial users must not become billable seats. This is ready to scope, and the seat-counting rule is the requirement everything else hangs off.",
"unknowns": [
"Where the 30% stall figure was measured, over what window, and whether it counts trials that stalled anywhere or only at the invite step - the paste asserts it without a source.",
"Whether an invited teammate becomes a billable seat at conversion or only when explicitly kept. That is a billing decision, not a product one; route it to whoever owns the seat model.",
"Whether invited teammates need any access restriction at all during the trial, or share the owner's view. Nothing in the paste says.",
"Whether an invite to a personal email domain raises a data-access question for the workspace's existing data. If the trial workspace can contain customer data, that belongs with legal or security before build, not in this spec."
],
"grounding": [
"The problem is an invite gate, not an onboarding gap - (from idea_text: trial accounts cannot invite teammates until someone enters a credit card).",
"Support load is real and quantified - (from idea_text: around 15 tickets a week asking for it).",
"A stall rate exists to measure against - (from idea_text: about 30% of trials that stall at the invite step never come back).",
"The billing constraint shapes the design - (from context: billing is seat-based, so anyone invited during a trial must not create a billable seat).",
"The idea clears the readiness floor on its own - (from prescan: readiness_score 4 of 4, 57 words, problem, user and metric signals all present)."
],
"spec": {
"problem_statement": "Self-serve trials are single-seat until a credit card is entered, so an evaluation that is a team decision is run by one person. The paste reports around 15 support tickets a week asking for invites and about 30% of trials that stall at the invite step never returning.",
"goals": [
"Let a trial owner invite teammates into the trial workspace without entering a card.",
"Keep invited trial teammates outside the seat count until the account converts, so nothing about billing changes during the trial.",
"Remove the invite question from the support queue by making the path obvious in-product."
],
"non_goals": [
"Changing the seat-based price model or what a seat costs after conversion.",
"Building roles and granular permissions. Invited trial teammates see what the owner sees; a permission model is a separate piece of work that this deliberately does not start.",
"Reworking card capture or the conversion flow itself.",
"Supporting invites into paid accounts, which already work and are out of scope here."
],
"users": [
"The trial owner - the person who signed up and currently holds the only seat.",
"Invited teammates, who have no account and arrive from an emailed link.",
"Support agents, who field the invite tickets named in the paste.",
"Sales, who told the team that evaluations are team decisions."
],
"requirements": [
{ "id": "R1", "text": "A trial owner can send an invite by email address from inside the product, with no card on file.", "priority": "must" },
{ "id": "R2", "text": "An invited teammate who accepts joins the same trial workspace and does not create a billable seat while the trial is active.", "priority": "must" },
{ "id": "R3", "text": "At conversion, the account owner is shown exactly which invited teammates will become paid seats and can drop any of them before paying.", "priority": "must" },
{ "id": "R4", "text": "Invites expire with the trial, and an unaccepted invite after trial end does not grant access.", "priority": "should" },
{ "id": "R5", "text": "The invite entry point is discoverable from the workspace view without visiting settings.", "priority": "should" },
{ "id": "R6", "text": "The trial owner can revoke an outstanding or accepted invite.", "priority": "could" }
],
"acceptance_criteria": [
{ "requirement_id": "R1", "criterion": "A trial account with no payment method on file can send an invite and receives a confirmation that it was sent." },
{ "requirement_id": "R2", "criterion": "After an invited teammate accepts, the account's billable seat count is unchanged and no invoice preview changes." },
{ "requirement_id": "R3", "criterion": "The conversion screen lists every accepted teammate with a control to exclude them, and the price shown updates to match the retained set." },
{ "requirement_id": "R4", "criterion": "An invite link opened after the trial end date returns an expired state and grants no workspace access." },
{ "requirement_id": "R5", "criterion": "The invite control is reachable in one click from the default workspace view for a trial account." },
{ "requirement_id": "R6", "criterion": "Revoking an accepted invite removes that teammate's access on their next request, not at next login." }
],
"success_metrics": [
"Share of trials that stall at the invite step. The paste's 30% is the only baseline supplied and its source is unconfirmed - confirm it before treating it as a target.",
"Invite-related support tickets per week, against the roughly 15 a week the paste reports.",
"Share of trials with at least two active people, which is the behaviour this is actually trying to produce."
],
"phasing": [],
"open_questions": [
"Does an accepted invite that is dropped at conversion keep read access, lose it immediately, or get a grace period?",
"Is there a cap on trial teammates? Without one, an invite endpoint that requires no card is an abuse surface worth reviewing with security.",
"Does the invited teammate get their own login, or join under the owner's session model?",
"Who owns the seat-counting rule in R2 - the billing service or the workspace service? The answer decides which team builds it."
],
"readiness_verdict": "ready-to-scope"
},
"roadmap": null,
"update": null
}
phasing is [] here on purpose. The idea is one
release's worth of work, and the contract says phases are not invented to look thorough. A genuinely oversized
idea returns {"phase": "1", "scope": "...", "exit_criteria": "..."} entries and, usually, a
readiness_verdict of too-broad-split-further.task: "roadmap"
The same idea and the same prescan, plus the summary of the spec above and the board the user pasted. The
browser parsed five items under three recognised headers, so parse_confidence is
high and naming an existing item to bump is legitimate.
Request
{
"input": {
"task": "roadmap",
"idea_text": "Trial accounts can't invite teammates until someone enters a credit card. Support gets around 15 tickets a week asking for it, and about 30% of trials that stall at the invite step never come back. Sales says every evaluation is a team decision anyway, so a single-seat trial is the wrong shape for how people actually buy.",
"idea_was_clipped": false,
"idea_clip_note": null,
"context": "B2B analytics product, self-serve tier. Billing is seat-based, so anyone invited during a trial must not create a billable seat. The trial is 14 days.",
"current_datetime": "2026-08-15T09:31:00-07:00",
"prescan": {
"word_count": 57,
"sentence_count": 3,
"has_problem_signal": true,
"has_user_signal": true,
"has_metric_signal": true,
"already_structured": false,
"too_short": false,
"readiness_score": 4,
"detected_sections": []
},
"spec_summary": {
"title": "Teammate invites during a self-serve trial",
"one_liner": "Trial accounts are single-seat until a card is entered, so a product bought by a team is evaluated by one person.",
"requirement_count": 6,
"must_count": 3,
"phasing_present": false,
"open_question_count": 4
},
"roadmap_text": "Now:\n- Usage-based billing meters\n- SSO for enterprise pilots\nNext:\n- Scheduled report exports\n- Audit log v2\nLater:\n- Mobile dashboards",
"roadmap_was_clipped": false,
"roadmap_facts": {
"bucket_counts": { "now": 2, "next": 2, "later": 1, "unlabeled": 0 },
"items": [
{ "label": "Usage-based billing meters", "bucket": "now" },
{ "label": "SSO for enterprise pilots", "bucket": "now" },
{ "label": "Scheduled report exports", "bucket": "next" },
{ "label": "Audit log v2", "bucket": "next" },
{ "label": "Mobile dashboards", "bucket": "later" }
],
"parse_confidence": "high"
}
}
}
Response
{
"lane": "roadmap",
"title": "Teammate invites during trial - placed in Next, behind the billing meters",
"summary": "The idea earns Next rather than Now because its must-have seat rule depends on work already in Now: the usage-based billing meters own seat counting. Both Now slots are taken and neither is a candidate to move. Audit log v2 drops to Later to keep Next at two items.",
"unknowns": [
"Whether the usage-based billing meters work actually exposes a seat-count rule this can reuse, or only meters usage. If it does not, this needs its own billing change and the placement is optimistic.",
"Whether Audit log v2 has a compliance commitment behind it. The pasted board carries no dates, owners or commitments, so bumping it is a suggestion, not a decision."
],
"grounding": [
"The dependency is real and not invented - (from context: billing is seat-based, so anyone invited during a trial must not create a billable seat; and from roadmap_facts.items: Usage-based billing meters sits in now).",
"Now has no free slot - (from roadmap_facts.bucket_counts: now holds 2 items).",
"The spec is scoped enough to place - (from spec_summary: 6 requirements, 3 of them must, phasing_present false).",
"The bumped item is one the scanner actually read - (from roadmap_facts.items: Audit log v2, bucket next, parse_confidence high)."
],
"spec": null,
"roadmap": {
"placement": "next",
"rationale": "The spec's three must requirements all sit on top of one rule - an invited trial teammate must not become a billable seat - and the work that owns seat counting, Usage-based billing meters, is in Now and not yet done. Starting this in Now means building that rule twice. Now is also full at two items, and neither of them is a candidate to move: the meters block this one, and SSO for enterprise pilots touches the same account-provisioning path. Next is the earliest honest slot.",
"bumped": [
{
"item": "Audit log v2",
"from_bucket": "next",
"to_bucket": "later",
"why": "Next already holds two items and this one has no stated dependency or commitment in the pasted board, while the invite work has a quantified stall rate behind it."
}
],
"updated_view": {
"now": [ "Usage-based billing meters", "SSO for enterprise pilots" ],
"next": [ "Scheduled report exports", "Teammate invites during trial" ],
"later": [ "Mobile dashboards", "Audit log v2" ]
},
"dependencies_flagged": [
"Usage-based billing meters (Now) owns seat counting, which requirement R2 depends on. If the meters ship without a seat-exclusion concept, the invite work has to add one.",
"SSO for enterprise pilots (Now) touches the same account-provisioning and identity path an invited teammate arrives through. The two should not be built in parallel by different people without agreeing that path first.",
"The conversion screen change in R3 blocks nothing on this board but does need whoever owns checkout."
],
"risk_if_delayed": "The stall rate the paste quotes keeps compounding across every trial cohort, and support keeps absorbing roughly 15 tickets a week that no amount of documentation removes. The cost is invisible because it lands as trials that quietly do not convert rather than as complaints.",
"risk_if_rushed": "Forcing it into Now means writing a seat-exclusion rule before the billing meters define seat counting, which is the exact kind of thing that gets written twice and disagrees with itself at conversion. The failure is a customer who is billed for teammates they thought were trial-only, which is worse than shipping a fortnight later."
},
"update": null
}
updated_view —
four where they started, one where bumped moved it — plus the new idea. Had
parse_confidence come back low or empty, bumped would be
[] and the rationale would say so instead of naming an item.task: "update"
The same idea again, plus both handoffs and the two fields this lane needs: audience and
cadence. Here they are exec and weekly, so the body carries outcomes and
asks and no implementation detail. Send eng instead and the same run returns the technical shape and
what is blocking; send customer and internal team names disappear entirely.
Request
{
"input": {
"task": "update",
"idea_text": "Trial accounts can't invite teammates until someone enters a credit card. Support gets around 15 tickets a week asking for it, and about 30% of trials that stall at the invite step never come back. Sales says every evaluation is a team decision anyway, so a single-seat trial is the wrong shape for how people actually buy.",
"idea_was_clipped": false,
"idea_clip_note": null,
"context": "B2B analytics product, self-serve tier. Billing is seat-based, so anyone invited during a trial must not create a billable seat. The trial is 14 days.",
"current_datetime": "2026-08-15T09:42:00-07:00",
"prescan": {
"word_count": 57,
"sentence_count": 3,
"has_problem_signal": true,
"has_user_signal": true,
"has_metric_signal": true,
"already_structured": false,
"too_short": false,
"readiness_score": 4,
"detected_sections": []
},
"spec_summary": {
"title": "Teammate invites during a self-serve trial",
"one_liner": "Trial accounts are single-seat until a card is entered, so a product bought by a team is evaluated by one person.",
"requirement_count": 6,
"must_count": 3,
"phasing_present": false,
"open_question_count": 4
},
"placement": {
"bucket": "next",
"rationale_one_liner": "The seat rule depends on the usage-based billing meters already in Now, and Now is full.",
"bumped_count": 1
},
"audience": "exec",
"cadence": "weekly"
}
}
Response
{
"lane": "update",
"title": "Weekly exec note on trial teammate invites",
"summary": "The idea is scoped and placed in Next, with one billing decision outstanding that an exec can actually unblock. The update leads with the outcome and the ask rather than the design, and states the stall rate as the paste supplied it rather than as a measured baseline.",
"unknowns": [
"Whether the reader already knows the trial is single-seat. The paste gives no history, so the note explains the problem in one line rather than assuming context.",
"The 30% stall figure has no stated source or window, so it is quoted as reported, not as measured."
],
"grounding": [
"The ask is the real blocker - (from spec_summary: 3 must requirements, and from placement.rationale_one_liner: the seat rule depends on work already in Now).",
"The bucket is stated as decided - (from placement.bucket: next).",
"The volume claims come from the paste - (from idea_text: around 15 tickets a week, about 30% of stalled trials never returning).",
"No delivery date is promised - (from the input: neither idea_text, context nor spec_summary supplies one)."
],
"spec": null,
"roadmap": null,
"update": {
"audience_addressed": "exec",
"cadence_addressed": "weekly",
"subject_line": "Trial invites scoped and placed in Next - one seat-billing decision blocks the estimate",
"body_markdown": "## Trial teammate invites - weekly note\n\n**Where it stands.** Self-serve trials are single-seat until a card is entered, so a product that gets bought by a team is evaluated by one person. The work is now scoped: six requirements, three of them must-have. It sits in Next on the roadmap, behind the usage-based billing meters that already own seat counting, and one item moved to Later to make room.\n\n**Why it is worth the slot.** Support reports around 15 tickets a week asking for invites, and about 30% of trials that stall at the invite step do not come back. Both figures come from the original report rather than from our own analytics, so treat them as the reason to look, not as a measured baseline.\n\n**What is blocking.** Whether a teammate invited during a trial becomes a billable seat when the account converts is a billing decision, not a product one. Until it is made, the estimate has a range rather than a number, and the conversion screen cannot be designed.\n\n**Timing.** No date yet. It starts when the billing meters land, which is the dependency, not a scheduling preference.\n",
"key_asks": [
"A decision, or a named owner for it, on whether invited trial teammates become billable seats at conversion. This is the one thing holding the estimate.",
"Confirmation of who owns the seat-counting rule between billing and workspace, so the work lands with one team rather than being negotiated between two."
],
"risk_callouts": [
"If the billing meters ship without a seat-exclusion concept, this work grows by whatever a second seat-counting rule costs, and the two can disagree at conversion.",
"The stall rate is unverified. If it turns out to be measured differently, the case for the Next slot weakens and the item should be re-argued rather than quietly kept."
],
"next_update_expected": "In a week, with the seat-billing decision either made or escalated by name."
}
}
key_asks would usually be [] on a
customer-audience run — an ask is only manufactured there if the idea genuinely needs the
customer to do something.7. Check the reply against what you posted
The app never trusts the object it just paid for, and neither should your script. Three of its checks are
pure comparison against the input you already hold, so they are worth reproducing wherever the run happens:
the lane — exactly one of the three objects is non-null and it is the one
lane names; the requirement ids — every
acceptance_criteria[].requirement_id exists in requirements; and
the bumped items — every bumped[].item appears verbatim in
roadmap_facts.items, and the list is empty whenever parse_confidence was not
high.
# bundle.json = { "input": {...as posted...}, "result": {...the parsed object...} }
jq '
(.result.lane) as $lane
| ([ "spec","roadmap","update" ] | map(select(.result[.] != null))) as $present
| ((.result.spec.requirements // []) | map(.id)) as $ids
| ((.result.spec.acceptance_criteria // []) | map(.requirement_id)) as $refs
| ((.input.roadmap_facts.items // []) | map(.label)) as $labels
| ((.result.roadmap.bumped // []) | map(.item)) as $bumped
| { lane_ok: ($present == [ $lane ]),
orphan_criteria: ($refs - $ids | unique),
invented_bumps: ($bumped - $labels | unique) }
' bundle.json
# lane_ok true and both lists empty means the reply agrees with what you posted
lanes = ["spec", "roadmap", "update"]
present = [k for k in lanes if result.get(k) is not None]
if present != [result.get("lane")]:
raise SystemExit(f"lane is {result.get('lane')} but {present} came back non-null")
spec = result.get("spec") or {}
ids = {r["id"] for r in spec.get("requirements", [])}
orphans = [c["requirement_id"] for c in spec.get("acceptance_criteria", [])
if c["requirement_id"] not in ids]
if orphans:
raise SystemExit("acceptance criteria point at unknown requirements: " + ", ".join(orphans))
facts = app_input.get("roadmap_facts") or {}
labels = {i["label"] for i in facts.get("items", [])}
bumped = [b["item"] for b in (result.get("roadmap") or {}).get("bumped", [])]
if bumped and facts.get("parse_confidence") != "high":
raise SystemExit("items were bumped although the roadmap parse was " + str(facts.get("parse_confidence")))
invented = [b for b in bumped if b not in labels]
if invented:
raise SystemExit("bumped items not on the pasted board: " + ", ".join(invented))
print(result["lane"], "-", result["title"])
const lanes = ["spec", "roadmap", "update"];
const present = lanes.filter(k => result[k] != null);
if (present.length !== 1 || present[0] !== result.lane) {
throw new Error(`lane is ${result.lane} but [${present}] came back non-null`);
}
const spec = result.spec || {};
const ids = new Set((spec.requirements || []).map(r => r.id));
const orphans = (spec.acceptance_criteria || [])
.map(c => c.requirement_id)
.filter(id => !ids.has(id));
if (orphans.length) throw new Error("orphaned acceptance criteria: " + orphans.join(", "));
const facts = appInput.roadmap_facts || {};
const labels = new Set((facts.items || []).map(i => i.label));
const bumped = ((result.roadmap || {}).bumped || []).map(b => b.item);
if (bumped.length && facts.parse_confidence !== "high") {
throw new Error("bumped items on a " + facts.parse_confidence + " roadmap parse");
}
const invented = bumped.filter(b => !labels.has(b));
if (invented.length) throw new Error("bumped items not on the board: " + invented.join(", "));
console.log(result.lane, "-", result.title);
// result and appInput decoded into structs mirroring the schemas above.
present := []string{}
if result.Spec != nil {
present = append(present, "spec")
}
if result.Roadmap != nil {
present = append(present, "roadmap")
}
if result.Update != nil {
present = append(present, "update")
}
if len(present) != 1 || present[0] != result.Lane {
log.Fatalf("lane is %s but %v came back non-null", result.Lane, present)
}
if result.Spec != nil {
ids := map[string]bool{}
for _, r := range result.Spec.Requirements {
ids[r.ID] = true
}
for _, c := range result.Spec.AcceptanceCriteria {
if !ids[c.RequirementID] {
log.Fatalf("criterion points at unknown requirement %s", c.RequirementID)
}
}
}
if result.Roadmap != nil {
labels := map[string]bool{}
for _, i := range appInput.RoadmapFacts.Items {
labels[i.Label] = true
}
if len(result.Roadmap.Bumped) > 0 && appInput.RoadmapFacts.ParseConfidence != "high" {
log.Fatal("items bumped although the roadmap parse was not high-confidence")
}
for _, b := range result.Roadmap.Bumped {
if !labels[b.Item] {
log.Fatalf("bumped item %q is not on the pasted board", b.Item)
}
}
}
import java.util.*;
import java.util.stream.*;
List<String> present = new ArrayList<>();
if (result.spec != null) present.add("spec");
if (result.roadmap != null) present.add("roadmap");
if (result.update != null) present.add("update");
if (present.size() != 1 || !present.get(0).equals(result.lane))
throw new IllegalStateException("lane is " + result.lane + " but " + present + " came back");
if (result.spec != null) {
Set<String> ids = result.spec.requirements.stream()
.map(r -> r.id).collect(Collectors.toSet());
for (var c : result.spec.acceptanceCriteria)
if (!ids.contains(c.requirementId))
throw new IllegalStateException("criterion points at unknown " + c.requirementId);
}
if (result.roadmap != null) {
Set<String> labels = appInput.roadmapFacts.items.stream()
.map(i -> i.label).collect(Collectors.toSet());
if (!result.roadmap.bumped.isEmpty()
&& !"high".equals(appInput.roadmapFacts.parseConfidence))
throw new IllegalStateException("bumped items on a low-confidence roadmap parse");
for (var b : result.roadmap.bumped)
if (!labels.contains(b.item))
throw new IllegalStateException("bumped item not on the board: " + b.item);
}
present = %w[spec roadmap update].select { |k| result[k] }
abort("lane is #{result["lane"]} but #{present} came back non-null") unless present == [result["lane"]]
spec = result["spec"] || {}
ids = (spec["requirements"] || []).map { |r| r["id"] }
orphans = (spec["acceptance_criteria"] || []).map { |c| c["requirement_id"] } - ids
abort("orphaned acceptance criteria: #{orphans.join(", ")}") unless orphans.empty?
facts = app_input["roadmap_facts"] || {}
labels = (facts["items"] || []).map { |i| i["label"] }
bumped = ((result["roadmap"] || {})["bumped"] || []).map { |b| b["item"] }
abort("bumped items on a #{facts["parse_confidence"]} parse") if bumped.any? && facts["parse_confidence"] != "high"
invented = bumped - labels
abort("bumped items not on the board: #{invented.join(", ")}") unless invented.empty?
puts "#{result["lane"]} - #{result["title"]}"
<?php
$present = array_values(array_filter(["spec", "roadmap", "update"],
fn($k) => ($result[$k] ?? null) !== null));
if ($present !== [$result["lane"]]) {
exit("lane is {$result["lane"]} but " . implode(",", $present) . " came back\n");
}
$spec = $result["spec"] ?? [];
$ids = array_column($spec["requirements"] ?? [], "id");
$orphans = array_diff(array_column($spec["acceptance_criteria"] ?? [], "requirement_id"), $ids);
if ($orphans) {
exit("orphaned acceptance criteria: " . implode(", ", $orphans) . "\n");
}
$facts = $appInput["roadmap_facts"] ?? [];
$labels = array_column($facts["items"] ?? [], "label");
$bumped = array_column(($result["roadmap"] ?? [])["bumped"] ?? [], "item");
if ($bumped && ($facts["parse_confidence"] ?? "") !== "high") {
exit("bumped items on a low-confidence roadmap parse\n");
}
$invented = array_diff($bumped, $labels);
if ($invented) {
exit("bumped items not on the board: " . implode(", ", $invented) . "\n");
}
var lanes = new[] { "spec", "roadmap", "update" };
var present = lanes.Where(k => result.TryGetProperty(k, out var v) &&
v.ValueKind != JsonValueKind.Null).ToList();
var lane = result.GetProperty("lane").GetString();
if (present.Count != 1 || present[0] != lane)
throw new Exception($"lane is {lane} but [{string.Join(",", present)}] came back");
if (lane == "spec")
{
var spec = result.GetProperty("spec");
var ids = spec.GetProperty("requirements").EnumerateArray()
.Select(r => r.GetProperty("id").GetString()!).ToHashSet();
foreach (var c in spec.GetProperty("acceptance_criteria").EnumerateArray())
{
var rid = c.GetProperty("requirement_id").GetString()!;
if (!ids.Contains(rid)) throw new Exception($"criterion points at unknown {rid}");
}
}
if (lane == "roadmap")
{
var facts = appInput.GetProperty("roadmap_facts");
var labels = facts.GetProperty("items").EnumerateArray()
.Select(i => i.GetProperty("label").GetString()!).ToHashSet();
var bumped = result.GetProperty("roadmap").GetProperty("bumped").EnumerateArray()
.Select(b => b.GetProperty("item").GetString()!).ToList();
if (bumped.Count > 0 && facts.GetProperty("parse_confidence").GetString() != "high")
throw new Exception("bumped items on a low-confidence roadmap parse");
foreach (var b in bumped)
if (!labels.Contains(b)) throw new Exception($"bumped item not on the board: {b}");
}
Idempotency-Key plus a retry_note in the input naming exactly what was wrong —
that is what the app does, and it is why a malformed first reply is not billed twice.Where to go next
The app itself runs the scan for free with no account: paste an idea and you get the word and
sentence counts, the problem, user and metric signals, the readiness score out of four, the sections it already
contains, and the Now/Next/Later parse of a pasted board — all client-side, with no network call. Signing
in is what buys the three metered lanes, and a signed-in run is kept in the app's runs collection so
the spec you wrote last week is still there when the roadmap conversation happens. The
token page shows the token this browser holds, copies a shell export for you and mints
a fresh guest token on request — it is the right place to send anyone who asks how to authenticate, and it
means nobody ever needs to open a developer console.
Spec Desk is derived from three agent skills in @anthropics/knowledge-work-plugins — write-spec, roadmap-update and stakeholder-update — whose guidance the three lanes implement. It is a drafting aid for a PM who stays accountable for the result, not a substitute for talking to engineering about feasibility, design about the experience, or legal and compliance about anything touching regulated data.