Skip to main content

Dystopic SDK

PyPI

Python client, CLI, and agent-hosting toolkit for the Dystopic platform. Anything you can do in the Dystopic UI you can script from Python or the dystopic CLI — and you can host your own agent behind a Dystopic dispatch endpoint with a few lines of code.

Highlights:

  • Sync DystopicClient + async AsyncDystopicClient (mirrored surface) covering the full Dystopic REST API.
  • Typed HTTP errors per status code (AuthenticationError, ForbiddenError, NotFoundError, ConflictError, ValidationError, RateLimitError, ServerError).
  • Auto-pagination via iter_* helpers and a generic paginate().
  • Streaming primitives and polling waiters (wait_for_experiment, wait_for_create_tasks, wait_for_seed_from_dataset, wait_for_export_tasks).
  • Agent hosting under the [odyssey] extra (the deprecated [agents] alias still resolves): a one-decorator FastAPI dispatch route, framework adapters (Anthropic, OpenAI Agents, LangChain, Strands), and the dystopic odyssey CLI group.
  • py.typed marker — mypy / pyright respect the SDK's annotations.

Install

pip install dystopic

# Agent hosting (FastAPI dispatch route, registration helpers, CLI group)
pip install 'dystopic[odyssey]'

# Agent hosting plus a framework adapter (the adapter extra implies [odyssey])
pip install 'dystopic[odyssey,anthropic]'

Adapter extras: [anthropic], [openai-agents], [langchain], [langchain-mcp], [strands], [mcp]. Each one pulls in [odyssey] automatically.

Projects scaffolded with odyssey scaffold include a register.py that loads .env before reading registration variables, so local .env files work the same way as the CLI.

The LangChain adapter drops framework-injected config, callbacks, and run_manager parameters from proxied tool payloads. LangGraph topology extraction folds common helper nodes such as enter_*, *_safe_tools, and *_sensitive_tools into the owning assistant node.

Quickstart — REST client

Authenticate with an API key created in the Dystopic web app. The SDK reads DYSTOPIC_API_KEY from the environment, or you can pass api_key= directly.

from dystopic import DystopicClient

client = DystopicClient(api_key="pk_live_...")  # or rely on DYSTOPIC_API_KEY

# Auto-paginate every project the key can see
for project in client.iter_projects(page_size=50):
    print(project["id"], project["name"])

# Create tasks on a workflow
client.create_tasks(project_id=22, workflow_id=10, payload={"count": 1})

# Wait for an experiment to finish
status = client.wait_for_experiment(
    project_id=22, workflow_id=10, experiment_id="exp-1",
    poll_interval=2.0, timeout=600.0,
)

Async usage mirrors the sync surface:

import asyncio
from dystopic import AsyncDystopicClient

async def main():
    async with AsyncDystopicClient(api_key="pk_live_...") as client:
        me = await client.whoami()
        async for project in client.paginate("/api/projects", page_size=50):
            print(project["id"])

asyncio.run(main())

Typed errors let you branch on the HTTP status without parsing bodies:

from dystopic import (
    AuthenticationError, ForbiddenError, RateLimitError,
)

try:
    client.update_prompt(7, {"name": "..."})
except RateLimitError as exc:
    sleep(exc.retry_after or 1.0)
except (AuthenticationError, ForbiddenError):
    refresh_credentials()

The SDK defaults to https://api.dystopic.ai. Point elsewhere with DYSTOPIC_BASE_URL=<url>, the CLI's --base-url flag, or base_url= — the base URL saved at dystopic auth login is reused for later commands.

See API_REFERENCE.md for the full method inventory.

Hosting an agent

With dystopic[odyssey] installed you can host your own agent behind a Dystopic-compatible POST /dispatch endpoint. register_dispatch_route handles inbound auth, the reachability probe, envelope parsing, and response shaping; proxy_call is the HTTP shim your tool bodies use to reach the platform mid-run.

from fastapi import FastAPI
from dystopic.odyssey import register_dispatch_route, proxy_call

app = FastAPI()

@register_dispatch_route(app, agent_token_env="AGENT_TOKEN")
async def run(envelope):
    # envelope.user_instruction is the inbound prompt; proxy_call reaches
    # the platform (tools, context) for the duration of this run.
    result = await build_and_run_agent(envelope.user_instruction)
    return result  # a str → {"final_response": ...}, or return a dict/envelope

