Matilda · Check the details

Matilda Python client SDK

Full API reference for the matilda-client Python package covering chat, streaming, durable streams, auth, files, conversations, feedback, System One.

Applies to: Python client SDK · 0.4.0 (stable)

View as Markdown

Version 0.4.0

A small, self-contained public Python SDK for building Matilda clients. Async-first (built on httpx), fully typed, with typed stream events and zero runtime dependencies beyond httpx and the standard library. Requires Python ≥ 3.12.

The SDK follows the OpenAI client shape where it helps: constructor config, resource groups, request options, typed API errors, and async-iterable streaming. It does not expose model or provider selection — Matilda core owns routing, safety, resumable SSE, server-side tool execution, and policy.

What's included

  • Chat — non-streaming, full-event streaming, text-only streaming, schema-constrained structured output, and durable stream resume
  • Conversations — list, retrieve, rename, and set message feedback
  • Files — upload (single and parallel), retrieve metadata
  • Feedback — report harmful content and submit response feedback
  • System One — typed state evaluation with the matilda-jev structured-decision model
  • Auth — managed PKCE browser login, RFC 8628 device flow, token refresh, and persistent token storage
  • API Keys — create, list, and revoke mc_live_ API keys via SDK methods or the matilda-key CLI

What's NOT included

  • Local tool-execution loop — for client-side tool execution (client_tools, local approval/sandbox loops, tool-result continuation), use the Matilda agent SDK
  • Session class — multi-turn conversations are managed via conversation_id (see §16. Multi-Turn Conversations)
  • Devices — push-notification device management is not yet exposed on the Python client surface

Table of Contents

  1. Installation
  2. Quick Start
  3. Configuration
  4. Authentication
  5. Chat — Non-Streaming
  6. Chat — Streaming (Full Events)
  7. Chat — Text-Only Helpers
  8. Chat — Structured Output
  9. Chat — Durable Streaming (Resume)
  10. Conversations (History)
  11. Files
  12. Feedback
  13. System One (matilda-jev)
  14. Error Handling
  15. Multi-Turn Conversations
  16. Suggested Tasks / Recipes

1. Installation

pip install matilda-client==0.4.0
# or: uv add matilda-client

Requires Python ≥ 3.12.

pydantic is an optional dependency — install it alongside the SDK if you want schema-validated structured output (§8). The SDK detects pydantic models by duck-typing, so any version works:

pip install pydantic

Import

from matilda_client import MatildaClient

Auth modules

The standalone auth helpers live in two submodules — the transport-agnostic OAuth protocol core, and the local-machine adapters (loopback receiver, file token store, managed login flows):

from matilda_client.auth import (
    create_token_manager,
    fetch_auth_server_metadata,
    memory_storage,
)
from matilda_client.auth_local import (
    create_file_token_store,
    create_loopback_receiver,
    run_device_login_flow,
    run_loopback_login_flow,
)

2. Quick Start

Minimal: send a message

import asyncio
import os

from matilda_client import MatildaClient


async def main():
    async with MatildaClient(token=os.environ["MATILDA_ACCESS_TOKEN"]) as client:
        response = await client.chat.create(input="Summarize this thread.")
        print(response.output_text)


asyncio.run(main())

Note: The token option above is fine for quick testing, but for production use we recommend the managed auth flows (login_with_browser or login_with_device_flow) which auto-wire a TokenManager with automatic token refresh. See §4. Authentication.

Minimal: streaming

import os

from matilda_client import MatildaClient


async def main():
    async with MatildaClient(token=os.environ["MATILDA_ACCESS_TOKEN"]) as client:
        async for event in client.chat.stream(input="Write a short plan."):
            if event.type == "response.output_text.delta":
                print(event.delta, end="", flush=True)

Authenticated: device flow + chat

import asyncio

from matilda_client import MatildaClient


async def main():
    async with MatildaClient() as client:
        # Authenticate via RFC 8628 device flow — prints a code to stderr
        await client.auth.login_with_device_flow(client_id="matilda-code")

        # Token is now managed automatically — no manual header wiring
        response = await client.chat.create(input="Hello, Matilda!")
        print(response.output_text)


asyncio.run(main())

3. Configuration

MatildaClient

client = MatildaClient(
    token="",                     # static Bearer JWT
    # keyword-only below:
    base_url=DEFAULT_BASE_URL,    # https://matilda.com/api
    get_token=None,               # async token provider
    config=None,                  # or pass a pre-built MatildaConfig
)
Parameter Type Default Description
token str "" A static access token. Use this for simple setups, or use get_token for managed refresh.
base_url str https://matilda.com/api The Matilda API base URL. Must be absolute for auth flows.
get_token TokenProvider None Async token provider called on every request (and once with force_refresh=True after a 401, before the single retry). A managed TokenManager is wired here automatically after client.auth.login_with_*.
config MatildaConfig None A pre-built config. When omitted, one is constructed from the other parameters.

MatildaConfig

A frozen dataclass — create a new client to change configuration.

Field Type Default Description
base_url str https://matilda.com/api The Matilda API base URL.
token str "" Static Bearer token.
get_token TokenProvider | None None Async token provider (see above).
timeout float 120.0 Request timeout in seconds.
api_version str MATILDA_CURRENT_API_VERSION API version sent via the X-Matilda-API-Version header.
chunk_size int 8388608 (8 MiB) Chunked-upload part size in bytes.
chunked_threshold int 16777216 (16 MiB) File size at/above which uploads switch to the chunked protocol.
part_concurrency int 4 Parallel parts per chunked upload.
max_part_retries int 3 Retries per upload part before the session is aborted.

Environment URLs

Environment Base URL
Production https://matilda.com/api

Instance isolation

Each MatildaClient instance holds its own independent config. Multiple instances in the same process are fully isolated — constructor options are scoped to that instance.

local = MatildaClient(base_url="http://localhost:3000/api")
prod = MatildaClient(base_url="https://matilda.com/api")

print(local.config.base_url)  # http://localhost:3000/api
print(prod.config.base_url)   # https://matilda.com/api

config (attribute)

Returns the current MatildaConfig.

cfg = client.config
print(cfg.base_url, cfg.token)

origin (config property)

The scheme+host of base_url — the origin where core-auth endpoints live. Auth flows resolve endpoints against config.origin, so base_url must be an absolute URL:

client.config.origin  # e.g. "https://matilda.com"

4. Authentication

The SDK provides a managed auth tier: on successful login, a TokenManager is auto-configured as the client's token provider. Every subsequent request automatically carries a managed access token with single-flight, skew-aware auto-refresh — and a 401 response triggers one force_refresh + retry before the failure surfaces.

OAuth client IDs

The following client IDs are registered in the Matilda OAuth client registry:

Client ID Use case Notes
matilda-code Public CLI / agent SDK PKCE + device flow. The default choice for most integrations.
matilda-desktop Desktop app (Electron) PKCE only (no device flow).
matilda-admin-mcp Admin MCP server Admin-scoped access.

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.

async def main():
    async with MatildaClient() as client:
        tokens = await client.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) instead of the hosted login page.
