dystopic docs is in beta — content is actively being added.
dystopic
ReferencePort your agent

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.

Code mode is how you port an agent: you hand the platform your Python source files and it runs them for you in an isolated E2B sandbox — no endpoint to host, no tunnel, no inbound auth. The platform installs your requirements, injects the per-run proxy URL and token as environment variables, and calls one entrypoint function per task.

This page is the reference for that path: the entrypoint signature the sandbox calls, how to reconstruct the run context from the injected env vars, the caps your source tree has to fit under, and the two footguns that only exist in code mode — the running event loop inside the sandbox, and the wholesale config replace on every update.

On the wire, code mode ships as mode: "sandbox" — the SDK's create_code_agent(...) sets this for you when you register, so you never write the wire value by hand. This page uses "code mode" for the developer-facing concept throughout. See Register the agent for the full registration flow.

The entrypoint

The sandbox invokes one top-level callable per task. Its signature is fixed — proxy_url and run_token are keyword-only:

def run(task_input, *, proxy_url: str, run_token: str) -> dict:
    # task_input : the task's current_state (a dict), or {}
    # proxy_url  : POST tool calls to {proxy_url}/tools/{tool_name}
    # run_token  : the per-run bearer for those calls
    return {
        "final_response": "The refund of $79.50 has been issued for order #4521.",
        # "messages": [...],   # optional rich transcript
        # "metadata": {...},   # optional
    }

The return contract: final_response is required and must be non-empty (an empty string is rejected), with optional messages and metadata. See the dispatch contract for the full response shape.

The function name and the file it lives in are declared at registration time as entrypoint and entrypoint_file. The name defaults to run in a main.py, but any single Python identifier in any .py file works — entrypoint must be a bare identifier, not a dotted path.

Reconstructing the run context: Envelope.from_env()

There is no inbound request to parse — the platform injects the run context as environment variables before calling your entrypoint. Envelope.from_env() reads them back into the Envelope object the rest of the SDK (proxy_call, ScopedProxy, the framework adapters) expects:

from dystopic.odyssey import Envelope

def run(task_input, *, proxy_url, run_token):
    env = Envelope.from_env()
    # env.proxy_url  == proxy_url   (trailing slash stripped)
    # env.run_token  == run_token
    ...

from_env() reads exactly two required variables — a missing one raises KeyError:

Env varMaps to
DYSTOPIC_ODYSSEY_PROXY_URLEnvelope.proxy_url (trailing / stripped)
DYSTOPIC_RUN_TOKENEnvelope.run_token

and three optional correlation ids (safe to log — the only secret on the wire is the run token):

Env varMaps to
DYSTOPIC_RUN_TOKEN_JTIEnvelope.run_token_jti
DYSTOPIC_RUN_IDEnvelope.run_id
DYSTOPIC_TASK_IDEnvelope.task_id

Envelope.from_env() does not populate user_instruction or task_input — those come from the entrypoint's task_input argument, not the environment (from_env() sets user_instruction="" and task_input={}). The env-var path exists purely to hand proxy_call and friends the proxy URL and bearer; the task payload arrives through the function arguments.

Two more env vars the platform sets are worth knowing but are not read by from_env(): DYSTOPIC_API_URL (the origin of the proxy URL) and DYSTOPIC_AGENT_ID. On the code-mode dispatch path the entrypoint signature has no agent_id to put on an Envelope, so the SDK's subprocess-env helper deliberately leaves an already-injected DYSTOPIC_AGENT_ID untouched rather than blanking it — see make_subprocess_env if you shell out from inside the sandbox.

Source-file caps

When you register a code agent from a directory or a file dict, the SDK walks it into a {relative_posix_path: source_text} mapping and validates it client-side before the HTTP round-trip, with the same caps the platform enforces. Three limits apply:

CapLimit
File count50 files
Per file200,000 bytes (200 KB, UTF-8 encoded)
Total1,000,000 bytes (1 MB, UTF-8 encoded)

Alongside the size caps, the walker enforces the file shape:

  • .py only. Non-Python files are rejected; there is no way to ship a data file or a requirements.txt as source (requirements is a separate config field).
  • Relative POSIX paths. Keys must match ^(?:[A-Za-z0-9_][A-Za-z0-9_\-]*/)*[A-Za-z0-9_][A-Za-z0-9_\-]*\.py$. Backslashes are rewritten to forward slashes and a leading ./ is stripped, but absolute paths, .. segments, Windows drive-letter prefixes (C:\...), and hidden/dotfile segments are rejected up front — so a bad path fails on your machine, not after upload.
  • No empty files except an empty __init__.py (a legitimate package marker).
  • Case-insensitive collision check. Main.py and main.py in the same tree are rejected, because a macOS or Windows sandbox would collapse them.

Directory walks skip a built-in ignore set (__pycache__/, *.pyc, .git/, .venv/, venv/, env/, .pytest_cache/, .mypy_cache/, .ruff_cache/, .tox/, *.egg-info/, build/, dist/, node_modules/). Add your own patterns with a .dystopicignore file at the source-dir root — its globs are appended to the built-in set.

The caps are inline-upload packaging limits, not agent-size limits — they bound the source tree the SDK ships in the registration payload. If your tree genuinely doesn't fit, trim your ignore globs (a .dystopicignore at the source root) first; if it still won't fit, connect the repo so CI clones the full checkout for each side instead of uploading inline source. A too-big tree surfaces as a ValueError at build time with the exact byte count.