The decorated handler can return a str (treated as final_response), a dict with at least {"final_response": "..."}, or any object exposing a final_output / final_response / output attribute. Framework adapters under dystopic.odyssey.adapters wrap Anthropic, OpenAI Agents, LangChain, and Strands runners into this shape. The LangChain adapter's dystopic_proxy handles Tool / StructuredTool and plain callables, and also BaseTool subclasses without a func slot — the shape of most langchain_community tools (DuckDuckGoSearchRun, WikipediaQueryRun, ...):

from langchain_community.tools import DuckDuckGoSearchRun
from dystopic.odyssey.adapters.langchain import dystopic_proxy

search = dystopic_proxy()(DuckDuckGoSearchRun())  # proxied in-run, real locally

Customer-hosted HTTP agents

external_http is an authoring model, not a containment guarantee. Dystopic dispatches to your public /dispatch endpoint, and the platform labels the agent live_uncontained: your process, subprocesses, sockets, browser sessions, and kernels run on your own infrastructure.

from dystopic.odyssey.registration import build_http_agent_payload

# Customer-hosted endpoint: supported, but live/uncontained.
customer_hosted = build_http_agent_payload(
    name="refund-agent-live",
    endpoint_url="https://agents.example.com/refund/dispatch",
    execution_profile={
        "runtime": "customer_hosted",
        "containment": "live_uncontained",
        "egress": {"dns": "provider_default"},
    },
)

Tool returns are model-facing payloads

In sandbox mode, proxy_call (and every adapter-wrapped tool) returns the platform simulator's response verbatim. That response is generated by an LLM for the model's benefit — by default nothing guarantees it matches the shape your real tool implementation returns. Agent code that consumes a proxied tool result structurally (a LangGraph seed/bootstrap node indexing into the payload, a handler doing result["orders"][0]["id"]) must either parse defensively or declare an output_schema for the tool.

A declared output_schema is a contract, not a hint: the simulator validates its tool_response against it and regenerates on violation, so conforming shapes are safe to consume from code. If the simulator cannot conform within its retry budget the tool call fails with a structured error payload instead of returning a malformed result.

from dystopic.odyssey.adapters.langchain import dump_tools_schema, proxy_tool

ORDER_SCHEMA = {
    "type": "object",
    "properties": {"id": {"type": "string"}, "status": {"type": "string"}},
    "required": ["id", "status"],
}

# Tools you build yourself: declare it at construction.
get_order = proxy_tool(
    "get_order",
    description="Look up an order by id.",
    args_schema=GetOrderArgs,
    output_schema=ORDER_SCHEMA,
)

# Tools you don't construct (community tools): pass a mapping at export time.
tools_schema = dump_tools_schema(tools, output_schemas={"get_order": ORDER_SCHEMA})

Declaring tools

Tool builds one validated entry of the platform's tools_schema from Python, so you don't hand-author the JSON. Each tool picks one of three execution modes:

  • simulated() — the default. The tool runs as an Odyssey-mocked (PWSA-simulated) call: the platform synthesises the response from the scenario, your tool body never runs live.
  • passthrough(...) — the call is forwarded to a live ToolEndpoint. Pass tool_name plus one of endpoint_id / endpoint_name.
  • native — your agent's own un-proxied code. Nothing to declare; just don't route it through Odyssey.
from dystopic.odyssey import Tool, simulated, passthrough, adapter

tools = [
    # 1. Simulated (default) — Odyssey mocks the response.
    Tool(
        name="get_consensus_rating",
        description="Analyst consensus for a ticker",
        input_schema={
            "type": "object",
            "properties": {"ticker": {"type": "string"}},
            "required": ["ticker"],
        },
        mode=simulated(),
    ),
    # 2. Passthrough — forwarded to a live ToolEndpoint, with a declarative
    #    ledger adapter that records each call into the simulated world-state.
    Tool(
        name="save_analysis",
        input_schema={
            "type": "object",
            "properties": {
                "ticker": {"type": "string"},
                "recommendation": {"type": "string"},
            },
        },
        mode=passthrough(endpoint_name="mcp:analysis", tool_name="save"),
        ledger=adapter(
            op="add",
            entity_type="analysis",
            id_from="$.ticker",
            field_map={"recommendation": "$.recommendation"},
        ),
    ),
]

Wire the list straight into any registration / payload builder that takes tools_schema — they accept list[Tool] (each is serialized via Tool.to_dict()) as well as raw dicts:

from dystopic.odyssey import registration