callback_port int | None random ephemeral Fixed loopback port. Recommended for FusionAuth redirect validation.
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. If omitted, caller handles browser opening.
success_redirect str DEFAULT_SUCCESS_REDIRECT URL the browser is 302-redirected to on success.
error_redirect str | None None URL for the error case; otherwise a bare 400 text response.
token_store StorageAdapter | None MemoryStorage() Custom token persistence.
token_lock TokenLock | None None Cross-process critical-section lock for token refresh (e.g. from create_file_token_store).
on_event Callable[[LoginFlowEvent], None] | None None Subscribe to login flow state events.
http httpx.AsyncClient | None None Override HTTP transport (testing, custom clients).

Returns

TokenSet — the token set from the login flow. The TokenManager is also auto-configured on the client instance.

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.

async def main():
    async with MatildaClient() as client:
        tokens = await client.auth.login_with_device_flow(
            client_id="matilda-code",
            on_event=lambda e: (
                print(f"Visit {e.verification_uri} and enter code: {e.user_code}")
                if e.type == "user_code"
                else None
            ),
        )

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 MemoryStorage() 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

auth.refresh_token(refresh_token, client_id)

Manually refresh an access token using a refresh token. This bypasses the TokenManager — use it only when you need raw token exchange.

tokens = await client.auth.refresh_token(old_refresh_token, "matilda-code")
Parameter Type Description
refresh_token str The refresh token to exchange.
client_id str OAuth client alias.

Returns TokenSet.

auth.get_tokens()

Returns the current token set from the managed TokenManager, or None if not authenticated.

tokens = await client.auth.get_tokens()
if tokens:
    print(f"Token expires at epoch ms: {tokens.expires_at}")

Returns TokenSet | None.

auth.logout()

Clears the token store, destroys the TokenManager, and disconnects the client's token provider.

await client.auth.logout()

Returns None.

Token persistence

By default, tokens are stored in memory (MemoryStorage()). For cross-process persistence (e.g. CLI sessions), use create_file_token_store:

from pathlib import Path

from matilda_client import MatildaClient, create_file_token_store


async def main():
    token_path = Path.home() / ".matilda" / "tokens.json"
    token_store = create_file_token_store(token_path)

    async with MatildaClient() as client:
        await client.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

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]: ...

Implement this (sync or async) to store tokens in a database, keychain, or any custom backend.

TokenManager

manager = TokenManager(
    token_endpoint="https://matilda.com/api/auth/oauth/token",
    client_id="matilda-code",
    store=...,
)

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

Also constructible via the create_token_manager(**kwargs) factory. The SDK auto-creates one on login. Refresh is single-flight per instance, evicts the session on invalid_grant, and re-reads the store inside the optional TokenLock so a process that lost a cross-process refresh race sees the winner's fresh token.

TokenSet

@dataclass(frozen=True)
class TokenSet:
    access_token: str
    expires_at: int              # epoch milliseconds
    refresh_token: str | None
    id_token: str | None         # OIDC id_token when 'openid' scope is granted

TokenSet.to_json_dict() / TokenSet.from_json_dict(...) serialise with camelCase keys — the on-disk JSON contract is shared with the TypeScript SDK, so token stores can be exchanged between the two.

AuthError class

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

Three frozen dataclasses, each 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

Standalone auth helpers

The matilda_client.auth module provides the raw PKCE and device-flow protocol helpers without the managed TokenManager. The managed client.auth.* methods cover most integrations, but these remain available for integrators who need direct protocol access:

Function Description
fetch_auth_server_metadata(issuer) RFC 8414 metadata discovery.
begin_login(authorization_endpoint, ...) Stateless "begin" half of a redirect/BFF login. Returns BeginLoginResult.
complete_login(token_endpoint, ...) Stateless "complete" half — validates state, exchanges the code.
refresh_token_grant(token_endpoint, client_id, refresh_token) Raw token refresh. Returns TokenSet.
request_device_code(device_authorization_endpoint, ...) Start a device flow. Returns DeviceAuthorization.
poll_device_token(token_endpoint, ...) Poll until the user authorises or the code expires.
create_pkce_pair() Generate PKCE code_verifier + code_challenge (S256).
build_authorize_url(authorization_endpoint, ...) Construct the authorize URL.
assert_state(received, expected) Validate the OAuth state round-trip (CSRF guard).
random_state() High-entropy OAuth state value.
base64_url_encode(data) Padding-free base64url (RFC 4648 §5).

The matilda_client.auth_local module adds the local-machine adapters on top of the protocol core:

Function / class Description
run_loopback_login_flow(...) Headless loopback login (PKCE + RFC 8252 redirect).
run_device_login_flow(...) Headless device-flow login (RFC 8628).
create_loopback_receiver(...) RFC 8252 single-use loopback redirect receiver.
create_file_token_store(file_path) 0600 JSON file store with cross-process lock.
default_device_on_event(event) Default device-flow event handler (prints code to stderr).
memory_storage() / MemoryStorage In-memory StorageAdapter.
create_token_manager(**kwargs) Per-session token manager with single-flight refresh.

API Key Management

The SDK provides methods for managing mc_live_ API keys. These hit the same JWT-protected endpoints the developer dashboard uses — the session JWT from a prior login_with_browser() or login_with_device_flow() call is carried automatically by the TokenManager.

api_keys.create(...)

Mints a new API key. The secret is returned only at creation time — store it immediately.

async def main():
    async with MatildaClient() as client:
        await client.auth.login_with_device_flow(client_id="matilda-code")

        key = await client.api_keys.create(
            name="ci-runner",
            # scopes=["api:code"],                  # omit → server default
            # expires_at="2026-12-31T23:59:59Z",    # omit → never expires
        )

        print(key["secret"])      # mc_live_...  — shown only once
        print(key["keyPrefix"])   # mc_live_abcd
        print(key["id"])          # UUID for revocation
Parameters
Parameter Type Default Description
name str (required) Human-readable key name (1–80 chars).
scopes list[str] | None server default Permission scopes (e.g. ['api:code']).
expires_at str | None — ISO 8601 expiry timestamp. Omit for no expiry.

Returns a dict — the key metadata plus the one-time secret.

api_keys.list()

Lists all non-revoked API keys for the authenticated user. Secrets are never included — only the keyPrefix for identification.

keys = await client.api_keys.list()
for key in keys:
    status = "REVOKED" if key.get("revokedAt") else "ACTIVE"
    print(f"{key['keyPrefix']}  {key['name']}  {status}")

Returns list[dict].

api_keys.revoke(key_id)

Revokes a key by ID. The key immediately stops working for authentication.

await client.api_keys.revoke(key["id"])
Parameter Type Description
key_id str The key UUID (from create or list).

Returns a dict (the revoked key with revokedAt set).

Response shape

API key dicts use the server's camelCase wire keys:

# ApiKey dict:
{
    "id": "8f3a2b1c-...",
    "name": "ci-runner",
    "keyPrefix": "mc_live_abcd1234",
    "scopes": ["api:code"],
    "createdAt": "2026-08-18T10:30:00.000Z",
    "lastUsedAt": None,
    "revokedAt": None,
    "expiresAt": None,
    # "secret": "mc_live_..."  — create() only, shown exactly once
}

CLI — matilda-key

The package ships a matilda-key CLI that mints API keys via device-flow login. This is the easiest way for social-login (Google/Apple) users to get an API key without writing code.

Installation

