Driving Launch Gate over HTTP
Everything the page does, you can do from a script. One endpoint does the work; the rest is
authentication and polling. The app takes one proposed launch — a
brief in free prose and a facts_text block of key: value
lines describing what it touches — plus a task field naming which of three
lanes to run over that one work object. It returns a single JSON envelope.
https://api.skillsafe.ai/v1/app-api
The only headers on any call are Authorization: Bearer <token> and
Content-Type: application/json — plus Idempotency-Key on
/run and /run-stream. There is no slug header. The
app slug is named in exactly one place: the JSON body of POST /guest, as
{"slug":"launch-gate"}. If you would rather not touch
DevTools, the token page shows and copies your token.
The response envelope
Every endpoint returns the same wrapper. Success carries data; failure carries
error. Nothing returns a bare value, so a client can branch on the presence of
error alone.
{"ok": true, "data": { ... }}
{"ok": false, "error": {"code": "validation_error", "message": "...", "details": { ... }}}
Error codes
| Code | HTTP | What it means | What to do |
|---|---|---|---|
| unauthorized | 401 | Missing, malformed or expired token, or a token minted against another app. | Mint a new one with POST /guest and {"slug":"launch-gate"}, or copy a personal token from the token page. Guest tokens expire; personal tokens outlive them. |
| payment_required | 402 | Balance below the run's minimum, so no hold could be placed. | Call /estimate first and compare min_credits against the credits from /me. |
| rate_limited | 429 | Too many requests in too short a window. | Back off with jitter and retry the same idempotency key; never tight-loop a poll. |
| validation_error | 400 | The body did not match the app's shape — a missing brief, a non-string facts_text, malformed JSON. | Read error.details; it names the offending field. Check first that you did not wrap the input in an input key. |
| not_found | 404 | No such job id, or a job that belongs to another subject. | Poll the job_id the /run response gave you, with the same token that created it. |
| server_error | 500 | Something failed upstream of the model. | Retry once with the same Idempotency-Key, which cannot double-bill. If it repeats, the run did not start and nothing was charged. |
A job that starts and then fails is not an HTTP error. It comes back as a normal
{"ok": true} envelope from GET /jobs/{job_id} with
status set to failed, so always branch on status as
well as on error.
The task field comes first
Launch Gate is a three-lane app. Every run must name its lane in task,
because all three lanes share one system prompt and one model and are routed by that field
alone. The contract is to produce that lane only and never a blend of two. If
task is missing or is not one of the three ids, the model picks the closest lane,
produces that lane's contract in full, and says so by setting lane to what it chose
plus lane_inferred to true — a fallback for a malformed request,
not a feature to rely on. Send the lane explicitly.
task | The question it answers | artifact.kind | next_lane.lane |
|---|---|---|---|
| applicability | Given this launch, which regulations, frameworks and internal approvals actually apply — and which plausibly-relevant ones do not? The scoping lane. It runs first and the other two work inside the scope it draws. | none | risk |
| risk | How bad is it, how likely, and who has to decide? Classifies on a severity-by-likelihood matrix, then applies escalation criteria. It does not re-scope. | memo | readiness |
| readiness | What evidence must exist, who owns it, and what is missing today? The producing lane: an audit-readiness register, one row per requirement. | register | "" |
The natural order through the app is applicability → risk →
readiness, and every response names what it thinks comes next in
next_lane. On applicability, next_lane.lane is
risk whenever any framework applies or any finding is high or above,
and "" otherwise. readiness is the last lane, so its
next_lane.lane is always the empty string.
The run input object
This is the exact JSON the page submits. It is the whole request body for
/estimate, /run and /run-stream alike — see the
warning below about the input wrapper that does not exist.
{
"task": "applicability",
"brief": "Weekly churn-risk email. We score every workspace nightly ... (free prose)",
"facts_text": "initiative: Weekly churn-risk email\nowner: Priya Raman, PM Growth\nlaunch: 2026-09-15\nregions: EU, UK, California\ndata: email, name, product usage events, IP address\npurpose: rank accounts by churn risk and alert the owner\nprocessors: Segment, SendGrid, Snowflake (US)\ntransfers: EU -> US\nautomated-decision: yes - accounts scoring below 0.3 are auto-downgraded\nretention: 24 months\nframeworks: GDPR, SOC 2, ISO 27001\nsecurity-review: not started\ndpa: SendGrid signed 2025-03; Segment pending",
"context": "optional free text - constraints, what has already been reviewed",
"prescan": {
"signals": {
"regions": [{"id": "eu", "label": "EU/EEA", "declared": true}],
"categories": [{"id": "email", "label": "email address", "special": false, "declared": true}],
"frameworks": [{"id": "GDPR", "name": "EU General Data Protection Regulation", "trigger": "regions: EU"}],
"processors": [{"name": "SendGrid", "dpa_status": "signed"}],
"launch_in_days": 27,
"automated_decision": true,
"cross_border": true
},
"flags": [
{"id": "GS-AUTOMATED-DECISION", "severity": "critical", "label": "Automated decision with a significant effect", "detail": "accounts scoring below 0.3 are auto-downgraded"}
],
"gates": [{"gate": "DPIA", "role": "dpo"}]
},
"clip_note": "present only when the paste was clipped"
}
Input fields
| Field | Type | Required | Notes |
|---|---|---|---|
| task | string | yes | One of applicability, risk or readiness. Send it. The lane is the single most consequential field in the body. |
| brief | string | yes | The launch brief in free prose: a product feature, a marketing programme, a data-sharing arrangement, a new vendor, a market entry. What is being proposed and why. |
| facts_text | string | in practice yes | The facts block, one key: value line each. The keys the app and the scanner both read are initiative, owner, launch, regions, data, purpose, processors, transfers, automated-decision, retention, frameworks, security-review and dpa. The facts block is authoritative where it conflicts with the brief, and the model says so in assumptions when the two disagree. |
| context | string | no | Free text: constraints, what has already been reviewed, internal risk appetite, who the reader is. |
| carried | string or object | no | A digest of the previous lane's output, which the page sends when the user takes a handoff button. The risk lane works inside the scope carried establishes rather than re-scoping it. |
| prescan | object | no | {signals, flags, gates} — deterministic facts read out of the same material by the in-browser scanner, which the model must reconcile. See the next section. |
| clip_note | string | no | Send only when the paste was too long to transmit whole; it names what was cut. The model will not comment on material the clip note says it was not shown. |
| retry_note | string | no | Sent only on an automatic re-ask after a reply that failed to parse. It quotes the parse error and restates the contract. Reuse the idempotency key with the attempt number incremented so a retry cannot double-bill. |
The output envelope
All three lanes return exactly the same outer object. Only body is per-lane. The
reply is one JSON object and nothing else — no prose before it and no code
fence around it — and it arrives as a string in data.output.output, so
you parse it yourself.
{
"lane": "applicability",
"lane_inferred": false,
"initiative": "short name for what is being launched, 2-8 words",
"title": "one line naming the lane's output for this initiative",
"posture": "clear-to-ship | conditions-first | blocked",
"verdict": "one sentence a VP can read and act on",
"summary": "3-6 sentences for the approver, ending with the not-legal-advice line",
"jurisdictions": ["EU/EEA", "United Kingdom", "California"],
"frameworks": [
{"id": "GDPR", "name": "EU General Data Protection Regulation",
"applies": "yes | likely | no", "basis": "Art. 3(2) - offering services to EU data subjects",
"why": "one sentence tying it to a fact in the input"}
],
"assumptions": ["..."],
"open_questions": ["..."],
"findings": [
{"id": "LG-001", "title": "short imperative title", "severity": "critical|high|medium|low",
"area": "privacy|security|consumer-protection|marketing|ai-governance|accessibility|records|contracts|sector-specific|cross-border|internal-approval",
"requirement": "the obligation in one clause, with its provision",
"evidence": "at most one sentence quoted or paraphrased from the input",
"why": "why it bites here",
"action": "the single next action, imperative",
"owner_role": "privacy-counsel"}
],
"coverage_check": [
{"flag_id": "GS-CROSS-BORDER", "status": "confirmed|set-aside|superseded",
"finding_id": "LG-003", "note": "one sentence"}
],
"approvals": [
{"role": "privacy-counsel", "decision_needed": "sign off the transfer basis",
"blocking": true, "by_when": "before launch | before general availability | within 30 days of launch"}
],
"artifact": {"kind": "none|markdown|register|memo", "filename": "", "content": ""},
"next_lane": {"lane": "risk", "reason": "one sentence"},
"body": { }
}
findingsids runLG-001upward —LG-001,LG-002, sequential, in the order emitted, sorted most serious first. Acoverage_checkentry points at one of them byfinding_id.bodyis the only per-lane part. Everything above it has the same keys and the same vocabulary in all three lanes, so one parser handles every response and switches onlaneonly to readbody. Abodynever carries a key belonging to another lane.postureis one of three values everywhere:clear-to-ship,conditions-firstandblocked. It is a recommendation to the approver, never a clearance: see the note at the foot of this page.frameworks[].appliesisyesonly when the input contains the trigger,likelywhen the trigger is strongly implied but not stated, andnowhen the input positively rules it out. A framework the model cannot place goes inopen_questionsrather than intoframeworkswithapplies: "yes".- Every array is present even when empty — an empty array, never
nulland never a missing key. An emptyapprovalsis a real answer and reads as "nothing gated". You can index without guarding. artifact.kindisnoneonapplicability,memowithfilenamerisk-memo.mdonrisk, andregisterwithfilenameaudit-readiness-register.mdonreadiness. Wheneverkindis notnone,contentis non-empty andfilenameis set.owner_roleandapprovals[].roledraw on role labels, never on people:privacy-counsel,dpo,security,legal,product-owner,data-engineering,marketing,finance,accessibility,procurement,outside-counsel. The one exception is an owner named in the facts block, which is echoed verbatim.
The prescan contract
The browser app runs a deterministic scan over the brief and the facts block before every run and
passes what it read in prescan. It has three parts, and they are not
interchangeable: signals is what the scan read, flags is what
it objects to, and gates is the approvals it derived.
The model must return exactly one coverage_check entry per flag id sent, and
none for an id that was not sent. That is what makes a free scan able to hold a paid run
accountable — anything unaccounted for is a defect you can detect programmatically:
sent = {f["id"] for f in payload["prescan"]["flags"]}
covered = {c["flag_id"] for c in result["coverage_check"]}
assert sent == covered, f"unreconciled: {sent - covered}; invented: {covered - sent}"
status is one of three values. confirmed — the flag is a real
issue, and finding_id names the LG-* finding that carries it.
set-aside — the rule fires but does not matter on this material, and
note says why in one sentence. superseded — a different, larger
finding subsumes it, and finding_id names that one. A flag is never silently
dropped.
prescan.signals
Signals are facts, not problems. They exist so the model does not have to re-derive from prose
what a parser already read out of the facts block, and so that a disagreement about a
countable thing — which regions were declared, how many days to launch, whether a
DPA is signed — resolves in favour of the scan, which is deterministic where the model is
not. Signals need no coverage_check entry.
| Key | Type | What it holds |
|---|---|---|
| regions | array | {id, label, declared} per region read from the regions line. declared is true when the facts block named it outright rather than the brief implying it. |
| categories | array | {id, label, special, declared} per data category. special marks a special category of personal data, which is the field that changes the lawful-basis analysis. |
| frameworks | array | {id, name, trigger} per framework the scan believes is pulled in, with the input line that pulled it. The model may disagree, and says so by returning applies as likely or no with a reason. |
| processors | array | {name, dpa_status} per named processor. dpa_status is read straight from the dpa line: signed, pending or unknown. |
| launch_in_days | number | Days from today to the launch date, or 0 when no date was given. This is the only date arithmetic in the system: the prompt is forbidden from inventing a deadline in days. |
| automated_decision | boolean | Whether the automated-decision line says yes. Drives the GDPR Art. 22 line of analysis. |
| cross_border | boolean | Whether the transfers line describes a transfer out of a declared region. |
prescan.flags
Each flag is {id, severity, label, detail}. id is the stable rule id,
a GS-* string, and it is the value that must come back as a
coverage_check.flag_id. severity is critical,
high, medium or low, and flags arrive sorted by severity
in that order. detail carries the countable fact or the quoted fragment the rule
fired on, and is an empty string when the rule has nothing to add.
"flags": [
{"id": "GS-AUTOMATED-DECISION", "severity": "critical",
"label": "Automated decision with a significant effect",
"detail": "accounts scoring below 0.3 are auto-downgraded"},
{"id": "GS-CROSS-BORDER", "severity": "high",
"label": "Personal data leaves a declared region",
"detail": "EU -> US"},
{"id": "GS-SECURITY-REVIEW-MISSING", "severity": "high",
"label": "No security review has started",
"detail": "security-review: not started"},
{"id": "GS-DPA-PENDING", "severity": "medium",
"label": "A named processor has no signed agreement",
"detail": "Segment pending"}
]
Treat the GS-* ids as an interface, and treat the set of them as data.
Renaming an id breaks the reconciliation contract in both directions: the model has no entry to
return and your assertion has nothing to match. The four above are what the launch bundled with
the app produces; a different launch produces a different set, and the same launch run through
two lanes can produce two different sets, because the scan sends only the rules that belong to
the lane you are about to run. Compute prescan per lane rather than caching one
lane's flags and resending them on another.
prescan.gates
gates is an array of {gate, role} — the approvals the scan
derived from the signals, such as {"gate": "DPIA",
"role": "dpo"} once automated_decision is true. They are
a starting point, not a verdict: the applicability lane returns its own
body.gates with a required field of yes,
likely or no, and the envelope's approvals array lists
only the gates the input actually raises. Gates need no coverage_check entry
either.
You may send an empty prescan, or omit it entirely. The lane still runs; it simply
has fewer deterministic facts to ground itself in, coverage_check comes back as an
empty array, and nothing checks the model's reading of the facts block for you.
1. A tiny client
A few lines of setup that every later step reuses: the base URL, the bearer token, and a JSON
post that raises on the error branch of the envelope. Two headers, no more —
Authorization and Content-Type. The slug constant is here only because
step 2 needs it in a request body; it never becomes a header. Replace the
"YOUR_TOKEN" placeholder by reading the token out of the environment or out
of wherever your program keeps secrets, rather than committing it. If you would rather not touch
DevTools, the token page shows and copies your token.
# Every call in this document uses these three values.
BASE="https://api.skillsafe.ai/v1/app-api"
SLUG="launch-gate" # used once, in the POST /guest body
TOKEN="${LAUNCH_GATE_TOKEN:-YOUR_TOKEN}" # from https://launch-gate.skillsafe.ai/tokens.html
post() { # post <path> <json>
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"
}
import json
import os
import urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "launch-gate" # used once, in the POST /guest body
TOKEN = os.environ.get("LAUNCH_GATE_TOKEN", "YOUR_TOKEN")
def call(path, payload=None, method="POST", idempotency_key=None):
body = json.dumps(payload).encode() if payload is not None else None
req = urllib.request.Request(BASE + path, data=body, method=method)
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
if idempotency_key:
req.add_header("Idempotency-Key", idempotency_key)
with urllib.request.urlopen(req) as resp:
envelope = json.loads(resp.read())
if not envelope.get("ok"):
raise RuntimeError(envelope["error"]["code"] + ": " + envelope["error"]["message"])
return envelope["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "launch-gate"; // used once, in the POST /guest body
const TOKEN = "YOUR_TOKEN"; // read this from the environment; never commit it
async function call(path, payload, method = "POST", idempotencyKey) {
const headers = {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json"
};
if (idempotencyKey) headers["Idempotency-Key"] = idempotencyKey;
const res = await fetch(BASE + path, {
method,
headers,
body: payload === undefined ? undefined : JSON.stringify(payload)
});
const envelope = await res.json();
if (!envelope.ok) {
throw new Error(`${envelope.error.code}: ${envelope.error.message}`);
}
return envelope.data;
}
package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
)
const (
base = "https://api.skillsafe.ai/v1/app-api"
slug = "launch-gate" // used once, in the POST /guest body
)
// token reads LAUNCH_GATE_TOKEN, or falls back to the placeholder.
func token() string {
if t := os.Getenv("LAUNCH_GATE_TOKEN"); t != "" {
return t
}
return "YOUR_TOKEN" // from https://launch-gate.skillsafe.ai/tokens.html
}
type envelope struct {
OK bool `json:"ok"`
Data json.RawMessage `json:"data"`
Error *struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
}
func call(method, path string, payload any, idemKey string) (json.RawMessage, error) {
var body io.Reader
if payload != nil {
b, _ := json.Marshal(payload)
body = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, body)
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Content-Type", "application/json")
if idemKey != "" {
req.Header.Set("Idempotency-Key", idemKey)
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, fmt.Errorf("%s: %s", env.Error.Code, env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
import java.nio.file.*;
public class LaunchGate {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String SLUG = "launch-gate"; // used once, in the POST /guest body
// Reads LAUNCH_GATE_TOKEN, or falls back to the placeholder.
static final String TOKEN =
System.getenv("LAUNCH_GATE_TOKEN") != null
? System.getenv("LAUNCH_GATE_TOKEN")
: "YOUR_TOKEN";
static final HttpClient CLIENT = HttpClient.newHttpClient();
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 = CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
if (res.statusCode() >= 400) {
throw new RuntimeException("HTTP " + res.statusCode() + ": " + res.body());
}
return res.body();
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
SLUG = "launch-gate" # used once, in the POST /guest body
TOKEN = ENV.fetch("LAUNCH_GATE_TOKEN", "YOUR_TOKEN")
def call(path, payload = nil, method = :post, idempotency_key: nil)
uri = URI(BASE + path)
req = method == :get ? Net::HTTP::Get.new(uri) : Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idempotency_key if idempotency_key
req.body = JSON.dump(payload) unless payload.nil?
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
envelope = JSON.parse(res.body)
raise "#{envelope['error']['code']}: #{envelope['error']['message']}" unless envelope["ok"]
envelope["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const SLUG = "launch-gate"; // used once, in the POST /guest body
// Reads LAUNCH_GATE_TOKEN, or falls back to the placeholder.
define("TOKEN", getenv("LAUNCH_GATE_TOKEN") ?: "YOUR_TOKEN");
function call(string $path, ?array $payload = null, ?string $idemKey = null): array {
$headers = [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
];
if ($idemKey !== null) {
$headers[] = "Idempotency-Key: " . $idemKey;
}
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_POST => $payload !== null,
CURLOPT_HTTPHEADER => $headers,
CURLOPT_POSTFIELDS => $payload === null ? "" : json_encode($payload),
]);
$envelope = json_decode(curl_exec($ch), true);
curl_close($ch);
if (empty($envelope["ok"])) {
throw new RuntimeException($envelope["error"]["code"] . ": " . $envelope["error"]["message"]);
}
return $envelope["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
public static class LaunchGate
{
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Slug = "launch-gate"; // used once, in the POST /guest body
// Reads LAUNCH_GATE_TOKEN, or falls back to the placeholder.
static readonly string Token =
Environment.GetEnvironmentVariable("LAUNCH_GATE_TOKEN") ?? "YOUR_TOKEN";
static readonly HttpClient Client = new HttpClient();
public static async Task<JsonElement> CallAsync(
string path, object payload = null, string idemKey = null, HttpMethod method = null)
{
var req = new HttpRequestMessage(method ?? HttpMethod.Post, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (idemKey is not null) req.Headers.Add("Idempotency-Key", idemKey);
if (payload is not null)
{
req.Content = new StringContent(
JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
}
var res = await Client.SendAsync(req);
var envelope = JsonDocument.Parse(await res.Content.ReadAsStringAsync()).RootElement;
if (!envelope.GetProperty("ok").GetBoolean())
{
var err = envelope.GetProperty("error");
throw new Exception($"{err.GetProperty("code")}: {err.GetProperty("message")}");
}
return envelope.GetProperty("data");
}
}
2. Get a token
A guest token is minted on demand and is enough for /me and
/estimate. Running a lane is metered, so it needs a
personal token — sign in at the token page and
copy it from there. This is the one and only call that names the app:
{"slug": "launch-gate"} in the JSON body, and note that this call
needs no Authorization header at all. Every POST /guest mints a
new guest identity, so reuse one token across a session rather than minting per request.
curl -sS -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d "{\"slug\":\"$SLUG\"}" | tee guest.json
# {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}
TOKEN=$(python3 -c "import json;print(json.load(open('guest.json'))['data']['token'])")
import json, urllib.request
body = json.dumps({"slug": SLUG}).encode()
req = urllib.request.Request(BASE + "/guest", data=body, method="POST")
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as resp:
guest = json.loads(resp.read())["data"]
TOKEN = guest["token"] # reuse this for the whole session
print(guest["subject_type"]) # "guest"
const res = await fetch(`${BASE}/guest`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: SLUG })
});
const guest = (await res.json()).data;
const token = guest.token; // reuse for the whole session
console.log(guest.subject_type); // "guest"
guestBody := bytes.NewReader([]byte(`{"slug":"launch-gate"}`))
req, _ := http.NewRequest("POST", base+"/guest", guestBody)
req.Header.Set("Content-Type", "application/json")
res, err := http.DefaultClient.Do(req)
if err != nil {
panic(err)
}
defer res.Body.Close()
var env struct {
Data struct {
Token string `json:"token"`
SubjectType string `json:"subject_type"`
} `json:"data"`
}
json.NewDecoder(res.Body).Decode(&env)
fmt.Println(env.Data.SubjectType) // "guest"
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString("{\"slug\":\"launch-gate\"}"))
.build();
HttpResponse<String> res = CLIENT.send(req, HttpResponse.BodyHandlers.ofString());
System.out.println(res.body());
// {"ok":true,"data":{"token":"aut_...","subject_type":"guest"}}
uri = URI(BASE + "/guest")
req = Net::HTTP::Post.new(uri)
req["Content-Type"] = "application/json"
req.body = JSON.dump({ "slug" => SLUG })
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |http| http.request(req) }
guest = JSON.parse(res.body)["data"]
token = guest["token"] # reuse for the whole session
puts guest["subject_type"] # "guest"
<?php
$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" => SLUG]),
]);
$guest = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
$token = $guest["token"]; // reuse for the whole session
echo $guest["subject_type"]; // "guest"
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/guest");
req.Content = new StringContent("{\"slug\":\"launch-gate\"}", Encoding.UTF8, "application/json");
var res = await Client.SendAsync(req);
var guest = JsonDocument.Parse(await res.Content.ReadAsStringAsync())
.RootElement.GetProperty("data");
var token = guest.GetProperty("token").GetString(); // reuse for the session
Console.WriteLine(guest.GetProperty("subject_type")); // "guest"
3. Check who you are and what you can spend
GET /me is free and tells you the subject_type —
guest or user — and the credit balance. Compare that balance
against the estimate in the next step before you run anything: a 402 after a reviewer has already
pasted a launch brief is a failure of the client, not of the user.
get /me
# {"ok":true,"data":{"subject_type":"user","credits":48210, ...}}
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 {
panic(err)
}
var me struct {
SubjectType string `json:"subject_type"`
Credits int `json:"credits"`
}
json.Unmarshal(data, &me)
fmt.Println(me.SubjectType, me.Credits)
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/me"))
.header("Authorization", "Bearer " + TOKEN)
.GET()
.build();
System.out.println(CLIENT.send(req, HttpResponse.BodyHandlers.ofString()).body());
// {"ok":true,"data":{"subject_type":"user","credits":48210, ...}}
me = call("/me", nil, :get)
puts "#{me['subject_type']} #{me['credits']}"
<?php
$ch = curl_init(BASE . "/me");
curl_setopt_array($ch, [
CURLOPT_RETURNTRANSFER => true,
CURLOPT_HTTPHEADER => ["Authorization: Bearer " . TOKEN],
]);
$me = json_decode(curl_exec($ch), true)["data"];
curl_close($ch);
echo $me["subject_type"] . " " . $me["credits"];
var me = await CallAsync("/me", method: HttpMethod.Get);
Console.WriteLine($"{me.GetProperty("subject_type")} {me.GetProperty("credits")}");
4. Price the run before you make it
POST /estimate is free, creates no job and charges nothing. It takes the same body
the run will take and returns the model, the markup and the two numbers that matter.
model_alias is gpt-terra and markup_bps is
1000, which is the app's ten-percent metered markup.
hold_credits is the amount reserved against your balance, priced at the full
output cap; the actual charge is usually far lower, so present it as reserved and never as
the price. min_credits is the floor you must be able to cover for the run to be
accepted at all. sponsor_enabled tells you whether the app owner is covering runs
for this subject.
input key.
POST /estimate, POST /run and POST /run-stream all take
{"task": ..., "brief": ..., "facts_text": ...} at the
top level of the body. Sending
{"input": {"task": ...}} instead is the one mistake worth
calling out, because it does not error. The request returns
200 with a plausible-looking hold, the run is accepted, you are billed — and
the model never sees task, brief or facts_text at all.
What comes back is a lane the model guessed at over an empty record, usually with
lane_inferred set to true and an initiative of
unknown. Assert on the top-level shape of your payload before you post it, and
check lane_inferred on every reply.
The hold differs per lane. The three lanes have different output caps — a
readiness run that has to produce a register of a dozen rows plus the Markdown file
reserves considerably more than an applicability read. Estimate the lane you are
about to run, and re-estimate whenever task changes.
post /estimate '{
"task": "applicability",
"brief": "Weekly churn-risk email. We score every workspace nightly ...",
"facts_text": "initiative: Weekly churn-risk email\nowner: Priya Raman, PM Growth\nregions: EU, UK, California\n...",
"context": "Security review has not started."
}'
# {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":4820,"min_credits":420,
# "sponsor_enabled":false}}
payload = {
"task": "applicability",
"brief": open("brief.txt").read(),
"facts_text": open("facts.txt").read(),
"context": "Security review has not started.",
}
# The body is the input object itself. This assertion is worth keeping.
assert "input" not in payload and payload["task"] in ("applicability", "risk", "readiness")
est = call("/estimate", payload)
print(est["model_alias"], est["markup_bps"], est["hold_credits"], est["min_credits"])
if me["credits"] < est["min_credits"]:
raise SystemExit(f"short by {est['min_credits'] - me['credits']} credits")
import { readFileSync } from "node:fs";
const payload = {
task: "applicability",
brief: readFileSync("brief.txt", "utf8"),
facts_text: readFileSync("facts.txt", "utf8"),
context: "Security review has not started."
};
// The body is the input object itself - no "input" wrapper.
if ("input" in payload) throw new Error("do not wrap the body");
const est = await call("/estimate", payload);
console.log(est.model_alias, est.markup_bps, est.hold_credits, est.min_credits);
if (me.credits < est.min_credits) {
throw new Error(`short by ${est.min_credits - me.credits} credits`);
}
brief, _ := os.ReadFile("brief.txt")
facts, _ := os.ReadFile("facts.txt")
// The body is the input object itself - there is no "input" wrapper.
payload := map[string]any{
"task": "applicability",
"brief": string(brief),
"facts_text": string(facts),
"context": "Security review has not started.",
}
data, err := call("POST", "/estimate", payload, "")
if err != nil {
panic(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"`
SponsorOn bool `json:"sponsor_enabled"`
}
json.Unmarshal(data, &est)
fmt.Println(est.ModelAlias, est.HoldCredits, est.MinCredits)
String brief = Files.readString(Path.of("brief.txt"));
String facts = Files.readString(Path.of("facts.txt"));
// The body is the input object itself - there is no "input" wrapper.
String payload = """
{"task": "applicability", "brief": %s, "facts_text": %s,
"context": "Security review has not started."}
""".formatted(JsonUtil.quote(brief), JsonUtil.quote(facts));
String estimate = call("/estimate", payload);
System.out.println(estimate);
// {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
// "markup_bps":1000,"hold_credits":4820,"min_credits":420}}
payload = {
"task" => "applicability",
"brief" => File.read("brief.txt"),
"facts_text" => File.read("facts.txt"),
"context" => "Security review has not started."
}
# The body is the input object itself - no "input" wrapper.
raise "do not wrap the body" if payload.key?("input")
est = call("/estimate", payload)
puts "#{est['model_alias']} #{est['hold_credits']} #{est['min_credits']}"
abort "short by #{est['min_credits'] - me['credits']}" if me["credits"] < est["min_credits"]
<?php
$payload = [
"task" => "applicability",
"brief" => file_get_contents("brief.txt"),
"facts_text" => file_get_contents("facts.txt"),
"context" => "Security review has not started.",
];
// The body is the input object itself - no "input" wrapper.
if (array_key_exists("input", $payload)) {
throw new RuntimeException("do not wrap the body");
}
$est = call("/estimate", $payload);
echo "{$est['model_alias']} {$est['hold_credits']} {$est['min_credits']}\n";
if ($me["credits"] < $est["min_credits"]) {
throw new RuntimeException("short by " . ($est["min_credits"] - $me["credits"]));
}
// The body is the input object itself - there is no "input" wrapper.
var payload = new
{
task = "applicability",
brief = await File.ReadAllTextAsync("brief.txt"),
facts_text = await File.ReadAllTextAsync("facts.txt"),
context = "Security review has not started."
};
var est = await CallAsync("/estimate", payload);
Console.WriteLine($"{est.GetProperty("model_alias")} {est.GetProperty("hold_credits")}");
5. Run a lane and poll for the result
POST /run takes the input object as its body and returns a job_id
immediately; poll GET /jobs/{job_id} until status is terminal
(succeeded, failed or cancelled). Two seconds between polls
is plenty. The model's reply is a string at data.output.output, so parse it
yourself.
Always send an Idempotency-Key on /run and
/run-stream, and derive it from three parts:
launch-gate:<lane>:<hash of input>:a<attempt> launch-gate:applicability:9f2c1a7be40d5c31:a1 launch-gate:risk:9f2c1a7be40d5c31:a1 # same brief, different lane, different key launch-gate:risk:9f2c1a7be40d5c31:a2 # deliberate re-ask after a parse failure
All three parts matter. Include the lane or the second lane over the same brief collides with the first and hands you back the first lane's cached result — you would ask for a risk matrix and get an applicability scope, with no error to tell you why. Include a hash of the input — the brief, the facts block and the context together — so editing one line of the facts block starts a new run rather than replaying the old one. Include an attempt counter so a deliberate re-ask is a new run while a transport-level retry of the same attempt reuses the key and cannot double-bill.
ATTEMPT=1
LANE="applicability"
HASH=$(cat brief.txt facts.txt | shasum -a 256 | cut -c1-16)
KEY="launch-gate:$LANE:$HASH:a$ATTEMPT"
JOB=$(curl -sS -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-d @payload.json | python3 -c "import json,sys;print(json.load(sys.stdin)['data']['job_id'])")
until curl -sS "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
| tee job.json | grep -q '"status":"succeeded"'; do sleep 2; done
python3 -c "import json;print(json.load(open('job.json'))['data']['output']['output'])"
import hashlib, time
def idem_key(payload, attempt=1):
material = "\x00".join([
payload["brief"],
payload.get("facts_text", ""),
payload.get("context", ""),
])
digest = hashlib.sha256(material.encode()).hexdigest()[:16]
return f"launch-gate:{payload['task']}:{digest}:a{attempt}"
job = call("/run", payload, idempotency_key=idem_key(payload))
job_id = job["job_id"]
while True:
status = call("/jobs/" + job_id, method="GET")
if status["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
if status["status"] != "succeeded":
raise RuntimeError("job " + status["status"])
result = json.loads(status["output"]["output"])
print(result["posture"], "-", result["verdict"])
for f in result["findings"]:
print(f" [{f['severity']:8}] {f['id']} {f['owner_role']}: {f['title']}")
import { createHash } from "node:crypto";
function idemKey(payload, attempt = 1) {
const material = [payload.brief, payload.facts_text ?? "", payload.context ?? ""]
.join("\u0000");
const digest = createHash("sha256").update(material).digest("hex").slice(0, 16);
return `launch-gate:${payload.task}:${digest}:a${attempt}`;
}
const job = await call("/run", payload, "POST", idemKey(payload));
let status;
do {
await new Promise(r => setTimeout(r, 2000));
status = await call(`/jobs/${job.job_id}`, undefined, "GET");
} while (!["succeeded", "failed", "cancelled"].includes(status.status));
if (status.status !== "succeeded") throw new Error(`job ${status.status}`);
const result = JSON.parse(status.output.output);
console.log(result.posture, "-", result.verdict);
for (const f of result.findings) {
console.log(` [${f.severity}] ${f.id} ${f.owner_role}: ${f.title}`);
}
material := payload["brief"].(string) + "\x00" + payload["facts_text"].(string)
sum := sha256.Sum256([]byte(material))
key := fmt.Sprintf("launch-gate:%s:%x:a1", payload["task"], sum[:8])
data, err := call("POST", "/run", payload, key)
if err != nil {
panic(err)
}
var job struct {
JobID string `json:"job_id"`
}
json.Unmarshal(data, &job)
for {
time.Sleep(2 * time.Second)
statusData, _ := call("GET", "/jobs/"+job.JobID, nil, "")
var st struct {
Status string `json:"status"`
Output struct {
Output string `json:"output"`
} `json:"output"`
}
json.Unmarshal(statusData, &st)
if st.Status == "succeeded" {
fmt.Println(st.Output.Output)
break
}
if st.Status == "failed" || st.Status == "cancelled" {
panic("job " + st.Status)
}
}
String material = brief + "\u0000" + facts;
String digest = Integer.toHexString(material.hashCode());
String key = "launch-gate:applicability:" + digest + ":a1";
HttpRequest run = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
String jobId = JsonUtil.path(
CLIENT.send(run, HttpResponse.BodyHandlers.ofString()).body(), "data", "job_id");
String status;
do {
Thread.sleep(2000);
status = call("/jobs/" + jobId, "");
} while (!status.contains("\"status\":\"succeeded\"")
&& !status.contains("\"status\":\"failed\""));
System.out.println(status);
require "digest"
def idem_key(payload, attempt = 1)
material = [payload["brief"], payload["facts_text"], payload["context"]].join("\0")
"launch-gate:#{payload['task']}:#{Digest::SHA256.hexdigest(material)[0, 16]}:a#{attempt}"
end
job = call("/run", payload, :post, idempotency_key: idem_key(payload))
status = nil
loop do
sleep 2
status = call("/jobs/#{job['job_id']}", nil, :get)
break if %w[succeeded failed cancelled].include?(status["status"])
end
raise "job #{status['status']}" unless status["status"] == "succeeded"
result = JSON.parse(status["output"]["output"])
puts "#{result['posture']} - #{result['verdict']}"
result["findings"].each { |f| puts " [#{f['severity']}] #{f['id']} #{f['title']}" }
<?php
function idem_key(array $payload, int $attempt = 1): string {
$material = implode("\0", [
$payload["brief"],
$payload["facts_text"] ?? "",
$payload["context"] ?? "",
]);
$digest = substr(hash("sha256", $material), 0, 16);
return "launch-gate:{$payload['task']}:{$digest}:a{$attempt}";
}
$job = call("/run", $payload, idem_key($payload));
do {
sleep(2);
$status = call("/jobs/" . $job["job_id"]);
} while (!in_array($status["status"], ["succeeded", "failed", "cancelled"], true));
if ($status["status"] !== "succeeded") {
throw new RuntimeException("job " . $status["status"]);
}
$result = json_decode($status["output"]["output"], true);
echo "{$result['posture']} - {$result['verdict']}\n";
foreach ($result["findings"] as $f) {
echo " [{$f['severity']}] {$f['id']} {$f['title']}\n";
}
using System.Security.Cryptography;
var material = payload.brief + "\0" + payload.facts_text;
var digest = Convert.ToHexString(
SHA256.HashData(Encoding.UTF8.GetBytes(material)))[..16].ToLower();
var key = $"launch-gate:{payload.task}:{digest}:a1";
var job = await CallAsync("/run", payload, key);
var jobId = job.GetProperty("job_id").GetString();
JsonElement status;
string state;
do
{
await Task.Delay(2000);
status = await CallAsync($"/jobs/{jobId}", method: HttpMethod.Get);
state = status.GetProperty("status").GetString();
} while (state is not ("succeeded" or "failed" or "cancelled"));
if (state != "succeeded") throw new Exception($"job {state}");
var result = JsonDocument.Parse(
status.GetProperty("output").GetProperty("output").GetString()).RootElement;
Console.WriteLine(result.GetProperty("verdict"));
6. Stream it instead, for anything interactive
POST /run-stream is the same call with the same body over Server-Sent Events. Deltas arrive as they are generated, which is what the page uses to advance its progress stages while a register is being written.
The same Idempotency-Key rule applies, and a done event closes the stream with the job id and what was actually charged. Accumulate the deltas and parse the JSON once the stream closes — a half-received envelope is not valid JSON, and half a risk matrix is worse than none.
curl -sS -N -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" \
-H "Content-Type: application/json" \
-H "Idempotency-Key: $KEY" \
-H "Accept: text/event-stream" \
-d @payload.json
# event: delta
# data: {"text":"{\"lane\":\"applicability\","}
# event: done
# data: {"job_id":"job_...","charged_credits":2914,"truncated":false}
import urllib.request
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", idem_key(payload))
req.add_header("Accept", "text/event-stream")
chunks = []
with urllib.request.urlopen(req) as stream:
for raw in stream:
line = raw.decode().strip()
if not line.startswith("data:"):
continue
event = json.loads(line[5:].strip())
if "text" in event:
chunks.append(event["text"])
print(".", end="", flush=True)
result = json.loads("".join(chunks))
print("\n", result["posture"], result["verdict"])
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": idemKey(payload),
"Accept": "text/event-stream"
},
body: JSON.stringify(payload)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", text = "";
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split("\n");
buffer = lines.pop();
for (const line of lines) {
if (!line.startsWith("data:")) continue;
const event = JSON.parse(line.slice(5).trim());
if (event.text) text += event.text;
}
}
const result = JSON.parse(text);
console.log(result.posture, result.verdict);
body, _ := json.Marshal(payload)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(body))
req.Header.Set("Authorization", "Bearer "+token())
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Idempotency-Key", key)
req.Header.Set("Accept", "text/event-stream")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
var sb strings.Builder
scanner := bufio.NewScanner(res.Body)
scanner.Buffer(make([]byte, 1024*1024), 1024*1024)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
var ev struct {
Text string `json:"text"`
}
if json.Unmarshal([]byte(strings.TrimSpace(line[5:])), &ev) == nil && ev.Text != "" {
sb.WriteString(ev.Text)
}
}
fmt.Println(sb.String())
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run-stream"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", key)
.header("Accept", "text/event-stream")
.POST(HttpRequest.BodyPublishers.ofString(payload))
.build();
StringBuilder text = new StringBuilder();
CLIENT.send(req, HttpResponse.BodyHandlers.ofLines())
.body()
.filter(line -> line.startsWith("data:"))
.forEach(line -> text.append(JsonUtil.path(line.substring(5).trim(), "text")));
System.out.println(text);
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = idem_key(payload)
req["Accept"] = "text/event-stream"
req.body = JSON.dump(payload)
text = +""
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|
next unless line.start_with?("data:")
event = JSON.parse(line[5..].strip) rescue next
text << event["text"] if event["text"]
end
end
end
end
result = JSON.parse(text)
puts "#{result['posture']} #{result['verdict']}"
<?php
$text = "";
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [
CURLOPT_POST => true,
CURLOPT_HTTPHEADER => [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: " . idem_key($payload),
"Accept: text/event-stream",
],
CURLOPT_POSTFIELDS => json_encode($payload),
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) use (&$text) {
foreach (explode("\n", $chunk) as $line) {
if (str_starts_with($line, "data:")) {
$event = json_decode(trim(substr($line, 5)), true);
if (isset($event["text"])) $text .= $event["text"];
}
}
return strlen($chunk);
},
]);
curl_exec($ch);
curl_close($ch);
$result = json_decode($text, true);
echo "{$result['posture']} {$result['verdict']}\n";
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run-stream");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Idempotency-Key", key);
req.Headers.Add("Accept", "text/event-stream");
req.Content = new StringContent(JsonSerializer.Serialize(payload), Encoding.UTF8, "application/json");
var res = await Client.SendAsync(req, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await res.Content.ReadAsStreamAsync());
var text = new StringBuilder();
while (await reader.ReadLineAsync() is { } line)
{
if (!line.StartsWith("data:")) continue;
var ev = JsonDocument.Parse(line[5..].Trim()).RootElement;
if (ev.TryGetProperty("text", out var t)) text.Append(t.GetString());
}
var result = JsonDocument.Parse(text.ToString()).RootElement;
Console.WriteLine(result.GetProperty("verdict"));
7. One worked example per lane
All three requests below send the same brief and the same facts_text
— the weekly churn-risk email that the app ships as its free example — and differ in
task and in the lane-filtered prescan. The envelope is identical across
all three; only body and artifact change. Arrays shown with a trailing
... follow the shapes documented above, abbreviated here to one or two
representative entries.
task: "applicability" — Draw the scope
Which frameworks are in scope and on what trigger, which plausibly-relevant ones are out and why, what data is being processed for what purpose on what candidate lawful basis, which approval gates the launch raises, and what the privacy notice will have to say. Be decisive about what is out: a launch cleared of HIPAA in one sentence is worth more to the reader than a register that lists it as "consider". This is the only lane that emits no file.
Request
{
"task": "applicability",
"brief": "<the launch brief>",
"facts_text": "<the key: value facts block>",
"context": "Security review has not started. Legal has seen the model card but not the transfer basis.",
"prescan": {
"signals": {
"regions": [{"id": "eu", "label": "EU/EEA", "declared": true}, {"id": "uk", "label": "United Kingdom", "declared": true}, {"id": "ca-us", "label": "California", "declared": true}],
"categories": [{"id": "email", "label": "email address", "special": false, "declared": true}, {"id": "ip", "label": "IP address", "special": false, "declared": true}],
"frameworks": [{"id": "GDPR", "name": "EU General Data Protection Regulation", "trigger": "regions: EU"}],
"processors": [{"name": "SendGrid", "dpa_status": "signed"},
{"name": "Segment", "dpa_status": "pending"}],
"launch_in_days": 27,
"automated_decision": true,
"cross_border": true
},
"flags": [
{"id": "GS-AUTOMATED-DECISION", "severity": "critical",
"label": "Automated decision with a significant effect",
"detail": "accounts scoring below 0.3 are auto-downgraded"},
{"id": "GS-CROSS-BORDER", "severity": "high",
"label": "Personal data leaves a declared region", "detail": "EU -> US"}
],
"gates": [{"gate": "DPIA", "role": "dpo"}]
}
}
Response data.output.output, parsed
{
"lane": "applicability",
"lane_inferred": false,
"initiative": "Weekly churn-risk email",
"title": "Churn-risk scoring and alerting - applicable frameworks and gates",
"posture": "conditions-first",
"verdict": "The launch is satisfiable before 15 September, but the auto-downgrade makes it an Art. 22 decision and that changes what has to exist first.",
"summary": "The programme scores every workspace nightly on personal data from three declared regions and acts on the score without a person in the loop ... This is a working analysis for privacy counsel and the accountable owner to review, and is not legal advice.",
"jurisdictions": ["EU/EEA", "United Kingdom", "California"],
"frameworks": [
{"id": "GDPR", "name": "EU General Data Protection Regulation", "applies": "yes",
"basis": "Art. 3(1) - processing personal data of EU data subjects",
"why": "The facts block declares EU as a launch region and email and usage events as the data."},
{"id": "CPRA", "name": "California Privacy Rights Act", "applies": "likely",
"basis": "1798.140(v) - the business threshold is not stated in the input",
"why": "California is a declared region, but no revenue or record-count fact was given."},
{"id": "SOC2", "name": "SOC 2", "applies": "yes",
"basis": "CC6.1 - logical access over the scoring pipeline",
"why": "The facts block names SOC 2 as an in-force framework."},
...
],
"assumptions": ["The score is computed from product usage events only; the brief does not mention any purchased data."],
"open_questions": ["Is the 0.3 threshold reviewable by a human before the downgrade takes effect?"],
"findings": [
{"id": "LG-001", "title": "Treat the auto-downgrade as an Art. 22 decision",
"severity": "critical", "area": "ai-governance",
"requirement": "GDPR Art. 22(1) - no solely automated decision with legal or similarly significant effect without a lawful ground and safeguards",
"evidence": "accounts scoring below 0.3 are auto-downgraded",
"why": "A downgrade changes the service the customer receives, and no human review is described anywhere in the input.",
"action": "Insert human review before any downgrade takes effect, or establish a lawful ground and the Art. 22(3) safeguards.",
"owner_role": "privacy-counsel"},
{"id": "LG-002", "title": "Establish the EU-to-US transfer basis before Segment is enabled",
"severity": "high", "area": "cross-border",
"requirement": "GDPR Chapter V - a transfer mechanism plus a transfer impact assessment",
"evidence": "transfers: EU -> US; processors: Segment, SendGrid, Snowflake (US)",
"why": "Three US processors receive EU personal data and only one DPA is signed.",
"action": "Record the transfer mechanism for each US processor and countersign the Segment DPA.",
"owner_role": "privacy-counsel"},
...
],
"coverage_check": [
{"flag_id": "GS-AUTOMATED-DECISION", "status": "confirmed", "finding_id": "LG-001", "note": "The downgrade is the significant effect."},
{"flag_id": "GS-CROSS-BORDER", "status": "confirmed", "finding_id": "LG-002", "note": "Three named processors sit in the US."}
],
"approvals": [
{"role": "dpo", "decision_needed": "decide whether a DPIA is required and own it if so",
"blocking": true, "by_when": "before launch"},
{"role": "privacy-counsel", "decision_needed": "sign off the Art. 22 position and the transfer basis",
"blocking": true, "by_when": "before launch"}
],
"artifact": {"kind": "none", "filename": "", "content": ""},
"next_lane": {"lane": "risk", "reason": "Four frameworks apply and two findings are high or above, so the exposure needs classifying before anyone sequences the work."},
"body": {
"in_scope": [
{"framework_id": "GDPR", "obligation": "record the processing in the Art. 30 register",
"trigger": "regions: EU with email, name, usage events and IP address",
"provision": "Art. 30(1)", "when": "before launch"},
{"framework_id": "GDPR", "obligation": "carry out a data protection impact assessment",
"trigger": "automated-decision: yes, applied to every workspace",
"provision": "Art. 35(3)(a)", "when": "before launch"},
...
],
"out_of_scope": [
{"framework_id": "HIPAA", "why": "No health data and no covered entity or business associate relationship appears in the input."}
],
"data_map": [
{"category": "email address", "special": false, "purpose": "addressing the churn alert",
"lawful_basis_candidate": "legitimate interests (Art. 6(1)(f)), balancing test required",
"retention_note": "24 months per the facts block"},
...
],
"gates": [
{"gate": "DPIA", "required": "yes", "role": "dpo",
"lead_time_note": "Depends on the Art. 22 position being settled first; the input gives no internal turnaround time."},
{"gate": "security review", "required": "yes", "role": "security",
"lead_time_note": "Not started per the facts block, and it gates the SOC 2 control evidence."}
],
"notice_changes": [
"The privacy notice needs a line on churn scoring, on the consequence of a low score, and on the categories of US recipients."
],
"scope_boundary": "This analysis covers privacy, cross-border transfer and audit-framework obligations on the facts given. It does not cover marketing-consent rules for the email channel itself, and it takes no view on the contractual position with the affected customers."
}
}
task: "risk" — Classify and escalate
A severity-by-likelihood matrix over the risks the input raises, then the escalation criteria applied to it. severity uses the same four bands as findings; likelihood is one of rare, unlikely, possible, likely and almost-certain; and score_band is the product band, one of low, moderate, high and severe.
The bands must be internally consistent — critical severity at likely likelihood cannot come out moderate — and that is worth asserting client-side. escalation.level is one of owner-decides, legal-review, senior-counsel, outside-counsel and executive-committee.
Every row with escalate: true must appear in escalation.trigger_rows, and the matrix has at least three rows whenever any framework applies. artifact.kind is memo: a Markdown memo under 400 words that the owner can forward to the escalation target.
Request
{
"task": "risk",
"brief": "<the same launch brief>",
"facts_text": "<the same facts block>",
"carried": "applicability: GDPR, UK GDPR and SOC 2 apply; CPRA likely; DPIA and security review are the two blocking gates.",
"prescan": {
"signals": {"launch_in_days": 27, "automated_decision": true, "cross_border": true,
"processors": [{"name": "Segment", "dpa_status": "pending"}]},
"flags": [
{"id": "GS-AUTOMATED-DECISION", "severity": "critical",
"label": "Automated decision with a significant effect",
"detail": "accounts scoring below 0.3 are auto-downgraded"},
{"id": "GS-SECURITY-REVIEW-MISSING", "severity": "high",
"label": "No security review has started", "detail": "security-review: not started"}
],
"gates": [{"gate": "DPIA", "role": "dpo"}]
}
}
Response data.output.output, parsed
{
"lane": "risk",
"lane_inferred": false,
"initiative": "Weekly churn-risk email",
"title": "Churn-risk scoring - risk classification and escalation",
"posture": "conditions-first",
"verdict": "One severe band survives the mitigations named in the input - the unreviewed automated downgrade - and it needs senior counsel rather than the owner.",
"summary": "Six risks were classified ... The residual position is acceptable only if the downgrade gains a human step before 15 September. This is a working analysis for the accountable owner and senior counsel to review, and is not legal advice.",
"jurisdictions": ["EU/EEA", "United Kingdom", "California"],
"frameworks": [
{"id": "GDPR", "name": "EU General Data Protection Regulation", "applies": "yes",
"basis": "Art. 22 and Chapter V", "why": "Carried from the applicability lane and confirmed by the facts block."},
...
],
"assumptions": ["The scope carried from the applicability run is taken as established and was not re-derived."],
"open_questions": ["Is there any internal precedent for an automated service downgrade, and where is it recorded?"],
"findings": [
{"id": "LG-001", "title": "Put a human step in front of the downgrade",
"severity": "critical", "area": "ai-governance",
"requirement": "GDPR Art. 22(3) - the right to obtain human intervention",
"evidence": "accounts scoring below 0.3 are auto-downgraded",
"why": "The exposure cannot be remediated after the fact: once a customer is downgraded, the decision has already had its effect.",
"action": "Hold downgrades in a review queue for the owner named in the facts block.",
"owner_role": "Priya Raman, PM Growth"},
...
],
"coverage_check": [
{"flag_id": "GS-AUTOMATED-DECISION", "status": "confirmed", "finding_id": "LG-001", "note": "Row R1 in the matrix carries it."},
{"flag_id": "GS-SECURITY-REVIEW-MISSING", "status": "confirmed", "finding_id": "LG-003", "note": "Row R4; it is the gating dependency for the SOC 2 evidence."}
],
"approvals": [
{"role": "senior-counsel", "decision_needed": "accept or reject the residual Art. 22 position",
"blocking": true, "by_when": "before launch"}
],
"artifact": {"kind": "memo", "filename": "risk-memo.md",
"content": "# Churn-risk email - risk memo\n\n**For:** senior counsel\n**From:** Priya Raman, PM Growth\n\n| Row | Risk | Severity | Likelihood | Band | Residual |\n|---|---|---|---|---|---|\n| R1 | ... |\n"},
"next_lane": {"lane": "readiness", "reason": "The mitigations now need owners, evidence and a status, which is the readiness register."},
"body": {
"matrix": [
{"id": "R1", "risk": "A customer is downgraded by the model with no human step",
"area": "ai-governance",
"severity": "critical", "likelihood": "likely", "score_band": "severe",
"exposure": "Regulatory exposure under Art. 22 that cannot be undone once the downgrade lands, plus a contractual argument from the affected customer.",
"mitigation": "Nothing in the input mitigates this; the brief describes the downgrade as automatic.",
"residual_band": "severe", "owner_role": "product-owner", "escalate": true},
{"id": "R4", "risk": "The scoring pipeline ships without a security review",
"area": "security",
"severity": "high", "likelihood": "likely", "score_band": "high",
"exposure": "A SOC 2 CC6.1 exception at the next audit window and no evidence of access control over a new data path.",
"mitigation": "None stated; the facts block says the review has not started.",
"residual_band": "high", "owner_role": "security", "escalate": true},
...
],
"escalation": {"level": "senior-counsel",
"reason": "A severe residual band survives every mitigation nameable from the input, and the exposure is unremediable after the fact.",
"who": "senior-counsel", "trigger_rows": ["R1", "R4"]},
"worst_case": "A cohort of EU customers is downgraded by the model in the first week and one complains to a supervisory authority. The complaint arrives with no DPIA, no human-review step and no transfer record for two of the three US processors.",
"accepted_risks": [
{"id": "R6", "risk": "IP address is retained for the full 24 months alongside the usage events",
"accepted_by_role": "product-owner",
"condition": "Valid only while the retention line stays at 24 months and the field stays out of the score itself."}
],
"appetite_note": "For a growth experiment on existing customer data this sits above a normal appetite, and it is the automated action rather than the scoring that puts it there."
}
}
task: "readiness" — Build the register
The producing lane. One row per requirement, mapped to a control, an evidence artifact, an owner role and a status — the row a SOC 2 or ISO 27001 auditor, or a GDPR accountability request, will actually ask for.
status is one of ready, in-progress, missing and not-applicable, and it is taken from the input: a DPA the facts block calls signed is ready, one it calls pending is in-progress, and silence is missing — never ready on an assumption.
readiness_score must equal the actual status counts in register, every blocking: true row that is missing must have a matching gaps entry, and the register has at least four rows whenever any framework applies. All three are cheap to assert and all three are real defects when they fail. artifact.kind is register: the same rows as a Markdown table and nothing else in the file.
Request
{
"task": "readiness",
"brief": "<the same launch brief>",
"facts_text": "<the same facts block>",
"carried": "risk: R1 severe and unmitigated (automated downgrade); R4 high (no security review); escalated to senior-counsel.",
"prescan": {
"signals": {"launch_in_days": 27,
"processors": [{"name": "SendGrid", "dpa_status": "signed"},
{"name": "Segment", "dpa_status": "pending"}],
"frameworks": [{"id": "SOC2", "name": "SOC 2", "trigger": "frameworks: SOC 2"}]},
"flags": [
{"id": "GS-DPA-PENDING", "severity": "medium",
"label": "A named processor has no signed agreement", "detail": "Segment pending"},
{"id": "GS-SECURITY-REVIEW-MISSING", "severity": "high",
"label": "No security review has started", "detail": "security-review: not started"}
],
"gates": [{"gate": "DPIA", "role": "dpo"}, {"gate": "security review", "role": "security"}]
}
}
Response data.output.output, parsed
{
"lane": "readiness",
"lane_inferred": false,
"initiative": "Weekly churn-risk email",
"title": "Churn-risk scoring - audit-readiness register",
"posture": "conditions-first",
"verdict": "Thirteen requirements, five of them missing, and every missing row has an owner in the facts block except the DPIA.",
"summary": "The register maps GDPR, SOC 2 and ISO 27001 obligations onto controls and evidence ... Four rows are ready today, all of them pre-existing controls rather than anything this launch built. This is a working analysis for the accountable owner and the audit function to review, and is not legal advice.",
"jurisdictions": ["EU/EEA", "United Kingdom", "California"],
"frameworks": [
{"id": "SOC2", "name": "SOC 2", "applies": "yes", "basis": "CC6.1 and CC7.2",
"why": "The facts block names SOC 2 and the launch adds a new nightly data path."},
...
],
"assumptions": ["Evidence locations were taken from the input; where it is silent the row says so rather than naming a system."],
"open_questions": ["Where is the DPIA template kept, and who countersigns it?"],
"findings": [
{"id": "LG-001", "title": "Open the DPIA before the security review",
"severity": "critical", "area": "records",
"requirement": "GDPR Art. 35(1) - a DPIA before processing likely to result in a high risk",
"evidence": "automated-decision: yes - accounts scoring below 0.3 are auto-downgraded",
"why": "It is the one blocking row with no owner named anywhere in the input, and the security review depends on its output.",
"action": "Assign the DPIA to the DPO this week.",
"owner_role": "dpo"},
...
],
"coverage_check": [
{"flag_id": "GS-DPA-PENDING", "status": "confirmed", "finding_id": "LG-002", "note": "REQ-01 is in-progress on the Segment agreement."},
{"flag_id": "GS-SECURITY-REVIEW-MISSING", "status": "confirmed", "finding_id": "LG-003", "note": "REQ-05, missing and blocking."}
],
"approvals": [
{"role": "dpo", "decision_needed": "own and sign the DPIA", "blocking": true, "by_when": "before launch"},
{"role": "security", "decision_needed": "complete the review and record the result", "blocking": true, "by_when": "before launch"},
{"role": "procurement", "decision_needed": "countersign the Segment DPA", "blocking": true, "by_when": "before launch"}
],
"artifact": {"kind": "register", "filename": "audit-readiness-register.md",
"content": "| Req | Requirement | Framework | Provision | Control | Evidence | Owner | Status | Blocking | Verification |\n|---|---|---|---|---|---|---|---|---|---|\n| REQ-01 | signed Art. 28 processor agreement with Segment | GDPR | Art. 28(3) | ... |\n"},
"next_lane": {"lane": "", "reason": "This is the last lane; what remains is closing the gaps with their owners."},
"body": {
"register": [
{"req_id": "REQ-01", "requirement": "signed Art. 28 processor agreement with Segment",
"framework_id": "GDPR", "provision": "Art. 28(3)",
"control": "procurement countersigns the DPA before the vendor is enabled in production",
"evidence_artifact": "countersigned DPA PDF in the contracts repository",
"owner_role": "procurement", "status": "in-progress", "blocking": true,
"verification": "An auditor pulls the vendor list and asks for a countersigned agreement per name."},
{"req_id": "REQ-04", "requirement": "DPIA covering the churn score and the automated downgrade",
"framework_id": "GDPR", "provision": "Art. 35(3)(a)",
"control": "the DPO signs the DPIA before the scoring job runs against production data",
"evidence_artifact": "the signed DPIA; the input does not say where DPIAs are kept",
"owner_role": "dpo", "status": "missing", "blocking": true,
"verification": "An auditor asks for the DPIA and its sign-off date, and compares it against the launch date."},
{"req_id": "REQ-05", "requirement": "security review of the nightly scoring pipeline",
"framework_id": "SOC2", "provision": "CC6.1",
"control": "security reviews and records access control over each new data path",
"evidence_artifact": "the review record; not started per the facts block",
"owner_role": "security", "status": "missing", "blocking": true,
"verification": "An auditor samples new data paths in the period and asks for the review for each."},
...
],
"gaps": [
{"req_id": "REQ-04", "gap": "No DPIA exists and no owner is named for it in the input",
"blocking": true, "first_step": "Assign the DPIA to the DPO and open it against the model card legal has already seen.",
"owner_role": "dpo"},
{"req_id": "REQ-05", "gap": "The security review has not started", "blocking": true,
"first_step": "Book the review for the scoring pipeline, not for the email channel.",
"owner_role": "security"},
...
],
"audit_pack": [
{"item": "the DPIA", "where_it_lives": "the input does not say"},
{"item": "the Art. 30 record for the churn processing", "where_it_lives": "the input does not say"}
],
"cadence": [
{"activity": "re-review the churn model's feature list", "frequency": "quarterly",
"owner_role": "data-engineering", "why": "a new feature can pull in a data category the DPIA never covered"},
{"activity": "re-check the downgrade review queue for backlog", "frequency": "monthly",
"owner_role": "product-owner", "why": "a queue nobody empties is not a human step"}
],
"readiness_score": {"ready": 4, "in_progress": 3, "missing": 5, "not_applicable": 1}
}
}
readiness_score above sums to thirteen, the number of rows the register actually has. Check that yourself: it is the cheapest test of whether the register in front of you is the register the model counted.
The pipeline
The three lanes are meant to be run in order over the same work object, and each response says so in next_lane. The handoff is carried: a short digest of the previous lane's output, so the risk lane classifies inside the scope applicability drew instead of re-deriving it, and readiness builds the register against the risks that were actually escalated.
carried is a convenience, not a channel — the brief and facts_text still go with every call, because each run is independent and the model has no memory of the last one.
Give each lane its own idempotency key. Same brief, different task: if the lane is
not in the key, the risk run collides with the applicability run and hands back the applicability
lane's cached result, with no error anywhere to say why the matrix is missing.
# 1. Scope it.
post /run '{"task":"applicability","brief":"<brief>","facts_text":"<facts>"}' > a.json
# ... poll /jobs/{id} until succeeded, then read the verdict out:
CARRIED=$(python3 -c "import json;o=json.loads(json.load(open('job.json'))['data']['output']['output']);print('applicability: '+o['verdict'])")
# 2. Classify it. New lane in the key, so a new run.
post /run "{\"task\":\"risk\",\"brief\":\"<brief>\",\"facts_text\":\"<facts>\",\"carried\":\"$CARRIED\"}"
# 3. Build the register, carrying the escalation.
post /run "{\"task\":\"readiness\",\"brief\":\"<brief>\",\"facts_text\":\"<facts>\",\"carried\":\"$CARRIED\"}"
base = {"brief": open("brief.txt").read(), "facts_text": open("facts.txt").read()}
scope = run_and_wait({**base, "task": "applicability"})
assert scope["lane"] == "applicability" and not scope["lane_inferred"]
risk = run_and_wait({**base, "task": "risk",
"carried": "applicability: " + scope["verdict"]})
print(risk["body"]["escalation"]["level"], risk["body"]["escalation"]["trigger_rows"])
ready = run_and_wait({**base, "task": "readiness",
"carried": "risk: " + risk["body"]["escalation"]["reason"]})
score = ready["body"]["readiness_score"]
assert sum(score.values()) == len(ready["body"]["register"]), score
open("audit-readiness-register.md", "w").write(ready["artifact"]["content"])
const base = {
brief: readFileSync("brief.txt", "utf8"),
facts_text: readFileSync("facts.txt", "utf8")
};
const scope = await runAndWait({ ...base, task: "applicability" });
if (scope.lane_inferred) throw new Error("task did not arrive");
const risk = await runAndWait({
...base, task: "risk", carried: `applicability: ${scope.verdict}`
});
console.log(risk.body.escalation.level, risk.body.escalation.trigger_rows);
const ready = await runAndWait({
...base, task: "readiness", carried: `risk: ${risk.body.escalation.reason}`
});
writeFileSync("audit-readiness-register.md", ready.artifact.content);
base := map[string]any{"brief": string(brief), "facts_text": string(facts)}
scope := runAndWait(with(base, "task", "applicability"), keyFor("applicability", base, 1))
if scope.LaneInferred {
panic("task did not arrive")
}
riskPayload := with(base, "task", "risk")
riskPayload["carried"] = "applicability: " + scope.Verdict
risk := runAndWait(riskPayload, keyFor("risk", base, 1))
readyPayload := with(base, "task", "readiness")
readyPayload["carried"] = "risk: " + risk.Body.Escalation.Reason
ready := runAndWait(readyPayload, keyFor("readiness", base, 1))
os.WriteFile("audit-readiness-register.md", []byte(ready.Artifact.Content), 0o644)
String brief = Files.readString(Path.of("brief.txt"));
String facts = Files.readString(Path.of("facts.txt"));
// 1. Scope it.
String scope = runAndWait(payloadFor("applicability", brief, facts, null),
"launch-gate:applicability:" + digest + ":a1");
String carried = "applicability: " + JsonUtil.path(scope, "verdict");
// 2. Classify it - note the different lane in the key.
String risk = runAndWait(payloadFor("risk", brief, facts, carried),
"launch-gate:risk:" + digest + ":a1");
// 3. Build the register.
String ready = runAndWait(payloadFor("readiness", brief, facts, carried),
"launch-gate:readiness:" + digest + ":a1");
Files.writeString(Path.of("audit-readiness-register.md"),
JsonUtil.path(ready, "artifact", "content"));
base = { "brief" => File.read("brief.txt"), "facts_text" => File.read("facts.txt") }
scope = run_and_wait(base.merge("task" => "applicability"))
raise "task did not arrive" if scope["lane_inferred"]
risk = run_and_wait(base.merge("task" => "risk",
"carried" => "applicability: #{scope['verdict']}"))
puts risk["body"]["escalation"]["level"]
ready = run_and_wait(base.merge("task" => "readiness",
"carried" => "risk: #{risk['body']['escalation']['reason']}"))
File.write("audit-readiness-register.md", ready["artifact"]["content"])
<?php
$base = ["brief" => file_get_contents("brief.txt"),
"facts_text" => file_get_contents("facts.txt")];
$scope = run_and_wait($base + ["task" => "applicability"]);
if ($scope["lane_inferred"]) {
throw new RuntimeException("task did not arrive");
}
$risk = run_and_wait($base + ["task" => "risk",
"carried" => "applicability: " . $scope["verdict"]]);
echo $risk["body"]["escalation"]["level"] . "\n";
$ready = run_and_wait($base + ["task" => "readiness",
"carried" => "risk: " . $risk["body"]["escalation"]["reason"]]);
file_put_contents("audit-readiness-register.md", $ready["artifact"]["content"]);
var brief = await File.ReadAllTextAsync("brief.txt");
var facts = await File.ReadAllTextAsync("facts.txt");
var scope = await RunAndWaitAsync(
new { task = "applicability", brief, facts_text = facts },
KeyFor("applicability", brief, facts));
var carried = "applicability: " + scope.GetProperty("verdict").GetString();
var risk = await RunAndWaitAsync(
new { task = "risk", brief, facts_text = facts, carried },
KeyFor("risk", brief, facts));
var ready = await RunAndWaitAsync(
new { task = "readiness", brief, facts_text = facts, carried },
KeyFor("readiness", brief, facts));
await File.WriteAllTextAsync("audit-readiness-register.md",
ready.GetProperty("artifact").GetProperty("content").GetString());
run_and_wait is step 5 wrapped in a function: post to /run with the
idempotency key, poll GET /jobs/{job_id} until terminal, and return
json.loads(status["output"]["output"]).
Notes that will save you a support round trip
- The run body is not wrapped in an
inputkey.POST /run,POST /run-streamandPOST /estimateall take the input object itself at the top level. Wrapping it returns200with a plausible hold and bills you for a payload the prompt cannot read. This is the only failure on this page that does not announce itself. - There is no slug header — not an
X-App-...variant, not anything. The only headers on any endpoint areAuthorization,Content-Typeand, on the two run endpoints,Idempotency-Key. The slug is named once, in thePOST /guestbody. A bogus custom header is ignored rather than rejected, so sending one looks like it works and then explains nothing when something else breaks. - The output is one JSON object, and you should still strip a stray code fence.
The contract says no prose and no fence; a client that trims a leading
```jsonand a trailing```before parsing costs three lines and removes a whole class of failure. Parse first, then checklane. - Check
lane_inferredon every reply. If it istrue, yourtaskfield did not arrive or was not recognised and the model chose a lane for you. Treat the response as suspect rather than as an answer to the question you asked — and check your payload for theinputwrapper, which is the usual cause. - Reconcile
coverage_checkagainst the flags you sent, both ways: a missing entry is an unreconciled finding, and an entry for an id you did not send is an invented one. Both are cheap to assert and both are real defects. - Assert the arithmetic.
readiness_scorehas to sum to the number of rows inregister; everymatrixrow withescalate: truehas to appear inescalation.trigger_rows; every blockingmissingrow needs agapsentry. The page checks all three, and so should you. - The facts block wins over the brief. Where the two disagree, the model takes
the facts block and says so in
assumptions. If your integration buildsfacts_textfrom a form, that form is the authority: a stalelaunch:date will quietly changelaunch_in_daysand every "before launch" judgement that leans on it. - Do not send anything you would not want restated. The prompt refuses to echo a credential, token, password or real-looking personal identifier found in a paste — it names it as something to rotate and moves on — but the safest input is one that never contained them. A launch brief is usually confidential; treat it that way.
What this app is not
Launch Gate produces a working analysis for a named human reviewer —
privacy counsel, security, or the accountable owner in the facts block. It is not a clearance and
it is not legal advice. posture is a recommendation to the approver, never a
decision instead of one; a framework the input does not place goes into open_questions
rather than being asserted, and an approval the input does not evidence is missing
rather than assumed. Build your integration so the output lands in front of the reviewer, not in
front of the launch button.