dystopic docs is in beta — content is actively being added.
dystopic
Reference

Tools: port, declare & configure

The canonical per-tool reference — routing calls through the proxy, the full tools_schema entry, input/output schemas, execution modes, the passthrough binding, ledger write policy and adapters, human-approval gating, and the registration caps and 422s.

Tools are the seam the whole platform turns on. Every tool exists in two places that must line up: in your agent's code, where each call is routed through the Odyssey proxy instead of a live API, and on the platform, where its tools_schema entry tells the world engine what the tool looks like and how its calls should behave. When the two halves line up, the simulated world can answer your agent's calls, record their effects, and diff behavior base-vs-head. When they don't, the proxy has nothing to route, the simulator invents shapes your code can't parse, or a "successful" run grades against a world that never changed.

A tool's platform entry answers four independent questions:

  1. What does the call look like?input_schema / output_schema.
  2. Where does the response come from? — the execution mode (Simulated / Executed / Live).
  3. How does the call change the world? — the ledger write policy and, optionally, a declarative ledger adapter.
  4. May it run at all without sign-off? — the human-approval gate.

This page walks the lifecycle in order, then gives the full entry grammar and the registration failure catalog.

1 · Port the call through the proxy

In your agent's code, a tool body stops hitting the live API and instead POSTs to {proxy_url}/tools/{tool_name} with the per-run bearer — one line with the SDK:

from dystopic.odyssey import proxy_call

def get_order(order_id: str) -> dict:
    # Was: return live_api.orders.get(order_id)
    return proxy_call("get_order", {"order_id": order_id})

The name you pass to proxy_call — the {tool_name} URL segment — must byte-match the name of a declared tools_schema entry, or the proxy has nothing to route to. This is the single most common porting mistake. Keep the code-side name and the declared name identical, always.

The porting walkthrough is 1 · Port your agent; the POST format, retry policy, and error classes are in the dispatch contract and wire contract.

2 · Declare the tool: the tools_schema entry

Tools are declared on the agent as tools_schema, a list of per-tool entries. You author it three equivalent ways: the SDK's Tool(...) builder passed to create_code_agent(...) / update_code_agent(...), the dashboard's tools table, or the raw list on POST/PUT /api/agents. Every entry is validated at registration — the full field list:

FieldRequiredType / valuesPurpose
nameyesstring matching ^[A-Za-z_][A-Za-z0-9_\-]{0,127}$, unique in the listThe routing key. Must match the code-side proxy_call name.
input_schemayesJSON Schema objectThe tool's argument shape. The proxy validates each call's args against it.
output_schemano — strongly recommendedJSON Schema objectPins the shape of the response. In Simulated mode the world engine validates its generated response against it and regenerates on a violation.
descriptionnostringHuman-readable purpose. A blank/null description is dropped, never stored as null.
default_execution_modenosimulated (default) | executed | live (legacy stored values sandbox / code_intercepted / passthrough also accepted)Where the response comes from. See execution modes.
passthrough_bindingonly for live{ endpoint_id | endpoint_name, tool_name }Which live ToolEndpoint (and which of its tools) answers a Live call. Required when the mode is live.
ledger_write_policynonone | record_only (default) | adapterWhether a Live call's response mutates the simulated world-state ledger.
ledger_adapteronly for adaptersingle effect or { "effects": [...] }The declarative state mutation this call performs. Required when (and only valid when) ledger_write_policy is adapter.
requires_human_approvalnoboolean (default false)Gate every call to this tool behind an approval decision.

Normalization quirks worth knowing: an unknown key on an entry is a 422 naming the allowed set; null for default_execution_mode / ledger_write_policy / requires_human_approval means "use the default", so form-driven clients can send explicit nulls safely.

Caps (each violation is a 422): at most 128 tools per agent; schemas nest at most 24 levels deep; each entry serializes to at most 64 KB; the whole tools_schema to at most 1 MB; duplicate names are rejected.

Input and output schemas

