← Gherkin Desk / API
Get a token

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 /estimatehold_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.

taskWhat it returnsExtra input it reads
auditA per-scenario BDD review, domain-language notes, a structure note
rewriteThe whole file rewritten declaratively, plus a changelog
gapsThe scenarios the file never covers, as ready-to-paste Gherkin, ranked by risk
stepdefsOne step definition per distinct signature, with the ambiguities namedtarget_language
testplanA 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

ThingValue
Base URLhttps://api.skillsafe.ai/v1/app-api
AuthAuthorization: Bearer <token>
BodyContent-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 identitycarried by the token. There is no X-App-Slug header. The one place the slug gherkin-desk appears is the body of POST /guest
IdempotencyIdempotency-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
CallPathCosts
Mint a guest tokenPOST /v1/app-api/guestfree, and the only call whose body is not a lane input
Who am IGET /v1/app-api/mefree
Price an inputPOST /v1/app-api/estimatefree, creates no job
Run a lanePOST /v1/app-api/runmetered; reserves hold_credits
Run a lane, streamedPOST /v1/app-api/run-streammetered; the same run as /run
Poll a jobGET /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": { ... }}}
CodeHTTPWhat to do
unauthorized401The token is missing, malformed, or was revoked. Mint a new one from /guest or sign in.
forbidden403A guest token tried to run a metered lane. Runs need a personal token unless the publisher sponsors guests.
payment_required402The balance is below min_credits for this lane. /estimate is free - call it first and never submit into a 402.
validation_error400The 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_limited429Back off and retry. Do not tight-loop.
not_found404Wrong path, or a job_id that does not belong to this token.
internal_error500Retry once with the SAME Idempotency-Key; a retry under the same key never double-bills.

The input object

FieldTypeMeaning
taskstringRequired in practice. One of audit, rewrite, gaps, stepdefs, testplan
featurestringThe .feature file text. The web app clips at ~14,000 characters on whole-scenario boundaries, keeping the Feature header and the Background
contextstringOptional. The domain, the system under test, what worries you
target_languagestringruby, javascript, python or java. Read by stepdefs; harmless elsewhere
factsobjectYour parse of the file. Treated as authoritative about structure and counts — see below
retry_notestringOptional. 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: auditready-to-run / needs-work / rewrite-first; rewriterewritten / rewritten-with-questions / too-little-to-rewrite; gapswell-covered / gaps-found / major-gaps; stepdefsdefinitions-drafted / drafted-with-ambiguities / too-little-to-define; testplanplan-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.

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.

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.

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.

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.

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.

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.