payload = registration.build_http_agent_payload(
    name="analyst-agent",
    endpoint_url="https://my-agent.example.com/dispatch",
    tools_schema=tools,
)
# …or build_code_agent_payload(...) / create_http_agent(...) /
# create_code_agent(...) / generate_ledger_schema(...) — same tools_schema arg.

A ledger=adapter(...) with an entity op (add / update / remove) REQUIRES a matching ledger_schema whose entities include that entity_type — a mismatch is not caught locally; it surfaces as a create-time 422 from the platform's validate_adapter_ontology_consistency check (set_flag adapters are exempt). Draft the ontology with registration.generate_ledger_schema(...), edit it, and pass it as ledger_schema= in the same call.

Refusals the platform decides (preconditions)

Tool(preconditions=[...]) declares the refusals the platform answers from the world before the simulator is called, so the model stops deciding them. Each rule is a structured gate:

Tool(
    name="exchange_delivered_order_items",
    input_schema=EXCHANGE_ARGS,
    preconditions=[
        {
            # Which entities the rule is about: the ones THIS CALL named.
            "entity_type": "item",
            "id_from": {"param": "new_item_ids"},
            # Polarity: "match" = every addressed entity satisfies `where`;
            # "no_match" = none does.
            "require": "match",
            "where": [{"field": "available", "cmp": "eq", "value": {"const": True}}],
            # What the agent reads when the gate refuses.
            "error": {"message": "New item not found or available", "code": 400},
        }
    ],
    simulator_instructions=(
        "Price differences settle against payment_method_id; refunds to the "
        "original method take 5-7 business days."
    ),
)

The filter grammar is the ledger_read one verbatim — same comparators, same {"param": ...} / {"const": ...} wrapped values — minus the fuzzy text lanes (contains / icontains / ilike): a substring match deciding a refusal denies the agent an action it was entitled to and is scored against the agent, which is a different risk class from one deciding a projection. Rules that matter, all of them the opposite of a plausible default:

  • id_from is required and is its own slot, not another where cond on the id. It is what lets the evaluator tell "entity absent" from "predicate false"; collapsing those is the wrong-entity-attribution bug this surface exists to remove.
  • require is required, with no default. A refusal rule that has not said which way it points is not finished.
  • where may be omitted only with require: "match", where it means "the addressed entity must exist". Preconditions fail closed: an absent entity refuses.
  • error.message is required (cap 1 000 chars) and error.code defaults to 400. error.response, when set, is returned as the tool response body verbatim — use it to reproduce a domain's own error shape (e.g. a bare string under error) rather than the platform's envelope.
  • At most 16 rules per tool.

Nothing is validated locally. The dict crosses the wire untouched and the platform cross-checks it against the agent's ledger_schema (entity type, fields, and every bound argument must be declared, and bound args must be required in the tool's input_schema) — a mismatch is a create-time 422, not a silent no-op. A refusal costs zero simulator tokens, moves nothing in the world, and records with its own precondition trace source.

simulator_instructions is the residue channel beside it: free prose addressed to the simulator for rules the grammar cannot yet express (arithmetic, nested traversal, arity). It is rendered beside its tool in the simulator's cached system prefix and stripped from every agent-facing dispatch payload — unlike description, which is agent-facing by design and therefore the wrong place to have been writing refusal rules. A scenario's behavior_instructions take precedence on conflict. Cap 4 000 characters. Prefer a precondition wherever the rule can be declared: a declaration is enforced before the model is called, prose is only ever a request the model may ignore.

In-sandbox tool calls

