The dispatch contract
The contract every ported agent honors — the run context you receive, the response you return, the proxy tool-call format, and the tool-schema shape.
Every ported agent — the platform runs your source in a sandbox — honors one contract. The platform hands your entrypoint the run context, your agent does its work, calling tools by POSTing to the Odyssey proxy, and you hand back a response whose only required field is a non-empty final_response. This page is the field-by-field contract for that exchange.
The run context: what you receive
There is no inbound request to parse. Before calling your entrypoint, the sandbox injects the run context two ways:
- Entrypoint arguments —
run(task_input, *, proxy_url, run_token): the scenario's task payload as the positionaltask_input(a dict, or{}), and the per-runproxy_url/run_tokenas keyword-only arguments. DYSTOPIC_*environment variables — the same proxy URL, run token, and non-secret correlation ids (DYSTOPIC_RUN_ID,DYSTOPIC_TASK_ID,DYSTOPIC_RUN_TOKEN_JTI, …), so code that can't thread arguments (framework adapters, subprocesses) can rebuild the context ambiently.
Envelope.from_env() reads those variables back into the frozen Envelope dataclass the rest of the SDK (proxy_call, ScopedProxy, the framework adapters) expects. DYSTOPIC_ODYSSEY_PROXY_URL and DYSTOPIC_RUN_TOKEN are required — a missing one raises KeyError, because without them the agent can't talk back to the proxy. Everything else degrades to a safe default (user_instruction is "" and task_input is {} on the env path — those arrive via the entrypoint arguments instead).
The full variable table lives in the wire contract; run_token_jti is the safe-to-log correlation id — log it instead of hashing run_token.
The response: what you return
The platform requires exactly one field — final_response — and optionally accepts messages and metadata for rich trace rendering. The minimum valid response is:
{ "final_response": "The refund of $79.50 has been issued for order #4521." }The SDK's build_response coerces three return shapes into that body, so a handler can return naturally:
- A
str→ wrapped as{"final_response": <str>}. - A
dict→ must containfinal_response; optionalmessagesandmetadatapass through, and any other keys pass through untouched (the platform ignores unknown keys). - An object with a
final_response,final_output,output, ormessageattribute (in that probe order) → the attribute is extracted and wrapped. This covers most framework result types (OpenAI AgentsRunResult, AnthropicMessage, StrandsAgentResult) so you canreturn resultdirectly.
final_response must be a non-empty string. Returning "" (or a dict/object whose final_response coerces to empty) raises ResponseShapeError and the run fails. The platform rejects empty responses because the LLM judge has nothing to grade against. Always return at least a one-sentence summary — even on failure:
return result.output or "Agent did not produce an output."A dict return that omits final_response entirely, or an object with none of the four recognized attributes, also raises ResponseShapeError. Numbers and other non-None values under final_response are coerced to str rather than rejected; only empty and None fail.
The optional messages and metadata are for rich rendering only — the run is still graded on final_response. If either is malformed, the platform drops it to null with a soft_warnings entry and grades on final_response alone; no exception is raised on your side (see gotchas).
The proxy tool-call format
Your agent doesn't execute tools itself — it forwards each call to the Odyssey proxy, which routes it to the simulator (or a live endpoint) for this run. The contract is one POST:
POST {proxy_url}/tools/{tool_name}
Authorization: Bearer {run_token}
Content-Type: application/json
{ "order_id": "4521" } ← the tool's argument objectThe request body is the tool arguments object (validated by the proxy against the tool's input_schema). A successful response is an envelope:
{
"tool_name": "get_order",
"response": { "status": "shipped", "shipped_at": "2026-04-01" },
"source": "odyssey",
"latency_ms": 412,
"matched_rule_index": null
}Extract response only — not the whole envelope. The tool_name / source / latency_ms fields are plumbing; handing them back to your model pollutes the prompt with platform internals. The SDK's proxy_call/async_proxy_call do this unwrapping for you: they return payload["response"] and nothing else.
proxy_call(name, args) reads the active envelope off the ambient ContextVar (bound with Envelope.from_env() + set_current(...)) and issues exactly this POST, so a module-level tool body reduces to one line:
from dystopic.odyssey import proxy_call
def get_order(order_id: str) -> dict:
return proxy_call("get_order", {"order_id": order_id})On a 4xx/5xx (or a network failure) the SDK raises ProxyCallError, carrying status_code, the parsed body, and a structured error_class pulled from detail.error_class — branch on error_class, not on English message strings. Retry policy, terminal statuses, and multi-agent X-Pipelines-Actor-Id attribution are covered in gotchas.
The tool-schema shape
When you register an agent, you declare its tools as a tools_schema list. Each entry is validated by the platform at registration:
{
"name": "get_order",
"input_schema": {
"type": "object",
"properties": { "order_id": { "type": "string" } },
"required": ["order_id"]
},
"description": "Look up an order by ID",
"output_schema": {
"type": "object",
"properties": { "status": { "type": "string" } },
"required": ["status"]
},
"default_execution_mode": "sandbox"
}name(required) — non-empty, unique within the list, matching^[A-Za-z_][A-Za-z0-9_\-]{0,127}$(OpenAI/Anthropic-compatible naming).input_schema(required) — a JSON Schema object. Parsed permissively, but must be an object under the platform's max nesting depth.description(optional) — string.output_schema(optional) — a JSON Schema object. Absent means the simulator's generated response may not match your tool's real shape — declare one when your agent indexes structurally into results (see gotchas).default_execution_mode(optional, defaults to Simulated) — accepts either the public vocabulary"simulated" | "executed" | "live"or the legacy stored values"sandbox" | "code_intercepted" | "passthrough"(still accepted; they're what the platform persists on the wire)."simulated"(stored"sandbox") has the world engine invent the response,"executed"(stored"code_intercepted") runs your real tool code in-sandbox with data routed to the ledger, and"live"(stored"passthrough") forwards to a bound live endpoint (requires apassthrough_binding). Absent ornullboth mean Simulated. See execution modes for the full axis and the public↔wire mapping.
The SDK's Tool(...).to_dict() emits exactly this shape; simulated(), executed(), and live() set default_execution_mode for you (intercepted() and passthrough() remain as legacy aliases of executed() and live()).
The fields above are the contract-relevant core. The full entry carries more — the passthrough_binding for Live tools, the ledger write policy and adapters, and requires_human_approval — all documented in the tools reference.
Next
You have the contract. Now wire it into a running agent:
Code mode: upload source
The run(task_input, *, proxy_url, run_token) entrypoint, Envelope.from_env, and the sandbox asyncio footgun.
Runtime gotchas
Env-mutation under concurrency, proxy retries, actor-id attribution, and silent-drop failure modes.
Wire contract & errors
The exhaustive low-level appendix: every field, proxy source value, error class, and env var.
7 · Integrate into CI/CD
Connect your repo on the platform for zero-yaml, platform-authoritative CI, wire the GitHub Action and the Dystopic App, and land a branded Dystopic check on every PR.
Code mode: upload source
Upload Python source instead of hosting a server — the run(task_input, *, proxy_url, run_token) entrypoint, Envelope.from_env(), the 50-file / 200KB / 1MB source caps, the asyncio-in-sandbox footgun, and why every code-config update re-ships the whole tree.