The console script is installed with the package (on the venv's PATH), or run without installing via uvx:

matilda-key create-api-key --name "my-key"
# or, without installing:
uvx --from matilda-client matilda-key create-api-key --name "my-key"

Usage

matilda-key create-api-key --name <key-name> [--scopes ...] [--expires-at ...] [--api-base-url ...] [--client-id ...]
Flag Required Default Description
--name yes — API key name (1–80 chars).
--scopes no server default Comma-separated scopes (e.g. api:code,api:chat).
--expires-at no never ISO 8601 expiry date.
--api-base-url no https://matilda.com/api API base URL.
--client-id no matilda-code OAuth client ID / alias.

The MATILDA_API_BASE_URL environment variable is also honoured as a fallback for --api-base-url.

What happens when you run it

  1. Device-flow login — a verification URL and code are printed to stderr. Open the URL, sign in with Google or Apple, enter the code.
  2. Key minting — once authenticated, an API key is created from the session JWT.
  3. Output — key metadata (ID, name, prefix, scopes, expiry) is printed to stderr. The secret is printed to stdout.

The stdout/stderr separation is deliberate: the secret on stdout is clean and pipeable, while the login flow and metadata remain visible on the terminal via stderr.

Piping the secret

# Capture into an env var (login flow still visible on terminal):
MATILDA_API_KEY=$(matilda-key create-api-key --name "ci-runner")

# Pipe to a file:
matilda-key create-api-key --name "ci-runner" > /tmp/key.txt

# Use in CI:
export MATILDA_API_KEY="$(cat /tmp/key.txt)"

Full example

$ matilda-key create-api-key --name "ci-runner" --scopes api:code

Starting device-flow login...

  Open https://matilda.com/device and enter code: ABCD-1234

Login successful!

Minting API key "ci-runner"...

API key created successfully.
  ID:          8f3a2b1c-...
  Name:        ci-runner
  Prefix:      mc_live_abcd1234
  Scopes:      api:code
  Expires:     never
  Created at:  2026-08-18T10:30:00.000Z

Secret printed to stdout. Store it securely — it won't be shown again.

The secret (mc_live_...) is on stdout; everything else is on stderr.


5. Chat — Non-Streaming

chat.create(...)

Sends a chat message and returns the complete response. Internally this runs the stream and collects all events.

async def main():
    async with MatildaClient(token="...") as client:
        response = await client.chat.create(
            input="What is the capital of Australia?",
            conversation_id="conv-123",
            response_mode="instant",
        )

        print(response.output_text)
        print(response.usage)

Parameters

Parameter Type Default Description
input str | None — The user's message. Required if messages is not provided.
messages list[dict] | None — Explicit message array. Overrides input. Each message: {"role": "user" | "assistant", "content": str}.
conversation_id str | None — Associates this message with a conversation thread for multi-turn chat.
file_ids list[str] | None — File IDs to attach (from files.upload()).
response_mode str | None 'auto' Response depth: 'auto', 'instant', or 'deep'.
incognito bool | None None Incognito turn: the server consults no conversation history or memory for this turn and persists nothing — no transcript, memory stores, or insights. Per-turn flag: resend it on every turn of an incognito session, and pass the session's prior messages via messages since the server keeps no transcript to fall back on. Omitted from the request unless set.
response_schema str | None — Raw JSON Schema (as a string) to grammar-constrain the response to. Prefer chat.stream_object / chat.create_object, which convert a pydantic model for you (see §8. Chat — Structured Output).
access_token str | None — Override the client-level access token for this request.
fingerprint str | None — Device fingerprint for rate limiting.
stall_timeout_ms int | None 45_000 SSE stall watchdog timeout in ms. Pass 0 to disable.
on_event Callable[[ChatEvent], None] | None — Catch-all stream event hook — fires for every event. Only honoured by convenience methods that consume the stream for you (chat.create(), chat.create_object()); use chat.stream() when you want to process events yourself.

MatildaChatResponse

Field Type Description
output_text str The full assistant response text.
events list[ChatEvent] Every event emitted during the stream.
stream_id str | None Durable stream ID (from the response.created event).
last_event_id str | None Last Redis stream entry ID (for resume).
usage Usage | None Token usage data.
errors list[StreamErrorInfo] Any errors emitted during the stream.
truncated_reason str | None Why the response was truncated (e.g. 'max_tokens').
artifacts list[Artifact] Download cards for files generated server-side (document lane), in stream order.

Response modes

  • 'auto' — Server decides the optimal response depth.
  • 'instant' — Optimised for low latency.
  • 'deep' — Optimised for thoroughness.

6. Chat — Streaming (Full Events)

chat.stream(...)

An async generator that yields ChatEvent objects as they arrive over SSE. This is the full event stream — tool calls, usage, status changes, safety replacements, and more.

async def main():
    async with MatildaClient(token="...") as client:
        async for event in client.chat.stream(input="Explain quantum computing."):
            if event.type == "response.created":
                print(f"Stream started: {event.stream_id}")
            elif event.type == "response.output_text.delta":
                print(event.delta, end="", flush=True)
            elif event.type == "response.tool_call.started":
                print(f"\nTool: {event.tool}")
            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}")

Each event is a frozen dataclass with a type: ClassVar[str] discriminator, so you can dispatch on event.type (as above) or with isinstance(event, OutputTextDelta).

ChatEvent

A union of 15 event dataclasses:

ResponseCreated — type="response.created"

Emitted once at stream start with the durable stream ID.

@dataclass(frozen=True)
class ResponseCreated:
    stream_id: str
    # type = "response.created"

OutputTextDelta — type="response.output_text.delta"

A text chunk from the assistant.

@dataclass(frozen=True)
class OutputTextDelta:
    delta: str
    # type = "response.output_text.delta"

OutputTextReplace — type="response.output_text.replace"

The server replaced the output (e.g. safety filter). The content field holds the replacement text; categories lists the safety categories that triggered the replacement.

@dataclass(frozen=True)
class OutputTextReplace:
    content: str | None = None
    categories: list[str] | None = None
    # type = "response.output_text.replace"

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

@dataclass(frozen=True)
class ToolCallProgress:
    tool: str
    message: str
    # type = "response.tool_call.progress"

ToolCallCompleted — type="response.tool_call.completed"

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

GenerationStatus — type="response.generation_status"

Generation phase update.

@dataclass(frozen=True)
class GenerationStatus:
    phase: str
    # type = "response.generation_status"

UsageEvent — type="response.usage"

Token usage data for the turn. The usage field is a Usage dataclass:

