This release is a pre-release and may not be stable for production use.
swisper-agent-sdk
Build a Swisper domain agent.
This package is self-contained. You do not need the Swisper backend, a database, or network access to build an agent and test it.
Where this is in its life: an alpha, and
a5means what it says. This tree declares0.1.0a19, which is not published yet; the newest version on PyPI — the onepip installgives you and the one to pin — is0.1.0a18. They are not the same package, and the red note below says exactly how they differ. Build against the surface described on this page —Agent,Delegation,Reply,d.recall,d.generate,d.card,d.say,harness,AgentContract— to keep working; it is what our own bundled agents use, so it breaks us before it breaks you. Anything not documented on this page may move without a deprecation cycle, and there are about 75 such exports (see "What is yours and what is ours" below, which says which is which).Pin an exact version (
swisper-agent-sdk==0.1.0a18) rather than a range while we are on0.1.0aN, and tell us you have pinned it — we will tell you before a change that would break you rather than after. There is no deprecation policy yet because there is not yet a partner whose upgrade we would be managing; when there is, that is the moment it gets written, and you will be told rather than left to read it here.
🔴 The PUBLISHED
0.1.0a18is not the SDK this page describes — but since 2026-09-13 the difference is a version number and whatever has merged since, not a missing feature.0.1.0a18was published on 2026-09-13 and it is the first release that carries the surface this page documents. Published from0.1.0a18onward, and absent from0.1.0a17and everything before it:
- The whole prompt-readback path —
http_prompts_transport,HttpPromptsTransport,PromptsUnavailable,CAPABILITY_PROMPTS_PATHandPromptLibrary.from_core. Everything in "Reading the operator's edits" below rests on these, and they now import from the installed wheel.- The delegation-token path —
current_delegation_token,delegation_token_from_body,use_delegation_token. "Calling a model" passestoken=current_delegation_token.- The rest of the tracing surface —
setup_tracing,adopt_trace_context,TraceTree,Observation,note_degradation.TraceContextMiddlewarewas published before them.- The typed refusals —
CapabilityRefused,DelegationTokenRefused,ProviderCallFailed. On0.1.0a17every refusal but one arrives asKeyError: 'model'; see "What ageneratecall raises".- The
doctorCLI subcommand, and theconformanceandtool_envelopemodules.Measured against the artefact, not the tree. The published wheel
swisper_agent_sdk-0.1.0a18-py3-none-any.whlwas downloaded from PyPI and its__all__parsed by AST: 115 exports and 28 modules in the wheel, 115 and 28 in the tree at the tagged commitc1ba8c7af— an identical surface. The same check against0.1.0a17read 115 against 100 and 28 against 26, which is the gap this note used to describe.
0.1.0a17is immutable on PyPI and was never overwritten. It could not be: a published release cannot be corrected in place, so the fix had to be a new number rather than a re-upload. Anyone still pinned to0.1.0a17has exactly the wheel they installed, without the surface above. Moving the pin is what gets it.Why this note has not gone away. The tree declares
0.1.0a19and nothing declaringa19is published, so anything merged since theagent-sdk-v0.1.0a18tag is documented here and not yet installable. That gap is permanent by design — the version gate requires the declared version to stay ahead of the newest published one, so the two coincide only in the moment between a publish and the next change. Check a symbol before you build on it:./.venv/bin/python -c "import swisper_agent_sdk as s; s.http_prompts_transport"
Install
Requires Python 3.11 or newer.
python3 -m venv .venv
./.venv/bin/pip install swisper-agent-sdk
Use the virtual environment's interpreter for everything below. Typing python3
rather than ./.venv/bin/python is the most common way to get a
ModuleNotFoundError that looks like a broken package and is not.
A handful of declared dependencies pull in far more packages. This package declares
pydantic,langgraph,jsonpath-ng,pyyaml,langchain-core,httpx,jsonschema,swisper-studio-sdk,redis,jinja2andtyping-extensions— andlanggraphalone brings its own dependency tree with it (langsmith,orjson,ormsgpack,zstandardand more). Measured on 2026-09-12 by installing0.1.0a17into 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:
python -m venv /tmp/probe && /tmp/probe/bin/pip install swisper-agent-sdk /tmp/probe/bin/pip list --format=freeze | wc -lOne of those 66 is
langsmith, and importing this package switches it OFF.langsmithis LangChain's own tracing client. It is not optional and cannot be removed:langgraphrequireslangchain-core, which hard-requireslangsmith, andlangchain-corepublishes 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_sdksetsLANGCHAIN_TRACING_V2,LANGCHAIN_TRACING,LANGSMITH_TRACING_V2andLANGSMITH_TRACINGtofalsein 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 wantswisper_agent_sdk.langsmith_offin 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:
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.tomloff the disk — which raisesFileNotFoundErrorafterpip 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:
httpxcosts 0 packages —langchain-core,langgraph-sdkandlangsmithalready require it — whilejsonschemacosts 5, since it bringsattrs,referencing,rpds-pyandjsonschema-specificationswith it.jsonschemaearns 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.httpxis 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:
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:
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:
$ ./.venv/bin/python -m swisper_agent_sdk.examples.twelve_line_agent
You asked me to: book a train to Bern
d.task is what the user asked for. Returning a Reply answers them;
returning Reply.ask(...) puts a question to them and pauses the turn.
What always arrives, and what you ask for
Eight fields reach your agent on every delegation, whatever it declares. They
are the turn's context — who is asking, which conversation this is, and how to
speak back. You read them from d.raw:
| field | what it is for |
|---|---|
chat_id |
The conversation this turn belongs to. Key anything you keep between turns on it. |
user_id |
Who the turn is for. Use it to scope anything you store yourself. |
user_input |
What the user actually typed, verbatim. d.task is the supervisor's instruction derived from it — when you need their exact words rather than the instruction, this is the field. |
user_locale |
Regional formatting, e.g. de-CH — dates, numbers, currency. |
user_timezone |
e.g. Europe/Zurich. Anything you resolve against "today" or "tomorrow" needs it. |
current_time |
The turn's own clock, ISO-8601, from the user's device. Use this rather than your process's clock: your process may be in a different timezone, or on a different day. |
interaction_language |
ISO 639-1, for user-facing text. Detected upstream and recomputed on every turn and every answer to a question. When it is set, write your reply in that language rather than detecting it yourself. |
llm_reasoning_language |
The language the model reasons in, which is not necessarily the one it answers in. |
Everything else is declared. A field outside these eight reaches d.raw only
if your agent asks for it by name:
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:
@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:
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:
$ 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
pytestfrom inside this directory. The package's ownasyncio_mode = "auto"setting lives in itspyproject.toml, and pytest only picks it up when invoked from here. Run it from the parent directory and everyasync deftest 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:
# pytest.ini, next to your tests
[pytest]
asyncio_mode = auto
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.
turn() returns a TurnResult with .text, .status, .resume_with, .cards,
.said, and two helpers for assertions — .said_something_about(...) and
.asked_for(...):
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.generatereaches whatevergenerate_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:
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:
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:
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.
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.
What is yours and what is ours
swisper_agent_sdk exports 120 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:
$ ./.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:
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_serviceworks 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_serviceyet; 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:
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=andtools=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 yourinstructionsverbatim inside it.It takes no arguments, and there is no
locationfield in the prompt — the prompt carries what your agent declares, and the turn's context comes from the turn's own inputs, which do not include a location.Two of the four bundled examples take different routes, and each is checkable by opening it:
examples/bring_your_own_model_agent.pycomposes the prompt and passes it to a client of its own (a stub, so the file runs offline);examples/train_search_agent.pycallsd.generate_structured(...)inside a LangGraph cycle, with a prompt it writes itself.Everything in this section describes what
compose_prompt()returns.
Most agents need one thing — instructions=:
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:
$ ./.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.
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:
@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:
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:
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 — Sonos today — declare the credential kind it needs, as a plain string:
agent = Agent(name="…", description="…", requires_credentials=("sonos",))
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:
# agent.contract.yaml
credentials:
- sonos
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:
{
"code": "CREDENTIAL_KEY_UNKNOWN",
"unknown": ["spotify"],
"known": ["sonos"],
"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
The platform holds the keys, picks the provider and meters the spend. You call one of two methods and never touch any of that:
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:
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'sinput_tokensandoutput_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 raisesCoreTooOldErrorif the platform is older than this SDK build needs, with the pin that would work today in the message.
🔴
.structuredis a real instance of your model, or it isNone. When the response carried a structured payload you get a validatedNextStep— never a bare dict to parse yourself. When it did not, you getNone. Handle both halves: reaching straight forresult.structured.toolis anAttributeErrorwaiting 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, nevertool_calling. The tier picks a model that is good at deciding among tools. It is not a mode and not a second API:d.generateis 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 wayexamples/train_search_agent.pydoes. 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", 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:
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).
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:
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:
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:
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:
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. |
RuntimeError |
No generate_transport= was given to the Agent. |
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: onlyNO_CAPABILITY_CONFIGwas mapped, and every other refusal — an expired token, a scope you were never granted, a provider outage — came back asKeyError: 'model'raised from inside this package, indistinguishable from one another. If that is what you see, you are pinned to0.1.0a17or earlier.0.1.0a18is 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:
agent = Agent(name="…", description="…", generate_transport=transport)
The SDK ships that client, so you never write one against our wire shape:
from swisper_agent_sdk import http_generate_transport
agent = Agent(
name="…",
description="…",
generate_transport=http_generate_transport(
"https://core.example.com", # core's ROOT — not the endpoint path
token=current_delegation_token, # a str, or a callable returning one
),
)
base_url is core's root; the endpoint path belongs to the SDK, so appending it
yourself earns a 404 with nothing to explain it. token is the delegation credential
— pass a callable in anything long-running, because tokens are TTL-bounded and a
captured one stops working silently. "Connecting it to core", below, has the rest.
An Agent built with no transport raises a RuntimeError naming the missing
generate_transport= the first time a handler calls d.generate.
🔴 Supplying your own
client=? Settimeout=Noneon it.httpx.AsyncClientdefaults to a 5-second timeout. This SDK's deadline is 30 s. Leave that default in place and a legitimate thirty-secondplannercall dies at five withhttpx.ReadTimeout— never becoming theCapabilityTimeoutthis SDK promises, and looking for all the world like our platform being flaky. The transport the SDK builds for you already setstimeout=None; the trap is only reachable by passing your own client. One deadline, and it isd.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
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:
# 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:
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
⚠️ Published from 0.1.0a18 onward. http_prompts_transport is in the wheel
pip install swisper-agent-sdk==0.1.0a18 gives you. 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:
# agent.contract.yaml
capabilities:
- recall
- prompts # ← without this line, core will never serve you a prompt
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 register uploads 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:
agent = Agent("train_search", "finds trains",
prompt_library=PromptLibrary.from_mapping({"train-search.planner": "..."}))
Now render it:
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:
{% 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.
{{ 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.
Letting the person see the stream
on_chunk gives you the fragments. To let the user see them arrive:
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:
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.
This snippet needs a web framework, and the SDK deliberately does not depend on one — the
tracing middleware is pure ASGI so it works with any of them. Install what you deploy with:
pip install fastapi uvicorn for the code below.
import asyncio
import json
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from swisper_agent_sdk import (
Agent,
StreamingEventSink,
TraceContextMiddleware,
to_input,
)
app = FastAPI()
app.add_middleware(TraceContextMiddleware) # 🔴 without this you are INVISIBLE in Studio
agent = Agent(...) # no sink here — see below
@app.post("/v1/execute")
async def execute(body: dict):
sink = StreamingEventSink() # 🔴 ONE PER REQUEST
async def run_and_close():
try:
return await agent.run_turn(to_input(body), event_sink=sink)
finally:
await sink.close() # 🔴 the TURN closes it, not the reader
async def frames():
turn = asyncio.create_task(run_and_close())
async for event in sink: # arrives as produced
yield f"data: {event.model_dump_json()}\n\n"
result, _ = await turn
yield f"event: result\ndata: {json.dumps(result.to_wire_result())}\n\n"
return StreamingResponse(frames(), media_type="text/event-stream")
Packaged UI results
An agent can return up to eight typed display outputs in its final reply:
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. 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:
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
.j2files and declare them.swisper-agent registerreads the files and sends their content to Swisper, which stores them. A prompt frozen in a Python string cannot make that journey. - Locally, those same files are what render — no server, no credentials, nothing to run. What you debug against is byte-for-byte what registration uploads, because both read through the same code path.
- Once registered, a prompt is content that people other than you can see, version and edit — which is the point. The engineer is not the bottleneck on wording.
✅ 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.
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.
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:
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.
Registering with core — swisper-agent register
Registration sends the complete supported declaration, including your exact
description and version, plus declared credential kinds. If the contract gains a
field this CLI cannot transmit, it refuses locally before sending anything. Captured
metadata does not itself grant approval or permission.
Registration requires operator approval before use. Successful re-registration invalidates previous approval and grants. If a response is lost, check the current registration before retrying; the CLI cannot infer its current approval state.
Installing this package also installs a swisper-agent command. Registration is how
core learns your agent's prompts and graph, so your agent appears as a real agent rather
than an opaque endpoint:
$ export SWISPER_REGISTER_TOKEN="…" # supplied by Swisper
$ swisper-agent register \
--contract ./agent.contract.yaml \
--core-url https://core.example.com
--core-url is core's root, not an endpoint path — the CLI owns the path. Prompt
files named in the contract resolve relative to the contract file, so the command
works from any directory.
🔴 There is no
--tokenflag, deliberately. A token passed as an argument is visible to every other process on the machine, and lands in your shell history and your CI logs. It is read fromSWISPER_REGISTER_TOKENand nowhere else.
The exit code tells you who refused, which matters because the fix is different in each case:
| Code | Meaning | What to do |
|---|---|---|
0 |
registered | continue |
1 |
partially registered — some artefacts landed, some did not | run it again; re-running is the repair |
2 |
usage error — a bad or missing argument | fix the command |
3 |
refused locally — your contract is wrong; core was never contacted | fix the contract |
4 |
refused by core — the request was well-formed and core declined it | read the reason; retrying changes nothing |
5 |
core unreachable — it could not be contacted at all | retry. Whether anything was written is unknown, and re-running is safe |
🔴 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.
A refusal prints the wire code plus what to do about it, rather than a stack trace.
🔴
fact_lookup_servicecan never be declared inagent.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 raisesWireUnserialisableInputDeclaredError, on both the write and the read path. See thefact_lookup_servicenote 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:
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_versioncheck and the error mapping all live belowd.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 ownclient=, settimeout=Noneon it: the deadline belongs tod.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:
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 bothsrc/andexamples/. 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 eleven on 2026-08-30:pydantic,langgraph,jsonpath-ng,pyyaml,langchain-core,httpx,jsonschema,swisper-studio-sdk,redis,jinja2andtyping-extensions. (It has said seven, then nine, then ten on successive days, each time correct when written. The last time it was wrong it omittedjinja2— the dependency the whole prompts-as-files chapter rests on. Run the command; do not trust this sentence.) (See the install-time note above:langgraphalone brings a much larger dependency tree with it — the count above is what we declare, not what actually installs.)httpxbackshttp_generate_transport, and it is imported inside that transport's constructor rather than at module scope — soimport swisper_agent_sdkstays 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.
Metadata
Release files for swisper-agent-sdk 0.1.0a19
For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.
Source distribution (sdist)
| File | Size | Uploaded | |
|---|---|---|---|
| swisper_agent_sdk-0.1.0a19.tar.gz | 338.1 kB | Details |
Built distribution (wheel)
| File | Interpreter | ABI | Platform | Reset |
|---|---|---|---|---|
| swisper_agent_sdk-0.1.0a19-py3-none-any.whl | Python 3 | none | any | Details |
Total release size: 675.9 kB
Release files / swisper_agent_sdk-0.1.0a19.tar.gz
| Download URL | swisper_agent_sdk-0.1.0a19.tar.gz |
|---|---|
| Size | 338.1 kB |
| Tags | Source |
|
SHA-256 checksum How to use checksums |
3f1eb6bacb382980885675c0dfd5c7f67c1100cd2e63fcc1eef683b8a3ccef07
|
|
BLAKE2b-256 checksum How to use checksums |
149ffa3c5b1871282a089eaf3f749bcdf8d33e4a9e9ef10ddc6da4b3ee7132ae
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 15, 2026.
Transparency logRelease files / swisper_agent_sdk-0.1.0a19-py3-none-any.whl
| Download URL | swisper_agent_sdk-0.1.0a19-py3-none-any.whl |
|---|---|
| Size | 337.8 kB |
| Tags | Python 3 |
|
SHA-256 checksum How to use checksums |
d15cb7e4c966c037ce884733881b4ef053854058280a95e2f976e091f89096e5
|
|
BLAKE2b-256 checksum How to use checksums |
5485ff39ab0099761ada62c0c845e7142a37eb6923e9de543499273eca88aeb5
|
| Upload date | |
|
Uploaded using Trusted Publishing? What is trusted publishing? |
Yes |
| Uploaded via |
twine/7.0.0 CPython/3.13.14
|
Provenance
Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.
PyPI Publish Attestation
PyPI verified that this artifact, at this checksum, originated from the publisher listed below.
Signed by GitHub Actions, verified by PyPI on Sep 15, 2026.
Transparency log