input_schema is required — it is the contract the proxy validates every call's arguments against. output_schema is optional but load-bearing in a simulated world: without it, nothing pins the shape of the response the LLM-backed simulator invents, and agent code that indexes structurally (result["orders"][0]["id"]) can KeyError on a perfectly "successful" simulated call. With it, the simulator validates and regenerates until the payload conforms. Declare one for every tool whose result your code destructures — and parse defensively anyway; see output-schema safety.

3 · Choose the execution mode

The mode answers exactly one question — where does this tool's response come from?

ModeSDK helperThe response comes from
Simulated (default)simulated()The world engine invents it from world state + output_schema. No real calls.
Executedexecuted()The tool's real code runs in the sandbox; its data operations hit the ledger-backed /data plane.
Livelive(tool_name=..., endpoint_id | endpoint_name=...)A bound live ToolEndpointreal side effects.

Default to Simulated; reach for Executed when the tool's own logic (not just its data) is what you're testing; reserve Live for tools whose real effects you deliberately want, with eyes open. The full axis — the public↔wire vocabulary mapping, the legacy intercepted()/passthrough() aliases, per-scenario override rules — is the execution-modes reference.

The passthrough_binding (Live tools only)

A Live tool must say which endpoint answers it. A ToolEndpoint is an org-level object (dashboard Tool endpoints, or the dystopic tool-endpoints CLI group) that fronts a real API and exposes named tools. The binding on the entry takes one of two equivalent forms:

{ "endpoint_id": "<uuid>", "tool_name": "issue_refund" }
{ "endpoint_name": "billing", "tool_name": "issue_refund" }

endpoint_name is resolved to the UUID at registration (endpoint names are unique per org), and the persisted entry keeps both — so re-PUTting an already-registered tools_schema is idempotent. The router also cross-checks that the endpoint is visible to your org and actually exposes tool_name. The live(...) SDK helper builds the binding for you and requires keyword args — a bare live() raises ValueError.

4 · Decide how the tool writes the ledger

The mode decides where the response comes from; ledger_write_policy decides how a Live call's effect on world state is recorded — the simulated world's ledger is what behavior-diffing and expected-outcome grading read, so a live call that changes the real world but not the ledger is invisible to grading. (Simulated tools mutate the ledger through the world engine itself, and Executed tools write through the /data plane — the policy is a Live-call concern.)

PolicyEffect on the ledger
record_only (default)The call and its response are persisted to the trace, but the ledger is never mutated.
noneNo ledger writer runs at all (the trace observation still persists).
adapterThe declarative ledger_adapter runs against the live response and applies its derived ops to the ledger.

The ledger_adapter grammar

An adapter makes a tool's state effect declarative and deterministic — "this call updates the order identified by order_id, setting status" — instead of asking the simulator to infer what changed. When to reach for one (and when not to) is covered in declare-world; this is the full shape.

A single-effect adapter:

{
  "op": "update",
  "entity_type": "order",
  "id_from": "$args.order_id",
  "field_map": { "status": "status" },
  "list_append": { "refund_ids": "refund.id" },
  "flags": ["refund_issued:{order_id}"]
}
KeyMeaning
opadd | update (default) | remove | set_flag. Entity ops create/mutate/delete one entity; set_flag flips named world flags and binds no entity.
entity_typeThe ontology entity the op targets. Required for entity ops; must exist in the agent's ledger_schema.entities (matched verbatim or via the singular/plural normalizer) — a hard 422 otherwise. Forbidden on set_flag.
id_fromPath to the entity id. Required for entity ops. Paths resolve against the tool's response; prefix with $args. to resolve against the request arguments instead.
field_map{ declared_field: path } — set each declared field from the resolved path.
list_append{ declared_field: path } — append the resolved value to a list field instead of replacing it.
flagsList of flag templates (e.g. "refund_issued:{order_id}"), interpolated from the call. Required non-empty for set_flag; optional alongside entity ops.

