Driving Gherkin Desk from your own code
Everything the web app's five lanes do is available over HTTP. Send a task, the .feature file text, and the parse your side made of it, and get back one JSON object. The Gherkin reader the browser runs for free — the AST, the twenty-nine lint rules, the step-signature index, the canonical formatter — is not re-run server-side. If you drive the API directly you must send your own facts object, because that object is the only thing the model is held accountable to.
The task field comes first
This is a multi-lane app with one system prompt and one model. Which lane you get is decided entirely by task. Send it on every call, including /estimate — hold_credits differs per lane because the prompts and output caps differ, and pricing one lane while running another is the most common mistake against this API.
task | What it returns | Extra input it reads |
|---|---|---|
audit | A per-scenario BDD review, domain-language notes, a structure note | — |
rewrite | The whole file rewritten declaratively, plus a changelog | — |
gaps | The scenarios the file never covers, as ready-to-paste Gherkin, ranked by risk | — |
stepdefs | One step definition per distinct signature, with the ambiguities named | target_language |
testplan | A manual QA plan: objective, role, priority, preconditions, action/expected pairs | — |
If task is missing or unrecognised the model picks the closest lane and names the one it chose in the task field of its reply. It never blends two lanes.
Base URL and headers
| Thing | Value |
|---|---|
| Base URL | https://api.skillsafe.ai/v1/app-api |
| Auth | Authorization: Bearer <token> |
| Body | Content-Type: application/json. The body is the input object — there is no {"input": ...} wrapper, and wrapping it returns 200 while hiding task from the model |
| App identity | carried by the token. There is no X-App-Slug header. The one place the slug gherkin-desk appears is the body of POST /guest |
| Idempotency | Idempotency-Key: <string> on /run and /run-stream. Hash (task, input, attempt) — two lanes over one file are two runs and must not share a key |
| Call | Path | Costs |
|---|---|---|
| Mint a guest token | POST /v1/app-api/guest | free, and the only call whose body is not a lane input |
| Who am I | GET /v1/app-api/me | free |
| Price an input | POST /v1/app-api/estimate | free, creates no job |
| Run a lane | POST /v1/app-api/run | metered; reserves hold_credits |
| Run a lane, streamed | POST /v1/app-api/run-stream | metered; the same run as /run |
| Poll a job | GET /v1/app-api/jobs/{job_id} | free |
The response envelope
Every response, success or failure, is the same shape. Read ok before you touch data.
{"ok": true, "data": { ... }}
{"ok": false, "error": {"code": "payment_required", "message": "...", "details": { ... }}}
| Code | HTTP | What to do |
|---|---|---|
unauthorized | 401 | The token is missing, malformed, or was revoked. Mint a new one from /guest or sign in. |
forbidden | 403 | A guest token tried to run a metered lane. Runs need a personal token unless the publisher sponsors guests. |
payment_required | 402 | The balance is below min_credits for this lane. /estimate is free - call it first and never submit into a 402. |
validation_error | 400 | The body was not the input object, or a field had the wrong type. The body IS the input object - there is no {"input": ...} wrapper. |
rate_limited | 429 | Back off and retry. Do not tight-loop. |
not_found | 404 | Wrong path, or a job_id that does not belong to this token. |
internal_error | 500 | Retry once with the SAME Idempotency-Key; a retry under the same key never double-bills. |
The input object
| Field | Type | Meaning |
|---|---|---|
task | string | Required in practice. One of audit, rewrite, gaps, stepdefs, testplan |
feature | string | The .feature file text. The web app clips at ~14,000 characters on whole-scenario boundaries, keeping the Feature header and the Background |
context | string | Optional. The domain, the system under test, what worries you |
target_language | string | ruby, javascript, python or java. Read by stepdefs; harmless elsewhere |
facts | object | Your parse of the file. Treated as authoritative about structure and counts — see below |
retry_note | string | Optional. The web app sets this on its single reformat retry |
facts carries stats, parses, scenarios[], step_signatures[], flags[] and flag_counts. The flags array is the contract. Every distinct flags[].id you send comes back as one entry in reconciliation[] with a status of confirmed, cleared or not-assessed. Send an empty flags array and you get an empty reconciliation — and no way to tell whether the model read the file properly.
{
"task": "audit",
"feature": "Feature: ATM withdrawal\n\n Scenario: Successful withdrawal\n Given my account balance is 500 dollars\n When I request 50 dollars\n Then my balance should be 450 dollars\n",
"context": "Retail banking ATM, Cucumber-JVM.",
"target_language": "java",
"facts": {
"stats": {"scenario_count": 1, "step_count": 3, "distinct_step_count": 3},
"parses": true,
"scenarios": [{"name": "Successful withdrawal", "kind": "scenario", "line": 3, "steps": 3, "tags": []}],
"step_signatures": [
{"signature": "my account balance is {int} dollars", "keywords": ["Given"], "uses": 1},
{"signature": "I request {int} dollars", "keywords": ["When"], "uses": 1},
{"signature": "my balance should be {int} dollars", "keywords": ["Then"], "uses": 1}
],
"flags": [],
"flag_counts": {"blocker": 0, "warn": 0, "note": 0}
}
}
The output contract
Every lane returns one JSON object with the same envelope; only body differs.
{
"task": "audit",
"title": "one line naming the file and the job",
"verdict": "one of the task's allowed verdicts",
"summary": "two to four sentences",
"assumptions": ["..."],
"open_questions": ["..."],
"findings": [{"id":"GDF-001","severity":"blocker|major|minor","scenario":"","line":0,
"issue":"","why":"","fix":""}],
"reconciliation": [{"flag":"GD03","status":"confirmed|cleared|not-assessed","note":""}],
"next_lane": "audit|rewrite|gaps|stepdefs|testplan|none",
"body": { }
}
Allowed verdicts, per lane: audit → ready-to-run / needs-work / rewrite-first; rewrite → rewritten / rewritten-with-questions / too-little-to-rewrite; gaps → well-covered / gaps-found / major-gaps; stepdefs → definitions-drafted / drafted-with-ambiguities / too-little-to-define; testplan → plan-drafted / plan-drafted-with-gaps / not-testable-manually.
One worked example per lane
task: "audit" — Audit the Gherkin
Request
{"task": "audit", "feature": "...", "context": "...", "target_language": "java", "facts": { ... }}
Reply (abridged — the envelope is identical for every lane)
{
"task": "audit",
"title": "ATM withdrawal - BDD authoring review",
"verdict": "rewrite-first",
"summary": "Three blockers and a duplicated scenario name...",
"assumptions": ["The daily limit is enforced by the account, not the ATM"],
"open_questions": ["What should happen to the card after the third wrong PIN?"],
"findings": [{"id":"GDF-001","severity":"blocker","scenario":"Background","line":6,
"issue":"A password literal is committed in a Background step",
"why":"It is in version control and in every CI log",
"fix":"Replace with a named credential from the secrets store"}],
"reconciliation": [{"flag":"GD03","status":"confirmed","note":"The wrong-PIN scenario has no Then"}],
"next_lane": "rewrite",
"body": {
"scenario_verdicts": [{"name":"Successful withdrawal","verdict":"split-it","note":"Three When steps"}],
"language_notes": [{"term":"I","problem":"the narrative says card holder","suggestion":"the card holder"}],
"structure_note": "The Background asserts, which makes every scenario..."
}
}
task: "rewrite" — Rewrite it declaratively
Request
{"task": "rewrite", "feature": "...", "context": "...", "facts": { ... }}
Reply (abridged — the envelope is identical for every lane)
{
"task": "rewrite",
"verdict": "rewritten-with-questions",
"body": {
"feature": "Feature: ATM withdrawal\n\n As a card holder\n I want to withdraw cash\n ...",
"changes": [{"scenario":"Successful withdrawal","change":"Lifted three click steps to one intent step",
"reason":"The scenario now survives a UI change"}],
"left_alone": ["The @regression tag, which the pipeline selects on"]
}
}
body.feature is a complete .feature file. The web app re-parses and re-lints it
before displaying it; if you drive the API directly, reproduce that check.
task: "gaps" — Find the missing scenarios
Request
{"task": "gaps", "feature": "...", "context": "...", "facts": { ... }}
Reply (abridged — the envelope is identical for every lane)
{
"task": "gaps",
"verdict": "major-gaps",
"body": {
"missing": [{
"id": "GAP-1",
"category": "error state",
"title": "The dispenser jams mid-withdrawal",
"risk": "high",
"why": "The account is debited before the notes leave the machine",
"gherkin": " Scenario: Dispenser jams during a withdrawal\n Given ...\n When ...\n Then ..."
}],
"covered_well": ["The happy-path debit and dispense pair"]
}
}
Every `gherkin` value is a complete, valid, two-space-indented scenario ready to
paste under the existing Feature:. `missing` is ordered by risk, highest first.
task: "stepdefs" — Draft the step definitions
Request
{"task": "stepdefs", "feature": "...", "target_language": "java", "facts": { ... }}
Reply (abridged — the envelope is identical for every lane)
{
"task": "stepdefs",
"verdict": "drafted-with-ambiguities",
"body": {
"language": "java",
"framework": "Cucumber JVM",
"file_path": "src/test/java/atm/WithdrawalSteps.java",
"definitions": [{
"signature": "I request {int} dollars",
"expression": "I request {int} dollars",
"steps_covered": ["When I request 50 dollars"],
"note": "Delegates to AtmContext; no assertion in a When",
"code": "@When(\"I request {int} dollars\")\npublic void i_request_dollars(Integer amount) { ... }"
}],
"ambiguities": [{"signature":"I request {placeholder} dollars",
"advice":"Outline raw text; it resolves to the {int} twin at run time"}],
"support_code": {"note":"picocontainer holder","code":"public class AtmContext { ... }"}
}
}
`target_language` is one of ruby, javascript, python, java. There is exactly ONE
definition per distinct signature - send your own signature list in
`facts.step_signatures` and the reply is checked against it.
task: "testplan" — Produce the manual test plan
Request
{"task": "testplan", "feature": "...", "context": "...", "facts": { ... }}
Reply (abridged — the envelope is identical for every lane)
{
"task": "testplan",
"verdict": "plan-drafted-with-gaps",
"body": {
"cases": [{
"id": "TC-1",
"title": "Withdraw 50 dollars from a 500 dollar balance",
"objective": "The balance is debited and the notes are dispensed",
"role": "QA engineer with a funded test card",
"priority": "P1",
"preconditions": ["A test account funded to 500 dollars"],
"steps": [{"action":"Request 50 dollars","expected":"50 dollars is dispensed"}],
"pass_criteria": "Balance reads 450 and 50 dollars was dispensed",
"source_scenario": "Successful withdrawal"
}],
"data_needed": ["A funded test card in the staging environment"],
"coverage_note": "The plan cannot verify the dispenser jam path by hand..."
}
}
Step by step
1. A tiny client helper
Nine lines of plumbing, reused by every step below. Replace YOUR_TOKEN with the token from step 2 — keep it out of source control and out of your shell history.
# Every call below uses these two. The token comes from step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN"
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"
def call(method, path, body=None):
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)
if data:
req.add_header("Content-Type", "application/json")
with urllib.request.urlopen(req) as r:
payload = json.loads(r.read())
if not payload.get("ok"):
raise RuntimeError(payload["error"]["code"] + ": " + payload["error"]["message"])
return payload["data"]
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
async function call(method, path, body) {
const res = await fetch(BASE + path, {
method,
headers: {
"Authorization": `Bearer ${TOKEN}`,
...(body ? { "Content-Type": "application/json" } : {})
},
body: body ? JSON.stringify(body) : undefined
});
const payload = await res.json();
if (!payload.ok) throw new Error(`${payload.error.code}: ${payload.error.message}`);
return payload.data;
}
package main
import (
"bytes"
"encoding/json"
"errors"
"io"
"net/http"
)
const base = "https://api.skillsafe.ai/v1/app-api"
const token = "YOUR_TOKEN"
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 rdr io.Reader
if body != nil {
b, _ := json.Marshal(body)
rdr = bytes.NewReader(b)
}
req, _ := http.NewRequest(method, base+path, rdr)
req.Header.Set("Authorization", "Bearer "+token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
res, err := http.DefaultClient.Do(req)
if err != nil {
return nil, err
}
defer res.Body.Close()
var env envelope
if err := json.NewDecoder(res.Body).Decode(&env); err != nil {
return nil, err
}
if !env.OK {
return nil, errors.New(env.Error.Code + ": " + env.Error.Message)
}
return env.Data, nil
}
import java.net.URI;
import java.net.http.*;
public class GherkinDesk {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static final String TOKEN = "YOUR_TOKEN";
static final HttpClient CLIENT = HttpClient.newHttpClient();
static String call(String method, String path, String jsonBody) throws Exception {
HttpRequest.Builder b = HttpRequest.newBuilder()
.uri(URI.create(BASE + path))
.header("Authorization", "Bearer " + TOKEN);
if (jsonBody == null) {
b.method(method, HttpRequest.BodyPublishers.noBody());
} else {
b.header("Content-Type", "application/json")
.method(method, HttpRequest.BodyPublishers.ofString(jsonBody));
}
HttpResponse<String> res = CLIENT.send(b.build(), HttpResponse.BodyHandlers.ofString());
return res.body(); // parse with your JSON library; check "ok" before "data"
}
}
require "json"
require "net/http"
require "uri"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN"
def call(method, path, body = nil)
uri = URI(BASE + path)
klass = method == "GET" ? Net::HTTP::Get : Net::HTTP::Post
req = klass.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
if body
req["Content-Type"] = "application/json"
req.body = JSON.generate(body)
end
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
payload = JSON.parse(res.body)
raise "#{payload["error"]["code"]}: #{payload["error"]["message"]}" unless payload["ok"]
payload["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
const TOKEN = "YOUR_TOKEN";
function call(string $method, string $path, ?array $body = null): array {
$headers = ["Authorization: Bearer " . TOKEN];
$opts = ["http" => ["method" => $method, "ignore_errors" => true]];
if ($body !== null) {
$headers[] = "Content-Type: application/json";
$opts["http"]["content"] = json_encode($body);
}
$opts["http"]["header"] = implode("\r\n", $headers);
$raw = file_get_contents(BASE . $path, false, stream_context_create($opts));
$payload = json_decode($raw, true);
if (empty($payload["ok"])) {
throw new RuntimeException($payload["error"]["code"] . ": " . $payload["error"]["message"]);
}
return $payload["data"];
}
using System.Net.Http;
using System.Net.Http.Headers;
using System.Text;
using System.Text.Json;
static class GherkinDesk {
const string Base = "https://api.skillsafe.ai/v1/app-api";
const string Token = "YOUR_TOKEN";
static readonly HttpClient Client = new HttpClient();
public static async Task<JsonElement> Call(HttpMethod method, string path, object? body = null) {
var req = new HttpRequestMessage(method, Base + path);
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
if (body != null) {
req.Content = new StringContent(JsonSerializer.Serialize(body), Encoding.UTF8, "application/json");
}
var res = await Client.SendAsync(req);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var root = doc.RootElement;
if (!root.GetProperty("ok").GetBoolean()) {
var err = root.GetProperty("error");
throw new Exception(err.GetProperty("code").GetString() + ": " + err.GetProperty("message").GetString());
}
return root.GetProperty("data").Clone();
}
}
2. Get a token
A guest token is free to mint and is enough for /me and /estimate. Running a lane is metered and needs a personal token, which comes from signing in — the app has a token page that reveals, copies and replaces the token this browser already holds, so you never need a storage inspector.
curl -s -X POST "$BASE/guest" \
-H "Content-Type: application/json" \
-d '{"slug":"gherkin-desk"}'
# -> {"ok":true,"data":{"token":"aut_...","guest_id":"gst_..."}}
import json, urllib.request
req = urllib.request.Request(
"https://api.skillsafe.ai/v1/app-api/guest",
data=json.dumps({"slug": "gherkin-desk"}).encode(),
headers={"Content-Type": "application/json"},
method="POST")
with urllib.request.urlopen(req) as r:
TOKEN = json.loads(r.read())["data"]["token"]
const res = await fetch("https://api.skillsafe.ai/v1/app-api/guest", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ slug: "gherkin-desk" })
});
const TOKEN = (await res.json()).data.token;
b, _ := json.Marshal(map[string]string{"slug": "gherkin-desk"})
res, _ := http.Post(base+"/guest", "application/json", bytes.NewReader(b))
defer res.Body.Close()
// decode into envelope, then env.Data -> {"token": "...", "guest_id": "..."}
String body = "{\"slug\":\"gherkin-desk\"}";
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/guest"))
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(body))
.build();
String json = CLIENT.send(req, HttpResponse.BodyHandlers.ofString()).body();
// json.data.token is your guest token
uri = URI("https://api.skillsafe.ai/v1/app-api/guest")
res = Net::HTTP.post(uri, JSON.generate({ "slug" => "gherkin-desk" }),
"Content-Type" => "application/json")
TOKEN = JSON.parse(res.body)["data"]["token"]
<?php
$opts = ["http" => [
"method" => "POST",
"header" => "Content-Type: application/json",
"content" => json_encode(["slug" => "gherkin-desk"]),
]];
$raw = file_get_contents("https://api.skillsafe.ai/v1/app-api/guest", false,
stream_context_create($opts));
$token = json_decode($raw, true)["data"]["token"];
var content = new StringContent("{\"slug\":\"gherkin-desk\"}", Encoding.UTF8, "application/json");
var res = await Client.PostAsync("https://api.skillsafe.ai/v1/app-api/guest", content);
using var doc = JsonDocument.Parse(await res.Content.ReadAsStringAsync());
var token = doc.RootElement.GetProperty("data").GetProperty("token").GetString();
3. Check who you are and what you can spend
Free. Returns subject_type (user or guest), credits, and the profile when there is one.
curl -s "$BASE/me" -H "Authorization: Bearer $TOKEN"
# -> {"ok":true,"data":{"subject_type":"user","credits":48210,...}}
me = call("GET", "/me")
print(me["subject_type"], me["credits"])
const me = await call("GET", "/me");
console.log(me.subject_type, me.credits);
data, err := call("GET", "/me", nil)
if err != nil {
panic(err)
}
fmt.Println(string(data))
String me = call("GET", "/me", null);
System.out.println(me);
me = call("GET", "/me")
puts "#{me["subject_type"]} #{me["credits"]}"
<?php
$me = call("GET", "/me");
echo $me["subject_type"], " ", $me["credits"], "\n";
var me = await Call(HttpMethod.Get, "/me");
Console.WriteLine(me.GetProperty("subject_type").GetString());
4. Price the lane you are about to run
Free, and it creates no job. Estimate the same input you are about to run, including task.
curl -s -X POST "$BASE/estimate" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-d @input.json
# -> {"ok":true,"data":{"model":"gpt-5.6-terra","model_alias":"gpt-terra",
# "markup_bps":1000,"hold_credits":4120,"min_credits":260,"sponsor_enabled":false}}
INPUT = json.load(open("input.json"))
est = call("POST", "/estimate", INPUT)
print(est["model_alias"], est["hold_credits"], est["min_credits"])
# estimate is FREE and creates no job. Estimate the lane you are about to run:
# hold_credits differs per task because the prompts and output caps differ.
const est = await call("POST", "/estimate", INPUT);
console.log(est.model_alias, est.hold_credits, est.min_credits);
data, err := call("POST", "/estimate", input)
if err != nil {
panic(err)
}
fmt.Println(string(data))
String est = call("POST", "/estimate", inputJson);
System.out.println(est);
est = call("POST", "/estimate", input)
puts "#{est["model_alias"]} #{est["hold_credits"]}"
<?php
$est = call("POST", "/estimate", $input);
echo $est["hold_credits"], "\n";
var est = await Call(HttpMethod.Post, "/estimate", input);
Console.WriteLine(est.GetProperty("hold_credits").GetInt32());
5. Run a lane and poll it
Metered. Submit, take job_id, poll /jobs/{job_id} until status is succeeded, failed or cancelled. output.output is the JSON string described in the output contract — parse it, do not regex it.
# 1. submit
JOB=$(curl -s -X POST "$BASE/run" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: gherkin-desk:audit:$(shasum -a 256 input.json | cut -c1-16):a1" \
-d @input.json | python3 -c 'import json,sys;print(json.load(sys.stdin)["data"]["job_id"])')
# 2. poll to terminal
until curl -s "$BASE/jobs/$JOB" -H "Authorization: Bearer $TOKEN" \
| tee /dev/stderr | grep -q '"status":"succeeded"'; do sleep 2; done
import hashlib, time
key = "gherkin-desk:audit:" + hashlib.sha256(
json.dumps(INPUT, sort_keys=True).encode()).hexdigest()[:16] + ":a1"
req = urllib.request.Request(BASE + "/run",
data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
with urllib.request.urlopen(req) as r:
job_id = json.loads(r.read())["data"]["job_id"]
while True:
job = call("GET", "/jobs/" + job_id)
if job["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
result = json.loads(job["output"]["output"]) # the envelope described below
print(result["verdict"], len(result["findings"]))
const key = `gherkin-desk:audit:${hash(JSON.stringify(INPUT))}:a1`;
const res = await fetch(`${BASE}/run`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key
},
body: JSON.stringify(INPUT)
});
const { job_id } = (await res.json()).data;
let job;
do {
await new Promise(r => setTimeout(r, 2000));
job = await call("GET", `/jobs/${job_id}`);
} while (!["succeeded", "failed", "cancelled"].includes(job.status));
const result = JSON.parse(job.output.output);
console.log(result.verdict, result.findings.length);
b, _ := json.Marshal(input)
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", "gherkin-desk:audit:"+hash(b)+":a1")
// submit, read data.job_id, then GET /jobs/{id} until status is terminal
HttpRequest req = HttpRequest.newBuilder()
.uri(URI.create(BASE + "/run"))
.header("Authorization", "Bearer " + TOKEN)
.header("Content-Type", "application/json")
.header("Idempotency-Key", "gherkin-desk:audit:" + hash(inputJson) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(inputJson))
.build();
String submitted = CLIENT.send(req, HttpResponse.BodyHandlers.ofString()).body();
// read data.job_id, then poll GET /jobs/{id}
uri = URI(BASE + "/run")
req = Net::HTTP::Post.new(uri)
req["Authorization"] = "Bearer #{TOKEN}"
req["Content-Type"] = "application/json"
req["Idempotency-Key"] = "gherkin-desk:audit:#{hash(input)}:a1"
req.body = JSON.generate(input)
res = Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) { |h| h.request(req) }
job_id = JSON.parse(res.body)["data"]["job_id"]
loop do
job = call("GET", "/jobs/#{job_id}")
break if %w[succeeded failed cancelled].include?(job["status"])
sleep 2
end
<?php
$opts = ["http" => [
"method" => "POST",
"header" => implode("\r\n", [
"Authorization: Bearer " . TOKEN,
"Content-Type: application/json",
"Idempotency-Key: gherkin-desk:audit:" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1",
]),
"content" => json_encode($input),
]];
$raw = file_get_contents(BASE . "/run", false, stream_context_create($opts));
$jobId = json_decode($raw, true)["data"]["job_id"];
// then poll GET /jobs/{id} until status is terminal
var req = new HttpRequestMessage(HttpMethod.Post, Base + "/run");
req.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Token);
req.Headers.Add("Idempotency-Key", $"gherkin-desk:audit:{Hash(input)}:a1");
req.Content = new StringContent(JsonSerializer.Serialize(input), Encoding.UTF8, "application/json");
var res = await Client.SendAsync(req);
// read data.job_id, then poll GET /jobs/{id}
6. Or stream it
The same run, delivered as SSE. Three event types: job once the run is accepted, delta for each chunk of text, and result at the end carrying the full output and charged_credits. The web app maps the arrival of the envelope keys onto its progress stages; you can do the same.
curl -N -s -X POST "$BASE/run-stream" \
-H "Authorization: Bearer $TOKEN" -H "Content-Type: application/json" \
-H "Idempotency-Key: gherkin-desk:audit:abc123:a1" \
-d @input.json
# event: delta data: {"text":"{\"task\":\"audit\","}
# event: job data: {"job_id":"job_..."}
# event: result data: {"output":{"output":"..."},"charged_credits":2840}
req = urllib.request.Request(BASE + "/run-stream",
data=json.dumps(INPUT).encode(), method="POST")
req.add_header("Authorization", "Bearer " + TOKEN)
req.add_header("Content-Type", "application/json")
req.add_header("Idempotency-Key", key)
buf, event = "", None
with urllib.request.urlopen(req) as r:
for raw in r:
line = raw.decode().rstrip("\n")
if line.startswith("event:"):
event = line[6:].strip()
elif line.startswith("data:"):
payload = json.loads(line[5:].strip())
if event == "delta":
buf += payload["text"]
elif event == "result":
buf = payload["output"]["output"]
result = json.loads(buf)
const res = await fetch(`${BASE}/run-stream`, {
method: "POST",
headers: {
"Authorization": `Bearer ${TOKEN}`,
"Content-Type": "application/json",
"Idempotency-Key": key
},
body: JSON.stringify(INPUT)
});
const reader = res.body.getReader();
const decoder = new TextDecoder();
let buffer = "", text = "", event = null;
for (;;) {
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("event:")) event = line.slice(6).trim();
else if (line.startsWith("data:")) {
const payload = JSON.parse(line.slice(5).trim());
if (event === "delta") text += payload.text;
if (event === "result") text = payload.output.output;
}
}
}
const result = JSON.parse(text);
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(b))
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Content-Type", "application/json")
res, _ := http.DefaultClient.Do(req)
defer res.Body.Close()
sc := bufio.NewScanner(res.Body)
for sc.Scan() {
line := sc.Text()
// "event: delta" then "data: {...}" - accumulate payload.text
_ = line
}
HttpResponse<java.util.stream.Stream<String>> res =
CLIENT.send(streamRequest, HttpResponse.BodyHandlers.ofLines());
StringBuilder text = new StringBuilder();
res.body().forEach(line -> {
// "event: delta" then "data: {...}" - append the "text" field
});
Net::HTTP.start(uri.hostname, uri.port, use_ssl: true) do |http|
http.request(stream_req) do |res|
res.read_body do |chunk|
# split on newlines, read "event:" then "data:" lines
end
end
end
<?php
$stream = fopen(BASE . "/run-stream", "r", false, stream_context_create($opts));
while (($line = fgets($stream)) !== false) {
// "event: delta" then "data: {...}"
}
fclose($stream);
using var stream = await Client.SendAsync(streamReq, HttpCompletionOption.ResponseHeadersRead);
using var reader = new StreamReader(await stream.Content.ReadAsStreamAsync());
string? line;
while ((line = await reader.ReadLineAsync()) != null) {
// "event: delta" then "data: {...}"
}
Three things worth knowing
Estimate is free and creates no job. It returns model (gpt-5.6-terra), model_alias (gpt-terra), markup_bps (1000), hold_credits, min_credits and sponsor_enabled. Compare hold_credits against the balance from /me before you submit; a 402 after submitting is a bug in your client, not in the user's wallet.
A run between min_credits and hold_credits still executes, with a reduced output cap, and returns "truncated": true. Surface that rather than presenting a clipped answer as complete.
The API executes nothing. No test suite is run, no repository is read, no browser is driven. Every reply is a reading of the text you sent, and the prompt forbids claiming otherwise.