Metadata-Version: 2.5
Name: swisper-agent-sdk
Version: 0.1.0a11
Summary: The published contract a partner builds a Swisper domain agent against
Project-URL: Documentation, https://pypi.org/project/swisper-agent-sdk/
License: MIT
Keywords: a2a,agent,domain-agent,sdk,swisper
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: License :: OSI Approved :: MIT License
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.11
Classifier: Programming Language :: Python :: 3.12
Classifier: Programming Language :: Python :: 3.13
Classifier: Typing :: Typed
Requires-Python: >=3.11
Requires-Dist: httpx>=0.27
Requires-Dist: jinja2>=3.1
Requires-Dist: jsonpath-ng>=1.7
Requires-Dist: jsonschema>=4.0
Requires-Dist: langchain-core>=0.1
Requires-Dist: langgraph>=0.2
Requires-Dist: pydantic>=2.0
Requires-Dist: pyyaml>=6.0
Requires-Dist: redis>=5.0
Requires-Dist: swisper-studio-sdk>=0.14.0
Provides-Extra: dev
Requires-Dist: mypy>=1.8.0; extra == 'dev'
Requires-Dist: pytest-asyncio>=0.21; extra == 'dev'
Requires-Dist: pytest>=7.0; extra == 'dev'
Requires-Dist: ruff>=0.2.2; extra == 'dev'
Description-Content-Type: text/markdown

# swisper-agent-sdk

Build a Swisper domain agent.

This package is self-contained. You do not need the Swisper backend, a database,
or network access to build an agent and test it.

> **Where this is in its life: an alpha, and `a5` means what it says.** The version
> is `0.1.0a11`. Build against it, and expect the surface described on this page —
> `Agent`, `Delegation`, `Reply`, `d.recall`, `d.generate`, `d.card`, `d.say`,
> `harness`, `AgentContract` — to keep working; it is what our own bundled agents
> use, so it breaks us before it breaks you. **Anything not documented on this page
> may move without a deprecation cycle**, and there are about 75 such exports (see
> "What is yours and what is ours" below, which says which is which).
>
> Pin an exact version (`swisper-agent-sdk==0.1.0a11`) rather than a range while we
> are on `0.1.0aN`, and **tell us you have pinned it** — we will tell you before a
> change that would break you rather than after. There is no deprecation policy yet
> because there is not yet a partner whose upgrade we would be managing; when there
> is, that is the moment it gets written, and you will be told rather than left to
> read it here.

## Install

Requires **Python 3.11 or newer**.

```bash
python3 -m venv .venv
./.venv/bin/pip install swisper-agent-sdk
```

Use the virtual environment's interpreter for everything below. Typing `python3`
rather than `./.venv/bin/python` is the most common way to get a
`ModuleNotFoundError` that looks like a broken package and is not.

> **A handful of declared dependencies pull in far more packages.** This package
> declares `pydantic`, `langgraph`, `jsonpath-ng`, `pyyaml`, `langchain-core`,
> `httpx` and `jsonschema` — and `langgraph` alone brings its own dependency tree
> with it (`langsmith`, `orjson`, `ormsgpack`, `zstandard` and more). Resolved from
> a clean manifest on 2026-08-27: **41 packages**, not seven. None of that is a
> defect — every one of them is something a declared dependency genuinely needs —
> but a partner watching 41 packages scroll by after being told about seven
> deserves to have been told first.
>
> Both numbers move as the package evolves, so read them from the source rather
> than from this paragraph — the declared list has changed four times already:
>
> ```bash
> python3 -c "import importlib.metadata as m;print(*[r for r in m.requires('swisper-agent-sdk') if 'extra ==' not in r],sep=chr(10))"
> ```
>
> That reads the *installed* package, so it works however you installed it. (An
> earlier version of this line read `pyproject.toml` off the disk — which raises
> `FileNotFoundError` after `pip install swisper-agent-sdk`, i.e. for exactly the
> path this page recommends first. A cold reader hit it. The one command offered
> here as more trustworthy than the prose has to work for the reader who runs it.)
>
> Not every declaration costs a download. Measured by resolving the manifest with
> each one removed: **`httpx` costs 0 packages** — `langchain-core`, `langgraph-sdk`
> and `langsmith` already require it — while **`jsonschema` costs 5**, since it brings
> `attrs`, `referencing`, `rpds-py` and `jsonschema-specifications` with it. `jsonschema`
> earns that: it validates card payloads against the schemas your agent declares, at the
> boundary, so a malformed card is refused where it is produced rather than somewhere
> downstream. `httpx` is declared despite costing nothing, because an import we do not
> declare breaks the day an upstream drops it — with an error naming a package our own
> manifest never mentions.

### From a directory, instead

Handed the package as a tarball or a source checkout rather than installing from
PyPI? Point `pip` at the directory instead of the package name — everything else
on this page works exactly the same either way:

```bash
python3 -m venv .venv
./.venv/bin/pip install -e ./swisper-agent-sdk
```

## Your first agent

This is `swisper_agent_sdk/examples/twelve_line_agent.py`, complete and
unabridged. It ships inside the package itself, so installing it is the only step
— there is nothing separate to fetch:

```python
from swisper_agent_sdk import Agent, Delegation, Reply

agent = Agent(
    name="echo_agent",
    description="Repeats the task back. The smallest thing that is still an agent.",
)


@agent.handler
async def handle(d: Delegation) -> Reply:
    if not d.task:
        return Reply.ask("What would you like me to do?")
    return Reply(text=f"You asked me to: {d.task}")
```

Run it — the same command whether you installed from PyPI or from a directory:

```console
$ ./.venv/bin/python -m swisper_agent_sdk.examples.twelve_line_agent
You asked me to: book a train to Bern
```

`d.task` is what the user asked for. Returning a `Reply` answers them;
returning `Reply.ask(...)` puts a question to them and pauses the turn.

## What always arrives, and what you ask for

Eight fields reach your agent on **every** delegation, whatever it declares. They
are the turn's context — who is asking, which conversation this is, and how to
speak back. You read them from `d.raw`:

| field | what it is for |
|---|---|
| `chat_id` | The conversation this turn belongs to. Key anything you keep between turns on it. |
| `user_id` | Who the turn is for. Use it to scope anything you store yourself. |
| `user_input` | What the user actually typed, verbatim. `d.task` is the supervisor's instruction *derived* from it — when you need their exact words rather than the instruction, this is the field. |
| `user_locale` | Regional formatting, e.g. `de-CH` — dates, numbers, currency. |
| `user_timezone` | e.g. `Europe/Zurich`. Anything you resolve against "today" or "tomorrow" needs it. |
| `current_time` | The turn's own clock, ISO-8601, from the user's device. Use this rather than your process's clock: your process may be in a different timezone, or on a different day. |
| `interaction_language` | ISO 639-1, for **user-facing text**. Detected upstream and recomputed on every turn and every answer to a question. When it is set, write your reply in that language rather than detecting it yourself. |
| `llm_reasoning_language` | The language the model reasons in, which is not necessarily the one it answers in. |

