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

Declare the world: topology & ledger

The lookup-grade reference for the two optional world declarations — the multi-agent topology (sub_agents + topology, extraction vs. hand-authoring, actor-id grammar, declared-vs-undeclared runtime behavior) and the ledger schema ontology (canonical singular snake_case, adapter consistency, three-state update).

The declare-world walkthrough shows the happy path: extract a topology from your live agent, generate a ledger draft, ship. This page is the byte-level reference behind it — every field name, every validation rule that turns into a 422, and every runtime nuance that decides whether the platform grades your agent the way you meant it to.

Two independent declarations live here, both optional and both skippable for a single stateless agent:

  • Topologysub_agents + topology, two sibling keys inside the agent's config. Declares which sub-agents exist, what tools each owns, and who can hand off to whom.
  • Ledger schema — a top-level ledger_schema field on the agent record. A closed-world ontology of the entity types and flags the simulated world-state may contain.

The ledger schema is for simulated-tool agents only. A coding agent that operates on a real repo checkout has no simulated world-state and declares no ledger schema. Topology applies to any multi-agent system regardless of mode.

Every identifier on this page is copied from the SDK (dystopic.odyssey.topology, dystopic.odyssey.registration, dystopic.odyssey.tools). Where the SDK and the platform validators must agree, the mismatch is called out.

Part 1 — Topology

The two config keys

The topology is the declared possibility graph — what your wiring can do, authored before any run — not runtime behavior, which the proxy captures separately. It is two keys inside the agent's config:

{
  "sub_agents": [
    { "actor_id": "triage",  "tools": ["lookup_order"], "talks_to": ["refunds"] },
    { "actor_id": "refunds", "tools": ["issue_refund"] }
  ],
  "topology": { "entry": "triage" }
}

sub_agents is a list of objects; each accepts exactly three keys, and any other key is rejected:

KeyRequiredShapeNotes
actor_idyesstring labelValidated against the actor-label grammar (below). Duplicates across sub_agents are rejected.
toolsnolist of stringsEach non-empty string is trimmed; the simulator uses this to scope that sub-agent's prompt. Max 256.
talks_tonolist of labels or "*"Declared hand-off targets. "*" is the open-world wildcard.

At most 64 sub_agents entries are allowed.

topology accepts exactly three keys:

KeyShapeNotes
entrystring or list of stringsThe sub-agent(s) a run launches into. A bare string is normalized to a one-element list at validation time, so read/judge paths always see a list.
versionpositive integerOptional version tag. bool is rejected (it is an int subclass).
policy"prior" or "strict"Defaults to "prior". The default is omitted from the stored shape so the persisted JSON stays byte-identical for the common case.

topology.policy controls how the declared adjacency is treated at read time:

  • prior (default) — purely descriptive. It grounds the read-time graph reconstruction and primes the simulated world, but the platform never blocks or downgrades a run against it.
  • strict — the declared adjacency becomes a read-time-graded contract. Off-graph edges (a hand-off not present in talks_to) surface to the reviewer. A source that declares talks_to: ["*"] is exempt from edge enforcement. Even under strict, off-graph edges are graded — never a hard run failure.

Topology validation enforces shape and the shared label namespace only. It does not require talks_to / topology.entry to reference a declared actor_id — an agent may legitimately reference a sub-agent that only does out-of-band work. Such references surface at read time as declared-with-no-observed-activity in the graph, not as a validation reject.

Extraction vs. hand-authoring

You can produce the {sub_agents, topology} shape three ways.

extract_topology reads a constructed multi-agent graph straight off a framework's live objects — before any run — and returns the declared-topology slice:

from dystopic.odyssey.topology import extract_topology

root = build_root_agent()          # your entrypoint agent object
declared = extract_topology(root)  # {"sub_agents": [...], "topology": {...}}  — or {} for a single agent

The signature is:

def extract_topology(
    root: Any,
    *,
    name_to_actor: ActorResolver | None = None,
) -> dict[str, Any]:
  • root — the entrypoint agent object, the one you launch at run time. Its identity becomes topology.entry.
  • name_to_actor — optional resolver mapping a framework-native name to an actor_id (see Resolver alignment).

It is a pure object→dict transform — no network calls; only the registration wrappers do HTTP.

The cleaner move is to skip the intermediate dict and pass the live object to a registration helper as topology_from, which calls extract_topology internally:

from pathlib import Path
from dystopic.odyssey import registration

