Metadata-Version: 2.5
Name: swisper-agent-sdk
Version: 0.1.0a33
Summary: The published contract a partner builds a Swisper domain agent against
Project-URL: Documentation, https://pypi.org/project/swisper-agent-sdk/
License-Expression: Apache-2.0
License-File: LICENSE
Keywords: a2a,agent,domain-agent,sdk,swisper
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
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: cryptography>=42
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
Requires-Dist: typing-extensions>=4.6
Provides-Extra: dev
Requires-Dist: fastapi>=0.110; 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.

> **Release `0.1.0a33`.** It succeeds `0.1.0a32`, the previous published release.
> Between releases this source page follows the development tree, so the immutable
> published package is not the SDK this page describes as later changes land. Pin the
> published version rather than assuming a source checkout and an installed release
> contain the same changes.
>
> Build against the documented surface — `Agent`, `Delegation`, `Reply`, `d.recall`,
> `d.generate`, `d.card`, `d.say`, `harness` and `AgentContract`. Anything not documented
> on this page may move without a deprecation cycle. Pin
> `swisper-agent-sdk==0.1.0a33` rather than a range while releases are alpha versions,
> 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.

### Published a19 and current source documentation

`0.1.0a19` was published from reviewed source
`9ced89117b9eeacd438fd7c7b42d6ac9532de250`, tagged `agent-sdk-v0.1.0a19`.
The wheel and sdist were downloaded and matched against their GitHub Release assets,
reviewed logical contents and cryptographically verified publication attestations.
This release includes complete registration metadata transmission and the repair that
preserves the published `a18` positional `Delegation` call sequence.

The README embedded in the immutable `a19` wheel/sdist and its released PyPI description
still contain build-time references to `a18` as the install pin and to `a19` as
unpublished. Those frozen references are superseded by the current installation advice
below; the released artifacts have not been rewritten or republished. This correction
updates active source documentation only.

SDK publication does not establish a live provider connection, provider mapping or device
action. Those require their own normal authorization and provider evidence.

### Dated release history — a18, 2026-09-13 UTC

`0.1.0a18` first published the following features, which were absent from `0.1.0a17`
and earlier releases:

- The prompt-readback path: `http_prompts_transport`, `HttpPromptsTransport`,
  `PromptsUnavailable`, `CAPABILITY_PROMPTS_PATH` and `PromptLibrary.from_core`.
- The delegation-token path: `current_delegation_token`, `delegation_token_from_body`
  and `use_delegation_token`.
- Additional tracing exports: `setup_tracing`, `adopt_trace_context`, `TraceTree`,
  `Observation` and `note_degradation`. `TraceContextMiddleware` was already published.
- Typed refusals: `CapabilityRefused`, `DelegationTokenRefused` and `ProviderCallFailed`.
- The `doctor` CLI command, and the `conformance` and `tool_envelope` modules.

The downloaded `swisper_agent_sdk-0.1.0a18-py3-none-any.whl` was inspected by AST:
**115 exports and 28 top-level modules**, matching the tree at tagged commit
`c1ba8c7af`. The earlier comparison against `a17` found 100 exports and 26 modules.
These are dated `a18` measurements, not counts for the current release.

Published `a18` and earlier artifacts remain immutable. The historical evidence above
is retained; the current installation pin is `a33`.

## Install

Requires **Python 3.11 or newer**.