Everything else is **declared**. A field outside these eight reaches `d.raw` only
if your agent asks for it by name:

```python
agent = Agent(name="...", description="...", inputs=("resolved_entities",))
```

That is the whole rule: **core plus declared**. A field you did not declare, and
that is not one of the eight, is not in `d.raw` at all — not `None`, absent. So
prefer `d.raw.get("...")` over `d.raw["..."]` for anything you declared
conditionally.

These eight are what **your code** reads. Four of them — language, locale,
timezone and the current time — are *also* composed into the model's prompt for
you: `d.generate` builds them into a system message and prepends it to every call,
ahead of the messages you passed. So you rarely need to restate them in
`instructions=`; see `PROMPTING.md`, "Context you get for free". Reading
`interaction_language` yourself is for branching in code, not for telling the
model which language to answer in — that part is already done.

### `d.task` is none of the eight, and needs no declaring

`d.task` comes from the envelope's `current_plan`, and the facade hands it to you
whether or not you declare anything. The consequence is worth knowing before it
puzzles you: for an agent that declares nothing, `"current_plan" in d.raw` is
`False` while `d.task` is populated. Use `d.task`; it is the supported way to read
the instruction.

### `d.task` survives a resume

When your agent returns `Reply.ask(...)`, the turn pauses and resumes later with
the user's answer in `d.answer` and `d.resumed` set to `True`. **`d.task` still
holds the original instruction.** It is not replaced by the answer, and you do not
have to stash it yourself:

```python
@agent.handler
async def handle(d: Delegation) -> Reply:
    if not d.resumed:
        return Reply.ask("Which day?")
    # d.task is still "book me a train"; d.answer is "Thursday"
    return Reply(text=f"{d.task} — on {d.answer}")
```

To carry anything *else* across that pause, hand it to
`Reply.ask(..., resume_with=...)` and read it back from `d.resume_with` on the
resuming turn. That is the supported bookmark; reaching into the raw envelope's
HITL state instead is not.

## Running a turn yourself

`harness` runs a real turn against your agent with **no core, no database and
no network**:

```python
import asyncio
from swisper_agent_sdk import harness
from my_agent import agent

result = asyncio.run(harness.turn(agent, "book a train to Bern"))
print(result.text)
```

### Check your install

Needs the directory install above — `tests/` ships in a source checkout, not in
the plain PyPI wheel:

```console
$ cd swisper-agent-sdk
$ ../.venv/bin/pip install -e ".[dev]"
$ ../.venv/bin/python -m pytest
```

That runs a handful of smoke tests: the package imports, importing it leaves your
logging alone, an agent answers a turn offline, and every bundled example runs and
prints something. If those pass, the SDK works on your machine.

> **Run `pytest` from inside this directory.** The package's own
> `asyncio_mode = "auto"` setting lives in its `pyproject.toml`, and pytest only
> picks it up when invoked from here. Run it from the parent directory and every
> `async def` test fails — **including correct ones** — which looks like a broken
> SDK and is not.

`[dev]` is what installs the test toolchain. The base install deliberately does not:
it is the runtime, not the toolchain.

**It is `pytest` and `pytest-asyncio` that you need, but `[dev]` installs more than
that** — measured on 2026-08-27, `[dev]` takes a clean install from **43 packages to
54**, adding `mypy`, `ruff`, `Pygments`, `pluggy`, `iniconfig`, `pathspec` and others.
Those are *our* tools for developing the SDK, and you inherit them. If you would rather
not, `pip install pytest pytest-asyncio` alongside the base install is enough for
everything on this page. *(Recorded because the paragraph above warns you about exactly
this under-counting for the base dependencies, and then this line did it again.)*

### Writing your own tests

🔴 **Do this first or every test below fails.** Put an `asyncio_mode` setting in
**your own** project — the SDK's own setting does not reach your test files, and
without it pytest refuses every `async def` test with *"async functions are not
natively supported"*, which names neither this SDK nor the fix:

```ini
# pytest.ini, next to your tests
[pytest]
asyncio_mode = auto
```

<sub>Or, in `pyproject.toml`: `[tool.pytest.ini_options]` / `asyncio_mode = "auto"`.
You also need `pytest` and `pytest-asyncio` installed — either `pip install pytest
pytest-asyncio`, or `pip install swisper-agent-sdk[dev]`, which brings those two plus our
own linting toolchain (11 packages in total; see "Check your install"). **This paragraph exists because a developer reading this page cold copied the
example below verbatim and got four failures out of four**; the requirement was documented,
but under "Check your install", where it read as being about the SDK's own test suite.</sub>

`turn()` returns a `TurnResult` with `.text`, `.status`, `.resume_with`, `.cards`,
`.said`, and two helpers for assertions — `.said_something_about(...)` and
`.asked_for(...)`:

```python
async def test_it_answers():
    r = await harness.turn(agent, "weather in Bern tomorrow")
    assert r.said_something_about("Bern")


async def test_it_can_ask_a_question():
    r = await harness.turn(agent, "book me a train")
    assert r.asked_for("departure time")
    r = await harness.answer(r, "09:00")     # resumes the paused turn
```

`harness.answer(...)` resumes a turn your agent paused with `Reply.ask(...)`.
Inside the handler, `d.answer` is what the user replied and `d.resumed` is
`True`.

> **What a green harness run does and does not prove.** The harness stands in for
> the supervisor — it builds the delegation a real one would send — and no token is
> minted or verified anywhere on this path. It does **not** stand in for the
> inference capability: `d.generate` reaches whatever `generate_transport=` you gave
> the agent, so a green run proves your agent's logic against **your** stand-in, not
> that the capability works against a live core. Those are different claims and only
> the first one is tested here.

## Saying things, and sending cards

Two ways to put something in front of the user besides your final answer.

**`await d.say("…")`** narrates a deterministic moment — typically just before a slow
call, where waiting for the model to decide to mention it is the wrong behaviour:

```python
await d.say("Checking which trains actually run at that hour…")
```

It publishes onto the same channel Swisper republishes to the frontend, marked as a
**process indicator** rather than answer text — so it is shown as a status line and is not
concatenated into the reply the user keeps. It also stays in `result.said` for your tests.

**`await d.card("swi-transport", payload)`** emits a card. **The types your agent may
emit are declared on the agent**, and an undeclared type raises
`UndeclaredCardTypeError` — never a silent drop:

```python
agent = Agent(name="…", description="…", cards=["swi-transport"])

await d.card("swi-transport", {"from": "Zurich", "to": "Bern"})   # fine
await d.card("swi-invented", {...})                               # UndeclaredCardTypeError
```