@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 (Redis stream entry 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"

ArtifactEvent — type="response.artifact"

The server generated a file (rendered PDF, chart image, spreadsheet) and published a download card for it. The payload only carries client-safe fields — never internal storage paths. chat.create() also collects these into MatildaChatResponse.artifacts.

@dataclass(frozen=True)
class Artifact:
    artifact_id: str
    url: str  # authed BFF download route, never a presigned URL
    filename: str | None = None
    content_type: str | None = None
    size_bytes: int | None = None


@dataclass(frozen=True)
class ArtifactEvent:
    artifact: Artifact
    # type = "response.artifact"

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"

7. Chat — Text-Only 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.

chat.stream_text(...)

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_client import MatildaClient, SafetyReplaceError


async def main():
    async with MatildaClient(token="...") as client:
        try:
            async for chunk in client.chat.stream_text(input="Write a haiku."):
                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.

chat.create_text(...)

Non-streaming convenience that returns just the final output text. Safety replace is handled naturally — the replacement text is returned. Raises MatildaStreamError if the stream produced any error events.

text = await client.chat.create_text(input="What is 2 + 2?")
print(text)  # "4"

SafetyReplaceError

class SafetyReplaceError(MatildaError):
    categories: list[str]
    # args[0] (str(err)) = replacement content (or empty string)

8. Chat — Structured Output

Structured output constrains the model's response to a JSON Schema, server-side (grammar-constrained decoding), and then validates it client-side against your pydantic model. Pass a pydantic model class (or a plain JSON-schema dict), receive a validated object — no prompt engineering, no brittle JSON extraction. The SDK duck-types on model_json_schema, so any pydantic v2 version works and pydantic stays an optional dependency.

chat.stream_object(schema, *, input=...)

Streams exactly like chat.stream() — you receive every ChatEvent — plus one final ObjectEvent with the parsed, schema-validated object.

from pydantic import BaseModel


class Recipe(BaseModel):
    name: str
    prep_time_minutes: float
    ingredients: list[str]


async def main():
    async with MatildaClient(token="...") as client:
        async for event in client.chat.stream_object(
            Recipe,
            input="Give me a recipe for pavlova.",
        ):
            if event.type == "response.output_text.delta":
                print(event.delta, end="", flush=True)  # raw JSON streaming in
            if event.type == "object":
                recipe = event.object  # a validated Recipe instance
                print(f"\nValidated: {recipe.name}")

The final event:

@dataclass(frozen=True)
class ObjectEvent[T]:
    object: T  # your pydantic model (or parsed JSON for dict schemas)
    # type = "object"

chat.create_object(schema, *, input=...)

Non-streaming convenience. Returns a MatildaObjectResponse — everything chat.create() returns, plus the validated object.

class Invoice(BaseModel):
    total: float
    currency: str


async def main():
    async with MatildaClient(token="...") as client:
        response = await client.chat.create_object(
            Invoice,
            input="Extract the invoice total: $1,250.00 AUD due 30 Sep.",
            conversation_id=conversation_id,
        )

        print(response.object.total)      # 1250.0 (float)
        print(response.object.currency)   # "AUD" (str)
        print(response.output_text)       # raw JSON text as returned

MatildaObjectResponse

Extends MatildaChatResponse with one additional field:

Field Type Description
object T | None The response text parsed as JSON and validated against your schema (a pydantic model instance for model classes; the parsed JSON for plain dict schemas).

Raw JSON Schema via response_schema

If you don't want pydantic validation, pass a stringified JSON Schema directly as response_schema on any chat call:

import json


async def main():
    async with MatildaClient(token="...") as client:
        response = await client.chat.create(
            input="List three Australian birds.",
            response_schema=json.dumps({
                "type": "object",
                "properties": {"birds": {"type": "array", "items": {"type": "string"}}},
                "required": ["birds"],
                "additionalProperties": False,
            }),
        )
        data = json.loads(response.output_text)  # guaranteed valid, schema-conforming JSON

With response_schema set, the response text is guaranteed to be valid JSON conforming to the schema — but parsing and validation are up to you.

Local $ref/$defs (pydantic's nested-model output) are inlined client-side before the schema is sent — the server's grammar compiler does not resolve #/$defs/... pointers. A recursive shape cannot be inlined: for a pydantic model schema it degrades to unconstrained at the recursion point (client-side model_validate still guards output); a recursive dict schema raises ValueError, since a dict has no client-side validation to catch the unconstrained recursion point.

OpenAI-compatible endpoint

The OpenAI-compatible endpoint (POST /api/v1/chat/completions) also honours structured output via the standard response_format parameter, so the OpenAI Python SDK's structured-output option works against Matilda as-is:

  • { "type": "json_schema", "json_schema": { "name": "...", "schema": {...} } } — grammar-constrained to your schema (the schema is applied with strict: true server-side; the strict and name fields you supply are re-wrapped downstream).
  • { "type": "json_object" } — guarantees valid JSON output without a schema (OpenAI JSON mode).

Safety replace and structured output. If the server replaces the output mid-stream (safety filter), stream_object and create_object raise SafetyReplaceError — the replacement text is in str(err) and the triggering categories in .categories. Deltas already yielded to your consumer are not rolled back; if you render streamed JSON, handle response.output_text.replace events (or choose non-streaming create_object) to avoid showing half-rendered output that is later discarded.

Truncation throws. If the stream is truncated before the JSON completes, both helpers raise MatildaObjectParseError with the partial text in .raw. See §15. Error Handling.

Stream errors throw. If the server emits an error event mid-stream, both helpers raise MatildaStreamError with the server's error code and message (f"{code}: {message}").


9. Chat — Durable Streaming (Resume)

Durable streaming lets a client disconnect mid-stream and resume from where it left off. The server buffers events in a Redis stream, keyed by a stream_id advertised at stream start.

Durable streaming lifecycle

  1. Start a stream — chat.stream() emits a ResponseCreated event with a stream_id.
  2. Persist the stream_id and conversation_id immediately.
  3. If disconnected, call chat.active_stream(conversation_id) to check if the stream is still live.
  4. Call chat.resume(stream_id, last_event_id=...) to replay buffered events from last_event_id onwards.

chat.resume(stream_id, *, last_event_id=None, ...)

Resumes a previously detached stream by replaying buffered events from last_event_id. Returns an async generator of ChatEvent. Raises MatildaAPIError (404) when the stream buffer has expired.

async for event in client.chat.resume(saved_stream_id, last_event_id=saved_last_event_id):
    if event.type == "response.output_text.delta":
        print(event.delta, end="", flush=True)
Parameter Type Default Description
stream_id str (required) The stream ID from response.created.
last_event_id str | None — The last Redis stream entry ID received. Omit to replay from the start.
access_token str | None — Override access token.
fingerprint str | None — Device fingerprint.
stall_timeout_ms int | None 45_000 SSE stall watchdog timeout.

chat.active_stream(conversation_id)

Checks whether a conversation has an active stream.

result = await client.chat.active_stream("conv-123")
# {"stream_id": "abc-123" | None, "status": "active" | "done" | "error" | None}

Returns a dict:

# ActiveStreamLookup:
{"stream_id": "abc-123" or None, "status": "active" | "done" | "error" | None}

chat.notify_on_completion(stream_id, enabled=True)

Request a push notification when a backgrounded stream completes.

await client.chat.notify_on_completion(stream_id, enabled=True)
Parameter Type Default Description
stream_id str (required) The stream to watch.
enabled bool True Enable or disable the notification.

Returns a dict (e.g. {"status": ...}).

Full resume example

import asyncio
import os

from matilda_client import MatildaClient


async def main():
    async with MatildaClient(token=os.environ["MATILDA_ACCESS_TOKEN"]) as client:
        stream_id = None
        last_event_id = None

        # Start streaming
        async for event in client.chat.stream(
            input="Write a long essay about Australia.",
            conversation_id="conv-123",
        ):
            if event.type == "response.created":
                stream_id = event.stream_id
            if event.type == "response.cursor":
                last_event_id = event.last_event_id
            if event.type == "response.output_text.delta":
                print(event.delta, end="", flush=True)

        # Later — check if the stream is still active, then resume
        active = await client.chat.active_stream("conv-123")
        if active["status"] == "active" and stream_id:
            print("\n--- Resuming ---")
            async for event in client.chat.resume(stream_id, last_event_id=last_event_id):
                if event.type == "response.output_text.delta":
                    print(event.delta, end="", flush=True)


asyncio.run(main())

10. Conversations (History)

Conversation resources return plain dict objects using the server's camelCase wire keys.

conversations.list(...)

Lists conversations with pagination.

async def main():
    async with MatildaClient(token="...") as client:
        result = await client.conversations.list(limit=20, offset=0)
        for conv in result["conversations"]:
            print(f"{conv['id']}: {conv['title']} (updated {conv['updatedAt']})")
Parameter Type Default Description
limit int | None — Maximum number of conversations to return.
offset int | None — Pagination offset.
surface str | None — Filter by server-verified product surface ("web", "mobile", "cli", "desktop", "api"). Unknown values return 400.

Returns a dict:

# ConversationListResponse:
{
    "conversations": [
        {
            "id": "conv-123",
            "userId": "user-1",
            "title": "My chat",
            "createdAt": "2026-08-18T10:30:00.000Z",
            "updatedAt": "2026-08-18T11:00:00.000Z",
        }
    ],
    "total": 1,
    "limit": 20,
    "offset": 0,
}

conversations.retrieve(conversation_id)

Retrieves a full conversation thread with all messages.

conv = await client.conversations.retrieve("conv-123")
for msg in conv["messages"]:
    print(f"[{msg['role']}] {msg['content']}")

Returns a dict — the summary shape above plus a messages list.

conversations.update(conversation_id, *, title=...)

Updates a conversation's metadata (currently only title).

await client.conversations.update("conv-123", title="My Chat About AI")
Parameter Type Description
conversation_id str The conversation to update.
title str The new title.

Returns None.

conversations.set_message_feedback(conversation_id, message_id, feedback)

Sets thumbs-up or thumbs-down feedback on a specific message.

await client.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}).