A multi-effect adapter wraps up to 10 effects — one tool call writing several related entities (a booking → the reservation, the user's reservation list, a payment record):

{ "effects": [ { "op": "add", "entity_type": "reservation", ... },
               { "op": "update", "entity_type": "user", ... } ] }

The two shapes are mutually exclusive — effects may not sit next to top-level effect keys. Under a closed field_policy, a field_map/list_append that targets an undeclared field on that entity is rejected at registration (see field closure).

5 · Gate the tool behind human approval

Set requires_human_approval: true and every call to this tool is intercepted at the proxy and held for an approval decision before it may execute. How the decision is produced is a run-level knob, not part of the tool: the human_approval_policies config resolves each gated call as none (gate off), seeded_policy (always_approve / always_deny / a fixed sequence), or odyssey_simulated (an LLM plays the approver). On suite and CI runs, wiring any gated tool with no explicit policy stamps the odyssey_simulated default — gated tools face a simulated approver, never a silently bypassed gate. Only an approve decision lets the call execute; deny / timeout / needs_more_info block it, and every decision lands in the trace.

Gate the tools whose real-world counterparts need sign-off — refunds over a threshold, destructive mutations, outbound messages — so the check exercises the same approval seam production has. Full mode/policy/decision semantics: human-approval gates.

The SDK builder, end to end

dystopic.odyssey.tools builds validated entries. A realistic three-tool declaration:

from dystopic.odyssey.tools import Tool, simulated, executed, live, adapter

tools = [
    # A read — simulated, with the response shape pinned.
    Tool(
        name="get_order",
        description="Look up an order by id",
        input_schema={"type": "object",
                      "properties": {"order_id": {"type": "string"}},
                      "required": ["order_id"]},
        output_schema={"type": "object",
                       "properties": {"status": {"type": "string"},
                                      "total": {"type": "number"}},
                       "required": ["status"]},
        mode=simulated(),          # optional — the default
    ),
    # Real tool logic under test — runs in-sandbox against the simulated world.
    Tool(
        name="compute_discount",
        input_schema={"type": "object", "properties": {"order_id": {"type": "string"}}},
        mode=executed(),
    ),
    # A live write — bound to a real endpoint, its world effect recorded declaratively.
    Tool(
        name="issue_refund",
        input_schema={"type": "object",
                      "properties": {"order_id": {"type": "string"}},
                      "required": ["order_id"]},
        mode=live(tool_name="issue_refund", endpoint_name="billing"),
        ledger=adapter(op="update", entity_type="order",
                       id_from="$args.order_id", field_map={"status": "status"}),
    ),
]

# tools_schema=[t.to_dict() for t in tools] on create_code_agent / update_code_agent

The Tool builder covers name, description, both schemas, the mode, and the adapter. Two entry fields it does not expose — requires_human_approval and an explicit ledger_write_policy of none/record_only — are set on the serialized dict (entry = t.to_dict(); entry["requires_human_approval"] = True) or from the dashboard tools table.

Importing tools from MCP

If your agent's tools already live behind MCP servers, odyssey mcp introspects them into a ready tools_schema, and odyssey sync pushes a wrapper's tools_schema to the platform without re-registering the whole agent. Both require the [odyssey] extra — see the CLI reference.

Registration failure catalog

Each of these is a 422 at agent create/update, naming the offending tool:

ErrorCause
requires a non-empty 'name' / name-pattern / duplicate-nameMissing, invalid, or repeated tool name.
requires an 'input_schema'Entry with no input schema.
has unknown fields: [...]A key outside the allowed entry fields (typo detector).
default_execution_mode='passthrough' requires a 'passthrough_binding'Live mode with no endpoint binding.
ledger_write_policy='adapter' requires a 'ledger_adapter'Adapter policy with no adapter declared.
declares a 'ledger_adapter' but ledger_write_policy=...Adapter declared without setting the policy to adapter.
op='<entity op>' requires a non-empty 'entity_type' / 'id_from'Entity op missing its binding keys.
op='set_flag' requires a non-empty 'flags' list / must not set 'entity_type'Malformed flag adapter.
adapter entity_type not in ledger_schema.entitiesEntity op against an undeclared ontology type (see adapter–ontology consistency).
entry / schema / list over a cap> 128 tools, > 24 schema depth, > 64 KB entry, > 1 MB total.