The declared list is also what the prompt tells the model about, so it cannot invent a
card type that has no renderer. Both are visible in tests as `result.said` and
`result.cards`.

**No mapper, no registry — and you do not need to read `card_builder.py`.** `d.card()`
serialises the dict you give it directly; that module is Swisper's own internal rendering
pipeline for its own cards, and none of it is something a partner calls. (It is importable
from the package root, has "card" in the name, and is 660 lines long — a cold reader read
all of it before finding this out. Hence this paragraph.)

**You can have your payloads validated, and you should.** Declare a JSON Schema per card
type and the SDK checks every payload against it, at the point of emission:

```python
agent = Agent(
    name="…", description="…",
    cards=["swi-transport"],
    card_schemas={
        "swi-transport": {
            "type": "object",
            "required": ["from", "to"],
            "properties": {"from": {"type": "string"}, "to": {"type": "string"}},
        }
    },
)

await d.card("swi-transport", {"from": "Zurich"})   # CardPayloadInvalidError
```

The error names the card type, the failing JSON-Path and the failing keyword, so a
malformed payload is refused **where it is produced** rather than surfacing as a blank
space in someone's chat. `card_schemas` is optional — omit it and payloads are passed
through unchecked — but it is the difference between finding a bug in your own test run
and finding it in a user's conversation. This is why `jsonschema` is a declared dependency
and the only one that costs a real download.

### What `d.card()` does to your reply text

🔴 **Worth knowing before it surprises you.** An emitted card is **prepended to the reply
text as a hidden XML block**, so `Reply.text` — and `result.text` in the harness — is not
only the sentence you wrote:

```
<swi-transport style="display:none" version="1.0.0" data-format="json">
{"from": "Zurich", "to": "Bern"}
</swi-transport>

Your train leaves at 08:04.
```

The frontend parses that block out and renders the card; the `display:none` keeps it
invisible if anything ever renders the string raw. **For assertions, prefer
`result.cards` and `result.said_something_about(...)` over an equality check on
`result.text`** — an exact-match test will fail the moment the agent emits a card, and the
diff will be dominated by markup rather than by the thing you were testing.

### Who builds a card: you provide the data, Swisper builds the rendering

**This is a division of labour, and it is deliberate.**

| | who |
|---|---|
| Deciding a card is the right way to show this | you, with us |
| The card's **type name** and the **shape of its payload** | **you** — you know your domain |
| Producing that payload at runtime, per turn | **you**, via `d.card(type, payload)` |
| The **rendering pipeline** — the component that turns your payload into something a user sees, its layout, states, theming, responsive behaviour and accessibility | **the Swisper design team** |

You are not expected to build, style, or register a renderer, and there is no hook here
for you to do so. **Send us the card type you need and an example payload, and the design
team builds the rendering for it.** That is the whole process.

The practical consequence, so it does not surprise you mid-build: **a card type whose
rendering has not been built yet does not display.** It is not an error and nothing raises
— the payload travels correctly all the way to the frontend and simply has nothing to draw
it. So agree your card types with us **early**, at the point you are designing the agent,
rather than discovering it when you first see a real conversation.

`UndeclaredCardTypeError` cannot warn you about this, and it is worth knowing why:
`cards=[...]` is checked against **your own declaration**, so it catches a typo in your
code. Whether a renderer exists is a fact about the Swisper frontend, which the SDK never
sees. In the same way, `harness.turn()` reports `cards: ['your_type']` for any type you
declared — that tells you *your agent emitted it correctly*, which is the half you own, and
it is silent about the half we own.

Types that render today are all `swi-`-prefixed — `swi-transport`, `swi-transport-booking`,
`swi-calendar`, `swi-email`, `swi-news`, `swi-places`, `swi-product`, `swi-reply`, plus the
payment and confirmation variants. If one of those already fits your data, use it and no
design work is needed.

<sub>`meal_plan` in `examples/meal_planning_agent.py` is an illustration of the mechanism,
not a type with a renderer behind it. While a card of yours is waiting on design,
`d.say(...)` and your final answer text always reach the user.</sub>

## What is yours and what is ours

`swisper_agent_sdk` exports **99 names**, and this page describes about twenty of
them. That is not an omission — most of the rest are machinery the package needs
across its own module boundaries, importable because Python has no other way to share
them. **You are not missing a chapter.** But "is the thing I need in here and
undocumented, or genuinely absent?" is a fair question with no answer until now, so:

| module | what it is | yours? |
|---|---|---|
| `agent` | `Agent`, `AgentGraphState` | ✅ **yes — start here** |
| `delegation` | `Delegation` and the errors it raises (`CapabilityTimeout`, `CardPayloadInvalidError`, `CoreTooOldError`, tier and entitlement errors) | ✅ **yes** |
| `transport` | `http_generate_transport` — how your agent reaches a model through core | ✅ **yes** |
| `contract` | `AgentContract`, `load_agent`, and the declaration errors | ✅ **yes**, if you use `agent.contract.yaml` |
| `nodes` | `BaseNode`, `MCPToolGroup`, the `EventSink` protocol | ⚠️ **advanced** — only for a multi-node graph. A single `@agent.handler` never needs them |
| `state_types` | `UserInTheLoop`, `ToolOperation`, `ContextFile`, `CardMapping` | ⚠️ **read-only types** you may receive; you do not construct them |
| `result_contract` | `DataObject`, `AgentResultEnvelope`, the type registry | ⚠️ **advanced** — typed results |
| `card_builder` | `CardMapperRegistry`, `build_card_xml`, `attach_cards_to_text` … | 🚫 **ours.** Swisper's own card rendering pipeline. `d.card()` does not go through it — see above |
| `prompt_context`, `prompt_policy` | prompt-assembly helpers | 🚫 **ours.** `d.generate` already applies these for you; see `PROMPTING.md` |
| `tracing` | `TracingConfig`, `TraceContextMiddleware`, `InMemoryTraceStore` | ✅ **yes** — and `TraceContextMiddleware` is one you must actually add; see below |
| `domain_agent_interface`, `memory`, `state_keys`, `errors`, `time_utils` | internal plumbing | 🚫 **ours** |

**The rule, if you would rather have one than a table: anything this README names is
yours and will be kept working. Anything it does not name is ours, and may move.**
Reaching into a 🚫 module is not forbidden and nothing will stop you — but it is not a
supported surface, and we will not know we broke you.

If you need something you cannot find here, that is worth telling us: it is either
missing, or documented badly, and both are ours to fix.

## A fuller example

`swisper_agent_sdk/examples/meal_planning_agent.py` is the one to read once the
twelve-line agent makes sense. It is a working agent that recalls what it knows
about you, asks a question when it does not know enough, resumes on your answer,
and emits a card:

```console
$ ./.venv/bin/python -m swisper_agent_sdk.examples.meal_planning_agent
[turn 1] status=waiting_for_input text="I don't have any dietary restrictions or allergies on file for you — could you tell me what I should plan around?"
[turn 2] status=complete text='<meal_plan style="display:none" version="1.0.0" data-format="json">\n{"basis":"I\'m vegetarian and allergic to peanuts.", ...}\n</meal_plan>\n\nHere\'s a plan built around: I\'m vegetarian and allergic to peanuts..'
cards emitted: ['meal_plan']
plan basis: "I'm vegetarian and allergic to peanuts."
```

It also shows how to reach data you are entitled to. Declare what you need on the
agent and read it from the delegation:

```python
agent = Agent(..., inputs=("fact_lookup_service",))

# in the handler:
facts = await d.fact_lookup_service.get_by_type("ALLERGY")
```

**You only receive what you declare.** Anything you did not ask for is absent from
your delegation, not present-and-empty.

> 🔴 **`fact_lookup_service` works in-process and is withheld over the wire.** It is a
> live service object, so it cannot be serialised to a remotely deployed agent — core
> drops it from the envelope unconditionally. **The example above runs green under the
> harness and returns nothing once your agent is deployed remotely, with no error.**
>
> This is stated here because it is the one place where following our own documentation
> produces a silent failure. Facts are meant to reach a remote agent **pre-loaded onto
> the envelope**, declared in the agent's contract — **that path is seamed but not yet
> wired, so a remote agent currently receives no pre-loaded facts.** If you are building
> for remote deployment, do not design around `fact_lookup_service` yet; talk to us.

## Two ways to write an agent, one runtime

`@agent.handler` is not a simplified mode you outgrow. **A handler is compiled
into a one-node graph**, so it runs through exactly the same machinery as an
agent you author as a graph yourself:

```python
agent = Agent(name="…", description="…", graph=my_compiled_graph)
```

Start with a handler. Move to `graph=` when you need more than one node. You are
not choosing a path you have to undo later.

## Telling the model how to behave

> 🔴 **The planner loop is yours, by design.** This package ships nodes and
> conventions that drop into a graph **you** own. It does not ship a plan/act cycle,
> and that is a settled decision rather than a gap waiting to be filled — the
> topology, the edges and the iteration cap are things only you can size for your
> domain. So `instructions=`, `routing=`, `narration_style=`, `tool_guidance=`,
> `prompt=` and `tools=` are **declarations**: nothing feeds them to a model on your
> behalf.
>
> **`agent.compose_prompt()` is the method that turns them into a prompt.** You
> call it and you get the assembled system prompt back, with your `instructions`
> verbatim inside it.
>
> It takes no arguments, and **there is no `location` field in the prompt** — the
> prompt carries what your agent declares, and the turn's context comes from the
> turn's own inputs, which do not include a location.
>
> Two of the four bundled examples take different routes, and each is checkable by opening it:
> [`examples/bring_your_own_model_agent.py`](src/swisper_agent_sdk/examples/bring_your_own_model_agent.py)
> composes the prompt and passes it to a client of its own (a stub, so the file runs
> offline);
> [`examples/train_search_agent.py`](src/swisper_agent_sdk/examples/train_search_agent.py)
> calls `d.generate_structured(...)` inside a LangGraph cycle, with a prompt it writes
> itself.
>
> **Everything in this section describes what `compose_prompt()` returns.**

Most agents need one thing — `instructions=`:

```python
agent = Agent(
    name="weather_agent",
    description="Answers weather and forecast questions.",
    instructions="""
    You answer questions about weather and forecasts.
    Prefer official meteorological sources over aggregators.
    Never speculate beyond seven days — say you don't know instead.
    """,
)
```

**Your `instructions` reach the model verbatim.** We never rewrite them, and they
are not a template: braces inside them are literal text, so you cannot
accidentally interpolate one of our internal variables.

The SDK composes the rest of the planner prompt around them — the planning loop,
how to ask a question and resume, and how to narrate.

The turn's own context travels separately: `d.generate` builds the user's language,
locale, timezone and current time into a system message and prepends it to every
call. **That context is why this wrapper exists.** Omit the language instruction
and you answer a German user in English. Omit the locale rules and dates come out
American. Omit the temporal instruction and "tomorrow" is the wrong day. None of
these fail a test you would think to write, and all of them reach a user.

If you want to replace one composed section rather than all of it, pass
`narration_style=`, `tool_guidance=` or `routing=`. If you want full control,
pass `prompt=` — and `swisper_agent_sdk.prompts.scaffolding()` is importable, so
you can still assemble the parts you did not want to write yourself.

📖 **`PROMPTING.md`** has the full map of what gets composed, plus two rules that
are not general prompting advice — they are things we got wrong in production, in
ways that reached users. Read it before you write your second agent.

It ships inside the installed package (next to `examples/`), so it is on your
disk the moment `pip install swisper-agent-sdk` finishes. Find it with:

```console
$ ./.venv/bin/python -c "import swisper_agent_sdk, pathlib; print(pathlib.Path(swisper_agent_sdk.__file__).parent / 'PROMPTING.md')"
```

Working from a source checkout instead? It is at
[`src/swisper_agent_sdk/PROMPTING.md`](src/swisper_agent_sdk/PROMPTING.md).

## Using what Swisper already knows — `d.recall`

Your agent can ask for facts Swisper already holds about this user, so it does not
have to interrogate them for things they have said before:

```python
@agent.handler
async def handle(d: Delegation) -> Reply:
    facts = await d.recall("dietary restrictions")
    return Reply(text=f"I found {len(facts)} thing(s) I already knew.")
```

You declare the capability, and core decides what you actually receive:

```python
agent = Agent(name="...", description="...", capabilities=("recall",))
```

**Two outcomes that look similar and are not.** Getting this wrong is the most likely
way to ship a bug here, so the SDK keeps them apart rather than collapsing both into an
empty result:

| What happened | What you get |
|---|---|
| You did **not** declare `recall` | `NotEntitledError` — raised **locally, offline**, before any network call. Your agent never holds the delegation token, so the SDK mirrors the refusal core would give you anyway. |
| You declared it, and core found nothing you may see | **An empty list.** Not an error. |

> 🔴 **An empty list never means "you were refused."** It means *core looked and there
> is nothing here that this agent is allowed to see* — which is a normal answer, not a
> failure, and your agent should carry on rather than escalate.

**Offline**, pass your own transport and no network is involved:

```python
async def fake_recall(query: str) -> list:
    return [{"type": "dietary_restriction", "value": "vegetarian"}]

agent = Agent(..., capabilities=("recall",), recall_transport=fake_recall)
```

Calling `d.recall(...)` on an agent built without a `recall_transport` raises a
`RuntimeError` that says exactly that — rather than silently returning nothing, which
would be indistinguishable from "core knows nothing about this user."

