# 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, durable human-in-the-loop
approvals, run control, and one ordered event log per run.

It wraps the OpenAI Agents SDK and LangGraph rather than replacing them: an `Agent` compiles to an
SDK agent, a `Workflow` compiles to a LangGraph graph, and each is executed by its own engine.
AgentDeck owns configuration; the engines own execution. There is no agent loop here and no graph
engine of its own.

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/Workflow objects 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`/`Workflow` objects,
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
Deck(
    agents=[...],                   # Agent instances
    workflows=[...],                # Workflow instances
    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=[Langfuse()],         # 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, MCP server, or
  workflow-as-tool 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 `Context[...]` parameter in
the catalog before anything runs:

```python run
from agentdeck import Agent, Context, 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: Context[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 is walked: an agent's tools, its `instructions=` callable, its `hooks=`
methods, and every node of every workflow. The message names the callable (or the node, or the
hook method), what it requires, and what the deck provides.

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 |
| `Context[Any]`, or a bare `Context` | 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  -  `Context[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 node 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 final state (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 (same
id, same history across calls) or a durable workflow's `thread_id` (required when that workflow is
`durable = True`, so a later call with the same id resumes it). `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 node that calls `interrupt()` makes a workflow's `run`
return an `InterruptResult`  -  `{"type": "interrupt", "payload": ..., "thread_id": ..., "id": ...}`
 -  instead of a final state; see [Workflows](/build-your-deck/workflows) for what that means for the node
that paused, and answer it with the handle its own `id` names, [below](#run).

### 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` is its initial state  -  the mapping its first node reads, e.g.
`deck.run("RefundApproval", {"order_id": "A-1003"})`. It is JSON data, not content, and the log
records it as one `data` block; see [Workflows](/build-your-deck/workflows) for what a node 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 not sent to the model
at all  -  a `uri` is a pointer, not content, and the engine never fetches it. `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, `node.updated` per completed node for a workflow, 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 node that declares an `agentdeck.Context` 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 three 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 or timer-driven 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 |
| `pause` | `(reason=None) -> bool` | ask it to stop at its next safe point; `False` means no control backend is configured |
| `resume` | `() -> None` | continue a paused run; a no-op if it is not, in fact, paused |
| `cancel` | `(reason=None) -> bool` | 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 graph's own state 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 node that read `ctx.data` before the pause or
interrupt reads `None` on that replay.

**The timer sweep (`sleep_until`'s wake-up) is internal, but it runs on its own  -  no cron or
scheduler required.** An open `Deck` sweeps for its own lifetime: started in `__aenter__`,
cancelled in `__aexit__`, on an interval set by `AGENTDECK_RUNTIME_SWEEP_INTERVAL_SECONDS`
(30 seconds by default). It reads each workflow's own checkpointer rather than the Runtime's log,
deliberately: the checkpoint backend defaults to durable (`sqlite`) while the event store
defaults to `memory`, and listing off the log alone would stop surviving a process restart under
that default pairing. Its resume goes through the Runtime whenever a due thread matches a run
`deck.runs.list(status=RunStatus.WAITING_ANSWER)` already knows about  -  closing that run's log
entry and freeing its session claim, the same as `Run.answer()` does  -  and carries no context,
for the same reason a handle from `runs.get()` has none: see
[below](#where-a-context-does-not-reach). A process that opens the deck, takes a turn and closes
within one interval never sweeps at all  -  the deadline fires on whoever next holds the deck open
past that.

## 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.

```python
from agentdeck import Deck
from agentdeck.observers import Langfuse

deck = Deck(agents=[booking], observers=[Langfuse()])

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

| `observers=` | What starts |
|---|---|
| *(omitted, or `None`)* | The configured `Langfuse()` 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 `Langfuse()`  -  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. |

`Langfuse()` 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=[Langfuse(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 `EventSinkPort`  -  one required method, two optional lifecycle hooks:

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


class CostObserver(EventSinkPort):
    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 `EventSinkPort` 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 `Workflow.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">
  `Langfuse()` 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 wrapping `fakeredis` 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

Four 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 internal timer sweep cannot resume a context-requiring workflow.** The unsupported
combination is specifically *`durable = True`* + a node that pauses on `sleep_until` + any node in
that graph declaring `Context[T]`. The sweep's resume supplies no context  -  an autonomous resume
has nobody present to supply one, and the original value was never written to the log or the
checkpoint, so there is nothing to recover either. What actually happens is not an error: the
thread resumes and the graph replays with `ctx.data` set to `None`. That surfaces wherever the
node first touches it  -  an `AttributeError` on `None` if it reaches for an attribute, or, for a
node written defensively as `if ctx.data:`, a plausible wrong answer and no failure at all. This is
a real limitation, stated rather than worked around: there is no deck-level context
provider and no way to supply one to the timer inbox. A `Run`'s own `resume()`/`answer()` are the
only continuations that carry a context, and only because the handle that started it retained one
([above](#run)).

**The headless runners pass 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 NoneType rather than an
AgentDeck run context". `Workflow.run()`, `Workflow.resume()` and `as_tool()` are further out
still: the bridge that injects a context into a node is installed when a `Deck` builds its
catalog, and those paths never install it, so a `Context[...]` node reached that way dies with
LangGraph's own `TypeError: <node>() missing 1 required positional argument`. Both are 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 `Context[...]` parameter.

## What else this does not cover

[Run Control](/runs-and-control/lifecycle-and-control) reaches agent runs today; a workflow (LangGraph)
run has no safe point yet, so `pause`/`cancel` record the request but nothing acts on it.

---

# events


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

# Events Reference

Specification for AgentDeck event schemas and kinds.

## Standard Kinds

- `run.started`: Run begins.
- `message.delta`: Streaming message chunk.
- `message.completed`: Full message complete.
- `tool.call`: Tool invocation initiated.
- `tool.result`: Tool execution completed.
- `run.completed`: Run ended successfully.
- `run.failed`: Run failed with error details.

---

# 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.

---

# 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.

---

# Settings

*Every AGENTDECK_* (and OPENAI_*/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`). Every variable is also settable in the shared `config.yaml`, under the section derived from its env-var prefix (`openai:`, `runner:`, …); an env var wins over the file.

## `OpenAISettings`

OpenAI-compatible endpoint configuration.

| Env var | Type | Default | Description |
|---|---|---|---|
| `OPENAI_MODEL` | `str` | *required* | Model name passed to the host Agents SDK runner. No default  -  always required. |
| `OPENAI_API_KEY` | `str` | `''` | API key for the endpoint. What empty does depends on `ca_bundle`: unset (the common case), the OpenAI client falls through to its own `OPENAI_API_KEY` process-env lookup and errors on the first model call if that's empty too; with `ca_bundle` set, the empty value is passed straight through instead and just sends no Authorization header  -  the self-hosted/corporate-CA case doesn't need a placeholder value the way the common path does. |
| `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. |

## `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. |
| `AGENTDECK_RUNTIME_SWEEP_INTERVAL_SECONDS` | `float` | `30.0` | How often, in seconds, an open Deck sweeps for a `sleep_until` timer whose wake moment has passed and resumes it, with no cron or scheduler required. Must be positive; runs for the Deck's own lifetime, started when it opens and cancelled when it closes. |

## `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 (or YAML `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. |

## `CheckpointSettings`

LangGraph checkpointer backend for `durable=True` workflows, as one scheme-shaped URL.

| Env var | Type | Default | Description |
|---|---|---|---|
| `AGENTDECK_CHECKPOINT` | `str` | `'sqlite://.agentdeck/checkpoints.sqlite3'` | LangGraph checkpointer for `durable=True` workflows: `sqlite://<path>` for dev (this default), `postgresql://<dsn>` for prod, or `memory://` for tests (never persists past the process). The scheme names the backend. |

## `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 `[durability]` 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.engines.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",
)
```

---

# context


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

# Context

Context provides strongly-typed runtime dependencies and request-scoped state to agents and tools.

## Using Context

Pass application context during run initialization to safely inject dependencies.

---

# 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()
```

---

# 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 agentdeck import Agent

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],
)
```

---

# workflows


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

# Workflows

Workflows orchestrate multi-step graphs and deterministic state transitions.

## Declaring a Workflow

```python
from agentdeck import Workflow