registration.create_code_agent(
    name="support-triage",
    entrypoint="run",
    source_dir=Path("./agent_src"),
    api_key="pk_live_...",     # org API key from Settings → API Keys
    topology_from=root,        # live object; extract_topology runs internally
)

To paste JSON into the dashboard editor instead, dump it from the CLI. dystopic odyssey dump-agent imports a zero-arg factory (module:attr), builds the object, and prints the payload; --section topology narrows to sub_agents + topology:

dystopic odyssey dump-agent \
  --framework openai \
  --factory app.agents:build_root_agent \
  --section topology

Paste the output into Agent → Topology → Import JSON on the web platform at https://platform.pipelines.tech.

Set sub_agents / topology one of three ways: topology_from on create_code_agent / update_code_agent, the dashboard editor (Agent → Topology → Import JSON), or a raw PUT /api/agents/{id} against https://api.pipelines.tech that carries the two keys in config:

{
  "config": {
    "sub_agents": [
      { "actor_id": "triage",  "tools": ["lookup_order"], "talks_to": ["refunds"] },
      { "actor_id": "refunds", "tools": ["issue_refund"] }
    ],
    "topology": { "entry": "triage" }
  }
}

The endpoint shallow-merges the submitted config on top of the stored one, per top-level key, so sending only sub_agents + topology preserves tools_schema and the rest. See Update semantics for the clear-a-key rule.

Which frameworks extract

extract_topology detects the framework by duck-typing, not isinstance, so the framework SDK need not be importable at extraction time. Two frameworks are wired today:

  • OpenAI Agents (agents.Agent) — matched when type(root).__module__ is "agents" or starts with "agents.", or when root structurally exposes name + tools + handoffs attributes.
  • LangGraph (compiled StateGraph / Pregel) — matched when the module is "langgraph" / "langgraph.*", or when root has a callable get_graph() plus a nodes mapping, or a callable compile() plus nodes.

An unrecognised object raises TypeError naming the supported frameworks. If the detected framework's SDK isn't importable, the adapter raises ImportError naming the missing extra. Adding CrewAI or others is additive behind the same extract_topology signature.

Single-agent extraction returns {}

If root has no declared multi-agent structure — a single agent with no handoffs — extract_topology returns an empty dict, not a sub_agents/topology pair, and nothing is folded into the config. SDK callers don't notice (it's transparent), but a raw-API user should not expect any topology key to appear for a single-agent root. This is why declaring a topology is genuinely optional — a single agent produces nothing to declare.

topology_from unconditionally overwrites

Passing topology_from is an explicit assertion of the declared topology. When extraction yields structure, sub_agents and topology overwrite anything already on the config — there is no merge with hand-authored keys:

Do not hand-author sub_agents / topology in the same call you pass topology_from — the extracted values win. Choose one source of truth: either pass the live object, or author the keys yourself and omit topology_from.

# BAD — the manual declaration is silently overwritten by extraction
config = {"sub_agents": [ ...manual... ]}
config.update(extract_topology(root))

# GOOD — let the helper own the keys
registration.build_code_agent_payload(..., topology_from=root)

The no-op is symmetric: nothing is folded when topology_from is None, and nothing is folded when extraction returns {}. So passing topology_from=None never touches existing keys.

Actor-id grammar

An actor_id (and each talks_to target, and each topology.entry) is validated by the same rules that validate the runtime actor_id header, so declared labels and runtime-stamped labels live in one namespace. A label is a /-delimited path:

RuleValue
Per-segment charset[a-zA-Z0-9_.:-]
Max segment length64 chars
Max depth (segments)8
Max total length256 chars

"supervisor/refund_worker" is a valid 2-segment label. Rejected shapes raise ActorLabelError (a ValueError subclass → clean 422): an empty segment (leading / trailing / double /), an over-length segment, more than 8 segments, or a character outside the charset. A None or empty label returns None — the single-agent shape.

The talks_to wildcard

A sub-agent may declare talks_to: ["*"], meaning "may reach any sub-agent". It's the shortcut for a hub agent — a supervisor / CEO that fans out to everyone without enumerating each peer. The wildcard is not a label, so it bypasses label validation, and under topology.policy: "strict" it exempts the source from edge enforcement:

{ "actor_id": "supervisor", "tools": [], "talks_to": ["*"] }

Declared vs. undeclared at runtime

The declared topology is a prior, never a run gate. At runtime, if a tool call carries an actor_id that is not in sub_agents, the run still succeeds — the sub-agent surfaces honestly as undeclared in the reconstructed graph rather than being guessed at. This happens when:

  • the runtime creates sub-agents dynamically (not statically constructed),
  • there is a typo between the declared actor_id and the runtime label, or
  • the topology was extracted before the agent structure changed.