## Calling a model

The platform holds the keys, picks the provider and meters the spend. You call one
of two methods and never touch any of that:

```python
from swisper_agent_sdk import Message

result = await d.generate([Message(role="user", content=briefing)], tier="planner")
print(result.text)
```

For an answer you can branch on, hand it a pydantic model instead and get a real
instance back:

```python
from pydantic import BaseModel

from swisper_agent_sdk import Message


class NextStep(BaseModel):
    tool: str | None = None
    answer: str | None = None


result = await d.generate_structured(
    [Message(role="user", content=briefing)], NextStep, tier="planner"
)
```

Both return a `GenerateResult`. `d.generate` fills `.text`; `d.generate_structured`
fills `.structured`. Three more fields are on **every** result, whichever you called:

- **`result.model`** — the model that actually answered. When a run behaves oddly
  this is your only handle on why, and you cannot get it any other way. Log it.
- **`result.usage`** — this call's `input_tokens` and `output_tokens`. It is how the
  platform attributes cost to your agent, so it is never absent.
- **`result.capability_version`** — which version of the capability contract the
  platform answered with. You will not normally read it; the SDK checks it for you
  and raises `CoreTooOldError` if the platform is older than this SDK build needs,
  with the pin that would work today in the message.

> 🔴 **`.structured` is a real instance of your model, or it is `None`.** When the
> response carried a structured payload you get a validated `NextStep` — never a
> bare dict to parse yourself. When it did not, you get `None`. **Handle both
> halves:** reaching straight for `result.structured.tool` is an `AttributeError`
> waiting for one bad round, and nothing type-checks it for you.

**You never name a language.** A system message carrying the user's interaction
language, locale, timezone and current time is composed and prepended to every
`generate` call, automatically, both methods, every time. Do not build one, and do
not translate anything by hand.

### The three tiers

`tier=` is required and the vocabulary is closed to three names. Anything else
raises `InvalidTierError` **locally, before the call leaves your process**, with the
three legal names in the message — a typo costs you no round trip and no waiting.

| tier | reach for it when |
|---|---|
| `planner` | The step reasons over the whole picture: deciding what to do next, weighing what a tool returned, writing the answer the user reads. The most capable, and the slowest. |
| `tool_selection` | The step is *which of these, with what arguments* — a bounded choice over a catalogue you supply. |
| `fast_classification` | The step is small and mechanical: routing an intent, picking between two obvious branches, tagging something. The cheapest and quickest. |