11. Files

files.upload(file, ...)

Uploads a single file by path. Files at or above the chunked threshold (MatildaConfig.chunked_threshold, 16 MiB default) use the parallel multipart protocol; smaller files use single-shot upload.

async def main():
    async with MatildaClient(token="...") as client:
        result = await client.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 (a normal turn refuses it, and an incognito turn refuses any other upload). It always uses an upload session, opened on POST /files/ephemeral/uploads, so a server without ephemeral uploads refuses it before any of the file is sent.

Returns FileComplete:

@dataclass
class FileComplete:
    file_id: str
    status: str  # 'pending' | 'scanning' | 'processing' | 'ready' | 'failed' | 'rejected'

files.upload_many(files, *, file_concurrency=3, **kwargs)

Uploads multiple files in parallel (auto-selecting single-shot vs chunked per file). One file's failure does not abort the others — per-file outcomes arrive in the result list, in input order, as either FileComplete or UploadError.

async def main():
    async with MatildaClient(token="...") as client:
        results = await client.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].

files.retrieve(file_id)

Retrieves metadata for a previously uploaded file.

file = await client.files.retrieve("file-abc123")
print(f"{file['filename']} — {file['status']} ({file['sizeBytes']} bytes)")
if file.get("extractedText"):
    print(f"Extracted: {file['extractedText'][:100]}...")

Returns a dict (a FileAttachment: id, filename, contentType, sizeBytes, status, optional extractedText, thumbnailUrl, localUri, failureReason, createdAt).

files.list_artifacts(conversation_id, *, limit=None, offset=None)

Lists generated artifacts for a conversation — used to re-hydrate artifact history across sessions.

async def main():
    async with MatildaClient(token="...") as client:
        result = await client.files.list_artifacts("conv-123", limit=50)
        for item in result.artifacts:
            print(f"{item.filename} ({item.content_type}, {item.size_bytes} bytes)")
        print(f"Total: {result.total}")
  • conversation_id (positional, required) is sent as the conversationId query parameter — the server returns 400 when it is missing.
  • limit (1–200, default 50) and offset (default 0) page the list.

Returns an ArtifactListResult:

@dataclass
class ArtifactListItem:
    artifact_id: str
    filename: str
    content_type: str
    size_bytes: int
    conversation_id: str | None
    message_id: str | None
    created_at: str

@dataclass
class ArtifactListResult:
    artifacts: list[ArtifactListItem]
    total: int

An empty conversation returns 200 with an empty list (never a 404).

files.download_artifact(artifact_id, filename=None)

Downloads a generated artifact's bytes (GET /files/{artifact_id}/content).

async def main():
    from pathlib import Path

    async with MatildaClient(token="...") as client:
        artifact = await client.files.download_artifact("art-123")
        print(f"{artifact.suggested_filename} ({artifact.content_type})")
        Path(artifact.suggested_filename).write_bytes(artifact.content)
  • artifact_id (positional, required) is URL-encoded onto the path.
  • filename (optional) is the fallback suggestion used when the server's Content-Disposition header carries no filename.

Returns an ArtifactDownload — the caller owns the bytes and chooses how to surface them (write to disk, stream elsewhere, etc.):

@dataclass
class ArtifactDownload:
    content: bytes
    content_type: str
    suggested_filename: str

suggested_filename falls back to the passed filename, then to the artifact_id, when Content-Disposition is absent. An unknown or foreign artifact returns a 404, which raises MatildaAPIError.

Using files in chat

Upload a file, then reference its file_id in a chat message:

async def main():
    async with MatildaClient(token="...") as client:
        upload = await client.files.upload("report.pdf", content_type="application/pdf")

        response = await client.chat.create(
            input="Summarise this report.",
            file_ids=[upload.file_id],
        )
        print(response.output_text)

Server-advertised upload settings (runtime config)

On its first upload, the client fetches the unauthenticated GET /config/public once and caches the result for the client's lifetime:

  • Sizing. A chunk_size / chunked_threshold you set explicitly (any value other than the MatildaConfig defaults) always wins; otherwise the server-advertised uploadChunkSizeBytes / uploadChunkedThresholdBytes apply.
  • Direct mode. When the server advertises uploadDirectEnabled, parts bypass core-api: the client mints a short-TTL presigned URL per part (POST /files/uploads/:uploadId/part-url?partNumber=N, body {"contentLength": N}) and PUTs the bytes straight to the uploads host. The proxied add-part route is disabled on such deployments (it answers 409), so there is no client-side opt-out. Presigned destinations are pinned to the server-advertised uploadsHost (https, exact hostname match) — anything else fails the upload with UploadError("Upload destination is not permitted."). The object-store ETag (quotes stripped) is collected per part and sent to the completion call, exactly as with the relay path.
  • Fail-open. An unreachable or erroring /config/public yields the defaults (chunked on, direct off), so a config outage never switches transport.

client.fetch_public_runtime_config()

Fetches the same runtime config directly. Raises MatildaAPIError on a non-2xx response — unlike the upload path, which fails open.

config = await client.fetch_public_runtime_config()
print(config.upload_direct_enabled, config.uploads_host)

Returns PublicRuntimeConfig:

@dataclass
class PublicRuntimeConfig:
    upload_chunk_size_bytes: int | None = None
    upload_chunked_threshold_bytes: int | None = None
    upload_chunked_enabled: bool = True
    upload_direct_enabled: bool = False
    uploads_host: str | None = None