```bash
python3 -m venv .venv
./.venv/bin/python -m pip install "swisper-agent-sdk==0.1.0a33"
```

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`, `jsonschema`, `swisper-studio-sdk`, `redis`, `jinja2` and
> `typing-extensions` — and `langgraph`
> alone brings its own dependency tree with it (`langsmith`, `orjson`, `ormsgpack`,
> `zstandard` and more). Measured on **2026-09-12** by installing `0.1.0a17` into a
> clean virtual environment on **Python 3.14.7**: **66 packages**, not eleven. (The same
> install measured **62** on 2026-08-28 — the package did not change; its upstream tree
> did.) None of that is a defect — every one of them is something a declared dependency
> genuinely needs — but a partner watching 66 packages scroll by after being told about
> eleven deserves to have been told first.
>
> 🔴 **This paragraph has now under-counted twice, and the second time it was the
> paragraph whose entire subject is the first time.** It said 41 while 62 installed —
> a cold reader measured it. And then 62 went stale on its own, with nobody touching
> this package: a third reader measured 66 on 2026-09-12. So treat every count on this
> page as **a dated observation, not a guarantee**: they move when *upstream* changes,
> with no edit here and no release of ours. Re-derive rather than cite:
>
> ```bash
> python -m venv /tmp/probe && /tmp/probe/bin/pip install swisper-agent-sdk
> /tmp/probe/bin/pip list --format=freeze | wc -l
> ```
>
> **One of those 66 is `langsmith`, and importing this package switches it OFF.**
> `langsmith` is LangChain's own tracing client. It is not optional and cannot be
> removed: `langgraph` requires `langchain-core`, which hard-requires `langsmith`, and
> `langchain-core` publishes no extras to opt out of. So it installs whether or not
> anyone wants it.
>
> It exports by reading an environment variable **at call time**, with no involvement
> from any code here — and those variable names say nothing about this package, so a
> shared CI block copied between repositories can arm it for an agent whose author never
> heard of it. What would leave the process is the contents of the turn.
>
> 🔴 **So `import swisper_agent_sdk` sets `LANGCHAIN_TRACING_V2`, `LANGCHAIN_TRACING`,
> `LANGSMITH_TRACING_V2` and `LANGSMITH_TRACING` to `false` in the host process, and sets
> langsmith's own global tracing switch to off.** This is the one thing importing this
> package changes outside itself, and it is documented here because it is a change to
> *your* process, not ours: **if your service legitimately uses LangSmith, this turns it
> off**, and you will want `swisper_agent_sdk.langsmith_off` in view before you debug why.
>
> Re-enable it after import if you mean to. Observability for agents built with this SDK
> goes to Studio instead — see the tracing section.
>
> 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.

### All your agents in one place: My agents

`swisper-agent ui` opens the developer page. Run it in a folder that holds no agent (for
example `~/Swisper Agents` itself) and the page opens on **My agents**: one card for each
agent in `~/Swisper Agents` (`--agents-dir` points it elsewhere). Each card shows who built
the agent and what Swisper says about it: *Not submitted from here*, *Waiting for an
operator*, *Going live*, *Live*, *Rejected*, *Blocked by Swisper* or *Suspended*. Open a card
to land on that agent's Start here. **New agent** creates the next one. The first time, the
page asks you to register once on this machine; after that, no agent asks again.

Run `swisper-agent ui` inside an agent's folder and the page opens straight on that agent's
Start here, as before. The header's **‹ My agents** and its switcher move between agents.

## 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-09-12 against `0.1.0a17` on Python 3.14.7, `[dev]` takes a clean
install from **66 packages to 76**, adding exactly ten: `pytest`, `pytest-asyncio`, `mypy`,
`mypy_extensions`, `ruff`, `pluggy`, `iniconfig`, `pathspec`, `ast_serialize` and `librt`.
(On 2026-08-28 the same two numbers were 62 and 72, and the added set included `Pygments`,
which no longer appears — the delta is stable at ten, its membership is not.)
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 (10 packages on top of the base install; 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.)

**If you call it anyway, know that it spells the type differently.** `d.card()` takes the
type **exactly as you declared it**, prefix included, and writes it into the tag verbatim:
`d.card("swi-transport", …)` → `<swi-transport …>`. `build_card_xml()` takes the **bare**
name and prepends `swi-` itself: `build_card_xml("transport", …)` → `<swi-transport …>`,
while `build_card_xml("swi-transport", …)` → `<swi-swi-transport …>`, which nothing
renders, with no mistake of your own. Two entry points, two spellings; the one this page
teaches is `d.card()`'s.

**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 **125 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; `http_prompts_transport` — how it reads its prompts from core (D-27) | ✅ **yes** |
| `contract` | `AgentContract`, `load_agent`, and the declaration errors | ✅ **yes**, if you use `agent.contract.yaml` |
| `prompt_library` | `PromptLibrary` — `from_contract` for local dev, **`from_core` in production** (reads what an operator edited, falls back to your bundled copy and says so) | ✅ **yes** |
| `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. ⚠ Takes the **bare** type (`transport`) and prepends `swi-` itself; `d.card()` takes the prefixed one |
| `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.
>
> One bundled example takes a different route, and you can check it by opening it:
> [`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. Whatever route you take, every model call goes through `d.generate` /
> `d.generate_structured` — see [Calling a model](#calling-a-model).
>
> **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."

## Declaring a third-party credential your agent needs

If your agent needs a per-user connection to a third-party service, declare the
credential **kind** it needs, as a plain string:

```python
agent = Agent(name="…", description="…", requires_credentials=("example-audio",))
```

**You declare only the key.** Never a credential, never a scope, never a user — where
and how a person logs in, and which hosts a token from that provider is allowed to
reach, are trust decisions that stay with the operator. This is the same shape as
`capabilities=` and `cards=`: your declaration only ever narrows what your agent is
given, never widens it.

If you author your agent through `agent.contract.yaml` instead, the same list is a
top-level `credentials:` section:

```yaml
# agent.contract.yaml
credentials:
  - example-audio
```

**The key must match a provider entry an operator has already added.** A provider
entry is core-owned, not something a partner registers by declaring it — you propose a
new one to us, an operator adds it, exactly as agent activation already works. Declare
a key with no matching entry and registration refuses your agent outright, with a `422`
naming what was wrong:

```json
{
  "code": "CREDENTIAL_KEY_UNKNOWN",
  "unknown": ["spotify"],
  "known": ["example-audio"],
  "message": "the contract declares a credential key core has no provider entry for; a provider entry is core-owned and operator-added -- a partner proposes it by declaring the key, an operator adds it"
}
```

`unknown` lists every key you declared that core has no entry for, and `known` lists
every key that already has one — so the fix is either a typo in your own contract, or a
new provider that needs to be proposed and added before you can declare it.

## Calling a model

A submitted agent makes every model call through `d.generate` or
`d.generate_structured`: Swisper's models, tiers and consumption policy. That is a
security decision, made by design — it keeps inference in Switzerland or Europe and
keeps your users' data from leaving. Calling a model directly from your agent, with a
client of its own, is not supported. A model of your own is for testing on your
machine only; your submitted agent never uses it and never holds a model key.
`swisper-agent check`, and the developer page's Checks list, fail an agent that depends
on or imports a direct model client — see
[A direct model client fails the check](#a-direct-model-client-fails-the-check).

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. |
| `CapabilityRefused` | **The base of every refusal the platform sends back**, and what you get for one this SDK build has no name for yet. `.error_code` is the stable code, `.params` the structured context, `str(err)` the platform's own sentence. Every class below it also **is** one. | `except CapabilityRefused` catches all of them; branch on `.error_code` when you need to. |
| `DelegationTokenRefused` | Your delegation token was refused: `TOKEN_EXPIRED`, `SCOPE_NOT_GRANTED`, `INVALID_SIGNATURE`, `AGENT_MISMATCH`, `TOKEN_KIND_MISMATCH`. | An expired token is fixed by re-minting. A scope you were never granted is not yours to fix — `.params` carries `requested_scope` and `granted_scopes`, which is the sentence that says which. |
| `NoCapabilityConfigError` | No model resolves for `(your agent, that tier)`: no row and no default, no provider linked, the linked provider unknown, or the provider not serving that tier (`.error_code` says which of the four). | Send us the message verbatim — it names the row that is missing. |
| `ProviderCallFailed` | The provider call behind the capability failed on our side: an outage, a quota or rate window, a vendor timeout. | Wait and retry; `.params["reset_at"]` names when if the provider said. Quote `.params["correlation_id"]` if it persists. |
| `CapabilityTimeout` | The call outran its deadline and was aborted — locally, or the platform said `DEADLINE_EXCEEDED`. | 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. |
| `CapabilityNotAnswered` | The address answered, but not as Swisper: a web page, a proxy, or a backend that does not serve the capability path. `str(err)` names the URL, the status and content type that came back, and the settings to check; `.url`, `.status` and `.content_type` carry the same. The prompts capability raises it too, and the bundled-prompts fallback records it as the reason. | Point `SWISPER_BASE_URL` at the Swisper environment you submit to, or ask its operator to route `/internal/v1/capability/` to Swisper. |
| `RuntimeError` | No `generate_transport=` was given to the `Agent` — or, served with `create_app`, neither `SWISPER_CORE_URL` nor `SWISPER_BASE_URL` is set. | See below. |

Catching only the first and the timeout 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. The
platform's refusals are one family: a `CapabilityRefused` you did not anticipate is
still a refusal with its code and params intact, never a `KeyError` from inside this
package — and `INFERENCE_MISCONFIGURED` (a provider credential or configuration
wrong on **our** side) arrives that way, with a message telling you not to retry.

> ⚠️ **Until 2026-09-13 this table was wrong**, and `0.1.0a17` — the last release before
> it was fixed — still behaves the old way: only `NO_CAPABILITY_CONFIG` was mapped, and
> every other refusal — an expired token, a scope you were never granted, a provider
> outage — came back as **`KeyError: 'model'` raised from inside this package**,
> indistinguishable from one another. If that is what you see, you are pinned to
> `0.1.0a17` or earlier. `0.1.0a18` is the first release that behaves as the table says.

### 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`. Served with
`create_app`, it is given one instead, at `SWISPER_CORE_URL`, else `SWISPER_BASE_URL`
(*Serving your agent*); with neither set, the `RuntimeError` names those two variables.

> 🔴 **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
```

`load_agent("agent.contract.yaml", ...)` reads those files into the agent's library for
you — the bundled copy is the **default**, never nothing. If you construct through
`contract.build_agent(...)` instead (it has no path to read from), hand it the library
yourself:

```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 = contract.build_agent(
    prompt_library=PromptLibrary.from_contract(contract, "agent.contract.yaml"),
)
```

### Reading the operator's edits — declare `prompts` as a capability

⚠️ **Available since `0.1.0a18` (2026-09-13 UTC).** `http_prompts_transport` is
included in the current pinned release, `swisper-agent-sdk==0.1.0a33`. It does not exist in `0.1.0a17` or
earlier; see the red note at the top of this page.

Once registered, your prompts are **managed in Studio**: an operator edits them there,
and the edit reaches your running agent through core (D-27). That read is a
**capability**, exactly like `recall` and `generate` — your contract declares it, the
operator's activation grants it, and the SDK reads only when it was declared:

```yaml
# agent.contract.yaml
capabilities:
  - recall
  - prompts        # ← without this line, core will never serve you a prompt
```

```python
from swisper_agent_sdk import current_delegation_token, http_prompts_transport, load_agent

agent = load_agent(
    "agent.contract.yaml",
    name="train_search", description="…",
    prompts_transport=http_prompts_transport(
        "https://core.example.com",       # core's ROOT — not the endpoint path
        token=current_delegation_token,   # the per-turn credential, see below
    ),
)
```

| Contract says | What `d.prompt(...)` renders |
|---|---|
| `prompts` **not** declared | Your bundled `.j2` files, always. Core is never asked, and nothing is announced — there was nothing to degrade from. |
| `prompts` declared, transport wired | **The operator's current text**, fetched through core on every turn. If core cannot serve it, the bundled copy — and the fallback is announced on the trace with its reason (`prompt_fetch_fell_back_to_bundled`), never silent. |
| `prompts` declared, **no** transport (local dev) | Your bundled files, silently — nothing in the process can reach core. |
| `prompts` declared **and** transport wired, but the contract ships **no `prompts:` block** | **Nothing — `d.prompt(...)` raises**, naming how to supply a library. With no declared files there is no bundled copy to render or to fall back to, and core is never asked: the absent library short-circuits the read before the transport is consulted. |

🔴 **Declaring the files is not declaring the capability.** A `prompts:` block tells core
which files you ship; `capabilities: [prompts]` tells core you may read the operator's
edits back. Until 2026-09-12 the second was undocumented, and an agent that declared
only the first rendered its packaged copy for ever while operators edited in Studio.

🔴 **And the converse — declaring the capability is not declaring the files.** That is the
last row above, and it is the noisier failure of the two: the capability and the transport
on their own give you an agent that raises the first time it renders anything.

🔴 **`contract.build_agent(...)`, not `Agent(contract.name, contract.description, ...)`.**
The second builds an agent that knows its name and nothing else — no inputs, no
capabilities, no cards, no entitlement key, no prompts. It works, so nothing complains,
right up until you run the shipping step below: `AgentContract.from_agent(agent)` then emits
a contract with **eight of twelve fields empty, over the top of the one you wrote**, and
`swisper-agent submit` sends that. `build_agent` carries the whole declaration through
and takes the same keyword arguments.

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`, `chat_id` and `current_time` are always available without being passed.
`interaction_language`, `user_locale`, `user_timezone` and `llm_reasoning_language` are
available **when the turn carries them**. A value you pass explicitly always wins.

🔴 **Treat those four as optional, because core does.** Core sends every one of these keys
on every turn and sends `null` for anything it has not resolved — a user with no locale on
file, a channel that carries no timezone. Your template will not see a null; it will see
nothing, and a bare `{{ user_locale }}` then fails that customer's turn while passing every
test you wrote.

```jinja
{{ user_locale | default('en') }}              {# good #}
{% if user_locale is defined %}…{% endif %}    {# good #}
{% if user_locale %}…{% endif %}               {# NOT a guard — raises on an absent name #}
```

That last line is the trap worth remembering: under `StrictUndefined` a bare truthiness
test raises exactly as a bare reference does, so the obvious way to "handle the missing
case" fails precisely when the case occurs. `swisper-agent check` recognises the two good
forms and will tell you about the third.

`current_time` is the exception, and it is a rule rather than a special case: **the SDK
defaults what it can know for itself, and never invents what belongs to the user.** Your
process owns a clock. It does not own the customer's locale, and guessing one puts silently
wrong formatting in front of a real person.

**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 top-level names in
the ones they do not.

**Read that boundary exactly, because it is narrower than it sounds.** `check` verifies
top-level variable names. It does **not** resolve attribute paths — `claim.status`,
`e.when` — because those are properties of a runtime object and cannot be known from
source. In a real template most references are attribute paths, so most of what your
template reads is *not* statically checked. `check` prints every path it could not verify;
read that list, and drive those branches in a test.

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.

A `.py` file it reads that Python can't read fails the check with two lines of its own.
The text after the line number is Python's own message:

```
✗ Python can't read broken.py:2: invalid syntax
      Fix the Python syntax error at broken.py:2, then run the checks again.
```

Code nested too deeply for Python's parser (a very long chain of `+`, say) gets two
lines too:

```
✗ Python can't read deep.py: too deeply nested to check
      Split the code in deep.py into smaller expressions, then run the checks again.
```

`check` reads a `.py` file the way Python does: UTF-8, with or without a byte-order mark,
or the encoding its `# -*- coding: … -*-` line declares. A file that is none of these
gets one line:

```
✗ Python can't read latin.py: not UTF-8 text
```

A coding line that names an encoding Python does not know gets Python's own words in
its place (`unknown encoding: …`).

Each counts as a problem, so `check` exits `2`. The file is left out and every other
file is still checked. The check for a direct model client (below) skips a file it can't
read, so it cannot see that file's imports: fix that file first, then run `check`
again.

#### A direct model client fails the check

`check` also fails an agent that calls a model directly rather than through `d.generate` /
`d.generate_structured` (see [Calling a model](#calling-a-model)). It fails when the
`requirements.txt` beside your contract names a direct model client, or when a `.py` file
in that folder or below imports one — `agent.py` and every local module, an import inside
a function included. The developer page's Checks list shows the same check, *Your agent
calls models only through Swisper*, in the same words:

```
$ swisper-agent check --contract agent.contract.yaml
✗ Your agent calls a model directly: requirements.txt:3 names openai; helpers.py:1 imports google.genai (google-genai). A submitted agent calls models only through d.generate / d.generate_structured: Swisper's models.
      Use d.generate / d.generate_structured instead. Your own model is for local testing only (Try it, or swisper-agent dev --answers model).
```

Every finding is listed, each with its file and line: `requirements.txt:<line> names
<package>` for a dependency, `<file>:<line> imports <name>` for an import, with the
package in brackets when the import name differs from it. It counts as a problem, so
`check` exits `2`. The clients it knows:

`openai`, `anthropic`, `google-generativeai`, `google-genai`, `mistralai`, `cohere`,
`groq`, `together`, `litellm`, `ollama`, `langchain-openai`, `langchain-anthropic`,
`langchain-google-genai`, `vertexai`.

A name in `requirements.txt` matches however it is spelled or pinned (`OpenAI==1.2`,
`langchain_openai[extra]>=0.1`), and on a line that points at a URL or a file it matches
the name that line names: `openai @ https://…`, a VCS or URL line carrying
`#egg=openai` (with `-e` or without), or a wheel by path or URL
(`langchain_openai-0.1.0-py3-none-any.whl`). An import matches in any of its forms
(`import vertexai.generative_models`, `from google import genai`). A comment or a string that mentions a client is not an
import. `--exclude` does not narrow this check.

**To fix it,** make the call with `d.generate` / `d.generate_structured` and remove the
client from `requirements.txt`. A model of your own stays on your machine, for local
testing only.

**The check is a guard, not the rule.** It reads `requirements.txt` (not
`pyproject.toml`, not a file that a `-r` line points to, and not a source archive named
only by its path or URL) and `import` statements (not `importlib` or other imports made
at run time), and it does not look inside environment, vendored or build folders
(`.venv`, `venv`, `node_modules`, `vendor`, `build`, `dist`, `__pycache__`, hidden
folders). The rule applies whatever the check sees: a submitted agent calls models only
through `d.generate` / `d.generate_structured`.

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

🔴 **Each fragment is published one behind, and that costs one provider interval.** The
last text chunk has to be marked `is_final`, and nothing can know a chunk is last until the
next frame arrives — so the publisher holds each fragment until it sees the following one.
Measured with a 1-fragment-per-second stub: the model produced `ONE` at 1.0 s and the user
saw it at 2.01 s. At a real model's 50–150 ms cadence that offset is invisible; for a
provider that batches by sentence it is not. The alternative — publish immediately, mark
nothing final — leaves every turn rendering as permanently still-typing, which is worse.

Note that `result.published` records content and order but not arrival time, so the
assertions in this chapter stay green either way. If time-to-first-token matters to you,
measure it on the wire.

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
answer = [e.content for e in result.published if e.process_indicator is None]
assert answer == ["The ", "best ", "match…"]   # d.say events also land in `published`
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.

### Serving your agent: `swisper_agent_sdk.serve`

Serve the agent with the SDK's own app. It does the streaming above for you, with a new
`StreamingEventSink` for every request (🔴 ONE PER REQUEST, closed by the turn, never
shared), and it does the part a hand-written handler forgets: **it refuses every call
Swisper did not sign, before your agent runs.**

```python
from swisper_agent_sdk import Agent
from swisper_agent_sdk.serve import create_app

agent = Agent(...)                 # no sink here: the app gives each request its own
app = create_app(agent)            # then: uvicorn my_agent:app
```

`pip install uvicorn` (or any ASGI server): the app is plain ASGI and pulls in no web
framework. It serves `POST /v1/execute` and `GET /health`, and it already carries the
tracing middleware, so do not add it again.

What it needs from its environment:

| Variable | What it is |
|---|---|
| `SWISPER_AGENT_PUBLIC_URL` | **Required.** Exactly the URL you submit with `submit --url`, without `/v1/execute`: the address Swisper calls you at, and signs. It must be `https://`; `http://` is accepted only for `localhost`, `127.0.0.1`, `::1` and in-cluster service hosts (`*.svc`, `*.svc.cluster.local`). It may carry a path, but no query, fragment or credentials; anything else makes `create_app` raise `ValueError`, so the app does not start. Left unset, `create_app` still builds the app, but every call is refused `NOT_CONFIGURED` (the `server.py` that `init` writes refuses to start instead). It is never rebuilt from the request's `Host`. |
| `SWISPER_BASE_URL` | Where Swisper's public keys are, and where your agent's model calls go (`d.generate` / `d.generate_structured`), for an agent you run: the environment's address, e.g. `https://dev.swisper.ai`. It must be `https://`; `http://` is accepted only for `localhost`, `127.0.0.1`, `::1` and in-cluster service hosts (`*.svc`, `*.svc.cluster.local`). It may carry no query, fragment or credentials. Those keys decide which calls are Swisper's, so anything else makes `create_app` raise `ValueError`, and the app does not start. |
| `SWISPER_CORE_URL` | Not needed for an agent you run yourself: leave it unset and set `SWISPER_BASE_URL`. When it is set, the keys are read from it and the model calls go to it, instead of `SWISPER_BASE_URL`, under the same rule. |
| `SWISPER_SUBMISSION_ID` | Optional. When set, `/health` echoes it as `submissionId`; unset or blank, `submissionId` is `null`. |

**Your agent's model calls need no wiring here.** An agent built without a
`generate_transport=` is given the SDK's `http_generate_transport` at `SWISPER_CORE_URL`,
else `SWISPER_BASE_URL`, with the token Swisper sends for each turn as its only
credential; a `generate_transport=` you built the agent with is kept. With neither
variable set, `create_app` logs it and every model call raises a `RuntimeError`: *"this
agent cannot call Swisper's models: neither $SWISPER_CORE_URL (set when Swisper hosts the
agent) nor $SWISPER_BASE_URL (set it when you host the agent, e.g.
https://dev.swisper.ai) is set"*. The host you give as `SWISPER_BASE_URL` must route
`/internal/v1/capability/*` (and `/internal/v1/providers/*` for provider agents) to the
Swisper backend; agents Swisper hosts reach it in-cluster.

Every `/v1/execute` must carry Swisper's RFC 9421 signature; a call without it, with an
unknown key, older than 300 s, not matching its body or URL, or seen before is answered
401 with one of `UNSIGNED`, `UNKNOWN_KEY`, `STALE`, `INVALID` or `REPLAYED`, and your
agent never runs. The module documentation (`help(swisper_agent_sdk.serve)`) has the
full rules.

⚠ **Writing your own `/v1/execute` instead** means verifying that signature yourself, on
every request. A handler that skips it answers anyone who can reach it.

### Packaged UI results

An agent can return up to eight typed display outputs in its final reply:

```python
return Reply(
    "Here is the overview.",
    ui_cards=[{
        "card_id": "overview",
        "schema_version": "1.0.0",
        "payload": {"label": "Current overview"},
        "fallback_text": "The interactive overview is unavailable.",
    }],
)
```

The card and exact schema version must exist in the reviewed package selected by
core. `swisper-agent card new` makes that package (*Adding a card*).
Payloads are finite JSON objects bounded to 256 KiB and nesting depth 32
(the root object is depth 0); fallback text is bounded to 2,048 characters.
These fields carry display data, not user, release,
resource or action authority. Core validates and captures them independently.

Use `result.to_wire_result()` only for the explicit final transport response.
Ordinary `model_dump()`, `model_dump_json()` and representations omit the typed
display channel. The SDK validates cards before the reply enters its traced
graph, so payload and fallback do not become automatic trace output. This does
not control logging independently implemented by an agent author. Existing
`d.card(...)` markup and narration keep their existing behavior.

### What tracing costs your turn

Publishing observations is **synchronous on the request path**, so an unreachable or wedged
Studio backend is latency your customer feels. It is bounded — `connection_timeout` defaults
to **1 second** and is applied as both the connect and the read bound — but bounded is not
free, and a turn publishes more than once.

Measured against a backend that accepts TCP and never answers, at the old 5-second default:
the narration line arrived at **+3,994 ms** instead of +0 ms, and the result 4 seconds late.
The answer itself was correct and complete throughout — a tracing failure never corrupts a
turn — but the *"still working on it"* line exists to cover a wait, and it was late to its
own job.

Raise `connection_timeout` if you would rather lose latency than lose traces. Do not raise
it far without deciding that deliberately.

### The two headers core sends you

Core passes the turn's trace context as `X-Trace-Id` and `X-Parent-Observation-Id`.
`TraceContextMiddleware` reads them for you and you never handle them by hand — but they are
what decides whether your agent emits observations at all, so it is worth knowing they
exist. **No header means no tracing**, and that is the mechanism behind bring-your-own-key:
core withholds them, and your agent emits nothing rather than inventing a trace of its own.

Two things in the snippet are load-bearing, and an earlier version of it 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 under `prompts/` and declare them. `swisper-agent
  submit` packs the files themselves and sends them to Swisper. 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 `submit` sends, because it ships the
  files, not a copy of them.
- 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.

✅ **That upgrade has landed — this paragraph used to say it had not.** Until 2026-09-12
it read *"editing a prompt in Studio does not yet reach a deployed partner agent without a
redeploy"*, which contradicted "Reading the operator's edits" two sections above on the
same page. The read path is built (D-27; the SDK side shipped in #2120): an agent that
declares `prompts` in `capabilities:` **and** wires `http_prompts_transport` renders the
operator's current text on every turn, no redeploy. That section is the authority on how;
this one only explains why files were the right unit — declaring them as files is exactly
what made the upgrade a configuration change for you rather than a rewrite.

**What still holds:** an agent that declares the files and *not* the capability renders its
packaged copy for ever, and so does one with no transport wired. The capability is the
switch, and it is not on by default.

## 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, unless you serve with `swisper_agent_sdk.serve`,
which already carries it.

```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 does not appear in Studio at all.** 🔴 Measured
2026-08-31, as a paired run: with the middleware absent the agent served the turn
correctly and **nothing whatever showed up** — with it installed, the same turn appeared,
nested under core's trace. Not a stray trace in the wrong place. Absent.

**This paragraph used to say the opposite** — that your agent "still traces, beautifully
nested, under a root of its own, in a trace nobody is looking at". That was wrong, and
wrong in the expensive direction: it sends you hunting for an orphan trace that does not
exist. If you are debugging a missing agent, **do not go looking for its trace elsewhere.
Check this line first.**

Either way nothing errors and no test you write will notice, which 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 in earlier alphas: `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.

### Submitting your agent — `swisper-agent submit`

Installing this package also installs a `swisper-agent` command. Getting an agent onto
an environment is four commands, and nobody at Swisper has to do anything by hand
until an operator reviews what you sent:

```console
$ swisper-agent init --purpose "Finds train connections and says when they leave."
                                           # once: a runnable project (see below)
$ swisper-agent keys init                  # once per machine: your publisher key
$ swisper-agent keys contact --name "Ada Lovelace" --email ada@example.com \
    --organisation "Analytical Engines AG" # once: who Swisper may contact
$ swisper-agent submit --env dev --url https://my-agent.example.com
```

Nothing you submit is usable until an operator approves it in Studio. `submit` builds a
package from your project, checks it, signs it with your key and sends it to the
environment's door; the submission then waits for approval.

To show your agent's results as a card, add one before you submit:
`swisper-agent card new` writes a first card and builds it, and `swisper-agent card build`
rebuilds it after every change (*Adding a card*, below). `submit` sends it with the agent.

To have Foundry build your agent from its GitHub repository instead, press the developer
page's **Build with Foundry** (*Building with Foundry*, below). The installer stores
Foundry's address for you; if you installed the SDK another way, set it once with
`swisper-agent foundry set <url>`.

`swisper-agent register` is **retired**. It needed a token nobody outside Swisper can
get; it now prints where to go instead and exits `2`.

#### `swisper-agent init`

Writes a runnable agent project to start from, ready for you to host (see *What `init` writes*
below). It never overwrites, and refuses (exit `3`, nothing written) when a path it would
write — or a directory on the way to it — already exists or is a symlink.

- `--purpose "TEXT"` — one line saying what the agent does (at most 512 characters, no
  line breaks or control characters). It becomes `identity.description` in `agent.yaml`
  and the role line — the first line — of the agent's first prompt, `prompts/plan.j2`
  (`You are <name>. TEXT`). Without it, both get a placeholder to replace. The prompt
  is declared in `agent.yaml` as `<name>.plan`, and `agent.py` renders it for its one
  model call. `check` refuses a declared prompt that no code renders, so remove the
  declaration if you remove that call.
- **git.** `init` then makes a git repository with a first commit of exactly the files
  it wrote — unless the directory is already inside a git repository (it says so and
  leaves that repository alone). The commit is made with your own git identity; when
  `git config user.email` is unset, the files are staged and `init` prints the
  `git commit` to run.
- **GitHub.** Then `init` creates a **private** GitHub repository named after the agent
  and pushes the first commit to it, with your own GitHub CLI (`gh`) and your own
  sign-in: Swisper never sees or holds a GitHub credential. Install `gh` from
  <https://cli.github.com> and sign in once with `gh auth login`. The repository belongs
  to the owner you chose last time, else your first organisation, else your own account;
  the owner a successful run used becomes the next default.
- `--github-owner <login>` — create the repository under this owner instead: your own
  login or one of your organisations. It must be a GitHub login (letters, digits and
  single `-`, not at either end, at most 39 characters); anything else is refused
  before anything is written (exit `2`) and never reaches `gh`.
- `--no-github` — make no GitHub repository.
- `--github` — accepted and changes nothing, since the repository is made by default;
  it stays so that scripts which pass it keep working.
- `--no-git` — make no git repository. With nothing to push, there is no GitHub
  repository either, and `init` says so in one line.

**A folder that already has its own git history.** `init` only ever pushes the commit it
made itself. If the folder is a git repository whose `origin` is on github.com, that is
the agent's repository: `init` says so, and creates and pushes nothing. If it has no
`origin`, an `origin` elsewhere, or the folder sits inside another repository, nothing
goes to GitHub and `init` says why; commit the agent's files where that history lives,
or create the agent in an empty folder.

**When GitHub says no, the project is still there.** If `gh` is not installed, you are
not signed in, the owner already has a repository with that name, or the organisation
does not let you create repositories, `init` says which, in plain words and with `gh`'s
own text where it gave one. Rate limits and other refusals show `gh`'s own words.
Either way the folder and its first commit are kept and `init` exits `0`. Fix what it
names, then run the line it prints, from inside the folder:

```console
$ gh repo create <your-org>/my-agent --private --source . --push
```

`init` fills in the owner and the agent's name; it shows `<your-org>` when it could not
yet tell the owner. When `init` could make no first commit (no `user.email`), make that
commit first.

**The agent `init` writes makes one model call.** `agent.py` renders
`prompts/plan.j2` with `d.prompt` and asks the model with `d.generate_structured`
(_Calling a model_), and its answer is the reply. It is the place to start changing:
the prompt says what the agent is for, and the `Answer` model is the shape of what
comes back. On the developer page, Try it asks you for that call's answer the first
time (saved to `answers.json`), or your own model answers it when "Your model" is set.
Once submitted, Swisper's models answer it.
`agent.yaml` declares `generate` under `capabilities:` for it: Swisper grants a model
call only to an agent that asks for it, so without it the call is refused once the agent
is live, and `swisper-agent check` fails an agent whose code calls a model without it.

**What `init` writes.** Besides `agent.yaml`, `agent.py`, the first prompt, a skeleton
`ui/manifest.json`, a `README.md`, a `.gitignore` and the two developer-page launchers,
the project is
ready for you to host as it stands:

- `server.py` — serves the agent with the SDK's own server, `serve.create_app` (*Serving
  your agent*), on port 8000: `GET /health`, and `POST /v1/execute`, which runs a turn
  only when Swisper signed the call. It binds the per-turn token Swisper sends (so the
  agent's capability calls work) and stops the turn when the caller disconnects. It
  refuses to start unless `SWISPER_AGENT_PUBLIC_URL` is an `https://` URL;
  `http://localhost` and `http://127.0.0.1` are accepted for local development only.
- `Dockerfile` and `.dockerignore` — an image that runs `server.py` with uvicorn on port
  8000, as user `10001`, with `GET /health` as its health check. `.dockerignore` keeps
  `.env`, key files (`*.pem`, `*.key`), `.git`, `tests/` and `answers.json` out of the
  image, so pass `SWISPER_AGENT_PUBLIC_URL` with `-e` when you run it. The image also
  runs with a read-only file system (`docker run --read-only --tmpfs /tmp …`), as a
  hardened host would run it.
- `requirements.txt` — pins `swisper-agent-sdk==<version>`, the version that wrote the
  project, and adds `uvicorn`. Until that version is on PyPI, put its wheel (the
  `swisper_agent_sdk-<version>-….whl` file you installed it from) in a `vendor/` folder
  in the project: the Dockerfile installs it from `vendor/` when the folder holds it,
  and from PyPI otherwise. `init` does not create `vendor/` for you.

Run it on your machine, from the project folder:

```console
$ SWISPER_AGENT_PUBLIC_URL=http://localhost:8000 uvicorn server:app --port 8000
```

or, with Docker:

```console
$ docker build -t my-agent .
$ docker run --rm -p 8000:8000 -e SWISPER_AGENT_PUBLIC_URL=http://localhost:8000 my-agent
```

Then `http://localhost:8000/health` answers `{"status": "ok", "submissionId": null}`.
That is all a local server shows you: it refuses every call to `/v1/execute` that
Swisper did not sign, and nothing on your machine signs one, so try the agent with
`swisper-agent dev` or the developer page. Where you run it for Swisper, pass the
address you submit instead — `-e SWISPER_AGENT_PUBLIC_URL=https://<where this agent is
reachable>` — and set `SWISPER_BASE_URL` too (*Serving your agent*).

#### Trying it on your machine — `swisper-agent dev`

```console
$ swisper-agent dev --agent agent:agent --answers answers.json
```

A local imitation of Swisper: type a message and press Enter to run a turn, and end
with Ctrl-D (or pipe the lines in). A line typed while the agent is waiting on a
question answers it. Only Swisper's side is simulated: every model and provider call is
answered from the `--answers` file (JSON) and shown as `simulated`, and a call the file
has no entry for fails the turn rather than being made up. Your agent's own network,
database and file I/O run as written. Each call is one line. Only a call that failed for
want of an entry adds `(answers: <file>)` after *"the turn fails"*, naming the answers
file it looked in (`(answers: no answers file)` when no `--answers` was given); a call
that was answered, or that failed for another reason, has no such suffix.

**`--answers model`.** Your model (the one Try it's *Your model* mode uses) answers the
model calls, shown as `not simulated (<model>)`. It is used only on this machine, by Try
it and `dev`, never by the submitted agent. Set it with `swisper-agent model set` (*Your
own model for local testing*, below). Provider calls are still answered from a
file: the `answers.json` in the folder of your agent's module, and only when that folder
holds `agent.yaml` or `agent.contract.yaml` (never the current folder's). That folder
is the project, and a provider call's line also shows what the developer page shows for
it: the provider's display name, when the contract's `providers:` entry gives one
(`display_name`), and whether the answer matches the operation's `outputSchema` in
`catalogue/operations.json`, when the catalogue gives one:

```console
  · simulated POST /internal/v1/providers/home-audio/operations/get_volume [<node>] — answered from the script (provider entry 1) — provider: Home Audio (home-audio) — schema: matches get_volume's outputSchema in catalogue/operations.json
```

An answer that breaks its schema ends `— schema: does not match …` instead; the answer
is still given to your agent, and the mismatch alone does not fail the turn: a run whose
turns complete still exits `0`. `[<node>]` is
shown only for a call made inside a graph node.

When the project's `answers.json` is not there, a provider call fails the turn and its
line says so:

```console
  · simulated POST /internal/v1/providers/<key>/operations/<op> [<node>] — no scripted answer — the turn fails (answers: <folder>/answers.json, which is not there)
```

and the run's first line ends *"provider calls answered from <folder>/answers.json,
which is not there."* When the folder holds no contract, it is not a project: the file
is not read, and the run's first line says why, once: *"provider calls answered from
<folder>/answers.json, not read: <folder> holds no agent.yaml or agent.contract.yaml, so
it is not a project."* Each provider call then fails the turn, and its line ends
`— no scripted answer — the turn fails (answers: none — not a project)`. A file
actually named `model` is passed as `./model`.

It exits `0` when every turn completed or is waiting on a question, and `3` when any
turn failed (nothing was sent). It exits `2` before any turn runs for a missing or
unusable answers file (not there, not JSON, or an entry it cannot read), whether given
with `--answers` or, in `--answers model`, the project's `answers.json`; and for bad
usage, such as an `--agent` that does not load. `--answers model` with no model set says
how to set one and exits `3`, as it does when your model settings cannot be read.

#### Your own model for local testing — `swisper-agent model`

```console
$ swisper-agent model set --preset gemini --model <model id> --key   # the key at a hidden prompt
$ swisper-agent model show                                           # what is stored, never the key
$ swisper-agent model test                                           # two tiny calls, to see it answer
```

These settings name the model that answers your agent's model calls while you try it on
this machine: in Try it on the developer page, whose *Your model* mode uses them (its
settings page, *Your own model*, edits the same settings), and in
`swisper-agent dev --answers model`. That is all they are for.

> 🔴 **Used only on this machine, only for testing.** The submitted agent never uses
> these settings: the SDK's server (`swisper_agent_sdk.serve`) never reads them, and the
> agent never holds a model key. In Swisper, every model call your agent makes goes
> through `d.generate` or `d.generate_structured` ([Calling a model](#calling-a-model)).

**Where they live.** In the SDK's config directory, the one that holds your publisher
key (*`swisper-agent keys`*), mode `0700`: `model.json` holds the style, preset,
address, model and per-tier models, never the key; `model-key` holds the key alone. Both
are mode `0600`. They are never in the project, never in `answers.json`, and never sent
to Swisper. `model show` prints the directory.

**`model set`** changes the stored settings. A flag not given keeps its stored value;
with nothing stored yet, it starts from the default preset, `gemini`.

- `--preset ID` fills the style and the address: `openai`, `gemini` (the default),
  `ollama`, `lmstudio` or `anthropic`; `none` for an address you type. `ollama` and
  `lmstudio` run on this computer and take no key. Presets name no models: ask the
  provider for its current ones with `model test --list`.
- `--style openai|anthropic`: `openai` for OpenAI-compatible Chat Completions,
  `anthropic` for the Messages API.
- `--address URL`: the provider's API address, `https://…`, or `http://` only for a
  model on this computer (`localhost`, `127.0.0.1` or `[::1]`). No user name or
  password, no query (`?`) and no fragment (`#`): the key goes in the key, never in the
  address.
- `--model ID`: the model that answers every tier. `--tier TIER=MODEL` gives one tier
  (`planner`, `tool_selection` or `fast_classification`) a model of its own, and
  `TIER=` hands it back to the main model. It can be repeated.
- `--key` asks for the key at a hidden prompt; `--key-stdin` reads it from stdin, for a
  script or a password manager; `--clear-key` deletes it. Use one of the three at most.
  The key is never taken from the command line: a value after `--key` is refused without
  being repeated, and nothing is saved.
- **The key belongs to the address it was typed for.** Changing the address needs a new
  key (`--key` or `--key-stdin`) or `--clear-key`; otherwise the save is refused on the
  key.

It prints `✓ model settings saved`, then the settings as `show` does. Each refused
setting is one `✗ <field>: <why>` line, followed by `Nothing was saved.`

**`model show`** prints the style, the preset, the address, the model, each tier's
model, `key stored: yes` or `no`, and where the settings are stored. The key is never
printed. With nothing stored, it says so, how to set them, and the presets.

**`model test`** calls your model with the stored settings and key: one short text
call, then one tiny structured call, the way Try it calls it. `--model ID` tests another
model without saving it. `--list` asks the provider for its models instead, which checks
the key and the address. It prints `✓ ok — …` when the model answered (or the provider
listed its models), else `✗ <kind> — <why>`, where the kind is one of:

- `key`: the provider refused the key (HTTP 401 or 403);
- `structured`: the model answered text, but not in the structured shape Try it asks
  for;
- `model_error`: the provider answered with an error, shown as it gave it;
- `unreachable`: nothing answered at the address, or it is not an API there;
- `rate_limited`: the provider is rate-limiting the key (HTTP 429);
- `timed_out`: no answer within 10 s.

**Not supported:** your model must speak OpenAI-compatible Chat Completions or
Anthropic's Messages API, with an API key. Providers that need their own cloud sign-in
(AWS Bedrock, Google Vertex, Azure OpenAI's own auth) are not supported.

`model` exits `0` when it showed or saved the settings, or the model answered; `2` for a
usage error, a setting `set` refuses, a key given on the command line, or no key at the prompt or on
stdin (nothing is saved); `3` when the settings cannot be read or written, or `test`
finds none stored; `4` when the provider refused (the key, the structured shape, or its
own error); and `5` when no answer came (unreachable, rate-limited or timed out).

#### Adding a card — `swisper-agent card new` and `swisper-agent card build`

A card is what Swisper shows in the chat for a result of your agent: one self-contained
HTML file that draws the data it is handed. Run both commands in the agent's folder (the
one with `agent.yaml`). They work offline, with no token and no network: nothing is
installed and nothing is fetched (no npm). The build is a zip of your own files.

```console
$ swisper-agent card new                   # a first card, named after the agent
$ swisper-agent card new --name my-card    # or with a name you choose
$ swisper-agent card build                 # rebuild after every change to a card
```

**What `card new` writes**, then prints, with the next steps:

- `ui/src/<card>/card.html` — the card. It loads nothing from anywhere (no
  `<script src>`, no `<link href>`), and Swisper refuses a card that tries. It talks to
  the page that hosts it in JSON-RPC messages (MCP Apps), and the handshake is already
  in it: the card asks `ui/initialize`, the host answers, the card says
  `ui/notifications/initialized`. The host sends the data only after this handshake,
  and closes a card that skips it after a few seconds. The card shows the data as text,
  never read as HTML, whatever it holds.
- `ui/src/<card>/fixtures/ready.json` — the example data the developer page previews
  the card with.
- `ui/manifest.json` — declares the card: its name, its address `ui://<agent>/<card>`,
  its file, the data it takes (`payloadSchema`, a `title` and a `text` to start with)
  and its example data. An existing manifest keeps its versions and its other cards as
  they are; a project without one gets one, with the contract's version.
- `ui/build.py` — when the project has none (see below).
- `ui/package.zip` — the built card package. Its digest goes into the manifest as
  `packageDigest`.

The flags:

- `--name <card>` — the card's name: a lowercase letter, then lowercase letters, digits,
  `-` and `_`, at most 64 characters. Without it, the card is named after the agent.
  `connect` (or `swi-connect`) is the name of Swisper's own card and cannot be yours: an
  agent named `connect` gets `connect-card` by default. When the agent's name cannot be
  a card name (it starts with a digit, for example), the default is `card`.
- `--contract <path>` — the agent's contract (default `./agent.yaml`). `card build`
  takes it too.

An agent has **at most 16 cards**; `card new` refuses a seventeenth.

**Refusals write nothing.** `card new` never overwrites. It lists what is in the way and
writes nothing at all when a file it would write is already there, the manifest already
has a card with that name or already holds 16, `ui/build.py` is a build of your own (add
the card there), `ui/package.zip` was built some other way, the manifest cannot be read
or names card files that are not under `ui/src/`, or something on the way is a symlink.
Give the card another name with `--name`, or move those files first.

**`card build` is the rebuild step.** It zips every file under `ui/src/` into
`ui/package.zip` and writes the package's digest into `ui/manifest.json`, changing
nothing else there. The package is reproducible: the same files always give the same
`packageDigest`. Hidden files (a name starting with `.`, such as `.DS_Store`) are left
out, and a symlink is refused. When `ui/src/` holds no `package.json` or lockfile, the
package gets a `package.json` and an empty lockfile, which say the cards have no npm
dependencies. It refuses, writing nothing, when there is no `ui/manifest.json`, or a
card in it names a file or example data that is not under `ui/src/`.

`python ui/build.py` does the same, as a convenience, with a Python that has the SDK.
With one that does not, it says to run `swisper-agent card build` instead.

**The loop.** The developer page's Card screen (`swisper-agent ui`) shows every card in
your package, in every state its example data gives, read from `ui/package.zip` — so a
change shows only after a build:

1. edit `card.html`, its example data and, when the data changes shape, its
   `payloadSchema` in `ui/manifest.json`;
2. run `swisper-agent card build`;
3. reload the developer page.

`card` exits `0` when it wrote or built the card, `2` when there is no `agent.yaml`
there — and `card new` also when it cannot be read, the agent's name cannot be part of
a card's address, or `--name` is not a card name or is Swisper's own — and `3` for any
refusal above. Sending the card from your agent is *Packaged UI results*; `submit` sends the
package with the agent.

#### Building with Foundry — `swisper-agent foundry`

Foundry can build and submit your agent for you, from its GitHub repository. The
developer page (`swisper-agent ui`) hands the agent over with one button, **Build with
Foundry**; `swisper-agent foundry` tells that button where Foundry is.

```console
$ swisper-agent foundry set https://foundry.example.com   # once: Foundry's address
$ swisper-agent foundry show                              # the address in use, and where it is from
$ swisper-agent foundry clear                             # delete the stored address
```

**The installer sets Foundry's address for you.** It stores `https://foundry.swisper.ai`
when no address is stored, and keeps one you set yourself, also when you run it again.
Change it with `swisper-agent foundry set <url>`, or override it with the environment
variable `SWISPER_FOUNDRY_URL`. Without an address, the button stays disabled.

- `set <url>` checks the address and stores it, without a trailing `/`, replacing any
  address stored before. It accepts `https://` with a host, or `http://` only for this
  computer (`localhost`, `127.0.0.1` or `::1`); no user name or password, no query (`?`),
  no fragment (`#`) and no spaces. The address is opened in your browser, so nothing that
  could hide a second destination or a secret is accepted. A refused address prints why,
  then `Nothing was written.` The address is stored as `foundry_url` in the SDK's config
  directory (the one that holds your publisher key, *`swisper-agent keys`*), mode `0600`,
  readable by you alone, and never in the project.
- `show` prints the address in use and where it comes from, as below. With neither a
  variable nor a stored address, it prints `foundry: not set` and how to set one.
- `clear` deletes the stored address. Clearing when nothing is stored is not an error.

**`SWISPER_FOUNDRY_URL` wins over the stored address**, under the same rule, and `show`
says which one is in use:

```console
$ swisper-agent foundry show
foundry: https://foundry.example.com
from:    config (<config directory>/foundry_url)
$ SWISPER_FOUNDRY_URL=https://foundry.example.com swisper-agent foundry show
foundry: https://foundry.example.com
from:    SWISPER_FOUNDRY_URL (env; wins over a stored address)
```

`set` and `clear` still change the stored address, and say so when the variable is set
and wins. An address that breaks the rule counts as none. That holds for the variable,
and for a stored file that others can read or that no longer holds an address. A variable
that breaks the rule is not passed over in favour of the stored address: fix it or unset
it. The page then treats Foundry's address as not set, so it never offers a dead or
unsafe link, and `show` says what is wrong.

`foundry` exits `0` when it stored, showed or cleared the address, including `show` with
nothing set and `clear` with nothing to clear. It exits `2` for an address `set` refuses,
or a missing argument. It exits `3` when `show` finds an address it cannot use, or when
the config directory cannot be read or written.

**The page's Build with Foundry button.** It opens a link to Foundry. Foundry is never
called, and nothing comes back. The link is built on this computer from three things
only:

- the folder's GitHub repository, `<owner>/<name>`, from its git `origin`;
- the Swisper installation the agent is submitted to. It is derived from the same
  address the page's Submit sends to (`--core-url` when given, else the `dev`
  environment's `https://dev.swisper.ai`), reduced to scheme, host and port, in lower
  case. A user name, password or token inside `--core-url` is never shown on the page
  and never put in the link, and `swisper-agent submit` and `status` never print one in
  the terminal either. If that address cannot be reduced this way, there is no
  installation to name, and pressing the button is refused (below);
- this SDK's version.

```text
https://foundry.example.com/handover/swisper-agent?v=1&repo=<owner>/<name>&installation=https%3A%2F%2Fdev.swisper.ai&sdk=<version>
```

The link carries no key, token, session or contact details. Foundry reads everything
else, such as the agent's name and purpose, from the repository. It signs and submits
with its own organisation key, not yours.

When you press the button, the page records which folder was handed over, and when (in
UTC). The record is `foundry-handovers.json` in the SDK's config directory (mode `0600`),
keyed by the folder's absolute path, and never goes in the repository. So the page still
knows after a reload or a restart, and pressing the button again updates the time.

**When the button is disabled**, the page shows why, and nothing is recorded:

- **No GitHub repository.** The folder's own git `origin` is none of
  `https://github.com/<owner>/<name>[.git]`, `ssh://git@github.com/<owner>/<name>[.git]`
  or `git@github.com:<owner>/<name>[.git]`, or there is no git repository or no
  `origin`. The host may be written in any case; `<owner>` must be a GitHub login
  (letters, digits and single `-`, not at either end, at most 39 characters). Only the folder's own repository counts: a
  folder inside someone else's checkout is not that checkout's repository. `init` creates
  the repository by default (*`swisper-agent init`*). A user name or token in an `https`
  origin is dropped before anything reads it, so it never reaches the link.
- **No Foundry address.** The page shows how to set one: run
  `swisper-agent foundry set <url>`, or set SWISPER_FOUNDRY_URL.

When both apply, the page names the missing repository. A folder with no agent in it
has nothing to hand over.

**When pressing the button is refused.** Two more problems are found only when the
button is pressed. The page's hand-over request then answers `409` with the code below
and a message saying why. No link is returned and nothing is recorded. Both are checked
after the two above, which win (`FOUNDRY_NO_REPO`, then `FOUNDRY_NOT_SET`):

- `FOUNDRY_BAD_CORE_URL`: **the Core URL is unusable.** The address Submit sends to is
  not an `http://` or `https://` address with a host and a valid port, so it cannot be
  reduced to scheme, host and port. The message never repeats the address. Start
  `swisper-agent ui` again with a correct `--core-url`.
- `FOUNDRY_RECORD_FAILED`: **the hand-over cannot be recorded.** `foundry-handovers.json`
  cannot be written in the SDK's config directory, for example when
  `SWISPER_AGENT_CONFIG_DIR` is a symbolic link.

#### `swisper-agent keys` — your publisher key

Your key **is** your identity as a publisher. Nobody at Swisper mints you a credential:
Swisper learns your public key from your first signed request, and an operator approves
what you submit. Every `submit` and `status` request is signed with it.

```console
$ swisper-agent keys init                  # create it (refuses if one exists)
$ swisper-agent keys show                  # its fingerprint, and the predecessor's
$ swisper-agent keys export --to ~/safe/swisper-publisher.pem   # back it up
$ swisper-agent keys rotate                # replace it; the old key vouches for the new
$ swisper-agent keys contact --name N --email E --organisation O   # your details
$ swisper-agent keys contact               # show them
```

The key lives in `$SWISPER_AGENT_CONFIG_DIR`, else `%APPDATA%\swisper-agent` on
Windows, else `$XDG_CONFIG_HOME/swisper-agent`, else `~/.config/swisper-agent` — as
`keys/publisher-ed25519.pem`, mode `0600` in a `0700` directory. It is never printed.
The fingerprint (lowercase hex SHA-256 of the raw public key) is what to compare when
someone asks "is that you?".

**Back it up with `keys export`.** Lose the key and you lose the identity, and with it
everything Swisper approved for it. The backup is written `0600` and never overwrites an
existing file.

**Rotating.** `keys rotate` makes a new key, has the old one sign it, and keeps the old
key under `keys/archive/`. Your next requests carry that vouch, so Swisper treats the new
key as the same publisher — including a block, which a rotation never escapes.

> 🔴 **Make one signed request (`submit` or `status`) with the new key before rotating
> again.** Only the immediate predecessor vouches. A key rotated twice before Swisper saw
> the middle one arrives unvouched, and starts unapproved.

**Your contact details.** Every submission carries your name, email and organisation,
so the operator reviewing it knows whom to ask. Set them once with `keys contact`; all
three are required. The email is checked for its shape only (`you@example.com`); name
and organisation are 1–256 characters on one line. A refused value names the field and
writes nothing (exit `2`). They are stored beside the key as `contact.json`, mode
`0600`, and a rotation keeps them.

#### `swisper-agent check --url` — before you submit an address

Once your agent runs somewhere public, ask whether Swisper will be able to call it there.
Run it from the project folder:

```console
$ swisper-agent check --url https://my-agent.example.com
```

The project is the current folder, whose contract `swisper-agent ui` would serve, or,
with `--contract path/to/agent.yaml` given too, that contract's folder; the `.env` there
is the one `public_url` below reads. `--contract` may also name the project folder
itself, which must then hold `agent.yaml` or `agent.contract.yaml`; a folder with
neither is refused before anything is called, exit `2`:
*"✗ <folder> is a folder with no agent.yaml or agent.contract.yaml in it; pass its
agent.yaml."* One of `--url` and `--contract` is required: with neither, `check` exits
`2` (*"one of the arguments --contract --url is required"*).

It asks the address three questions, in this order, and prints one line for each — `✓`
or `✗`, the check's name, and what it found in plain words:

- `https` — the address answers over TLS with a certificate this computer trusts: the
  public authorities, or `SSL_CERT_FILE` / `SSL_CERT_DIR` when set (and then it says so,
  because that bundle is on this computer, not where Swisper calls from). A failure says
  which: a certificate this computer does not trust (*"use a certificate from a public
  authority, such as Let's Encrypt"*, and, for an unknown issuer, that a network which
  replaces certificates on the way, such as a company proxy, causes this too), a
  connection that never completes a TLS handshake (*"Is https served on this port?"*), a
  name that does not resolve, or nothing answering.
- `health` — `GET <address>/health` answers `200` with JSON whose `status` is `"ok"`, as
  an agent served with the SDK does. A failure names a redirect (*"Swisper does not follow
  redirects: submit the address your agent answers at itself"*), any status other than
  `200`, an answer that is not JSON, or a `status` other than `"ok"`.
- `public_url` — an unsigned `POST <address>/v1/execute` is refused `401 UNSIGNED`, and
  this project's `SWISPER_AGENT_PUBLIC_URL` (the environment variable, else that key in
  the folder's `.env`) is the address. A failure names which: the agent answered
  `NOT_CONFIGURED` (it was started without `SWISPER_AGENT_PUBLIC_URL`, so it refuses
  every call Swisper makes: set it to the address and restart it), it answered anything
  other than `401 UNSIGNED`, the project sets no `SWISPER_AGENT_PUBLIC_URL`, the
  project's value is not usable, or it differs from the address.

When `https` fails, nothing else is called and the other two read *"not checked"*.
`health` and `public_url` ask different routes, so each runs whatever the other found.
A last line, marked `ℹ`, says what this computer cannot check: whether Swisper's network
reaches the address, whether your agent reaches Swisper back (it fetches Swisper's
signing keys from there), and whether the running agent's `SWISPER_AGENT_PUBLIC_URL` is
this project's value, beyond the fact that it has one.

**An address that is not `https` is a verdict, not an error.** Nothing is called, and
the answer looks like this (`http://localhost` included):

```console
$ swisper-agent check --url http://my-agent.example.com
✗ https      Swisper calls an agent only at an https:// address, and this one is http://. Nothing was called.
✗ health     The address is not https, so this was not checked.
✗ public_url The address is not https, so this was not checked.

  ℹ This computer cannot check whether Swisper's network reaches the address, nor whether your agent reaches Swisper back (it fetches Swisper's signing keys from there). Nor can it see whether the running agent's SWISPER_AGENT_PUBLIC_URL is this project's value: at most that the agent has one.
```

Every call goes one at a time to the address, never to Swisper, and takes at most 10 s
of wall-clock time from connecting to the last byte read. Redirects are not followed,
the calls are never signed, and they never carry a token or the address's
`user:password@`. It connects directly, never through a proxy set on this computer.

It exits `0` when all three checks pass and `3` when any fails, a non-`https` address
included. An address it cannot read as a URL with a host, or one that names port `0`,
exits `2` before anything is called, as do a `--contract` folder with no contract in it
and `--url` together with `--state` or `--exclude` (those belong to `check --contract`).

#### `swisper-agent submit`

```console
$ swisper-agent submit --env dev --url https://my-agent.example.com
```

**Coming soon: Swisper hosts your agent.** Today you run your agent yourself and submit
the address it answers at. Swisper running it for you, from a signed and scanned image,
is coming.

- **Where it runs.** `--url https://…` for an agent you run — and serve it with
  `SWISPER_AGENT_PUBLIC_URL` set to **exactly** that URL, the one that goes into
  `runtime.json` — with Docker, `docker run … -e
  SWISPER_AGENT_PUBLIC_URL=https://my-agent.example.com …`. The `server.py` that `init`
  writes refuses to start without it (or with anything but an `https` URL;
  `http://localhost` and `http://127.0.0.1` for local development only), and serves
  with the SDK's own `swisper_agent_sdk.serve`, which refuses every call Swisper did not
  sign for that URL (*Serving your agent*). Check the address with
  `swisper-agent check --url` before you submit it. `submit` warns when the variable is
  set here and differs.
- **Where it goes.** `--env dev` — the only environment today. `--core-url URL` points
  at a local stack instead.
- **The project.** `--contract` (default `./agent.yaml`, else the older
  `./agent.contract.yaml`); its directory is the project.
- **Tiers.** `--tiers basic,pro` names the tiers you ask for. The default is
  `basic,pro`. The operator may grant any subset of what you ask for, but granting more
  than you asked needs the Access console — so the default asks for both, and the
  operator narrows it.

What goes into the package, from the project directory:

| Entry | From |
|---|---|
| `agent.yaml` | your contract, byte for byte, whatever its file name |
| `prompts/…` | every file under `prompts/` (dot-files skipped; a symlink is refused). A prompt the contract declares outside `prompts/` is refused. |
| `catalogue/operations.json` | if present. Every provider it has operations for must be declared under the contract's `providers:` with `endpoint:` — the provider's MCP relay URL, https, on one of that provider's own `hosts`. `check` and `submit` refuse the package otherwise. |
| `ui/manifest.json` + `ui/package.zip` | only when `ui/package.zip` exists. A manifest without it submits the agent **card-less**, with a notice — and its versions must still match the contract's. `swisper-agent card new` writes both; `swisper-agent card build` rebuilds the package and its digest in the manifest (*Adding a card*). |
| `runtime.json`, `intended_tiers.json` | the flags above |
| `submitter.json` | your contact details from `keys contact`: `{"name", "email", "organisation"}` |
| `evidence/check.json`, `evidence/doctor.json` | `check` run on your contract; `doctor`'s verdict and exit code when you pass `--agent module:attribute`. A doctor that cannot load the agent refuses locally, and its error stays on your terminal. |

A symlink anywhere `submit` reads from — the contract, `prompts/`, `catalogue/`, `ui/`,
or any directory on the way to them — is refused before anything is read or sent
(`ENTRY_SYMLINK`), so a cloned repository cannot make `submit` upload a file from
elsewhere on your machine.

Without contact details `submit` refuses before building anything (exit `3`) and says
to run `swisper-agent keys contact --name … --email … --organisation …`. Swisper keeps
the details a version was submitted with: if yours changed since you submitted this
version, `submit` refuses before sending and says so — they travel with your next
version, so bump `identity.version`.

Before anything is sent, `submit` runs `check` (a failing check refuses), builds the
package and validates it with the same validator the door runs — so a package the door
would refuse never leaves your machine. The package is deterministic: submitting an
unchanged project again sends identical bytes, and Swisper answers with the **same**
submission rather than a new one. Anything that changes a byte makes it a second
submission of the same version, which Swisper refuses (`VERSION_EXISTS`) while the first
one waits.

Validate a package **only** with `swisper_agent_sdk.package.validate_package` — the
function `submit` and the door both call. It enforces size, depth, count and
pattern-length limits that the shipped `submission_schema.json` alone does not, so
validating a package view directly against the schema is unsupported.

A connection failure is retried up to three times (after 1 s, then 2 s). A `503` means
Swisper is busy (for example `CHECKS_UNAVAILABLE`, when its checks are all running) and
resending the same package is safe, so it is retried up to three times over about 20 s
(after 5 s, then 15 s, or after the `Retry-After` Swisper sends, if 30 s or less), and
`submit` says it is waiting. A `429` is retried once, only when Swisper says to wait 30 s
or less. Every attempt is signed afresh. Nothing else is retried.

On success it prints the submission id, its state and the next command:

```console
✓ submitted my_agent 1.0.0 to https://dev.swisper.ai
  id:      3f2c9a4e-8d1b-4c7e-9a2f-5b6d7e8f9a01
  state:   waiting for approval

Next: swisper-agent status 3f2c9a4e-8d1b-4c7e-9a2f-5b6d7e8f9a01
```

#### `swisper-agent status`

```console
$ swisper-agent status <id>
$ swisper-agent status --agent my_agent    # every submission of it that your key sent
```

Prints the state (`received`, `checks failed`, `waiting for approval`, `approving`,
`approval stopped at step N`, `live`, `rejected`, `suspended`, `superseded`), the step,
the reasons, the operator's note or rejection reason **exactly as written**, where the
agent runs, and when it last changed. Only the key that submitted can read a
submission. `--env` defaults to `dev`.

#### Exit codes

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

| Code | Meaning | What to do |
|---|---|---|
| `0` | accepted (`submit`) / read (`status`) / every check passed (`check`, `check --url`) / the card written or built (`card`) / the address stored, shown or cleared (`foundry`) / every turn completed or waiting on a question (`dev`) / the model settings shown or saved, or the model answered (`model`) | continue |
| `2` | usage error — a bad or missing argument, such as an address `check --url` cannot read as a URL, a `--name` that cannot be a card's, an address `foundry set` refuses, a `--contract` folder with no contract in it for `check --url`, a missing or unusable answers file for `dev` (its `--answers`, or in `--answers model` the project's `answers.json`), or a setting `model set` refuses or a key given on its command line (and the retired `register`). For `check` (without `--url`), `2` also means a check failed: a usage error and a failed check exit alike, so a script treats both as a stop | fix the command, or what the check names |
| `3` | **refused locally** — nothing was sent (a failing check, an invalid package, no key, no contact details, or changed details for a version already submitted; for `check --url`, a check that failed; for `card`, a refusal, which writes nothing; for `foundry`, an address it cannot use, or a config directory it cannot read or write; for `dev`, a turn that failed, or `--answers model` with no model set or with model settings it cannot read; for `model`, settings it cannot read or write, or `model test` with none stored) | fix what it names |
| `4` | **refused by Swisper** — the request was sent and declined; the code is printed verbatim (e.g. `PUBLISHER_BLOCKED`, `VERSION_EXISTS`, `RATE_LIMITED`). For `model test`, your model's provider refused: the key, the structured shape, or its own error | read the reason; retrying changes nothing (for `model test`, fix the key or setting it names) |
| `5` | Swisper **unreachable, or its answer could not be trusted** — no answer, a `5xx` after the retries, or a `2xx` whose body does not prove the outcome (unreadable, or missing the submission's `id`/`state`). For `model test`, no answer from your model's provider: unreachable, rate-limited or timed out | for `model test`, check the address or try again later; otherwise run `swisper-agent status --agent <name>` first: if it is listed, it arrived. If not, submit again later |

🔴 **`4` and `5` are deliberately different.** A refusal is a decision and an outage is
weather; a pipeline that treats them alike either retries refusals forever or fails a
deploy over a blip.

> 🔴 **`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. Served with `create_app`, an agent built without a transport is given it,
with the per-turn token bound for you (*Serving your agent*); what follows is for wiring
it yourself:

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

✅ **Core sends you the token, on every turn.** It arrives in the `/v1/execute` body as
`delegation_token`. Lift it before `to_input` (which drops unknown keys by design), bind it
for the turn, and pass `current_delegation_token` as the `token=` argument:

```python
from swisper_agent_sdk import delegation_token_from_body, use_delegation_token

with use_delegation_token(delegation_token_from_body(body)):
    result, _ = await agent.run_turn(to_input(body))
```

⚠ This paragraph used to say the opposite — that nothing handed a deployed agent a token.
It was wrong: the SDK was discarding a credential core had always sent (D-27 Am.4).

**The same token reads your prompts.** `http_prompts_transport(base_url, token=...)` is
the sibling of `http_generate_transport`; pass it as `prompts_transport=` and declare
`prompts` in `capabilities:` — see "Reading the operator's edits" above. One base URL, one
credential, two verbs.

## 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 **twelve** on 2026-09-30: `pydantic`, `langgraph`, `jsonpath-ng`, `pyyaml`,
  `langchain-core`, `httpx`, `jsonschema`, `swisper-studio-sdk`, `redis`, `jinja2`,
  `typing-extensions` and `cryptography` (Ed25519 publisher keys; there is none in the
  standard library).
  (It has said *seven*, then *nine*, then *ten*, then *eleven*, 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.
