Wire contract & errors
The exhaustive low-level contract every ported agent honors — response shape, proxy request/response/error classes, retry policy, DYSTOPIC_* env vars, and the SDK error hierarchy.
This is the exhaustive, byte-level contract shared by three steps of the flow: porting an agent, running checks, and reviewing findings. Those pages describe what to do; this page is the single lookup surface for exactly what goes on the wire — the shape you return, the proxy request/response/error classes, the environment variables injected into the sandbox runtime, and the SDK's exception hierarchy.
Everything here is confirmed against the SDK modules dystopic.odyssey, dystopic.errors, and dystopic.config.
Response shape (what your entrypoint returns)
The platform requires exactly one field — final_response — and optionally accepts messages and metadata for rich-mode trace rendering. build_response coerces three return shapes:
str→ wrapped as{"final_response": <str>}.dict→ must contain afinal_responsekey; optionalmessages/metadataare forwarded, other keys pass through (the platform ignores unknown keys).- Any object with a
final_response,final_output,output, ormessageattribute (in that probe order) → the attribute is extracted and wrapped. This covers the OpenAI Agents SDKRunResult, AnthropicMessage, StrandsAgentResult, etc., so you canreturn resultdirectly.
final_response is required and non-empty
An empty or None final_response raises ResponseShapeError and fails the run — an honest failure is better than silently getting graded on nothing. The error message is:
Handler returned an empty string for
final_response. The platform rejects empty responses (the LLM judge has nothing to grade against). Return at least a one-sentence summary of what the agent did or why it failed.
Always return at least a one-sentence summary, even on the failure path:
return result.output or "Agent did not produce an output."final_response is capped at 50,000 characters on the platform side; overflow is truncated with a …[truncated] marker and recorded as a soft-warning — the judge still scores the head.
Rich mode: messages and metadata
Both are optional. When present, they drive full-transcript trace rendering instead of the thin final-response view.
{
"final_response": "The refund has been issued.",
"messages": [
{ "role": "user", "content": "Refund order #4521..." },
{
"role": "assistant",
"tool_calls": [
{ "id": "c1", "name": "get_order", "arguments": { "order_id": "4521" } }
]
},
{ "role": "tool", "tool_call_id": "c1", "content": "{\"status\":\"shipped\"}" },
{ "role": "assistant", "content": "The refund has been issued." }
],
"metadata": {
"model": "claude-sonnet-4-5",
"system_prompt_id": "refund-v3",
"total_input_tokens": 1842,
"total_output_tokens": 217,
"agent_runtime_ms": 4815
}
}messages[] schema (validated on the platform side):
role— required, must be one ofsystem,user,assistant,tool. Any other value drops that message.content— optional; must be a string, an array, ornull(oneOf: [string, array, null]). A bare content-part object is rejected.tool_call_id— optional string.tool_calls— optional array. Each entry normalizes to the flat form{"id"?, "name": str, "arguments"?}. The OpenAI Chat Completions nested shape ({"type": "function", "function": {"name", "arguments"}}) is accepted and flattened.thinking— optional array of objects.- Any other key passes through opaquely (
additionalProperties: true).
metadata recognised keys get first-class chrome; the rest is stored opaquely: system_prompt_id, model, total_input_tokens, total_output_tokens, agent_runtime_ms.
Soft-warnings (malformed rich mode is dropped, never fatal)
If messages or metadata is unparseable, the platform does not raise. It:
- Drops the offending field (or entry) to
null, - Appends a human-readable reason to
soft_warnings, - Still grades the run on
final_responsealone, rendering the thin trace.
Examples of what triggers a soft-warning: messages not a list, a message that isn't an object, an out-of-set role, a non-string tool_call_id, metadata that isn't an object. If every message is dropped (or you sent messages: []), the messages field normalizes to null and rich mode stays off. Write defensively, or return only final_response when unsure — the failure surfaces in the run's metadata, not as an exception in your process.
A structurally-unusable response (not merely a malformed optional field) raises AgentResponseContractError on the platform: the run is marked FAILED and the judge is not invoked. That is distinct from a soft-warning, which is advisory.
Proxy call contract
Every ported tool body becomes one HTTP call to the per-run Odyssey proxy. dystopic.odyssey.proxy_call is the smallest function that satisfies it.
Request
POST {proxy_url}/tools/{tool_name}
Authorization: Bearer {run_token}
Content-Type: application/json
X-Pipelines-Actor-Id: {actor_id} # optional — multi-agent attribution onlyThe body is the tool's argument object (validated against the tool's input_schema on the platform side):
{ "order_id": "4521" }The X-Pipelines-Actor-Id header is stamped only when an acting sub-agent label is bound (via set_actor_id, ScopedProxy, or envelope.for_actor(...)); absent, single-agent calls are byte-identical to the pre-multi-agent shape. Attribution rides the header, never the body — a tool that legitimately takes an actor_id argument passes it through unchanged.
Success response
The proxy always returns HTTP 200 for application-level outcomes (even an injected error payload — this mirrors real tool semantics), with this body:
{
"tool_name": "get_order",
"response": { "status": "shipped", "shipped_at": "2026-04-01" },
"source": "odyssey",
"latency_ms": 412,
"matched_rule_index": null,
"validation": null
}response— the payload your tool body should consume.proxy_callunwraps and returns only this field, not the whole envelope. (Legacy paths that return the payload directly are tolerated.)source— where the response came from (see below).latency_ms— float.matched_rule_index—intonly when a failure rule fired, elsenull.validation— object only onsource: "odyssey", elsenull.
source values
source is the runtime provenance of one proxy response — where this call's payload actually came from. It is a wire-level field, distinct from (but downstream of) the tool's declared execution mode (Simulated / Executed / Live). A Simulated tool yields odyssey (or injected when a failure rule fires); a Live tool yields passthrough (or transport_error on a failed live hop). An Executed tool's own code runs in the sandbox, so its data-plane reads/writes come back through the data call, not this source axis. The wire strings themselves keep the legacy vocabulary — only the human-facing mode names changed.
source | Meaning |
|---|---|
odyssey | Simulator generated the response (Simulated tool — legacy sandbox). |
injected | A declared failure/injection rule produced the payload. |
passthrough | A live endpoint was hit and returned successfully (Live tool). |
error | Sandbox dispatch itself errored (an {"error": ...} payload). |
transport_error | A Live hop failed (endpoint unavailable / unsupported / the live tool errored). |
The two error sources are intentionally disjoint: Simulated rows emit odyssey / injected / error; Live rows emit passthrough / transport_error.
For the full public↔wire mapping (simulated → sandbox, executed → code_intercepted, live → passthrough), the simulated()/executed()/live() SDK helpers, and their legacy aliases, see Execution modes.
Error responses and ProxyCallError
HTTP 4xx/5xx (or a network failure) raises dystopic.odyssey.ProxyCallError. The platform's structured error body is:
{ "detail": { "error_class": "run_token_expired", "message": "The run token has expired." } }ProxyCallError exposes:
| Attribute | Contents |
|---|---|
status_code | HTTP status, or None for transport-level failures (DNS, refused, timeout). |
body | Parsed JSON body, or raw text if not JSON, or None for transport failures. |
tool_name | The tool being called. |
error_class | The platform's structured discriminator, extracted from body["detail"]["error_class"]. None for transport failures and legacy responses. Branch on this, not on English strings — the platform keeps the discriminator stable across releases. |
Proxy HTTP-level status codes: 400 malformed tool name/args; 401 missing/invalid/expired run token; 404 valid token but run not registered in this proxy's context store (body carries detail.error_class); 500 server misconfiguration or unhandled dispatch exception; 503 backing store unreachable (detail.error_class = "context_store_unavailable").
StaleRunTokenError is a ProxyCallError subclass raised specifically when the run has closed: HTTP 401 with error_class in {run_token_expired, run_token_invalid, run_token_rejected}. The helper is_stale_run_token(exc) returns True for exactly that condition.
Retry policy
proxy_call / async_proxy_call (and data_call) auto-retry transient platform conditions:
- HTTP
429(rate-limited), OR - HTTP
503witherror_classin{lock_contention, context_store_unavailable, trace_sequence_contended}.
Everything else — 401, 404, 400/422, 500, and unrecognised 503s — is terminal and raised immediately.
- Up to 4 total attempts (3 retries).
- Exponential backoff with ±25% jitter, base
0.5 s, capped at4.0 s:min(0.5 · 2^attempt, 4.0) · (0.75 + random·0.5). - Disable per call with
auto_retry=False. - Override the per-call timeout with
timeout=<seconds>; otherwise the default is read fromDYSTOPIC_PROXY_TIMEOUT_S(falling back to 120.0 s).
from dystopic.odyssey import proxy_call
# default: retry transient errors, 120s (or DYSTOPIC_PROXY_TIMEOUT_S) timeout
result = proxy_call("get_order", {"order_id": "4521"})
# latency-sensitive: no retry, tighter timeout
result = proxy_call("get_order", {"order_id": "4521"}, auto_retry=False, timeout=30.0)DYSTOPIC_* environment variables
The platform injects the run context into the sandbox as environment variables before calling your entrypoint. The same variables are set by make_subprocess_env(envelope) when your entrypoint spawns a subprocess that needs the run context.
| Variable | Contents | Required for Envelope.from_env()? |
|---|---|---|
DYSTOPIC_ODYSSEY_PROXY_URL | Base proxy URL (append /tools/{name}). | Yes |
DYSTOPIC_RUN_TOKEN | Per-run bearer. | Yes |
DYSTOPIC_API_URL | Origin (scheme://host[:port]) derived from the proxy URL. | No |
DYSTOPIC_RUN_TOKEN_JTI | Non-secret correlation id. | No (optional) |
DYSTOPIC_AGENT_ID | Agent id. Omitted (not blanked) when the envelope has none, so the platform-injected value survives on the code-mode path. | No |
DYSTOPIC_TASK_ID | Task id. | No (optional) |
DYSTOPIC_RUN_ID | Run id. | No (optional) |
Envelope.from_env() reads DYSTOPIC_ODYSSEY_PROXY_URL + DYSTOPIC_RUN_TOKEN (both required — a missing one raises KeyError) and the optional correlation ids DYSTOPIC_RUN_TOKEN_JTI, DYSTOPIC_RUN_ID, DYSTOPIC_TASK_ID. Its user_instruction is "" and task_input is {} — those arrive to code-mode entrypoints as the task_input positional argument, not via the env.
from dystopic.odyssey import Envelope, proxy_call_with
env = Envelope.from_env()
order = proxy_call_with(env, "get_order", {"order_id": "4521"})DYSTOPIC_PROXY_TIMEOUT_S (proxy-call default timeout) is read by the SDK but not set by the dispatch machinery — your process inherits whatever is in the environment, or the built-in 120.0 s default. An unparseable value logs a warning and falls back to 120.0 s.
SDK API error hierarchy
Calls made through DystopicClient (registration, review creation, run export — everything that hits api.pipelines.tech) raise exceptions from dystopic.errors. This is a different hierarchy from ProxyCallError above: ProxyCallError is the tool-call wire; the classes below are the platform REST API wire.
DystopicError (base — dystopic.DystopicError)
├── DystopicTimeoutError (an SDK polling helper timed out)
├── DystopicConnectionError (cannot connect to the API)
└── DystopicAPIError (non-2xx response; carries .status_code, .message, .body)
├── AuthenticationError 401 (missing / invalid credentials)
├── ForbiddenError 403
├── NotFoundError 404
├── ConflictError 409 (typically idempotency-key reuse with a different payload)
├── ValidationError 422
├── RateLimitError 429 (carries .retry_after: float | None)
└── ServerError 5xx (non-retryable server errors)error_for_status(status_code) picks the most specific subclass: the exact-status map above for 401/403/404/409/422/429, ServerError for any 500–599, and the base DystopicAPIError otherwise.
DystopicAPIErrorcarriesstatus_code: int,message: str, andbody: Any; its string form isDystopic API error {status_code}: {message}.RateLimitErroradditionally carriesretry_after: float | None.
from dystopic.errors import DystopicAPIError, RateLimitError
try:
client.create_review(...)
except RateLimitError as exc:
wait = exc.retry_after or 30.0
...
except DystopicAPIError as exc:
print(exc.status_code, exc.body)Anti-duplication note
This page is the single home for the low-level contract. The flow and reference pages that touch it link down here rather than restating it:
Execution modes
The per-tool Simulated / Executed / Live axis — where a tool's response comes from, the public↔SDK-helper↔wire mapping, the legacy aliases, and how default_execution_mode is validated.
CLI reference
The command surface for the whole flow — auth, config, whoami/health, odyssey init/push/publish/dev, the platform-authoring groups (scenarios, suites, agents gate/ci-suite, repo), ci init/review --platform-suite, runs export, and the credential/base-url resolution precedence.