The graph reconstruction unions the declared prior with runtime-observed activity and tags each node with provenance. Declared topology powers deterministic derived attribution for tool calls the runtime couldn't attribute; a runtime-observed agent absent from the declared topology is surfaced as undeclared instead of being invented.

Resolver alignment (name_to_actor)

name_to_actor maps a framework-native name (an OpenAI Agents agent name; a LangGraph node name) to an actor_id. The rule that bites:

Pass the same resolver to extraction and to your runtime hooks. If the extraction-time mapping and the run-time mapping differ, declared labels won't be byte-identical to runtime-stamped ones, and every sub-agent shows as undeclared even though it's in the topology.

resolver = {"Agent1": "agent_1", "Agent2": "agent_2"}

# extraction
topology = extract_topology(root, name_to_actor=resolver)

# registration (extraction happens internally with the same resolver)
registration.create_code_agent(..., topology_from=root, name_to_actor=resolver)

# runtime hooks — same resolver
hooks = dystopic_run_hooks(name_to_actor=resolver)

Part 2 — Ledger schema

The ontology shape

The ledger schema declares a closed-world ontology of the entity types and flags the simulated world-state may contain. It is a top-level ledger_schema field on the agent record (not inside config). Pass it as the ledger_schema argument to create_code_agent / update_code_agent, or author the same JSON in the dashboard (Agent → Ledger → Import JSON):

{
  "entities": [
    {
      "type": "order",
      "id_field": "order_id",
      "description": "Customer orders",
      "fields": [
        { "name": "status", "type": "string", "enum": ["pending", "shipped", "delivered"] },
        { "name": "amount", "type": "number" }
      ]
    }
  ],
  "flags": {
    "policy": "open",
    "values": ["warehouse_outage"]
  }
}

ledger_schema accepts exactly three top-level keys (any other is rejected): entities, flags, field_policy. Each entities object accepts type, id_field, description, fields, field_policy, key_pattern, key_patterns.

Field type must be one of the fixed set: string, number, integer, boolean, object, array.

Generating a draft

Rather than hand-write the ontology, generate a draft from your tools_schema and edit it in place:

from dystopic.odyssey import registration

draft = registration.generate_ledger_schema(
    api_key="pk_live_...",
    tools_schema=my_tools,
)
# tweak `draft` if you want, then ship it
registration.create_code_agent(
    name="support-triage",
    entrypoint="run",
    source_dir=Path("./agent_src"),
    api_key="pk_live_...",
    tools_schema=my_tools,
    ledger_schema=draft,
)

