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

---

# AgentDeck

*Write the agent. AgentDeck owns everything around it.*
Source: https://agentdecksdk.com/

# AgentDeck

<p className="brand-tagline">
  <span>Build the agent.</span>
  <span>Own the runtime.</span>
</p>

You write agents and workflows as small Python definitions, and skills as `SKILL.md`
directories. AgentDeck owns everything around them: project discovery, settings and provider
wiring, tools and MCP servers, sessions, streaming, typed workflows with human approval.
Execution stays in the OpenAI Agents SDK and LangGraph.

## A whole agent

```python
# .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.")
```

Nothing registers it — the file's location is the registration. Run it:

```python
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)


asyncio.run(main())
```

## What you did not write

- **Registration** — drop a file into `.agentdeck/`; discovery does the rest.
- **Provider and runner config** — layered settings from env, `.env`, and `config.yaml`.
- **Conversation memory** — `deck.run(..., session_id=...)` keeps a session across turns and
  surfaces; set `AGENTDECK_SESSION` to keep it across a process restart too.
- **Streaming** — `deck.stream()` and an SSE HTTP surface: same agent, no extra code.
- **Workflow machinery** — graph compilation, durability, and human interrupts from a typed state class.

## What AgentDeck is not

No YAML or JSON agent DSL — definitions are Python. No auth system, no marketplace, no
hosted control plane. It does not reimplement the engines it runs on.

[Install and run one →](/getting-started) · [How the pieces fit →](/concepts)

---

# Getting Started

*Install AgentDeck, point it at a model, and run your first agent.*
Source: https://agentdecksdk.com/getting-started

# Getting Started

Target: a running agent in under five minutes.

## Install

```bash
uv venv && source .venv/bin/activate
uv pip install "agentdeck-sdk[serve]"
```

The distribution on PyPI is **`agentdeck-sdk`**, and the import package is **`agentdeck`** — so
you install one name and import another. The two differ because PyPI refuses `agentdeck` as too
similar to an unrelated placeholder project; `import agentdeck` is what every example here uses
and it is not changing.

`serve` is the extra for the HTTP surface. Others: `durability` for the Postgres checkpointer and
event store, `redis` for Redis-backed sessions or event log, `observability` for Langfuse
tracing. Plain `uv pip install agentdeck-sdk` already gets you a working `durable=True` — the
default SQLite checkpointer ships in base — and is enough for the first agent below.

Contributing to AgentDeck itself instead? Clone the repo and run `make install` — that
path is in `CONTRIBUTING.md`, not here.

## Point it at a model

```bash
export OPENAI_MODEL=gpt-4.1-mini
export OPENAI_API_KEY=sk-...
```

Unset `OPENAI_BASE_URL` means `api.openai.com`. Point it at any OpenAI-compatible server
(a gateway, vLLM, Ollama); Chat-Completions-only servers also need
`OPENAI_USE_RESPONSES=false`.

## Create the project

Definitions live in a `.agentdeck/` directory next to where you run. Location is
registration — there is no catalog file.

```text
.agentdeck/
├── agents/greeter/agent.py        # an Agent(...)
├── workflows/new_booking/workflow.py
└── skills/parse-request/          # SKILL.md
```

One file is a complete agent:

```python file=.agentdeck/agents/greeter/agent.py
# .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.")
```

## Run it

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


asyncio.run(main())
```

For a conversation that remembers across turns, pass a `session_id` instead:

```python run
import asyncio

from agentdeck import Deck


async def main() -> None:
    async with Deck.from_project() as deck:
        await deck.run("Greeter", "book me a slot Tuesday", session_id="sess-1")
        second = await deck.run("Greeter", "actually, make it Wednesday", session_id="sess-1")
        print(second.output)


asyncio.run(main())
```

## Next

- Give an agent reusable instructions — [Skills](/concepts/skills)
- Keep a conversation across turns — [Sessions and Memory](/concepts/sessions-and-memory)
- Make sessions and event logs survive a restart — [Choosing a Store Backend](/concepts/choosing-a-store-backend)
- Check every public constructor, method, setting and CLI flag — [Reference](/reference)

---

# Core Concepts

*The things you write, and what AgentDeck does around them.*
Source: https://agentdecksdk.com/concepts

# Core Concepts

AgentDeck owns configuration; the OpenAI Agents SDK and LangGraph own execution. You write
a few kinds of definition, and the platform supplies everything around them.

| You write | It is | Runs on |
|---|---|---|
| [Agent](/concepts/agents) | keyword arguments: instructions, tools, handoffs | OpenAI Agents SDK |
| [Skill](/concepts/skills) | a directory: `SKILL.md` + optional scripts | disclosed into an agent's own execution |
| [Workflow](/concepts/workflows) | typed state + a graph | LangGraph |

When you need exact constructor arguments, method signatures, environment variables or CLI flags,
use [Reference](/reference). These concept pages explain the model; the reference pages name the
API surface precisely.

## The shape of a project

Everything lives in `.agentdeck/` next to where you run. The path *is* the registration —
no catalog file, no `__init__.py`, no decorator to remember.

```text
.agentdeck/
├── agents/greeter/agent.py            # an Agent(...)
├── workflows/new_booking/workflow.py  # a Workflow(...)
└── skills/parse-request/              # SKILL.md + scripts/
```

`Deck.from_project()` discovers, imports, and compiles all of it, and fails fast if anything is
broken:

```python
import asyncio

from agentdeck import Deck


async def main() -> None:
    async with Deck.from_project() as deck:
        print(sorted(deck.agents), sorted(deck.workflows))


asyncio.run(main())
```

## What runs a turn

`Deck` is also the composition root: it wires the discovered project into one Runtime and hands
it to whatever is serving. A chat served over HTTP — `POST /agents/{name}/chat`, streamed or not
— is played by that Runtime, which records the turn as an event log: the run opening, text
deltas, tool calls, token usage per model call, then the result. The log is in memory by default;
set `AGENTDECK_EVENTS=sqlite:///path/to/events.db` to keep it across restarts, or
`redis://`/`postgresql://` for a log that several workers can share — the URL's own scheme
picks the backend. SQLite's file can be opened by several processes on the same machine, but
not from a second one, which is why `redis` and `postgres` exist as separate options rather
than "just point everyone at the same file." The Python API (`Deck.run`, `Deck.stream`) plays the turn on the same
Runtime, so it records the same events — same agent, same session, same answer, same log.

## The division of labor

Deterministic code mutates external state; models decide. A model call never writes to
your systems directly — a node or a tool does, and those are ordinary Python you can test
without a model.

Agents, workflows, and skills compose in any direction: an agent can be a workflow node
([`AgentNode`](/concepts/workflows)), a workflow can be an agent's tool
(`Workflow.as_tool()`), and a skill can be declared on any agent that needs it, workflow node
or not.

Whatever the shape, a run in flight stays governable: it can be paused, resumed and cancelled
by its id, at documented safe points — see [Run Control](/concepts/run-control).

---

# Agents

*Declarative agent definitions — keyword arguments in, SDK Agent out.*
Source: https://agentdecksdk.com/concepts/agents

# Agents

An agent is an `Agent(...)` call. Pass keyword arguments; `build()` forwards the ones you set to
the Agents SDK constructor and drops the rest so the SDK keeps its own defaults.

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

support = Agent(
    name="Support",
    instructions="Answer customer questions. One short paragraph, no bullet lists.",
    model="gpt-4.1-mini",
    model_settings={"temperature": 0.2},
)
```

`name` is required — it is also the registry key a `Deck` resolves, e.g. `deck.run("Support", …)`.

## What you did not write

No registration, no runner wiring, no provider setup, no session plumbing. The same agent is
reachable one-shot, as a conversation, streamed, over HTTP, and as another agent's tool:

```python run
import asyncio

from agentdeck import Deck
from agentdeck.core import MessageCompleted, RunCompleted, TextDelta


async def main() -> None:
    async with Deck.from_project() as deck:
        await deck.run("Support", "where is my order?")                       # one-shot
        await deck.run("Support", "where is my order?", session_id="wa-123")  # remembers
        async for event in deck.stream("Support", "and now?", session_id="wa-123"):  # streams
            # `type(event)` is always `Event` — the envelope. The discriminator is
            # `event.payload`, whose own `kind` is what the union matched on; an
            # unfamiliar kind still parses (as `UnknownEvent`), so a default case
            # tolerates it instead of crashing on a newer writer.
            match event.payload:
                case TextDelta(text=text):
                    print(text, end="", flush=True)
                case MessageCompleted():
                    print()
                case RunCompleted(usage=usage):
                    print(f"[{usage.input_tokens}+{usage.output_tokens} tokens]")
                case _:
                    pass


asyncio.run(main())
```

## The configuration surface

| Argument | Purpose |
|---|---|
| `instructions` | the system prompt: role, priorities, tool rules, output expectations |
| `model` | per-agent override; `None` uses the `OPENAI_MODEL` setting |
| `model_settings` | forwarded to `agents.ModelSettings(**model_settings)` — temperature, token budget, parallel tool calls |
| `tools` | plain functions (compiled at `build()`), already-built SDK tool objects, or a `Workflow` to expose as a tool — see [Definitions](/reference/definitions) |
| `handoffs` | peers this agent can transfer the conversation to |
| `handoff_description` | how this agent describes itself to a peer deciding whether to hand off |
| `output_type` | a Pydantic model for structured final output; `None` for free text |
| `skills` | names resolved against the owning `Deck`'s `skills=` roots — see [Skills](/concepts/skills) |
| `mcp` | names resolved against the owning `Deck`'s `mcp=` file — the agent says *which*, the file owns transport |
| `hooks` | an `AgentHooks` instance for telemetry and bookkeeping |
| `base` | a reusable `AgentDeclaration` subclass to inherit defaults from — see [Definitions](/reference/definitions) |

## Handoffs by name

`handoffs` accepts a built `Agent`, or a **string registry name** resolved against the whole
catalog at `Deck.build()`. Two agents can hand off to each other without importing each other,
and mutual handoffs resolve without recursing forever:

```python
# .agentdeck/agents/triage/agent.py
from agentdeck import Agent

triage = Agent(
    name="Triage",
    instructions="Route billing questions to Billing, everything else to Support.",
    handoffs=["Billing", "Support"],
)
```

An unknown name raises `NotFoundError` naming the agents that do exist.

## Structured output

Pass a Pydantic model for a strict JSON schema where every field is required. If a smaller
chat-completions model stalls trying to fill every field, wrap it to keep defaults optional:

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

## MCP servers degrade instead of crashing

`build()` is deliberately network-free — it registers the server specs but connects nothing, so
every `mcp=` name compiles against known-but-not-yet-connected servers. Connecting happens when
the `Deck` opens (`async with Deck.from_project() as deck`, its `__aenter__`), right after which
every already-compiled agent's MCP status is re-resolved against what actually connected. A
server that failed to connect at that point is dropped from that agent's tools, with a banner
prepended to its instructions naming what's missing — it costs that agent the toolset, not the
whole process.

Next: give an agent deterministic, non-sandboxed powers with [skills](/concepts/skills).

---

# Capabilities

*Sandbox powers — not part of v3, and no longer in the codebase.*
Source: https://agentdecksdk.com/concepts/capabilities

# Capabilities

Capabilities were what an agent was *allowed to do* inside a sandbox: run commands, edit files,
remember things, compact its own context. They were declared on `BaseSandboxAgent`, the
subclassing entry point v1 offered for a sandboxed agent.

Sandboxing is not part of v3. `BaseSandboxAgent` and the machinery around it went with the v1
API, and the spec classes that described a sandboxed agent's powers (`CapabilitiesSpec`,
`ShellSpec`, `FilesystemSpec`, `MemorySpec`, `CompactionSpec`) have now been removed too —
nothing in `Agent`/`Deck` ever constructed one, and there was no `capabilities=` argument to
pass one to. Keeping unreachable scaffolding in the tree only made the feature look closer than
it is.

It can come back: reintroducing a sandbox port and the declarations that feed it is additive,
so nothing here is a one-way door.

Until then: [Skills](/concepts/skills) covers the deterministic, non-sandboxed half of the same
picture (progressive disclosure of instructions and scripts into an agent's own execution), and
a plain Python `tool=` callable covers most of what `shell`/`filesystem` gave a sandboxed agent
for a specific, narrower task.

---

# Choosing a Store Backend

*Four independent storage decisions, one environment variable each, and what each default costs you.*
Source: https://agentdecksdk.com/concepts/choosing-a-store-backend

# Choosing a Store Backend

AgentDeck keeps four different things somewhere, and each one is its own decision with its own
environment variable. They are independent on purpose — nothing makes them agree — so a
deployment that sets one and forgets the others is the normal way to get a "durable" system that
loses half of what it was supposed to keep.

Every one of them is a single URL whose **scheme picks the backend**. There is no separate
"backend" variable to disagree with the URL.

| Variable | Holds | Default | Reaches another process? |
|---|---|---|---|
| `AGENTDECK_EVENTS` | the canonical event log — every run, every turn | `memory://` | no |
| `AGENTDECK_CHECKPOINT` | LangGraph's state for `durable=True` workflows | `sqlite://.agentdeck/checkpoints.sqlite3` | same machine |
| `AGENTDECK_SESSION` | an agent's conversation history per `session_id` | unset (in-process SQLite) | no |
| `AGENTDECK_CONTROL` | pending pause/cancel signals, and each running run's liveness lease | `memory://` | no |

The exhaustive field list, including the Redis-only session knobs, is in the [settings
reference](/reference/settings) — this page is about which value to pick and what happens if you
pick wrong.

## The defaults are a laptop, not a deployment

Out of the box everything except the checkpointer lives inside the running process. That is the
right default for writing an agent — no services to start, nothing to clean up — and it is
wrong for anything you leave running. Two log lines say so at startup, one for the event log and
one for the control port; they are not noise.

What the defaults actually cost:

- **The event log is gone on restart**, and it never evicts while the process lives, so a
  long-running server accumulates every event it has ever emitted in memory.
- **A second worker sees nothing.** Not a stale view — *nothing*. It has its own log, its own
  session store, and its own control port.
