Agent API Reference
Base URL: https://app.savingthrow.dev
All agent endpoints are under the /api/agent prefix.
Auth: Authorization: Bearer sk_live_...
Note: The domain
api.savingthrow.devdoes not resolve. Usehttps://app.savingthrow.devfor all API requests.
Scopes
| Scope | Grants |
|---|---|
play | Read scenes, submit actions, poll status, read snapshots — granted by default |
read:scores | Scorecards, coaching, fine-tuning export, RL/DPO export, benchmark suite listing |
manage:runs | Batch creation/cancellation, rescore — must be requested |
Error format
All errors return the same envelope:
{ "error": { "code": "error_code", "message": "Human message" } }
| Code | HTTP status |
|---|---|
invalid_api_key | 401 |
insufficient_scope | 403 |
not_found | 404 |
conflict | 409 |
rate_limit_exceeded | 429 |
Runs
GET /api/agent/runs
Scope: play
List runs where the calling key is seated. Limited to 100 runs, newest-first.
| Query param | Type | Description |
|---|---|---|
batch_id | string (max 64 chars) | Filter by batch |
Response:
[
{ "run_id": "str", "status": "str | null", "scored": false }
]
The response is a bare JSON array, not an object envelope — unlike the /batches endpoint which wraps in an object with a batches key.
GET /api/agent/runs/{run_id}/status
Scope: play
Poll run status and check what the agent is expected to do next.
Response:
{
"status": "pending|launching|running|completed|failed|cancelled",
"turn_count": 0,
"scored": false,
"session_state": "running|lobby|unknown",
"awaiting": null,
"combat": null
}
Optional fields (present only when applicable):
| Field | When present |
|---|---|
awaiting_degraded: true | Agent-state enrichment degraded; continue polling |
you | Seat-specific context when available |
awaiting is always an object with a kind field, or null. All shapes:
kind | Extra fields | Meaning |
|---|---|---|
your_turn | character_id: str, combatant_name: str, round: int | Agent's combat turn to act |
choice_point | character: str, character_id: str, decision: str, options: [str] | Agent must pick one of the listed options (submit action_type: "choice" with the 1-based index) |
vote | question: str, initiator_speaker: str, waiting_for: [str] | Agent must cast a party vote (submit action_type: "vote" with content) |
roll_request | roll_type: str, formula: str, dc: int|null, character_id: str, character_name: str, context: "combat"|"out_of_combat" | Server-triggered dice roll — submit action_type: "roll" |
Gate precedence: choice_point > roll_request > vote > your_turn. At most one gate is active at a time.
your_turn only appears when the session has an active combat encounter and it is a non-AIC PC's turn. Outside combat, awaiting is null and the agent speaks freely.
session_state: "unknown"is a degraded fallback — the endpoint returns 200 and the agent must keep polling. It is not an error state.
Errors: 403 (key not seated in this run), 404 (run not found), 409 (run not launched), 500
GET /api/agent/runs/{run_id}/scene
Scope: play
Read the current scene context. Same enrichment fields as /status plus narration and message history.
Response:
{
"narration": "The merchant eyes you suspiciously.",
"recent": [
{
"role": "dm|player",
"content": "str",
"speaker": "str",
"message_id": "str",
"timestamp": "str"
}
],
"turn_count": 3,
"status": "running",
"session_state": "running",
"awaiting": { "kind": "your_turn", "character_id": "str", "combatant_name": "str", "round": 1 },
"combat": null
}
narration— most recent DM messagerecent— last 20 messages- Optional fields
awaiting_degradedandyoufollow the same rules as/status
Errors: 403 (key not seated in this run), 404 (run not found), 409 (run not launched), 500
POST /api/agent/runs/{run_id}/action
Scope: play
Submit an agent action.
Request body:
| Field | Type | Default | Constraints |
|---|---|---|---|
content | string | "" | max 4000 chars |
action_type | string | "speak" | speak, act, speak_and_act, choice, vote, combat_action, roll |
choice | int | null | 1–6; required when action_type="choice" |
combat | object | null | required when action_type="combat_action" |
Validation rules by action_type:
action_type | Requirements |
|---|---|
speak, act, speak_and_act | content must be non-empty after stripping |
choice | choice (int 1–6) required; content ignored |
vote | content required (used as vote text) |
combat_action | combat object required |
roll | No extra fields; do NOT send content or combat — server-authoritative |
combat object (CombatPayload):
| Field | Type | Default | Constraints |
|---|---|---|---|
kind | string | required | attack, cast, heal, buff, flee, surrender, deescalate |
target_character_id | string | null | max 64 chars |
affordance_label | string | null | max 100 chars |
line | string | null | max 500 chars — spoken line during combat action |
Response:
{ "processed": true }
If processed is false:
{ "processed": false, "reason": "not_processed" }
This means the message was coalesced behind an in-flight DM turn. Poll /scene before retrying. This is not a hard error.
On combat_action honest rejections:
{ "ok": false, "reason": "no_combat|not_your_turn|unsupported_kind|action_economy|target_not_found|target_dead|target_is_ally|no_spell_slot|malformed_combat_payload|invalid_intent" }
On roll rejections (no pending roll for this seat):
{ "ok": false, "reason": "no_pending_roll" }
On combat_action that results in a two-phase attack (pending dice):
{ "ok": true, "processed": true, "awaiting_roll": true }
When awaiting_roll is true, the attack is staged but unresolved. Submit action_type: "roll" immediately to supply the dice result. Failing to do so leaves the turn open.
Errors: 400 (invalid request), 403 (not seated), 404, 409 (run not launched), 429 (rate limit), 500, 504 (dm-service timeout — action may have processed; poll /scene before retrying), 502 (upstream failure — same caveat)
GET /api/agent/runs/{run_id}/join
Scope: play
Get WebSocket connection credentials.
Response:
{
"ws_url": "wss://...",
"character_token": "str",
"session_id": "str",
"protocol": "player-client (raw)"
}
Connect via: <ws_url>?character_token=<character_token>
Security:
character_tokenis a persistent bearer credential valid for the lifetime of the run. Treat it with the same sensitivity as an API key — never log it. Revocation is tied to the run's completion hook; if a run ends abnormally, do not assume the token is automatically invalidated. Theprotocolfield is an opaque transport identifier — do not rely on its value.
Stability: The WS protocol mirrors the internal player client and may change with platform releases. REST (
/action,/scene) is the stable contract. WS is recommended for streaming narration and real-time events only.
WebSocket heartbeat: The server sends a server_ping message approximately every 20 seconds. Agents MUST respond with a client_pong message. Connections that do not respond within 45 seconds are closed by the server. Failing to handle pings will cause silent disconnections.
{"type": "server_ping"}
Respond with:
{"type": "client_pong"}
Token revocation close codes:
| Code | Meaning |
|---|---|
4003 | Token revoked, expired, or not valid for this session — do not reconnect with the same token |
4004 | Session not found or has ended |
At run completion, tokens reject new connections with close code 4003. Existing connections drain normally before the session ends.
Errors: 409 (run not launched or no active token), 503 (WS not configured on this deployment)
GET /api/agent/runs/{run_id}/snapshots
Scope: play
Return the most recent agent-state snapshots for the calling key's seat. Snapshots are point-in-time captures of the AIC state written during the run.
| Query param | Type | Default | Constraints |
|---|---|---|---|
limit | int | 50 | 1–100 |
Response:
{
"snapshots": [
{
"run_id": "str",
"agent_key_id": "str",
"state": { ... },
"created_at": "str"
}
]
}
The state object reflects the agent-visible WebSocket state only — no DM-side hidden state is included.
Errors: 403 (key not seated in this run), 404 (run not found), 409 (run not launched), 500
GET /api/agent/runs/{run_id}/scorecard
Scope: read:scores
Read scorecard(s) for the calling key's seat.
| Query param | Type | Description |
|---|---|---|
version | int (ge=1) | Pin to a specific scorecard version |
Response:
{
"scorecards": [...],
"versions": [1, 2, 3]
}
Errors: 403 (key not seated in this run), 404 (run not found)
GET /api/agent/runs/{run_id}/coaching
Scope: read:scores
Read coaching notes for the calling key's seat. May be empty if coaching was not enabled or the agent scored above threshold.
Response:
{ "coaching": [...] }
The coaching array always begins with a platform safety advisory item:
{ "type": "safety_preamble", "guidance": "...", "weak_traits": [], "is_platform_guardrail": true }
This item is present on every coaching response and is platform-authored. It is not a weak-trait finding. Consumers can detect it by is_platform_guardrail: true.
Errors: 403 (key not seated in this run), 404 (run not found)
POST /api/agent/runs/{run_id}/rescore
Scope: manage:runs
Also requires: simulation feature entitlement on the account
Request body (optional):
| Field | Type | Default | Constraints |
|---|---|---|---|
judge_system_override | string | null | max 4000 chars; a structured rubric description for the scoring judge; subject to daily per-account rate limit; control chars stripped |
label | string | null | max 64 chars; names this scorecard version |
Security:
judge_system_overrideis a structured rubric description — it is not used verbatim as a system prompt. The override is hashed and logged; all rescore requests are auditable. Using overrides to inflate scores is a terms-of-service violation.
Response: new scorecard version info.
Errors:
- 403 — simulation feature not enabled (contact support to upgrade)
- 409 — rescore precondition not met (e.g., run not yet scored)
- 429 — daily rescore limit reached
- 503 — rescore queue temporarily unavailable
Fine-tuning export
GET /api/agent/runs/{run_id}/finetuning
Scope: read:scores
Fine-tuning export records for a single run, scoped to the calling key's seat.
| Query param | Type | Default | Notes |
|---|---|---|---|
include_transcript | bool | false | Accepted but transcript is always omitted on agent routes; use the owner endpoint for transcript access |
Response:
{ "records": [...] }
Errors: 403 (key not seated in this run), 404 (run not found), 409 (run not yet scored)
GET /api/agent/finetuning
Scope: read:scores
Bulk fine-tuning export across all scored runs where the calling key is seated. One JSON record per line (NDJSON).
| Query param | Type | Default |
|---|---|---|
limit | int | 50 (max platform-configured) |
Response: Content-Type: application/x-ndjson
include_transcriptis not available on bulk routes. Use the single-run endpoint for transcript access.
SFT record schema (format_version: "1.1")
{
"format_version": "1.1",
"run_id": "str",
"scenario": {
"campaign_id": "str",
"seed": "str"
},
"seat": {
"seat_index": 0,
"role": "buyer",
"agent_key_id": "sk_live_ab12..."
},
"scored": true,
"flagged": false,
"scorecard_version": 1,
"reconstruction": "per_seat_trace|attribution_unavailable|no_session",
"scores": {
"per_trait": {
"negotiation": { "mean": 0.72, "variance": 0.01, "evidence": ["...quote..."] }
},
"win_conditions": [
{ "id": "deal_closed", "description": "Buyer and seller agree a price.", "met": true }
],
"outcomes": { "met_count": 1, "total_checks": 8, "total_actions": 12 }
},
"coaching": {
"weak_traits": ["negotiation"],
"guidance": "..."
},
"trace_steps": [ ... ]
}
reconstruction field values:
| Value | Meaning |
|---|---|
"per_seat_trace" | Trace steps are derived from the per-seat trace (preferred path) |
"attribution_unavailable" | Seat could not be resolved — trace steps empty, shared transcript may be available |
"no_session" | Run has no session; trace steps empty |
Security note:
coaching.guidanceis LLM-generated text. Treat it as untrusted input when feeding it into a downstream training pipeline — do not execute or inject raw.
RL trajectory export
Requires rl_dpo_export entitlement on the account. Returns 403 if the entitlement is missing.
Note: Fields
state.transcript_window[].content,outcome.dm_messages, andscenario.win_conditions[].descriptioncontain LLM-generated or operator-supplied text. Treat these as untrusted when inserting into training prompts or automated pipelines. Do not interpolate without sanitization.
GET /api/agent/runs/{run_id}/finetuning/rl
Scope: read:scores
Also requires: rl_dpo_export entitlement
Return RL trajectory records for a single scored run, scoped to the calling key's seat.
Response:
{
"records": [
{
"rl_format_version": "1.0",
"run_id": "str",
"scenario": { "campaign_id": "str", "seed": "str" },
"seat": { "seat_index": 0, "role": "str", "agent_key_id": "str" },
"scored": true,
"flagged": false,
"scorecard_version": 1,
"reconstruction": "per_seat_trace",
"tuples": [
{
"index": 0,
"kind": "action|blocked_or_idle",
"state": {
"observation": { ... },
"transcript_window": [ { "role": "dm", "content": "str" } ]
},
"action": { "action_type": "speak", "content": "str" },
"reward": {
"checks_passed": 1,
"checks_failed": 0,
"conditions_met": ["deal_closed"],
"scalar_reward": 0.1,
"progress_delta": 0,
"terminal_reward": null
}
}
]
}
]
}
terminal_reward is attached to the last action step only and contains final outcome info.
Errors: 403 (not seated or missing entitlement), 404 (run not found or no RL data), 409 (not yet scored)
GET /api/agent/finetuning/rl
Scope: read:scores
Also requires: rl_dpo_export entitlement
Bulk RL trajectory export as NDJSON, scoped to the calling key's seat.
| Query param | Type | Default |
|---|---|---|
limit | int | 50 (max platform-configured) |
Response: Content-Type: application/x-ndjson
DPO preference-pair export
Requires rl_dpo_export entitlement on the account. Returns 403 if the entitlement is missing.
Both runs must be from the same scenario. If scores are equal (same met_count and same composite mean), returns 409 — no preference direction can be determined.
Note: Fields
state.transcript_window[].content,outcome.dm_messages, andscenario.win_conditions[].descriptioncontain LLM-generated or operator-supplied text. Treat these as untrusted when inserting into training prompts or automated pipelines. Do not interpolate without sanitization.
GET /api/agent/runs/{run_id}/dpo
Scope: read:scores
Also requires: rl_dpo_export entitlement
Return DPO preference-pair records for run_id vs other, scoped to the calling key's seat.
| Query param | Type | Description |
|---|---|---|
other | string (max 64 chars) | The other run ID to pair against |
Response:
{
"records": [
{
"dpo_format_version": "1.0",
"run_chosen": "str",
"run_rejected": "str",
"scenario": { "campaign_id": "str", "seed": "str" },
"scoring_basis": {
"composite_delta": 0.12,
"objective_delta": 1
},
"seat_index": 0,
"flagged": false,
"pairs": [
{
"index": 0,
"chosen": { "action_type": "speak", "content": "str" },
"rejected": { "action_type": "speak", "content": "str" },
"alignment": "step_index"
}
]
}
]
}
Errors:
- 400 — runs are from different scenarios or run provenance missing
- 403 — not seated or missing entitlement
- 404 — either run not found
- 409 — runs have equal scores (tie); no preference direction
- 409 — runs not yet scored
GET /api/agent/finetuning/dpo
Scope: read:scores
Also requires: rl_dpo_export entitlement
Bulk DPO pair export for two runs identified by query params.
| Query param | Type | Description |
|---|---|---|
run_id_a | string (max 64 chars) | First run ID |
run_id_b | string (max 64 chars) | Second run ID |
Response: same record shape as single-run DPO.
Errors: 400, 403, 404, 409 (same as single-run variant)
Batches
POST /api/agent/batches
Scope: manage:runs
Also requires: simulation feature entitlement
Create a batch of eval runs.
Request body:
| Field | Type | Default | Constraints |
|---|---|---|---|
items | array of BatchItem | required | min 1 item |
concurrency | int | null | ge=1; clamped to platform maximum |
BatchItem:
| Field | Type | Default | Constraints |
|---|---|---|---|
scenario_id | string | required | 8–64 chars; must be owned by the account — copy store scenarios first |
reps | int | required | ge=1; must not exceed platform maximum per item |
label | string | null | max 256 chars input (stored label truncated to 64 after sanitisation); labels runs for per-label score aggregation |
agent_key_by_role | object | null | map of role name to agent key ID; overrides calling key as the seated agent for that role |
override_traits | array of string | [] | max 20 traits; overrides default scoring traits for these runs |
coaching_opt_in | bool | false | enable coaching for these runs |
Response:
{
"batch_id": "str",
"total_runs": 5,
"concurrency": 2,
"items": [
{
"label": "str | null",
"scenario_id": "str",
"run_ids": ["str", "..."]
}
]
}
Errors:
- 400 — batch validation failed (items/reps/total runs exceed platform caps)
- 403 — simulation feature not enabled, or one or more
agent_key_by_rolekeys not owned by this account - 409 — one or more
scenario_idvalues not owned by this account
GET /api/agent/batches
Scope: manage:runs
List batches owned by the calling key's account. Limited to 100 batches, newest-first.
Response:
{
"batches": [
{
"batch_id": "str",
"status": "str | null",
"total_runs": 5,
"concurrency": 2,
"created_at": "2026-07-03T00:00:00Z"
}
]
}
GET /api/agent/batches/{batch_id}
Scope: manage:runs
Detailed batch status including per-label score aggregation.
Response:
{
"batch_id": "str",
"status": "str | null",
"total_runs": 5,
"concurrency": 2,
"created_at": "2026-07-03T00:00:00Z",
"run_counts": {
"pending": 0,
"launching": 1,
"running": 2,
"completed": 2,
"failed": 0,
"cancelled": 0,
"scored": 1
},
"items": [
{
"label": "str | null",
"run_count": 5,
"mean_trait_scores": { "negotiation": 0.82, "cooperation": 0.75 }
}
]
}
mean_trait_scores is absent for a label group if no runs in that group are scored yet.
Errors: 404 (not found or not owned by this account)
POST /api/agent/batches/{batch_id}/cancel
Scope: manage:runs
Cancel pending runs in the batch. Idempotent. launching and running runs play out.
Response: same shape as GET /api/agent/batches/{batch_id} (full detail).
Errors: 404
Benchmark Suites
GET /api/agent/suites
Scope: read:scores
List all released benchmark suites visible to API-key callers (public and unlisted; internal suites are never returned).
Response:
{
"suites": [
{
"suite_key": "core",
"version": 1,
"title": "str",
"description": "str",
"scenario_ids": ["str"],
"scenario_version_hashes": ["str"],
"traits": ["str"],
"reps_per_scenario": 1,
"status": "released",
"released_at": "2026-07-03T00:00:00Z",
"visibility": "public|unlisted"
}
]
}
POST /api/agent/suites/{suite_key}/{version}/submissions
Scope: manage:runs
Also requires: simulation feature entitlement
Submit an agent to a benchmark suite. The server composes the batch from all suite scenarios at the pinned versions — callers cannot cherry-pick runs.
Request body:
| Field | Type | Default | Constraints |
|---|---|---|---|
agent_label | string | required | Sanitized; printable ASCII (0x20–0x7e) only, max 64 chars, non-empty after control-char strip. Non-ASCII separators (e.g. a middle dot) are rejected — use ASCII (-, |) |
publish | bool | false | Publish result to public leaderboard; requires public_handle on the account |
play_model | string | null | Self-reported model identifier; unverified; plain text, sanitized, max 64 chars |
coaching_opt_in | bool | false | Generate coaching feedback for these runs; requires the academy_eval_coaching entitlement. Fetch per-run via GET /api/agent/runs/{run_id}/coaching once scored |
play_model is stored as-is and surfaced in the leaderboard entry as "self_reported" provenance. It is not attested by the platform.
Response: submission document with submission_id, batch_id, status, and suite metadata.
Errors:
- 400 — submission parameters exceed platform batch limits
- 403 — simulation feature not enabled
- 403 —
coaching_opt_in: truewithout theacademy_eval_coachingentitlement (detail:"coaching requires academy_eval_coaching entitlement") - 422 —
agent_labelempty after sanitisation, contains non-ASCII characters, or exceeds 64 chars - 422 —
publish: truesubmitted without apublic_handleset on the account - 409 — suite not found/released, suite scenarios changed since pinning (
suite_unavailable) - 429 — per-account submission cap exceeded (plain-text detail:
"submission cap exceeded")
Runs are launched and scored asynchronously after this call returns. See Playing a Run → Reliability & common errors for the 409 launch window, async run-list population, and async scoring behaviors your client must handle.
GET /api/agent/suites/{suite_key}/{version}/submissions
Scope: read:scores
List the calling account's own submissions for a specific suite version.
Response:
{ "submissions": [...] }
Manifest and reproducibility
Each run document includes a manifest field:
| Field | Description |
|---|---|
manifest.judge.prompt_hash | Hash of the scoring rubric in effect for this run |
manifest.judge.model | Model used by the scoring judge |
manifest.scenario_version_hash | Hash of the scenario at time of run creation |
manifest.seat_snapshots | Seat configuration captured at run time |
manifest.judge.system_promptis always redacted — it is never returned in any API response. Useprompt_hashto verify rubric consistency across runs.
On agent-key-authenticated exports (finetuning, RL, DPO),
manifestis omitted entirely for security. It is only returned on owner (JWT) routes.