> 🔴 **It is `tool_selection`, never `tool_calling`.** The tier picks **a model that
> is good at deciding among tools**. It is not a mode and not a second API:
> `d.generate` is the same call at every tier, and naming this tier does not by
> itself put a single tool in front of the model. Deciding *which* tool to call is
> your loop's job — show the model a catalogue in the prompt and read its choice off
> a typed result, the way
> [`examples/train_search_agent.py`](src/swisper_agent_sdk/examples/train_search_agent.py)
> does. The old name read as "the tool-calling API", and a partner who goes looking
> for one loses an afternoon; the current name is the fix. If what you actually want
> is to hand the model tool *declarations*, that is a separate argument — see
> ["Offering tools to the model"](#offering-tools-to-the-model), next.

Picking the right tier is a judgement no error can make for you. See `PROMPTING.md`,
"Choosing a tier is a quality decision and nothing checks it" — it is the failure
you meet *after* you know the vocabulary.

### Offering tools to the model

🔴 **`tools=` means two different things in this SDK, and they are unrelated.** The
tier vocabulary makes this collision easy to walk into, so here are both:

| where | what it takes | what it does |
|---|---|---|
| `Agent(tools=(my_function, …))` | your Python callables | **Prompt composition only.** Their signatures and docstrings become the `## Your tools` block in the prompt `compose_prompt()` builds. Nothing calls them. |
| `d.generate(…, tools=[ToolDeclaration(…)])` | `ToolDeclaration` objects | **Goes on the wire.** The declarations are sent with the request and reach the model as tools it may call. |

A `ToolDeclaration` is a name, a description and a JSON-schema parameter dict:

```python
from swisper_agent_sdk import ToolDeclaration

result = await d.generate(
    messages,
    tier="tool_selection",
    tools=[
        ToolDeclaration(
            name="search_connections",
            description="Find train connections between two stations.",
            parameters={
                "type": "object",
                "properties": {"origin": {"type": "string"}, "destination": {"type": "string"}},
            },
        )
    ],
)
```

**No component in this package sends tool declarations for you yet**, so the bundled
example takes the other route: it shows the model a catalogue **in the prompt** and
reads the choice off a typed result
([`examples/train_search_agent.py`](src/swisper_agent_sdk/examples/train_search_agent.py)).
Both routes work; the prompt-catalogue one is the one we have run in anger, and it is
the one with a file you can copy.

### Deadlines

Every call is bounded. Leave it alone and you get **30 s**, sized for the slowest
tier; pass `deadline_ms=` to set your own:

```python
result = await d.generate(messages, tier="fast_classification", deadline_ms=5_000)
```

A call that does not come back in time is aborted and raises `CapabilityTimeout`,
naming the tier, the deadline and the time actually spent. It is never left to hang
— a slow provider on our side must not become an indefinite wait in your demo. If a
tier legitimately needs longer, a bigger `deadline_ms=` is the answer.

Tightening the budget on a cheap tier is worth the one line: 30 s of a user waiting
on a one-word classification is a defect no test will show you.

### Watching the text arrive

`d.generate()` hands you the finished answer. If you want to show it as it is written,
there are two ways, and they solve different problems.

**The pieces, as they arrive:**

```python
async for chunk in d.generate_stream(messages, tier="planner"):
    print(chunk.text, end="", flush=True)
    if chunk.done:
        print(f"\n[{chunk.usage.output_tokens} tokens]")
```

**The pieces *and* the finished answer**, which is usually what you actually want:

```python
result = await d.generate(messages, tier="planner", on_chunk=lambda c: show(c.text))
# result.text is the whole thing, already assembled
```

`chunk.model` rides every chunk; `chunk.usage` only arrives on the last one, because
that is the first moment it is known. Nothing fakes an early value for it.

**Streaming needs its own transport**, separate from the one-shot one:

```python
agent = Agent(..., stream_transport=my_stream_transport)
```

They are two seams because they are two shapes — one awaits a single response, the
other yields frames — and a single parameter accepting both would let a client that
cannot stream satisfy the type and fail at the first streamed call.

#### The deadline means something slightly different here

On a normal call, `deadline_ms` bounds the whole call. On a stream it bounds **time
without progress** — the wait for the first chunk, and each gap between chunks.

It is the same rule, not a second one: a normal call has exactly one delivery, so
"the whole call" and "the wait for the next piece" are the same number. Streaming is
where they come apart, and bounding the total would mean a long answer that is
arriving perfectly well gets killed for taking a while. What the deadline catches is
a provider that has **stopped**, which is the failure you actually need protection
from.

So a forty-five second answer that streams steadily is fine on the default. A stream
that goes quiet for thirty seconds raises `CapabilityTimeout`.

### What a `generate` call raises

🔴 **The one to expect first is `NoCapabilityConfigError`**, and it is the likeliest
thing to meet you on your first live call. The platform resolves a model per
`(your agent, tier)`; when there is no row for that pair and no platform default
either, the call is refused rather than quietly falling back to something nobody
chose. The message is composed by the platform, which knows your agent's identity,
and it names what an operator has to do about it. **A tier that works for one agent
can be unconfigured for yours** — nothing about it is a property of your code.

| raised | when | your move |
|---|---|---|
| `InvalidTierError` | `tier=` is not one of the three names. Raised **locally**, before the call leaves your process. | Fix the typo. |
| `NoCapabilityConfigError` | No model is configured for `(your agent, that tier)`, and no default. | Send us the message verbatim — it names the row that is missing. |
| `CapabilityTimeout` | The call outran its deadline and was aborted. | Raise `deadline_ms=`, or move the step to a cheaper tier. |
| `CoreTooOldError` | The platform is older than this SDK build requires. | The message names the pin that works today. |
| `ValueError` | You passed `stream=True` to `d.generate()`. That method returns one assembled result; its transport awaits a single response. Raised **locally**, before any call is made. | Use `d.generate_stream(...)` for an async iterator of chunks, or `d.generate(..., on_chunk=...)` to watch text arrive **and** still get the assembled result. |
| `RuntimeError` | No `generate_transport=` was given to the `Agent`. | See below. |

Catching only the first and third is the mistake worth naming: **`except
(InvalidTierError, CapabilityTimeout)` leaves the likeliest failure uncaught.** All of
these import from the package root, so you can `except` any of them by name.

### Where the call goes, and the one thing you wire

`d.generate` does all of the call but the last hop: it composes the request, prepends
the context block, checks the tier and enforces the deadline. (`d.generate_structured`
does one thing more — it derives the JSON schema from your model and validates the
response back into a real instance of it.) Carrying the request to the platform is a
**transport** handed to the agent:

```python
agent = Agent(name="…", description="…", generate_transport=transport)
```

**The SDK ships that client**, so you never write one against our wire shape:

```python
from swisper_agent_sdk import http_generate_transport

agent = Agent(
    name="…",
    description="…",
    generate_transport=http_generate_transport(
        "https://core.example.com",       # core's ROOT — not the endpoint path
        token=current_delegation_token,   # a str, or a callable returning one
    ),
)
```

`base_url` is core's **root**; the endpoint path belongs to the SDK, so appending it
yourself earns a 404 with nothing to explain it. `token` is the delegation credential
— pass a **callable** in anything long-running, because tokens are TTL-bounded and a
captured one stops working silently. "Connecting it to core", below, has the rest.

An `Agent` built with **no** transport raises a `RuntimeError` naming the missing
`generate_transport=` the first time a handler calls `d.generate`.

> 🔴 **Supplying your own `client=`? Set `timeout=None` on it.** `httpx.AsyncClient`
> defaults to a **5-second** timeout. This SDK's deadline is **30 s**. Leave that
> default in place and a legitimate thirty-second `planner` call dies at five with
> `httpx.ReadTimeout` — never becoming the `CapabilityTimeout` this SDK promises, and
> looking for all the world like our platform being flaky. **The transport the SDK
> builds for you already sets `timeout=None`; the trap is only reachable by passing
> your own client.** One deadline, and it is `d.generate(deadline_ms=...)`.

That same seam is what lets a real planner loop run offline: pass a function that
returns the response shape and your entire graph runs with no model, no key and no
network.
[`examples/train_search_agent.py`](src/swisper_agent_sdk/examples/train_search_agent.py)
does exactly that, and it is the file to copy.

## Your prompts, and where they live in the Swisper ecosystem

`instructions="..."` is the fastest way to a working agent and fine for a single-node one.
Past that, prompts belong in **files** — Jinja2 templates declared in your contract:

```yaml
# agent.contract.yaml
prompts:
  - name: train-search.planner
    file: prompts/planner.j2
```

Then give the agent a library to render them from. **This step is not optional** — a
contract declares prompts, it does not load them:

```python
import pathlib

from swisper_agent_sdk import Agent, PromptLibrary
from swisper_agent_sdk.contract import AgentContract

contract = AgentContract.from_yaml(pathlib.Path("agent.contract.yaml").read_text())
agent = Agent(
    contract.name, contract.description,
    prompt_library=PromptLibrary.from_contract(contract, "agent.contract.yaml"),
)
```

In a test, where you want to vary one prompt without touching a file:

```python
agent = Agent("train_search", "finds trains",
    prompt_library=PromptLibrary.from_mapping({"train-search.planner": "..."}))
```

Now render it:

```python
system = d.prompt("train-search.planner", destination="Zurich")
result = await d.generate([Message(role="system", content=system)], tier="planner")
```

A template branches on what the turn knows, and each branch says what to **do** about it:

```jinja
{% if destination %}
The traveller is going to {{ destination }}. Do not ask again — confirm it and move on.
{% else %}
No destination yet. Ask before searching; a search without one wastes the turn.
{% endif %}
```

`user_input`, `interaction_language`, `user_locale`, `user_timezone` and `current_time` are
available without being passed. A value you pass explicitly always wins.

**A typo fails the turn rather than rendering blank.** Jinja's own behaviour for an unknown
variable is the empty string, silently — one transposed letter would remove a section of
your prompt and nothing would say so. Instead:

```
PromptVariableError: train-search.planner: destinaton is not available.
  available: current_time, destination, interaction_language, travel_date, user_input
  did you mean: destination
```

### Checking every template before you ship

```
$ swisper-agent check --contract agent.contract.yaml
✗ travel_date — 'train-search.planner' reads it, this call site does not pass it
```

Offline, tokenless, non-zero exit — so it belongs in a pre-commit hook and in CI. **You
declare nothing:** the allowlist comes from your own `d.prompt("name", ...)` calls. It also
catches the reverse (a keyword no template reads) and templates nothing renders. The
render-time error covers the branches your tests walk; this covers the ones they do not.

A template that will not parse is reported like anything else — naming the file, the line,
and what Jinja was looking for — never as a stack trace:

```
✗ prompts/answer.j2:1 — Unexpected end of template. Jinja was looking for the
  following tags: 'elif' or 'else' or 'endif'.
```

`check` reads every `.py` beside your contract to find those call sites. If your layout has
directories that should not count — vendored code, a generated client, fixtures that are
wrong on purpose — name them:

```
$ swisper-agent check --contract agent.contract.yaml --exclude vendor --exclude fixtures
```

`--exclude` matches a whole path segment, so `--exclude vendor` leaves `vendored_logic.py`
alone.

### Letting the person see the stream

`on_chunk` gives *you* the fragments. To let the **user** see them arrive:

```python
result = await d.generate(messages, tier="planner", stream_to_user=True)
```

One fragment at a time onto the channel Swisper already republishes to the frontend, with
the last marked final. Without it, an agent can stream perfectly and the person still sees
one block of text at the end.

**Proving it, offline:**

```python
result = await harness.turn(agent, "trains to Zurich")
assert result.published                     # what the USER would have received
assert [e.content for e in result.published] == ["The ", "best ", "match…"]
assert result.published[-1].is_final
```

`result.published` is the only observable difference between an agent that streams to a
person and one that streams into a void. Every other assertion — the text, the status, the
timings — is green either way.

🔴 **In a real deployment you must give the agent a sink your server can drain.** The
default is an in-memory collector, which is right for tests and wrong for a server: a list
is only readable after the turn, so every fragment would arrive at once, at the end.

```python
from swisper_agent_sdk import Agent, StreamingEventSink

agent = Agent(...)                       # no sink here — see below

@app.post("/v1/execute")
async def execute(body: dict):
    sink = StreamingEventSink()          # 🔴 ONE PER REQUEST

    async def run_and_close():
        try:
            return await agent.run_turn(to_input(body), event_sink=sink)
        finally:
            await sink.close()           # 🔴 the TURN closes it, not the reader

    async def frames():
        turn = asyncio.create_task(run_and_close())
        async for event in sink:                     # arrives as produced
            yield f"data: {event.model_dump_json()}\n\n"
        result, _ = await turn
        yield f"event: result\ndata: {result.model_dump_json()}\n\n"

    return StreamingResponse(frames(), media_type="text/event-stream")
```

Two things there are load-bearing, and an earlier version of this snippet got both wrong:

🔴 **The turn closes the sink, in its own `finally` — not the reader.** Iteration ends on
the close sentinel and nothing else. A `finally` attached to the `async for` can only run
*after* the loop, so it can never be what ends it: the request hangs forever, with no error
and no log line. That snippet shipped, and a partner deploying a service hit exactly that.

🔴 **One sink per request, and pass it to the turn — never bind it to a module-level
agent.** A shared sink gives every concurrent request the same queue, and whichever
response generator reaches it first takes the fragment, *including fragments belonging to
someone else's conversation*. `Agent(event_sink=...)` still exists for a single-tenant
process; `run_turn(event_sink=...)` is what a server wants.

Add `X-Accel-Buffering: no` to the response headers if you sit behind nginx — otherwise it
buffers your stream into one block and nothing in your tests will notice.

### Seeing exactly what the model got

The prompt on the wire is not only what you wrote — Swisper composes the turn's language,
locale, timezone and clock onto every call. That half is invisible in your own source, and
it is usually the half you need when a model misbehaves:

```python
result = await harness.turn(agent, "trains to Zurich tomorrow")
print(result.prompt)          # every message, role-labelled, exactly as sent
```

### Why files, and not strings — the ecosystem answer

**Because in a real Swisper environment, prompts are not a code artifact. They are managed
content.**

Swisper keeps prompts and configuration in a **config service**, and **Studio** is the
surface people work through: it has a Template Editor that resolves a node's bound prompt,
prompts are **versioned**, and publishing a new version re-points the binding to it
server-side rather than asking every client to re-link. The same service holds the
configuration around them — which model tier a node runs at, and the overrides that select
it per agent and per node. You never set a model name in your code; you declare a tier, and
the platform resolves what that means for your agent in that environment.

**What that means for you, concretely, today:**

- Write your prompts as `.j2` files and declare them. `swisper-agent register` reads the
  files and sends their **content** to Swisper, which stores them. A prompt frozen in a
  Python string cannot make that journey.
- Locally, those same files are what render — no server, no credentials, nothing to run.
  What you debug against is byte-for-byte what registration uploads, because both read
  through the same code path.
- Once registered, a prompt is content that people other than you can see, version and
  edit — which is the point. The engineer is not the bottleneck on wording.

⚠️ **One honest limit at this version.** Your running agent renders the files shipped in
your package. Editing a prompt in Studio does not yet reach a deployed partner agent
without a redeploy — reading prompts back from the config service at runtime is the next
piece of work, and it is why declaring them as files now matters: it is what makes that
upgrade a configuration change for you rather than a rewrite.

## Tracing — on by default, one line to wire up

Your agent traces itself. Every node, every model call, every tool call, published the
same way an agent running inside Swisper publishes, so a turn of yours and a turn of ours
look the same to whoever is debugging at 2am. **There is no switch to turn it on**, and
that is deliberate: an agent nobody can see should not be something you can ship by
forgetting a flag.

There is one line you do have to write.

```python
from fastapi import FastAPI
from swisper_agent_sdk import TraceContextMiddleware

app = FastAPI()
app.add_middleware(TraceContextMiddleware)     # <- this one
```

### Why that line is not optional, and what happens without it

Swisper has already opened a trace before it calls you, and it tells you where your work
belongs in two request headers. Those headers arrive on the HTTP request and nowhere
else — the delegation body does not carry them.

**If those headers go unread, your agent still traces.** Completely. Correctly. Beautifully
nested — under a root of its own, in a trace nobody is looking at, absent from the one
that has the rest of the conversation in it. Nothing errors, nothing is empty, and no
test you write will notice. That failure is the reason this is middleware you install
rather than a function we ask you to remember to call.

It is plain ASGI, so it works under FastAPI, Starlette, Quart or a bare ASGI server, and
it pulls none of them in.

### Testing it, with nothing running

Configure nothing and your traces go into memory, where your tests can read them back.
No Redis, no credentials, no network, no Swisper.

```python
result = await harness.turn(agent, "book me a branch appointment")

plan = result.trace.node("plan")
assert plan.child("search_branches").type == "TOOL"    # nested, not a sibling
```

**Assert on shape, not on counts.** Tracing rarely breaks by stopping — it breaks by
arriving flat: every observation present, every parent wrong, and the picture still
renders. A count cannot see that. The second line above is the one that fails when it
happens, and it fails nowhere else.

### In production

Set `SWISPER_STUDIO_REDIS_URL` and `SWISPER_STUDIO_PROJECT_ID` in the environment and the
same code publishes for real. Connection strings belong in your secret manager, so they
are read from the environment rather than passed to a constructor — the argument exists
if you need it, but you usually will not.

Two things worth knowing:

- **Content is obfuscated before it leaves your process, always.** Prompt and result
  bodies are hashed. There is no setting for this and we do not offer one, because the
  underlying default is *off* and an environment that simply never set it would publish
  customer content in the clear — a mistake with no symptom.
- **A turn Swisper marks as private carries no trace header, and then nothing is
  published for that turn.** Your agent answers exactly as usual. Absence of a trace
  means *do not trace* — never *start one of your own*.

If you build your graph yourself with `Agent(graph=…)` rather than `@agent.handler`,
build it with `create_traced_graph` from `swisper_studio_sdk` instead of a bare
`StateGraph` — otherwise the nodes in it are not traced. We do not reach into a graph we
did not build.

## Shipping your agent

`agent.contract.yaml` is your `Agent(...)` declaration as a file — identity,
inputs, outputs, capabilities and cards — so core, or a teammate, can see what
your agent promises without reading your source. It round-trips both ways:

```python
from swisper_agent_sdk import AgentContract, load_agent

# emit it once your agent is built
AgentContract.from_agent(agent).write("agent.contract.yaml")

# read it back into a working agent — the file carries the declaration, never
# code, so you re-attach your graph/handler behaviour by hand
agent = load_agent(
    "agent.contract.yaml", name="my_agent", description="…", graph=my_graph
)
```

The declaration survives that trip intact — including `prompts:`, `nodes:` and `edges:`.
(It did not before `0.1.0a11`: `from_agent` returned those three empty, so
`from_agent(contract.build_agent())` silently discarded every managed prompt and your whole
graph topology. If you wrote a workaround for that, you can drop it.)

**No file at that path?** `load_agent(...)` builds an agent exactly as it would
without a contract at all — every `DomainAgentInput` field except
`fact_lookup_service` reachable, unfiltered (`default_inputs()`), no declared
outputs/capabilities/cards. Nothing regresses for an agent that has not adopted
the file yet.

### Registering with core — `swisper-agent register`

Installing this package also installs a `swisper-agent` command. Registration is how
core learns your agent's prompts and graph, so your agent appears as a real agent rather
than an opaque endpoint:

```console
$ export SWISPER_REGISTER_TOKEN="…"      # supplied by Swisper
$ swisper-agent register \
    --contract ./agent.contract.yaml \
    --core-url https://core.example.com
```

`--core-url` is core's **root**, not an endpoint path — the CLI owns the path. Prompt
files named in the contract resolve **relative to the contract file**, so the command
works from any directory.

> 🔴 **There is no `--token` flag, deliberately.** A token passed as an argument is
> visible to every other process on the machine, and lands in your shell history and
> your CI logs. It is read from `SWISPER_REGISTER_TOKEN` and nowhere else.

The exit code tells you who refused, which matters because the fix is different in each
case:

| Code | Meaning |
|---|---|
| `0` | registered |
| `1` | partially registered — some artefacts landed, some did not |
| `2` | usage error — a bad or missing argument |
| `3` | **refused locally** — your contract is wrong; core was never contacted |
| `4` | **refused by core** — the request was well-formed and core declined it |

A refusal prints the wire code plus what to do about it, rather than a stack trace.

> 🔴 **`fact_lookup_service` can never be declared in `agent.contract.yaml`.**
> It is a live service object, not data — core withholds it from the wire
> unconditionally, for every agent, with no per-agent override. Declaring it
> here raises `WireUnserialisableInputDeclaredError`, on both the write and the
> read path. See the `fact_lookup_service` note above: it works for an
> in-process agent (`examples/meal_planning_agent.py`), never for one declared
> to run remotely.

### Connecting it to core — `http_generate_transport`

Any agent that calls a model needs a **transport**: the client that speaks to core's
capability endpoint. The SDK ships one, so you never write an HTTP client against our
wire shape:

```python
from swisper_agent_sdk import Agent, http_generate_transport

agent = Agent(
    name="train_search_agent",
    description="Finds train connections.",
    graph=my_graph,
    generate_transport=http_generate_transport(
        "https://core.example.com",   # core's root — not the endpoint path
        token=current_delegation_token,   # a str, or a callable returning one
    ),
)
```

Then `await d.generate(messages, tier="planner")` reaches a real model.

**Pass a callable for `token` in anything long-running.** Delegation tokens are
TTL-bounded, so a token captured once at construction stops working the moment it
outlives its TTL — and every call after that is refused. A callable is re-read per call.

> **Why this ships here rather than being yours to write.** The deadline, the
> `capability_version` check and the error mapping all live *below* `d.generate`.
> A hand-rolled client would re-implement all three — and the version check is the one
> that tells you, in words, that your SDK is too old for the deployment you are
> pointed at. If you do supply your own `client=`, set `timeout=None` on it: the
> deadline belongs to `d.generate(deadline_ms=...)`, and a client timeout silently
> pre-empts it.

⚠ **Today you supply `token` yourself.** Core mints delegation tokens, but nothing yet
hands one to a deployed agent — there is no field on the delegation envelope carrying it.
Stated in the present tense because it is a real limitation now: this transport works and
is tested end to end, and the piece that feeds it a token at runtime is still being built.

## What this package deliberately does not give you

Said up front, because discovering it by hitting a wall is worse:

- **No `swisper.*` imports.** The SDK never reaches into the Swisper backend, and
  a build check enforces that on both `src/` and `examples/`. If something you
  need is missing here, that is our gap to close — tell us rather than working
  around it.
- **No database access.** Your agent never opens a database connection. Data you
  are entitled to arrives through a capability call that core authorises for that
  turn.
- **A deliberately small dependency list.** A test asserts that every import is
  declared and every declaration is imported — **a property, not a count** — so a new
  one cannot arrive as a side effect of a convenient import, undeclared. Read the list
  from the source rather than from this sentence, which has been wrong before:
  `python3 -c "import importlib.metadata as m;print(*[r for r in m.requires('swisper-agent-sdk') if 'extra ==' not in r],sep=chr(10))"`.
  It was **ten** on 2026-08-27: `pydantic`, `langgraph`, `jsonpath-ng`, `pyyaml`,
  `langchain-core`, `httpx`, `jsonschema`, `swisper-studio-sdk`, `redis` and `jinja2`.
  (It has said *seven* and then *nine* on the same day, each time correct when written.
  The last time it was wrong it omitted `jinja2` — the dependency the whole
  prompts-as-files chapter rests on. Run the command; do not trust this sentence.) (See the
  install-time note above: `langgraph` alone brings a much larger dependency
  tree with it — the count above is what *we* declare, not what actually
  installs.)
  `httpx` backs `http_generate_transport`, and it is imported **inside** that
  transport's constructor rather than at module scope — so `import swisper_agent_sdk`
  stays free of anything network-capable for an agent that only runs the offline
  harness or verifies a delegation grant offline.
- **The SDK does not configure logging.** Importing it leaves your root logger
  untouched. Call `swisper_agent_sdk.correlation.configure_logging()` yourself if
  you want ours.