- **The checkpointer is the exception**: it defaults to a SQLite file, so a `durable=True`
  workflow that paused genuinely survives a restart, even when the log of that run does not.

## Which value to set

**One process, and you want the log to survive a restart.** SQLite everywhere it is offered:

```bash
export AGENTDECK_EVENTS=sqlite:///var/lib/agentdeck/events.sqlite3
export AGENTDECK_CONTROL=sqlite:///var/lib/agentdeck/control.sqlite3
# AGENTDECK_CHECKPOINT already defaults to a SQLite file
```

A relative path stays relative (`sqlite://./events.sqlite3`); a third slash makes it absolute.
Several processes on the same machine can share a SQLite file — which is what makes `agentdeck
runs signal` able to reach a run in another terminal — but a file cannot reach a second machine.

The two SQLite users are simpler than they look: the event log and control port use the standard
library, and the SQLite *checkpointer* — LangGraph's — ships in base too, so the `durable=True`
default above works on a plain install with no extra.

**Several workers, or more than one machine.** The event log has to be a service, and so does
the session store:

```bash
export AGENTDECK_EVENTS=postgresql://user:pw@db/agentdeck     # or redis://cache:6379/0
export AGENTDECK_CHECKPOINT=postgresql://user:pw@db/agentdeck
export AGENTDECK_SESSION=redis://cache:6379/1
export AGENTDECK_CONTROL=sqlite:///var/lib/agentdeck/control.sqlite3
```

`postgresql://` — for the event log and the checkpointer both — needs the `durability` extra.
`redis://` — for either the event log or sessions — needs the `redis` extra. The control port
has no networked backend at all today: a signal, and a lease, still only reach a run from the same
machine.

**Tests.** `memory://` for all three that offer it, including
`AGENTDECK_CHECKPOINT=memory://`, so nothing survives the test that wrote it and no file is left
behind.

## Two kinds of store, and why they don't collapse

The event log and the other three are not the same kind of thing, and picking backends is easier
once that is clear.

The **event log** is the shared, append-only record: one ordered sequence per run, and the only
thing a surface, a dashboard, or another worker is allowed to read. See [Runs and the Event
Log](/concepts/runs-and-the-event-log).

The **checkpointer and the session** are engine-private working memory — LangGraph's graph state
and the Agents SDK's conversation history. They are what a run actually resumes *from*, and
nothing outside the engine reads them. See [Sessions and
Memory](/concepts/sessions-and-memory).

The consequence catches people out: **`durable=True` and a shared event log are two separate
requirements.** A workflow can pause durably in a Postgres checkpointer while
[`deck.runs.list(status=RunStatus.WAITING_ANSWER)`](/guides/human-approval) — which reads the
*event log* — shows a second process nothing at all, because that log is still `memory://`. The
approval is safely parked and nobody can find it.

The **control port** is a third, smaller thing: per-run scratch state that a second worker has to
be able to read. Nothing in it is a record of what happened, only what is true right now, which
is why `sqlite://` is as far as it goes — see
[Pause, Resume, Cancel](/operating/pause-resume-cancel).

## The control port is also the lease port

It holds two things, and the second one is why sharing it matters more than the signal path
alone suggests:

| What | Written by | Read by |
|---|---|---|
| a pending pause or cancel | whoever asked | the run itself, at its next safe point |
| a **lease** on a running run, renewed six times per TTL | the worker playing it | the next turn that finds that run still open |

The lease is how a session gets freed by evidence instead of by a timer. A worker killed outright
stops renewing; the next turn on that session reads the lapsed lease, knows nobody is playing that
run, and closes it. Without a shared lease port there is nothing to read, so the only backstop is
`AGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS` (**3600** by default), and one crashed process locks
one user out of one conversation for the rest of that hour.

```bash
export AGENTDECK_CONTROL=sqlite:///var/lib/agentdeck/control.sqlite3
```

That drops the wait to one `AGENTDECK_RUNTIME_LEASE_TTL_SECONDS` (**90** by default). A lease is
only ever evidence of death, never of life: a run the port has never seen is never reported dead,
so `memory://` behaves exactly as it did before leases existed rather than freeing sessions it
knows nothing about.

None of this reaches a run that was **paused** or **waiting on an answer** when its worker died.
There is no worker there to be dead, so that session holds until the run is resumed, answered or
cancelled, with no timer and no lease involved.

## Changing your mind later

The backends are chosen at startup and are not migrated between. Pointing `AGENTDECK_EVENTS` at
a new URL gives you an empty log, not a moved one; the old events stay where they were. The same
goes for the checkpointer, with a sharper edge: a workflow parked on an approval in one
checkpointer is not resumable from another, so drain your pending approvals before switching.

Next: [Runs and the Event Log](/concepts/runs-and-the-event-log) for what the log actually
contains, or the [settings reference](/reference/settings) for every variable in one table.

---

# Protocols and Surfaces

*What reads the canonical event stream today, and, plainly, what does not exist yet.*
Source: https://agentdecksdk.com/concepts/protocols-and-surfaces

# Protocols and Surfaces

A surface is a thin reader over the one canonical event stream [every run
produces](/concepts/runs-and-the-event-log) — nothing about how a run is played changes with
who is watching it. The Python API is the base case: no wire, no translation, just the events
themselves.

```python no-test reason="deck.stream is already runnable in concepts/agents"
async for event in deck.stream("Support", "and now?", session_id="wa-123"):
    ...  # a text.delta per chunk, message.completed once the reply is in, run.completed last
```

## What exists today

**HTTP and SSE.** `agentdeck-serve` (`agentdeck/serve.py`) runs a FastAPI app wire-compatible
with v1.2.1: `POST /agents/{name}/chat`, `?stream=true` for an event stream, the same shape for
`/workflows/{name}`, and `/runs/{run_id}/{pause,cancel,resume}` for [run
control](/operating/pause-resume-cancel). Every one of those handlers calls into the same
`Runtime` the Python API uses, passing it plain arguments — `name`, `session_id`, and so on. The
`Runtime` mints the `RunContext` itself for every call; no handler builds one. One canonical log
either way.

```bash
curl -X POST http://localhost:8000/agents/Support/chat -d '{"session_id": "wa-123", "message": "where is my order?"}'
```

**The CLI.** `agentdeck runs signal <run_id> {pause,cancel,resume}` (see
[Reference → CLI](/reference/cli)) is the one shipped command, and it is a control surface,
not a chat client — it writes a pause/cancel/resume request for a run someone else is running.
There is no shipped command that starts or renders a conversation.

## What is not built

Stated plainly, because a reader deciding whether AgentDeck fits needs it, not a rosier
version of it:

- **No authentication on any endpoint.** Anyone who can reach the process can call every route
  above, including run control.
- **Unnamespaced.** The HTTP surface runs every request without a namespace, so one log
  space serves the whole deployment — `RunContext` carries `namespace`, but nothing
  today varies them per caller.
- **A second, native HTTP surface exists but is not reachable.** `agentdeck/surfaces/serve/app.py`
  builds a FastAPI app over the raw canonical events, with routes under `/v2/invocables/{name}/chat`
  and none of v1's wire translation. No console script mounts it, so it is exercised only by the
  test suite (and by a reference terminal renderer, `agentdeck/surfaces/cli/chat.py`, that reads
  its stream using nothing but `event.origin` and a message's id — proof that a thin reader is
  enough, not something `pip install agentdeck-sdk` gives you to run).
- **ACP, A2A, an MCP server, and an OpenAI-compatible endpoint do not exist.** The PRD's
  "any invocable, any surface, zero per-surface code" claim (FR-2, FR-14) holds for the two
  surfaces above and stops there today.

None of this is scheduled to change quietly — a new surface earns its own page here once it
ships, not before.

---

# Run Control

*What a safe point is, why asking a run to stop is not the same fact as it stopping, and what a resume replays.*
Source: https://agentdecksdk.com/concepts/run-control

# Run Control

A run in flight can be paused, resumed, or cancelled through a `Run` handle, from the process
running it or from another one entirely — see [Pause, Resume, Cancel](/operating/pause-resume-cancel)
for how to reach one from outside the process. In your own code it looks like this:

```python no-test reason="needs a live run_id from a run this process is streaming"
run = await deck.runs.get(run_id)
await run.pause(reason="operator stepped away")
await run.resume()
await run.cancel(reason="user closed the tab")
```

## Asking is not stopping

`pause` and `cancel` **record a request** and return. They do not wait for the run to
stop, because at the moment you ask, nobody can know when it will: the run may be halfway
through a tool call that has to finish first.

That is why a controlled run writes three things to its log rather than one:

| Event | Means |
| --- | --- |
| `control.requested` | the signal is recorded — `{verb, reason?}` |
| `control.observed` | the run reached a safe point and is acting on it — `{verb, safe_point}` |
| `run.paused` / `run.cancelled` / `run.resumed` | the effect |

A request is **not** a status change. A run stays `running` after `control.requested`; only the
effect moves it to `paused` or `cancelled`. So "did it stop?" is answered by watching the run's
events for the effect — not by the response to your request.

A signal that arrives after the run already ended does nothing, and says nothing: a terminal
event is a run's last event, so there is nowhere for a rejection to land. Sending the same
pause twice records one request. Neither is an error.

## Safe points

A signal is acted on at the next **safe point**, never mid-token and never by killing anything:

- `stream_item` — between two items of the model's stream. The chunk in flight is always
  delivered whole.
- `tool_dispatch` — before a tool call is dispatched.
- `node_boundary` — between two graph nodes.

An agent run honors `stream_item`. A workflow (LangGraph) run honors `node_boundary`: nothing
in the graph is mid-execution between two `updates` chunks, so a pause or cancel always lands
somewhere well defined.

**A tool call that is already running is never interrupted.** If you pause while a tool is
executing, the call runs to completion and the run stops before the step that would have used
its result. The `safe_point` on `control.observed` is what tells "cancel took eight seconds"
from "cancel took eight seconds *because a tool call did*".

### How long a pause or cancel takes to land

Two things add up, and both are bounded:

1. **Up to 200ms** before the run notices the request. The run re-reads pending control at most
   once per 200ms, so a long answer costs a handful of reads instead of one per token — a
   400-chunk answer streaming for 12 seconds costs 58 reads, not 400. This is a latency bound,
   not a correctness one: nothing is ever missed, only noticed a moment later.
2. **However long the current step takes to reach a safe point** — the rest of a streamed chunk,
   or all of a tool call that is already in flight. This one is your run's shape, not ours.

For a streaming answer that is typically well under half a second. For a run inside a 30-second
tool call it is 30 seconds, and no setting can change that without killing the call.

