Run Sandbox Gate from your own code
Both metered lanes are available over HTTP and each returns one JSON object. The division of labour: the page measures failure-path coverage (every documented outcome against its trigger), holds every magic value to the published error catalog, catches a value that means two things, verifies the Luhn digit of every test card, measures event and time coverage, checks isolation, keys, reset, quotas, abuse controls and the path to live, and ranks the isolation and simulation rungs, then hands it all to the run as facts. The run writes the design or the cutover; the page then holds it to its own facts (sbreply.js): the ranked rungs, every outcome covered with a trigger, a real code and an operation, no invented card number, one meaning per value, every event fired, time simulated, _test_ and _live_ prefixes; the settled gate, a named pre-approval error, the four checklist steps, a canary confirming the event delivery, and exactly the five pitfalls.
Base URL and the response shape
Every endpoint lives under https://api.skillsafe.ai/v1/app-api, and every response has one shape:
{ "ok": true, "data": { ... } }
{ "ok": false, "error": { "code": "...", "message": "..." } }
The token is minted for this app (the guest call takes {"slug":"sandbox-gate"}), so no slug header is needed afterwards. Send it as Authorization: Bearer YOUR_TOKEN. The run body is the input object, sent bare.
| Status | Code | Meaning |
|---|---|---|
| 400 | validation_error | The body is not a JSON object, or a required key (task, platform, sandbox, facts) is missing. The page always sends all five of task, platform, sandbox, carry, facts; carry is an empty string for design. |
| 401 | unauthorized | No token, or a stale one. Mint a guest token or sign in again. |
| 402 | insufficient_credits | The balance is under min_credits. Price with /estimate first. |
| 403 | forbidden | A guest token tried to run: both lanes are metered and need a personal sign-in. |
| 404 | not_found | Unknown job id. |
| 429 | rate_limited | Back off and retry. |
| 5xx | server_error | Transient. Retry with the same Idempotency-Key so a retry never double-bills. |
The task field comes first
Two lanes, chosen by task, in the order the work runs - the sandbox, then going live:
design- the test-mode design: the isolation and simulation rungs, the test-value catalog for every documented outcome with real codes, event triggers, time simulation, seed and reset, keys, quotas, abuse controls and parity.cutover- the path to live: the gate and its named pre-approval error, the cutover checklist, the live canary, the five pitfalls, go-back signals, metrics and the integrator guide.
A missing or unknown task is answered as design. Both lanes send the same five fields. The facts are the page's checks, one row per line, and a line starting "must:" is a rule the reply has to hold. The page's handoff carries the design's decisions into carry for the cutover lane.
The input
| Field | Required | What |
|---|---|---|
task | yes | design or cutover. If it is missing or neither, do design and set "task": "design". |
platform | yes | the platform's name and purpose and the interview: greenfield or retrofit, the domain, the budget, the gate to live, the existing test infrastructure, the timing, time-dependent state, async events, an end-user surface, read-mostly, per-tenant infrastructure, parallel teams, compliance; "not given" means not given. |
sandbox | yes | the test mode today: isolation, the published error codes, every documented outcome (outcome, code, trigger, operation, the page's findings), the events, the time-dependent flows, seed and reset, keys, quotas, abuse controls and the path to live. |
carry | send it (empty for design) | cutover task only: the design's decisions, when the design task ran first; may be empty. |
facts | yes | the page's checks, one line each as area | id | status | evidence, the coverage, the rungs and the carrier. A line starting "must:" applies to your reply. |
retry_note | no | optional; if present, your previous reply was unusable - follow it. |
The rules the model applies
From the source skill, @samber/api-test-mode-design - the only standard the run applies:
- A sandbox is a stateful environment that validates business logic across a sequence of calls, not a mock server's static request-to-response mapping. A mock tests the integrator's assumptions and keeps simulating old behaviour when the API changes.
- The domain sets the carrier and the gate: payments-like values ride the transaction instrument and graduation is compliance-gated; communications-like values ride addresses, labelled per operation, and no real message ever leaves the sandbox; data-aggregation-like values ride identities (personas, password suffixes) and the design centre is fixture variety.
- Isolation, ranked: the soft toggle by default (one account, a mode flag on every object, mode-prefixed keys); the hard separate copy (its own host or account copy, keys, webhooks, configuration) when teams test in parallel, compliance demands demonstrable isolation, or a toggle's blast radius is real - per-tenant infrastructure makes it nearly free, a greenfield platform builds it cheaply, a budget of weeks deletes it from this pass. No test mode (integrator convention) only for a read-mostly API with no money, messages or sensitive data. Always state the isolation level in the docs; mirror any end-user surface.
- Simulation, ranked: magic values by default (a documented fake input selects an outcome); on-demand simulation endpoints the moment outcomes stop being a function of request input (mint objects, force states, fire events now); a config-driven fixture builder with a starter-fixture library when data shapes genuinely vary. Magic values include deliberate failures and step-up flows, map one-to-one onto real codes from the published catalog - never a sandbox-only error shape - are labelled per operation when one input means different things, and are contract: stable, documented, never expiring. Show tokens rather than raw card numbers in example code.
- Events and time: an explicit fire-this-event-now endpoint or CLI trigger per event type; document decoupled state and delivery, and an unsigned CLI event next to the always-verify rule. A test clock with a frozen time, advanced deliberately, capped per call and per clock; auto-aging fixtures as the cheaper rung. Never "wait real calendar time". Seed synthetic data only, never production data; minting and reset endpoints; periodic resets of shared sandboxes. Sandbox configuration derives from production's infrastructure-as-code.
- Keys: a type prefix plus a _test_ or _live_ mode substring, enforced both ways; a wrong-environment key gets a named error, never an opaque 401. Test keys still deserve real secret hygiene. Two abuse classes, never conflated: leaked keys (server-side secrets, scoped keys, secret scanning) and card testing, which is a live-mode control (edge bot challenges, endpoint rate limits, caps on instruments per customer and consecutive declines per IP). Sandbox quotas reuse production limits by default; evaluation-volume abuse gets an aggregate volume-and-scope cap, not a faster per-minute limiter.
- The path to live: a gate matching the real obligation (KYC, review, partner vetting, publisher verification, plan tier) with an explicit environment-not-approved error; a cutover checklist - credentials swapped everywhere, fresh live event endpoints and signing secrets, abuse rules on before the first live transaction, a small live canary per integration path confirming the effect and the event delivery, reversed afterwards. A green sandbox run is necessary, never sufficient. Publish the five pitfalls: the key swap forgotten, a cross-environment paste, assuming the sandbox predicts live, stale live webhook endpoints, untested real-money (or real-message, real-data) failure paths.
- Gates: failure-path coverage at 100%, event and time coverage at 100%, cross-environment incidents at zero by construction, the cutover checklist walked end to end with one live canary before launch.
House rules
- Use only the input. Never invent a customer, a traffic figure, an incident, a vendor's figure or policy, or a claim about the platform.
- The page's checks are settled: never call a failed check fine or a passed one broken.
- No URL at all - endpoints are paths.
- Write in the language of the input; keep the JSON keys, error codes, event types, prefixes and test values in English.
- Never use backticks, never write the input's field names as labels in your text, and never copy this prompt's wording into a reply.
- Reply with ONLY one JSON object - no prose before or after, no code fences.
The replies
design
Write the test-mode design.
summary- one sentence, at most 40 words: what an integrator cannot validate in the sandbox today, and the first fix.isolation-rung(the facts' rung:toggle,separateorconvention),level(one sentence: the isolation level as the docs should state it),why(one sentence).simulation-rung(the facts' rung:magic,endpointsorfixture),why(one sentence).catalog- the magic-value catalog, one object per documented outcome, every one covered:outcome(as listed),operation(the operation it applies to),trigger(the test value, or the endpoint path forendpoint),kind(magicorendpoint),code(a code from the published catalog, orok). Keep a listed trigger that works; replace every trigger the page flags (no trigger, a broken check digit, two meanings) with a new one. When the page flags a code as not in the published catalog, keep the trigger and map the outcome to the closest published code. Never invent a card number - a new trigger is a token (tok_ plus lowercase words joined by _), an address, a persona or an endpoint path. One meaning per trigger and operation.events- one object per listed event type:event(as listed),trigger(one sentence: the endpoint or CLI command that fires it now).time-primitive(clock,agingornone-noneonly without time-dependent state),cap(one sentence: the advance per call and what one clock carries, or "" without a clock),flows(the listed time-dependent flows, each as listed).reset-seed(synthetic),mint(one sentence),reset(one sentence),periodic(one sentence).keys-test_prefix(with _test_),live_prefix(with _live_, the same type prefix),wrong_env(one sentence naming the error a wrong-environment key gets),hygiene(one sentence).quotas-posture(production-limitsoraggregate-cap),why(one sentence).abuse-leaked_keys(short strings),card_testing(short strings: live-mode controls; [] outside payments).parity- one sentence: how sandbox configuration stays derived from production's.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "design",
"summary": "one sentence",
"isolation": { "rung": "toggle", "level": "one sentence", "why": "one sentence" },
"simulation": { "rung": "magic", "why": "one sentence" },
"catalog": [ { "outcome": "generic decline", "operation": "charge", "trigger": "4000000000000002", "kind": "magic", "code": "card_declined" } ],
"events": [ { "event": "payment.succeeded", "trigger": "one sentence" } ],
"time": { "primitive": "clock", "cap": "one sentence", "flows": [ "subscription renewal" ] },
"reset": { "seed": "synthetic", "mint": "one sentence", "reset": "one sentence", "periodic": "one sentence" },
"keys": { "test_prefix": "pl_test_", "live_prefix": "pl_live_", "wrong_env": "one sentence", "hygiene": "one sentence" },
"quotas": { "posture": "production-limits", "why": "one sentence" },
"abuse": { "leaked_keys": [ "a control" ], "card_testing": [ "a control" ] },
"parity": "one sentence",
"caveats": [ ]
}
The page holds it to its own facts (check() in sbreply.js; any warning makes the reply flagged): the reply answers the task it was sent; no URL; no backticks; no percentage; the ranked isolation and simulation rungs and a stated isolation level; every documented outcome in the catalog with a trigger, a kind, an operation and a code from the published catalog (or ok), no code silently changed; no invented card number and no card number that fails the Luhn check; one meaning per trigger and operation; at least one failure outcome; every listed event with a trigger that fires it now; a clock or aging when state is time-dependent, a cap on the clock, every listed flow, no waiting real time; a synthetic seed with minting, reset and periodic cleanup; a type prefix with _test_ and with _live_; a named wrong-environment error; a quota posture; leaked-key controls, and live card-testing controls for payments; parity.
cutover
Write the path from sandbox to live.
summary- one sentence, at most 40 words.gate-shape(the facts' gate:kyc,review,partner,publisher,planornone),error(one sentence: the named error a not-yet-approved environment returns - when the input names none, name the one you recommend in the published codes' naming style, as a recommendation),why(one sentence).checklist- the cutover steps in order: objects withstep,owner(a role),check(how you know it is done). Credentials swapped everywhere, fresh live event endpoints and signing secrets, abuse rules on before the first live transaction, and a live canary that confirms the effect and the event delivery.canary-paths(the integration paths a canary covers, as short strings),reverse(one sentence: how each canary is reversed - or, where the action cannot be undone, how its effect is contained).pitfalls- exactly five objects, one per pitfall:kind(key-swap,cross-environment,sandbox-predicts-live,stale-webhooksoruntested-failures, each once),text(one sentence: that pitfall in this platform's terms).go_back- two to four strings: the signals that send an integration back to the sandbox.metrics- two to four strings: what to track after launch.docs- the integrator-facing cutover guide as one plain-text string: the gate, the checklist, the canary and the five pitfalls.caveats- at most 4 short strings;[]when there is nothing to say.
{
"task": "cutover",
"summary": "one sentence",
"gate": { "shape": "kyc", "error": "one sentence", "why": "one sentence" },
"checklist": [ { "step": "one sentence", "owner": "integration engineer", "check": "one sentence" } ],
"canary": { "paths": [ "one-off charge" ], "reverse": "one sentence" },
"pitfalls": [ { "kind": "key-swap", "text": "one sentence" } ],
"go_back": [ "one string" ],
"metrics": [ "cross-environment incidents" ],
"docs": "the guide as plain text",
"caveats": [ ]
}
The page requires: the reply answers the task it was sent; no URL; no backticks; no percentage; the settled gate; a named pre-approval error, never an opaque 401; a checklist that swaps credentials, creates fresh live event endpoints and signing secrets, turns abuse rules on and runs a live canary that confirms the event delivery, each step with an owner and a check; canary paths and a reversal; exactly five pitfalls, one of each kind (key-swap, cross-environment, sandbox-predicts-live, stale-webhooks, untested-failures), each with its text; two to four go-back signals and metrics; and a guide covering the canary.
Worked examples
Three runs from the page's free samples (invented platforms; card numbers are public test numbers): the design for a payments API on a mode toggle, the cutover for the same API carrying that design, and the design for an SMS API whose sandbox is a mock server. Each input is exactly what the page sends; each reply is the saved reply the sample replays, shipped unedited - but the facts and long strings are shortened in this display only, and every cut is listed under the example. The last line is the page's own check of the full reply.
design - A payments API on a mode toggle
The page read it as: integrators will find bugs in live (10 fail, 4 warn, 8 pass); 7 of 8 outcomes triggerable; isolation by rank: toggle; simulation by rank: magic; failing checks: isodocs, coverage, realcodes, luhn, eventcov, timecov, wrongenv, hygiene, legible, checklist.
Input (sandbox is 2159 characters; the first 1399 are shown; facts is 2895 characters; the first 1391 are shown):
{
"task": "design",
"platform": "Platform: Payquorlo\nWhat it does: Card payments and subscriptions for online merchants; integrators build checkout and recurring billing against the API.\nMode: retrofit\nDomain: Payments-like (money moves)\nBudget: Weeks\nGate to live: KYC activation\nExisting test infrastructure: A staging environment\nTiming: A platform surface run for years\nTime-dependent state: yes; async events: yes; end-user surface: yes; read-mostly with no money, messages or sensitive data: no\nPer-tenant infrastructure: no; teams testing in parallel: no; compliance demands demonstrable isolation: no",
"sandbox": "Isolation today: A mode toggle on the live account; isolation level stated in the docs: no; end-user surface mirrored: yes; config from production's infrastructure-as-code: yes\nPublished error codes:\n- card_declined (402) - the issuer declined the card\n- insufficient_funds (402) - not enough funds\n- expired_card (402) - the card has expired\n- incorrect_cvc (402) - the security code is wrong\n- authentication_required (402) - the issuer requires step-up authentication\n- rate_limited (429) - too many requests\nDocumented outcomes (outcome | code | trigger | operation | page's findings):\n- payment succeeds | ok | magic: 4242424242424242 | charge\n- generic decline | card_declined | magic: 4000000000000002 | charge\n- insufficient funds | insufficient_funds | magic: 4000000000009995 | charge\n- expired card | expired_card | magic: 4000000000000069 | charge\n- wrong CVC | incorrect_cvc | magic: 4000000000000128 | charge | fails the Luhn check - client-side validation rejects it before it reaches the API\n- step-up authentication | authentication_required | magic: 4000002500003155 | charge\n- fraud block | test_decline_fraud | magic: 4100000000000019 | charge | code not in the published catalog - a sandbox-only error shape\n- dispute opened | ok | no trigger | charge | no way to trigger it\nEvents (fireable on demand today):\n- payment.succeeded | yes\n- payment.failed | yes\n- dispute.created |\n\n[... 760 more characters cut from this display ...]",
"carry": "",
"facts": "The page's checks (10 fail, 4 warn, 8 pass, 0 n/a):\n- Isolation | model | pass | A mode toggle on the live account\n- Isolation | isodocs | fail | \"sandbox\" with no stated isolation level guarantees nothing\n- Isolation | mirrored | pass | mirrored\n- Isolation | parity | pass | same source\n- Failure paths | coverage | fail | 7 of 8 triggerable (87.5%); missing: dispute opened\n- Failure paths | failpaths | pass | 6 failure outcomes\n- Failure paths | realcodes | fail | fraud block (test_decline_fraud)\n- Failure paths | labels | pass | unambiguous\n- Failure paths | luhn | fail | 4000000000000128 (check digit should be 7)\n- Events and time | eventcov | fail | 2 of 4 fireable; missing: dispute.created, subscription.renewed\n- Events and time | clisig | warn | unsigned test events break the always-verify rule unless documented beside it\n- Events and time | timecov | fail | only by waiting: subscription renewal, trial expiry\n- Events and time | seed | pass | Synthetic data only\n- Events and time | reset | warn | Reset on request\n- Keys, quotas and abuse | prefix | pass | pl_test_ and pl_live_\n- Keys, quotas and abuse | wrongenv | fail | An opaque 401\n- Keys, quotas and abuse | hygiene | fail | test keys on a toggle still read and write real account configuration\n- Keys, quotas and abuse | quota | pass | The production limits, unchanged\n- Keys, quotas and abuse | carding | warn |\n\n[... 1504 more characters cut from this display ...]"
}
Reply:
{
"task": "design",
"summary": "Integrators cannot open a dispute, fire dispute.created or subscription.renewed, advance renewals or trial expiry, or reach the wrong-CVC path; first add a dispute token, CLI triggers for every event and a test clock.",
"isolation": {
"rung": "toggle",
"level": "Test mode is a soft toggle on the merchant's one account: every object carries a test or live mode flag, pl_test_ keys see only test objects, pl_live_ keys see only live objects, and test mode cannot change live account configuration.",
"why": "A budget of weeks rules the separate copy out of this pass, so the toggle stays until the first cross-mode leakage incident or a compliance regime that demands demonstrable isolation."
},
"simulation": {
"rung": "magic",
"why": "Every Payquorlo outcome, including the dispute, can be selected by the payment instrument in the charge request, and with weeks of budget documented test cards and tokens are the cheapest primitive that covers all eight."
},
"catalog": [
{
"outcome": "payment succeeds",
"operation": "charge",
"trigger": "4242424242424242",
"kind": "magic",
"code": "ok"
},
{
"outcome": "generic decline",
"operation": "charge",
"trigger": "4000000000000002",
"kind": "magic",
"code": "card_declined"
},
{
"outcome": "insufficient funds",
"operation": "charge",
"trigger": "4000000000009995",
"kind": "magic",
"code": "insufficient_funds"
},
{
"outcome": "expired card",
"operation": "charge",
"trigger": "4000000000000069",
"kind": "magic",
"code": "expired_card"
},
{
"outcome": "wrong CVC",
"operation": "charge",
"trigger": "tok_incorrect_cvc",
"kind": "magic",
"code": "incorrect_cvc"
},
{
"outcome": "step-up authentication",
"operation": "charge",
"trigger": "4000002500003155",
"kind": "magic",
"code": "authentication_required"
},
{
"outcome": "fraud block",
"operation": "charge",
"trigger": "4100000000000019",
"kind": "magic",
"code": "card_declined"
},
{
"outcome": "dispute opened",
"operation": "charge",
"trigger": "tok_charge_disputed",
"kind": "magic",
"code": "ok"
}
],
"events": [
{
"event": "payment.succeeded",
"trigger": "The existing CLI trigger for payment.succeeded fires it now, as does a charge with 4242424242424242."
},
{
"event": "payment.failed",
"trigger": "The existing CLI trigger for payment.failed fires it now, as does a charge with any decline value in the catalog."
},
{
"event": "dispute.created",
"trigger": "A new CLI trigger for dispute.created fires it now, and a charge with tok_charge_disputed succeeds and then opens a dispute that emits it."
},
{
"event": "subscription.renewed",
"trigger": "A new CLI trigger for subscription.renewed fires it now, and advancing the subscription's test clock past its renewal date emits it through the real billing path."
}
],
"time": {
"primitive": "clock",
"cap": "A test clock starts frozen and each advance call moves it forward by at most one billing interval, and one clock carries only the test customers and subscriptions attached to it, with a fixed ceiling on how many.",
"flows": [
"subscription renewal",
"trial expiry"
]
},
"reset": {
"seed": "synthetic",
"mint": "Test-mode create calls and the catalog's tokens mint synthetic customers, cards, subscriptions and disputes on demand, never copies of live objects.",
"reset": "A self-serve reset call deletes every test-mode object on the account and leaves live objects and live configuration untouched, replacing today's reset on request.",
"periodic": "Test mode is not shared between merchants, so a scheduled purge of stale test objects is optional housekeeping, offered per account rather than imposed."
},
"keys": {
"test_prefix": "pl_test_",
"live_prefix": "pl_live_",
"wrong_env": "A pl_test_ key used on a live object, or a pl_live_ key on a test object, gets a named key_environment_mismatch error stating which mode the key belongs to, instead of today's opaque 401.",
"hygiene": "Test keys are secrets too: they stay server-side, are covered by secret scanning, and on the toggle they are scoped so they can no longer read or write the real account configuration."
},
"quotas": {
"posture": "production-limits",
"why": "Test mode already reuses the production limits unchanged, so integrators meet rate_limited in the sandbox exactly where they will meet it live."
},
"abuse": {
"leaked_keys": [
"secret keys used server-side only, never in client code",
"secret scanning on the pl_test_ and pl_live_ prefixes",
"scoped keys so a test key cannot touch account configuration",
"one-step revoke and roll for a leaked key"
],
"card_testing": [
"live-mode edge bot challenges on checkout",
"keep the existing live endpoint rate limits",
"live cap on cards attached per customer",
"live cap on consecutive declines per IP"
]
},
"parity": "Test mode already comes from the same infrastructure-as-code source as live; keep the mode flag, limits and new test-clock settings in that source so nothing is configured by hand on the test side.",
"caveats": [
"The toggle is a budget decision: revisit the separate copy after the first cross-mode leakage incident or when compliance demands it.",
"CLI events are unsigned; document that beside the always-verify rule so integrators do not skip signature checks in live.",
"Fraud block now returns card_declined, the closest published code; the sandbox-only test_decline_fraud shape is retired.",
"4000000000000128 fails the Luhn check and is withdrawn; tok_incorrect_cvc replaces it in docs and example code."
]
}
What step 5 prints for it: toggle | magic | charge payment succeeds -> ok | charge generic decline -> card_declined | charge insufficient funds -> insufficient_funds | charge expired card -> expired_card | charge wrong CVC -> incorrect_cvc | charge step-up authentication -> authentication_required | charge fraud block -> card_declined | charge dispute opened -> ok
The page's check: kept - nothing to flag
cutover - A payments API on a mode toggle
The input carries the saved design's decisions (R.laneForm fills carry from the design reply), as the page's handoff does.
Input (sandbox is 2159 characters; the first 1399 are shown; facts is 2803 characters; the first 1391 are shown):
{
"task": "cutover",
"platform": "Platform: Payquorlo\nWhat it does: Card payments and subscriptions for online merchants; integrators build checkout and recurring billing against the API.\nMode: retrofit\nDomain: Payments-like (money moves)\nBudget: Weeks\nGate to live: KYC activation\nExisting test infrastructure: A staging environment\nTiming: A platform surface run for years\nTime-dependent state: yes; async events: yes; end-user surface: yes; read-mostly with no money, messages or sensitive data: no\nPer-tenant infrastructure: no; teams testing in parallel: no; compliance demands demonstrable isolation: no",
"sandbox": "Isolation today: A mode toggle on the live account; isolation level stated in the docs: no; end-user surface mirrored: yes; config from production's infrastructure-as-code: yes\nPublished error codes:\n- card_declined (402) - the issuer declined the card\n- insufficient_funds (402) - not enough funds\n- expired_card (402) - the card has expired\n- incorrect_cvc (402) - the security code is wrong\n- authentication_required (402) - the issuer requires step-up authentication\n- rate_limited (429) - too many requests\nDocumented outcomes (outcome | code | trigger | operation | page's findings):\n- payment succeeds | ok | magic: 4242424242424242 | charge\n- generic decline | card_declined | magic: 4000000000000002 | charge\n- insufficient funds | insufficient_funds | magic: 4000000000009995 | charge\n- expired card | expired_card | magic: 4000000000000069 | charge\n- wrong CVC | incorrect_cvc | magic: 4000000000000128 | charge | fails the Luhn check - client-side validation rejects it before it reaches the API\n- step-up authentication | authentication_required | magic: 4000002500003155 | charge\n- fraud block | test_decline_fraud | magic: 4100000000000019 | charge | code not in the published catalog - a sandbox-only error shape\n- dispute opened | ok | no trigger | charge | no way to trigger it\nEvents (fireable on demand today):\n- payment.succeeded | yes\n- payment.failed | yes\n- dispute.created |\n\n[... 760 more characters cut from this display ...]",
"carry": "Isolation: toggle - Test mode is a soft toggle on the merchant's one account: every object carries a test or live mode flag, pl_test_ keys see only test objects, pl_live_ keys see only live objects, and test mode cannot change live account configuration.\nSimulation: magic\nKeys: test pl_test_, live pl_live_; A pl_test_ key used on a live object, or a pl_live_ key on a test object, gets a named key_environment_mismatch error stating which mode the key belongs to, instead of today's opaque 401.\nEvents fired by: payment.succeeded, payment.failed, dispute.created, subscription.renewed\nTime: clock\nCard testing (live): live-mode edge bot challenges on checkout; keep the existing live endpoint rate limits; live cap on cards attached per customer; live cap on consecutive declines per IP\nCatalog: 8 outcomes, 6 of them failures",
"facts": "The page's checks (10 fail, 4 warn, 8 pass, 0 n/a):\n- Isolation | model | pass | A mode toggle on the live account\n- Isolation | isodocs | fail | \"sandbox\" with no stated isolation level guarantees nothing\n- Isolation | mirrored | pass | mirrored\n- Isolation | parity | pass | same source\n- Failure paths | coverage | fail | 7 of 8 triggerable (87.5%); missing: dispute opened\n- Failure paths | failpaths | pass | 6 failure outcomes\n- Failure paths | realcodes | fail | fraud block (test_decline_fraud)\n- Failure paths | labels | pass | unambiguous\n- Failure paths | luhn | fail | 4000000000000128 (check digit should be 7)\n- Events and time | eventcov | fail | 2 of 4 fireable; missing: dispute.created, subscription.renewed\n- Events and time | clisig | warn | unsigned test events break the always-verify rule unless documented beside it\n- Events and time | timecov | fail | only by waiting: subscription renewal, trial expiry\n- Events and time | seed | pass | Synthetic data only\n- Events and time | reset | warn | Reset on request\n- Keys, quotas and abuse | prefix | pass | pl_test_ and pl_live_\n- Keys, quotas and abuse | wrongenv | fail | An opaque 401\n- Keys, quotas and abuse | hygiene | fail | test keys on a toggle still read and write real account configuration\n- Keys, quotas and abuse | quota | pass | The production limits, unchanged\n- Keys, quotas and abuse | carding | warn |\n\n[... 1412 more characters cut from this display ...]"
}
Reply (long strings shortened for display - see the cuts below):
{
"task": "cutover",
"summary": "Payquorlo gates live mode on KYC activation with a named error, then each merchant swaps to pl_live_ keys, registers fresh live webhooks, turns on card-testing controls and runs a reversed live canary per payment path.",
"gate": {
"shape": "kyc",
"error": "A pl_live_ call from an account whose KYC activation is not complete gets a named account_not_activated error saying which activation step is outstanding, instead of today's opaque failure.",
"why": "Payquorlo moves real money, so live mode opens only after the merchant passes KYC activation, and the integrator must be able to tell an unapproved account from a broken integration."
},
"checklist": [
{
"step": "Confirm KYC activation is complete for the account before any live call.",
"owner": "merchant account owner",
"check": "A live-mode read returns data rather than account_not_activated."
},
{
"step": "Swap every pl_test_ key for its pl_live_ key in every server, job, secret store and deploy environment.",
"owner": "integration engineer",
"check": "A search of configuration and secret stores finds no pl_test_ key in any live deploy, and no call returns key_environment_mismatch."
},
{
"step": "Register fresh live webhook endpoints and store their new signing secrets, never reusing test-mode endpoints or secrets.",
"owner": "integration engineer",
"check": "Each live endpoint is listed in live mode with its own signing secret, and signature verification passes on a live delivery."
},
{
"step": "Turn on the live abuse rules before the first live transaction: edge bot challenges on checkout, endpoint rate limits, a cap on cards per customer and a cap on consecutive declines per IP.",
"owner": "risk owner",
"check": "Each rule shows as enabled in live mode before the canary charge is made."
},
{
"step": "Run a small live canary on each integration path with a team-owned card and confirm both the effect and the event delivery.",
"owner": "integration engineer",
"check": "The canary charge or subscription appears in live mode and its payment.succeeded or subscription event reaches the live webhook endpoint with a valid signature."
},
{
"step": "Reverse every canary and confirm the reversal.",
"owner": "finance owner",
"check": "Each canary charge shows as refunded and each canary subscription as cancelled in live mode."
}
],
"canary": {
"paths": [
"one-off checkout charge",
"subscription signup with first billing",
"live webhook delivery and signature check"
],
"reverse": "Each canary charge is fully refunded and each canary subscription is cancelled immediately after its effect and its event delivery are confirmed."
},
"pitfalls": [
{
"kind": "key-swap",
"text": "The key swap forgotten: one job or server still sends pl_test_ keys after launch, so live checkouts quietly create test objects."
},
{
"kind": "cross-environment",
"text": "A cross-environment paste: a pl_live_ key pasted into a test config or a pl_test_ object id used in live, now answered with key_environment_mismatch."
},
{
"kind": "sandbox-predicts-live",
"text": "Assuming the sandbox predicts live: a green test-mode run is necessary but never sufficient, since real issuers, step-up and KYC limits behave in ways test cards only approximate."
},
{
"kind": "stale-webhooks",
"text": "Stale live webhook endpoints: live events sent to a test-mode endpoint or verified with a test-mode signing secret, so payment.succeeded never lands."
},
{
"kind": "untested-failures",
"text": "Untested real-money failure paths: live declines, step-up authentication, refunds and disputes never exercised, so the first real one breaks checkout or billing."
}
],
"go_back": [
"Any key_environment_mismatch error in live traffic",
"A canary whose effect appears but whose event never reaches the live endpoint",
"Live failure outcomes the integration does not handle, such as authentication_required or a dispute",
"A Payquorlo API change that alters an outcome the integration relies on"
],
"metrics": [
"cross-environment incidents, target zero",
"live webhook delivery and signature-verification failures",
"live decline and authentication_required rates per integration path",
"card-testing rule triggers in live mode"
],
"docs": "Going live on Payquorlo. The gate: live mode opens only after KYC activation; until then every pl_live_ call returns account_not_activated naming the outstanding step. The checklist, in order: 1. confirm KYC activation is complete; 2. swap [... 1015 more characters cut from this display]",
"caveats": [
"A live dispute cannot be safely canaried; dispute handling rests on tok_charge_disputed and the dispute.created trigger in test mode.",
"Test and live share one account on the toggle, so the key swap and key_environment_mismatch carry the isolation."
]
}
Cut from this display only (the saved reply and the check use the full text): docs (1252 characters, 237 shown).
What step 5 prints for it: merchant account owner: Confirm KYC activation is complete for t | integration engineer: Swap every pl_test_ key for its pl_live_ | integration engineer: Register fresh live webhook endpoints an | risk owner: Turn on the live abuse rules before the | integration engineer: Run a small live canary on each integrat | finance owner: Reverse every canary and confirm the rev
The page's check: kept - nothing to flag
design - An SMS API with a mock for a sandbox
The page read it as: integrators will find bugs in live (6 fail, 3 warn, 8 pass); 4 of 5 outcomes triggerable; isolation by rank: separate; simulation by rank: endpoints; failing checks: model, isodocs, coverage, labels, eventcov, reset.
Input (sandbox is 1595 characters; the first 1396 are shown; facts is 2482 characters; the first 1393 are shown):
{
"task": "design",
"platform": "Platform: Textwren\nWhat it does: An SMS and voice API; integrators send messages, look up numbers and receive delivery receipts by webhook.\nMode: retrofit\nDomain: Communications-like (messages reach people)\nBudget: Quarters\nGate to live: Partner vetting\nExisting test infrastructure: A mock server integrators were told to use\nTiming: A platform surface run for years\nTime-dependent state: no; async events: yes; end-user surface: no; read-mostly with no money, messages or sensitive data: no\nPer-tenant infrastructure: yes; teams testing in parallel: no; compliance demands demonstrable isolation: no",
"sandbox": "Isolation today: A mock server; isolation level stated in the docs: no; config from production's infrastructure-as-code: no\nPublished error codes:\n- invalid_number (400) - the number is not a valid phone number\n- unreachable (422) - the carrier cannot reach the number\n- opted_out (422) - the recipient opted out\n- unknown_number (404) - no record for the number\nDocumented outcomes (outcome | code | trigger | operation | page's findings):\n- message queued | ok | magic: +15005550006 | send\n- invalid number | invalid_number | magic: +15005550001 | send\n- recipient opted out | opted_out | magic: +15005550004 | send | the same value means different things and the operation is not labelled\n- number not found | unknown_number | magic: +15005550004 | operation not labelled | the same value means different things and the operation is not labelled\n- carrier unreachable | unreachable | no trigger | send | no way to trigger it\nEvents (fireable on demand today):\n- message.delivered | no\n- message.failed | no; trigger: None - integrators wait for real events; decoupled state and delivery documented: na\nTime-dependent flows: none listed; clock: None - integrators wait\nSeed: Synthetic data only; reset: No reset; shared sandbox: yes\nKeys: test tb_test_, live tb_live_; wrong-environment key gets: A named wrong-environment error; test keys get secret hygiene: yes; sandbox quota: The production\n\n[... 199 more characters cut from this display ...]",
"carry": "",
"facts": "The page's checks (6 fail, 3 warn, 8 pass, 0 n/a):\n- Isolation | model | fail | A mock server - stateless, it tests assumptions and keeps simulating old behaviour\n- Isolation | isodocs | fail | \"sandbox\" with no stated isolation level guarantees nothing\n- Isolation | parity | warn | drift is the works-in-sandbox, fails-live root cause\n- Failure paths | coverage | fail | 4 of 5 triggerable (80%); missing: carrier unreachable\n- Failure paths | failpaths | pass | 4 failure outcomes\n- Failure paths | realcodes | pass | every code is in the catalog\n- Failure paths | labels | fail | +15005550004 - the same value means different things and the operation is not labelled\n- Events and time | eventcov | fail | 0 of 2 fireable; no trigger mechanism; missing: message.delivered, message.failed\n- Events and time | seed | pass | Synthetic data only\n- Events and time | reset | fail | No reset - a shared sandbox drifts\n- Keys, quotas and abuse | prefix | pass | tb_test_ and tb_live_\n- Keys, quotas and abuse | wrongenv | pass | A named wrong-environment error\n- Keys, quotas and abuse | hygiene | pass | server-side only, scanned\n- Keys, quotas and abuse | quota | pass | The production limits, unchanged\n- The path to live | legible | pass | explicit\n- The path to live | checklist | warn | missing: abuse rules on before the first live transaction; a live canary with confirmed effect and event\n\n[... 1089 more characters cut from this display ...]"
}
Reply:
{
"task": "design",
"summary": "Integrators cannot reach carrier unreachable, fire either delivery receipt, tell opted-out from not-found on +15005550004, or reset shared state; first replace the mock server with a per-tenant stateful sandbox and its simulation endpoints.",
"isolation": {
"rung": "separate",
"level": "The Textwren sandbox is a separate per-tenant copy with its own host, tb_test_ keys, webhook endpoints and configuration, and no message or call placed in it ever reaches a real phone or carrier.",
"why": "Textwren already runs per-tenant infrastructure, which makes a hard separate copy nearly free, and a stateful copy replaces a mock server that only tests integrators' assumptions and keeps simulating old behaviour."
},
"simulation": {
"rung": "endpoints",
"why": "A carrier failure and a delivery receipt are states and events that no number in a send request can encode, so integrators need endpoints that force a number's state and fire receipts now."
},
"catalog": [
{
"outcome": "message queued",
"operation": "send",
"trigger": "+15005550006",
"kind": "magic",
"code": "ok"
},
{
"outcome": "invalid number",
"operation": "send",
"trigger": "+15005550001",
"kind": "magic",
"code": "invalid_number"
},
{
"outcome": "recipient opted out",
"operation": "send",
"trigger": "+15005550004",
"kind": "magic",
"code": "opted_out"
},
{
"outcome": "number not found",
"operation": "lookup",
"trigger": "+15005550009",
"kind": "magic",
"code": "unknown_number"
},
{
"outcome": "carrier unreachable",
"operation": "send",
"trigger": "/sandbox/numbers/{number}/force_unreachable",
"kind": "endpoint",
"code": "unreachable"
}
],
"events": [
{
"event": "message.delivered",
"trigger": "POST /sandbox/messages/{message_id}/deliver marks a queued sandbox message delivered and sends message.delivered to the tenant's sandbox webhook now."
},
{
"event": "message.failed",
"trigger": "POST /sandbox/messages/{message_id}/fail marks a queued sandbox message failed and sends message.failed to the tenant's sandbox webhook now."
}
],
"time": {
"primitive": "none",
"cap": "",
"flows": []
},
"reset": {
"seed": "synthetic",
"mint": "Sandbox endpoints mint synthetic numbers, opt-out records and queued messages so each test starts from a known state.",
"reset": "POST /sandbox/reset clears a tenant's sandbox messages, opt-outs and forced number states in one call, replacing today's absence of any reset.",
"periodic": "Shared sandbox data is wiped on a published schedule so drift from earlier test runs never leaks into a later one."
},
"keys": {
"test_prefix": "tb_test_",
"live_prefix": "tb_live_",
"wrong_env": "Keep the existing named wrong-environment error, so a tb_test_ key sent to the live host, or a tb_live_ key sent to the sandbox, says which environment it belongs to.",
"hygiene": "Keep test keys server-side and under secret scanning, as today, and issue each tenant's sandbox keys from that tenant's own copy."
},
"quotas": {
"posture": "production-limits",
"why": "The sandbox already applies the production limits unchanged, so integrators hit the same throttling there as in live sending."
},
"abuse": {
"leaked_keys": [
"secret keys used server-side only",
"secret scanning on the tb_test_ and tb_live_ prefixes",
"scoped keys per operation",
"one-step revoke and roll for a leaked key"
],
"card_testing": []
},
"parity": "Build each tenant's sandbox copy from production's infrastructure-as-code, with only the carrier connection swapped for a simulator that never sends, so behaviour cannot drift from live.",
"caveats": [
"The mock server must be retired with a deprecation notice; integrators still pointed at it keep testing stale behaviour.",
"+15005550004 now means opted out on send only; number not found moves to +15005550009 on lookup.",
"Document that a message's state and its receipt delivery are decoupled, so a forced state and a fired event can be tested apart."
]
}
What step 5 prints for it: separate | endpoints | send message queued -> ok | send invalid number -> invalid_number | send recipient opted out -> opted_out | lookup number not found -> unknown_number | send carrier unreachable -> unreachable
The page's check: kept - nothing to flag
Build the input yourself
Load the engine in node (global.window = global; require("./sbkit.js"); require("./sbreply.js")), then:
const K = SboxKit, R = SboxReply;
const f = K.form(Object.assign(K.blank(), { name: "Acme", domain: "payments", codes: "card_declined | 402 | declined",
outcomes: "approved | ok | 4242424242424242 | charge\ndeclined | card_declined | 4000000000000002 | charge", test_prefix: "ac_test_", live_prefix: "ac_live_" }));
// f.rows (every check: area, id, label, status, evidence), f.matrix (each outcome with its issues), f.coverage, f.iso, f.sim
// K.luhn("4242424242424242") - the check digit; K.catalogMd(f) - the test-value catalog as Markdown
const b = K.buildInput(f, "design"); // or "cutover" (R.laneForm(form, "cutover", design) fills carry)
const wire = K.toWire(b.input); // POST this to /estimate and /run
// then: const reply = R.normalize(R.parseReply(job.output.output), "design");
// R.check(reply, b) // {status: kept or flagged, notes}
Get a token for the steps below from /tokens.html.
Step 1 — a tiny client helper
One function that sends JSON and unwraps the response. Paste a personal token from /tokens.html, or leave it empty and mint a guest token in step 2.
BASE="https://api.skillsafe.ai/v1/app-api"
TOKEN="YOUR_TOKEN" # from https://sandbox-gate.skillsafe.ai/tokens.html
api() { # api METHOD PATH [JSON-BODY]
curl -s -X "$1" "$BASE$2" -H "Content-Type: application/json" \
${TOKEN:+-H "Authorization: Bearer $TOKEN"} ${3:+--data "$3"}
}
import json, urllib.request
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://sandbox-gate.skillsafe.ai/tokens.html
def api(method, path, body=None, headers=None):
req = urllib.request.Request(BASE + path, method=method,
data=None if body is None else json.dumps(body).encode(),
headers={"Content-Type": "application/json", **({"Authorization": "Bearer " + TOKEN} if TOKEN else {}), **(headers or {})})
with urllib.request.urlopen(req) as r:
env = json.load(r)
if env.get("error"):
raise RuntimeError(env["error"])
return env["data"]
// sandbox-gate.mjs -- Node 18+ (global fetch)
const BASE = "https://api.skillsafe.ai/v1/app-api";
let TOKEN = "YOUR_TOKEN"; // from https://sandbox-gate.skillsafe.ai/tokens.html
async function api(method, path, body, headers = {}) {
const r = await fetch(BASE + path, { method, headers: { "Content-Type": "application/json",
...(TOKEN ? { Authorization: "Bearer " + TOKEN } : {}), ...headers },
body: body === undefined ? undefined : JSON.stringify(body) });
const env = await r.json();
if (env.error) throw new Error(env.error.code + ": " + env.error.message);
return env.data;
}
package main
import ("bytes"; "encoding/json"; "fmt"; "net/http")
const base = "https://api.skillsafe.ai/v1/app-api"
var token = "YOUR_TOKEN" // from https://sandbox-gate.skillsafe.ai/tokens.html
func api(method, path string, body any, headers map[string]string) (map[string]any, error) {
var buf bytes.Buffer
if body != nil { json.NewEncoder(&buf).Encode(body) }
req, _ := http.NewRequest(method, base+path, &buf)
req.Header.Set("Content-Type", "application/json")
if token != "" { req.Header.Set("Authorization", "Bearer "+token) }
for k, v := range headers { req.Header.Set(k, v) }
res, err := http.DefaultClient.Do(req)
if err != nil { return nil, err }
defer res.Body.Close()
var env struct { Data map[string]any; Error map[string]any }
json.NewDecoder(res.Body).Decode(&env)
if env.Error != nil { return nil, fmt.Errorf("%v", env.Error) }
return env.Data, nil
}
// Sandbox Gate.java -- Java 17+, with com.fasterxml.jackson.core:jackson-databind
import com.fasterxml.jackson.databind.*;
import java.net.URI; import java.net.http.*; import java.util.*;
public class Sandbox Gate {
static final String BASE = "https://api.skillsafe.ai/v1/app-api";
static String token = "YOUR_TOKEN"; // from https://sandbox-gate.skillsafe.ai/tokens.html
static final ObjectMapper M = new ObjectMapper();
static final HttpClient C = HttpClient.newHttpClient();
static JsonNode api(String method, String path, Object body, Map<String, String> headers) throws Exception {
var b = HttpRequest.newBuilder(URI.create(BASE + path)).header("Content-Type", "application/json");
if (!token.isEmpty()) b.header("Authorization", "Bearer " + token);
if (headers != null) headers.forEach(b::header);
b.method(method, body == null ? HttpRequest.BodyPublishers.noBody() : HttpRequest.BodyPublishers.ofString(M.writeValueAsString(body)));
JsonNode env = M.readTree(C.send(b.build(), HttpResponse.BodyHandlers.ofString()).body());
if (env.hasNonNull("error")) throw new RuntimeException(env.get("error").toString());
return env.get("data");
}
}
require "json"
require "net/http"
BASE = "https://api.skillsafe.ai/v1/app-api"
TOKEN = "YOUR_TOKEN" # from https://sandbox-gate.skillsafe.ai/tokens.html
def api(method, path, body = nil, headers = {})
uri = URI(BASE + path)
req = Net::HTTPGenericRequest.new(method, !body.nil?, true, uri.request_uri,
{ "Content-Type" => "application/json" }.merge(TOKEN.empty? ? {} : { "Authorization" => "Bearer #{TOKEN}" }).merge(headers))
req.body = JSON.generate(body) unless body.nil?
env = JSON.parse(Net::HTTP.start(uri.host, uri.port, use_ssl: true) { |h| h.request(req) }.body)
raise env["error"].to_s if env["error"]
env["data"]
end
<?php
const BASE = "https://api.skillsafe.ai/v1/app-api";
$TOKEN = "YOUR_TOKEN"; // from https://sandbox-gate.skillsafe.ai/tokens.html
function api(string $method, string $path, $body = null, array $headers = []) {
global $TOKEN;
$h = ["Content-Type: application/json"];
if ($TOKEN) $h[] = "Authorization: Bearer $TOKEN";
foreach ($headers as $k => $v) $h[] = "$k: $v";
$ch = curl_init(BASE . $path);
curl_setopt_array($ch, [CURLOPT_CUSTOMREQUEST => $method, CURLOPT_HTTPHEADER => $h, CURLOPT_RETURNTRANSFER => true]);
if ($body !== null) curl_setopt($ch, CURLOPT_POSTFIELDS, json_encode($body));
$env = json_decode(curl_exec($ch), true);
if (!empty($env["error"])) throw new Exception(json_encode($env["error"]));
return $env["data"];
}
// Program.cs -- .NET 6+ console app
using System.Net.Http.Json; using System.Text.Json; using System.Text.Json.Nodes;
static class Api {
public const string Base = "https://api.skillsafe.ai/v1/app-api";
public static string Token = "YOUR_TOKEN"; // from https://sandbox-gate.skillsafe.ai/tokens.html
static readonly HttpClient C = new();
public static async Task<JsonNode?> Call(string method, string path, object? body = null, Dictionary<string, string>? headers = null) {
var req = new HttpRequestMessage(new HttpMethod(method), Base + path);
if (Token != "") req.Headers.Add("Authorization", "Bearer " + Token);
if (headers != null) foreach (var h in headers) req.Headers.Add(h.Key, h.Value);
if (body != null) req.Content = JsonContent.Create(body);
var env = JsonNode.Parse(await (await C.SendAsync(req)).Content.ReadAsStringAsync())!;
if (env["error"] is JsonNode e && e.GetValueKind() != JsonValueKind.Null) throw new Exception(e.ToJsonString());
return env["data"];
}
}
Step 2 — a token
A guest token is enough for /me and /estimate. A run is metered, so it needs a personal token: sign in on /tokens.html and copy it from there.
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN=$(curl -s -X POST "$BASE/guest" -H "Content-Type: application/json" -d '{"slug":"sandbox-gate"}' | jq -r '.data.token')
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = ""
TOKEN = api("POST", "/guest", {"slug": "sandbox-gate"})["token"]
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN = "";
TOKEN = (await api("POST", "/guest", { slug: "sandbox-gate" })).token;
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = ""
g, err := api("POST", "/guest", map[string]string{"slug": "sandbox-gate"}, nil)
if err != nil { panic(err) }
token = g["token"].(string)
// inside main: a guest token (enough for /me and /estimate); a run needs your personal token
token = "";
token = api("POST", "/guest", Map.of("slug", "sandbox-gate"), null).get("token").asText();
# a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
TOKEN.replace("")
TOKEN.replace(api("POST", "/guest", { slug: "sandbox-gate" })["token"])
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
$TOKEN = "";
$TOKEN = api("POST", "/guest", ["slug" => "sandbox-gate"])["token"];
// a guest token (enough for /me and /estimate); a run needs your personal token from /tokens.html
Api.Token = "";
Api.Token = (await Api.Call("POST", "/guest", new { slug = "sandbox-gate" }))!["token"]!.GetValue<string>();
Step 3 — GET /me
Who the token is (subject_type: user or guest) and the credit balance.
api GET /me | jq '.data | {subject_type, credits}'
me = api("GET", "/me")
print(me["subject_type"], me.get("credits"))
const me = await api("GET", "/me");
console.log(me.subject_type, me.credits);
me, _ := api("GET", "/me", nil, nil)
fmt.Println(me["subject_type"], me["credits"])
JsonNode me = api("GET", "/me", null, null);
System.out.println(me.get("subject_type") + " " + me.get("credits"));
me = api("GET", "/me")
puts [me["subject_type"], me["credits"]].inspect
$me = api("GET", "/me");
echo $me["subject_type"], " ", $me["credits"] ?? "", "\n";
var me = await Api.Call("GET", "/me");
Console.WriteLine($"{me!["subject_type"]} {me["credits"]}");
Step 4 — POST /estimate
Free. The body is the run input itself, the bare object (never wrapped in {"input": ...}). hold_credits is a reservation, not the price; min_credits is what the balance must cover; model_alias reads gpt-terra.
# input.json: the run input object - {"task": "design", "platform": "Platform: Payquorlo\nMode: retrofit\nDomain: Payments-like ...", "sandbox": "Isolation today: A mode toggle on the live account ...", "carry": "", "facts": "The page's checks (10 fail, 4 warn, ...):\n- Isolation | model | pass | ..."}
# - see the input table
api POST /estimate "$(cat input.json)" | jq '.data | {hold_credits, min_credits, model, model_alias, markup_bps, warnings}'
inp = json.load(open("input.json")) # {"task": "design", "platform": "Platform: Payquorlo\nMode: retrofit\nDomain: Payments-like ...", "sandbox": "Isolation today: A mode toggle on the live account ...", "carry": "", "facts": "The page's checks (10 fail, 4 warn, ...):\n- Isolation | model | pass | ..."}
# - see the input table
est = api("POST", "/estimate", inp)
print(est["hold_credits"], est["min_credits"], est["model"], est.get("warnings"))
import { readFileSync } from "node:fs";
const input = JSON.parse(readFileSync("input.json", "utf8")); // {task: "design" or "cutover", platform, sandbox, carry, facts} (see the input table)
const est = await api("POST", "/estimate", input);
console.log(est.hold_credits, est.min_credits, est.model, est.warnings);
raw, _ := os.ReadFile("input.json") // add "os" to the imports
var input map[string]any
json.Unmarshal(raw, &input)
est, _ := api("POST", "/estimate", input, nil)
fmt.Println(est["hold_credits"], est["min_credits"], est["model"])
Map<String, Object> input = M.readValue(new java.io.File("input.json"), Map.class);
JsonNode est = api("POST", "/estimate", input, null);
System.out.println(est.get("hold_credits") + " " + est.get("min_credits") + " " + est.get("model"));
input = JSON.parse(File.read("input.json"))
est = api("POST", "/estimate", input)
puts [est["hold_credits"], est["min_credits"], est["model"]].inspect
$input = json_decode(file_get_contents("input.json"), true);
$est = api("POST", "/estimate", $input);
echo $est["hold_credits"], " ", $est["min_credits"], " ", $est["model"], "\n";
var input = JsonNode.Parse(File.ReadAllText("input.json"))!;
var est = await Api.Call("POST", "/estimate", input);
Console.WriteLine($"{est!["hold_credits"]} {est["min_credits"]} {est["model"]}");
Step 5 — POST /run, then poll the job
Send an Idempotency-Key built from the lane and the input: a retry with the same key never bills twice. The page uses sandbox-gate:<task>:<inputHash>:a<attempt> - the lane is part of the key, retry_note is left out of the hash - and its one automatic reformat retry is a new attempt number, named in the page as one extra run. A design reply carries summary, isolation, simulation, catalog, events, time, reset, keys, quotas, abuse, parity and caveats; a cutover reply carries summary, gate, checklist, canary, pitfalls, go_back, metrics, docs and caveats. The code below prints the gist of either: the two rungs and each test value with its code, or each cutover step with its owner.
KEY="sandbox-gate:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1"
JOB=$(curl -s -X POST "$BASE/run" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: $KEY" --data @input.json | jq -r '.data.job_id')
while :; do
J=$(api GET "/jobs/$JOB"); S=$(echo "$J" | jq -r '.data.status')
case "$S" in succeeded|failed|cancelled) break;; esac; sleep 2
done
echo "$J" | jq -r '.data.output.output' > reply.json # one JSON object, the reply contract
echo "$J" | jq '.data | {charged_credits, truncated}'
jq -r 'if .task == "cutover" then (.checklist[] | "\(.owner): \(.step)")
else ("\(.isolation.rung), \(.simulation.rung)", (.catalog[] | "\(.trigger) -> \(.code)")) end' reply.json
import hashlib, time
key = "sandbox-gate:" + inp["task"] + ":" + hashlib.sha256(json.dumps(inp).encode()).hexdigest()[:16] + ":a1"
job = api("POST", "/run", inp, {"Idempotency-Key": key})
while True:
j = api("GET", "/jobs/" + job["job_id"])
if j["status"] in ("succeeded", "failed", "cancelled"):
break
time.sleep(2)
reply = json.loads(j["output"]["output"]) # the reply as one JSON object
print(j["charged_credits"], j.get("truncated"), reply["task"])
if reply["task"] == "cutover":
for x in reply["checklist"]:
print(x["owner"], x["step"])
else:
print(reply["isolation"]["rung"], reply["simulation"]["rung"])
for c in reply["catalog"]:
print(c["trigger"], c["code"])
import { createHash } from "node:crypto";
const key = `sandbox-gate:${input.task}:${createHash("sha256").update(JSON.stringify(input)).digest("hex").slice(0, 16)}:a1`;
const job = await api("POST", "/run", input, { "Idempotency-Key": key });
let j;
do { await new Promise((r) => setTimeout(r, 2000)); j = await api("GET", "/jobs/" + job.job_id); }
while (!["succeeded", "failed", "cancelled"].includes(j.status));
const reply = JSON.parse(j.output.output);
console.log(j.charged_credits, j.truncated, reply.task);
if (reply.task === "cutover") for (const x of reply.checklist) console.log(x.owner, x.step);
else { console.log(reply.isolation.rung, reply.simulation.rung); for (const c of reply.catalog) console.log(c.trigger, c.code); }
sum := sha256.Sum256(raw) // add "crypto/sha256", "encoding/hex", "time" to the imports
key := "sandbox-gate:" + input["task"].(string) + ":" + hex.EncodeToString(sum[:8]) + ":a1"
job, _ := api("POST", "/run", input, map[string]string{"Idempotency-Key": key})
var j map[string]any
for {
time.Sleep(2 * time.Second)
j, _ = api("GET", "/jobs/"+job["job_id"].(string), nil, nil)
if s := j["status"]; s == "succeeded" || s == "failed" || s == "cancelled" { break }
}
var reply map[string]any
json.Unmarshal([]byte(j["output"].(map[string]any)["output"].(string)), &reply)
fmt.Println(j["charged_credits"], j["truncated"], reply["task"])
key := "catalog"
if reply["task"] == "cutover" {
key = "checklist"
}
ls, _ := reply[key].([]any)
for _, x := range ls {
xx := x.(map[string]any)
fmt.Println(xx["trigger"], xx["code"], xx["owner"], xx["step"])
}
var digest = java.security.MessageDigest.getInstance("SHA-256").digest(M.writeValueAsBytes(input));
String key = "sandbox-gate:" + input.get("task") + ":" + HexFormat.of().formatHex(digest, 0, 8) + ":a1";
JsonNode job = api("POST", "/run", input, Map.of("Idempotency-Key", key)), j;
do { Thread.sleep(2000); j = api("GET", "/jobs/" + job.get("job_id").asText(), null, null); }
while (!Set.of("succeeded", "failed", "cancelled").contains(j.get("status").asText()));
JsonNode reply = M.readTree(j.get("output").get("output").asText());
System.out.println(j.get("charged_credits") + " " + reply.path("task").asText());
if (reply.path("task").asText().equals("cutover")) {
for (JsonNode x : reply.path("checklist")) System.out.println(x.path("owner").asText() + " " + x.path("step").asText());
} else {
for (JsonNode x : reply.path("catalog")) System.out.println(x.path("trigger").asText() + " " + x.path("code").asText());
}
require "digest"
key = "sandbox-gate:#{input["task"]}:#{Digest::SHA256.hexdigest(JSON.generate(input))[0, 16]}:a1"
job = api("POST", "/run", input, { "Idempotency-Key" => key })
j = nil
loop do
sleep 2
j = api("GET", "/jobs/#{job["job_id"]}")
break if %w[succeeded failed cancelled].include?(j["status"])
end
reply = JSON.parse(j["output"]["output"])
puts [j["charged_credits"], j["truncated"], reply["task"]].inspect
if reply["task"] == "cutover"
reply["checklist"].each { |x| puts "#{x["owner"]} #{x["step"]}" }
else
reply["catalog"].each { |x| puts "#{x["trigger"]} #{x["code"]}" }
end
$key = "sandbox-gate:" . $input["task"] . ":" . substr(hash("sha256", json_encode($input)), 0, 16) . ":a1";
$job = api("POST", "/run", $input, ["Idempotency-Key" => $key]);
do { sleep(2); $j = api("GET", "/jobs/" . $job["job_id"]); }
while (!in_array($j["status"], ["succeeded", "failed", "cancelled"]));
$reply = json_decode($j["output"]["output"], true);
echo $j["charged_credits"], " ", $reply["task"], "\n";
if ($reply["task"] === "cutover") { foreach ($reply["checklist"] as $x) echo $x["owner"], " ", $x["step"], "\n"; }
else { foreach ($reply["catalog"] as $x) echo $x["trigger"], " ", $x["code"], "\n"; }
var digest = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(input.ToJsonString()));
var key = $"sandbox-gate:{input["task"]}:{Convert.ToHexString(digest, 0, 8).ToLowerInvariant()}:a1";
var job = await Api.Call("POST", "/run", input, new() { ["Idempotency-Key"] = key });
JsonNode j;
do { await Task.Delay(2000); j = (await Api.Call("GET", "/jobs/" + job!["job_id"]))!; }
while (!new[] { "succeeded", "failed", "cancelled" }.Contains(j["status"]!.GetValue<string>()));
var reply = JsonNode.Parse(j["output"]!["output"]!.GetValue<string>())!;
var task = (string?)reply["task"];
Console.WriteLine($"{j["charged_credits"]} {task}");
if (task == "cutover") { foreach (var x in reply["checklist"]!.AsArray()) Console.WriteLine($"{x!["owner"]} {x["step"]}"); }
else { foreach (var x in reply["catalog"]!.AsArray()) Console.WriteLine($"{x!["trigger"]} {x["code"]}"); }
Step 6 — POST /run-stream
The same run as Server-Sent Events, with the same input.json. Browsers receive progress ticks rather than text deltas; the final done event carries the reply.
# Server-Sent Events: tick/delta events while the job runs, then a final event with the job
curl -N -s -X POST "$BASE/run-stream" -H "Content-Type: application/json" -H "Authorization: Bearer $TOKEN" \
-H "Idempotency-Key: sandbox-gate:$(jq -r .task input.json):$(shasum -a 256 input.json | cut -c1-16):a1" --data @input.json
rev = json.load(open("input.json")) # the same run input as input.json: {"task": "design", "platform": "Platform: Payquorlo\nMode: retrofit\nDomain: Payments-like ...", "sandbox": "Isolation today: A mode toggle on the live account ...", "carry": "", "facts": "The page's checks (10 fail, 4 warn, ...):\n- Isolation | model | pass | ..."}
# - see the input table
req = urllib.request.Request(BASE + "/run-stream", method="POST", data=json.dumps(rev).encode(),
headers={"Content-Type": "application/json", "Authorization": "Bearer " + TOKEN,
"Idempotency-Key": "sandbox-gate:" + rev["task"] + ":" + hashlib.sha256(json.dumps(rev).encode()).hexdigest()[:16] + ":a1"})
with urllib.request.urlopen(req) as r:
for line in r:
line = line.decode().strip()
if line.startswith("data:"):
print(line[5:].strip()[:200])
const rev = JSON.parse(readFileSync("input.json", "utf8"));
const r = await fetch(BASE + "/run-stream", { method: "POST", body: JSON.stringify(rev), headers: {
"Content-Type": "application/json", Authorization: "Bearer " + TOKEN,
"Idempotency-Key": `sandbox-gate:${rev.task}:${createHash("sha256").update(JSON.stringify(rev)).digest("hex").slice(0, 16)}:a1` } });
const dec = new TextDecoder();
for await (const chunk of r.body) for (const line of dec.decode(chunk).split("\n"))
if (line.startsWith("data:")) console.log(line.slice(5).trim().slice(0, 200));
rraw, _ := os.ReadFile("input.json") // add "bufio", "strings" to the imports
rs := sha256.Sum256(rraw)
req, _ := http.NewRequest("POST", base+"/run-stream", bytes.NewReader(rraw))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+token)
req.Header.Set("Idempotency-Key", "sandbox-gate:"+input["task"].(string)+":"+hex.EncodeToString(rs[:8])+":a1")
res, _ := http.DefaultClient.Do(req)
sc := bufio.NewScanner(res.Body)
for sc.Scan() { if l := sc.Text(); strings.HasPrefix(l, "data:") { fmt.Println(l[5:]) } }
String rev = java.nio.file.Files.readString(java.nio.file.Path.of("input.json"));
var rd = java.security.MessageDigest.getInstance("SHA-256").digest(rev.getBytes());
var sreq = HttpRequest.newBuilder(URI.create(BASE + "/run-stream")).header("Content-Type", "application/json")
.header("Authorization", "Bearer " + token).header("Idempotency-Key", "sandbox-gate:" + input.get("task") + ":" + HexFormat.of().formatHex(rd, 0, 8) + ":a1")
.POST(HttpRequest.BodyPublishers.ofString(rev)).build();
C.send(sreq, HttpResponse.BodyHandlers.ofLines()).body().filter(l -> l.startsWith("data:")).forEach(System.out::println);
rev = File.read("input.json")
uri = URI(BASE + "/run-stream")
req = Net::HTTP::Post.new(uri, "Content-Type" => "application/json", "Authorization" => "Bearer #{TOKEN}",
"Idempotency-Key" => "sandbox-gate:#{JSON.parse(rev)["task"]}:#{Digest::SHA256.hexdigest(rev)[0, 16]}:a1")
req.body = rev
Net::HTTP.start(uri.host, uri.port, use_ssl: true) do |h|
h.request(req) { |res| res.read_body { |chunk| chunk.each_line { |l| puts l if l.start_with?("data:") } } }
end
$rev = file_get_contents("input.json");
$ch = curl_init(BASE . "/run-stream");
curl_setopt_array($ch, [CURLOPT_POST => true, CURLOPT_POSTFIELDS => $rev, CURLOPT_HTTPHEADER => [
"Content-Type: application/json", "Authorization: Bearer $TOKEN",
"Idempotency-Key: sandbox-gate:" . json_decode($rev, true)["task"] . ":" . substr(hash("sha256", $rev), 0, 16) . ":a1"],
CURLOPT_WRITEFUNCTION => function ($ch, $chunk) { foreach (explode("\n", $chunk) as $l) if (str_starts_with($l, "data:")) echo $l, "\n"; return strlen($chunk); }]);
curl_exec($ch);
var rev = File.ReadAllText("input.json");
var rd = System.Security.Cryptography.SHA256.HashData(System.Text.Encoding.UTF8.GetBytes(rev));
var sreq = new HttpRequestMessage(HttpMethod.Post, Api.Base + "/run-stream") { Content = new StringContent(rev, System.Text.Encoding.UTF8, "application/json") };
sreq.Headers.Add("Authorization", "Bearer " + Api.Token);
sreq.Headers.Add("Idempotency-Key", $"sandbox-gate:{input["task"]}:{Convert.ToHexString(rd, 0, 8).ToLowerInvariant()}:a1");
using var http = new HttpClient();
using var res = await http.SendAsync(sreq, HttpCompletionOption.ResponseHeadersRead);
using var rdr = new StreamReader(await res.Content.ReadAsStreamAsync());
for (string? l; (l = await rdr.ReadLineAsync()) != null;) if (l.StartsWith("data:")) Console.WriteLine(l);