12. Feedback

feedback.report(...)

Reports a message for harmful, inaccurate, off-topic, or privacy-violating content.

await client.feedback.report(
    message_id="msg-456",
    conversation_id="conv-123",
    reason="inaccurate",
    comment="The capital of Australia is Canberra, not Sydney.",
)

Parameters

Parameter Type Description
message_id str The message being reported.
conversation_id str The conversation containing the message.
reason 'harmful' | 'inaccurate' | 'off_topic' | 'privacy' | 'other' Report reason.
comment str | None Optional additional context.

Returns a dict (e.g. {"reportId": ..., "acknowledgedAt": ...}).

feedback.response(...)

Submits general response feedback (positive/negative sentiment with platform context).

await client.feedback.response(
    message_id="msg-456",
    conversation_id="conv-123",
    comment="Great answer!",
    platform="web",
    app_version="1.0.0",
)

Parameters

Parameter Type Description
message_id str The message being rated.
conversation_id str The conversation containing the message.
comment str | None Optional feedback text.
platform 'web' | 'ios' | 'android' | 'unknown' | None Client platform.
app_version str | None App version string.
build_number str | None Build number.

Returns a dict (e.g. {"feedbackId": ..., "acknowledgedAt": ...}).

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-client), 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 client.feedback.report_bug(
    title="stream() silently closes on 429",
    description="The stream closes without error when the API returns 429.",
    reproduction="Call chat.stream() in a loop until rate-limited; observe the close event.",
)

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": ...}).


13. System One (matilda-jev)

System One is Matilda's structured-decision service, powered by the matilda-jev model. You send a state object plus a map of typed questions; the API returns an answer for each question. Schema validation lives in jev itself, so new question shapes arrive without an SDK release.

async with MatildaClient(token="mc_live_...") as client:
    decision = await client.systemone.evaluate(
        state={"ticket": "Charged twice", "customer_tier": "gold"},
        questions={"urgent": {"type": "noul", "instructions": "Needs a same-day response?"}},
    )

print(decision.answers["urgent"])  # {'type': 'noul', 'noul': 0.96}
print(decision.model_version)      # 'matilda-decision-v1'

Missing or expired credentials raise MatildaAPIError with status 401, and an otherwise-valid key without the api:systemone scope raises 403; create a key with the api:systemone scope to call it (see §4. API Key Management). Caller-fixable jev errors (400 / 413 / 422 / 429) pass through with jev's response body in error.response_text.

systemone.evaluate(*, state, questions, access_token=None, fingerprint=None)

Evaluates one state against typed questions.

Param Type Description
state Mapping[str, Any] The situation jev assesses.
questions Mapping[str, Any] Question id → question. Jev owns the schema; the choice / score / noul shapes are current.
access_token str | None Optional per-request Bearer override.
fingerprint str | None Device fingerprint.

Returns a SystemOneDecision:

# SystemOneDecision:
#   model: str                    — public model id ("matilda-jev")
#   model_version: str | None     — concrete jev build, from the x-matilda-model-version header
#   answers: dict[str, Any]       — answer per question id
#   usage: dict[str, Any] | None  — token accounting, when the deployment reports it

15. Error Handling

Every error raised by this SDK derives from MatildaError.

MatildaAPIError

Raised on non-2xx HTTP responses from the API.

class MatildaAPIError(MatildaError):
    status: int         # HTTP status code
    response_text: str  # Raw response body

QuotaExceededError

A MatildaAPIError subclass for HTTP 429 with a structured quota_exceeded body — the daily quota is spent. Carries the quota payload as attributes:

class QuotaExceededError(MatildaAPIError):
    tier: str | None
    messages_used: int | None
    messages_limit: int | None
    tokens_used: int | None
    tokens_limit: int | None
    resets_at: str | None
    upgrade_action: str | None

MatildaStreamError

A terminal error event on the chat SSE stream, surfaced by the text/object helpers. str(err) is f"{code}: {message}".

class MatildaStreamError(MatildaError):
    code: str  # a ChatErrorCode

MatildaContentTypeError

Raised by the streaming entry points (chat.stream(), chat.stream_object(), chat.resume()) when a streaming endpoint returns a 2xx response whose Content-Type is not text/event-stream — e.g. an HTML error page from a proxy or a JSON error body from a misconfigured gateway. Raised before parsing begins so an unconsumable body fails loudly instead of silently yielding no events.

class MatildaContentTypeError(MatildaError): ...
# str(err) names the offending Content-Type (or its absence)

SafetyReplaceError

Raised by chat.stream_text(), chat.stream_object(), and chat.create_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 that triggered the replacement.

class SafetyReplaceError(MatildaError):
    categories: list[str]

MatildaObjectParseError

Raised by chat.stream_object() and chat.create_object() when the response cannot be parsed as JSON or fails pydantic validation — e.g. when a stream is truncated. raw contains the full response text; cause is the underlying json.loads or pydantic error. See §8. Chat — Structured Output.

class MatildaObjectParseError(MatildaError):
    raw: str
    cause: BaseException | str

UploadError

Raised when a file upload fails after retries (also used for missing-token upload attempts).

AuthError

Raised by auth flows. The code field is an OAuth error code (e.g. 'invalid_grant', 'authorization_pending', 'expired_token', 'access_denied'). The retryable field distinguishes transient failures (5xx, network) from permanent ones (revoked refresh token).

class AuthError(MatildaError):
    code: str
    retryable: bool

Chat error codes (ChatErrorCode)

These codes are emitted via the ResponseError stream event and appear in MatildaChatResponse.errors. Also available as the CHAT_ERROR_CODES frozenset:

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_000 ms (DEFAULT_STALL_TIMEOUT_MS — 3× the server's 15-second keep-alive ping cadence)
  • Disable: Pass stall_timeout_ms=0 (not recommended — mobile loses background-to-foreground hung-stream recovery)

Error handling example

from matilda_client import (
    MatildaAPIError,
    MatildaClient,
    QuotaExceededError,
    SafetyReplaceError,
)


async def main():
    async with MatildaClient(token="...") as client:
        try:
            text = await client.chat.create_text(input="Hello!")
            print(text)
        except QuotaExceededError as err:
            print(f"Daily quota exceeded — resets at {err.resets_at}.")
        except MatildaAPIError 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)}")

16. Multi-Turn Conversations

The client SDK does not have a Session class. Multi-turn conversations are managed by passing a conversation_id to each chat call. The server reconstructs the full conversation history server-side from the session store.

Pattern

  1. Generate a conversation ID (any unique string, e.g. a UUID).
  2. Pass it to every chat.create() or chat.stream() call.
  3. The server maintains the conversation history — you only send the latest message.

Important: Omitting conversation_id auto-creates a new conversation per call, and the response never returns that auto-created ID. Mint your own ID (e.g. uuid.uuid4()) and thread it through — it's the only way to continue a conversation.

import asyncio
from uuid import uuid4

from matilda_client import MatildaClient