Both of those are about a run that is *live*. Cancelling a run that is already **paused** (or
waiting on an answer) is different, not slower: there is no gate left to notice a request, so
`run.cancel` claims the run itself and ends it right there instead of merely recording
something for a later `resume`/`answer` to find — a **pause** against a suspended run, by
contrast, does stay merely recorded, since lifting or answering it is what would act on it. A
paused or waiting run that nobody ever cancels stays parked, holding its session **forever** —
no `stale_run_after` timer reaches it, deliberately (see [Sessions and
Memory](/concepts/sessions-and-memory#one-turn-at-a-time)) — until a `resume`/`answer` continues
it or a `cancel` ends it.

## What resume replays

A paused run is suspended **in the log**, not parked in memory: the process is free to exit, and
any worker holding the same event store can lift the pause. The run keeps its `id`, and
`seq` carries on from where it stopped, whatever kind of run it is. What a resume continues from
there is not the same for an agent and a workflow:

| Run | Resume continues from |
| --- | --- |
| Agent | the run's original input, replayed with the log as history |
| Workflow (LangGraph), `durable = True` | the node boundary it paused at, from any process |
| Workflow (LangGraph), `durable = False`, same process | the node boundary it paused at |
| Workflow (LangGraph), `durable = False`, another process | refused, naming `durable = True` |

**An agent run has no stack to return to**, so resuming re-enters the engine with the run's
original input and the log as history: work the paused turn had already done can happen again.
The model is asked again, and a tool it had already called may be called a second time.
`RunContext` has no idempotency-key field to lean on for this, only the run's `id` (which does
stay the same across a resume, see above) if your own side effects want a key to dedupe against. Keep
tools idempotent, and treat a resumed turn the way you treat a resumed LangGraph interrupt node.

**A workflow run has its own checkpoint**, one `updates` chunk (a node boundary) at a time, so
resuming continues it instead of replaying: no node that already ran runs again. That is only as
durable as the checkpoint backing it. A `durable = False` workflow's checkpoint lives in the
memory of the process that paused it, so lifting that pause from a *different* process is
refused rather than silently replayed from the entry node with an empty state. Set
`durable = True` on the workflow (with a durable checkpoint backend) for a pause that must be
resumable from anywhere.

`run.resume()` itself returns nothing — read the continuation back with `run.events()`, or block
for the final outcome with `await run`. A no-op (nothing to resume: the run finished, was
cancelled, is still running, or another worker got there first) is silent the same way. Exactly
one caller can resume a paused run: the transition is a conditional append to the log, so two
racing resumes cannot both play the turn.

**Cancel is terminal.** A cancelled run cannot be resumed, and a cancel cannot un-do what a tool
already did — an email that was sent stays sent. That holds for a run cancelled *while paused*
too: `resume` finds the pending cancel and ends the run rather than continuing it, so asking
to resume does not quietly override whoever cancelled.

`paused` is also not `waiting_answer`: a run stopped on an `interrupt()` is waiting for an answer
and resumes *with a value*; a paused run is waiting for an operator and resumes with nothing.

---

# Runs and the Event Log

*The one ordered record every turn appends to, and why a run's status is read from it instead of stored anywhere else.*
Source: https://agentdecksdk.com/concepts/runs-and-the-event-log

# Runs and the Event Log

Every turn — a chat message or a workflow invocation — appends events to one ordered log for
that run. This log, not anything an engine keeps for itself, is what every consumer of a run
reads: replay, audit, the HTTP surface, pause and resume.

Every way of starting a turn writes here, and they all write the same thing. `run`, `stream` and
`runs.start` record a run exactly as `POST /agents/{name}/chat` and `POST /workflows/{name}` do,
because all of them play the turn on the same Runtime. So a run you started from a script is as
readable afterwards as one a client started over HTTP — same events, same order, same store.
`Deck` has no public store property; `deck.runs.get(id)` is the documented way back to a run,
whether that's the same process moments later or a second one pointed at the same durable store.

```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
import os

os.environ["AGENTDECK_EVENTS"] = "sqlite://./events.sqlite3"

from agentdeck import Deck
from agentdeck.core.status import status_of


async def main() -> None:
    async with Deck.from_project() as deck:
        run = await deck.runs.start(
            "Greeter", "book me a slot Tuesday", session_id="sess-1", namespace="workspace:acme"
        )
        await run  # waits for the turn to finish; a TurnResult back

        # A second, independent handle on the same run: what a later process, or a
        # dashboard, reads back — `get` never mutates, so holding one changes nothing.
        same_run = await deck.runs.get(run.id, namespace="workspace:acme")
        events = [event async for event in same_run.events()]
        for event in events:
            print(event.seq, event.kind)
        seqs = {event.seq for event in events}
        gaps = [n for n in range(max(seqs) + 1) if n not in seqs] if seqs else []
        print("gaps:", gaps)
        print("status:", status_of(events))


asyncio.run(main())
```

```text
0 run.started
1 text.delta
2 text.delta
3 usage.reported
4 message.completed
5 run.completed
gaps: []
status: completed
```

`run` and `same_run` never touch each other directly — a handle caches no authoritative state, so
the two agree because the durable store is the only thing either ever reads from. What
`same_run.events()` returns is whatever the run actually wrote, nothing cached in between — the
same thing a later process, or a dashboard, would see calling `deck.runs.get(run.id)` against the
same durable store.

## Reading a run does not drive it

A run advances in a deck-owned task from the moment it starts. Reading it is observation and
nothing else: any number of readers can hold `events()` or `stream()` on one run without stealing
events from each other, without advancing it, and without having started it. A reader that stops
reading, or is cancelled, does not stop the run. It plays on to its own natural end, bounded to
one turn, and frees its session there.

Stopping a turn early is a separate, explicit act: `run.cancel(reason)`. See [Run
Control](/concepts/run-control).

The exact kinds in between `run.started` and `run.completed` depend on what the run did, not on
a fixed shape: a `text.delta` per streamed chunk, a `tool.call.started`/`tool.call.completed`
pair per tool call, `usage.reported` per model call, `message.completed` once a full reply is
in. This run made no tool call, so none of those appear above.

## seq is the order, and the loss check

`seq` is per-run and contiguous from zero — `0, 1, 2, …` with no gaps — which is what makes it
the ordering authority rather than `ts`, the wall-clock timestamp carried alongside it for
information only. The gap check above is that made concrete: the missing numbers in one run's
events, `[]` when there are none. A consumer that gets events out of order, or suspects one went
missing, refetches the run and runs the same check over the result instead of guessing from
timing.

## Every event says which schema wrote it

Each envelope carries `v`, a `{major, minor}` pair, and the two halves mean different things.
**`major`** is what a reader must already understand to parse the envelope at all: reading a log
whose `major` this version does not support fails on the first event, by name, rather than as a
validation error on a model you have never met. **`minor`** records an addition an old reader
already tolerates by construction — a kind it has never seen arrives as `UnknownEvent`, a content
block it has never seen as `UnknownBlock`, and neither consults the number to do it.

So a minor bump is safe to read with an older version and a major bump is not, which is also the
migration rule: a durable log written by an incompatible major has to be replayed into a new store,
or read with the version that wrote it. Only `sqlite`, `postgresql` and `redis` are affected —
`memory://` keeps nothing across a restart, so there is nothing to migrate.

## Status is a fold, not a field

A run's status — `running`, `paused`, `waiting_answer`, `completed`, `failed`, or `cancelled` —
is not written anywhere as its own row. `status_of` derives it by folding the run's own lifecycle
events in order and taking the last transition; a log with none of those folds to `None`, and a
log ending in `run.completed` folds to `completed` every time it's asked, with no cache to go
stale after a restart. `await run.status()` is this same fold, done for you — `paused` and
`waiting_answer` differ in what resuming them takes — nothing for a pause, an answer for an
interrupt — which [Run Control](/concepts/run-control) covers in full.

## The log is not the engine's memory

This log is the only thing any surface, protocol adapter, or dashboard is allowed to read — but
it is not what feeds the model on the next turn. Each engine keeps its own execution state
privately: the OpenAI Agents SDK's session, LangGraph's checkpointer. That state is what a run
actually resumes from; this log is a record of what happened, kept for everyone else.

The trade that follows is real, not a technicality: this log gives you every input, every tool
call, and every completed message, at the message level — never a byte-exact replay of what the
model itself saw. A tool result here is a capped preview, size, and hash, not the tool's actual
output; a model's internal reasoning between messages isn't recorded here at all. Build an audit
trail on this log and it will tell you truthfully what happened and in what order. It will not
hand you back the exact context an engine fed the model to produce it.

## Where the log lives

`AGENTDECK_EVENTS`'s scheme picks the store: `memory://` (the default) keeps it in the process
and loses it on exit; `sqlite://<path>` (as above) survives a restart; `redis://`/`rediss://`
and `postgresql://` are the two several workers can share. SQLite's cross-process story is a
shared *file*, not shared memory: several processes on the same machine can open it, but a
file can't reach a second machine — which is exactly why `redis` and `postgresql` exist as
separate options rather than "just point everyone at the same file."

## A listed exception: timer resumes

The claim at the top of this page — every consumer of a run reads this log — has one carve-out.
An internal sweep lists which timer-paused workflow threads are due and resumes them, by reading
each workflow's own LangGraph checkpointer, not this log. That is deliberate: the checkpoint
backend defaults to durable (`sqlite`) while the event store defaults to `memory`, so listing off
the log alone would stop surviving a process restart under the default configuration — exactly
the guarantee a due timer keeps its wake-up call across one restart. This sweep has no public
entry point — nothing outside `Deck` calls it — but an open `Deck` drives it on its own for as
long as it stays open, on `AGENTDECK_RUNTIME_SWEEP_INTERVAL_SECONDS` (30 seconds by default), with
no cron or scheduler required.

When a due thread also has a run `deck.runs.list(status=RunStatus.WAITING_ANSWER)` already knows
about (parked by a `Deck.run()` or HTTP call, which does go through the Runtime), the sweep
resumes it *through the Runtime*, so that resume itself lands in the log like any other; only a
thread with no logged run at all — parked by calling a durable `Workflow`'s own `run`/`resume`
directly — falls back to resuming straight off the checkpointer, since there is no log entry to
reconcile. See [Workflows → Timers](/concepts/workflows#timers) for where this shows up in
practice.

Next: [Run Control](/concepts/run-control) covers what a run in flight can be asked to do, and
how those requests land in this same log.

---

# Sessions and Memory

*What a session is, where its history lives, and why it is not the event log.*
Source: https://agentdecksdk.com/concepts/sessions-and-memory

# Sessions and Memory

A session is the history one conversation's model calls are built from. Naming one —
`run` and `stream` take an optional `session_id` — is what decides whether a turn remembers the
ones before it:

```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:
        await deck.run("Greeter", "my name is Sagi", session_id="wa-1")
        # A peek at the session's own history, to show it grew — not how a consumer
        # normally reads a conversation back; see "Reading a session" below.
        first_turn = await deck.session_for("wa-1").get_items()
        print("items after turn 1:", len(first_turn))

        await deck.run("Greeter", "what is my name?", session_id="wa-1")
        second_turn = await deck.session_for("wa-1").get_items()
        print("items after turn 2:", len(second_turn))
        first_item = second_turn[0]
        print("turn 1 is still there:", first_item["role"], first_item["content"])


asyncio.run(main())
```

```text
items after turn 1: 2
items after turn 2: 4
turn 1 is still there: user my name is Sagi
```

By the second call, the session already holds the first turn's user message and reply —
that is what "my name is Sagi" being present at `second_turn[0]` proves. A call with no
`session_id` gets none of this: each one is a one-shot with no session and no memory of the
last one.

## Where a session lives

`deck.session_for(session_id)` is a thin lookup, not a store of its own: `AGENTDECK_SESSION`
set to a `redis://` URL (needs the `redis` extra: `pip install "agentdeck-sdk[redis]"`) means
every session is a `RedisSession` sharing one Redis client,
reachable from any worker and surviving a restart; unset, each `session_id` gets one in-process
SQLite session, held in the `Deck` instance that created it — gone the moment that process
exits, invisible to any other worker. `AGENTDECK_SESSION_REDIS_KEY_PREFIX` and
`AGENTDECK_SESSION_REDIS_TTL` are the Redis-only knobs. FR-6's promise — a conversation
surviving a process restart — needs Redis configured; the fallback is a dev convenience, not a
second durable option. Full table:
[Settings → `SessionSettings`](/reference/settings#sessionsettings).

The same `session_id` names one conversation everywhere it is used — over HTTP
(`POST /agents/{name}/chat`), from the Python API, or from an agent that hands off to a peer —
so a caller does not need to know which door a conversation started at to keep talking to it.

## Not the event log

This session is what a run's engine reads to answer the next turn. It is not the platform's
record of the run, which is a different store entirely: see [Runs and the Event
Log](/concepts/runs-and-the-event-log#the-log-is-not-the-engines-memory) for what the log
keeps instead and why the two are not derived from one another. In short: the log is what
every consumer — replay, audit, a dashboard — is allowed to read; this session is private to
the engine that owns it, and it is the only thing that actually feeds the model. `get_items()`
above is a peek for demonstration, not a documented read API — a real consumer reads the log.

## One turn at a time

A session holds one turn in flight. Starting a second one against the same `session_id`
before the first finishes does not queue or interleave it — it is refused outright:

```python no-test reason="needs a live session with a run already in flight to raise"
from agentdeck import SessionBusyError

try:
    await deck.run("Greeter", "another message, same session", session_id="wa-1")
except SessionBusyError as busy:
    ...  # "session 'wa-1' already has run '<run_id>' in flight, so run '<run_id>' cannot start on it"
```

The refusal is the log deciding, not a lock the caller has to manage: opening a run is a
conditional write that fails if the session already has one open, so exactly one caller ever
wins, whether the two calls came from the same process or two different ones. Over HTTP the
same refusal arrives as `409`. A **running** run that dies without closing cleanly (a killed
worker, not a graceful exit) still frees the session eventually — once it has gone quiet for
`AGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS`, described in [Run Control](/concepts/run-control).

A run **parked** — paused, or waiting on a human answer — is different, and deliberately so: it
holds the session until someone acts on it, however long that takes. No timer ever takes it
over, because nothing is silently dying there to infer from silence — the run is exactly where
it stopped, waiting. The refusal names the call that frees it instead of claiming the holder is
"in flight", which would be false of a run nobody is running:

```text
session 'wa-1' is held by run '<run_id>', parked waiting for an answer — supply it with
run.answer(...) or end it with run.cancel(...), see ...
```

An approval nobody ever answers holds its session forever, not for an hour — `run.cancel(...)`
is what ends it.

---

# Skills

*Progressive knowledge disclosure into an agent's own execution, described by SKILL.md.*
Source: https://agentdecksdk.com/concepts/skills

# Skills

A skill is a directory with one file of prose: `SKILL.md`. It is not a program to run — an
agent's own instructions gain a name and a description per declared skill, and a `load_skill`
tool it can call to read the full body once it decides one applies. No subprocess, no separate
executable contract.

```text
.agentdeck/skills/parse-request/
├── SKILL.md            # frontmatter + instructions for the model
└── scripts/            # optional: anything the instructions tell the model to run itself
```

## SKILL.md

YAML frontmatter, then prose. `name` must match the directory name; `description` is what
`disclosure_text` puts in an agent's instructions when deciding whether this skill applies —
both are required, or `Deck.build()` fails naming the bundle.

```markdown
---
name: parse-request
description: Extract pickup, dropoff, and date from a free-text booking request.
---

Split the request on "->". The part before is the pickup city, the part after is the dropoff
city. If a date appears, use it; otherwise ask the user for one.
```

## Declaring a skill on an agent

```python
# .agentdeck/agents/booking/agent.py
from agentdeck import Agent

booking = Agent(name="Booking", instructions="Help the user book a ride.", skills=["parse-request"])
```

```python
from agentdeck import Deck

deck = Deck(agents=[booking], skills="./skills")  # or Deck.from_project(), discovering skills/*
```

`skills=` on the `Deck` is one or more root directories, scanned direct-child only
(`<root>/<name>/SKILL.md`, never recursive) and merged into one name-keyed registry — a name
declared under two roots fails `build()` naming both. `Agent(skills=[...])` names which of those
this particular agent may use; a name absent from the roots, or from a `Deck` with no `skills=`
at all, fails `build()` the same way an unknown handoff or MCP server does.

## What an agent actually sees

Two things, and only two: the disclosure block appended to its instructions (every declared
skill's name and description, never the body), and a `load_skill(name)` tool scoped to that
agent's own `skills=[...]` — a name outside that list is unreachable even though the registry
knows it.

```text
### Skills available
Each entry below is a name and a description. Call `load_skill(name)` to read the
full instructions before following one.
- parse-request: Extract pickup, dropoff, and date from a free-text booking request.
```

The model decides whether a skill applies from the description alone, then calls `load_skill` to
read the full `SKILL.md` body before following it — the same two-step a human would take skimming
a table of contents before opening a page.

## Why not a subprocess

A skill used to be a script AgentDeck ran in a subprocess, with a typed-output contract for
workflows. That executable model is gone: nothing in the package or its tests used the typed
output path, and running arbitrary scripts is a sandboxing concern that is disabled and tracked
separately. A skill today is instructions plus what the agent itself already has — its own
tools — so anything a skill's script used to do, an ordinary `tool=` callable does instead, with
the skill's `SKILL.md` describing when to reach for it.

---

# Workflows

*Typed state, a LangGraph graph, and durable pauses for humans.*
Source: https://agentdecksdk.com/concepts/workflows

# Workflows

A workflow is a Pydantic state class plus a graph. You build the graph with LangGraph's
`StateGraph`; AgentDeck compiles it and plays it on the Runtime the same way it plays an agent.

```python
# .agentdeck/workflows/new_booking/workflow.py
from langgraph.graph import END, StateGraph
from pydantic import BaseModel

from agentdeck import Workflow


class BookingState(BaseModel):
    request: str
    quote: str = ""


def _quote(state: BookingState) -> dict:
    return {"quote": f"€420 for {state.request}"}


def _build_graph() -> StateGraph:
    graph = StateGraph(BookingState)
    graph.add_node("quote", _quote)
    graph.set_entry_point("quote")
    graph.add_edge("quote", END)
    return graph


new_booking = Workflow(name="NewBooking", state=BookingState, graph=_build_graph)
```

Run it by name; the return value is the final state:

```python
import asyncio

from agentdeck import Deck


async def main() -> None:
    async with Deck.from_project() as deck:
        state = await deck.run("NewBooking", {"request": "Berlin -> Munich"})
        print(state["quote"])


asyncio.run(main())
```

## Nodes that do the interesting work

| Node | What it does |
|---|---|
| `AgentNode(agent)` | runs an [`Agent`](/reference/definitions) as a node; forwards its text deltas to the graph's custom stream |
| `LoadFileNode` | pulls a file's contents into state from the host filesystem; the path must be absolute |

```python
from agentdeck.authoring import AgentNode

graph.add_node("draft", AgentNode(drafting_agent, input_key="request", output_key="draft"))
```

An agent used inside a workflow node compiles standalone, with no `Deck` catalog in view: its own
MCP servers resolve the same way a root agent's do, but `handoffs=`/`skills=` naming another
catalog entry do not — put those on a root agent instead.

## Durability and human approval

Set `durable=True` and the graph compiles with a checkpointer (`AGENTDECK_CHECKPOINT_*`), so a run
is identified by `session_id` (its `thread_id`) and survives the process dying. Only then can a
node pause for a human:

```python
from langgraph.types import interrupt


def _confirm(state) -> dict:
    decision = interrupt({"question": "Send this quote?"})
    return {"approved": decision == "yes"}
```

A paused run returns an `InterruptResult` — `{"type": "interrupt", "payload": …,
"thread_id": …, "id": …}` — instead of a final state, and its own `id` is enough to answer it,
no inbox lookup required:

```python
import asyncio

from agentdeck import Deck


async def main() -> None:
    async with Deck.from_project() as deck:
        paused = await deck.run("Approval", {}, session_id="quote-42")
        if paused["type"] == "interrupt":
            mine = await deck.runs.get(paused["id"])
            await mine.answer("yes")


asyncio.run(main())
```

<Callout type="warning">
**An interrupt node re-runs from its start.** When the run resumes, everything in that node
before the `interrupt()` call executes a second time. Keep interrupt nodes pure — send the
email, charge the card, and write to your database in an *earlier* node, never in the one
that pauses.

If the node also reads a `Context[T]`, that second pass is where a forgotten context bites
silently — see [answering with a
context](/guides/human-approval#if-the-workflow-takes-a-context-hold-the-same-handle-to-answer-it).
</Callout>

## Timers

`sleep_until(when)` pauses a durable run until a timezone-aware moment, using the same
interrupt machinery with a `{"type": "timer", "wake_at": …}` payload — so a timer-paused
thread is distinguishable from a human-paused one in the inbox. Naive datetimes are
rejected.

AgentDeck runs no daemon, and there is no public way to drive this sweep — but an open `Deck`
drives it on its own: an internal listing finds timer threads whose moment has passed, and an
internal resume plays each one, on a loop scoped to the deck's own lifetime (started when it
opens, cancelled when it closes) rather than a cron or scheduler the user has to wire in. The
interval is `AGENTDECK_RUNTIME_SWEEP_INTERVAL_SECONDS` (30 seconds by default). A process that
opens the deck, takes a turn and closes within one interval never sweeps at all — a due
`sleep_until` then wakes on whoever next holds the deck open past that.

**Known limit: the listing reads the checkpointer, not the event log.** Both find due threads
through each workflow's own LangGraph checkpointer rather than the [event
log](/concepts/runs-and-the-event-log#a-listed-exception-timer-resumes) — deliberately, since
the checkpoint backend defaults to durable (`sqlite`) while the event store defaults to
`memory`, and a due timer surviving a process restart depends on the checkpointer's own
durability. The resume still plays a due thread *through the Runtime* when it matches a run
`deck.runs.list(status=RunStatus.WAITING_ANSWER)` already knows about, so that resume itself is
recorded in the log like any other; only a thread with no logged run at all falls back to
resuming straight off the checkpointer.

## Streaming and composition

`Deck.stream()` yields a `node.updated` event per completed node and a `custom` event per
`get_stream_writer()` call, then one terminal `run.completed` carrying the final state — or a
`run.interrupted` event in its place when the run pauses.

`Workflow.as_tool()` turns the whole workflow into a tool an agent can call (pass it in
`Agent(tools=[...])`), which is how a conversation reaches deterministic multi-step work.

**A workflow exposed as a tool must be `durable=False`.** A tool call carries no thread, and a
durable workflow needs one to load and persist its checkpoint — so `build()` rejects the
combination rather than letting it fail the first time a model reaches for the tool:

```
agent 'booking' uses workflow 'Onboarding' as a tool, but it is durable=True.
```

If the work genuinely needs to survive a restart or pause for a human, it is a root invocable,
not an ability: call it with `deck.run("Onboarding", state, session_id=...)`, where you control
the thread.

---

# Add a Tool

*Give an agent a plain Python function it can call.*
Source: https://agentdecksdk.com/guides/add-a-tool

# Add a Tool

An agent that can only talk cannot check anything real. Give it a Python function, and the
Agents SDK turns it into something the model can call mid-conversation, reads the result of,
and answers from.

```python file=.agentdeck/agents/scheduler/agent.py
from agents import function_tool

from agentdeck import Agent


@function_tool
def lookup_slot(day: str) -> str:
    """Return the fixed free slot for a day."""
    return f"{day} 09:00"


scheduler = Agent(
    name="Scheduler",
    instructions="Help the user book a slot. Use lookup_slot to check availability.",
    tools=[lookup_slot],
)
```

`tools` takes plain functions and compiles each one — `@function_tool` above is optional, and an
already-built SDK tool object is accepted unchanged. Run the agent the same way as any other:

```python run
import asyncio

from agentdeck import Deck


async def main() -> None:
    async with Deck.from_project() as deck:
        result = await deck.run("Scheduler", "is Tuesday free?")
        print(result.output)


asyncio.run(main())
```

## What you did not write

A JSON schema for the tool's parameters, the code that parses the model's call arguments
against it, and the loop that feeds the result back for another turn. `function_tool` builds
the schema from the type hints and reads the description from the docstring; the Agents SDK
runner drives the call-then-continue loop once the model decides to use it. Nothing in
`agentdeck` sits between the two — `tools` just hands the SDK's `Agent` constructor a list it
already knows how to run.

## When a tool raises

A tool that raises does not fail the run. The exception is caught, the model is handed an error
string and is free to retry or work around it, and the run completes normally. The exception's
type and message are recorded on that call's `tool.call.completed` event, under `error`, so the
failure is in the log even when the model writes an answer that never mentions it.

That recording is something `agentdeck` attaches while compiling a plain function. A tool you
decorated with `@function_tool` yourself (as `lookup_slot` above is) reaches the engine untouched
and keeps whatever failure handling you gave it, so its exceptions stay off the event.
Drop the decorator if you want the log entry; `tools` compiles the bare function perfectly well.

## Tools vs. skills

`tools` is for exactly this: a function that runs in the host process and returns a value,
callable mid-conversation. A [skill](/concepts/skills) is a different thing — prose the agent
reads to decide *when* to reach for a tool, not a tool itself. A skill's `SKILL.md` commonly
describes how to use one or more of an agent's own `tools=[...]`.

Next: [Agents](/concepts/agents) explains how tools compose with handoffs, skills and structured
output. The exact `Agent(...)` fields and tool rules are in [Definitions](/reference/definitions).

---

# Human Approval

*Pause a workflow for a person's decision, and answer it from wherever that person is.*
Source: https://agentdecksdk.com/guides/human-approval

# Human Approval

Some steps should not happen until a person says yes. A workflow node can pause for exactly
that — durably, so the process asking does not have to be the process answering, provided the
answering process can see the run at all. `durable=True` covers the graph's own state (LangGraph's
checkpointer, file-backed by default); `Run.pending()` and `Run.answer()` go through a different
store — the Runtime's event log — so a second process only sees a paused run if *that* store is
shared too, which the default (in-process memory) is not. See [Where the log
lives](/concepts/runs-and-the-event-log#where-the-log-lives) for the backends that cross a
process boundary.

```python file=.agentdeck/workflows/quote_approval/workflow.py
from langgraph.graph import StateGraph
from langgraph.types import interrupt
from pydantic import BaseModel

from agentdeck import Workflow


class QuoteState(BaseModel):
    request: str
    quote: str = ""
    approved: bool = False


def _quote(state: QuoteState) -> dict:
    return {"quote": f"EUR 420 for {state.request}"}


def _confirm(state: QuoteState) -> dict:
    decision = interrupt({"question": f"Send quote: {state.quote}?"})
    return {"approved": decision == "yes"}


def _build_graph() -> StateGraph:
    graph = StateGraph(QuoteState)
    graph.add_node("quote", _quote)
    graph.add_node("confirm", _confirm)
    graph.set_entry_point("quote")
    graph.add_edge("quote", "confirm")
    return graph


quote_approval = Workflow(name="QuoteApproval", state=QuoteState, durable=True, graph=_build_graph)
```

`quote` runs once and does its work before anything pauses. `confirm` is the node that
interrupts — keep it to the question itself, because [it re-runs from its start on
resume](/concepts/workflows#durability-and-human-approval).

Starting the run returns the question instead of a final state — and the question itself now
carries the run's own `id`, so whoever gets handed it can answer it with no second lookup:

```python run
import asyncio

from agentdeck import Deck
from agentdeck.core.status import RunStatus


async def main() -> None:
    async with Deck.from_project() as deck:
        paused = await deck.run("QuoteApproval", {"request": "Berlin -> Munich"}, session_id="quote-42")
        print(paused)  # {"type": "interrupt", "payload": {"question": ...}, "thread_id": "quote-42", "id": ...}

        # A second process — the one with the person in front of it — finds it in the
        # approval inbox instead, one Run per waiting workflow across the whole catalog.
        inbox = await deck.runs.list(status=RunStatus.WAITING_ANSWER)
        print([(run.id, await run.pending()) for run in inbox])

        mine = await deck.runs.get(paused["id"])
        await mine.answer("yes")
        final = await mine
        print(final)  # {"request": ..., "quote": ..., "approved": True}


asyncio.run(main())
```

`deck.runs.list(status=RunStatus.WAITING_ANSWER)` lists every paused run across the whole
catalog — a real caller narrows it further with `await run.pending()`, whose `payload` names the
question and whose own dict now carries the run's `id` too. `deck.runs.get(id)` rehydrates a
handle to any run this deck already knows about, so a caller holding just the id off `paused`
skips the inbox entirely.

### If the workflow takes a context, hold the same handle to answer it

`QuoteApproval` above needs nothing from the caller. A workflow declaring `Context[T]` does, and
the context is not something `answer()` takes as an argument at all — it is retained on the
`Run` handle from whenever the run started, for that handle's whole life:

```python illustrative reason="continues the example above, for a workflow that declares Context[T]"
# start() is the one call that can carry a context; recovering a handle later via get() never
# does. Keep the handle start() returned if the same process is going to answer it.
run = await deck.runs.start("QuoteApproval", {"request": "Berlin -> Munich"}, session_id="quote-42", context=corpus)
...
await run.answer("yes")  # resupplies `corpus`, because this is the same handle
```

Answering through a *different* handle — `deck.runs.get(run.id)` from a second process, or from
the inbox above — carries no context at all, because none was ever written to the log. [The node
re-runs from its start](/concepts/workflows#durability-and-human-approval), so its `ctx.data` read
happens twice, and the second read sees whatever the answering handle had — `corpus` if it is the
same one `start()` returned, `None` if it was recovered any other way. That is not a bug to work
around; it is why an application that needs an answer to carry a context keeps the process that
started the run alive to answer it, or accepts that a second process answers with `None`.

## The same thing over HTTP

A caller outside the process uses three endpoints instead of three method calls — same
inbox, same thread id:

```bash illustrative reason="needs a live agentdeck-serve process"
curl -X POST "http://localhost:8000/workflows/QuoteApproval?thread_id=quote-42" -d '{"request": "Berlin -> Munich"}'
curl http://localhost:8000/workflows/QuoteApproval/pending
curl -X POST http://localhost:8000/workflows/QuoteApproval/quote-42/resume -d '{"value": "yes"}'
```

`resume` on a thread that is not paused — wrong id, already answered, never started — answers
**404**, not a stale state: there is nothing there to apply the value to.

## Limits

A workflow's `thread_id` is its session: one turn on it at a time, so a second `POST` to the
same thread while the approval is outstanding answers **409**, the same as talking over an
agent conversation that is still mid-turn. The approval holds the session until someone
answers it or cancels the run — deliberately with no timer to fall back on:
`AGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS` only ever applies to a run that is still running,
never to one parked waiting for this.

Pausing or cancelling a run by `run_id` — [Run Control](/concepts/run-control) — does not
reach a workflow run today: a workflow has no safe point of its own, so `interrupt()` is the
only way one of these pauses, and answering it is the only way it continues.

Next: [Workflows](/concepts/workflows) covers timers (`sleep_until`) — the other thing
`interrupt()` is used for — and what `as_tool()` does with the whole graph. The exact
`Workflow(...)`, `Deck.run(...)`, `Run.answer(...)` and `deck.runs.list(status=...)` APIs are in
[Definitions](/reference/definitions) and [Deck](/reference/deck).

---

# Serve Over HTTP

*Run agentdeck behind FastAPI, and talk to an agent from outside the process.*
Source: https://agentdecksdk.com/guides/serve-over-http

# Serve Over HTTP

`deck.run(...)` is a script talking to itself. A real deployment has a client in one
process and agentdeck in another — `agentdeck-serve` is a FastAPI app wrapping the same
Runtime that call would have used, so a turn started over HTTP lands in the same event log a
Python caller's turn would.

```bash illustrative reason="starts a long-running server; nothing to assert on here"
uv pip install "agentdeck-sdk[serve]"
HOST=0.0.0.0 PORT=8000 agentdeck-serve
```

`HOST` and `PORT` default to `0.0.0.0` and `8000`. `GET /health` answers `503` until the
lifespan has opened the project, then `{"status": "ok", "agents": [...], "workflows": [...],
"skills": [...]}`.

## Talking to an agent

```bash illustrative reason="needs a live agentdeck-serve process"
curl -X POST http://localhost:8000/agents/Scheduler/chat \
  -d '{"session_id": "sess-1", "message": "is Tuesday free?"}'
# {"output": "..."}
```

`session_id` is the conversation: the same id on a later call continues it, and a second
concurrent call on the same id while the first is still running answers **409** — one turn
per session at a time, whether that session belongs to an agent or, as in
[Human Approval](/guides/human-approval), a workflow thread.

Add `?stream=true` for Server-Sent Events instead of a single body: a `data: {"delta": "..."}`
line per chunk of text, then one `event: done` line carrying `{"output", "usage"}` — or
`event: error` with `{"error": "<exception type>"}` in `done`'s place if the turn fails
partway through.

```bash illustrative reason="needs a live agentdeck-serve process"
curl -N -X POST "http://localhost:8000/agents/Scheduler/chat?stream=true" \
  -d '{"session_id": "sess-2", "message": "and Wednesday?"}'
```

**A client that disconnects mid-stream does not cancel the turn.** The run advances in a
deck-owned task and the SSE response is only watching it, so a closed browser tab, a dropped
connection or a client timeout leaves the run playing to its own end, its answer in the log and
its session freed there. Stopping a turn is an explicit `POST /runs/{run_id}/cancel` (or
`run.cancel(...)` in Python), covered in [Pause, Resume,
Cancel](/operating/pause-resume-cancel).

## Workflows on the same server

`POST /workflows/{name}` and its `?stream=true`, `/pending`, and `/{thread_id}/resume`
counterparts run on this same server and the same Runtime — [Human
Approval](/guides/human-approval) walks the full round trip, including the interrupt shape
and the 404 a stale resume gets. Pausing, resuming, or cancelling a run you didn't start —
by `run_id` rather than by session — is [Pause, Resume,
Cancel](/operating/pause-resume-cancel).

## What is not here

There is no `GET /runs/{run_id}` to poll a run's status by id alone. Watching a live run
means holding its stream; checking on one you are not holding means calling `resume` and
reading whether it answers 200 or 409 — [Pause, Resume, Cancel](/operating/pause-resume-cancel)
covers both paths since they apply the same way to a paused agent run.

Next: [Human Approval](/guides/human-approval) is the same server used for a workflow that
needs a person's answer instead of a client's. For the Python API that records the same runs,
see [Deck](/reference/deck); for server configuration, see [Settings](/reference/settings).

---

# Pause, Resume, Cancel

*Which endpoint to call to stop a run you did not start, what each response means, and how to tell whether it worked.*
Source: https://agentdecksdk.com/operating/pause-resume-cancel

# Pause, Resume, Cancel

Anyone who knows a run's `run_id` can ask it to pause, resume, or cancel over HTTP — from a
dashboard, a curl command, or a second terminal. See [Run Control](/concepts/run-control) for
what a safe point is and what a resume replays; this page is the operator's side: which
endpoint to call, what it hands back, and what to do when a run does not seem to be stopping.
An embedded Python caller reaches the same three verbs on a `Run` handle instead —
`(await deck.runs.get(run_id)).pause(...)`/`.resume()`/`.cancel(...)`, see
[Deck](/reference/deck#run) — but the wire below is what every HTTP caller gets either way.

```bash
curl -X POST http://localhost:8000/runs/$RUN_ID/pause -d '{"reason": "operator stepped away"}'
curl -X POST http://localhost:8000/runs/$RUN_ID/cancel -d '{"reason": "user closed the tab"}'
curl -X POST http://localhost:8000/runs/$RUN_ID/resume
```

## What each call hands back

`pause` and `cancel` both answer `{"run_id", "verb", "recorded": true}` the moment the request
is written down — for a *live* run, that is before it stops, since both wait on the run's own
gate to notice. A `cancel` against a run already paused or waiting on an answer is different:
there is no gate left to notice anything, so the run is claimed and ended within the same call,
and `recorded: true` is returned only once it already has been. `resume` answers
`{"run_id", "status", "events"}` with the count of events the continuation produced, or `409`
if the run was not paused: still running, already finished, already cancelled, or picked up by
another caller first. None of those three cases is an error worth retrying differently; they
are all "there was nothing here to resume."

`pause` or `cancel` on a run that already ended is accepted and does nothing, so a double
click is harmless.

Both endpoints also have a `503` — "run control is unavailable: no control backend is
configured" — in their code path, but it does not happen behind a normally-started
`agentdeck-serve` (or any `Deck`): `resolve_control_port()` always wires a real `ControlPort`,
`memory://` by default, and refuses to open at all if `AGENTDECK_CONTROL`'s scheme names
anything it doesn't recognize, which surfaces at startup rather than on a `pause`/`cancel`
request. That 503 is reachable only from an embedder who builds a `Runtime` directly with no
`ControlPort`, bypassing `Deck` and `agentdeck-serve` entirely — not a state a deployment
following this page can reach.

## Reaching a run in another process

The signal has to land somewhere the run's own loop is reading from, and the default is
in-process memory: fine for a single worker talking to itself, invisible to anyone else. With
that default, a second web worker and the `agentdeck runs signal` CLI below cannot reach a run
at all — not because the run rejected the signal, but because they wrote it to a different
process's memory. This is the shape of bug report that looks like "cancel does nothing" and is
actually "the API server has three workers and you signaled the wrong one."

Point the control backend at a shared file to fix that:

```bash
AGENTDECK_CONTROL=sqlite://./.agentdeck/control.sqlite3
```

Then a second terminal can reach the same run by id alone:

```bash
agentdeck runs signal <run_id> cancel --control-db ./.agentdeck/control.sqlite3 --reason "typo"
```

SQLite's cross-process story rests on shared memory, so this covers one file behind more than
one process on the same machine, not one file behind more than one machine.

A paused run's *resume* is never a signal, even with the file backend: continuing a run needs
its event log, so lifting a pause belongs to a process holding a Runtime —
`POST /runs/{run_id}/resume` or `(await deck.runs.get(run_id)).resume()` — not to the CLI above.

## Watching for the effect

`recorded: true` is not "stopped." The question an operator actually has after pausing or
cancelling a run is simple to ask and, today, has no single clean answer over HTTP: **there is
no endpoint that reports a run's current status by `run_id` alone** — an embedded Python caller
has one (`(await deck.runs.get(run_id)).status()`), but nothing here exposes it on the wire. The
only ways to learn whether a pause or cancel landed over HTTP are the ones already open to you:

- If you (or your caller) are the one holding the streaming response — the SSE connection from
  `POST /agents/{name}/chat?stream=true` — the run's own `control.observed` and
  `run.paused` / `run.cancelled` events arrive on that stream, in that order, and are the
  authoritative answer.
- If you are not holding that stream — you signaled from a second terminal, or a dashboard
  that only has the `run_id` — the only feedback available is calling `resume`: a `409` tells
  you the run is not currently paused (it may still be running toward your cancel, or it may
  already be done), and a `200` with events tells you it *was* paused and your resume just
  played it. Calling `resume` to check status has a side effect — it lifts a pending pause — so
  only do this if lifting the pause is what you actually want next.

There is no way today to poll "is run X paused yet" without either of those two paths. If your
pause or cancel is taking longer than [the bound Run Control describes](/concepts/run-control#how-long-a-pause-or-cancel-takes-to-land),
the run is not stuck — it is either still finishing the tool call it was in, or nobody has
touched it since it paused. A paused run that nobody resumes holds its session open **forever**
— `AGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS` never reaches a paused run, deliberately — until a
`resume` or a `cancel` ends the wait; it is not consuming a live loop in the meantime, so there
is nothing to cancel out of urgency beyond recording the cancel itself.

---

# Reference

*Technical reference for AgentDeck's public APIs and configuration.*
Source: https://agentdecksdk.com/reference

# Reference

Two pages here are generated straight from the code, not written by hand — `make check`
regenerates both and fails if a committed page differs from what the code produces, so
neither can drift out from under a settings or CLI change:

- [Settings](/reference/settings) — every `AGENTDECK_*` (and `OPENAI_*`/`TAVILY_*`)
  environment variable, from `agentdeck/runtime/settings.py`.
- [CLI](/reference/cli) — the `agentdeck` command tree, from `agentdeck/cli.py`.

The rest of the public surface is behavioral rather than tabular, so it stays hand-written
prose and isn't part of this generator:

- [Deck](/reference/deck) — the composition root: lifecycle, the turn-starting methods, and
  what `TurnResult` carries.
- [Definitions](/reference/definitions) — `Agent`/`AgentDeclaration` and `Workflow`/
  `WorkflowDeclaration`, the authoring API a project builds.
- [Capabilities](/reference/capabilities) — removed with the sandbox; see the page for why.

Not yet published as pages:

- `Skills`, the capability object a `Deck` composes — covered today in
  [Skills](/concepts/skills)
- `MCP`, the other capability object a `Deck` composes — its constructor argument is on
  [Deck](/reference/deck#construction-and-lifecycle), and how a connection failure degrades an
  agent rather than crashing the process is on [Agents](/concepts/agents#mcp-servers-degrade-instead-of-crashing)
- Workflow nodes (`AgentNode`, `LoadFileNode`) — covered today in [Workflows](/concepts/workflows)
- HTTP endpoints

---

# Capabilities

*The sandbox capability specs — removed; sandboxing is not part of v3.*
Source: https://agentdecksdk.com/reference/capabilities

# Capabilities

[Capabilities](/concepts/capabilities) covers the state of this feature: sandboxing is not part
of v3, so there is no `capabilities=` argument on `Agent` and no page here can make one exist.

The spec classes this page used to document field by field — `CapabilitiesSpec`, `ShellSpec`,
`FilesystemSpec`, `MemorySpec`, `CompactionSpec` — have been removed from the codebase along
with the rest of the sandbox scaffolding. Nothing constructed them, so nothing imports them.
Read them in the git history if you need the shape they had; this page comes back when the
feature does.

---

# 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](/concepts/runs-and-the-event-log) 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](/concepts/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](/concepts/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/definitions) 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](/concepts/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/definitions) 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](/concepts/run-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](/concepts/runs-and-the-event-log), 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](/concepts/run-control#safe-points) reaches agent runs today; a workflow (LangGraph)
run has no safe point yet, so `pause`/`cancel` record the request but nothing acts on it.

---

# Definitions

*Agent, AgentDeclaration, Workflow, and WorkflowDeclaration — the authoring API, field by field.*
Source: https://agentdecksdk.com/reference/definitions

# Definitions

Two constructors are what a project actually builds. [Agents](/concepts/agents),
[Capabilities](/concepts/capabilities), and [Workflows](/concepts/workflows) cover why each
argument exists and how they compose; this page is the exhaustive one — every field and every
method, with its type and default.

## `Agent`

```python
from agentdeck import Agent
```

| Argument | Type | Default | Purpose |
|---|---|---|---|
| `base` | `type[AgentDeclaration] \| None` | `None` | a shareable set of defaults (see below); keyword-only |
| `name` | `str` | `base.name`, or the base class name | registry key and SDK agent name |
| `instructions` | `str \| Callable` | `base.instructions` | the system prompt, or a callable computing it per turn |
| `handoff_description` | `str \| None` | `base.handoff_description` | how this agent describes itself to a peer deciding whether to hand off |
| `model` | `str \| None` | `base.model` | per-agent override; `None` uses the `OPENAI_MODEL` setting |
| `model_settings` | `Mapping[str, Any]` | `base.model_settings` | forwarded to `agents.ModelSettings(**model_settings)` |
| `tools` | `Sequence[Any]` | `base.tools` | plain functions (compiled at `build()`), already-built SDK tool objects, or a `Workflow` to expose as a tool |
| `handoffs` | `Sequence[Any]` | `base.handoffs` | peer agents this one can transfer to — a registry name (`str`), a built `Agent`, or a `Handoff` |
| `output_type` | `type \| AgentOutputSchemaBase \| None` | `base.output_type` | a Pydantic model (or `AgentOutputSchema`) for structured output; `None` for free text |
| `hooks` | `AgentHooks \| None` | `base.hooks` | SDK lifecycle callbacks; a method may declare a `Context` first instead of the SDK's wrapper |
| `skills` | `Sequence[str]` | `base.skills` | names resolved against the owning `Deck`'s `skills=` roots |
| `mcp` | `Sequence[str]` | `base.mcp` | names resolved against the owning `Deck`'s `mcp=` file |

`Agent` is immutable once constructed (`AttributeError` on assignment) — a `Deck` compiles it once
at `build()`, and nothing about the compiled result can drift out from under a mutation
afterwards. A value explicitly passed here always wins over `base`'s, including an explicit empty
value: omission, not falsiness, is what defers to the base. `base=` is keyword-only by
construction, so `Agent(SomeDeclaration, name=...)` is a `TypeError` rather than a silently
accepted positional base.

A tool is a plain function. `build()` compiles it: the name comes from the function, the
description from its docstring, and the schema the model sees from its annotated parameters.

```python
def find_slots(day: str) -> str:
    """Find free appointment slots on a given day."""
    ...
```

One parameter is not shown to the model. A parameter annotated `agentdeck.Context[T]` receives
the value passed to `deck.run(..., context=...)` — see [Deck](/reference/deck) — and is absent
from the tool schema entirely, whatever it is named:

```python
from agentdeck import Context

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

Declaring two such parameters is a `build()` error. So is a callable whose signature cannot be
read — a decorator that dropped `functools.wraps` is the usual cause — since there is then no
honest schema to show the model and no way to tell "declares no context" from "could not look".
And so is a `T` the owning deck's `Deck(context=...)` cannot satisfy, when it declared one: a
`ContextTypeError` naming both types, at `build()`. See
[Deck](/reference/deck#declaring-the-context-type) for what counts as compatible and what is
deferred to invocation instead.

An already-built Agents SDK tool object (`@function_tool`, `WebSearchTool()`, …) is still
accepted and passed straight through. It is **engine-native**: agentdeck introspects nothing
about it, it gets no portability guarantee, and it cannot receive a `Context`.

The same annotation works at the other three injection sites, with the same rules. `instructions=`
may be a callable taking at most one `Context[T]` and nothing else, in which case only the string
it returns reaches the model. A `hooks=` method may name a `Context[T]` where the SDK's own
wrapper would go — it has to be the first parameter, since that is where the SDK passes its
context — and a hooks object declaring none is passed through untouched. A workflow node takes
one alongside its `state`, and the two stay strictly separate: `state` is the workflow's mutable
data, `ctx.data` is the environment the run was handed.

| Method | Signature | Behavior |
|---|---|---|
| `build()` | `() -> agents.Agent` | compiles a fresh SDK agent from the fields above — no caching, and no catalog: handoffs by name and `tools=[a_workflow]` need `Deck.build()`'s two-pass compile instead |
| `run()` | `(message=None, **runner_options) -> agents.RunResult` | one-shot **headless** run |

`Agent.run()` is not `Deck.run()`. It calls the SDK's own `Runner.run` directly — no Runtime, no
event log, and it returns the SDK's `RunResult`, not a `TurnResult`. Reach for it inside a script
or a test that only needs one headless call and does not care about the log; reach for
`Deck.run()` (see [Deck](/reference/deck)) for anything that should be recorded and readable back
afterwards.

It also passes no context. There is no `context=` on the headless runner, so a tool declaring one
refuses when the model calls it, saying the run "carries NoneType rather than an AgentDeck run
context". A context-declaring agent is a `Deck.run()` agent.

`skills=`/`mcp=` names unresolved against the owning `Deck`'s catalog fail `Deck.build()` naming
the offending agent and the names it declared that nothing provides.

### `AgentDeclaration`

```python
from agentdeck.authoring import AgentDeclaration
```

A reusable set of defaults: subclass it, set class attributes, and pass the subclass as `base=`
to as many `Agent(...)` calls as need it.

```python
class BookingBase(AgentDeclaration):
    instructions = "You handle bookings."
    model_settings = {"temperature": 0.2}

booking = Agent(base=BookingBase, name="booking", tools=[find_slots])  # find_slots: see above
support = Agent(base=BookingBase, name="support", instructions="You handle support tickets.")
```

`AgentDeclaration` is never constructed or run directly — every attribute it carries is a
`ClassVar`, and it exists only to be named as `Agent(base=...)`.

## `Workflow`

```python
from agentdeck import Workflow
```

| Argument | Type | Default | Purpose |
|---|---|---|---|
| `base` | `type[WorkflowDeclaration] \| None` | `None` | a shareable graph-building declaration (see below); keyword-only |
| `name` | `str` | `base.name`, or the base class name | registry key |
| `description` | `str` | `base.description` | tool description when exposed via `as_tool()` |
| `state` | `type` (a Pydantic model) | `base.state` | the graph's state schema — required one way or the other |
| `durable` | `bool` | `base.durable` (`False`) | compiles with a checkpointer (`AGENTDECK_CHECKPOINT_*`) when `True`, so a run can resume by `thread_id` after the process dies |
| `graph` | `() -> StateGraph` | `base.build_graph` | a bare graph-building factory, in place of overriding `build_graph()` on a `base=` |

Exactly one of `base=` (a `WorkflowDeclaration` subclass overriding `build_graph()`) or `graph=`
(a bare `() -> StateGraph` factory) supplies the graph — the same override-on-construction shape
`Agent(base=...)` has, so the two constructors read as one pattern. `Workflow` is immutable once
constructed, for the same reason `Agent` is.

| Method | Signature | Behavior |
|---|---|---|
| `build_graph()` | `() -> StateGraph` | the graph, uncompiled — delegates to `graph=`/`base.build_graph()` |
| `build()` | `() -> CompiledStateGraph` | compiles the graph, with a checkpointer iff `durable` |
| `run()` | `(state=None, *, thread_id=None, **runner_options) -> Any` | runs the graph once, direct-call (no event log); returns the final state or an `InterruptResult` |
| `run_stream()` | `(state=None, *, thread_id=None, **runner_options) -> AsyncIterator` | `node_update`/`custom` events, then `done` — or an `InterruptResult` in its place |
| `resume()` | `(thread_id, value, **runner_options) -> Any` | continues a paused run; `interrupt()` returns `value` |
| `pending()` | `() -> list[InterruptResult]` | every thread of this workflow currently paused on an interrupt |
| `as_tool()` | `(*, name=None, description=None, output_keys=None, defaults=None, strict_json_schema=False) -> FunctionTool` | exposes the workflow as a tool |
| `node_names()` | `() -> list[str]` | node names on the graph, excluding `START`/`END` |

`resume()` and a paused `interrupt()` both raise `ConfigError` when `durable` is `False` — there is
no checkpointer to resume from. `thread_id` is required when `durable` is `True` (a `ValueError`
otherwise) and ignored when it is not.

The same asymmetry as `Agent.run()` applies here. Calling `some_workflow.run(...)` directly drives
the compiled graph and writes nothing to the event log; `Deck.run()` and `Run.answer()` are what
the Runtime actually plays, and record the run — see [Deck](/reference/deck). `Deck.stream()`
plays a workflow the same way, so nothing on `Deck` calls `run_stream()` — it stays useful for a
script or a test that wants the raw graph stream with no log.

The asymmetry goes further for a node that declares a `Context[T]`. The bridge that puts a context
into a node is installed when a `Deck` builds its catalog, so `run()`, `resume()` and `as_tool()`
never install it: such a node reached by one of those paths dies with LangGraph's own
`TypeError: <node>() missing 1 required positional argument`, not with agentdeck's message. Loud
and immediate, but it is the raw engine error — the price of leaving a log-free convenience alone.

`as_tool()` requires `state` to be a Pydantic model (`TypeError` otherwise, since the tool's JSON
schema comes from `state.model_json_schema()`), and requires `durable=False` — a tool call
supplies no `thread_id`, which a durable workflow needs for its checkpoint, so `Deck.build()`
raises `ConfigError` naming the agent and the workflow. A durable workflow is a root invocable:
reach it through `deck.run(...)` with a session, not as an agent's ability. `defaults` pins specific state fields to fixed
values regardless of what the model passes, and strips those fields from the schema the model
sees; `output_keys` filters the final state down to a subset of channels in the tool's return
value. Both are optional — the default is the whole schema, the whole final state.

### `WorkflowDeclaration`

```python
from agentdeck.authoring import WorkflowDeclaration
```

Override `state` and `build_graph()`:

```python
class Booking(WorkflowDeclaration):
    state = BookingState
    durable = True

    @classmethod
    def build_graph(cls):
        g = StateGraph(cls.state)
        ...
        return g

booking_flow = Workflow(base=Booking, name="Booking")
```

Never constructed or run directly — it exists only to be named as `Workflow(base=...)`.

---

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

---

# Roadmap

*What AgentDeck is working on next, in the order it is being worked on, and what is deliberately not planned.*
Source: https://agentdecksdk.com/roadmap

# Roadmap

AgentDeck ships in themed releases. Each one has a single sentence it has to satisfy, and an issue
that does not satisfy it waits for the release that fits — which is why the list below is short
and the milestones are not a dumping ground.

<Callout type="info">
  The authoritative list is
  [GitHub Milestones](https://github.com/agentdecksdk/agentdeck/milestones) — this page explains the
  shape, GitHub holds the contents. If the two disagree, GitHub is right and this page is stale.
</Callout>

## v3.0.0 — one way to work · **released**

`Deck` is the one composition root: `Deck(agents=…)` and `Deck.from_project()`, two front doors
onto one catalog. Multimodal input, a versioned event envelope, `Context[T]` injection carried
over both engines' native channels, observability declared where the deck is declared, and the
v1 HTTP/SSE wire frozen byte-for-byte.

See the [changelog](/changelog).

## v3.1.0 — on PyPI, and the first hardening instalment · **released**

`pip install agentdeck-sdk`. The distribution is `agentdeck-sdk` and the import stays `agentdeck`
— PyPI refuses the shorter name as too close to an unrelated placeholder project.

It also carries the first of the hardening work rather than waiting for the whole milestone:
`Agent(model=...)` is honoured instead of silently overridden, checkpointer failures raise
`StoreError`, `agentdeck-serve --help` prints usage, and `agentdeck.testing` is exported as a
supported test harness. See the [changelog](/changelog) and
[Known Issues](/known-issues#fixed-in-v310).

## v4.0.0 — hardening · **released**

> Make what exists more correct, more robust, more trustworthy. Ship no new user-facing
> capability.

Thirty-seven issues, most of them findings from people using the SDK rather than reading it. The
line for inclusion was **what happens when nothing is added**: a durable workflow that dies on a
transient failure is a defect and belonged here; an agent that receives more tools than it needs
still works, and that belongs in v3.3.

The major version is what the hardening cost: a run's identity, the run-scoped API and the
control plane all changed shape, and none of those breaks could be deferred without carrying the
defect they fix.

- **A run has one identity, and it is minted.** `run_id=` is gone; `key=` is an application
  identifier for lookup and idempotency, never the run's address. Two namespaces reusing one key
  now get two unrelated runs instead of a collision, and a pause meant for one tenant can no
  longer land on another's run.
- **Every per-run verb moved onto a `Run` handle.** `deck.runs` is `start`/`get`/`list`; the
  handle owns `pause`, `resume`, `cancel`, `answer`, `status`, `pending`, `events`, and `await`.
- **Nothing accepted is silently discarded.** A cancel against a run waiting on a human ends it.
  A pause recorded against a suspended run refuses the answer rather than being lifted by it.
  A tool that raises records `tool.call.completed.error`.
- **A killed worker is detected, not waited out.** A run holds a lease while it plays, so with a
  shared control backend its session is free within 90 seconds instead of an hour.
- **`sleep_until` wakes up.** An open deck sweeps for its own lifetime, with no cron wired in.
- **A test harness**, exported, so scripting a model costs a line instead of sixty.

What survived the milestone is on [Known Issues](/known-issues), with workarounds. The upgrade
itself is in the [changelog](/changelog), which lists every break and what replaces it.

## v5.0.0 — native workflows & universal invocation · **next**

> Write agentic systems as normal Python, compose anything through one invocation model, and get
> the same `Run` lifecycle, durability and observability whether the implementation is
> AgentDeck-native or a wrapped LangGraph graph.

The change that earns a major version is not more workflow features. It is that a workflow becomes
**ordinary Python**: an imperative `@workflow` with `ctx.invoke`, `ctx.run`, `ctx.reporter` and a
durable operation/replay model. LangGraph and anything else become invocables behind one boundary,
auto-wrapped where `.agentdeck/` finds them.

Deliberately narrow. What is in it:

- **An imperative `@workflow`**, and a universal invocable boundary an agent, a tool, a workflow
  and a foreign SDK object all sit behind.
- **Typed suspension**: `ask()`, `pause()`, `wait()`, `safepoint()` in place of today's untyped
  `answer(value)` and its magic-dict interrupt vocabulary.
- **Subagents** as a child run that falls out of the invocation tree rather than a second
  execution system: usage, cancel, observability and depth included.
- **One reporter per run**, rebuilt on the new context, with the same semantics for a native
  workflow and a wrapped one.
- **A handoff and an invocation named in the event model**, now that both share one trace.
- **The LangGraph checkpoint namespace fix**, a blocker: seamless wrapping cannot be advertised
  while a durable thread's identity can cross namespaces.

Everything below is downstream of it. Doing the composition or protocol work first would mean
doing it twice.

## v5.1.0 — composition, skills & integrations

> What you assemble a deck out of, once there is one way to invoke any of it.

Skills consumed through the `SKILL.md` protocol with progressive disclosure, and pinned bundles
you can install and verify. MCP authentication, per-user credentials, declarative per-agent tool
filters, and presets for the SDK's hosted tools. A zero-config `Preset` for the infrastructure a
deck opens. `.agentdeck/` composition ergonomics, including sharing a type between a bundle and
the program that composes it, and a context-aware ASGI surface. Two decks side by side in one
process. **Advisors**: consult a peer about the conversation you are already having, read-only,
and resume with the answer without handing the conversation over. Advisors land here rather than
in v5.0.0 because child-invocation semantics have to exist first.

## v5.2.0 — operations, control & protocol surfaces

> More than one caller, and someone operating it.

Authentication on the mutating endpoints. Injecting messages into a run already in flight. A CLI
that can *read*: list the approval inbox, show a run, answer from a terminal. Standard agent
protocols (A2A, an MCP server surface, OpenAI-compatible) as adapters over the event stream.

## Provisional, past v5.2

Themed and named, not scheduled: what the native workflow and runtime work in v5.0.0 turns up will
move things between these, so none of them is a milestone yet.

| Theme | What is in it |
|---|---|
| Durability & runtime hardening | Lease and liveness beyond one machine, timer/inbox unification, recovery policies, durable suspension edge cases |
| Observability & scale | Trace nesting and correlation, store query optimization, inbox pagination, due-work indexing |
| Security & isolation | Sandboxing, safe context projection, stronger execution and tool boundaries |

Docs, tests, simplification and small findings are continuous. They ship with whatever release
they land in rather than waiting for one of their own.

## Not planned

<Callout type="warning">
  **Nothing is sandboxed today.** AgentDeck runs your tools, skills and workflow nodes as ordinary
  Python in your process, and a model-chosen tool call is trusted by design. Isolation is a named
  provisional theme above, not a milestone with a date, so build as if it will never arrive. Read
  [SECURITY.md](https://github.com/agentdecksdk/agentdeck/blob/main/SECURITY.md) before you give an
  agent something destructive.
</Callout>

Also deliberately absent, and unlikely to change: a YAML agent format, an execution engine of
AgentDeck's own, multi-tenancy beyond a namespace and a shared token, model routing, and a
prompt-management or evaluation product. The
[README](https://github.com/agentdecksdk/agentdeck#what-it-deliberately-does-not-do) explains why
each one is somebody else's job.

## How something gets onto this page

Findings come from people using the SDK — a first-time user's session, an outside review, or the
documentation assistant's own failures — and every one gets a disposition: fixed, rejected with a
reason, or scheduled against a named issue. Nothing sits in a document with no answer.

If you hit something that is not on [Known Issues](/known-issues),
[open an issue](https://github.com/agentdecksdk/agentdeck/issues/new) — a reproduction is worth more
than a diagnosis.

---

# 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/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
[Choosing a Store Backend](/concepts/choosing-a-store-backend).
&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](/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 [Roadmap](/roadmap).

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

---

# Changelog

*What changed in each release of AgentDeck, and what to do about it when upgrading.*
Source: https://agentdecksdk.com/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.0**. Earlier releases are listed at the bottom.

## v4.0.0

*Released 2026-08-16.*

**Hardening, and it cost a major version.** Thirty-seven issues, most of them findings
from people using the SDK rather than reading it. Nothing here adds a user-facing
capability; what it adds is the right to trust what was already there. Three things
had to change shape to fix the defect underneath them: a run's identity, the
run-scoped API, and the control plane. Read **Upgrading** before you bump.

### Upgrading

- **Breaking: `deck.runs` is now `start`/`get`/`list`, and a `Run` handle owns every op that
  acts on a run already in flight** (#322). `deck.runs.pause/cancel/resume/answer/status/pending`
  are removed, not deprecated. `await deck.runs.start(name, input, ...)` begins a run and hands
  back a `Run` (`.id`, `.key`, `.namespace`, `.session_id`) whose own methods replace them:
  `run.status()`, `run.pause(reason)`, `run.resume()`, `run.cancel(reason)`, `run.pending()`,
  `run.answer(value)`, `run.events(from_seq=0, follow=False)`, and `await run` for the result — a
  `TurnResult` for an agent, the graph's own state for a workflow. `deck.runs.get(id)` (optionally
  `namespace=`) or `deck.runs.get(namespace=, key=)` rehydrates a handle to a run that already
  exists; it never mutates and raises `NotFoundError` for one this namespace has never heard of.
  `deck.runs.list(namespace=, status=, limit=)` replaces the old `pending()` inbox and stays
  scoped to one namespace. Two handles on one run always agree — the durable store is the only
  thing either reads from. `deck.run()`/`deck.stream()` are unchanged in behavior (still return
  an interrupt as a value rather than raising); `await run` on a `Run` that is `PAUSED` or
  `WAITING_ANSWER` instead raises the new `RunSuspendedError` (a `RunStateError`), carrying
  `.pending`, since there is no timeout parameter to wait either state out. `context=` is retained
  on the handle `runs.start()` returns for that handle's whole life — `resume()`/`answer()` no
  longer take one, and a handle from `get()` always resupplies `None`. `PendingRun` is no longer
  public (`deck.runs.list(status=RunStatus.WAITING_ANSWER)` replaces it); `InterruptResult` gains
  the canonical `id` alongside its existing fields. `EventStorePort.locate()` is removed (no
  caller left once `Deck._status` went with it) and replaced by `find_by_key(ctx, key)`, the read
  side of the `(namespace, key)` claim, across all four stores.
- **Breaking: a run's `id` is now minted, never derived from a caller-supplied value** (#324).
  `deck.run(...)`/`deck.stream(...)` no longer accept `run_id=`: the keyword is `key=`, an
  optional stable application identifier for lookup and idempotency, and it plays no part in
  the run's own address any more. Every run gets a fresh, globally unique `id` regardless of
  `key`, so two namespaces reusing one key now get two unrelated runs instead of the collision
  risk `run_id=` carried. `(namespace, key)` is a permanent claim once a run starts with it — a
  second `deck.run(..., key=...)` reusing one raises `DuplicateKeyError` rather than replaying
  the run that holds it, and the pairing survives a restart. The `events` table gains a `key`
  column and its run-scoped uniqueness tightens from `(namespace, log_key, run_id, seq)` to
  `(namespace, run_id, seq)`, so one logical run can no longer be split across two log keys. An
  existing SQLite events database is migrated in place on open (`key` column added, the tightened
  index rebuilt); a database with rows that genuinely violate the tighter constraint raises
  `StoreError` naming the conflict instead of silently picking a survivor. `list_runs` gains a
  `limit` parameter across all four stores.
- **Breaking: `deck.run(...)`/`deck.stream(...)` now raises `SessionBusyError` on a session
  held by a run parked `PAUSED` or `WAITING_ANSWER`, however long ago it went quiet** (#311).
  Every store's `claim_start` applied `AGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS` to *any* open
  run, including one suspended waiting for a human — so a parked approval was silently closed
  `failed` (destroying it) by the very next turn started on its session once the window had
  passed, contradicting the README's own promise that an approval outlives the process that
  asked for it. The timer now only ever applies to `RUNNING`; a parked run holds its session
  until `deck.runs.answer`/`deck.runs.resume` continues it or `deck.runs.cancel` ends it,
  however long that takes. `SessionBusyError`'s message reflects it too: a parked holder names
  the call that frees it instead of claiming it is "in flight", which was never true of it.
  If your deployment relied on a stale approval being cleaned up automatically, call
  `deck.runs.cancel(run_id)` on it explicitly instead — see [Sessions and
  Memory](https://agentdecksdk.com/concepts/sessions-and-memory#one-turn-at-a-time).
- **`redis` is no longer installed by `pip install agentdeck-sdk`** (#253). A deployment with
  `AGENTDECK_SESSION=redis://...` or `AGENTDECK_EVENTS=redis://...` now raises `ImportError` at
  boot — `Deck.__aenter__` resolves both through `SessionFactory.from_settings()` and
  `resolve_event_store()` before it opens — not on first use. It was a base dependency because a
  Redis-backed session (`agents.extensions.memory.RedisSession`) was imported unconditionally on
  every agent run, whatever `AGENTDECK_SESSION` was set to. That import is now deferred to the
  point a `redis://` URL is actually configured, and the client moves to a new `[redis]` extra:
  `pip install "agentdeck-sdk[redis]"`. Selecting a `redis://` session or event log without it
  raises a clear `ImportError` naming the install command, the way the durability extras already
  do.

### Added

- **A fourth example, `examples/existing-langgraph-agent`** — a LangGraph graph written
  without agentdeck, wrapped in four lines and gaining the event log, streaming and run
  control without a change to the graph module. It documents the two things wrapping asks
  for: `graph=` takes an uncompiled `StateGraph` factory (so agentdeck can attach a
  checkpointer when a workflow is `durable`), and a sibling module inside a bundle is
  imported relatively (`from .pipeline import …`). A `TypedDict` state is fine; a pydantic
  model is not required.
- **`AGENTDECK_RUNNER_HANDOFF_ENDS_ON_USER_TURN`** (#178). agentdeck collapses a handoff's
  transcript into a single assistant-role message before handing it to the next agent, and some
  OpenAI-compatible endpoints (Gemini's, for one) reject a request that carries no user role at
  all. Setting this to `true` appends a closing user turn after the collapsed transcript, via
  `RunConfig.handoff_history_mapper`. Off by default: it changes what every model sees on every
  handoff, including against OpenAI, so it stays opt-in rather than becoming everyone's new
  default behavior. Wired into both places agentdeck sets `nest_handoff_history` — a
  Runtime-driven run and a workflow node driving an agent of its own.
- **`AGENTDECK_RUNNER_HANDOFF_CLOSING_TURN`** (#178), defaulting to `"Please continue."` — the
  content of the user turn `AGENTDECK_RUNNER_HANDOFF_ENDS_ON_USER_TURN` appends. Override it 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. An empty (or
  whitespace-only) value refuses to start rather than silently producing an empty user turn —
  the shape a provider strict enough to need the setting is likely to reject too.

### Changed

- **Breaking: `RunStatus.WAITING_HUMAN` is now `RunStatus.WAITING_ANSWER`**, value
  `waiting_answer` (#295). The state pairs with the verb that leaves it, and covers a timer, a
  webhook or another agent as honestly as a person — `sleep_until` parks here, so a wall-clock
  wait was being recorded as a human one. An ordinary API break, not a schema change: status is
  derived by folding the log and is never serialised into a payload, so no golden file and no
  snapshot moves. `RunInterrupted.reason`'s `"human"` literal *is* in the schema and is
  unchanged; renaming it is a separate versioned change.
- **Breaking: `ControlPort` gains `consume(run_id, expected) -> bool`** (#295), the
  compare-and-set that takes the intent a caller just ruled on and only that one. A third-party
  adapter must implement it; both shipped adapters (`memory`, `sqlite`) do. It replaces
  `resume_run` writing `RESUME` over whatever was pending — an unconditional write that could
  overwrite, and silently destroy, a cancel that arrived while the run was suspended. A gate that
  honors a signal now takes it too, so the port is empty afterwards rather than holding a
  sentinel.
- **A cancel or pause recorded against a *stopped* run is now read where that run is picked up**
  (#295). A run that has already stopped has no loop polling the gate, so the operation
  continuing it — an answer, or a resume — reads the control port at its claim and rules on what
  it finds. Every such read ends in an event or an explicit no-op, never in silence.
- **Breaking: `EventStorePort` gains `locate(run_id, ctx) -> log_key | None`** (#316), so finding
  the log holding a run id is an indexed lookup rather than a scan of every run in the namespace
  — `log_key` is the session id for a run under one, so a run id alone never named its own log.
  A third-party adapter must implement it; all four shipped ones (`memory`, `sqlite`, `redis`,
  `postgres`) do, adding no data any of them didn't already hold: SQLite and Postgres gain an
  index over `events`' own `namespace`/`run_id` columns (`CREATE INDEX IF NOT EXISTS`, so it
  applies cleanly to a database an earlier build already created), and memory/Redis keep a
  derived `(namespace, run_id) -> log_key` mapping a replay of the log rebuilds. `Deck._status`
  (behind `deck.runs.status`) uses it now instead of walking `list_runs`.
- **Breaking: `deck.run(...)`/`deck.stream(...)` no longer stop a run when its caller stops
  reading it** (#325). Execution used to *be* consuming the event generator, so closing
  `stream()`'s frame (or having the task reading it cancelled, as a real HTTP disconnect does)
  closed the run underneath it as `run.cancelled`. A run now advances in a deck-owned task from
  the moment it starts, independent of whether anyone is still watching — the same task any
  number of readers may observe through the store without stealing its events from one another
  or advancing it, and without needing to have started it themselves. A client that disconnects
  mid-stream therefore no longer stops the turn it was reading: the run keeps executing to its
  own natural end (bounded to one turn, its session freed once it reaches one), and the explicit
  `deck.runs.cancel(run_id)` is how a caller who wants that back gets it. `deck.stream()`'s wire
  bytes are unchanged (`tests/golden/` proves it byte-for-byte) and `deck.run()`'s propagated
  exception on a failed turn is unchanged; only the disconnect-cancels-execution coupling is
  gone. `Deck.aclose()` now settles or cancels whatever it is still executing before closing the
  store, and logs which happened per run.

- **`agentdeck.testing.scripted_model_server`'s `tool_name=` now also accepts a sequence of
  names** (#248), one tool call per request in order, then plain text once the sequence is
  exhausted — the shape a multi-step tool chain or a handoff round trip needs to script.
  A single name keeps its existing one-shot behavior unchanged.
- **Error messages a first-time user hits during composition or a first run now name the one
  docs page that answers them** (#238): the skill frontmatter/discovery `ConfigError`s (missing
  `description`, a name that doesn't match its directory, a duplicate name across skill roots),
  `SessionBusyError`, the store/checkpoint `ImportError`s for the `durability` and `redis`
  extras, the unknown-scheme `ValueError`s for `AGENTDECK_CONTROL`/`AGENTDECK_EVENTS`/
  `AGENTDECK_CHECKPOINT`, and the durable-workflow missing-`thread_id` `ValueError` (both the
  direct-call and the langgraph-engine copy). No error type or field changed, only the
  message text. In passing, the two durability install hints now say `agentdeck-sdk[durability]`
  (the actual distribution name) instead of the pre-rename `agentdeck[durability]`.

### Deprecated

- **`Usage.usd` is documented as reserved, not populated** (#177). agentdeck does not price
  model calls — no provider returns dollars in a response body, and a price depends on a
  contract, a tier and a date rather than on the call — so the field is `None` unless a caller
  sets its own cost. No behavior changes; it was always `None` in practice. Slated for removal
  at the next major.

### Removed

- **Breaking: `RunStatus.PENDING` is deleted, and `status_of([])` returns `None`** (#295). It
  was the fold's identity element for an empty sequence, never a state a run is in: `run.started`
  is a run's row 0, so there is no moment between "does not exist" and `RUNNING` for it to name.
  A store already answered `None` for a run it never saw (#294); `status_of` now agrees, so
  `status_of` is typed `RunStatus | None` and `can_resume` accepts `None`.

- **Breaking: the six run-scoped verbs move from flat `Deck` methods to `deck.runs.*`, and
  `tick`/`due_resumes` leave the public surface entirely** (#294). `deck.pause`, `deck.cancel`,
  `deck.resume`, `deck.answer`, `deck.status` and `deck.pending` are gone; call
  `deck.runs.pause(...)`, `deck.runs.cancel(...)`, `deck.runs.resume(...)`,
  `deck.runs.answer(...)`, `deck.runs.status(...)` and `deck.runs.pending(...)` instead — same
  signatures, same behavior, just grouped under the noun they act on rather than sitting flat
  beside the catalog and the two verbs (`run`/`stream`) that start a turn. `deck.tick()` and
  `deck.due_resumes()` — the timer sweep nothing in agentdeck calls yet — are no longer public at
  all; `sleep_until` keeps working, since the underlying sweep is unchanged, just no longer
  reachable from outside `Deck`.

### Fixed

- **A worker killed outright held its session for up to an hour** (#244). Liveness was inferred
  from silence, and a healthy turn can be quiet for a long time — so the staleness window had to
  be generous, and one crashed process locked one user out of one conversation for
  `AGENTDECK_RUNTIME_STALE_RUN_AFTER_SECONDS` (3600 by default), with no way to shorten it after
  the fact. A run now holds a **lease** while it plays and renews it six times per TTL, so the
  next turn on that session can positively assert that nobody is executing the run it found open,
  instead of waiting out a timer. With `AGENTDECK_CONTROL=sqlite:///<path>` a killed worker's
  session is claimable within one lease TTL (**90 seconds** by default, set with
  `AGENTDECK_RUNTIME_LEASE_TTL_SECONDS`). The new `LeasePort` reports only runs it **held and
  watched expire** — a run it
  has never seen is never reported dead — so with the `memory://` default, which knows nothing
  about any other process, behavior is exactly as before and the staleness timer remains the only
  backstop; boot warns when that is the case. Suspended runs are unaffected: `PAUSED` and
  `WAITING_ANSWER` have no worker to be dead, so they still hold their session until resumed,
  answered or cancelled. No new public API on `Deck` or `deck.runs`. Redis and Postgres lease
  backends follow when `AGENTDECK_CONTROL` gains those schemes.
- **A cancel or pause could land on the wrong tenant's run when two namespaces shared a
  caller-supplied `run_id`** (#315). Both `ControlPort` adapters (`memory`, `sqlite`) kept one
  pending signal per bare `run_id` — `acme/order-1234` and `globex/order-1234` shared a row, so
  a cancel meant for one could land on the other, and `consume()`'s compare-and-set made the two
  fight over the same slot. The control plane now addresses a run by its `id`, an opaque address
  that `RunContext.id` supplies — `Gate`, `Runtime.signal`/`resume`/`resume_run` and both
  `ControlPort` adapters all key by it, and no path takes a bare caller-supplied `run_id`.
  **Unnamespaced deployments see no change at all**: an unnamespaced id is byte-identical to
  today's `run_id`, so stored ids, the unnamespaced CLI (`agentdeck runs signal`) and the frozen
  v1 HTTP wire are unaffected. A caller-supplied `run_id` starting with `adr:` is now refused —
  that prefix marks a namespaced id, and without the reservation an unnamespaced one could be
  crafted to collide with it. `agentdeck runs signal`
  now builds a `RunContext` to reach that same refusal, rather than writing straight to the
  `ControlPort`: a forged `run_id` shaped like a real `encode(namespace, run_id)` could otherwise
  reach a live namespaced run's `Gate` with no validation at all, from the one caller-facing
  surface that talks to a `ControlPort` without going through a `Runtime`.

  **Breaking, sqlite only:** the `signals` table's primary key is now `id`, not `run_id`. A
  file with no pending signal migrates automatically in place. A file with one or more pending
  signals refuses to open instead: the old schema never recorded a namespace at all, so a
  pending row cannot be told apart from one that collided under the very bug being fixed here,
  and carrying it forward under a guessed identity could silently re-address it to an unrelated
  run. Let every in-flight run settle (or clear the `signals` table) before upgrading.

- **A tool that raises is now recorded on `tool.call.completed.error`** (#250). The field has
  been in the schema since v3.0.0 and nothing ever set it, so a database call that timed out or
  an API that 500'd left no machine-readable trace anywhere: the run completed, HTTP answered
  200, and the only sign of failure was whatever prose the model chose to write about it — which
  a model that paraphrases past the word "error" omits entirely. `compile_tool` now passes its
  own `failure_error_function` to the Agents SDK, records the exception type and message, and
  the openai-agents translator moves it onto the paired `tool.call.completed`, capped at
  `RESULT_PREVIEW_MAX` like `result_preview` beside it.

  **Nothing the model sees changes**, deliberately: the formatter delegates to the SDK's own
  `default_tool_error_function`, so the failure text and the agent's freedom to retry are
  byte-identical to before. A tool failure is still not a run failure, the run still ends
  `completed`, and no event kind was added. One gap, by design: a tool the author decorated with
  `@function_tool` themselves is passed to the engine untouched and keeps its own failure
  handling, so its exceptions stay unrecorded — that is the existing trade for handing in a
  pre-built SDK tool, not a new one.

- **`sleep_until` now actually wakes up** (#303). An open `Deck` sweeps for its own lifetime —
  started in `__aenter__`, cancelled in `__aexit__` — resuming any durable workflow parked past
  its wake moment with no cron or scheduler wired in by the user. Previously `_tick`/`_due_resumes`
  (the mechanism behind the sweep) were never called by anything, so a parked timer held
  `WAITING_ANSWER` forever, keeping its session claim, until something else happened to call the
  now-private `_tick`. The interval is `AGENTDECK_RUNTIME_SWEEP_INTERVAL_SECONDS` (default 30s) on
  `RuntimeSettings`, on by default — there is no deployment for which silently never waking a timer
  is the safer choice. A sweep that raises is logged and retried on the next interval rather than
  ending the loop; a process that opens the deck, takes a turn and closes within one interval never
  sweeps at all, and the deadline fires on whoever next holds the deck open past that.
- **A cancel against a run waiting for an answer is honored instead of vanishing** (#229, #295,
  #311). `deck.runs.cancel` on a parked run returned `True`, recorded the signal, and the run
  answered on anyway: only `resume_run` polled the control port, and an approval does not come
  back that way. `deck.runs.cancel` against a suspended run now claims and terminates it right
  there, recording `control.requested` then `run.cancelled` — no `control.observed`, because the
  run reached no safe point; it was already stopped when the cancel landed. Ends the same way for
  a *paused* run. Claiming happens at the cancel itself rather than being deferred to whoever
  next answers or resumes: once #311 stopped a stale timer from ever reclaiming a parked run's
  session, a deferred cancel could sit unread forever if nobody happened to touch the run again.
  `deck.runs.cancel(run_id, reason, namespace=...)` takes the same `namespace` `deck.runs.pending`
  already does, needed to locate a suspended run opened outside the default namespace at all.
- **`deck.runs.resume` on a run that is waiting for an answer now refuses, naming
  `deck.runs.answer`** (#295), and `deck.runs.answer` on a paused run refuses naming
  `deck.runs.resume`. Both raise the new `agentdeck.errors.RunStateError`, which the HTTP surface
  answers as `409`. `resume` used to return `[]` for a parked run — silence, to a caller holding
  that run's only answer — because the lookup behind it listed `PAUSED` runs only.
- **A pause recorded against a run waiting for an answer now refuses the answer** (#295) rather
  than being silently lifted by it, and stays pending. Lifting would let an answer override an
  operator who said stop; refusing costs the answerer one round trip and keeps both intents
  intact.

- **`EventStorePort.run_status` no longer returns `PENDING` for a run the store never heard
  of** (#294). It now returns `None` for that case, distinguishing it from a run that exists but
  hasn't logged a lifecycle transition yet — the two used to fold into the same value. Only the
  default projection changes (no adapter overrides `run_status`); `RunStatus.PENDING` and
  `status_of()`'s own contract are unchanged.
- **The documentation entry path now points readers to skills, sessions, durable stores and the
  API reference instead of ending at a two-link dead end** (#239). The getting-started page now lists the
  next concepts to read, the concepts overview names the reference as the source for exact API
  details, and the how-to guides link onward to the specific reference pages behind the APIs they
  use.
- **`pip install agentdeck-sdk` now runs a `durable=True` workflow with no extra** (#232).
  `langgraph-checkpoint-sqlite` — what `AGENTDECK_CHECKPOINT`'s default (`sqlite://...`) needs —
  moves from the optional `[durability]` extra into base dependencies, so the default that every
  human-approval workflow relies on is installable by default. `[durability]` now covers the
  Postgres checkpointer and event store only.
- **The non-streamed HTTP surface now answers every server-side failure with the documented
  500 `{"detail": "internal error"}`, not just `AgentdeckError` ones** (#243). A workflow
  node's plain exception, an SDK error, or an `httpx` transport failure used to fall through to
  Starlette's bare-text `Internal Server Error` on the non-streamed chat and workflow endpoints,
  while the streamed path already reported the identical failure correctly as an in-band SSE
  `error` event. A catch-all handler beside the existing one closes that gap; a tool's own
  exception is a separate, still-open gap (#250) — the SDK's default `failure_error_function`
  swallows it into a successful 200 before it ever reaches this handler. 404/409/422 and the
  existing `AgentdeckError` 500 are unchanged, and no exception message reaches the response
  body.
- **The human-approval guide now shows `answer()` re-supplying a context, and says what omitting
  it does** (#255). Two rules meet on resume — the interrupt node re-runs from its start, and the
  context is never serialized with the run — so a node reading `ctx.data` after an approval gets
  `None` rather than an error, and the run continues with a quietly wrong value. Both rules were
  already documented separately and correctly; their interaction was stated once in prose and
  never demonstrated, and two clean-room reviewers missed the consequence anyway.
- **The openai-agents engine no longer refuses a `DataBlock` on input** (#226). It used to raise
  `ConfigError` there — a `DataBlock` was an output-only block in practice, so the typed way to
  hand a model structured per-run context did not exist and every embedded application invented
  its own prose preamble. It now renders as its own part, `json.dumps(data, ensure_ascii=False)`
  with nothing wrapped around it: each block is already a separate entry in the SDK's content
  list, so the boundary between it and a neighbouring `TextBlock` is the API's own rather than a
  delimiter this adapter
  invents, and there is no open/close token embedded data could spoof to escape early.
  `ResourceBlock` still raises — a `uri` is a pointer the engine never fetches, and the message now
  says so, rather than reading identically to the data case. Crash reconciliation renders a
  `DataBlock` the same way on its log-side transcript, so a turn that carries one does not read as
  a permanent session divergence on every turn after it.
- **A langgraph workflow run now has a safe point, so `pause` and `cancel` can reach it**
  (#128). `LangGraphEngine` checkpoints the run's control gate between two `updates` chunks
  (langgraph's own node boundary), which is what produces `control.observed{safe_point:
  "node_boundary"}`; a workflow run previously had no safe point at all, so a signal against it
  sat unread until the graph finished on its own.

  **A resumed pause continues from that boundary: it never replays.** Unlike an interrupted
  run, which re-enters from its start, a paused workflow's checkpoint already has everything
  before the pause, so `deck.runs.resume` re-enters langgraph with `None` (its own idiom for
  continuing a thread) rather than the run's original input, and no already-completed node
  runs again. That guarantee holds for `durable=True` from any process; a `durable=False`
  workflow can only be resumed from the process that paused it (its checkpoint lives in that
  engine's own memory, ADR-D5), and is refused, naming `durable = True`, if resumed from
  another one instead of being silently replayed from the entry node with empty state.
- **The docs site is swept against the whole v4.0.0 surface.** Three claims were stale rather than
  merely thin: `definitions.mdx` named `Deck.runs.answer()`, removed by #322; `run-control.mdx`
  still called a run's identity `run_id`, renamed by #324; and `choosing-a-store-backend.mdx` said
  the control port "only has to outlive the seconds between a request and that safe point", which
  #244 made false by putting each run's liveness lease in the same backend. Two v4 changes had no
  page at all: a reader no longer drives the run it is reading (#325), now in
  `runs-and-the-event-log.mdx` and `serve-over-http.mdx`, and a raising tool's exception landing on
  `tool.call.completed.error` (#250), now in `add-a-tool.mdx` with the `@function_tool` opt-out
  named. `known-issues.mdx` retitles its fixed table to v4.0.0 and moves #244 and #178 into it,
  both closed; `roadmap.mdx` is rewritten around the shipped v4.0.0 and the v5.0.0/v5.1.0/v5.2.0
  milestones that replace v3.3/v3.4/v3.5. `AGENTDECK_CONTROL`'s own description now says it holds
  the lease port too, so the generated settings reference says it as well.
- **Docs swept against nine issues closed since the last pass** (#317). `known-issues.mdx` gains
  a `Fixed in v3.2.0` section (#250, #229, #232, #243, #255, #253, #226); `usage.usd` moves off
  the page entirely, since #177 ruled it a design position rather than a defect. The one entry
  that stayed open got reworded rather than removed: a tool's non-serializable return still
  reaches the model as a `repr()` — #251 was closed by folding it into #250, but #250's fix
  shipped only the raise half, so this half is untracked by any open issue today. `run-control.mdx`
  and `runs-and-the-event-log.mdx` drop their last `waiting_human`/`pending` references, both
  renamed away by #295. `README.md`'s extras line now matches `pyproject.toml` (SQLite
  checkpointer in base, `redis` its own extra) and its run-control bullet says "agent or workflow".
- **`tests/test_generated_reference.py` now covers all five files `generate_docs_reference.py`
  writes, not two** (#317). `settings.mdx` and `cli.mdx` stay pinned byte for byte; `llms.txt`
  joins them. `changelog.mdx` and `llms-full.txt` only assert the generator still produces them,
  rather than pinning them too: both derive from `CHANGELOG.md`, which is `merge=union` so
  concurrent PRs can each add an entry, and a byte pin would fail every open PR the moment any
  other one merged one. The three previously untested pages could drift from `CHANGELOG.md`/the
  site's own pages for a whole release with `make check` green throughout — reported as unrelated
  churn by two different agents this week when they regenerated one page and were surprised by
  the other four changing too.

### Added

- **`examples/agent-with-a-skill/`** — an agent with two tools and one skill, the first shipped
  example to include a `SKILL.md`. Skills were the only thing `Deck.from_project()` discovers with
  no runnable example, so the frontmatter contract could only be learned from a build error
  ([#242](https://github.com/agentdecksdk/agentdeck/issues/242)).
- **`docs/delivery/review-v3-outsider.md`** — the v3.0.0 clean-room review: three reviewers given
  only the wheel, the README, the docs site and `examples/`, each building a small app and reporting
  what broke. Source of the `finding:`-labelled issues opened against v3.0.0.

## Earlier releases

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

---