generate_ledger_schema POSTs to /api/agents/ledger-schema:generate (base URL defaults to https://api.pipelines.tech) and returns the un-persisted draft dict — the value of the response's ledger_schema key. Nothing is saved on an agent by this call; it mirrors the dashboard's generate → review → save flow. It raises AgentAPIError (with status_code / body) on non-2xx, including a 422 if the model returns an ontology that fails shape validation.

Validation rules (each is a 422)

At least one entity, or null. A non-null ledger_schema must declare entities with at least one type. An empty entities: [] is rejected — send ledger_schema: null (the default) for the fully-open ledger instead. (A zero-entity schema would look "declared" but behave identically to a null ledger, silently disabling strict mode.)

Canonical singular lower_snake_case entity types. Each type must match ^[a-z][a-z0-9_]*$order, order_item; never Order or order-item. Strict mode compares your declared type verbatim against the simulator's emitted entity_type, so a non-canonical form never matches and burns the regeneration budget.

No singular/plural collisions. Two declared types that fold onto the same canonical key under the simulator's normalizer (e.g. order + orders) are rejected as a collision. The check is on for create/draft, and relaxed for a partial update so a previously-valid agent whose persisted schema already collides isn't wedged out of editing unrelated fields — only newly introduced collisions against the persisted schema are rejected. Net effect: existing collisions are grandfathered; you can't introduce a new one.

Adapter–ontology consistency. A tool with ledger_write_policy: "adapter" for an entity op (add / update / remove) requires a non-null ledger_schema whose entities include the adapter's entity_type, matched verbatim or via the same singular/plural normalizer. Otherwise registration 422s. A set_flag adapter binds no entity and is exempt from the cross-check.

Caps. At most 100 entity types. flags.policy and every field_policy must be one of open / closed.

field_policy: open vs. closed field closure

field_policy (global on ledger_schema, or per-entity) gates strict-mode field closure and defaults to open:

  • open (default) — extra fields beyond the declared set are allowed (today's behavior).
  • closed — an add/update writing a field outside an entity's declared field set (plus its id_field) drives strict-mode regeneration. A per-entity field_policy: null inherits the schema-level policy.

Under a closed field policy, an adapter's field_map / list_append that maps an undeclared field on that entity is rejected at registration — a closed entity writes only its declared fields, so the op would be dropped at runtime whenever the field resolved. Declare the field, or set the entity's field_policy to open.

Ledger adapters: what and when

A tool carries two independent declarations. Its mode decides how the tool's response is produced — simulated() (default; the world engine answers), executed() (the tool's real code runs, its data operations hitting the ledger-backed /data plane), or live() (a bound live endpoint answers, real side effects). See execution modes for the full public↔wire mapping and the legacy intercepted() / passthrough() aliases. Its ledger write policy decides how the tool's effect on world state is recorded. By default the simulator infers what changed; a ledger adapter makes that effect declarative and deterministic — this call updates the order identified by order_id, setting status from the input — or adds / removes an entity, or flips a flag with set_flag.

Reach for a ledger adapter when a tool has a well-defined, deterministic mutation of simulated state you want recorded reliably and graded against — issue_refundorder.status, create_ticket → add a ticket, trigger_outageset_flag. Deterministic writes keep the ledger consistent across the base and head checks, so behavior-diffing and expected-outcome grading don't hinge on the simulator guessing the state change. Skip adapters for read-only tools, when you're content to let the simulator infer state, or on a fully-open ledger (ledger_schema: null). set_flag adapters are the one kind that binds no entity and needs no schema.

Two things named "adapter." A ledger adapter (this section) maps a tool call to a deterministic state mutation. A framework adapter — the OpenAI Agents / LangGraph topology extractors in Part 1 — reads a multi-agent graph to produce sub_agents + topology. Same word, unrelated machinery.

The adapter binding (SDK)

The SDK's dystopic.odyssey.tools module builds the tools_schema entries the platform validates. A tool declares a ledger adapter with tools.adapter(...):

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

Tool(
    name="issue_refund",
    input_schema={"type": "object", "properties": {"order_id": {"type": "string"}}},
    mode=live(tool_name="issue_refund", endpoint_name="billing"),
    ledger=adapter(op="update", entity_type="order", id_from="order_id",
                   field_map={"status": "status"}),
)

Tool.to_dict() serializes this to ledger_write_policy: "adapter" plus a ledger_adapter block. Entity ops (add/update/remove) require the agent to also declare a ledger_schema whose entities include this entity_type, or agent creation 422s; set_flag adapters are exempt. The full adapter grammar — every effect key, $args. path resolution, list_append, flag templates, and the multi-effect effects list — lives in the tools reference.

Update semantics

Two independent merge behaviors apply on update, and they're easy to conflate.

Config-level topology merge (sub_agents / topology, which live in config) is shallow, per top-level key. On PUT /api/agents/{id}, the submitted config merges over the stored one:

  • Sending sub_agents + topology overwrites those two and preserves everything else (tools_schema, auth, …).
  • Omitting a key leaves it unchanged.
  • Sending a key explicitly as null removes it from the stored config.

Code-mode caveat. For a code agent the config blob (which holds source_files, entrypoint, sub_agents, topology, …) is replaced wholesale on update — there is no per-key merge. So changing topology_from on a code agent means re-shipping the full source tree in the same call; the SDK's update_code_agent raises ValueError if you pass topology_from without source_dir / files.

Ledger-schema three-state (ledger_schema is a top-level payload field, not inside config). Because plain None can't distinguish "leave unchanged" from "clear it", the SDK's update_code_agent / update_http_agent omit ledger_schema from the wire entirely when you don't pass it, and put the field on the wire only when you passed something:

# 1. Omitted — sentinel; field never leaves the client; stored schema unchanged
registration.update_code_agent(...)

# 2. A dict — sets / replaces the agent's ledger_schema
registration.update_code_agent(..., ledger_schema={...})

# 3. Explicit None — clears it; reverts to the fully-open ledger
registration.update_code_agent(..., ledger_schema=None)

Declaring tools_schema and ledger_schema that disagree — an adapter tool whose entity_type isn't in the schema (or vice versa) — is rejected with 422 on update just as on create. Update the two together.

See also