async def main():
    async with MatildaClient() as client:
        # Authenticate with device flow — token refresh is handled automatically
        if not await client.auth.get_tokens():
            await client.auth.login_with_device_flow(client_id="matilda-code")

        conversation_id = str(uuid4())

        # Turn 1
        r1 = await client.chat.create(
            input="What is the capital of France?", conversation_id=conversation_id
        )
        print(r1.output_text)  # "Paris"

        # Turn 2 — server remembers the previous turn
        r2 = await client.chat.create(
            input="What about Germany?", conversation_id=conversation_id
        )
        print(r2.output_text)  # "Berlin"

        # Turn 3
        r3 = await client.chat.create(
            input="And Italy?", conversation_id=conversation_id
        )
        print(r3.output_text)  # "Rome"


asyncio.run(main())

Contrasting with the agent SDK

The Matilda agent SDK provides a Session class that wraps an Agent with auto-managed conversation_id, a turns list, and session-level defaults. If you need client-side tool execution, approval loops, or session state management, consider the agent SDK. For simple chatbot integrations, the client SDK's conversation_id pattern is sufficient.

Retrieving conversation history

# List all conversations
result = await client.conversations.list(limit=50)

# Retrieve a specific conversation with full message history
conv = await client.conversations.retrieve(conversation_id)
for msg in conv["messages"]:
    print(f"[{msg['role']}] {msg['content']}")

17. Suggested Tasks / Recipes

Recipe 1: CLI chatbot

A complete interactive CLI chatbot with device-flow auth and streaming.

import asyncio
from pathlib import Path
from uuid import uuid4

from matilda_client import MatildaClient, create_file_token_store


async def main():
    token_store = create_file_token_store(Path.home() / ".matilda" / "tokens.json")

    async with MatildaClient() as client:
        # Authenticate if needed
        if not await client.auth.get_tokens():
            print("Starting device flow authentication...")
            await client.auth.login_with_device_flow(
                client_id="matilda-code",
                token_store=token_store.store,
                token_lock=token_store.lock,
            )
            print("Authenticated!")

        conversation_id = str(uuid4())

        while True:
            user_input = await asyncio.to_thread(input, "\nYou: ")
            if not user_input.strip() or user_input.lower() == "exit":
                break

            print("Matilda: ", end="", flush=True)
            async for chunk in client.chat.stream_text(
                input=user_input, conversation_id=conversation_id
            ):
                print(chunk, end="", flush=True)
            print()


asyncio.run(main())

Recipe 2: File Q&A

Upload a document and ask questions about it.

import asyncio
from uuid import uuid4

from matilda_client import MatildaClient


async def main():
    async with MatildaClient() as client:
        # Authenticate with device flow — token refresh is handled automatically
        if not await client.auth.get_tokens():
            await client.auth.login_with_device_flow(client_id="matilda-code")

        # Upload a file
        upload = await client.files.upload(
            "report.pdf",
            content_type="application/pdf",
            on_progress=lambda pct: print(f"\rUploading: {pct}%", end="", flush=True),
        )
        print(f"\nUploaded: {upload.file_id} ({upload.status})")

        # A conversation_id is required for follow-ups to share history — omitting it
        # auto-creates a new conversation per call.
        conversation_id = str(uuid4())

        # Ask a question about it
        response = await client.chat.create(
            input="Summarise the key findings in this report.",
            file_ids=[upload.file_id],
            conversation_id=conversation_id,
        )
        print(response.output_text)

        # Follow-up question in the same conversation
        follow_up = await client.chat.create(
            input="What are the recommendations?",
            file_ids=[upload.file_id],
            conversation_id=conversation_id,
        )
        print(follow_up.output_text)


asyncio.run(main())

Recipe 3: Conversation history browser

List, paginate, and inspect conversation history.

import asyncio

from matilda_client import MatildaClient


async def main():
    async with MatildaClient() as client:
        # Authenticate with device flow — token refresh is handled automatically
        if not await client.auth.get_tokens():
            await client.auth.login_with_device_flow(client_id="matilda-code")

        # List first page
        offset = 0
        limit = 10
        page = await client.conversations.list(limit=limit, offset=offset)

        print(f"Total conversations: {page['total']}\n")

        for conv in page["conversations"]:
            print(f"[{conv['id']}] {conv['title']}")
            print(f"  Updated: {conv['updatedAt']}\n")

        # Load next page
        offset += limit
        if offset < page["total"]:
            page = await client.conversations.list(limit=limit, offset=offset)
            for conv in page["conversations"]:
                print(f"[{conv['id']}] {conv['title']}")

        # Retrieve a full conversation
        if page["conversations"]:
            full = await client.conversations.retrieve(page["conversations"][0]["id"])
            print(f"\n--- {full['title']} ---")
            for msg in full["messages"]:
                print(f"\n[{msg['role'].upper()}]")
                print(msg["content"])
                if msg.get("feedback"):
                    print(f"  Feedback: {msg['feedback']}")


asyncio.run(main())

Recipe 4: Durable stream recovery

Start a stream, simulate a disconnect, and resume from the last cursor.

import asyncio
from uuid import uuid4

from matilda_client import MatildaClient


async def main():
    async with MatildaClient() as client:
        # Authenticate with device flow — token refresh is handled automatically
        if not await client.auth.get_tokens():
            await client.auth.login_with_device_flow(client_id="matilda-code")

        conversation_id = str(uuid4())
        stream_id = None
        last_event_id = None
        received_text = ""

        # Start streaming — simulate disconnect after a few events
        print("Starting stream...")
        try:
            async for event in client.chat.stream(
                input="Write a very long, detailed essay about the history of computing.",
                conversation_id=conversation_id,
            ):
                if event.type == "response.created":
                    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
                if event.type == "response.completed":
                    print("Stream completed naturally.")
        except Exception as err:
            print(f"Disconnected: {err}")

        print(f"Received {len(received_text)} chars before disconnect.")

        # Check if the stream is still active
        if stream_id:
            active = await client.chat.active_stream(conversation_id)
            print(f"Stream status: {active['status']}")

            if active["status"] == "active":
                print("\n--- Resuming ---")
                async for event in client.chat.resume(
                    stream_id, last_event_id=last_event_id
                ):
                    if event.type == "response.output_text.delta":
                        received_text += event.delta
                        print(event.delta, end="", flush=True)
                    if event.type == "response.completed":
                        print("\n\n--- Resume complete ---")
                        print(f"Total received: {len(received_text)} chars")


asyncio.run(main())

Recipe 5: Multi-environment setup

Run a local dev backend and production clients in the same process. Each instance manages its own auth independently.

import asyncio

from matilda_client import MatildaClient


async def main():
    async with (
        MatildaClient(base_url="http://localhost:3000/api") as local,
        MatildaClient(base_url="https://matilda.com/api") as production,
    ):
        # Authenticate each instance independently — device flow or browser login
        if not await local.auth.get_tokens():
            await local.auth.login_with_device_flow(client_id="matilda-code")
        if not await production.auth.get_tokens():
            await production.auth.login_with_browser(client_id="matilda-code")

        # Run the same prompt against both environments
        local_response, prod_response = await asyncio.gather(
            local.chat.create_text(input="Explain quantum entanglement."),
            production.chat.create_text(input="Explain quantum entanglement."),
        )

        print("Local:", local_response)
        print("Production:", prod_response)

        # Instances are fully isolated — each manages its own token lifecycle,
        # and config is immutable after construction, so one instance's setup
        # can never leak into another.


asyncio.run(main())

Recipe 6: Custom token store

Implement StorageAdapter to store tokens in a database or other custom backend.

import asyncio