For a BYO in-sandbox / code-mode agent (a script the platform runs directly in the run's sandbox, not a hosted /dispatch endpoint), there's no inbound envelope to parse — the platform injects the proxy URL and run token as DYSTOPIC_* env vars instead. Envelope.from_env() reads them, and proxy_call_with reaches the proxy:

from dystopic.odyssey import Envelope, proxy_call_with

env = Envelope.from_env()  # reads DYSTOPIC_ODYSSEY_PROXY_URL + DYSTOPIC_RUN_TOKEN
order = proxy_call_with(env, "get_order", {"order_id": "4521"})
print(order)

Envelope.from_env() raises KeyError if either load-bearing var (DYSTOPIC_ODYSSEY_PROXY_URL, DYSTOPIC_RUN_TOKEN) is missing; the correlation ids (DYSTOPIC_RUN_TOKEN_JTI, DYSTOPIC_RUN_ID, DYSTOPIC_TASK_ID) are optional. A hosted handler reads the same fields off the parsed envelope, so the plain proxy_call(name, args) (which pulls the active envelope from the request ContextVar) is the equivalent there.

Fetching seeded context (RAG)

Some ported agents don't call a search tool — they inject a knowledge base into the prompt, or a framework pre-retrieves before the LLM runs. There's no tool call to hang capture on, so fetch_context pulls a seeded context store (declared per world variant under _context_stores) by reference through the proxy and records the retrieval:

from dystopic.odyssey import fetch_context

docs = fetch_context("handbook", query="reset password", limit=5)
# -> [{"doc_id": "kb-12", "chunk": "To reset your password..."}, ...]

Exactly one selector is required: query= (lexical over the store's content_field), key= (one doc by id), or slice= (the whole store — True or a positive int cap). Variants mirror the proxy_call family: fetch_context_with(envelope, ...) (explicit envelope for BYO in-sandbox scripts), async_fetch_context / async_fetch_context_with, and the best-effort safe_fetch_context / async_safe_fetch_context.

When the port runs its own vector pipeline, pull the corpus with fetch_context(store, slice=True), run the retriever, and report the ids back so the platform records which seeded docs were surfaced:

from dystopic.odyssey import fetch_context, record_context_retrieval

corpus = fetch_context("handbook", slice=True)
hits = my_retriever(corpus, "how do I reset my password?")
record_context_retrieval("handbook", [h.doc_id for h in hits])   # ids or {doc_id, score?}

Risk asymmetry. A swallowed fetch means the agent reasons over empty context, so prefer the raising fetch_context when retrieval is load-bearing. A dropped record only loses observability, so safe_record_context_retrieval is the low-risk default there. Failures raise ContextFetchError (.status_code / .body / .store / .error_class); both helpers share proxy_call's retry policy (429 + retryable 503, 4 attempts, exponential backoff, 30 s default timeout). Envelope.context_stores exposes the stores the world seed declared ({name, entity_type, access_modes}) when the dispatch provided them. Full contract: https://docs.dystopic.ai/docs/reference/context-plane.

CLI

# Core: auth and resource management
dystopic auth login --api-key "pk_live_..."   # or export DYSTOPIC_API_KEY
dystopic whoami
dystopic projects list
dystopic workflow list --project-id 22
dystopic datasets export --dataset-id 123 --format csv --output dataset.csv

# Agents: create, run locally, and register — everything agent-related is `odyssey …`
odyssey init                                           # interactive wizard → registers the agent
odyssey scaffold --framework anthropic --dir my-agent  # scaffold a hostable wrapper project
odyssey dev --agent-id 42 --serve-app my_agent.app:app # tunnel a local wrapper to the platform
odyssey publish 412                                    # activate a draft agent
odyssey versions --agent-id 42                         # inspect version snapshots
odyssey versions --agent-id 42 --from-version 1 --to-version 3  # diff versions
odyssey versions --agent-id 42 --from-version 1 --color always  # force colored diff output

Agent commands live under the odyssey namespace (equivalently dystopic odyssey …). The old top-level dystopic init / dystopic agents … / dystopic test spellings now print a one-line redirect to their odyssey equivalent.

There is no project config file. Agents, scenarios, suites, and the CI gate are platform state; the CLI names them by id or by name and the server resolves them. Nothing about a run is read out of your repository.

The core CLI autoloads a .env file if present (--no-dotenv opts out). Config is saved at ~/.config/dystopic/config.json with owner-only permissions; in CI prefer DYSTOPIC_API_KEY over a checked-in config file.

Testing agents from the terminal

The test-loop commands live behind the optional [cli] extra (pulls in rich for live progress and questionary for prompts):

pip install 'dystopic[cli]'

odyssey test creates a test run from a CSV of seeds and watches it to completion, then prints a summary and exits with a CI-friendly code:

# Create + watch a run, streaming live progress
odyssey test tests/seeds.csv --agent 412

# CI gate: fail (exit 1) if the pass rate drops below 90%
odyssey test tests/seeds.csv --agent 412 --min-pass-rate 0.9 --display plain

# Reattach to a run you started earlier (no FILE/--agent needed)
odyssey test --attach 1187

Use --display plain (line-oriented) or --display none (summary only) in CI where the rich live view isn't wanted; --poll-interval and --timeout tune the watch loop.

List, inspect, and export past runs:

odyssey runs list --agent 412
odyssey runs show 1187
odyssey runs export 1187 --json out.json --junit out.xml

Validate a seed file server-side before spending a run on it:

odyssey seeds lint tests/seeds.csv --agent 412

Suite-driven runs

Scenarios and suites live on the platform, so you can run a stored suite instead of a local CSV. Name it by id or by name:

odyssey test --suite refunds --agent 412   # a platform suite, by name
odyssey test --suite 88 --agent 412        # …or by id
dystopic suites list 412                   # what's bound to this agent

Give exactly one scenario source — a seed FILE or --suite (or --attach to rejoin a check already running). --agent is required for both.

--agent NAME works anywhere --agent is accepted: a name is resolved against the platform with an exact match (a numeric id passes through unchanged):

odyssey test tests/seeds.csv --agent claude-v1

Author the scenarios and suites themselves with:

dystopic scenarios import 412 scenarios.csv --suite-id 88
dystopic suites create 412 --name refunds
dystopic worlds create 412 --name empty-cart --initial-state @state.json

A world is the initial state a scenario or suite runs against; scenarios create --world-id / suites create --world-id reference one by id. The agent's first variant becomes its default whether or not you pass --default, and every mutation requires org-admin.

Browse the agents you can run against:

odyssey list                 # optionally --search NAME
odyssey show 412

Exit codes: 0 the run completed with no task execution failures and the --min-pass-rate gate (if set) was met — without --min-pass-rate, judged seed failures alone do not fail the command; 1 below --min-pass-rate, a task execution failure (failed/timed_out), or an operational error (dispatch failure or poll timeout); 2 usage error.

Creating agents

The wizard registers the agent on the platform as it goes — there is no local file to edit and no separate push step:

odyssey init                   # wizard → creates the agent (needs an API key)
odyssey publish 412            # …or answer "publish now?" in the wizard
odyssey test --suite default --agent 412

Its top choice is Code (mode: code — your own Python entrypoint, which we run; this is the normal shape) vs Sandbox (mode: sandbox — a run_command such as Claude Code / Codex / Cursor / Aider, for non-Python runtimes and CLI/coding harnesses). A CLI harness is the agent, so the wizard never asks it for a code source.

Creating leaves the agent a draft; odyssey publish <id> runs full server-side validation and activates it. Editing a registered agent afterwards is done in the dashboard (or via DystopicClient.update_agent).

Secrets are org credentials on the platform. The wizard pre-fills the env-var name from the harness (e.g. ANTHROPIC_API_KEY), lets you rename it, and prompts for the value — which is stored as an org credential and mapped onto the agent's credential_refs. The value never touches your repository.

Add or rotate one outside the wizard with dystopic credentials (mutations require org-admin):

dystopic credentials list                              # masked values only
dystopic credentials set ANTHROPIC_API_KEY             # masked prompt
cat key.txt | dystopic credentials set OPENAI_API_KEY --value-stdin
dystopic credentials usage ANTHROPIC_API_KEY           # who depends on it

The agent config only ever carries the reference (odyssey update --credential-ref); the value lives here and is never printed back.

The CI check

The PR check is two runs of the same platform suite — one on the merge-base, one on the head commit — plus the diff between them:

dystopic repo connect acme/support        # connect the repo to its umbrella agent
dystopic agents ci-suite set 412 88       # which suite CI runs
dystopic agents gate set 412 --block-on-constraint  # what turns the check red
odyssey ci init                           # scaffold .github/workflows/

Without a gate the check is advisory: it still runs and reports, but never blocks. dystopic agents gate clear 412 returns it to that state.

Documentation

Full guides live at https://docs.dystopic.ai/docs.

License

Proprietary — see LICENSE. © Dystopic.

Release files for dystopic 0.21.1

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Built distribution (wheel)

Table of built distributions (wheels) for dystopic 0.21.1
File Interpreter ABI Platform
dystopic-0.21.1-py3-none-any.whl Python 3 none any Details

Release files / dystopic-0.21.1-py3-none-any.whl

Download URL dystopic-0.21.1-py3-none-any.whl
Size 405.4 kB
Tags Python 3
SHA-256 checksum
How to use checksums
238bc9b82193201c8ab17c5121c9c16ffa2aecd492c22ea9220863cd0c82d7e7
BLAKE2b-256 checksum
How to use checksums
3e0808f4eabac5a1cbdd1da540c1eb00a279dd991b556978ab92fa6aeccf05b4
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 2, 2026.

Transparency log

Release history Release notifications | RSS feed

0.24.0

1 release file

0.23.0

1 release file

0.22.0

1 release file

This release

0.21.1 This release

1 release file

0.16.0

1 release file

0.12.3

1 release file

0.6.0

1 release file

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page