Registering a code agent

You upload the source through the SDK, not from a repo config file. create_code_agent(...) walks your source into the inline {path: source} mapping, validates it against the caps above, builds the POST /api/agents body, and ships it — all in one call against the SDK base URL https://api.pipelines.tech:

from dystopic.odyssey.registration import create_code_agent

agent = create_code_agent(
    name="refund-agent",
    entrypoint="run",
    entrypoint_file="main.py",
    source_dir="./agent",              # walked into the inline source mapping
    requirements=["httpx>=0.27", "anthropic"],
    python_version="3.12",
    tools_schema=[
        {
            "name": "get_order",
            "input_schema": {
                "type": "object",
                "properties": {"order_id": {"type": "string"}},
                "required": ["order_id"],
            },
        },
    ],
    api_key=key,
)

Instead of a directory, pass the files inline as a {relative_posix_path: source_text} dict — the same shape the walker produces, useful for a single-file agent or a generated tree:

create_code_agent(
    name="refund-agent",
    entrypoint="run",
    entrypoint_file="main.py",
    files={"main.py": open("src/main.py").read()},
    requirements=["httpx>=0.27"],
    api_key=key,
)

entrypoint defaults to run and entrypoint_file to main.py when omitted. requirements are pip specifiers (no flags, no env markers). python_version is optional and the platform pins 3.93.13. The lower-level build_code_agent_payload(...) returns the same POST /api/agents body without sending it, if you want to inspect or route the payload yourself.

For the full registration flow — the dashboard path, publishing, and the secondary dystopic agents push route from a minimal manifest — see Register the agent.

The code-mode registration path needs the optional odyssey extra: pip install 'dystopic[odyssey]'. Building a code-agent payload imports the registration helpers, so a bare [cli] install raises a clean, actionable error rather than an ImportError.

Footgun: no asyncio.run() in the sandbox

The E2B sandbox already runs your entrypoint inside a live event loop. Calling asyncio.run(coro) from there raises:

RuntimeError: asyncio.run() cannot be called from a running event loop

This bites any agent that reaches for an async LLM client or async_proxy_call from a synchronous run(). The escape is to spawn a fresh thread — which has no running loop — and call asyncio.run() there.

There is a second, quieter half to this: ContextVars are thread-local. If you use the SDK's ambient-envelope path (Envelope.from_env() + proxy_call), the envelope you bind on the main thread is not visible inside the worker thread. You have to re-bind it inside the thread's target with set_current(...):

import asyncio
import threading
from dystopic.odyssey import Envelope, async_proxy_call
from dystopic.odyssey.context import set_current

def run(task_input, *, proxy_url, run_token):
    def _target(out):
        # Re-bind the envelope INSIDE the thread: ContextVars are thread-local,
        # so a binding on the caller's thread won't reach here.
        with set_current(Envelope.from_env()):
            out["result"] = asyncio.run(_do_work(task_input))

    out = {}
    t = threading.Thread(target=_target, args=(out,))
    t.start()
    t.join()
    return out["result"]

async def _do_work(task_input):
    order = await async_proxy_call("get_order", {"order_id": task_input["order_id"]})
    # ... run the agent ...
    return {"final_response": "..."}

set_current(Envelope.from_env()) must run inside _target, not in run() before starting the thread. Bind it on the wrong thread and every proxy_call inside the coroutine raises LookupError: No active Dystopic envelope on this context.

If you never use the ambient ContextVar path — passing proxy_url and run_token explicitly to your own HTTP calls — the fresh-thread rule still applies to asyncio.run(), but the set_current binding is irrelevant.

Footgun: code-config updates replace the whole config

The platform's update endpoint (PUT /api/agents/{id}) replaces a code agent's config blob wholesale whenever the request carries config — it does not merge a partial source tree into the previous one. The validator requires the full source_files / entrypoint_file / entrypoint shape and rejects anything partial with 422.

The practical rule: any code-config change re-ships the entire source tree. These fields all land inside config, so changing any one of them forces a full re-upload in the same call — source_dir / files, entrypoint, entrypoint_file, requirements, python_version, topology_from, execution_profile.

from pathlib import Path
from dystopic.odyssey import registration

# WRONG — bumps requirements without source; the platform would replace the
# config blob and drop the old source. The SDK catches this first:
#   ValueError: update_code_agent received code-config field(s) ['requirements']
#   without 'source_dir' or 'files'. ...
registration.update_code_agent(agent_id=42, requirements=["new-lib"], api_key=key)

# RIGHT — re-ship the full tree alongside the changed field:
registration.update_code_agent(
    agent_id=42,
    requirements=["new-lib"],
    source_dir=Path("./agent"),   # the whole tree, every time
    api_key=key,
)

Two consequences follow:

  • Non-config fields are partial-safe. description, tools_schema, ledger_schema, concurrency_cap, and run_timeout_s live outside config; updating one of those alone leaves the source untouched. Only config fields trigger the wholesale replace.
  • Omitted entrypoint fields are preserved, not defaulted. If you re-ship the tree but leave entrypoint / entrypoint_file out, update_code_agent reads the current agent row and reuses its stored values rather than silently resetting them to run / main.py — so a non-default entrypoint survives a requirements bump.

Do not carry an "I can PATCH one field" habit into code agents: for a code agent, a partial config is a data-loss bug that the SDK stops before it reaches the wire.

Next

The last piece is the set of runtime behaviors that are correct at low volume and wrong under load — read Runtime gotchas before you register.