workflow = Workflow(
    name="approval_pipeline",
)
```

---

# 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.
- **Workflow with Human Approval:** Pausing for approval before high-stakes actions.
- **Agent with a Skill:** Extending agent capability using domain skills.
- **Existing LangGraph Agent:** Wrapping a LangGraph graph in AgentDeck.
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.

---

# langgraph


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

# LangGraph

Wrap and run LangGraph state graphs with AgentDeck.

## Running LangGraph

Compile LangGraph graphs into workflows with unified event streams and run control.

---

# 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, Context, Deck

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

def read_doc(slug: str, docs: Context[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. `Context[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.

```text
Jack executes through AgentDeck
   → the run emits canonical events
   → a surface streams them
   → the site renders the conversation and the tree beside it
```

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 `Context` 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 `Context[...]` 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


Source: https://agentdecksdk.com/meet-agentdeck/mental-model

# Mental Model

AgentDeck separates **user intent** from **runtime machinery**.

## User Intent vs. Machinery

* **You define:** Agents, tools, workflows, skills, and application context.
* **AgentDeck manages:** Run identity, lifecycle state, persistence, event streams, cancellation, and concurrency.

## Core Primitives

- **Deck:** The composition root holding all registered agents, workflows, and tools.
- **Agent / Workflow:** The declarative definitions of your autonomous components.
- **Run:** An executing instance with an isolated session and immutable event stream.
- **Events:** The typed append-only record of everything that occurred during a run.

---

# Overview

*What AgentDeck is, why it exists, and how it works.*
Source: https://agentdecksdk.com/meet-agentdeck/overview

# Overview

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

## What AgentDeck Does

When building agentic applications, writing model prompts is only the first step. Operating them in production requires runtime infrastructure:

- **State & Sessions:** Managing conversation history and memory across turns.
- **Run Lifecycle:** Pausing for human approval, resuming safely, and handling cancellation.
- **Event Logging:** Capturing every token, tool call, and state transition in an immutable stream.
- **Interoperability:** Running OpenAI Agents and LangGraph without lock-in or forced rewrites.

---

## Architecture at a Glance


- **Agents & Workflows:** Declarative Python definitions of your agents and graphs.
- **Deck:** The single interface holding your catalog of agents, tools, workflows, and skills.
- **Run:** An active execution instance managing lifecycle, storage, and event dispatch.
- **Control & Events:** Real-time steering (pause, resume, answer) and typed observability.

---

## Three Key Principles

1. **Compose cleanly:** Assemble components into a `Deck` with simple declarations.
2. **Bring your stack:** Keep existing framework agents and MCP tools intact.
3. **Control runtime execution:** Gain full control over running executions with reliable persistence.

---

## Start Building

- [Quickstart](/meet-agentdeck/quickstart) - install the SDK and run your first agent.
- [Mental Model](/meet-agentdeck/mental-model) - understand user intent versus runtime machinery.
- [Build Your Deck](/build-your-deck/agents) - explore agents, tools, and workflows.

---

# 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():
            print(event.kind)
        result = await run
        print("Status:", run.status)
        print("Result:", result)
```

## ◆ 04 : WATCH WHAT HAPPENED

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

```text
run.started
agent.message
run.completed

Status: COMPLETED
Result: Hello! How can I help you today?
```

## 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 LangGraph or OpenAI SDK workflows 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 **v4.0.2**. Earlier releases are listed at the bottom.

## v4.0.2

*Released 2026-08-19.*

### Changed

- **`examples/jack` reads `JACK_*` environment variables.** `ASK_AGENTDECK_ORIGINS`,
  `ASK_AGENTDECK_SESSIONS_PER_DAY` and `ASK_AGENTDECK_TURNS_PER_SESSION` are now
  `JACK_ORIGINS`, `JACK_SESSIONS_PER_DAY` and `JACK_TURNS_PER_SESSION`, correcting 4.0.1,
  which said they would keep their names. A deployment that sets the old names falls back to
  the defaults silently, so rename them when you upgrade.
- **The documentation landing page is one continuous build rather than nine chapters.** It
  builds Jack once, from an `Agent` to a live run, and the execution tree beside the code is
  that code's own tree.
- **[How Jack is built](https://agentdecksdk.com/jack) is its own page**, with the decisions
  and the alternatives they beat in
  [Implementation notes](https://agentdecksdk.com/jack/notes). The README follows the same
  build.

### Fixed

- **Jack could not be reached from the published site.** The docs build baked
  `http://localhost:8100` as its API origin whenever `NEXT_PUBLIC_AGENTDECK_API_URL` was
  unset, so the panel called the visitor's own machine. `docs-site/.env.production` now
  carries the origin, and a real environment variable still wins.
- **A second question to Jack replaced the first.** The panel keeps the transcript, so a
  conversation reads as one.

## Earlier releases

| Version | Date | Notes |
|---|---|---|
| [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

## Defaults that will trap you

### Approvals disappear on restart, under the shipped defaults

`AGENTDECK_EVENTS` defaults to `memory://` while `AGENTDECK_CHECKPOINT` defaults to durable
SQLite. `deck.runs.list(status=RunStatus.WAITING_ANSWER)` reads the event log; the timer path
reads the checkpointer. After a restart the two disagree, and a parked approval can never be
answered because the listing returns empty.

**Until it is fixed:** set `AGENTDECK_EVENTS` to a durable store  -  `sqlite://`, `postgresql://`
or `redis://`  -  in anything that outlives one process. See
[Settings](/reference/settings).
&nbsp;→ [#212](https://github.com/agentdecksdk/agentdeck/issues/212)

## 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) |
| `answer()` accepts any value with no validation, and the node owns interpreting it | [#235](https://github.com/agentdecksdk/agentdeck/issues/235) |
| 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 `Context` | [#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

Guidance for upgrading across major AgentDeck releases.

## Upgrading to Current Release

- `Deck` is the single composition root.
- Ensure all environment variables use the `AGENTDECK_*` prefix.

---

# 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


Source: https://agentdecksdk.com/runs-and-control/events

# Events

Every run produces an ordered, typed stream of events.

## Streaming Events

```python
async for event in run.events():
    print(event.kind, event.payload)
```

---

# 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`, supply input with `run.answer()`:

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

---

# lifecycle-and-control


Source: https://agentdecksdk.com/runs-and-control/lifecycle-and-control

# Lifecycle & Control

AgentDeck provides a deterministic state machine for controlling runs.

## States

Runs transition through clear lifecycle states: `QUEUED`, `RUNNING`, `PAUSED`, `WAITING`, `COMPLETED`, `FAILED`, and `CANCELLED`.

---

# 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


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

# Runs

A `Run` represents a single execution of an agent or workflow.

## Starting a Run

```python
from agentdeck import Deck

async def execute(deck: Deck):
    async with deck:
        run = await deck.runs.start("bot", input="Task")
        result = await run
        print(result)
```

---

# sessions


Source: https://agentdecksdk.com/runs-and-control/sessions

# Sessions

Sessions maintain conversation history and state across multiple runs.

## Session Continuity

Specify `session_id` to continue a multi-turn conversation with memory intact.

---
