# AgentDeck SDK  -  full documentation

AgentDeck SDK is a Python harness for agents you have to operate. You write agents, workflows and
skills as small declarations in a `.agentdeck/` directory; AgentDeck supplies everything around
them: discovery, layered settings, sessions, streaming, MCP servers, human-in-the-loop
approvals, run control, and one ordered event log per run.

It executes native `@workflow` and `@tool` targets alongside the OpenAI Agents SDK for agents:
an `Agent` compiles to an SDK agent, and a `@workflow` is ordinary Python awaited by AgentDeck's
own executor. AgentDeck owns configuration and orchestration, and there is no agent loop here.

Use it when the wiring around an agent has become the work: several agents and workflows in one
project, a chat surface and a batch path over the same definitions, runs you must inspect
afterwards, approvals that outlive the process that requested them.

Source: https://agentdecksdk.com · Generated from the same Markdown the site renders.

---

# Agentic software should feel like software

*Build agents, tools and workflows as normal software. AgentDeck gives them one execution model you can observe, control and extend.*
Source: https://agentdecksdk.com/

<div className="landing">



<Model>
<Snippet status="shipped">
```python
jack = Agent(
    name="Jack",
    instructions="Help developers build with AgentDeck.",
    tools=[search_docs, read_doc, read_changelog],
)

deck = Deck(agents=[jack], context=DocsCorpus)
```
</Snippet>
</Model>



</div>

---

# CLI

*The agentdeck command tree, generated from agentdeck/cli.py's own --help output.*
Source: https://agentdecksdk.com/reference/cli

# CLI