from matilda_client import MatildaClient


# Example: a database-backed token store
class DatabaseTokenStore:
    def __init__(self, db):
        self._db = db

    async def get(self, key: str) -> str | None:
        row = await self._db.fetchone("SELECT value FROM tokens WHERE key = ?", (key,))
        return row["value"] if row else None

    async def set(self, key: str, value: str) -> None:
        await self._db.execute(
            "INSERT INTO tokens (key, value) VALUES (?, ?) "
            "ON CONFLICT (key) DO UPDATE SET value = excluded.value",
            (key, value),
        )

    async def remove(self, key: str) -> None:
        await self._db.execute("DELETE FROM tokens WHERE key = ?", (key,))


async def main():
    token_store = DatabaseTokenStore(my_database)

    async with MatildaClient() as client:
        await client.auth.login_with_device_flow(
            client_id="matilda-code",
            token_store=token_store,
            # No cross-process lock needed — the database handles concurrency
        )

        # Tokens are now persisted in the database and survive process restarts
        response = await client.chat.create(input="Hello!")
        print(response.output_text)


asyncio.run(main())

Exports Reference

Client

Export Kind Description
MatildaClient class Main client class — owns the chat, files, conversations, feedback, systemone, api_keys, and auth resources.
MatildaConfig dataclass Client configuration (frozen).
TokenProvider type alias Callable[[bool], Awaitable[str | None]] — async token provider.

Chat responses and events

Export Kind Description
MatildaChatResponse dataclass Collected response from chat.create().
MatildaObjectResponse[T] dataclass chat.create_object() response (extends MatildaChatResponse, adds object).
ChatEvent type alias Union of the 15 stream event dataclasses.
MatildaObjectEvent[T] type alias chat.stream_object() events — all chat events, plus a final ObjectEvent.
ObjectEvent[T] dataclass Terminal structured-output event (type="object").
ResponseCreated dataclass response.created — stream ID.
OutputTextDelta dataclass response.output_text.delta — text chunk.
OutputTextReplace dataclass response.output_text.replace — safety replacement.
StatusEvent dataclass response.status — lifecycle status.
QueuedEvent dataclass response.queued — queue position.
ToolCallStarted dataclass response.tool_call.started.
ToolCallProgress dataclass response.tool_call.progress.
ToolCallCompleted dataclass response.tool_call.completed.
GenerationStatus dataclass response.generation_status.
UsageEvent dataclass response.usage — wraps Usage.
Usage dataclass Token usage payload.
CursorEvent dataclass response.cursor — durable cursor.
Truncated dataclass response.truncated.
ArtifactEvent dataclass response.artifact — wraps Artifact.
Artifact dataclass Server-generated file download card (artifact_id, url, optional filename/content_type/size_bytes).
Completed dataclass response.completed.
ResponseError dataclass response.error — code + message.
StreamErrorInfo dataclass Error entry in MatildaChatResponse.errors.
EventCallback type alias Callable[[ChatEvent], None].
ChatErrorCode type alias Literal union of the chat error codes.

Errors

Export Kind Description
MatildaError class Base class for every SDK error.
MatildaAPIError class HTTP error (status, response_text).
QuotaExceededError class 429 quota error with structured quota attributes.
MatildaStreamError class Terminal stream error event (code).
MatildaContentTypeError class Streaming endpoint returned a non-SSE 2xx response (proxy/gateway misconfiguration).
SafetyReplaceError class Safety filter replacement (categories).
MatildaObjectParseError class Structured output parse/validation failure (raw, cause).
UploadError class File upload failure.
AuthError class OAuth error (code, retryable).

Auth

Export Kind Description
AuthResource class Managed auth on client.auth — login_with_browser, login_with_device_flow, refresh_token, get_tokens, logout.
TokenManager class Per-session token manager with single-flight refresh.
create_token_manager function TokenManager factory.
TokenSet dataclass OAuth token set (access_token, refresh_token, id_token, expires_at).
StorageAdapter protocol Pluggable token storage (sync or async).
MemoryStorage / memory_storage class / function In-memory StorageAdapter.
FileTokenStore / create_file_token_store class / function 0600 JSON file store with .store and cross-process .lock.
LoopbackReceiver / create_loopback_receiver class / function RFC 8252 loopback redirect receiver.
run_loopback_login_flow function Headless PKCE loopback login.
run_device_login_flow function Headless RFC 8628 device login.
default_device_on_event function Prints the device user code to stderr.
LoginFlowEvent type alias StateEvent | AuthorizeUrlEvent | UserCodeEvent.
StateEvent / AuthorizeUrlEvent / UserCodeEvent dataclasses Login flow state events.
AuthServerMetadata dataclass RFC 8414 server metadata.
fetch_auth_server_metadata function Metadata discovery.
DeviceAuthorization dataclass Device-flow code + URIs.
request_device_code / poll_device_token functions Device-flow grant halves.
BeginLoginResult / begin_login / complete_login dataclass / functions Stateless redirect-login halves.
exchange_code_for_tokens function Authorization-code exchange.
refresh_token_grant function Raw refresh-token grant.
create_pkce_pair function PKCE verifier + S256 challenge.
build_authorize_url function Construct the authorize URL.
assert_state function Validate the OAuth state round-trip.
random_state function High-entropy state value.
base64_url_encode function Padding-free base64url.

Files and feedback types

Export Kind Description
FileComplete dataclass Upload completion result (file_id, status).
ArtifactListItem dataclass One generated artifact in a history listing.
ArtifactListResult dataclass Response from files.list_artifacts (artifacts, total).
ArtifactDownload dataclass Downloaded artifact bytes (content, content_type, suggested_filename).
PublicRuntimeConfig dataclass Server-advertised upload settings (GET /config/public).
SystemOneDecision dataclass systemone.evaluate() result (model, model_version, answers, usage).
FeedbackPlatform type alias 'web' | 'ios' | 'android' | 'unknown'.
ReportReason type alias 'harmful' | 'inaccurate' | 'off_topic' | 'privacy' | 'other'.

Constants and version helpers

Export Value Description
DEFAULT_BASE_URL https://matilda.com/api Production API base URL.
DEFAULT_CHUNK_SIZE 8 MiB Chunked-upload part size.
DEFAULT_CHUNKED_THRESHOLD 16 MiB Chunked-upload file-size threshold.
DEFAULT_PART_CONCURRENCY 4 Parallel upload parts.
DEFAULT_MAX_PART_RETRIES 3 Retries per upload part.
MAX_FILE_COUNT 5 Maximum files per request.
S3_MIN_CHUNK_SIZE 5 MiB S3 multipart minimum.
DEFAULT_STALL_TIMEOUT_MS 45 000 SSE stall watchdog default.
DEFAULT_SUCCESS_REDIRECT https://matilda.com/cli/signed-in Browser-login success redirect.
MATILDA_API_VERSION_HEADER X-Matilda-API-Version API version header name.
MATILDA_CURRENT_API_VERSION — Current API version string.
CHAT_ERROR_CODES frozenset The chat error codes.
sdk_version() — Installed package version.

Built and maintained by Maincode — Australia's sovereign AI company.

MAINCODE HELP

Matilda uses product guides and may check public web sources to troubleshoot your issue. Check the linked sources. Leave out passwords, API keys and personal information.

Try asking
Start a new question