Skip to main content
Pre-release

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 a4 means what it says. The version is 0.1.0a4. Build against it, and expect the surface described on this page — Agent, Delegation, Reply, d.recall, d.generate, d.card, d.say, harness, AgentContract — to keep working; it is what our own bundled agents use, so it breaks us before it breaks you. Anything not documented on this page may move without a deprecation cycle, and there are about 75 such exports (see "What is yours and what is ours" below, which says which is which).

Pin an exact version (swisper-agent-sdk==0.1.0a4) rather than a range while we are on 0.1.0aN, and tell us you have pinned it — we will tell you before a change that would break you rather than after. There is no deprecation policy yet because there is not yet a partner whose upgrade we would be managing; when there is, that is the moment it gets written, and you will be told rather than left to read it here.

Install

Requires Python 3.11 or newer.

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

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

A handful of declared dependencies pull in far more packages. This package declares pydantic, langgraph, jsonpath-ng, pyyaml, langchain-core, httpx and jsonschema — and langgraph alone brings its own dependency tree with it (langsmith, orjson, ormsgpack, zstandard and more). Resolved from a clean manifest on 2026-08-27: 41 packages, not seven. None of that is a defect — every one of them is something a declared dependency genuinely needs — but a partner watching 41 packages scroll by after being told about seven deserves to have been told first.

Both numbers move as the package evolves, so read them from the source rather than from this paragraph — the declared list has changed four times already:

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

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 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 pytest and pytest-asyncio. The base install deliberately does not: it is the runtime, not the toolchain.

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 — pip install swisper-agent-sdk[dev] brings both. 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.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:

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

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.

Your payload is passed through untouched. d.card() serialises exactly the dict you give it — there is no mapper, no registry and no schema on this path. You do not need to read card_builder.py: 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.)

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 93 names, and this page describes about eighteen of them. That is not an omission — most of the rest are machinery the package needs across its own module boundaries, importable because Python has no other way to share them. You are not missing a chapter. But "is the thing I need in here and undocumented, or genuinely absent?" is a fair question with no answer until now, so:

module what it is yours?
agent Agent, AgentGraphState yes — start here
delegation Delegation and the errors it raises (CapabilityTimeout, CardPayloadInvalidError, CoreTooOldError, tier and entitlement errors) yes
transport http_generate_transport — how your agent reaches a model through core yes
contract AgentContract, load_agent, and the declaration errors yes, if you use agent.contract.yaml
nodes BaseNode, MCPToolGroup, the EventSink protocol ⚠️ advanced — only for a multi-node graph. A single @agent.handler never needs them
state_types UserInTheLoop, ToolOperation, ContextFile, CardMapping ⚠️ read-only types you may receive; you do not construct them
result_contract DataObject, AgentResultEnvelope, the type registry ⚠️ advanced — typed results
card_builder CardMapperRegistry, build_card_xml, attach_cards_to_text 🚫 ours. Swisper's own card rendering pipeline. d.card() does not go through it — see above
prompt_context, prompt_policy prompt-assembly helpers 🚫 ours. d.generate already applies these for you; see PROMPTING.md
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="Here's a plan built around: I'm vegetarian and allergic to peanuts.."
cards emitted: ['meal_plan']

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

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

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

Telling the model how to behave

🔴 The planner loop is yours, by design. This package ships nodes and conventions that drop into a graph you own. It does not ship a plan/act cycle, and that is a settled decision rather than a gap waiting to be filled — the topology, the edges and the iteration cap are things only you can size for your domain. So instructions=, routing=, narration_style=, tool_guidance=, prompt= and tools= are declarations: nothing feeds them to a model on your behalf.

agent.compose_prompt() is the method that turns them into a prompt. You call it and you get the assembled system prompt back, with your instructions verbatim inside it.

It takes no arguments, and there is no location field in the prompt — the prompt carries what your agent declares, and the turn's context comes from the turn's own inputs, which do not include a location.

Two of the four bundled examples take different routes, and each is checkable by opening it: examples/bring_your_own_model_agent.py composes the prompt and passes it to a client of its own (a stub, so the file runs offline); examples/train_search_agent.py calls d.generate_structured(...) inside a LangGraph cycle, with a prompt it writes itself.

Everything in this section describes what compose_prompt() returns.

Most agents need one thing — instructions=:

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

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

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

Where the call goes, and the one thing you wire

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

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=? 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 does exactly that, and it is the file to copy.

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
)

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

Registering with core — swisper-agent register

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

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

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

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

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

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

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

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

Connecting it to core — http_generate_transport

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

from swisper_agent_sdk import Agent, http_generate_transport

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

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

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

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

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

What this package deliberately does not give you

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

  • No swisper.* imports. The SDK never reaches into the Swisper backend, and a build check enforces that on both src/ and examples/. If something you need is missing here, that is our gap to close — tell us rather than working around it.
  • No database access. Your agent never opens a database connection. Data you are entitled to arrives through a capability call that core authorises for that turn.
  • A deliberately small dependency list. A test asserts that every import is declared and every declaration is imported — a property, not a count — so a new one cannot arrive as a side effect of a convenient import, undeclared. Read the list from the source rather than from this sentence, which has been wrong before: python3 -c "import importlib.metadata as m;print(*[r for r in m.requires('swisper-agent-sdk') if 'extra ==' not in r],sep=chr(10))". It was seven on 2026-08-27: pydantic, langgraph, jsonpath-ng, pyyaml, langchain-core, httpx and jsonschema. (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.

Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

swisper_agent_sdk-0.1.0a4.tar.gz (195.0 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

swisper_agent_sdk-0.1.0a4-py3-none-any.whl (213.8 kB view details)

Uploaded Python 3

File details

Details for the file swisper_agent_sdk-0.1.0a4.tar.gz.

File metadata

  • Download URL: swisper_agent_sdk-0.1.0a4.tar.gz
  • Upload date:
  • Size: 195.0 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/7.0.0 CPython/3.13.14

File hashes

Hashes for swisper_agent_sdk-0.1.0a4.tar.gz
Algorithm Hash digest
SHA256 a4fd6e3c7ba9ed3be0a4ecbd546177e96f44641170c5c4105f393906e819515f
MD5 c8f380a8bd9b21353868f3ce47566b24
BLAKE2b-256 6f4ea3a01ada16e994f890874dfbc643996a76a1587c9c2e9747b3fddc4da3d7

See more details on using hashes here.

Provenance

The following attestation bundles were made for swisper_agent_sdk-0.1.0a4.tar.gz:

Publisher: publish-agent-sdk.yml on Fintama/helvetiq

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file swisper_agent_sdk-0.1.0a4-py3-none-any.whl.

File metadata

File hashes

Hashes for swisper_agent_sdk-0.1.0a4-py3-none-any.whl
Algorithm Hash digest
SHA256 bb30337bfd83aaa0cc39fed8f0a5d61a1654f8960f700c0bf1c522ebc6993dba
MD5 1ee62020dba3b52e9476110b5e9ec668
BLAKE2b-256 f46155951bb1d4b872dbb7f5574eb399c19a786696230753273cde7c79fac30e

See more details on using hashes here.

Provenance

The following attestation bundles were made for swisper_agent_sdk-0.1.0a4-py3-none-any.whl:

Publisher: publish-agent-sdk.yml on Fintama/helvetiq

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.
Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page