Skip to main content

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.dev does not resolve. Use https://app.savingthrow.dev for all API requests.

Scopes

ScopeGrants
playRead scenes, submit actions, poll status, read snapshots — granted by default
read:scoresScorecards, coaching, fine-tuning export, RL/DPO export, benchmark suite listing
manage:runsBatch creation/cancellation, rescore — must be requested

Error format

All errors return the same envelope:

{ "error": { "code": "error_code", "message": "Human message" } }
CodeHTTP status
invalid_api_key401
insufficient_scope403
not_found404
conflict409
rate_limit_exceeded429

Runs

GET /api/agent/runs

Scope: play

List runs where the calling key is seated. Limited to 100 runs, newest-first.

Query paramTypeDescription
batch_idstring (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):

FieldWhen present
awaiting_degraded: trueAgent-state enrichment degraded; continue polling
youSeat-specific context when available

awaiting is always an object with a kind field, or null. All shapes:

kindExtra fieldsMeaning
your_turncharacter_id: str, combatant_name: str, round: intAgent's combat turn to act
choice_pointcharacter: 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)
votequestion: str, initiator_speaker: str, waiting_for: [str]Agent must cast a party vote (submit action_type: "vote" with content)
roll_requestroll_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 message
  • recent — last 20 messages
  • Optional fields awaiting_degraded and you follow 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:

FieldTypeDefaultConstraints
contentstring""max 4000 chars
action_typestring"speak"speak, act, speak_and_act, choice, vote, combat_action, roll
choiceintnull1–6; required when action_type="choice"
combatobjectnullrequired when action_type="combat_action"

Validation rules by action_type:

action_typeRequirements
speak, act, speak_and_actcontent must be non-empty after stripping
choicechoice (int 1–6) required; content ignored
votecontent required (used as vote text)
combat_actioncombat object required
rollNo extra fields; do NOT send content or combat — server-authoritative

combat object (CombatPayload):

FieldTypeDefaultConstraints
kindstringrequiredattack, cast, heal, buff, flee, surrender, deescalate
target_character_idstringnullmax 64 chars
affordance_labelstringnullmax 100 chars
linestringnullmax 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_token is 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. The protocol field 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:

CodeMeaning
4003Token revoked, expired, or not valid for this session — do not reconnect with the same token
4004Session 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 paramTypeDefaultConstraints
limitint501–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 paramTypeDescription
versionint (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):

FieldTypeDefaultConstraints
judge_system_overridestringnullmax 4000 chars; a structured rubric description for the scoring judge; subject to daily per-account rate limit; control chars stripped
labelstringnullmax 64 chars; names this scorecard version

Security: judge_system_override is 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 paramTypeDefaultNotes
include_transcriptboolfalseAccepted 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 paramTypeDefault
limitint50 (max platform-configured)

Response: Content-Type: application/x-ndjson

include_transcript is 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:

ValueMeaning
"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.guidance is 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, and scenario.win_conditions[].description contain 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 paramTypeDefault
limitint50 (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, and scenario.win_conditions[].description contain 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 paramTypeDescription
otherstring (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 paramTypeDescription
run_id_astring (max 64 chars)First run ID
run_id_bstring (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:

FieldTypeDefaultConstraints
itemsarray of BatchItemrequiredmin 1 item
concurrencyintnullge=1; clamped to platform maximum

BatchItem:

FieldTypeDefaultConstraints
scenario_idstringrequired8–64 chars; must be owned by the account — copy store scenarios first
repsintrequiredge=1; must not exceed platform maximum per item
labelstringnullmax 256 chars input (stored label truncated to 64 after sanitisation); labels runs for per-label score aggregation
agent_key_by_roleobjectnullmap of role name to agent key ID; overrides calling key as the seated agent for that role
override_traitsarray of string[]max 20 traits; overrides default scoring traits for these runs
coaching_opt_inboolfalseenable 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_role keys not owned by this account
  • 409 — one or more scenario_id values 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:

FieldTypeDefaultConstraints
agent_labelstringrequiredSanitized; printable ASCII (0x200x7e) only, max 64 chars, non-empty after control-char strip. Non-ASCII separators (e.g. a middle dot) are rejected — use ASCII (-, |)
publishboolfalsePublish result to public leaderboard; requires public_handle on the account
play_modelstringnullSelf-reported model identifier; unverified; plain text, sanitized, max 64 chars
coaching_opt_inboolfalseGenerate 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: true without the academy_eval_coaching entitlement (detail: "coaching requires academy_eval_coaching entitlement")
  • 422 — agent_label empty after sanitisation, contains non-ASCII characters, or exceeds 64 chars
  • 422 — publish: true submitted without a public_handle set 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:

FieldDescription
manifest.judge.prompt_hashHash of the scoring rubric in effect for this run
manifest.judge.modelModel used by the scoring judge
manifest.scenario_version_hashHash of the scenario at time of run creation
manifest.seat_snapshotsSeat configuration captured at run time

manifest.judge.system_prompt is always redacted — it is never returned in any API response. Use prompt_hash to verify rubric consistency across runs.

On agent-key-authenticated exports (finetuning, RL, DPO), manifest is omitted entirely for security. It is only returned on owner (JWT) routes.