Generated from [`agentdeck/cli.py`](https://github.com/agentdecksdk/agentdeck/blob/main/agentdeck/cli.py) by capturing each subcommand's own `--help` output  -  the same rendering a terminal would show, not a second hand-written copy of it. `make check` regenerates this page and fails if the result differs (`scripts/generate_docs_reference.py`).

## `agentdeck`

```text
usage: agentdeck [-h] {runs} ...

positional arguments:
  {runs}

options:
  -h, --help  show this help message and exit
```

## `agentdeck runs`

```text
usage: agentdeck runs [-h] {signal} ...

positional arguments:
  {signal}

options:
  -h, --help  show this help message and exit
```

## `agentdeck runs signal`

```text
usage: agentdeck runs signal [-h] --control-db CONTROL_DB [--reason REASON]
                             run_id {cancel,pause,resume}

positional arguments:
  run_id                the run to signal
  {cancel,pause,resume}
                        the verb - see Run Control for what each does

options:
  -h, --help            show this help message and exit
  --control-db CONTROL_DB
                        path to the ControlPort's SQLite file
  --reason REASON       why, recorded in the run's log with the request
```

---

# Deck

*The composition root  -  builds a catalog from Agent objects and @workflow definitions or a project directory, and runs every turn on it.*
Source: https://agentdecksdk.com/reference/deck

# Deck

`Deck` is the one object a project talks to. Build it directly from `Agent` objects and
`@workflow` definitions, or discover the same catalog from a project directory with
`Deck.from_project()`  -  either way it
is the composition root: every turn started through it plays on the same
[Runtime](/runs-and-control/events) the HTTP surface runs chats on, and is recorded the
same way.

```python file=.agentdeck/agents/greeter/agent.py
from agentdeck import Agent

greeter = Agent(name="Greeter", instructions="You are a friendly scheduling assistant. Keep replies to one short sentence.")
```

```python run
import asyncio

from agentdeck import Deck


async def main() -> None:
    async with Deck.from_project() as deck:
        result = await deck.run("Greeter", "hello")
        print(result.output)
        print(result.usage)
        print(result.run_id)


asyncio.run(main())
```

## Construction and lifecycle

```python illustrative reason="a signature sketch, not a runnable call"
Deck(
    agents=[...],                   # Agent instances
    workflows=[...],                # @workflow definitions
    skills="./skills",              # a path, a sequence of paths, or a Skills(...) object
    mcp=".mcp.json",                # a path, or an MCP(...) object
    context=Calendar,               # the *type* of the per-run context, if this catalog wants one
    observers=[LangfuseObserver()],         # taps on the event stream; None reads settings, () means none
)
```

`Deck.from_project(path=".agentdeck")` discovers the same four catalog arguments from today's
directory layout (`agents/<bundle>/agent.py`, `workflows/<bundle>/workflow.py`,
`skills/*/SKILL.md`, `.mcp.json` next to `.agentdeck/`) and hands them to the same constructor  -
there is one catalog mechanism underneath either front door.

The lifecycle is `NEW -> build() -> BUILT -> (async with) -> OPEN -> CLOSED`:

- **`build()`** validates every name the catalog references (an unknown skill or MCP server name;
  an agent and a workflow sharing a root name) and compiles every
  agent/workflow to an `InvocableSpec`. It reads local files only  -  no network call, no MCP
  server started  -  and is idempotent, so it doubles as a CI check. Calling a turn-starting method
  without an explicit `build()` still works  -  `async with deck:` calls it for you.
- **`async with deck:`** starts everything `build()` left alone: the real engines, the event
  store, the session factory, the observers, and every configured MCP server. This is what a
  turn-starting method actually needs; calling one before opening raises.
- **`CLOSED` is terminal.** `aclose()` (or the `async with` block's exit) closes the Runtime's
  observers and tears down what this `Deck` itself opened  -  an `MCP(...)` it holds, always, and the event
  store it built from settings, but never one passed in through construction. Reopening a closed
  `Deck` raises; construct a new one instead.

**One `Deck` per process.** Constructing a second one while the first is still live raises
`ConfigError`, naming both projects. The restriction is real rather than stylistic: every project
is mounted under a single module alias and MCP servers are registered process-wide, so two decks
side by side would read each other's bundles and share each other's servers. Decks one after
another are fine  -  close the first, construct the next:

```python
async def validate_every_project() -> None:
    async with Deck.from_project("./alpha/.agentdeck"):
        ...

    # the first deck is closed by the time this one is constructed
    async with Deck.from_project("./beta/.agentdeck"):
        ...
```

A script that validates several projects in a loop needs that `aclose()` between them; a service
that mounts one deck's `asgi()` inside an existing app is unaffected, since that is still one
deck. Running two side by side is a capability we intend to add, tracked in
[issue #213](https://github.com/agentdecksdk/agentdeck/issues/213)  -  until then, a deck per tenant is
a process per tenant.

`deck.agents`/`deck.workflows` are read-only mappings once built  -  mutating either raises.

## Declaring the context type

`context=` on the constructor is the **type** of the application context this catalog's callables
receive. The value itself never goes here  -  it arrives per run, on `run`/`stream`/`answer`/`resume`
below. Declaring the type is what lets `build()` check it against every `ToolCtx[...]` parameter in
the catalog before anything runs:

```python run
from agentdeck import Agent, ToolCtx, ContextTypeError, Deck


class Calendar:
    def find(self, day: str) -> str:
        return f"{day} 09:00"


class Warehouse:
    """A different application environment entirely."""


def find_slots(day: str, environment: ToolCtx[Calendar]) -> str:
    """Find free appointment slots on a given day."""
    return environment.data.find(day)


booking = Agent(name="Booking", instructions="Book things.", tools=[find_slots])

try:
    Deck(agents=[booking], context=Warehouse).build()
except ContextTypeError as refused:
    print(refused)
```

Every injection site an agent compiles through is walked: its tools, its `instructions=`
callable, and its `hooks=` methods. The message names the callable (or the hook method), what it
requires, and what the deck provides. A `@workflow` body is not among them: it compiles without
this check, so its own `WorkflowCtx[...]` requirement stands or falls when the body runs.

What counts as compatible is what the *runtime* can decide, and nothing more:

| Requirement | Verdict |
|---|---|
| the exact declared type | compatible |
| a supertype of it (the deck declares a subclass) | compatible |
| `ToolCtx[Any]`, or a bare `ToolCtx` | compatible with anything |
| a runtime ABC  -  `Mapping[str, Any]`, `Sequence[str]` | checked against the ABC, ignoring its parameters |
| a `@runtime_checkable` protocol with only methods | checked structurally |
| a union  -  `ToolCtx[Calendar \| None]` | compatible if any arm is |
| a protocol `issubclass` refuses to rule on, a `TypeVar` | **deferred**  -  accepted here, decided at invocation |
| an already-built SDK tool object, a callable whose signature cannot be read | **deferred**  -  nothing to introspect |

`build()` is not a partial type checker and does not try to become one. Where no runtime answer
exists it accepts and lets the requirement stand or fall when the callable actually runs  -
refusing on a guess would reject builds a static checker would pass. `ContextTypeError` is a
subclass of `ConfigError`, so a caller already catching `ConfigError` around `build()` keeps
catching it.

Declaring nothing is the third case, and the default: no `context=` on the constructor means no
build-time check at all. `run(context=...)` still works exactly the same  -  the declaration buys
the check, not the capability. Passing an *instance* where the type belongs
(`Deck(context=my_calendar)`) is refused at construction, since every check would then silently
defer and the parameter would promise something it never delivered.

## Starting a turn

| Method | Signature | Returns |
|---|---|---|
| `run` | `(name, input, *, context=None, session_id=None, namespace=None, key=None)` | `TurnResult` for an agent; the workflow's own result (or an `InterruptResult`) for a workflow |
| `stream` | `(name, input, *, context=None, session_id=None, namespace=None, key=None)` | `AsyncGenerator[Event]`  -  the run's own canonical events, live |
| `runs.start` | `(name, input, *, context=None, session_id=None, namespace=None, key=None)` | a [`Run`](#run) handle  -  the same admission as `run`/`stream`, without waiting for or reading it |

`run`/`stream`/`runs.start` resolve `name` against whichever catalog holds it  -  an agent or a
workflow  -  and play it on the Runtime, recorded exactly as `POST /agents/{name}/chat` or
`POST /workflows/{name}` would record it. Pass `session_id=` for a conversational agent turn: the
same id means the same history across calls. `key=` is an optional stable
application identifier  -  for lookup (`runs.get(namespace=, key=)`) and idempotency, never the
run's address: the run's own `id` is always minted, never derived from it, and reusing a
`(namespace, key)` pair whose run already started raises `DuplicateKeyError` naming the run that
holds it, rather than replaying that run. A body that calls `ctx.ask(...)` makes a workflow's `run`
return an `InterruptResult`  -  `{"type": "interrupt", "payload": ..., "thread_id": "", "id": ...}`
 -  instead of its return value; see [Workflows](/build-your-deck/workflows) for what that means for
the body that paused, and answer it with the handle its own `id` names, [below](#run). Address the
run by `id`: `thread_id` is vestigial, and no executor produces one.

### What `input` accepts

For an **agent** turn: a `str`  -  the common case, and unchanged  -  or a **list of content blocks**,
which is how a turn carries anything that is not text. A string is coerced to one `text` block on
the way in, so the two forms meet as the same thing before any engine sees them.

For a **workflow**, `input` binds to the body's own parameters the way calling it would: a mapping
binds by name and must name exactly them, e.g.
`deck.run("RefundApproval", {"order_id": "A-1003"})`, while a body declaring one parameter takes
whatever it was given whole. It is JSON data, not content, and the log records it as one `data`
block; see [Workflows](/build-your-deck/workflows) for what a body does with it.

| Block | Fields | For |
|---|---|---|
| `TextBlock` | `text` | prose |
| `ImageBlock` | `media_type`, `data_b64` | an image inline, base64 |
| `AudioBlock` | `media_type`, `data_b64` | audio inline, base64  -  a voice note, a recorded call |
| `ResourceBlock` | `uri`, `media_type` | bytes held elsewhere, referenced rather than carried |
| `DataBlock` | `data` | JSON as content  -  a structured result, a workflow's state |

```python no-test reason="needs an image file and a live model that accepts one"
import base64

from agentdeck.core import ImageBlock, TextBlock

photo = base64.b64encode(open("receipt.png", "rb").read()).decode()
result = await deck.run(
    "Intake",
    [TextBlock(text="What is the total on this receipt?"), ImageBlock(media_type="image/png", data_b64=photo)],
)
```

Inline blocks are capped at **1 MB decoded**, enforced at construction rather than documented and
hoped for: base64 in an event lands in an append-only log and replays down every SSE connection
for the life of that run. Anything larger belongs in a `ResourceBlock`.

An engine that cannot express a block raises `ConfigError` naming the block type, rather than
dropping it  -  a turn that silently loses its image is worse than one that refuses. Two known
limits on the openai-agents engine at the pinned `openai-agents==0.17.0`: `AudioBlock` needs the
Chat-Completions path (`OPENAI_USE_RESPONSES=false`), and `ResourceBlock` is refused rather than
sent  -  a `uri` is a pointer, not content, and the engine never fetches it, so sending the URI
alone would risk a caller believing the model saw bytes it never received. Read the resource and
send its bytes as a text, image, audio or data block instead. `DataBlock` **is**
sent: it renders as its own part, `json.dumps(data, ensure_ascii=False)` with nothing wrapped
around it, since it is already a separate entry in the model's content list rather than text
concatenated into a caller's own prompt.

`stream` yields the same `Event` objects a run's log would hand back after the fact  -  `text.delta`
per token for an agent, a terminal event last  -
not a rendered wire format. This is the one method here that does not return a `TurnResult`,
because a caller that wants the final answer out of a stream has to fold it from the events, the
same way any other consumer of the log would.

`context=` supplies the application's own environment for one run  -  a database handle, a client,
whatever the code the run reaches needs. A tool, a dynamic-instructions callable, an agent hook
or a `@workflow` body that declares an `agentdeck.ToolCtx` parameter receives it as `ctx.data`, by
reference; see [Definitions](/reference/python-api) for how each declares one. Both engines
carry it, through their own native runtime-context channel rather than anything agentdeck
invented. Three things it deliberately is not: the model never sees it (the context parameter is
absent from the tool schema), it is never written to the event log, and it cannot cross the HTTP
surface  -  a live Python object has no wire form, so a context-requiring root is reachable from an
embedded Python caller and not from `asgi()`. That boundary and two others are set out under
[Where a context does not reach](#where-a-context-does-not-reach) below; read it before you build
one into a served path.

`type(event)` is always `Event`  -  it is the envelope, not the discriminator. Switch on
`event.payload` (a `match` narrows it to `TextDelta`, `MessageCompleted`, `RunCompleted`, …, see
[Agents](/build-your-deck/agents) for a worked example) or on `event.kind` if a string is more
convenient; either way, include a default case, since an unfamiliar kind still parses as
`UnknownEvent` rather than raising.

## `TurnResult`

An agent's `run` assembles a `TurnResult` from the run's own `run.completed` event  -  never the
SDK's own result object, so a caller depends on agentdeck's event schema rather than on whichever
engine ran the turn. `stream` does not return one; it yields the events themselves, as above.

| Field | Type | Meaning |
|---|---|---|
| `output` | `Any` | the run's structured output (a `DataBlock`'s data), or the joined text of the final message |
| `usage` | `Usage` | `input_tokens`, `output_tokens`, `usd`  -  the run's authoritative total |
| `run_id` | `str` | this run's id |
| `session_id` | `str \| None` | the session this turn belongs to, if `session_id=` was given |

**`usage.usd` is always `None`, on purpose.** agentdeck does not price model calls: no provider
returns a dollar figure in a response, and a price depends on a contract, a tier and a date
rather than on the call, so owning a table for every model on every provider would trade an
empty field for a stale one. `usd` is reserved for a caller that supplies its own cost; compute
one from `input_tokens`/`output_tokens` and your own price table if you need it (#177). The
field is deprecated and slated for removal at the next major, so do not build on it.

Calling `Agent.run()` directly  -  the class's own headless runner, bypassing `Deck` and the
Runtime entirely  -  still returns the SDK's own `RunResult`; see
[Definitions](/reference/python-api) for that distinction.

## `Run` and `deck.runs`

`deck.runs` is the collection that finds or starts a `Run`  -  three operations, and no per-run op
duplicated here. Once you hold a `Run`, every op that acts on it lives on the handle itself.

```python no-test reason="needs an open deck and a live run"
run = await deck.runs.start("Approval", {"order_id": "A-1003"}, session_id="t-1")

same_run = await deck.runs.get(run.id)                              # canonical
same_run = await deck.runs.get(namespace="acme", key="order-1234")  # application identity

waiting = await deck.runs.list(namespace="acme", status=RunStatus.WAITING_ANSWER)
```

| Method | Signature | Returns |
|---|---|---|
| `runs.start` | see [Starting a turn](#starting-a-turn) | `Run` |
| `runs.get` | `(id=None, *, namespace=None, key=None)`  -  exactly one of `id`/`key` | `Run`; raises `NotFoundError` for one this namespace has never heard of |
| `runs.list` | `(*, namespace=None, status=None, limit=None)` | `list[Run]`, scoped to one namespace  -  no cross-namespace listing |

`get()` never mutates: it does not create, start, resume, claim ownership or move lifecycle
state, and it takes no `context=`  -  a run recovered this way has durable identity and durable
state, never the ephemeral value a live process held for it (see `context=` [below](#context)).
It returns a run in any state, terminal included.

### `Run`

A deck-bound handle, not a second runtime  -  it holds no engine, store, MCP registry or observer,
and delegates every operation back through the deck. A handle caches no authoritative state, so
two handles on one run always agree: the durable store is the only thing either ever reads from.

| Attribute | Type | Meaning |
|---|---|---|
| `id` | `str` | the canonical, minted, durable id |
| `key` | `str \| None` | the caller's own application identifier, if one was given to `start()` |
| `namespace` | `str \| None` | the isolation boundary this run was started in |
| `session_id` | `str \| None` | the session this run belongs to, if any |

| Method | Signature | Behavior |
|---|---|---|
| `status` | `() -> RunStatus` | this run's current status, folded from its own events |
| `can` | `Controls` | `can.pause`, `can.resume`, `can.cancel`  -  a plain attribute, read off the status this handle last saw |
| `pause` | `(reason=None) -> None` | ask it to stop at its next safe point; `UnsupportedControlError` when no control backend is configured |
| `resume` | `() -> None` | continue a paused run; `RunStateError` when it is waiting for an answer instead |
| `cancel` | `(reason=None) -> None` | ask it to stop for good; a suspended run ends immediately rather than waiting for a safe point |
| `pending` | `() -> InterruptResult \| None` | what it is waiting to be answered about, or `None` if it is not `WAITING_ANSWER` |
| `answer` | `(value) -> None` | answer the interrupt it is paused on |
| `events` | `(*, from_seq=0, follow=False) -> AsyncIterator[Event]` | this run's own events  -  a snapshot by default, tailed live with `follow=True` |
| `await run` | | the result: a `TurnResult` for an agent, the body's own return value for a workflow |

```python no-test reason="needs a live Run from runs.start/get"
await run.pause("operator stepped away")
await run.resume()
await run.cancel("user closed the tab")

for waiting in await deck.runs.list(status=RunStatus.WAITING_ANSWER):
    await waiting.answer("yes")
```

`pause`/`cancel` record a request and return immediately  -  not when the run actually stops, which
nobody can know at the moment of asking. [Run Control](/runs-and-control/lifecycle-and-control) is the full
contract: what a safe point is, why a request is not a status change, and what a resume replays.

A `follow=True` ends at the same boundary `deck.stream()` does  -  a terminal event or a
suspension  -  not at the run's true end. Following a run that was later resumed past an
interrupt replays only up to that interrupt; call `events()` again, or read it with
`follow=False`, to see what came after.

**`await run` raises rather than blocks once the run has stopped without finishing.** `PAUSED`
and `WAITING_ANSWER` both raise `RunSuspendedError` (a `RunStateError`) instead of hanging with no
timeout parameter to escape it  -  `.pending` on the exception carries the interrupt's own payload
for the `WAITING_ANSWER` case, `None` for a plain pause:

```python no-test reason="needs a live Run parked on an interrupt or a pause"
from agentdeck.errors import RunSuspendedError

try:
    result = await run
except RunSuspendedError as parked:
    print(parked.status, parked.pending)  # RunStatus.WAITING_ANSWER, {"payload": ..., ...}  -  or PAUSED, None
```

A caller who wants to wait polls `status()`/`pending()` instead of awaiting.

`context=` is retained on the handle `runs.start()` returned, for that handle's whole life  -
`resume()` and `answer()` reuse it rather than taking one again. A handle recovered through
`runs.get()` carries no context at all, since it was never written to the log, so `resume()`/
`answer()` on it always resupply `None`. A suspended `@workflow` body is unaffected by that: it
is parked in memory with the `ctx` it already holds, so it reads the original value when it
continues on its next line.

## Serving over HTTP

`asgi()` returns an ASGI application  -  a FastAPI app whose lifespan **opens the deck on startup
and closes it on shutdown**, so a served deck needs no separate `async with`:

```python
from agentdeck import Deck

deck = Deck.from_project("./.agentdeck")
app = deck.asgi()                          # uvicorn yourmodule:app
```

That is all `agentdeck serve` is. It also means you can mount a deck inside an existing service
and have it open and close with the host app.

Two details worth knowing. The FastAPI import is deferred inside the method, so `agentdeck.deck`
stays importable without the `[serve]` extra  -  you only need it to actually serve. And requests
arriving before the lifespan has run get `503 {"status": "starting"}` rather than touching a deck
that is not open yet.

A third, if any of this catalog's callables declare a context: **it does not reach a served run.**
A context is a live Python object with no wire form, so an HTTP-started run always carries `None`
 -  [see below](#where-a-context-does-not-reach).

## Observers

The event log is the hub. An **observer** is a read-only tap on it  -  telemetry, cost accounting,
audit  -  and a deck can have as many as it likes. `observers=` is where they are declared, and
they start with the deck. `agentdeck.observers` ships three: `LangfuseObserver`, and two
deliberately plain ones for local development, `ConsoleObserver` (one line printed per event) and
`FileObserver` (one JSON line appended per event)  -  neither takes any formatting or rotation
option, so reach for your own observer once you need one.

```python
from agentdeck import Deck, views
from agentdeck.observers import ConsoleObserver, LangfuseObserver

deck = Deck(
    agents=[booking],
    observers=[ConsoleObserver(view=views.chat | views.reports), LangfuseObserver()],
)

async with deck:                      # every observer starts here, once, before any run
    await deck.run("booking", "hi")   # never mid-run
```

`view=` narrows what an observer receives to a subset of the stream. `agentdeck.views` has one
predicate per concern  -  `chat`, `tools`, `reports`, `lifecycle`, `errors`, `usage`  -  plus `all`,
composable with `|`, `&` and `~`. An observer given no `view=` sees everything.

| `observers=` | What starts |
|---|---|
| *(omitted, or `None`)* | The configured `LangfuseObserver()` observer if `AGENTDECK_LANGFUSE_PUBLIC_KEY` and `AGENTDECK_LANGFUSE_SECRET_KEY` are both set (see [Settings](/reference/settings)); nothing otherwise, with no warning. |
| `[observer, …]` | Exactly these, in order. Naming any observer suppresses the settings-derived `LangfuseObserver()`  -  a deck told which taps to open does not open another behind your back, so include it explicitly if you want it alongside your own. |
| `()` | None at all, even where the environment configures Langfuse. |

`LangfuseObserver()` is configured entirely by `AGENTDECK_LANGFUSE_*`, so there is one place to set the
endpoint, environment, sample rate and service name rather than two. Naming it with no keys
configured raises `ConfigError` at open, rather than tracing nothing quietly.

#### Two layers, and only one is on by default

| | |
|---|---|
| **semantic** (always) | Each run rendered from the canonical event log  -  what happened. Identical for an agent turn and a workflow run, because both are traced from the same events. |
| **raw** (`sdk_spans=True`) | OpenInference maps every agent, generation and tool call the Agents SDK makes, with its input and output  -  detail the event log does not record. |

```python
deck = Deck(agents=[booking], observers=[LangfuseObserver(sdk_spans=True)])
```

**The raw layer arrives as a second, separate trace per run  -  it is not nested under the first.**
Nesting would require the engine to establish an OTel context, and the engines are barred from the
Langfuse SDK by design (`.importlinter`'s `langfuse-is-telemetry-private`). So a turn with
`sdk_spans=True` shows up in Langfuse as two traces: the agentdeck one, and the SDK's own.

That is why it is opt-in. Reach for it when you are debugging *how* a turn ran  -  latency, retries,
what a tool actually received  -  and leave it off when you want one clean trace per run.

### Writing your own

An observer implements `Observer`  -  one required method, two optional lifecycle hooks:

```python
from agentdeck.core.ports import Observer


class CostObserver(Observer):
    async def start(self) -> None:
        ...   # open a client or a file; called once, at deck open, before any run

    async def emit(self, event) -> None:
        ...   # in-memory work only; never awaits a round trip

    async def close(self) -> None:
        ...   # the stream has ended: write out whatever is buffered
```

`start()` and `close()` both default to no-ops, so an observer that only needs `emit` defines
only `emit`. Raising from `start()` refuses the deck's open  -  better than a deck that runs with
an observer which silently never worked.

The lifecycle is the one the rest of the deck follows:

- **`build()`** checks that everything in `observers=` is an `Observer` and does nothing
  else. Nothing is started, no telemetry client is constructed, no exporter contacted  -  so a deck
  with Langfuse configured still validates in CI with nothing reachable.
- **Opening** calls each `start()` in order and registers every observer before the Runtime
  exists, so no run can be the thing that turns observability on.
- **Closing** tells every observer its stream has ended, which is what makes it write out
  whatever it buffered. What the deck *constructs* it owns; naming `observers=` means the deck
  builds no Langfuse client of its own.

An observer is fire-and-forget by contract: each has its own bounded queue, one that is slow or
raises costs its own backlog and never a run, and one that keeps failing is disabled and later
retried. An observer that cannot afford to lose an event reads the store instead.

One run is one trace. Traces are rendered from the [canonical event
stream](/runs-and-control/events), which is why an agent turn and a workflow run are
traced by the same code and both carry their `session_id`  -  nothing instruments the OpenAI
Agents SDK, and nothing opens a span outside an observer. A direct `Agent.run()`, which
bypasses the Runtime, therefore bypasses the observers too.

There is no `deck.observers` property, for the same reason there is no `runtime` or `store`:
nothing needs one, and adding a property later is additive while removing one is not.

<Callout type="info">
  `LangfuseObserver()` needs the `observability` extra: `pip install "agentdeck-sdk[observability]"`. Without
  it, a deck with no Langfuse keys is unaffected  -  the SDK is imported when the observer starts,
  not when it is constructed.
</Callout>

## Sessions

`session_for(session_id)` returns the SDK conversation-memory session `run`/`stream` use for that
id: Redis-backed when `AGENTDECK_SESSION` is set, an in-process SQLite session otherwise
(lost on process exit). Pass `session_factory=` to `Deck(...)` to inject one  -  the seam tests use
to swap in a fake without a real Redis server.

<Callout type="info">
  A Redis-backed session needs the `redis` extra: `pip install "agentdeck-sdk[redis]"`. Without
  it, `AGENTDECK_SESSION` unset (the default) is unaffected  -  the Redis client is imported only
  once a `redis://` URL is actually configured.
</Callout>

## Where a context does not reach

Three boundaries, all of them consequences of the same fact: a context is a live Python object that
is never serialized, so it exists only for as long as some caller is holding it.

**A context cannot cross the HTTP surface at all.** There is no wire form for a live object, and
`asgi()` invents none. An agent or workflow whose callables require a context is therefore
reachable from an embedded Python caller  -  `deck.run(...)`, a `Run` from `runs.start(context=...)`
 -  and **not** from a served deck. `POST /agents/{name}/chat` will run it with `ctx.data` set to
`None`. If a root needs a context, do not serve it; drive it from the process that holds the
object.

**The headless runner passes no context.** `Agent.run()` calls the SDK's own `Runner.run` with no
context object, so a tool that declares one fails when the model calls it  -  the compiled tool
refuses rather than running short an argument, saying the run carries something "rather than an
AgentDeck run context". That is the price of leaving a deliberately log-free convenience alone;
`Deck.run()` is the path with the contract.

**Skills never receive a context.** A skill is progressive disclosure of prose  -  a `SKILL.md` an
agent reads through the generated `load_skill` tool  -  not a program agentdeck runs. There is no
callable to inject into, so there is nothing here that a future release would "add": a skill that
needs application state is an ordinary tool with a `ToolCtx[...]` parameter.

## What else this does not cover

[Run Control](/runs-and-control/lifecycle-and-control) reaches every executor: an agent run stops
between stream items or before a tool is dispatched, a workflow at its own `ctx.safepoint()`.
One limit is left on the workflow path  -  a suspended `@workflow` body is parked in memory, in the
process that started it, so a restart or a second worker loses it and the resume raises
`ConfigError` saying so. Durable replay of an imperative workflow is not built yet: keep the
process that started the run alive until it is answered.

---

# Events

*Every event kind a run can emit, with its payload fields.*
Source: https://agentdecksdk.com/reference/events

# Events Reference

One ordered log per run. Every managed invocation appends to it, whatever started the run, and
status is folded from it rather than stored beside it.

## The envelope

Every event carries the same envelope, whatever its kind:

| Field | Meaning |
|---|---|
| `v` | Schema version |
| `kind` | One of the kinds below |
| `seq` | Position in this run's log, monotonic from 0 |
| `run_id` | The run this belongs to |
| `session_id` | The session, when the run has one |
| `namespace` | The run's namespace label |
| `origin` | Which invocable emitted it |
| `ts` | When the store recorded it |
| `payload` | The kind-specific body, below |

`seq` and `ts` are assigned by the store, not the producer, so ordering is the store's to
guarantee rather than a caller's to get right.

`v` is `{major: 4, minor: 0}`. A reader refuses any other major outright rather than guessing at a
wire shape it was never taught, so a log written by another major is read with the release that
wrote it or replayed into a new store. There is no migration.

## Lifecycle

Each of these sets the run's status. See [Lifecycle & Control](/runs-and-control/lifecycle-and-control).

| Kind | Payload | Notes |
|---|---|---|
| `run.started` | `invocable`, `kind_of_invocable`, `input` | Opens the run |
| `run.completed` | `output`, `usage` | Terminal. `usage` is the authoritative total |
| `run.failed` | `error_code`, `message`, `retryable` | Terminal. `error_code` is closed, so branch on it rather than parsing the message |
| `run.paused` | `reason` | Not terminal, and not waiting on an answer |
| `run.resumed` | `reason`, `value` | Same `run_id`, `seq` keeps counting |
| `run.cancelled` | `reason` | Terminal |
| `run.interrupted` | `interrupt_id`, `reason`, `payload`, `thread_id`, `expected_resume` | Waiting on an answer. Not terminal |

## Control

| Kind | Payload | Notes |
|---|---|---|
| `control.requested` | `verb`, `reason` | The signal was recorded, not that the run has acted on it |
| `control.observed` | `verb`, `safe_point` | The run reached a safe point and is acting on it |

## Content

| Kind | Payload | Notes |
|---|---|---|
| `text.delta` | `message_id`, `text` | One streamed fragment |
| `thought.delta` | `message_id`, `text` | Reasoning fragment, a separate channel |
| `message.completed` | `message_id`, `text` | The record. Deltas are streaming UX |
| `artifact.created` | `artifact_id`, `media_type`, `uri`, `size` | A reference to bytes stored elsewhere |

## Agents

| Kind | Payload | Notes |
|---|---|---|
| `agent.changed` | `previous_agent`, `next_agent` | The active agent changed, after a handoff completed. Never emitted for one requested but failed or refused |

## Tools and nodes

| Kind | Payload | Notes |
|---|---|---|
| `tool.call.started` | `call_id`, `tool`, `args` | Paired with the completion by `call_id` |
| `tool.call.completed` | `call_id`, `tool`, `result_preview`, `result_size`, `result_sha256`, `artifacts` | A capped preview plus size and hash, never the result itself |
| `node.updated` | `node`, `state_patch` | `state_patch` shallow-merges: top-level keys replace |

## Reporting

Advisory. These describe progress; they never change status.

| Kind | Payload | Notes |
|---|---|---|
| `report` | `level`, `message`, `fields` | What the running code said about itself: `info`, `warning` and `error` are prose a person reads, `record` is a named fact a consumer filters |
| `usage.reported` | `model`, `usage` | One model call. The total on `run.completed` wins |

## Other

| Kind | Payload | Notes |
|---|---|---|
| `input.appended` | `input`, `source` | Mid-turn steering |
| `answer.refused` | `reason` | An answer that was not one of the options the run asked for. The run is still `waiting_answer`, and the next answer can still land |
| `custom` | `name`, `data` | Engine-specific. `name` must be namespaced |

## Reading them

```python
async for event in run.events(from_seq=0, follow=True):
    print(event.seq, event.kind)
```

An unknown kind is carried rather than rejected, so a reader written today does not break against
a log written by a newer release of the same schema major.

```python
from agentdeck.core.events import KNOWN_KINDS, TERMINAL_KINDS
```

`KNOWN_KINDS` is the set above. `TERMINAL_KINDS` is `run.completed`, `run.failed` and
`run.cancelled`: seeing one of those means no further events will arrive for that run.

---

# python-api


Source: https://agentdecksdk.com/reference/python-api

# Python API

Core types and public classes exported from `agentdeck`.

## Exports

- `Deck`: Unified composition root.
- `Agent`: Declarative agent specification.
- `Workflow`: Declarative workflow specification.
- `tool`: Decorator declaring a leaf capability.
- `workflow`: Decorator declaring orchestration as an async function.
- `ToolCtx`: The context a tool receives.
- `WorkflowCtx`: `ToolCtx` plus orchestration, for a `@workflow` body.

---

# run


Source: https://agentdecksdk.com/reference/run

# Run Reference

API reference for `Run` instances and execution controls.

## Methods

- `await run`: Await completion of the run and return the final output.
- `run.events()`: Stream events as an async generator.
- `await run.pause()`: Pause execution.
- `await run.resume()`: Resume paused execution.
- `await run.answer(value)`: Supply input to a waiting run.
- `await run.cancel()`: Cancel execution.

The three control methods are strict. They raise `RunStateError` when the run's state refuses the
operation (resuming a run that is waiting for an answer), and `UnsupportedControlError` when the
control can never be applied here (no control backend configured). An operation with nothing left
to do, such as cancelling a run that already finished, returns quietly.

## Availability

- `run.can.pause`, `run.can.resume`, `run.can.cancel`: whether each control is available, combining
  the engine's own capability with the run's current state.

```python
if run.can.pause:
    await run.pause()
```

`can` reads the status this handle last saw, so it is for enabling buttons and branching, not for
guaranteeing the next call succeeds. The run can end in between, which is what the strict methods
above report.

---

# Settings

*Every AGENTDECK_*, OPENAI_*, ANTHROPIC_*, GEMINI_*, OLLAMA_*, OPENROUTER_*, and TAVILY_* environment variable, generated from agentdeck/runtime/settings.py.*
Source: https://agentdecksdk.com/reference/settings

# Settings

Generated from [`agentdeck/runtime/settings.py`](https://github.com/agentdecksdk/agentdeck/blob/main/agentdeck/runtime/settings.py)'s `LayeredSettings` subclasses  -  this page cannot drift from the code because `make check` regenerates it and fails if the result differs (`scripts/generate_docs_reference.py`). Values come from process environment variables or the project-root `.env`; process values win.

## `OpenAISettings`

OpenAI-compatible endpoint configuration.

| Env var | Type | Default | Description |
|---|---|---|---|
| `OPENAI_MODEL` | `str` | `'gpt-4.1-mini'` | Default model for agents that do not declare `model=`. |
| `OPENAI_API_KEY` | `str` | `''` | API key for OpenAI. A custom `OPENAI_BASE_URL` may omit it when the endpoint needs no auth. |
| `OPENAI_BASE_URL` | `str` | `''` | OpenAI-compatible endpoint base URL. Empty uses the SDK default, api.openai.com. |
| `OPENAI_CA_BUNDLE` | `str` | `''` | Path to a CA/certificate bundle for verifying the endpoint's TLS certificate. Empty uses the system's default trust store. |

## `ModelProviderSettings`

Credentials for prefixed model providers.

| Env var | Type | Default | Description |
|---|---|---|---|
| `ANTHROPIC_API_KEY` | `str` | `''` | API key for `anthropic/...` models. |
| `GEMINI_API_KEY` | `str` | `''` | API key for `gemini/...` models. |
| `OLLAMA_BASE_URL` | `str` | `''` | Endpoint for `ollama/...` models. |
| `OPENROUTER_API_KEY` | `str` | `''` | API key for `openrouter/...` models. |

## `RunnerSettings`

Defaults for the host-side Agents SDK runner.

| Env var | Type | Default | Description |
|---|---|---|---|
| `AGENTDECK_RUNNER_WORKFLOW_NAME` | `str` | `'agentdeck'` | Name recorded on the host Agents SDK run (`RunConfig.workflow_name`)  -  identifies which workflow produced a run in tracing/observability. |
| `AGENTDECK_RUNNER_TEMPERATURE` | `float` | `1.0` | Sampling temperature for the host agent loop's model. |
| `AGENTDECK_RUNNER_MAX_TURNS` | `int` | `30` | Maximum turns `Runner.run`/`run_streamed` may take before giving up. |
| `AGENTDECK_RUNNER_MAX_TOKENS` | `int` or `None` | `None` | Cap on tokens per response for the host agent loop's `ModelSettings`. `None` means the model's own default (uncapped). |
| `AGENTDECK_RUNNER_HANDOFF_ENDS_ON_USER_TURN` | `bool` | `False` | Append a synthetic user turn after a handoff's collapsed history so the transferred-to agent's request ends on a user role instead of an assistant one. Off by default: it changes what every model sees on every handoff, including against OpenAI, and only some OpenAI-compatible endpoints reject the assistant-terminated shape. |
| `AGENTDECK_RUNNER_HANDOFF_CLOSING_TURN` | `str` | `'Please continue.'` | Content of the synthetic user turn `handoff_ends_on_user_turn` appends. Override for a deployment whose conversations aren't English  -  the default is otherwise an English sentence injected into every handoff regardless of the conversation's own language. |

## `RuntimeSettings`

Knobs the Runtime itself reads.

| Env var | Type | Default | Description |
|---|---|---|---|
| `AGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS` | `float` | `3600.0` | How long, in seconds, a running open run may go without writing an event before it is treated as abandoned and its session ownership is released for another worker to claim. Never applies to a run paused or waiting on an answer  -  those hold their session until resumed, answered, or cancelled. Must be positive; set it above the longest gap a healthy turn can go quiet. |
| `AGENTDECK_RUNTIME_LEASE_TTL_SECONDS` | `float` | `90.0` | How long, in seconds, a run's lease stays valid without renewal. A worker renews its lease six times per TTL while it plays a run, so a process killed outright frees its session within one TTL instead of one `stale_run_after_seconds`. Only takes effect with a lease backend shared across processes (`AGENTDECK_CONTROL=sqlite:///<path>`); with the in-memory default nothing is ever reported dead and the staleness timer remains the only backstop. Must be positive; set it above the longest the event loop can be blocked in one go. |

## `LangfuseSettings`

Langfuse LLM-observability export config.

| Env var | Type | Default | Description |
|---|---|---|---|
| `AGENTDECK_LANGFUSE_PUBLIC_KEY` | `str` | `''` | Langfuse public key. Tracing stays off unless this and `secret_key` are both set. |
| `AGENTDECK_LANGFUSE_SECRET_KEY` | `str` | `''` | Langfuse secret key. Tracing stays off unless this and `public_key` are both set. |
| `AGENTDECK_LANGFUSE_BASE_URL` | `str` | `'http://localhost:3000'` | Langfuse endpoint. |
| `AGENTDECK_LANGFUSE_ENVIRONMENT` | `str` | `'local'` | Langfuse `environment` tag attached to every exported span. |
| `AGENTDECK_LANGFUSE_DEBUG` | `bool` | `False` | Enable the Langfuse SDK's own debug logging. |
| `AGENTDECK_LANGFUSE_SAMPLE_RATE` | `float` | `1.0` | Fraction of traces exported to Langfuse, from 0.0 to 1.0. |
| `AGENTDECK_LANGFUSE_SERVICE_NAME` | `str` | `'agentdeck'` | OpenTelemetry resource `service.name` for every exported span. Without it, spans fall back to `unknown_service` and are unattributed in the Langfuse UI. |

## `TavilySettings`

Tavily web-search API. One knob: `TAVILY_API_KEY`.

| Env var | Type | Default | Description |
|---|---|---|---|
| `TAVILY_API_KEY` | `str` | `''` | Tavily web-search API key. Empty makes the `web_search` tool return an `error:` string instead of raising  -  it degrades the same way an unavailable MCP server does, rather than disappearing. |

## `EventsSettings`

Where the Runtime's canonical event log is written, as one scheme-shaped URL.

| Env var | Type | Default | Description |
|---|---|---|---|
| `AGENTDECK_EVENTS` | `str` | `'memory://'` | Where the Runtime's canonical event log is written: `memory://` (default, in-process, gone when the process exits), `sqlite://<path>`, `redis://<url>`/`rediss://<url>` (needs the `[redis]` extra), or `postgresql://<dsn>` (needs the `[postgres]` extra). The scheme names the backend. |

## `ControlSettings`

Where a run's pending control signals live  -  what pause and cancel are written to.

| Env var | Type | Default | Description |
|---|---|---|---|
| `AGENTDECK_CONTROL` | `str` | `'memory://'` | Where a run's pending control signals and its liveness lease live: `memory://` (default, reachable only from this process) or `sqlite://<path>` (crosses process boundaries, and is required for the `agentdeck runs signal` CLI to reach a run, and for a killed worker's session to be freed by its lapsed lease rather than by `stale_run_after_seconds`). The scheme names the backend. |

## `SessionSettings`

Configuration for Redis-backed agent conversation memory.

| Env var | Type | Default | Description |
|---|---|---|---|
| `AGENTDECK_SESSION` | `str` or `None` | `None` | Redis URL for `RedisSession`-backed agent conversation memory (`agentdeck.adapters.executors.openai_agents.sessions.SessionFactory`), needing the `[redis]` extra. `None` falls back to one in-process `SQLiteSession` per session key  -  no persistence across a restart, no sharing across workers. |
| `AGENTDECK_SESSION_REDIS_KEY_PREFIX` | `str` | `'agents:session'` | Key prefix under which `RedisSession` stores conversations in Redis. |
| `AGENTDECK_SESSION_REDIS_TTL` | `int` or `None` | `None` | Per-session TTL in seconds for Redis-backed conversations. `None` means sessions persist indefinitely. |

---

# Agents

*Declarative agents with tools, instructions, and model configurations.*
Source: https://agentdecksdk.com/build-your-deck/agents

# Agents

Agents are autonomous decision-making entities that use tools to achieve goals.

## Declaring an Agent

```python
from agentdeck import Agent

support_agent = Agent(
    name="support_agent",
    instructions="You help customers troubleshoot issues.",
    model="gpt-4o",
)
```

## Choose a model provider

Prefix `model=` to select a provider. `Deck.build()` checks every required credential before any model call.

```python
openai_agent = Agent(name="openai", model="openai/gpt-4o")
anthropic_agent = Agent(name="anthropic", model="anthropic/claude-3-7-sonnet")
gemini_agent = Agent(name="gemini", model="gemini/gemini-2.5-flash")
ollama_agent = Agent(name="ollama", model="ollama/llama3.2")
openrouter_agent = Agent(name="openrouter", model="openrouter/openai/gpt-4o")
```

Configure only the providers your deck uses:

```dotenv
OPENAI_MODEL=gpt-4.1-mini
OPENAI_API_KEY=sk-...
ANTHROPIC_API_KEY=sk-ant-...
GEMINI_API_KEY=...
OLLAMA_BASE_URL=http://localhost:11434/v1
OPENROUTER_API_KEY=sk-or-...
```

An agent without `model=` uses `OPENAI_MODEL`. Bare model names use OpenAI, while namespaced IDs
such as `vendor/model` remain available for an endpoint configured with `OPENAI_BASE_URL`.
## SDK-native Options

Some agent options come directly from the OpenAI Agents SDK. For example, structured output can use
`AgentOutputSchema` from the SDK's `agents` package:

```python
from agents import AgentOutputSchema
from pydantic import BaseModel

from agentdeck import Agent


class Verdict(BaseModel):
    approved: bool
    reason: str


reviewer = Agent(
    name="reviewer",
    instructions="Approve or reject the request.",
    output_type=AgentOutputSchema(Verdict, strict_json_schema=False),
)
```

`AgentOutputSchema` comes from the OpenAI Agents SDK (`agents`). `agentdeck` keeps that boundary
visible: it discovers and configures your agent, then passes SDK-native options through to the SDK
runner rather than wrapping them in a second `agentdeck` type.

---

# Context

*Typed, request-scoped access to your own application state, for tools and workflows.*
Source: https://agentdecksdk.com/build-your-deck/context

# Context

`ToolCtx[T]` and `WorkflowCtx[T]` give a `@tool` or `@workflow` typed, request-scoped access to
your own application state: a database client, a config object, whatever the run needs. AgentDeck
never inspects the value, it holds it by reference and hands it to whichever callable declared it.

## Declare it, pass it, use it

Declare the type once on `Deck(context=...)`. Pass the object once per run, on
`deck.run(context=...)`. A callable reaches it through `ctx.data`.

```python run
import asyncio

from agentdeck import Agent, Deck, ToolCtx


class Billing:
    def status(self, order_id: str) -> str:
        return f"{order_id}: shipped"


def order_status(order_id: str, billing: ToolCtx[Billing]) -> str:
    """Look up the shipping status of an order."""
    return billing.data.status(order_id)


agent = Agent(name="support", instructions="Help with order questions.", tools=[order_status])
deck = Deck(agents=[agent], context=Billing)


async def main() -> None:
    async with deck:
        result = await deck.run("support", "hi", context=Billing())
        print(result.output)


asyncio.run(main())
```

`billing.data` is the exact `Billing()` instance passed to `context=`, not a copy. `Deck.build`
checks every `ToolCtx[...]`/`WorkflowCtx[...]` parameter in the catalog against the declared type
before any run starts, so a mismatched context fails at build time, not mid-run. See
[Deck: declaring the context type](/reference/deck#declaring-the-context-type) for the full
compatibility rules.

## What each context can do

A `@tool` takes `ToolCtx` and is a leaf capability. A `@workflow` takes `WorkflowCtx`, which is
`ToolCtx` plus orchestration. Declaring the wrong one is a `build()` error: a tool that could ask a
person or start another run would no longer be a leaf.

| Capability | `ToolCtx` (`@tool`) | `WorkflowCtx` (`@workflow`) |
|---|---|---|
| `ctx.data`, the context object | yes | yes |
| `ctx.agent`, `ctx.run_id`, `ctx.session_id` | yes | yes |
| `ctx.reporter`, report progress out of band | yes | yes |
| `await ctx.safepoint()`, offer a pause/cancel point | yes | yes |
| `await ctx.ask(question, options=...)`, suspend for an answer | no | yes |
| `ctx.invoke(target, ...)`, `await ctx.parallel(...)`, start child runs | no | yes |
| `ctx.agents.create()`, `ctx.agents.fork()`, mint an agent not in the catalog | no | yes |

## Where it does not reach

A context is a live Python object, never serialized, and it cannot cross the HTTP surface. See
[Where a context does not reach](/reference/deck#where-a-context-does-not-reach) for the full list.

## Related

- [Workflows](/build-your-deck/workflows) - `ctx.ask()` and `ctx.safepoint()` in practice
- [Deck](/reference/deck) - `context=` construction and every compatibility rule
- [Runs](/runs-and-control/runs) - starting and rehydrating the runs a context rides on

---

# deck


Source: https://agentdecksdk.com/build-your-deck/deck

# Deck

`Deck` is the single composition root for your entire application.

## Creating a Deck

```python
from agentdeck import Deck, Agent

deck = Deck(
    agents=[Agent(name="bot", instructions="Help user.")],
)
```

Or discover components automatically from `./.agentdeck/`:

```python
deck = Deck.from_project()
```

## What Deck is responsible for

You own intent: which agents, tools, workflows and skills exist, and what they do. `Deck` owns the
machinery underneath them.

| Deck is responsible for | you never wire it yourself |
|---|---|
| run identity | every run gets a durable id, a namespace, and an optional key of your choosing |
| execution lifecycle | starting, suspending, resuming and cancelling, and the rules for which of those a run will accept right now |
| persistence | the event log, and the session a conversation accumulates in |
| event streams | one canonical stream per run, replayable and tailable, whichever process is executing it |
| concurrency | one turn per session, settled atomically, so two servers cannot both answer the same conversation |

That split is the reason a `Deck` takes declarations rather than infrastructure: an agent says what
it does, and nothing in it knows which store, executor or surface it will run under.

---

# skills


Source: https://agentdecksdk.com/build-your-deck/skills

# Skills

Skills provide modular, reusable domain knowledge and instructions defined in `SKILL.md` files.

## Adding Skills

Attach skills directly to agents or load them from `./.agentdeck/skills/`.

---

# tools


Source: https://agentdecksdk.com/build-your-deck/tools

# Tools

Tools allow agents to perform actions and fetch external information.

## Defining a Tool

```python
from agents import function_tool

from agentdeck import Agent


@function_tool
def lookup_order(order_id: str) -> str:
    """Fetch status for an order."""
    return f"Order {order_id} is in transit."

agent = Agent(
    name="order_bot",
    instructions="Look up customer orders.",
    tools=[lookup_order],
)
```

`function_tool` comes from the OpenAI Agents SDK (`agents`), which `agentdeck` installs and
passes through unchanged. Tools are the SDK's execution primitive, not an `agentdeck` wrapper type.

## Handling Failures

A tool can return a user-facing failure like any other result. Do that when the model can recover
and continue the conversation:

```python
@function_tool
def lookup_shift(date: str) -> str:
    """Return the nurse on duty for a date."""
    row = SHIFTS.get(date)
    if row is None:
        return f"no shift on record for {date}"
    return row
```

If a tool raises instead, the Agents SDK catches the exception and gives the model its standard
tool-error string. `agentdeck` records the call as `tool.call.completed`, not `run.failed`, and the
run can still complete. In that case `Deck.run()` returns the model's final answer, the
non-streamed HTTP route returns `200 {"output": ...}`, and the streamed HTTP route ends with
`done` if the model completes.

That is different from an exception that escapes the SDK runner, for example custom tool failure
handling that re-raises. Then the log records `run.failed` with `error_code="engine_error"`,
`Deck.run()` raises the exception, `Deck.stream()` yields `run.failed` and then raises, the
non-streamed HTTP route returns `500 {"detail": "internal error"}`, and the streamed HTTP route
sends an `error` event with the exception type.

---

# workflows


Source: https://agentdecksdk.com/build-your-deck/workflows

# Workflows

A workflow coordinates other executions. Write one as an ordinary async function.

```python
from agentdeck import Deck, WorkflowCtx, workflow

@workflow
async def resolve(ctx: WorkflowCtx, ticket: dict) -> str:
    """Refund a ticket, once a person says so."""
    if not await ctx.ask(f"refund {ticket['id']}?", options=[True, False]):
        return "declined"
    return "refunded"

deck = Deck(workflows=[resolve])
```

## It suspends where it stands

`ctx.ask(...)` stops the run and waits. The body is not unwound: its local variables are still
there, so the answer continues on the next line rather than replaying the workflow from the top.

```python
run = await deck.runs.start("resolve", {"id": "T-1"})
# ... the run is now `waiting_answer`, and shows up in deck.runs.list(status=...)
await run.answer(True)
print(await run)   # "refunded"
```

### Options make it a choice

```python
env = await ctx.ask("which environment?", options=["dev", "prod"])
```

The options travel with the question, so anything listing pending runs can render them:

```python
pending = await run.pending()
pending["payload"]["options"]   # ["dev", "prod"]
```

An answer that is not one of them is refused before it is recorded. The answerer gets the error,
an `answer.refused` event lands in the log, and the run is still waiting for a real answer.

```python
await run.answer("staging")   # ValueError, nothing resumed
await run.answer("prod")      # lands
```

A question with no options takes whatever it is given: nothing outside the body can judge a
free-form answer better than the body can.

An operator's pause behaves the same way at a safepoint, and a cancel ends the run instead.

```python
@workflow
async def batch(ctx: WorkflowCtx, items: list[str]) -> int:
    done = 0
    for item in items:
        await process(item)
        await ctx.safepoint()   # a pause parks here; a cancel stops the run here
        done += 1
    return done
```

A parked body lives in the process that started the run. If that process restarts, the run is
still `waiting_answer` in the log but the body is gone, and answering it says so rather than
silently re-running the workflow.

## Input binds like a call

| the run's input | the body |
|---|---|
| `deck.run("research", "kites")` | `research(ctx, topic)` gets `topic="kites"` |
| `deck.run("resolve", {"ticket": t, "urgent": True})` | `resolve(ctx, ticket, urgent)` gets both |
| `deck.run("summarize", {"a": 1})` | `summarize(ctx, payload)` gets the mapping whole |

One parameter is one value, a mapping included. Several parameters bind by name.

---

# index


Source: https://agentdecksdk.com/examples

# Examples

Explore real-world runnable examples built with AgentDeck.

## Available Examples

- **Chat Agent with a Tool:** Basic tool execution and streaming.
- **Agent with a Skill:** Extending agent capability using domain skills.
The reference application, [Jack](/jack), is documented separately: he is the agent answering
questions on this site, and his page records the reasoning behind each decision in him.

---

# existing-agents


Source: https://agentdecksdk.com/integrations/existing-agents

# Existing Agents

Wrap existing agents without requiring a rewrite.

## Wrapping Agents

Bring your custom agent implementations and wrap them with AgentDeck's runtime.

---

# mcp


Source: https://agentdecksdk.com/integrations/mcp

# Model Context Protocol (MCP)

Connect external tools and resources over standard MCP transports.

## Attaching MCP Servers

Attach MCP clients directly to your `Deck` configuration.

---

# openai-agents-sdk


Source: https://agentdecksdk.com/integrations/openai-agents-sdk

# OpenAI Agents SDK

Integrate OpenAI Agents directly into AgentDeck.

## Native Execution

AgentDeck runs OpenAI Agents natively while adding sessions, durable runs, and event streaming.

---

# How Jack is built

*What building a real application with AgentDeck looks like, and what AgentDeck provides around your application code.*
Source: https://agentdecksdk.com/jack

# How Jack is built

Jack is the AgentDeck documentation agent running on this site. The same application powers the
live experience at the bottom of the home page, and its source is in the repository.

This page shows what building a real application with AgentDeck looks like: what you write, and
what you get around it.

## The application

Jack answers questions about AgentDeck by reading its documentation.

```text
Question
   → Jack
      → search_docs / read_doc / read_changelog
   → Answer
```

That shape is the whole application, and the code says the same thing:

```python
from agentdeck import Agent, ToolCtx, Deck

def search_docs(query: str, docs: ToolCtx[DocsCorpus]) -> str:
    """Find AgentDeck documentation pages matching a query."""
    return docs.data.search(query)

def read_doc(slug: str, docs: ToolCtx[DocsCorpus]) -> str:
    """Read one AgentDeck documentation page in full, by its slug."""
    return docs.data.pages[slug]

jack = Agent(
    name="Jack",
    instructions=instructions,
    tools=[search_docs, read_doc, read_changelog],
)

deck = Deck(agents=[jack], context=DocsCorpus)
```

Three ordinary functions, one agent, one Deck. `ToolCtx[DocsCorpus]` is how a tool reaches the
application's own data; the model is offered only `query` or `slug`.

## The application code stays application code

Almost everything Jack-specific is about what Jack should do: how to search the corpus, how to
read a page, what to say when the documentation does not cover something, and how to cite what
he used. That is the file you would expect to write.

**You write the behavior.** Nothing above reaches for a runtime concern, because it does not
have to.

## AgentDeck provides the system around it

The same small application runs inside a foundation it did not have to build.

```text
Run
├── executions     nested invocations, each addressable
├── events         one ordered log per run
├── reports        progress and status from inside the work
├── state          sessions that outlive a single call
├── interaction    branches that wait for a person
└── control        pause, resume, cancel
```

Around that model, other things connect to the same execution: observers and telemetry, the HTTP
and SSE surfaces, and the website itself. They read the run rather than a translation of it, so
none of them needs Jack to expose anything special.

Jack did not assemble these. They are what a `Deck` gives an application the moment it runs.

## From execution to the website

The live experience on this site is that model, exposed.

```mermaid
flowchart TD
    stream["Deck.stream()<br/>canonical Event"] --> filter["PUBLIC_KINDS filter<br/>run.started<br/>text.delta<br/>tool.call.started<br/>run.completed<br/>run.failed"]
    filter -->|"canonical Event unchanged"| sse["SSE response<br/>data: event.model_dump_json()"]
    sse --> browser{"Browser<br/>switch (event.kind)"}
    browser -->|"text.delta"| transcript["Transcript"]
    browser -->|"run.started<br/>tool.call.started<br/>run.completed<br/>run.failed"| tree["Execution tree"]
```

The allowlist drops other event kinds without reshaping the canonical events it permits.

The route is a few lines over `deck.stream()`, and the wire is the event log itself:

```python
async for event in deck.stream(AGENT, question, context=corpus, session_id=session_id):
    if event.kind in PUBLIC_KINDS:
        yield f"data: {event.model_dump_json()}\n\n"
```

There is no translation layer on either side. The browser switches on `event.kind`, and so would
a Python process reading the same run back tomorrow. The execution tree you see next to Jack is
built from those events, in the order they arrived.

## See it running

Ask him something on the [home page](/) and watch the tree fill as he works: a tool call appears
when he makes one, and the run resolves when it completes.

## Source

- [`examples/jack`](https://github.com/agentdecksdk/agentdeck/tree/dev/examples/jack)  -  the agent, its tools, the corpus and the route
- [Implementation notes](/jack/notes)  -  the decisions behind this build, and the alternatives they beat
- [Examples](/examples)  -  smaller runnable projects, each exercising a single idea

---

# Implementation notes

*The decisions behind Jack, and the alternatives each one beat.*
Source: https://agentdecksdk.com/jack/notes

# Implementation notes

The engineering behind [how Jack is built](/jack). Each note is a decision that had a reasonable
alternative, and the reason that alternative lost.

## The tools are undecorated

A tool that declares a `ToolCtx` parameter stays a plain function. `@function_tool` puts every
parameter into the schema the model sees, and the model has no `DocsCorpus` to pass. `build()`
compiles the function instead, so the model is offered only `query` or `slug`.

See [Tools](/build-your-deck/tools) and [Context](/build-your-deck/context).

## The context type is declared

```python
deck = Deck(agents=[jack], context=DocsCorpus)
```

`context=DocsCorpus` is the *type*; the instance goes in per run. Declaring it makes `build()`
check every `ToolCtx[...]` in the catalog, tools and the instructions callable alike, before a
question is ever asked. The wrong type raises `ContextTypeError` naming both, at startup rather
than mid-answer.

## Search is a dict and a scan

The corpus is about thirty pages and 120 KB, so retrieval is TF-IDF over `Path.read_text()`, in
roughly thirty lines.

A vector store lost on three counts: at this size a scan beats an embedding round-trip, there is
no index to rebuild when a page changes, and it cannot return a stale chunk. `DocsCorpus.search`
is where that decision lives, and its signature is what stays fixed if the corpus outgrows it.

## Explicit composition, not `Deck.from_project()`

Every other example discovers its catalog from a `.agentdeck/` directory. Jack cannot, because
his tools and his Deck share one `DocsCorpus` class and a bundle has no clean way to share a type
with the program composing it:

- a module beside `.agentdeck/` imports only when the process started in that directory, and a
  server starts wherever its supervisor puts it;
- a module inside it resolves through `agentdeck_project`, an internal alias that exists only
  *after* `from_project()` has run, which is too late for a module-level import.

Explicit composition has neither problem. Both front doors are described in [Deck](/build-your-deck/deck).

## Its own route, not `Deck.asgi()`

AgentDeck packages an HTTP surface and Jack writes his own, for two structural reasons:

| | |
|---|---|
| **Context cannot cross HTTP** | A run started through `asgi()` carries `context=None`. There is no wire form for a live Python object, and both tools need the `DocsCorpus`. |
| **The chat wire is frozen** | Its body is exactly `{"session_id", "message"}`, pinned byte-for-byte by `tests/golden/`. The page a reader is on has nowhere to go in it. |

The result is about forty lines over `deck.stream()`, streaming canonical events with no
translation layer.

## What a public endpoint needed

Jack is unauthenticated on purpose: a documentation assistant that asks you to log in is not a
documentation assistant. The limits below are the whole of what stands between a public hostname
and someone else's bill.

- **An event allowlist.** Five kinds reach the browser. `tool.call.completed` is not among them:
  its `result_preview` is the tool's output verbatim, so a tool that raised would put its
  exception text on a public wire.
- **A quota shaped as conversations.** Three sessions per client per day, twenty turns each.
  Turns bound a conversation that re-sends its history every turn; sessions stop the way around
  that, which is to finish twenty turns and start again.
- **A token ceiling.** The only structural answer to "can this be used to write someone's essay".
  An instruction is persuadable; a ceiling is not.
- **An origin check, which is not authentication.** `Origin` is browser-set and forged in one
  flag. It stops another site embedding the endpoint, and nothing more.

## Prompt injection

The *structure* of the prompt cannot be forged: the page slug is validated against the corpus, so
only known values survive, and the delimiter is stripped from any reader selection. What the
model chooses to say with that text is not guarded, and no delimiter makes it so.

The full accounting is in the
[example's README](https://github.com/agentdecksdk/agentdeck/tree/dev/examples/jack#readme).

---

# Mental Model

*The four primitives, what you declare versus what AgentDeck runs, and how one becomes the other.*
Source: https://agentdecksdk.com/meet-agentdeck/mental-model

# Mental Model

AgentDeck separates what you declare from what it runs. You own the intent. It owns the machinery.


## What you declare

Four kinds of thing, each a plain Python declaration with no runtime attached.

| You write | With | It becomes |
|---|---|---|
| Agent | `Agent(name=..., instructions=...)` | a model that decides, optionally with tools, handoffs and subagents |
| Tool | `@tool` | a leaf capability, called by a model or by a workflow |
| Workflow | `@workflow` | ordinary Python that coordinates executions and can suspend in place |
| Skill | a `SKILL.md` on disk, named by string in `Agent(skills=[...])` | prose the model reads on demand through a generated `load_skill` tool |

A `@tool` and a `@workflow` execute nothing on their own: each stays inert until a `Deck` compiles
it. An `Agent` also has `Agent.run()`, a one-shot headless call that skips the deck entirely and so
gets no event log, no session and no controls. Everything below is about the path through a `Deck`,
which is the one with the contract.

## Deck: the composition root

A `Deck` holds the catalog. It is where declarations become things that can be named and invoked,
and it is the only object you construct to get a working system.

```python
deck = Deck(agents=[assistant], workflows=[approve])
```

`Deck.build()` resolves every name in that catalog before a run starts: handoffs, subagents,
skills, MCP servers, model credentials. A missing provider key or an unresolvable name is an error
you get at build time, not halfway through a user's turn.

## Run: the thing with identity

Invoking a catalog entry produces a **run**, and the run is what everything else hangs off. It has
a durable id, a status, a log, and the session it belongs to if it belongs to one. It outlives the
handle you got it from:
`deck.runs.get(id)` picks the same run up again, in this process or another one.

| A run has | Meaning |
|---|---|
| identity | a minted id, optionally a `(namespace, key)` you chose |
| status | one of `running`, `paused`, `waiting_answer`, `completed`, `failed`, `cancelled` |
| a session | the conversation it belongs to, or `None` when it stands alone |
| a log | every event it produced, in order |

## Control and Events: the two ways out

A live run is not a black box. **Control** is what you send in: `pause()`, `resume()`,
`cancel()`, `answer()`. **Events** are what comes out: an append-only, typed record of every
token, tool call, report and state transition.

The log is not a copy of the state, it *is* the state. A run's status is derived by folding its
own events in order, so there is no second store to fall out of sync after a restart.

## What AgentDeck manages, so you do not

Run identity, lifecycle transitions, persistence, event dispatch, cancellation, concurrency, and
the session a turn belongs to. You never construct a run id, write a status, or decide when a
paused run may resume.

## Next

- [Quickstart](/meet-agentdeck/quickstart) - run the first agent.
- [Runs](/runs-and-control/runs) - identity, handles, and picking a run up again.
- [Lifecycle & Control](/runs-and-control/lifecycle-and-control) - the six states and what is legal in each.
- [Events](/runs-and-control/events) - reading the stream.

---

# Overview

*What AgentDeck is, the problem it solves, and what you keep when you adopt it.*
Source: https://agentdecksdk.com/meet-agentdeck/overview

# Overview

AgentDeck is a production runtime and harness for AI agents and multi-agent workflows.

Writing the prompt is the short part. Everything after it is infrastructure you did not set out to
build, and AgentDeck is that infrastructure.

## The problem it solves

| What you hit | What AgentDeck gives you |
|---|---|
| The agent forgets the last turn | sessions that carry history across runs |
| A run needs a human to approve something | pause, resume and answer on a live handle |
| The process died mid-run | durable run identity you can pick up again, in another process |
| "What did it actually do?" | an append-only log of every token, tool call and state transition |
| A tool needs your database | typed, request-scoped context injected into tools and workflows |

None of that is model work, and all of it stands between a prototype that runs and something you
can operate.

## What you keep

- **Your model provider.** An agent selects OpenAI, Anthropic, Gemini, Ollama or OpenRouter by
  model prefix, and each reads its own credential.
- **Your tools.** MCP servers attach to a `Deck` directly, and an OpenAI Agents SDK tool you
  already built passes through uncompiled. An SDK agent passes through as a handoff target.
- **Your execution.** The agent loop stays in the SDK, or in a `@workflow`, which is ordinary
  Python with no engine underneath it. AgentDeck owns configuration and orchestration, not the loop.

## What it asks of you

One composition root. Declare agents, tools, workflows and skills, hand them to a `Deck`, and run
them. AgentDeck takes over run identity, lifecycle, persistence, event dispatch, cancellation and
concurrency, and does not ask you about any of it again.

## Next

- [Quickstart](/meet-agentdeck/quickstart) - install the SDK and run your first agent.
- [Mental Model](/meet-agentdeck/mental-model) - the four primitives and how they fit together.
- [Build Your Deck](/build-your-deck/agents) - agents, tools, workflows and context in depth.

---

# Quickstart

*Build a Deck, start a Run, and watch its events.*
Source: https://agentdecksdk.com/meet-agentdeck/quickstart

# Quickstart

Build a Deck, start a Run, and watch its events.

## ◆ 01 : INSTALL

```bash
pip install agentdeck-sdk
```

## ◆ 02 : BUILD YOUR DECK

Compose an agent into a Deck:

```python
from agentdeck import Agent, Deck

agent = Agent(
    name="assistant",
    model="gpt-4o-mini",
    instructions="You are a concise assistant.",
)

deck = Deck(agents=[agent])
```

## ◆ 03 : START A RUN

Execute the agent within the Deck's runtime context:

```python
async def main():
    async with deck:
        run = await deck.runs.start("assistant", input="Hello!")
        async for event in run.events(follow=True):
            print(event.kind)
        result = await run
        print("Status:", await run.status())
        print("Result:", result.output)
```

`follow=True` streams until the run reaches a terminal event. Without it you get only what the
log already holds, which for a run this young is one event. `run.status()` is a coroutine, not a
property.

## ◆ 04 : WATCH WHAT HAPPENED

Running the script emits an ordered sequence of lifecycle and content events:

```text
run.started
text.delta
usage.reported
message.completed
run.completed

Status: completed
Result: Hello!
```

`text.delta` is one streamed fragment and there is usually more than one; `message.completed`
carries the finished text. Every kind a run can emit is listed in the
[events reference](/reference/events).

## What you just used

- **Agent**: Your executable component.
- **Deck**: The composition root for your agents, workflows, tools, and skills.
- **Run**: A first-class execution you can observe and control.
- **Events**: The ordered record of what happened during that Run.

<BrandCallout type="runtime" title="A RUN IS CONTROLLABLE">
A Run is not just a return value. It is a living, controllable execution with safe-point pause, resume, and cancellation:

```python
await run.pause()
await run.resume()
await run.cancel()
```
</BrandCallout>

## Next

- [Add a Tool](/build-your-deck/tools) -> Give your agents callable capabilities.
- [Define Agents](/build-your-deck/agents) -> Build decision-making LLM agents.
- [Workflows](/build-your-deck/workflows) -> Build multi-step deterministic graphs.
- [Understand Runs & Control](/runs-and-control/runs) -> Learn lifecycle, streaming, and inspection.
- [Bring an Existing Agent](/integrations/existing-agents) -> Wrap OpenAI Agents SDK agents into a Deck.
- [API Reference](/reference/deck) -> Full reference for Deck and Run.

---

# Changelog

*What changed in each release of AgentDeck, and what to do about it when upgrading.*
Source: https://agentdecksdk.com/resources/changelog

{/* Generated by scripts/generate_docs_reference.py from CHANGELOG.md  -  do not edit. */}

# Changelog

Rendered from the repository's own `CHANGELOG.md`, so this page cannot drift from it. Entries
are written for someone using the package: what changed, and what to do about it.

The current release is **v5.0.3**. Earlier releases are listed at the bottom.

## v5.0.3

*Released 2026-08-23.*

### Changed

- **A cancelled run's log is closed for good.** Appending to a run that already has its
  `run.cancelled` raises `RunStateError` on every store instead of landing behind that event, which
  is what a write already in flight when `Deck.aclose()` abandoned the run used to do. A takeover's
  `run.failed` still seals nothing: it is written for a run only believed dead, and one that turns
  out to be alive goes on writing and may reclaim its own session.

### Fixed

- **A cancelled `answer()` or `resume()` no longer leaves a run claimed but unplayed.** Both claim
  the run before there is anything to hand back, and a cancellation in between left the log saying
  the run was live with nothing playing it and its session held until the staleness window passed.
  The run is closed with `run.cancelled` instead, so the record says what happened and the session
  is free at once.
- **`Deck(agents=[...])` refuses a raw Agents SDK agent at construction.** It was admitted
  silently, because a catalog entry only had to have a `.name`, and `build()` then died on
  `AttributeError: 'Agent' object has no attribute 'skills'`. The refusal is a `ConfigError`
  naming the object and pointing at `handoffs=`, which does take a raw SDK agent.
- **Asking two questions at once is refused instead of losing the branch that was already
  waiting.** One run holds one answer, so two `ctx.ask(...)` calls raced under `asyncio.gather`
  overwrote the first branch's future and left it waiting for the life of the run with no error
  and no event. Only the workflow body itself may suspend its run now, so the first such
  `ctx.ask(...)` (or `ctx.safepoint()`) raises `ConfigError` and the run fails without ever
  parking. Fan out with `ctx.parallel(ctx.invoke(...), ...)` instead, where each child run holds
  its own answer. Not a permanent rule: concurrent questions on one run wait on the answer inbox
  ([#413](https://github.com/agentdecksdk/agentdeck/issues/413)).
- **Correction to 5.0.0: no executor is named `"langgraph"`.** The "engine port is `Executor`"
  entry below lists `LangGraphEngine` becoming `LangGraphExecutor` and keeps `"langgraph"` among
  the wire values. Both went with the engine in that same release; the executors a 5.x deck names
  are `"native"`, `"openai-agents"` and `"stub"`.
- **Known Issues no longer documents removed machinery.** The restart entry named
  `AGENTDECK_CHECKPOINT`, a setting 5.0 removed along with the checkpointer, and one row described
  a graph `interrupt()`. Both are gone from [Known Issues](/resources/known-issues).
- **`run.answer()` says what an ask without options does with the value.** An ask that named
  `options` refuses anything outside them; one that named none hands the value to the body, which
  is the only thing that can judge it. `PendingRun.invocable` now says why the name is general.

## Earlier releases

| Version | Date | Notes |
|---|---|---|
| [v5.0.0](https://github.com/agentdecksdk/agentdeck/releases/tag/v5.0.0) | 2026-08-22 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v5.0.0) |
| [v4.0.5](https://github.com/agentdecksdk/agentdeck/releases/tag/v4.0.5) | 2026-08-19 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v4.0.5) |
| [v4.0.4](https://github.com/agentdecksdk/agentdeck/releases/tag/v4.0.4) | 2026-08-19 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v4.0.4) |
| [v4.0.3](https://github.com/agentdecksdk/agentdeck/releases/tag/v4.0.3) | 2026-08-19 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v4.0.3) |
| [v4.0.2](https://github.com/agentdecksdk/agentdeck/releases/tag/v4.0.2) | 2026-08-19 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v4.0.2) |
| [v4.0.1](https://github.com/agentdecksdk/agentdeck/releases/tag/v4.0.1) | 2026-08-18 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v4.0.1) |
| [v4.0.0](https://github.com/agentdecksdk/agentdeck/releases/tag/v4.0.0) | 2026-08-16 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v4.0.0) |
| [v3.1.0](https://github.com/agentdecksdk/agentdeck/releases/tag/v3.1.0) | 2026-08-13 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v3.1.0) |
| [v3.0.1](https://github.com/agentdecksdk/agentdeck/releases/tag/v3.0.1) | 2026-08-12 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v3.0.1) |
| [v3.0.0](https://github.com/agentdecksdk/agentdeck/releases/tag/v3.0.0) | 2026-08-11 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v3.0.0) |
| [v3.0.0b1](https://github.com/agentdecksdk/agentdeck/releases/tag/v3.0.0b1) | 2026-08-10 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v3.0.0b1) |
| [v2.0.0](https://github.com/agentdecksdk/agentdeck/releases/tag/v2.0.0) | 2026-08-06 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v2.0.0) |
| [v2.0.0b4](https://github.com/agentdecksdk/agentdeck/releases/tag/v2.0.0b4) | 2026-08-06 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v2.0.0b4) |
| [v2.0.0b3](https://github.com/agentdecksdk/agentdeck/releases/tag/v2.0.0b3) | 2026-08-05 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v2.0.0b3) |
| [v2.0.0b2](https://github.com/agentdecksdk/agentdeck/releases/tag/v2.0.0b2) | 2026-08-05 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v2.0.0b2) |
| [v2.0.0b1](https://github.com/agentdecksdk/agentdeck/releases/tag/v2.0.0b1) | 2026-08-05 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v2.0.0b1) |
| [v1.2.1](https://github.com/agentdecksdk/agentdeck/releases/tag/v1.2.1) | 2026-08-03 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v1.2.1) |
| [v1.2.0](https://github.com/agentdecksdk/agentdeck/releases/tag/v1.2.0) | 2026-07-28 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v1.2.0) |
| [v1.1.0](https://github.com/agentdecksdk/agentdeck/releases/tag/v1.1.0) | 2026-07-27 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v1.1.0) |
| [v1.0.0](https://github.com/agentdecksdk/agentdeck/releases/tag/v1.0.0) | 2026-07-27 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v1.0.0) |
| [v0.2.0](https://github.com/agentdecksdk/agentdeck/releases/tag/v0.2.0) | 2026-07-26 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v0.2.0) |
| [v0.1.0](https://github.com/agentdecksdk/agentdeck/releases/tag/v0.1.0) | 2026-07-26 | [release notes](https://github.com/agentdecksdk/agentdeck/releases/tag/v0.1.0) |

---

# Known Issues

*Defects in the current release that will surprise you, what happens, and what to do until each is fixed.*
Source: https://agentdecksdk.com/resources/known-issues

# Known Issues

Everything here is real, reproduced, and open against **v4.0.0**. It is published rather than
quietly tracked because most of these fail *silently*  -  you get a plausible wrong answer, not an
error  -  and an hour spent debugging one of them is an hour this page could have saved.

<Callout type="warning">
  The worst kind left: **a tool returning something unserializable still reaches the model as a
  raw memory address.** If an agent's answers look subtly corrupted, read that entry first.
</Callout>

## Silent wrong answers

These produce no error. Nothing in the log says anything went wrong.

### A tool returning something unserializable reaches the model as a memory address

Return a value JSON cannot carry and it is neither rejected nor flagged  -  it is coerced to its
`repr()`, and that string, typically containing a raw memory address, enters both the event log
and the prompt. `result_sha256`/`result_size` are computed over that same repr, so two identical
non-serializable results are recorded as two different ones.

This was filed as #251 and closed by folding its done-when items into #250 (below), on the
reasoning that both are "a tool result mishandled in the same translation function". #250's fix
shipped only the raise half; this half  -  the warning and the hashing fix  -  was never
implemented. The behavior described here is unchanged in the tree, and no open issue currently
tracks it.

**Until it is fixed:** return JSON-compatible values from tools. If you return an object today,
check what the model is actually receiving.
&nbsp;→ [#251](https://github.com/agentdecksdk/agentdeck/issues/251), folded into
[#250](https://github.com/agentdecksdk/agentdeck/issues/250), which did not implement this half

## Rough edges

Not silent, not traps  -  just things that will cost you a few minutes.

| What | Issue |
|---|---|
| `run()`'s return type makes the documented interrupt idiom fail a type checker | [#231](https://github.com/agentdecksdk/agentdeck/issues/231) |
| The CLI can send signals but cannot read anything  -  no inbox, no run view | [#256](https://github.com/agentdecksdk/agentdeck/issues/256) |
| `Deck.asgi()` cannot serve an agent whose tools declare a `ToolCtx` | [#227](https://github.com/agentdecksdk/agentdeck/issues/227) |

## Fixed in v3.1.0

Removed from this page rather than struck through, because a Known Issues page that lists fixed
things teaches you to distrust the entries that are still true. Recorded here so an upgrade is
worth it, and in full in the [changelog](/resources/changelog).

| What | Issue |
|---|---|
| `Agent(model=...)` was ignored  -  every run used `OPENAI_MODEL` | [#247](https://github.com/agentdecksdk/agentdeck/issues/247) |
| Checkpointer connection failures surfaced as raw driver exceptions | [#233](https://github.com/agentdecksdk/agentdeck/issues/233) |
| `agentdeck-serve --help` crashed instead of printing usage | [#245](https://github.com/agentdecksdk/agentdeck/issues/245) |

## Fixed in v4.0.0

| What | Issue |
|---|---|
| A worker killed outright held its session for up to an hour; a run now holds a lease, and with `AGENTDECK_CONTROL=sqlite:///<path>` the session is free within one 90s TTL | [#244](https://github.com/agentdecksdk/agentdeck/issues/244) |
| A handoff against a non-OpenAI endpoint returned a bare 400; set `AGENTDECK_RUNNER_HANDOFF_ENDS_ON_USER_TURN=true` to append the closing user turn those providers require. Off by default, since it changes what every model sees on every handoff | [#178](https://github.com/agentdecksdk/agentdeck/issues/178) |
| A pause or cancel could land on the wrong tenant's run when two namespaces shared a caller-supplied `run_id` | [#315](https://github.com/agentdecksdk/agentdeck/issues/315) |
| A parked approval was destroyed by the staleness timer once its window passed | [#311](https://github.com/agentdecksdk/agentdeck/issues/311) |
| `sleep_until` never woke up: an open Deck now sweeps for its own lifetime | [#303](https://github.com/agentdecksdk/agentdeck/issues/303) |
| A tool that raises completed the run with `tool.call.completed.error` never set | [#250](https://github.com/agentdecksdk/agentdeck/issues/250) |
| Cancelling a run waiting on a human did nothing | [#229](https://github.com/agentdecksdk/agentdeck/issues/229) |
| A default install could not run a `durable=True` workflow  -  the SQLite checkpointer moved into base dependencies | [#232](https://github.com/agentdecksdk/agentdeck/issues/232) |
| An engine failure over HTTP returned bare text rather than the documented 500 shape | [#243](https://github.com/agentdecksdk/agentdeck/issues/243) |
| Omitting `context` on `answer()` silently gave the re-run node `None`  -  now demonstrated in the human-approval guide | [#255](https://github.com/agentdecksdk/agentdeck/issues/255) |
| `redis` was a base dependency because the default session path imported it unconditionally | [#253](https://github.com/agentdecksdk/agentdeck/issues/253) |
| A `DataBlock` could not be sent *to* a model, only received from one | [#226](https://github.com/agentdecksdk/agentdeck/issues/226) |

<Callout type="info">
  `usage.usd` used to be listed on this page. It isn't a defect: agentdeck does not price model
  calls, on purpose ([#177](https://github.com/agentdecksdk/agentdeck/issues/177))  -  see
  [`TurnResult`](/reference/deck#turnresult) for the ruling, not a fix.
</Callout>

## What is being done about all this

The hardening milestone most of this page belonged to shipped as **v4.0.0** and closed. What is
left above is being re-evaluated against the execution model **v5.0.0** introduces rather than
carried forward as-is: the surfaces some of these entries describe are the ones it replaces. See
the [GitHub issues](https://github.com/agentdecksdk/agentdeck/issues).

<Callout type="info">
  Hit something that is not here?
  [Open an issue](https://github.com/agentdecksdk/agentdeck/issues/new). A reproduction is worth more
  than a diagnosis  -  most of this page came from people who sent one.
</Callout>

---

# migration-guides


Source: https://agentdecksdk.com/resources/migration-guides

# Migration Guides

## v4 to v5.0.0

v5.0.0 is a breaking release: a v5 build cannot read an event log v4 wrote, and several public
names moved. There is no compatibility shim for any of these. Read every section before
upgrading a running deployment.

### Event log: v5 refuses a v4 log

The envelope's schema version bumped from `major=3` to `major=4`. SQLite and Postgres refuse a
4.x log outright, before parsing a single event:

| Store | On a 4.x log |
|---|---|
| SQLite | `Deck` opening it raises `StoreError` |
| Postgres | Same: `StoreError` |
| Redis | No such check. Its keys were shaped differently under 4.x; do not point a v5 process at 4.x Redis data |

```python illustrative reason="requires a project pointed at a 4.x SQLite event log"
from agentdeck import Deck, StoreError

try:
    async with Deck.from_project() as deck:
        pass
except StoreError as exc:
    print(exc)
```

The message names exactly why:

```
the event log at '<path>' was written by agentdeck 4.x (its 'events' table still has the
'log_key' column 5.0 replaced with 'session_id'). agentdeck 5.0 does not migrate a 4.x log:
replay it into a new store, or reopen it with the 4.x version that wrote it.
```

Underneath that guard is a second, unconditional one: `Event` itself refuses any schema major
but its own, so even a hand-rolled reader cannot mix the two.

**What to do:** drain a 4.x deployment (let every open run finish under v4) before pointing a v5
process at its store, or keep the v4 build around to read that log. There is no migration path
and no store that reads across the boundary.

### `EnginePort` is `Executor`, and `start`/`resume` become one `execute`

| v4 | v5 |
|---|---|
| `EnginePort` (in `agentdeck.core.ports`) | `Executor` |
| `agentdeck/adapters/engines/` | `agentdeck/adapters/executors/` |
| `InvocableSpec.engine` | `InvocableSpec.executor` |
| `EnginePort.start(...)` and `EnginePort.resume(...)`, two methods | `Executor.execute(...)`, one method |

This is a restructuring, not a rename. `execute` takes the same `history` a resumed run always
carried, and reads off it whether this play is fresh, a replayed pause, or an answered interrupt;
there is no separate `resume` left to implement. `Executor.aclose()` is new and optional (default:
no-op) for an executor that holds something to release when the deck closes.

**What to do:** fold a custom `EnginePort.start`/`resume` pair into one `Executor.execute`. Wire
values are unchanged: an executor is still selected by the name it always used (`"native"`,
`"openai-agents"`, `"stub"`), and `run.failed` still carries `error_code="engine_error"`.

### LangGraph is removed, not deprecated

`agentdeck/adapters/engines/langgraph/` and the `langgraph` dependency are gone. There is no
drop-in replacement.

**What to do:** rewrite a LangGraph-backed workflow as a native
[`@workflow`](/build-your-deck/workflows), which needs no engine at all.

### `EventSinkPort` is `Observer`

```python run
from agentdeck.core.ports import Executor, Observer
from agentdeck.observers import ConsoleObserver, FileObserver, LangfuseObserver

print(Executor.__name__, Observer.__name__, LangfuseObserver.__name__)
```

| v4 | v5 |
|---|---|
| `EventSinkPort` | `Observer` |
| `agentdeck.observers.Langfuse` | `agentdeck.observers.LangfuseObserver` |

`ConsoleObserver` and `FileObserver` are new in v5, alongside the rename.

**What to do:** rename `Langfuse(...)` to `LangfuseObserver(...)` wherever it is constructed, and
`EventSinkPort` to `Observer` in any custom implementation.

### Runtime settings: no `config.yaml`

v4 read settings from environment variables, a project `.env`, and a shared `config.yaml`
(resolved via `AGENTDECK_CONFIG_PATH` → cwd → a packaged default). v5 removes the YAML source
entirely: every setting comes from a process environment variable or the project's `.env`, and
nothing else.

**What to do:** move every key a `config.yaml` held into `AGENTDECK_*` environment variables (see
the full list on the [Settings reference](/reference/settings)), and delete the file. A
`config.yaml` left behind is not read.

---

# troubleshooting


Source: https://agentdecksdk.com/resources/troubleshooting

# Troubleshooting

Common operational errors and how to resolve them.

## Common Issues

- **Session is busy:** A run is already active on this session. Wait for completion or cancel it before starting another.
- **Port conflicts:** Configure `AGENTDECK_SERVE_PORT` when running the HTTP server.

---

# Events

*One ordered log per run, how to stream it live, and how to read it back afterwards.*
Source: https://agentdecksdk.com/runs-and-control/events

# Events

Every run produces one ordered, typed log. Every managed invocation appends to it, whatever
started the run, and a run's status is folded from it rather than stored beside it, so there is no
second source that can disagree.

## Streaming a run as it happens

```python
async for event in run.events(follow=True):
    print(event.kind)
```

```text
run.started
tool.call.started
text.delta
message.completed
run.completed
```

**`follow=True` is what makes this live.** Without it, `events()` returns only what the log already
holds and stops, which for a run that has just started is a single event. That is the right default
for reading a finished run back and the wrong one for watching a live one.

The loop ends at the segment's own boundary: a terminal event (`run.completed`, `run.failed`,
`run.cancelled`) or a suspension (`run.paused`, `run.interrupted`). Call it again after a resume
to see what came next.

## Reading one back afterwards

```python
run = await deck.runs.get(run_id)

async for event in run.events():           # everything, no waiting
    ...

async for event in run.events(from_seq=120):   # only what is new to you
    ...
```

`from_seq` resumes from a position you already have, so a consumer that crashed does not replay
what it already processed. `seq` is assigned by the store, not the producer, and counts from 0
within a run.

## Streaming without a handle

When you want the events and the result in one pass and do not need the handle:

```python
async for event in deck.stream("Jack", question):
    if event.kind == "text.delta":
        print(event.payload.text, end="")
```

## Switching on kind

The payload is typed per kind, so a consumer branches on `event.kind` and gets the right fields:

```python
if event.kind == "tool.call.started":
    print(event.payload.tool, event.payload.args)
elif event.kind == "text.delta":
    print(event.payload.text, end="")
```

An unknown kind is carried rather than rejected, so a reader written today keeps working against a
log written by a newer release of the same schema major.

## Related

- [Event types](/reference/events) - all 22 kinds, their payloads, and the envelope
- [Runs](/runs-and-control/runs) - starting a run and rehydrating its handle
- [Lifecycle & Control](/runs-and-control/lifecycle-and-control) - which events set which status

---

# human-input


Source: https://agentdecksdk.com/runs-and-control/human-input

# Human Input

Suspend execution for human approval or additional inputs.

## Answering Prompts

When a run enters `waiting_answer`, supply input with `run.answer()`:

```python
await run.answer({"approved": True})
```

---

# Lifecycle & Control

*The six states a run can be in, what moves it between them, and which ones you can act on.*
Source: https://agentdecksdk.com/runs-and-control/lifecycle-and-control

# Lifecycle & Control

A run is in exactly one of six states. Status is folded from the event log rather than stored
beside it, so there is no second source that can disagree with what happened.

## The states

| Status | Meaning | Terminal |
|---|---|---|
| `running` | Executing now | no |
| `paused` | Stopped cooperatively at a safe point | no |
| `waiting_answer` | Parked on an interrupt, waiting for input | no |
| `completed` | Finished, with a result | yes |
| `failed` | Finished, with an error | yes |
| `cancelled` | Stopped and will not continue | yes |

There is no queued state. A run is `running` from the moment it starts.

```python
from agentdeck.core.status import RunStatus

RunStatus.RUNNING, RunStatus.PAUSED, RunStatus.WAITING_ANSWER
RunStatus.COMPLETED, RunStatus.FAILED, RunStatus.CANCELLED
```

## What moves a run between them

Each lifecycle event sets exactly one status, which is why the log and the status can never
disagree.

| Event | Resulting status |
|---|---|
| `run.started` | `running` |
| `run.paused` | `paused` |
| `run.interrupted` | `waiting_answer` |
| `run.resumed` | `running` |
| `run.completed` | `completed` |
| `run.failed` | `failed` |
| `run.cancelled` | `cancelled` |

## What you can act on

`paused` and `waiting_answer` are the two suspended states, and each refuses the other's
operation: lift a pause with `resume()`, answer an interrupt with `answer(value)`. The three
terminal states accept nothing, and asking anyway returns quietly rather than raising.

```python
run = await deck.runs.start("Jack", question)

await run.pause()          # running -> paused, at the next safe point
await run.resume()         # paused -> running
await run.cancel()         # -> cancelled
await run.answer(value)    # waiting_answer -> running

status = await run.status()   # a coroutine, not a property
```

`pause` and `cancel` are requests, not interrupts. The run records the signal, then acts on it
when it next reaches a safe point: between stream items, before dispatching a tool, or at a node
boundary. Two events make that visible, `control.requested` when the signal is recorded and
`control.observed` when the run picks it up, so a control that has not taken effect yet is
distinguishable from one that was never seen.

### Capability matrix

Which operations are legal from each state:

| State | `pause()` | `resume()` | `cancel()` | `answer()` |
|---|---|---|---|---|
| `running` | ✓ | -- | ✓ | -- |
| `paused` | -- | ✓ | ✓ | -- |
| `waiting_answer` | ✓ | -- | ✓ | ✓ |
| `completed` / `failed` / `cancelled` | -- | -- | -- | -- |

`✓` is legal; `--` is not, and covers both a refusal that raises and a call that quietly does
nothing. `PRECONDITIONS` in `agentdeck/core/status.py` carries the exact verdict and reason
attached to each cell.

## Related

- [Runs](/runs-and-control/runs) - starting a run and getting the handle back
- [Pause / Resume](/runs-and-control/pause-resume) - safe points in more detail
- [Human Input](/runs-and-control/human-input) - answering a run parked at an interrupt
- [Events](/reference/events) - every event kind and its payload

---

# pause-resume


Source: https://agentdecksdk.com/runs-and-control/pause-resume

# Pause / Resume

Pause executing runs and resume them safely across processes or workers.

## Resuming Runs

```python
await run.pause()
await run.resume()
```

---

# Runs

*A run is a first-class execution with a durable identity you can pick up again, in this process or another one.*
Source: https://agentdecksdk.com/runs-and-control/runs

# Runs

A `Run` is one execution of an agent or workflow. It is not a return value you either catch or
lose: it has an identity, a log, and a handle you can get back later.

## Starting one

```python
async with deck:
    run = await deck.runs.start("Jack", "how do I pause a run?")
    result = await run
```

`start` returns the handle immediately; `await run` waits for the result. A `TurnResult` for an
agent, the workflow's own result for a workflow  -  a native body's return value, a graph's final
state.

## Its identity

| Attribute | What it is |
|---|---|
| `run.id` | Minted by AgentDeck, globally unique |
| `run.key` | The application identity you chose, if you passed one |
| `run.namespace` | The label this run was started under |
| `run.session_id` | The conversation it belongs to, if any |

`key` is yours to pick and is how you find a run again without storing its `id`:

```python
run = await deck.runs.start("Jack", question, key=f"ticket-{ticket_id}")
```

## Picking it up again

`deck.runs.get()` rehydrates a handle to a run that already exists. It never creates, starts or
resumes anything, and it returns a run in any state, terminal included.

```python
run = await deck.runs.get(run_id)                       # by canonical id
run = await deck.runs.get(key="ticket-42")              # by your own key
runs = await deck.runs.list(status=RunStatus.PAUSED)    # everything parked
```

**This works from another process.** A run paused by a web request can be resumed by a worker,
because the handle carries durable identity and reads durable state rather than anything the
original process held in memory. Two handles on one run always agree: the store is the only thing
either of them reads.

<Callout type="warning">
Rehydration is only as durable as the stores behind it. `AGENTDECK_EVENTS` defaults to `memory://`,
which is per-process, so a second process will not find the run. Set it to a `sqlite://`,
`postgresql://` or `redis://` URL before relying on this. See [Settings](/reference/settings).
</Callout>

A rehydrated run takes no `context`: the ephemeral Python object the first process held is gone,
and the run recovers its durable state instead.

One thing rehydration is **not**: running two `Deck` objects side by side in a single process.
That is a separate constraint and it is deliberate, unrelated to whether a run can be picked up
elsewhere.

## Watching it happen

```python
async for event in run.events(follow=True):
    print(event.kind)
```

Without `follow=True` you get only what the log already holds, which for a run that just started
is one event. See [Events](/runs-and-control/events).

## Handoffs

A run's active agent can change mid-conversation: one agent hands off to another, and the same
run keeps going under the new one. `run.id` and `run.session_id` do not change; the log records
only that the agent did:

```python
async for event in run.events():
    if event.kind == "agent.changed":
        print(event.payload.previous_agent, "->", event.payload.next_agent)
```

A handoff that was requested but failed or was refused leaves no `agent.changed` behind.

## Related

- [Sessions](/runs-and-control/sessions) - conversation history across runs
- [Lifecycle & Control](/runs-and-control/lifecycle-and-control) - the six states, and pausing
- [Run API](/reference/run) - every method on the handle

---

# Sessions

*Keep an agent's message history across multiple runs, and what happens when two runs reach for the same one at once.*
Source: https://agentdecksdk.com/runs-and-control/sessions

# Sessions

A session is a conversation's identity across multiple runs. Pass the same `session_id` to
`deck.run()` (or `stream`/`runs.start()`) and the agent sees every earlier turn in that
conversation; pass a different one, or none, and the run starts with no memory of any other.

## Continuing a conversation

```python run
import asyncio

from agentdeck import Agent, Deck

assistant = Agent(name="Assistant", instructions="Keep replies to one short sentence.")


async def main() -> None:
    async with Deck(agents=[assistant]) as deck:
        first = await deck.run("Assistant", "My name is Ada.", session_id="conversation-1")
        second = await deck.run("Assistant", "What's my name?", session_id="conversation-1")
        assert first.session_id == second.session_id == "conversation-1"


asyncio.run(main())
```

Both runs share `session_id="conversation-1"`, so the second run's model call carries the first
run's messages along with its own. Every run gets a session either way: leave `session_id` out and
it is scoped to that run's own `run.id`, so nothing carries into the next call.

## One turn per session at a time

A session's history changes while a run is using it, so only one run may hold a session at a time.
Starting a second run against a session that already has one open raises `SessionBusyError`
instead of running against a conversation still being written:

```python run
import asyncio

from agentdeck import Agent, Deck, SessionBusyError

assistant = Agent(name="Assistant", instructions="Keep replies to one short sentence.")


async def main() -> None:
    async with Deck(agents=[assistant]) as deck:
        first = await deck.runs.start("Assistant", "hello", session_id="conversation-2")
        try:
            await deck.runs.start("Assistant", "hello again", session_id="conversation-2")
        except SessionBusyError as busy:
            print(busy)
        await first


asyncio.run(main())
```

The message names the run holding the session and, when that run is not actually executing, the
call that frees it:

| Holding run's state | Message says | Fix |
|---|---|---|
| Running | session already has a run in flight | wait for it to finish, or give the new turn a different `session_id` |
| Paused | held by run `<id>`, paused | `run.resume()` or `run.cancel()` on that run |
| Waiting for an answer | held by run `<id>`, parked waiting for an answer | `run.answer(...)` or `run.cancel()` on that run |

A run whose process was killed outright never reaches an ending, so without a fix it would hold
its session forever. AgentDeck frees it two ways: a worker sharing `AGENTDECK_CONTROL=sqlite://...`
notices the dead run's lease has lapsed and takes over immediately; failing that, any worker frees
it once the run has gone silent for `AGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS` (one hour by
default). A **paused** or **waiting-for-answer** run is never freed this way: it holds its session
until something resumes, answers, or cancels it, however long that takes.

## Where the history lives

| `AGENTDECK_SESSION` | Backend | Notes |
|---|---|---|
| unset (default) | in-process, per session key | lost when the process exits; not shared across workers |
| `redis://...` | Redis | needs `pip install "agentdeck-sdk[redis]"`; shared across processes and workers; survives a restart |

`AGENTDECK_SESSION_REDIS_KEY_PREFIX` and `AGENTDECK_SESSION_REDIS_TTL` tune the Redis backend's key
prefix and per-session expiry; see [Settings](/reference/settings).

## Related

- [Runs](/runs-and-control/runs) - starting a run and rehydrating its handle
- [Lifecycle & Control](/runs-and-control/lifecycle-and-control) - pausing, resuming, and cancelling
- [Deck](/reference/deck) - `session_for()` and injecting a `session_factory`

---
