Version 0.1.0
A Python SDK for building agentic applications on Matilda. Provides agent abstractions, client-side tool execution, multi-turn sessions, automatic retry with exponential backoff, DSML tool-call interception, and durable stream resume — all on top of the Matilda-native chat contract. Async-first (asyncio + httpx). Requires Python ≥ 3.12.
The agent SDK wraps the client SDK (matilda-client) and adds:
- Agent — a named, configurable persona with dynamic instructions and purpose-based routing
- Runner — runs agent turns with streaming, retry, and a client-side tool execution loop
- Session — multi-turn conversations with auto-managed
conversation_idand turn accumulation - Client tools — register handlers the agent can invoke mid-turn; the SDK handles the roundtrip loop
- DSML interception — tool calls emitted as text tokens (
<|DSML|tool_call>) are automatically captured and surfaced as native tool events - Durable stream resume — reconnect to a detached stream from the last cursor
Agent runs send persist: false by default — conversations do not appear in the Matilda web app's chat history.
What's included
- Agent — named persona with static or dynamic instructions, purpose-based routing
- Runner —
run(),stream(),stream_text(),run_text(),run_object(),stream_object()with retry and tool execution - Session — multi-turn conversations with automatic
conversation_idreuse and client-side transcript replay - Client tools —
ToolHandlerswith automatic roundtrip loop and advertised-tool guard - Files — upload (single and parallel), retrieve metadata
- Conversations — list, retrieve, rename, and set message feedback
- Auth — managed PKCE browser login, RFC 8628 device flow, token restore, persistent token storage
What's NOT included
- Server-side tool execution — server-side tools (web search, code execution, etc.) are handled by Matilda core. The agent SDK's tool loop is for client-side tools only.
- Model/provider selection — Matilda core owns routing, safety, and policy.
Table of Contents
- Installation
- Quick Start
- Configuration
- Authentication
- Agent
- Runner — Run (Non-Streaming)
- Runner — Stream (Full Events)
- Runner — Text Helpers
- Runner — Structured Output
- Client Tools
- Session (Multi-Turn)
- Stream Resume
- Files
- Conversations
- Feedback
- Error Handling
- Multi-Agent Patterns
- Suggested Tasks / Recipes
- Exports Reference
1. Installation
pip install matilda-agent-sdk==0.1.0
# or: uv add matilda-agent-sdk
Requires Python ≥ 3.12. The client SDK (matilda-client) is installed automatically as a dependency.
pydantic is an optional dependency — install it alongside the SDK if you want schema-validated structured output (§9):
pip install pydantic
Import
from matilda_agent_sdk import Agent, Runner, run, stream
2. Quick Start
Minimal: run a single agent turn
import asyncio
from matilda_agent_sdk import run
async def main():
result = await run(
{"name": "greeter", "instructions": "Be friendly and concise."},
"Say hello in three languages.",
)
print(result.final_output)
print(result.usage)
asyncio.run(main())
Minimal: streaming
from matilda_agent_sdk import stream
async def main():
async for event in stream(
{"name": "storyteller", "instructions": "Write a short sci-fi haiku."},
"Write about a Dyson sphere.",
):
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
if event.type == "response.completed":
print("\n[done]")
Authenticated: device flow + run
import asyncio
from matilda_agent_sdk import MatildaClient, Runner
async def main():
runner = Runner(MatildaClient(base_url="https://matilda.com/api"))
# Authenticate via RFC 8628 device flow — prints a code to stderr
if not await runner.auth.get_tokens():
await runner.auth.login_with_device_flow(client_id="matilda-code")
# Token is now managed automatically — refresh on 401 comes for free
result = await runner.run(
{"name": "helper", "instructions": "Be concise."},
"What is the capital of Australia?",
)
print(result.final_output)
asyncio.run(main())
3. Configuration
configure_default_client(client)
Installs the shared default MatildaClient used by the module-level convenience functions (run, stream, stream_text, run_text, run_object, stream_object) and their shared default Runner. Returns nothing.
import os
from matilda_agent_sdk import MatildaClient, configure_default_client
configure_default_client(
MatildaClient(
base_url="https://matilda.com/api",
token=os.environ["MATILDA_ACCESS_TOKEN"],
)
)
The client derives its base URL, static token, and async get_token provider from MatildaConfig — see the client SDK docs for the full field list.
MatildaClient
The underlying API client, re-exported from matilda-client. Each Runner can hold its own MatildaClient instance for isolation, or share the module-level default.
import os
from matilda_agent_sdk import MatildaClient, Runner
client = MatildaClient(
base_url="https://matilda.com/api",
token=os.environ["MATILDA_ACCESS_TOKEN"],
)
runner = Runner(client)
Runner
The main agent execution class. Optionally accepts an explicit MatildaClient for isolation.
from matilda_agent_sdk import MatildaClient, Runner
# Uses the shared default client (materialised lazily, or installed
# via configure_default_client)
default_runner = Runner()
# Uses an isolated client — independent config, auth, and token lifecycle
isolated_runner = Runner(MatildaClient(base_url="https://matilda.com/api"))
| Constructor parameter | Type | Description |
|---|---|---|
client |
MatildaClient | None |
Optional explicit client. If omitted, the shared default is used (created on first use if none was configured). |
Environment URLs
| Environment | Base URL |
|---|---|
| Production | https://matilda.com/api |
Instance isolation
Each Runner holds its own MatildaClient (either explicit or the shared default). The auth, files, conversations, and feedback resources resolve their client lazily, so a client installed via configure_default_client() is picked up correctly.
from matilda_agent_sdk import MatildaClient, Runner
runner_a = Runner(MatildaClient(base_url="https://matilda.com/api"))
runner_b = Runner(MatildaClient(base_url="https://matilda.com/api"))
# Each runner is fully isolated — independent auth, config, and token lifecycle
await runner_a.auth.login_with_device_flow(client_id="matilda-code")
await runner_b.auth.login_with_browser(client_id="matilda-code")
Other configuration utilities
| Export | Description |
|---|---|
MatildaConfig |
Client configuration dataclass (re-exported from matilda-client). |
MATILDA_API_VERSION_HEADER / MATILDA_CURRENT_API_VERSION |
API version constants, importable from matilda_client. |
4. Authentication
The agent SDK provides AgentAuth — a managed auth tier layered on the client SDK's TokenManager. On successful login, a TokenManager is auto-configured as the runner client's token provider. Every subsequent request automatically carries a managed access token with single-flight, skew-aware auto-refresh.
OAuth client ID
The agent SDK uses the matilda-code OAuth client ID, which supports both PKCE browser login and RFC 8628 device flow. All examples in this documentation use matilda-code.
API keys
Agents can also authenticate with an mc_live_ API key instead of OAuth — useful for CI and server-side deployments where there's no browser. Keys are minted with the client SDK's matilda-key CLI (social-login friendly — no code required):
matilda-key create-api-key --name "my-agent"
# or without installing: uvx --from matilda-client matilda-key create-api-key --name "my-agent"
# mints an mc_live_… key after device-flow sign-in; see the client SDK docs for flags
Then supply it as a static access token:
import os
from matilda_agent_sdk import MatildaClient, configure_default_client
configure_default_client(
MatildaClient(
base_url="https://matilda.com/api",
token=os.environ["MATILDA_API_KEY"],
)
)
runner.auth.login_with_browser(...)
Managed PKCE browser login (RFC 8252 loopback). Starts a temporary local server, opens the browser, receives the auth code, exchanges it for tokens, and auto-wires a TokenManager on the runner's client.
tokens = await runner.auth.login_with_browser(
client_id="matilda-code",
open_browser=lambda url: print(f"Open: {url}"),
)
print(tokens.access_token)
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
client_id |
str |
(required) | OAuth client alias (e.g. 'matilda-code'). |
scope |
str |
'openid offline_access' |
Space-separated OAuth scopes. |
identity_provider_id |
str | None |
None |
Route straight to a federated IdP (e.g. Google SSO). |
callback_port |
int | None |
random ephemeral | Fixed loopback port. |
timeout_ms |
int |
300_000 (5 min) |
How long to wait for the browser callback. |
open_browser |
Callable[[str], None | Awaitable[None]] | None |
None |
Called with the authorize URL. |
success_redirect |
str |
DEFAULT_SUCCESS_REDIRECT ('https://matilda.com/cli/signed-in') |
URL the browser is 302-redirected to on success. |
error_redirect |
str | None |
None |
URL for the error case. |
token_store |
StorageAdapter | None |
memory_storage() |
Custom token persistence. |
token_lock |
TokenLock | None |
None |
Cross-process critical-section lock for token refresh. |
on_event |
Callable[[LoginFlowEvent], None] | None |
None |
Subscribe to login flow state events. |
http |
httpx.AsyncClient | None |
None |
Override HTTP transport. |
Returns TokenSet.
runner.auth.login_with_device_flow(...)
Managed RFC 8628 device flow. Requests a device code, prints the user code and verification URL to stderr (by default), and polls until the user authorises.
def show_login(event):
if event.type == "user_code":
print(f"Visit {event.verification_uri} and enter code: {event.user_code}")
tokens = await runner.auth.login_with_device_flow(
client_id="matilda-code",
on_event=show_login,
)
If no on_event handler is provided, the SDK prints the user code and verification URL to stderr automatically (default_device_on_event).
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
client_id |
str |
(required) | OAuth client alias. |
scope |
str |
'openid offline_access' |
Space-separated OAuth scopes. |
token_store |
StorageAdapter | None |
memory_storage() |
Custom token persistence. |
token_lock |
TokenLock | None |
None |
Cross-process lock for token refresh. |
on_event |
Callable[[LoginFlowEvent], None] | None |
default_device_on_event |
Subscribe to login flow events. If omitted, prints user code to stderr. |
http |
httpx.AsyncClient | None |
None |
Override HTTP transport. |
Returns TokenSet.
runner.auth.restore(...)
Adopts tokens that were already persisted (e.g. by create_file_token_store) without repeating the interactive login. Returns None when the store holds nothing usable, so a caller can fall back to login_with_*.
from pathlib import Path
from matilda_client import create_file_token_store
token_store = create_file_token_store(Path.home() / ".matilda" / "tokens.json")
tokens = await runner.auth.restore(
client_id="matilda-code",
token_store=token_store.store,
token_lock=token_store.lock,
)
if not tokens:
# No persisted tokens — fall back to interactive login
await runner.auth.login_with_device_flow(
client_id="matilda-code",
token_store=token_store.store,
token_lock=token_store.lock,
)
Parameters
| Parameter | Type | Description |
|---|---|---|
client_id |
str |
OAuth client alias. |
token_store |
StorageAdapter |
Token persistence adapter. |
token_lock |
TokenLock | None |
Cross-process lock. |
http |
httpx.AsyncClient | None |
Override HTTP transport. |
metadata |
AuthServerMetadata | None |
Pre-fetched server metadata (skips discovery). |
Returns TokenSet | None.
runner.auth.get_tokens()
Returns the current token set from the managed TokenManager, or None if not authenticated.
tokens = await runner.auth.get_tokens()
if tokens:
print(f"Token expires at epoch ms: {tokens.expires_at}")
Returns TokenSet | None.
runner.auth.logout()
Clears the token store, destroys the TokenManager, and restores the client's previous token provider (important when the client is the shared default).
await runner.auth.logout()
Returns None.
Token persistence
By default, tokens are stored in memory (memory_storage()). For cross-process persistence (e.g. CLI sessions), use create_file_token_store — imported from matilda_client:
from pathlib import Path
from matilda_agent_sdk import MatildaClient, Runner
from matilda_client import create_file_token_store
async def main():
token_path = Path.home() / ".matilda" / "tokens.json"
token_store = create_file_token_store(token_path)
runner = Runner(MatildaClient(base_url="https://matilda.com/api"))
await runner.auth.login_with_browser(
client_id="matilda-code",
token_store=token_store.store,
token_lock=token_store.lock,
)
The file store uses a 0600 JSON file with a lockfile-based single-writer lock to prevent cross-process refresh races.
StorageAdapter protocol
Imported from matilda_client:
class StorageAdapter(Protocol):
def get(self, key: str) -> str | None | Awaitable[str | None]: ...
def set(self, key: str, value: str) -> None | Awaitable[None]: ...
def remove(self, key: str) -> None | Awaitable[None]: ...
TokenManager
await manager.get_access_token(force_refresh=False) # -> str
await manager.get_tokens() # -> TokenSet | None
await manager.set_tokens(tokens) # -> None
await manager.clear() # -> None
TokenSet
@dataclass(frozen=True)
class TokenSet:
access_token: str
expires_at: int # epoch milliseconds
refresh_token: str | None
id_token: str | None
AuthError class
Imported from matilda_client:
class AuthError(MatildaError):
code: str # OAuth error code (e.g. 'invalid_grant', 'authorization_pending')
retryable: bool # True for transient 5xx/network; False for revoked tokens
LoginFlowEvent
Imported from matilda_client — three frozen dataclasses with a type discriminator:
# StateEvent (type="state") — status: 'idle' | 'awaiting_user' | 'exchanging' | 'authenticated' | 'error'
# AuthorizeUrlEvent (type="authorize_url") — url: str
# UserCodeEvent (type="user_code") — user_code, verification_uri, verification_uri_complete
5. Agent
An Agent is a named, reusable persona with optional instructions, context, purpose, and metadata. You can pass a plain dict with the same keys anywhere an Agent is accepted — the SDK normalises it (a camelCase responseMode key is also accepted as an alias of response_mode).
Agent class
from matilda_agent_sdk import Agent
reviewer = Agent(
name="code-reviewer",
purpose="code",
instructions="Review code for correctness, security, and readability.",
context="Project: matilda-core\nLanguage: Python",
metadata={"team": "platform"},
)
The Agent is reusable — construct once, run many times.
Constructor parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
name |
str |
(required) | Agent name. Must be non-empty (raises ValueError otherwise). |
instructions |
AgentInstructions |
None |
Static string or dynamic callable (see below). |
context |
str | None |
None |
Additional context appended to instructions under a Context: header. |
purpose |
AgentPurpose |
'code' |
Controls the default response_mode and how the server routes the request. |
response_mode |
str | None |
None |
Override the response mode. If omitted, derived from purpose. |
metadata |
dict |
{} |
Custom data available to dynamic instructions. |
AgentPurpose
AgentPurpose = Literal["code", "analysis", "general"]
| Purpose | response_mode default |
|---|---|
'code' |
'auto' |
'analysis' |
'deep' |
'general' |
'auto' |
Dynamic instructions
Instructions can be a callable that receives a runtime context dict, letting you customise behaviour per-call (async callables are awaited):
from matilda_agent_sdk import Agent, run
def reviewer_instructions(ctx):
lang = ctx["metadata"].get("language", "auto-detect")
strictness = ctx["metadata"].get("strictness", "normal")
return "\n".join(
[
"Review the following code.",
f"Language: {lang}",
f"Strictness: {strictness}",
"Focus on: correctness, security, and readability.",
]
)
agent = Agent(name="code-reviewer", purpose="code", instructions=reviewer_instructions)
async def main():
result = await run(
agent,
"def add(a, b): return a + b",
metadata={"language": "Python", "strictness": "strict"},
)
print(result.final_output)
AgentInstructions
AgentInstructions = str | Callable[[dict], str | Awaitable[str]]
The context dict passed to the callable:
{
"agent_name": agent.name,
"input": trimmed_prompt,
"purpose": purpose,
"metadata": merged_metadata,
}
How agent messages are constructed
The SDK packs the agent's identity, instructions, context, and the user's prompt into a single user message with labelled sections:
Agent: code-reviewer
Instructions:
Review the following code.
Language: Python
...
Context:
Project: matilda-core
Code task:
def add(a, b): return a + b
build_agent_chat_request(...)
Low-level async builder that constructs the chat request dict without executing it. Useful for testing, logging, or custom execution paths.
import asyncio
from matilda_agent_sdk import build_agent_chat_request
async def main():
request = await build_agent_chat_request(
prompt="Fix the failing test",
agent={"name": "helper", "purpose": "code"},
instructions="Be specific.",
context="Project root: /repo",
conversation_id="conv-1",
file_ids=["file-1"],
client_tools=[{"name": "read_file", "description": "Read a file", "parameters": {"type": "object"}}],
)
# request["messages"], request["responseMode"], request["conversation_id"], ...
Parameters
| Parameter | Type | Description |
|---|---|---|
prompt |
str |
The user's message. Required. |
agent |
Agent | dict | None |
Optional agent to use. Defaults to a generic agent. |
purpose |
AgentPurpose | None |
Fallback purpose if agent doesn't specify one. |
instructions |
AgentInstructions | None |
Override the agent's instructions. |
context |
str | None |
Override the agent's context. |
metadata |
dict | None |
Merge with agent's metadata. |
conversation_id |
str | None |
Associate with a conversation thread. |
file_ids |
list[str] | None |
File IDs to attach. |
client_tools |
list[dict] | None |
Client tools to advertise. |
response_mode |
str | None |
Override response mode. |
response_schema |
str | None |
Raw JSON Schema string to constrain output. |
history |
list[dict] | None |
Prior-transcript messages replayed ahead of this turn. |
Returns a dict shaped like a chat request (messages, responseMode, optional conversation_id / fileIds / clientTools / responseSchema).
6. Runner — Run (Non-Streaming)
runner.run(agent, prompt, **options)
Runs an agent turn and returns the complete result. Internally streams and collects all events.
from matilda_agent_sdk import Runner
runner = Runner()
result = await runner.run(
{"name": "helper", "instructions": "Be concise."},
"What is the capital of Australia?",
conversation_id="conv-123",
response_mode="instant",
callbacks={
"on_token": lambda delta: print(delta, end="", flush=True),
"on_done": lambda: print("\n[done]"),
},
)
print(result.final_output)
print(result.usage)
Run options
All options are keyword-only.
| Option | Type | Default | Description |
|---|---|---|---|
conversation_id |
str | None |
auto-generated | Associates this turn with a conversation thread. |
file_ids |
list[str] | None |
— | File IDs to attach (from files.upload()). |
client_tools |
list[dict] | None |
— | Client tools to advertise for this turn. |
purpose |
AgentPurpose | None |
— | Fallback purpose (used when agent is a dict without one). |
response_mode |
str | None |
— | Override the response mode. |
response_schema |
str | None |
— | Raw JSON Schema (as a string) to grammar-constrain the response to. Prefer runner.run_object / runner.stream_object, which convert a pydantic model for you (see §9). |
stall_timeout_ms |
int | None |
45_000 |
SSE stall watchdog timeout in ms. Pass 0 to disable. |
metadata |
dict | None |
— | Custom data available to dynamic instructions. |
tool_handlers |
ToolHandlers | None |
— | Handlers for client tools (see §10). |
max_tool_roundtrips |
int |
25 |
Maximum tool roundtrip cycles before stopping. |
max_retries |
int |
0 |
Maximum retries on retryable errors (5xx, 429, 408, network). |
throw_on_stream_error |
bool |
True |
Raise MatildaAgentStreamError if the stream emits an error event. |
callbacks |
dict | None |
— | Callback hooks for events (see below). |
history |
list[dict] | None |
— | Prior-transcript messages replayed ahead of this turn. Session threads these automatically; only set this on a standalone Runner when you want to inject external context. Caller-supplied entries sent on a Session turn appear before the session's own transcript. |
fingerprint |
str | None |
— | Device fingerprint for rate limiting. |
callbacks
Simple callback hooks, passed as a dict, that fire as events arrive. An alternative to manually iterating stream().
result = await runner.run(
agent,
prompt,
callbacks={
"on_token": lambda delta: print(delta, end="", flush=True),
"on_tool_call": lambda name, args: print(f"Tool: {name}"),
"on_tool_result": lambda name, result, is_error: print(f"Result: {result}"),
"on_usage": lambda usage: print(f"Tokens: {usage.output_tokens}"),
"on_error": lambda code, message: print(f"Error: {code}"),
"on_retry": lambda attempt, error, delay_ms: print(f"Retry {attempt} in {delay_ms}ms"),
"on_done": lambda: print("Done"),
},
)
| Callback | Signature | Fires when |
|---|---|---|
on_event |
(event: AgentEvent) -> None |
Every event — catch-all, fires before the typed hooks below. Useful for telemetry, UI plumbing, or event logging. |
on_token |
(delta: str) -> None |
A text chunk arrives. |
on_tool_call |
(name: str, args: dict) -> None |
The agent calls a client tool. |
on_tool_result |
(name: str, result: str, is_error: bool) -> None |
A client tool handler returns. |
on_usage |
(usage: Usage) -> None |
Token usage data arrives. |
on_error |
(code: str, message: str) -> None |
A stream error occurs. |
on_retry |
(attempt: int, error: dict, delay_ms: float) -> None |
A retryable error triggers a retry. |
on_done |
() -> None |
The stream finishes. Fires per-turn in multi-turn tool loops. |
AgentRunResult
| Field | Type | Description |
|---|---|---|
agent_name |
str |
The agent's name. |
final_output |
str |
The full assistant response text. Accumulated from response.output_text.delta events. |
events |
list[AgentEvent] |
Every event emitted during the run. |
stream_id |
str | None |
Durable stream ID (from the stream.started event). |
last_event_id |
str | None |
Last stream event ID (for resume). |
usage |
UsageSummary | None |
Token usage. Accumulated across multi-roundtrip runs. |
errors |
list[StreamErrorDetail] |
Any errors emitted during the run (code + message). |
truncated_reason |
str | None |
Why the response was truncated (e.g. 'max_tokens', 'max_tool_roundtrips'). |
safety_replace |
dict | None |
Set when the backend replaced the answer for safety ({"message": ..., "categories": [...]}). final_output holds the replacement text. |
throw_on_stream_error=False
By default, run() raises MatildaAgentStreamError if the stream emits an error event. Pass throw_on_stream_error=False to suppress the raise and inspect errors on the returned result instead:
result = await runner.run(agent, prompt, throw_on_stream_error=False)
if result.errors:
for err in result.errors:
print(f"{err.code}: {err.message}")
print("Partial output:", result.final_output or "(none)")
7. Runner — Stream (Full Events)
runner.stream(agent, prompt, **options)
An async generator that yields AgentEvent objects as they arrive. This is the full event stream — tool calls, usage, status changes, safety replacements, and more. Accepts the same options as run() except callbacks / throw_on_stream_error.
async for event in runner.stream(
{"name": "explainer", "instructions": "Explain quantum computing."},
"What is quantum entanglement?",
):
if event.type == "run.started":
print(f'Agent "{event.agent_name}" started.')
elif event.type == "stream.started":
print(f"Stream {event.stream_id} connected.")
elif event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
elif event.type == "client.tool.requested":
print(f"\nTool requested: {event.name}")
elif event.type == "client.tool.result":
print(f"Tool result: {event.result}")
elif event.type == "response.usage":
print(f"\nTokens: {event.usage.output_tokens}")
elif event.type == "response.completed":
print("\n[done]")
elif event.type == "response.error":
print(f"Error: {event.code} — {event.message}")
AgentEvent
A union of 21 event dataclasses — the 14 chat events shared with the client SDK, plus 7 agent-level events. Each carries a type: ClassVar[str] discriminator; dispatch on event.type or isinstance().
RunStarted — type="run.started"
Emitted once at the start of a run with the agent's name.
@dataclass(frozen=True)
class RunStarted:
agent_name: str
# type = "run.started"
StreamStarted — type="stream.started"
Emitted once when the SSE stream connects, with the durable stream ID.
@dataclass(frozen=True)
class StreamStarted:
stream_id: str
# type = "stream.started"
OutputTextDelta — type="response.output_text.delta"
A text chunk from the assistant.
@dataclass(frozen=True)
class OutputTextDelta:
delta: str
# type = "response.output_text.delta"
StatusEvent — type="response.status"
Stream lifecycle status change.
@dataclass(frozen=True)
class StatusEvent:
status: str # 'thinking' | 'streaming' | 'queued' | 'idle' | 'done' | 'error' | ...
# type = "response.status"
QueuedEvent — type="response.queued"
Queue position update while waiting for a free slot.
@dataclass(frozen=True)
class QueuedEvent:
state: str
position: int
estimated_wait_seconds: float
# type = "response.queued"
ToolCallStarted — type="response.tool_call.started"
A server-side tool invocation began.
@dataclass(frozen=True)
class ToolCallStarted:
tool: str
input_or_args: object = None
output: str | None = None
# type = "response.tool_call.started"
ToolCallProgress — type="response.tool_call.progress"
Progress update from a running server-side tool.
@dataclass(frozen=True)
class ToolCallProgress:
tool: str
message: str
# type = "response.tool_call.progress"
ToolCallCompleted — type="response.tool_call.completed"
A server-side tool invocation finished.
@dataclass(frozen=True)
class ToolCallCompleted:
tool: str
status: str # 'success' | 'error'
input: str | None = None
output: str | None = None
# type = "response.tool_call.completed"
ClientToolRequested — type="client.tool.requested"
The agent called a client tool. The SDK will execute the matching handler from tool_handlers.
@dataclass(frozen=True)
class ClientToolRequested:
name: str
args: dict
id: str | None = None
# type = "client.tool.requested"
ClientToolExecuting — type="client.tool.executing"
The SDK is about to execute the handler for a requested client tool.
@dataclass(frozen=True)
class ClientToolExecuting:
name: str
args: dict
id: str | None = None
# type = "client.tool.executing"
ClientToolResult — type="client.tool.result"
A client tool handler returned a result.
@dataclass(frozen=True)
class ClientToolResult:
name: str
result: str
is_error: bool
id: str | None = None
# type = "client.tool.result"
ClientToolRoundtrip — type="client.tool.roundtrip"
Emitted after each tool roundtrip cycle, showing progress against the maximum.
@dataclass(frozen=True)
class ClientToolRoundtrip:
turn: int
max_turns: int
# type = "client.tool.roundtrip"
TurnRetrying — type="turn.retrying"
A retryable error occurred and the turn is being retried.
@dataclass(frozen=True)
class TurnRetrying:
attempt: int
max_retries: int
error: dict
delay_ms: float
# type = "turn.retrying"
GenerationStatus — type="response.generation_status"
Generation phase update.
@dataclass(frozen=True)
class GenerationStatus:
phase: str
# type = "response.generation_status"
OutputTextReplace — type="response.output_text.replace"
The server replaced the output via a safety filter. content holds the replacement text; categories lists the safety categories.
@dataclass(frozen=True)
class OutputTextReplace:
content: str | None = None
categories: list[str] | None = None
# type = "response.output_text.replace"
UsageEvent — type="response.usage"
Token usage data for the turn, in the usage field:
@dataclass(frozen=True)
class Usage:
output_tokens: int
context_pct: float | None = None # context window usage (0-100)
context_messages_trimmed: int | None = None # messages trimmed to fit context budget
context_budget_tokens: int | None = None # total context budget in tokens
CursorEvent — type="response.cursor"
Durable stream cursor (event ID). Persist this to resume from this point.
@dataclass(frozen=True)
class CursorEvent:
last_event_id: str
# type = "response.cursor"
Truncated — type="response.truncated"
The response was cut short.
@dataclass(frozen=True)
class Truncated:
reason: str
# type = "response.truncated"
Completed — type="response.completed"
The stream finished successfully.
Completed()
ResponseError — type="response.error"
An error occurred during the stream.
@dataclass(frozen=True)
class ResponseError:
code: str # a ChatErrorCode
message: str
# type = "response.error"
8. Runner — Text Helpers
These helpers filter the event stream to just text — useful when you only need the response text and don't care about tool calls, usage, or status events.
runner.stream_text(agent, prompt, **options)
An async generator that yields raw string deltas. Raises SafetyReplaceError when the server replaces the output (safety filter). Raises MatildaStreamError on stream errors.
from matilda_agent_sdk import Runner, SafetyReplaceError
runner = Runner()
async def main():
try:
async for chunk in runner.stream_text(
{"name": "poet", "instructions": "Write a haiku."},
"Write about the ocean.",
):
print(chunk, end="", flush=True)
except SafetyReplaceError as err:
print(f"\nSafety replace: {', '.join(err.categories)}")
Why raise on safety replace? The original text has already been yielded to the consumer by the time the replace event arrives. Raising forces the consumer to handle the replacement explicitly — silently dropping it would lose the replacement message.
runner.run_text(agent, prompt, **options)
Non-streaming convenience that returns just the final output text. Raises SafetyReplaceError on safety replacement, and propagates MatildaAgentStreamError if the stream produced error events.
text = await runner.run_text(
{"name": "helper"},
"What is 2 + 2?",
)
print(text) # "4"
SafetyReplaceError
class SafetyReplaceError(MatildaError):
categories: list[str]
# str(err) = replacement content (or empty string)
Convenience functions
The SDK exports shared-default-runner-backed convenience functions so you don't need to instantiate a Runner for simple use cases:
from matilda_agent_sdk import run, run_text, stream, stream_text
# These are equivalent to default_runner.run(), default_runner.stream(), etc.
result = await run(agent, prompt, ...) # AgentRunResult
text = await run_text(agent, prompt, ...) # str
async for event in stream(agent, prompt, ...):
... # AgentEvent
async for chunk in stream_text(agent, prompt, ...):
... # str
These use the shared default client (installed via configure_default_client(), or created with defaults on first use). For isolated config or auth, instantiate your own Runner.
9. Runner — Structured Output
Structured output constrains the agent's response to a JSON Schema, server-side (grammar-constrained decoding), and validates it client-side against your pydantic model. Pass a pydantic model class (or JSON-schema dict), receive a validated object — no prompt engineering, no brittle JSON extraction.
runner.stream_object(agent, prompt, schema, **options)
Streams exactly like runner.stream() — you receive every AgentEvent (including tool-loop and usage events) — plus one final event with the parsed, schema-validated object. Options are the run options.
from pydantic import BaseModel
class Issue(BaseModel):
severity: str # 'low' | 'medium' | 'high'
description: str
class Review(BaseModel):
summary: str
issues: list[Issue]
async def main():
reviewer = Agent(name="reviewer", instructions="Review the code the user provides.")
async for event in runner.stream_object(reviewer, "Review this function: ...", Review):
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
if event.type == "object":
review = event.object # a validated Review instance
print(f"\nValidated: {review.summary}")
The final event:
@dataclass(frozen=True)
class ObjectEvent[T]:
object: T # your pydantic model
# type = "object"
runner.run_object(agent, prompt, schema, **options)
Non-streaming convenience. Like runner.run(), it honours callbacks, throw_on_stream_error, retries, and the tool loop — and returns an AgentObjectResult: the full AgentRunResult plus the validated object.
from pydantic import BaseModel
class Invoice(BaseModel):
total: float
currency: str
async def main():
result = await runner.run_object(
extractor,
"Invoice total $1,250.00 AUD due 30 Sep.",
Invoice,
)
print(result.object.total) # 1250.0 (float)
print(result.object.currency) # "AUD" (str)
print(result.final_output) # raw JSON text as returned
print(result.usage) # token usage, as usual
AgentObjectResult
Extends AgentRunResult with one additional field:
| Field | Type | Description |
|---|---|---|
object |
T | None |
The response text parsed as JSON and validated against your schema. |
Convenience functions
Shared-default-runner-backed, like the other top-level helpers:
from matilda_agent_sdk import run_object, stream_object
result = await run_object(agent, prompt, schema, ...)
async for event in stream_object(agent, prompt, schema, ...):
...
Raw JSON Schema via response_schema
The run options (and therefore Session turns) accept a stringified JSON Schema directly on any run or stream:
import json
result = await runner.run(
agent,
"List three Australian birds.",
response_schema=json.dumps({
"type": "object",
"properties": {"birds": {"type": "array", "items": {"type": "string"}}},
"required": ["birds"],
"additionalProperties": False,
}),
)
data = json.loads(result.final_output) # guaranteed valid, schema-conforming JSON
With response_schema set, final_output is guaranteed to be valid JSON conforming to the schema — but parsing and validation are up to you.
Safety replace and structured output. Like
run_text()/stream_text(), the object helpers raiseSafetyReplaceErrorwhen the server replaces the output mid-stream —str(err)holds the replacement text and.categoriesthe triggering categories. Token deltas already yielded to your consumer are not rolled back;run_object()is unaffected at the value level, since it raises before returning a result.
Truncation throws. If the stream is truncated before the JSON completes, both helpers raise
MatildaObjectParseErrorwith the partial text in.raw. See §17. Error Handling.
Stream errors throw. If the server emits an
errorevent mid-stream,stream_object()raisesMatildaStreamErrorwith the server's error code and message, andrun_object()raisesMatildaAgentStreamErrorwith the partialresultattached — matching the behaviour of the text helpers.
10. Client Tools
Client tools are handlers you register that the agent can invoke mid-turn. The SDK handles the entire roundtrip loop: detecting tool calls, executing your handler, feeding the result back to the agent, and repeating until the agent stops calling tools or the roundtrip limit is reached.
Tool execution loop
┌─────────────────────────────────────────────────────────┐
│ Turn 0 │
│ 1. Send messages + client_tools to server │
│ 2. Stream events — agent responds, may call tools │
│ 3. If tool calls detected: │
│ a. client.tool.requested → client.tool.executing │
│ b. Execute handler from tool_handlers │
│ c. client.tool.result │
│ d. Append result to messages as a user message │
│ e. client.tool.roundtrip (turn + 1 / max_turns) │
│ f. Go to Turn 1 │
│ 4. If no tool calls: run is done │
│ 5. If turn >= max_tool_roundtrips: truncated │
└─────────────────────────────────────────────────────────┘
Declaring and handling tools
import asyncio
import json
from matilda_agent_sdk import ToolResult, stream
# 1. Declare the tools so the server knows they exist
client_tools = [
{"name": "get_weather", "description": "Get current weather for a city", "parameters": {"type": "object"}},
{"name": "calculate", "description": "Evaluate a math expression", "parameters": {"type": "object"}},
]
# 2. Register handlers — the SDK calls these when the agent invokes a tool
async def get_weather(args, ctx):
city = args.get("city", "unknown")
return ToolResult(content=json.dumps({"city": city, "temp": 22, "condition": "sunny"}))
async def calculate(args, ctx):
try:
# NOTE: eval is a stand-in for a real expression parser — never eval
# untrusted input in production.
result = eval(str(args["expression"]), {"__builtins__": {}}, {})
return ToolResult(content=str(result))
except Exception:
return ToolResult(content="Invalid expression", is_error=True)
tool_handlers = {"get_weather": get_weather, "calculate": calculate}
# 3. Pass both to stream() or run()
async def main():
async for event in stream(
{"name": "assistant", "instructions": "Use the available tools to answer questions."},
"What is the weather in Sydney, and what is 15 * 23?",
tool_handlers=tool_handlers,
client_tools=client_tools,
):
if event.type == "client.tool.requested":
print(f"→ Agent requested: {event.name}({json.dumps(event.args)})")
if event.type == "client.tool.executing":
print(f"⚙ Executing: {event.name}")
if event.type == "client.tool.result":
suffix = " (error)" if event.is_error else ""
print(f"← Result: {event.result}{suffix}")
if event.type == "client.tool.roundtrip":
print(f" Roundtrip {event.turn}/{event.max_turns}")
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
asyncio.run(main())
ToolHandler
ToolHandler = Callable[[dict, ToolExecutionContext], Awaitable[ToolResult]]
ToolExecutionContext
@dataclass(frozen=True)
class ToolExecutionContext:
tool_call_id: str | None = None
ToolResult
@dataclass(frozen=True)
class ToolResult:
content: str
is_error: bool = False
ToolHandlers
ToolHandlers = dict[str, ToolHandler]
max_tool_roundtrips
Controls how many back-and-forth tool cycles the SDK allows before stopping. Default is 25 (DEFAULT_MAX_TOOL_ROUNDTRIPS). Lower it to prevent infinite loops or control cost.
result = await runner.run(
{"name": "tool-heavy-agent", "instructions": "Use tools to gather information."},
"Do a task that needs tools",
max_tool_roundtrips=5,
tool_handlers={"search": lambda args, ctx: search_handler(args)},
client_tools=[{"name": "search", "description": "Search", "parameters": {"type": "object"}}],
)
roundtrips = [e for e in result.events if e.type == "client.tool.roundtrip"]
print(f"Roundtrips used: {len(roundtrips)} (max was 5)")
Advertised-tool guard
The SDK enforces that the agent can only call tools that were advertised for the current turn. If the model calls a tool that wasn't in client_tools, the SDK returns an error result instead of executing a handler:
Tool not offered this turn: <name>
This prevents a model from talking the runner into invoking a handler that was never offered — tool output is untrusted input.
DSML tool-call interception
Some models emit tool calls as text tokens wrapped in DSML markup (<|DSML|tool_call>{...}</|DSML|tool_call>) instead of using native function calling. The SDK automatically intercepts these text tokens, parses the JSON payload, and surfaces them as native ClientToolRequested events — the consumer never sees the raw markup.
This interception is fully automatic and applies to all streaming paths (the tag constants are exported as DSML_TOOL_CALL_OPEN / DSML_TOOL_CALL_CLOSE).
Human-in-the-loop
The tool execution loop makes human-in-the-loop trivial: a tool handler is just an async callable, so it can block on stdin, a UI prompt, or any other input source.
import asyncio
from matilda_agent_sdk import ToolResult
async def ask_user(args, ctx):
answer = await asyncio.to_thread(input, f"\n Agent asks: {args['question']}\n > ")
return ToolResult(content=answer.strip())
tool_handlers = {"ask_user": ask_user}
client_tools = [
{"name": "ask_user", "description": "Ask the user a question", "parameters": {"type": "object"}},
]
result = await runner.run(
{"name": "clarifier", "instructions": "Ask the user for clarification when needed."},
"Help me plan a trip",
tool_handlers=tool_handlers,
client_tools=client_tools,
)
11. Session (Multi-Turn)
A Session wraps an Agent with auto-managed conversation_id and accumulates turn results. Agent runs send persist: false, so the server does not retain turn content — instead, the session accumulates each turn's exchange client-side and replays the transcript with every subsequent turn, giving the model full conversation context.
create_session(agent, **options)
import asyncio
from matilda_agent_sdk import create_session
async def main():
session = create_session(
{"name": "tutor", "instructions": "You are a patient programming tutor. Explain concepts simply."}
)
print("Conversation ID:", session.conversation_id)
# Turn 1
r1 = await session.run("What is a closure in Python?")
print("Turn 1:", r1.final_output[:100], "...")
# Turn 2 — the session replays the accumulated transcript, so the model
# sees the previous exchange
r2 = await session.run("Can you show me a simple example?")
print("Turn 2:", r2.final_output[:100], "...")
# The session accumulates all turn results
print("Total turns:", len(session.turns))
print("Last turn stream ID:", session.last_turn.stream_id if session.last_turn else None)
asyncio.run(main())
Session class
session.run(prompt, **options)
Runs a turn and accumulates the result in session.turns. Options are the run() options (minus conversation_id); per-turn options merge with session defaults. Returns AgentRunResult.
session.stream(prompt, **options)
Streams a turn, yielding AgentEvent as they arrive. The result is accumulated in session.turns when the stream completes.
async for event in session.stream("Explain decorators in one paragraph."):
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
print("\n[Turns accumulated]:", len(session.turns))
print("[Final output cached]:", (session.last_turn.final_output[:60] + "...") if session.last_turn else "")
session.conversation_id
The auto-generated (or provided) conversation ID. Reused across all turns.
session.turns
A list[AgentRunResult] — one per completed turn.
session.last_turn
The most recent AgentRunResult, or None if no turns have run.
Session options
| Option | Type | Default | Description |
|---|---|---|---|
runner |
Runner | None |
shared default | Custom runner instance. |
client |
MatildaClient | None |
— | Explicit client (wraps it in a fresh Runner). Ignored when runner is set. |
conversation_id |
str | None |
auto-generated | Explicit conversation ID. |
purpose |
AgentPurpose | None |
— | Fallback purpose when agent is a dict. |
response_mode |
str | None |
— | Session-level response mode override. |
metadata |
dict | None |
{} |
Session-level metadata merged into every turn. |
tool_handlers |
ToolHandlers | None |
— | Session-level tool handlers. |
max_tool_roundtrips |
int |
25 |
Session-level roundtrip cap. |
Metadata passthrough
Metadata can be set at multiple levels: Agent construction, Session construction, or per-call. Per-call metadata merges with (and overrides) session defaults.
from matilda_agent_sdk import create_session
def tutor_instructions(ctx):
env = ctx["metadata"].get("env", "unknown")
user = ctx["metadata"].get("user", "anonymous")
return f"Environment: {env}. User: {user}."
async def main():
session = create_session(
{"name": "helper", "instructions": tutor_instructions},
metadata={"env": "staging", "user": "demo-user"},
)
# Session-level metadata is used by default
await session.run("Who am I?")
# Per-call metadata overrides session defaults
await session.run("Who am I now?", metadata={"user": "admin"})
# → env=staging (from session), user=admin (overridden per-call)
Custom runner
A Session can use a custom Runner for dependency injection in tests or isolated configuration:
import asyncio
from matilda_agent_sdk import Runner, Session
async def main():
my_runner = Runner()
session = Session(
{"name": "custom-runner-agent", "instructions": "Be brief."},
runner=my_runner,
)
result = await session.run("What is 2 + 2?")
asyncio.run(main())
12. Stream Resume
Durable streaming lets a client disconnect mid-stream and resume from where it left off. The server buffers events, keyed by a stream_id advertised at stream start.
resume_agent_stream(stream_id, last_event_id=None, *, on_event=None, ...)
Resumes a previously detached stream by replaying buffered events from last_event_id. Returns the accumulated AgentRunResult.
import asyncio
from matilda_agent_sdk import resume_agent_stream
async def main():
result = await resume_agent_stream(
saved_stream_id,
saved_last_event_id,
on_event=lambda event: (
print(event.delta, end="", flush=True)
if event.type == "response.output_text.delta"
else None
),
)
print("Resumed output:", result.final_output)
Parameters
| Parameter | Type | Description |
|---|---|---|
stream_id |
str |
The stream ID from the stream.started event (or result.stream_id). |
last_event_id |
str | None |
The last cursor received (from the response.cursor event or result.last_event_id). Omit to replay from the start. |
on_event |
Callable[[AgentEvent], None] | None |
Event handler callback. |
client |
MatildaClient | None |
Explicit client. Defaults to the shared default. |
access_token |
str | None |
Override access token. |
fingerprint |
str | None |
Device fingerprint. |
stall_timeout_ms |
int | None |
SSE stall watchdog timeout (default 45_000). |
Returns AgentRunResult.
401 auto-refresh
If the resume request returns 401 and the client has a get_token provider, the SDK automatically refreshes the token and retries once.
Full resume example
import asyncio
from matilda_agent_sdk import resume_agent_stream, stream
async def main():
stream_id = None
last_event_id = None
received_text = ""
# Start streaming — capture IDs for potential resume
async for event in stream({"name": "resumable-agent"}, "Tell me a fact."):
if event.type == "stream.started":
stream_id = event.stream_id
if event.type == "response.cursor":
last_event_id = event.last_event_id
if event.type == "response.output_text.delta":
received_text += event.delta
print(event.delta, end="", flush=True)
print(f"\n[Stream completed — stream_id: {stream_id}, cursor: {last_event_id}]")
# Later — resume from the last cursor if the stream was interrupted
if stream_id:
result = await resume_agent_stream(
stream_id,
last_event_id,
on_event=lambda event: (
print(event.delta, end="", flush=True)
if event.type == "response.output_text.delta"
else None
),
)
print(f"\n[Resumed — output: {result.final_output[:60]}...]")
asyncio.run(main())
13. Files
The Runner exposes a files resource (the client SDK's FilesResource) for uploading and retrieving files. Uploaded files can be attached to agent runs via file_ids.
runner.files.upload(file, ...)
Uploads a single file by path. Files at or above the chunked threshold use the parallel multipart protocol; smaller files use single-shot upload.
async def main():
result = await runner.files.upload(
"hello.txt",
content_type="text/plain",
on_progress=lambda pct: print(f"Upload: {pct}%"),
)
print(f"File ID: {result.file_id}, Status: {result.status}")
Parameters
| Parameter | Type | Default | Description |
|---|---|---|---|
file |
str | Path |
(required) | Path to the file to upload. |
content_type |
str |
'application/octet-stream' |
MIME type. |
filename |
str | None |
path basename | Override the reported filename. |
on_progress |
Callable[[int], None] | None |
— | Progress callback (0–100). |
ephemeral |
bool |
False |
Upload for an incognito chat: the server deletes the file 24 hours after upload, and only incognito turns may attach it. |
Returns FileComplete:
@dataclass
class FileComplete:
file_id: str
status: str # 'pending' | 'scanning' | 'processing' | 'ready' | 'failed' | 'rejected'
runner.files.upload_many(files, *, file_concurrency=3, ...)
Uploads multiple files in parallel. One file's failure does not abort the others — per-file outcomes arrive in the result list as FileComplete or UploadError.
from matilda_client import UploadError
async def main():
results = await runner.files.upload_many(["doc1.txt", "doc2.txt"])
for i, result in enumerate(results):
if isinstance(result, UploadError):
print(f"File {i} failed: {result}")
else:
print(f"File {i}: {result.file_id} ({result.status})")
Returns list[FileComplete | UploadError].
runner.files.retrieve(file_id)
Retrieves metadata for a previously uploaded file.
file = await runner.files.retrieve("file-abc123")
print(f"{file['filename']} — {file['status']} ({file['sizeBytes']} bytes)")
Returns a dict (a FileAttachment: id, filename, contentType, sizeBytes, status, optional extractedText, thumbnailUrl, localUri, failureReason, createdAt).
Using files in agent runs
Upload a file, then reference its file_id in an agent run:
async def main():
upload = await runner.files.upload("report.txt", content_type="text/plain")
result = await runner.run(
{"name": "analyst", "purpose": "analysis", "instructions": "Summarise the report."},
"What are the key findings?",
file_ids=[upload.file_id],
)
print(result.final_output)
14. Conversations
The Runner exposes a conversations resource for listing, retrieving, renaming, and providing feedback on conversations. Resources return plain dict objects using the server's camelCase wire keys.
Note: Agent runs send
persist: falseby default, so they do not appear in the Matilda web app's chat history. The conversations resource accesses conversations created by other clients (e.g. the web app). If you need agent runs to appear in chat history, you would need to override thepersistflag — but this is not exposed as a public option in the agent SDK.
runner.conversations.list(...)
Lists conversations with pagination.
async def main():
result = await runner.conversations.list(limit=20, offset=0)
for conv in result["conversations"]:
print(f"{conv['id']}: {conv['title']} (updated {conv['updatedAt']})")
| Parameter | Type | Description |
|---|---|---|
limit |
int | None |
Maximum number of conversations to return. |
offset |
int | None |
Pagination offset. |
Returns a dict (ConversationListResponse: conversations, total, limit, offset).
runner.conversations.retrieve(conversation_id)
Retrieves a full conversation thread with all messages.
conv = await runner.conversations.retrieve("conv-123")
for msg in conv["messages"]:
print(f"[{msg['role']}] {msg['content']}")
Returns a dict — the summary shape plus a messages list.
runner.conversations.update(conversation_id, *, title=...)
Updates a conversation's metadata (currently only title).
await runner.conversations.update("conv-123", title="My Chat About AI")
Returns None.
runner.conversations.set_message_feedback(conversation_id, message_id, feedback)
Sets thumbs-up or thumbs-down feedback on a specific message.
await runner.conversations.set_message_feedback("conv-123", "msg-456", "positive")
| Parameter | Type | Description |
|---|---|---|
conversation_id |
str |
The conversation containing the message. |
message_id |
str |
The message to rate. |
feedback |
'positive' | 'negative' |
The feedback value. |
Returns a dict (e.g. {"ok": True}).
15. Feedback
runner.feedback.report_bug(...)
Files a bug report from your application. Reports land as GitHub issues in a private feedback repository — titled [SDK] <title> and labelled sdk-feedback — so they route directly to the team maintaining the SDK.
The SDK automatically stamps the report with its package name (matilda-agent-sdk), package version, and the Python runtime version, so you usually only need title and description. Pass the optional fields to override or supply anything else.
await runner.feedback.report_bug(
title="runner.stream() hangs after a client tool handler raises",
description="The run coroutine never finishes when a tool handler raises asynchronously.",
reproduction="Register a tool whose handler raises after an await, then call runner.stream()",
)
Parameters
| Parameter | Type | Description |
|---|---|---|
title |
str |
One-line summary of the bug (required, max 200 chars). |
description |
str |
What went wrong (required, max 4000 chars). |
reproduction |
str | None |
Optional steps to reproduce (max 4000 chars). |
package |
str | None |
Reporting package name — defaults to this SDK's package name. |
package_version |
str | None |
Reporting package version — defaults to this SDK's version. |
platform |
'web' | 'ios' | 'android' | 'unknown' | None |
Client platform. |
app_version |
str | None |
Your application's version string. |
idempotency_key |
str | None |
Optional key (max 128 chars) so retries don't create duplicate issues. |
Returns a dict (e.g. {"feedbackId": ..., "acknowledgedAt": ...}).
17. Error Handling
MatildaAgentRunError
Raised on HTTP-level failures (non-2xx response from the server).
class MatildaAgentRunError(MatildaError):
status: int # HTTP status code
response_text: str # Raw response body
MatildaAgentStreamError
Raised by run() when the stream emits an error event (unless throw_on_stream_error=False). Carries the full AgentRunResult with whatever was collected before the error.
class MatildaAgentStreamError(MatildaError):
code: str # ChatErrorCode of the first error ('unknown' when empty)
errors: list[StreamErrorDetail]
result: AgentRunResult
from matilda_agent_sdk import MatildaAgentRunError, MatildaAgentStreamError
try:
await runner.run(agent, prompt)
except MatildaAgentStreamError as err:
print("Stream error code:", err.code)
print("Partial output before error:", err.result.final_output)
print("All errors:", err.errors)
except MatildaAgentRunError as err:
print("HTTP error status:", err.status)
print("Response body:", err.response_text)
SafetyReplaceError
Raised by stream_text(), run_text(), stream_object(), and run_object() when the server replaces the output via a safety filter. str(err) contains the replacement text (or empty string), and categories lists the safety categories.
class SafetyReplaceError(MatildaError):
categories: list[str]
MatildaObjectParseError
Raised by stream_object() / run_object() when the response cannot be parsed as JSON or fails pydantic validation — e.g. a truncated stream (see §9). raw holds the full response text; cause is the underlying json.loads or pydantic error. Safety replacement does not surface here — it raises SafetyReplaceError first.
class MatildaObjectParseError(MatildaError):
raw: str
cause: BaseException | str
Client SDK errors (re-exported)
The client SDK's error types are re-exported unchanged: MatildaError (shared base), MatildaAPIError (status, response_text), QuotaExceededError (structured 429 quota attributes), MatildaStreamError (code), MatildaContentTypeError (non-SSE 2xx from a streaming endpoint), and UploadError. AuthError (code, retryable) is importable from matilda_client.
Chat error codes (ChatErrorCode)
These codes are emitted via the ResponseError stream event and appear in AgentRunResult.errors (mirrored on StreamErrorDetail.code):
| Code | Description |
|---|---|
internal_error |
Server-side failure. |
upstream_unavailable |
The AI model is not responding. |
rate_limited |
Too many requests. |
content_blocked |
Safety filter blocked the content. |
stream_aborted |
The stream was interrupted before completion. |
deadline_exceeded |
The response did not finish before the deadline. |
context_too_large |
The conversation is too long for the model. |
invalid_request_error |
The request was not accepted. Sending it again unchanged fails the same way. |
url_read_incomplete |
A URL the server tried to read did not finish loading. |
request_budget_exceeded |
The request exceeded its server-side budget. |
stalled |
No SSE events for the configured stall window. |
unknown |
Unclassified error (old server without typed codes). |
Stall watchdog
The streaming parser arms an idle-event watchdog. If no SSE event arrives for stall_timeout_ms milliseconds, the stream is considered dead and aborted with a 'stalled' error.
- Default:
45_000ms (DEFAULT_AGENT_STALL_TIMEOUT_MS) - Disable: Pass
stall_timeout_ms=0(not recommended)
Retry behaviour
The SDK retries retryable errors within a single turn. Retries are controlled by max_retries (default: 0 — no retries; DEFAULT_MAX_RETRIES).
Retryable errors:
- HTTP 429 (rate limited), 408 (request timeout), 5xx (server errors)
- Network errors:
httpx.ConnectError,httpx.ConnectTimeout,httpx.ReadTimeout,httpx.WriteTimeout,httpx.PoolTimeout,httpx.RemoteProtocolError - SSE stall watchdog (
stallederror code) TimeoutError
Retry delay: Exponential backoff with jitter (1 s base), capped at 30 seconds.
Important: Retries only happen when no text has been yielded to the consumer yet. Retrying past that point would replay text the caller already has.
When a retry occurs, a TurnRetrying event is emitted:
async for event in runner.stream(agent, prompt, max_retries=3):
if event.type == "turn.retrying":
print(f"Retry {event.attempt}/{event.max_retries} in {event.delay_ms}ms: {event.error['message']}")
Error handling example
import asyncio
from matilda_agent_sdk import (
MatildaAgentRunError,
MatildaAgentStreamError,
Runner,
SafetyReplaceError,
)
async def main():
runner = Runner()
try:
text = await runner.run_text(agent, "Hello!")
print(text)
except MatildaAgentStreamError as err:
print(f"Stream error: {err.code} — {err}")
print("Partial output:", err.result.final_output)
except MatildaAgentRunError as err:
if err.status == 401:
print("Session expired — re-authenticate.")
elif err.status == 429:
print("Rate limited — slow down.")
else:
print(f"API error {err.status}: {err.response_text}")
except SafetyReplaceError as err:
print(f"Safety filter: {', '.join(err.categories)}")
asyncio.run(main())
18. Multi-Agent Patterns
The SDK has no built-in orchestrator — multi-agent emerges from composition. The Runner is your execution primitive, and standard Python patterns (chaining, asyncio.gather, tool-based delegation) build the architecture.
Pattern 1: Sequential pipeline
Chain run() calls, feeding each agent's output to the next. Each stage has a single responsibility.
import asyncio
from matilda_agent_sdk import Agent, Runner
async def main():
runner = Runner()
researcher = Agent(
name="researcher",
purpose="analysis",
instructions="Produce a structured list of key facts for a blog post. Bullet points only.",
)
writer = Agent(
name="writer",
purpose="general",
instructions="Given research notes, write an engaging blog post draft under 400 words.",
)
editor = Agent(
name="editor",
purpose="general",
instructions="Polish the draft for clarity, grammar, and flow. Return the full revised post.",
)
topic = "Why developers are adopting AI coding assistants"
# Stage 1 → 2 → 3
research = await runner.run(researcher, f"Research this topic: {topic}")
draft = await runner.run(writer, research.final_output)
edited = await runner.run(editor, draft.final_output)
print(edited.final_output)
# Total token usage across the pipeline
total_tokens = sum(
r.usage.output_tokens for r in (research, draft, edited) if r.usage
)
print(f"Total output tokens: {total_tokens}")
asyncio.run(main())
Pattern 2: Parallel fan-out / fan-in
Run multiple specialist agents concurrently with asyncio.gather, then feed their outputs to a synthesiser.
import asyncio
from matilda_agent_sdk import Agent, Runner
async def main():
runner = Runner()
security_reviewer = Agent(
name="security-reviewer",
purpose="analysis",
instructions="Review code for vulnerabilities. Report only security issues.",
)
performance_reviewer = Agent(
name="performance-reviewer",
purpose="analysis",
instructions="Review code for efficiency. Report only performance issues.",
)
synthesiser = Agent(
name="synthesiser",
purpose="analysis",
instructions="Given reviews from multiple reviewers, produce a prioritised action list.",
)
code = "def get_user_data(user_id, db): ..."
# Fan-out: two reviewers analyse concurrently
security, performance = await asyncio.gather(
runner.run(security_reviewer, f"Review this code:\n```python\n{code}\n```"),
runner.run(performance_reviewer, f"Review this code:\n```python\n{code}\n```"),
)
# Fan-in: synthesiser merges the reviews
combined_input = "\n".join(
[
"## Security Review", security.final_output,
"## Performance Review", performance.final_output,
]
)
synthesis = await runner.run(synthesiser, combined_input)
print(synthesis.final_output)
asyncio.run(main())
Pattern 3: Router / delegator
A triage agent receives queries and decides which specialist to invoke. Each specialist is exposed as a client tool — when the agent calls a tool, the SDK handler runs the specialist agent via run() and returns its output.
import asyncio
from matilda_agent_sdk import Agent, Runner, ToolResult
runner = Runner()
billing_specialist = Agent(
name="billing-specialist",
instructions="You are a billing support specialist.",
)
technical_specialist = Agent(
name="technical-specialist",
instructions="You are a technical support specialist. Include code examples when relevant.",
)
triage_agent = Agent(
name="triage",
purpose="general",
instructions="You are a customer support triage specialist.",
)
triage_tools = [
{
"name": "ask_billing_specialist",
"description": "Route billing questions to the billing specialist.",
"parameters": {"type": "object", "properties": {"question": {"type": "string"}}, "required": ["question"]},
},
{
"name": "ask_technical_specialist",
"description": "Route technical questions to the technical specialist.",
"parameters": {"type": "object", "properties": {"question": {"type": "string"}}, "required": ["question"]},
},
]
async def ask_billing_specialist(args, ctx):
result = await runner.run(billing_specialist, f"Answer: {args['question']}")
return ToolResult(content=result.final_output)
async def ask_technical_specialist(args, ctx):
result = await runner.run(technical_specialist, f"Answer: {args['question']}")
return ToolResult(content=result.final_output)
tool_handlers = {
"ask_billing_specialist": ask_billing_specialist,
"ask_technical_specialist": ask_technical_specialist,
}
async def main():
triage_prompt = "\n".join(
[
"A customer asked:",
'"I\'m getting a 401 Unauthorized error when calling the /api/chat endpoint."',
"You MUST forward this to a specialist by calling a tool.",
"After the specialist responds, relay their answer.",
]
)
await runner.run(
triage_agent,
triage_prompt,
tool_handlers=tool_handlers,
client_tools=triage_tools,
max_tool_roundtrips=6,
callbacks={
"on_tool_call": lambda name, args: print(f"Triage chose: {name}"),
"on_tool_result": lambda name, result, is_error: print("Specialist responded."),
"on_token": lambda delta: print(delta, end="", flush=True),
},
)
asyncio.run(main())
Tip: Keep the triage agent's instructions short — put the routing rules in the task prompt. If routing rules are in the
instructionsfield, the server's Auto-mode system prompt may interpret them as a prompt-injection attempt rather than operating instructions.
19. Suggested Tasks / Recipes
Recipe 1: CLI agent with device-flow auth and streaming
A complete interactive CLI agent with device-flow auth, streaming, and multi-turn sessions.
import asyncio
from pathlib import Path
from matilda_agent_sdk import MatildaClient, Runner, create_session
from matilda_client import create_file_token_store
async def main():
token_store = create_file_token_store(Path.home() / ".matilda" / "tokens.json")
runner = Runner(MatildaClient(base_url="https://matilda.com/api"))
# Try to restore persisted tokens, fall back to interactive login
restored = await runner.auth.restore(
client_id="matilda-code",
token_store=token_store.store,
token_lock=token_store.lock,
)
if not restored:
print("Starting device flow authentication...")
await runner.auth.login_with_device_flow(
client_id="matilda-code",
token_store=token_store.store,
token_lock=token_store.lock,
)
print("Authenticated!")
session = create_session(
{"name": "cli-assistant", "purpose": "general", "instructions": "Be helpful, concise, and friendly."},
runner=runner,
)
while True:
user_input = await asyncio.to_thread(input, "\nYou: ")
if not user_input.strip() or user_input.lower() == "exit":
break
print("Agent: ", end="", flush=True)
async for event in session.stream(user_input):
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
print()
asyncio.run(main())
Recipe 2: Client tools (weather + calculator)
An agent that uses client tools to answer questions requiring external data.
import asyncio
import json
from matilda_agent_sdk import MatildaClient, Runner, ToolResult
async def get_weather(args, ctx):
city = args.get("city", "unknown")
# In reality, call a weather API
return ToolResult(content=json.dumps({"city": city, "temp": 22, "condition": "sunny"}))
async def calculate(args, ctx):
try:
# NOTE: eval is a stand-in for a real expression parser — never eval
# untrusted input in production.
result = eval(str(args["expression"]), {"__builtins__": {}}, {})
return ToolResult(content=str(result))
except Exception:
return ToolResult(content="Invalid expression", is_error=True)
client_tools = [
{"name": "get_weather", "description": "Get current weather for a city", "parameters": {"type": "object"}},
{"name": "calculate", "description": "Evaluate a math expression", "parameters": {"type": "object"}},
]
tool_handlers = {"get_weather": get_weather, "calculate": calculate}
async def main():
runner = Runner(MatildaClient(base_url="https://matilda.com/api"))
if not await runner.auth.get_tokens():
await runner.auth.login_with_device_flow(client_id="matilda-code")
async for event in runner.stream(
{"name": "assistant", "instructions": "Use the available tools to answer."},
"What is the weather in Sydney, and what is 15 * 23?",
tool_handlers=tool_handlers,
client_tools=client_tools,
):
if event.type == "client.tool.requested":
print(f"→ {event.name}({json.dumps(event.args)})")
if event.type == "client.tool.result":
print(f"← {event.result}")
if event.type == "response.output_text.delta":
print(event.delta, end="", flush=True)
asyncio.run(main())
Recipe 3: Multi-agent code review pipeline
Sequential pipeline: security review → performance review → synthesis.
import asyncio
from matilda_agent_sdk import Agent, MatildaClient, Runner
async def main():
runner = Runner(MatildaClient(base_url="https://matilda.com/api"))
if not await runner.auth.get_tokens():
await runner.auth.login_with_device_flow(client_id="matilda-code")
security = Agent(
name="security",
purpose="analysis",
instructions="Review for vulnerabilities. Be specific.",
)
performance = Agent(
name="performance",
purpose="analysis",
instructions="Review for efficiency. Be specific.",
)
synthesiser = Agent(
name="synthesiser",
purpose="analysis",
instructions="Merge reviews into a prioritised action list. Use 🔴 🟡 🟢 priority.",
)
code = 'def get_user_data(user_id, db): query = "SELECT * FROM users WHERE id = " + user_id'
sec, perf = await asyncio.gather(
runner.run(security, f"Review:\n```python\n{code}\n```"),
runner.run(performance, f"Review:\n```python\n{code}\n```"),
)
combined = f"## Security\n{sec.final_output}\n\n## Performance\n{perf.final_output}"
result = await runner.run(synthesiser, combined)
print(result.final_output)
asyncio.run(main())
Recipe 4: Dynamic instructions with metadata
An agent whose instructions adapt based on runtime metadata.
import asyncio
from matilda_agent_sdk import Agent, run
def reviewer_instructions(ctx):
lang = ctx["metadata"].get("language", "auto-detect")
strictness = ctx["metadata"].get("strictness", "normal")
return "\n".join(
[
"Review the following code.",
f"Language: {lang}",
f"Strictness: {strictness}",
"Focus on: correctness, security, and readability.",
"Cite line numbers when possible.",
]
)
agent = Agent(name="code-reviewer", purpose="code", instructions=reviewer_instructions)
async def main():
result = await run(
agent,
"def add(a, b): return a + b",
metadata={"language": "Python", "strictness": "strict"},
)
print(result.final_output)
asyncio.run(main())
Recipe 5: Stream resume with disconnect recovery
Start a stream, simulate a disconnect, and resume from the last cursor.
import asyncio
from matilda_agent_sdk import resume_agent_stream, stream
async def main():
stream_id = None
last_event_id = None
received_text = ""
print("Starting stream...")
try:
async for event in stream({"name": "writer"}, "Write a very long essay about Australia."):
if event.type == "stream.started":
stream_id = event.stream_id
if event.type == "response.cursor":
last_event_id = event.last_event_id
if event.type == "response.output_text.delta":
received_text += event.delta
# Simulate disconnect after 500 chars
if len(received_text) > 500:
print("\n--- Simulated disconnect ---")
break
except Exception as err:
print(f"Disconnected: {err}")
print(f"Received {len(received_text)} chars before disconnect.")
# Resume from the last cursor
if stream_id:
print("\n--- Resuming ---")
result = await resume_agent_stream(
stream_id,
last_event_id,
on_event=lambda event: (
print(event.delta, end="", flush=True)
if event.type == "response.output_text.delta"
else None
),
)
print(f"\nTotal output: {len(result.final_output)} chars")
asyncio.run(main())
Recipe 6: Custom Runner with file token store
A standalone Runner with persistent auth for CLI or long-running service use.
import asyncio
from pathlib import Path
from matilda_agent_sdk import MatildaClient, Runner
from matilda_client import create_file_token_store
async def main():
token_path = Path.home() / ".matilda" / "tokens.json"
token_store = create_file_token_store(token_path)
runner = Runner(MatildaClient(base_url="https://matilda.com/api"))
# Restore persisted tokens or login interactively
restored = await runner.auth.restore(
client_id="matilda-code",
token_store=token_store.store,
token_lock=token_store.lock,
)
if not restored:
await runner.auth.login_with_device_flow(
client_id="matilda-code",
token_store=token_store.store,
token_lock=token_store.lock,
)
# Runner is ready — tokens auto-refresh on 401
result = await runner.run(
{"name": "helper", "instructions": "Be concise."},
"What is the capital of Australia?",
)
print(result.final_output)
# Later: logout clears the token store
# await runner.auth.logout()
asyncio.run(main())
20. Exports Reference
Classes
| Export | Description |
|---|---|
Agent |
Named persona with static or dynamic instructions. |
Runner |
Main execution class. Holds auth, files, conversations, feedback resources. |
Session |
Multi-turn conversation wrapper with auto-managed conversation_id. |
AgentAuth |
Managed auth: login, restore, token refresh, logout (via runner.auth). |
MatildaAgentRunError |
HTTP-level failure (status, response_text). |
MatildaAgentStreamError |
SSE stream error (code, errors, partial result). |
MatildaClient |
Underlying API client (re-exported from matilda-client). |
MatildaConfig |
Client config dataclass (re-exported). |
FilesResource |
File upload/retrieve resource (re-exported; also at runner.files). |
ConversationsResource |
Conversation list/retrieve/update/feedback resource (re-exported). |
FeedbackResource |
Bug-report resource, agent-stamped (report_bug; also at runner.feedback). |
Errors (re-exported)
| Export | Description |
|---|---|
MatildaError |
Shared base class. |
MatildaAPIError |
Non-2xx HTTP response. |
QuotaExceededError |
429 quota error with structured attributes. |
MatildaStreamError |
Terminal stream error event (code). |
MatildaContentTypeError |
Streaming endpoint answered 2xx with a non-SSE body (proxy/gateway misconfiguration). |
SafetyReplaceError |
Safety filter replacement (categories). |
MatildaObjectParseError |
Structured output parse/validation failure (raw, cause). |
UploadError |
File upload failure. |
Functions
| Export | Description |
|---|---|
run(agent, prompt, **options) |
Run an agent turn via the shared default runner. Returns AgentRunResult. |
stream(agent, prompt, **options) |
Stream agent events. Returns an async generator of AgentEvent. |
stream_text(agent, prompt, **options) |
Stream text deltas. Returns an async generator of str. |
run_text(agent, prompt, **options) |
Run and return just the text. Returns str. |
stream_object(agent, prompt, schema, **options) |
Stream with structured output. Async generator of AgentObjectEvent. |
run_object(agent, prompt, schema, **options) |
Run with structured output. Returns AgentObjectResult. |
create_session(agent, **options) |
Create a Session. |
build_agent_chat_request(**options) |
Build a chat request dict without executing. Async. |
resume_agent_stream(stream_id, last_event_id=None, **options) |
Resume a detached durable stream. Returns AgentRunResult. |
configure_default_client(client) |
Install the shared default MatildaClient. |
Constants
| Export | Value | Description |
|---|---|---|
DEFAULT_AGENT_PURPOSE |
'code' |
Default agent purpose. |
DEFAULT_AGENT_STALL_TIMEOUT_MS |
45_000 |
Default SSE stall watchdog timeout. |
DEFAULT_MAX_TOOL_ROUNDTRIPS |
25 |
Default maximum tool roundtrips. |
DEFAULT_MAX_RETRIES |
0 |
Default maximum retries. |
AGENT_SDK_NAME / AGENT_SDK_LANGUAGE |
— | SDK provenance constants stamped on requests. |
DSML_TOOL_CALL_OPEN / DSML_TOOL_CALL_CLOSE |
— | DSML tool-call tag constants. |
Types
| Export | Description |
|---|---|
AgentPurpose |
Literal['code', 'analysis', 'general'] |
AgentInstructions |
str | Callable[[dict], str | Awaitable[str]] |
AgentEvent |
Union of the 21 agent stream event dataclasses. |
AgentObjectEvent |
AgentEvent plus the terminal ObjectEvent. |
ObjectEvent[T] |
Terminal structured-output event (type="object"; re-exported from matilda-client). |
AgentRunResult |
Result of an agent run (dataclass). |
AgentObjectResult[T] |
AgentRunResult plus the validated object. |
ToolHandler |
(args, ctx) -> Awaitable[ToolResult] |
ToolHandlers |
dict[str, ToolHandler] |
ToolExecutionContext |
Context passed to tool handlers (tool_call_id). |
ToolResult |
{content, is_error} dataclass. |
StreamErrorDetail |
Alias of matilda_client.StreamErrorInfo — {code, message} pair on AgentRunResult.errors. Shared class, so isinstance() holds across both packages. |
UsageSummary |
Alias of matilda_client.Usage — token usage summary on AgentRunResult. Shared class, so isinstance() holds across both packages. |
UsageEvent |
response.usage event (re-exported from matilda-client). |
EventCallback / ObjectEventCallback |
Catch-all event callback types. |
MatildaClientProtocol |
Protocol a Runner client's object must satisfy. |
RunStarted / StreamStarted |
Agent lifecycle events. |
ClientToolRequested / ClientToolExecuting / ClientToolResult / ClientToolRoundtrip |
Client-tool events. |
TurnRetrying |
Retry notice event. |
OutputTextDelta / OutputTextReplace / StatusEvent / QueuedEvent / ToolCallStarted / ToolCallProgress / ToolCallCompleted / GenerationStatus / CursorEvent / Truncated / Completed / ResponseError |
Chat events re-exported from matilda-client so consumers dispatch on one set of types. |
Auth imports
Auth types (AuthError, StorageAdapter, TokenManager, TokenSet, MemoryStorage / memory_storage, AuthServerMetadata, login-flow functions, create_file_token_store, DEFAULT_SUCCESS_REDIRECT) live in matilda_client / matilda_client.auth / matilda_client.auth_local — import them from there. See the client SDK exports.
Built and maintained by Maincode — Australia's sovereign AI company.