Appearance
matterwave-sdk
The Python client for the MatterWave API: the OpenAI-compatible /v1 surface, with typed responses, a streaming iterator, retries that honour Retry-After, and the server's X-Request-Id on every error.
pip install matterwave-sdkThe distribution is matterwave-sdk and the import package is matterwave_sdk. The bare names belong to others: matterwave on PyPI is an unrelated physics library that ships its own top-level matterwave module, and matterwave is also the import name of this repo's API server (api/). See docs/package-names.md.
Python 3.10 or newer; the only dependency is httpx.
Configure
python
from matterwave_sdk import MatterWave
client = MatterWave(api_key="mw_...", base_url="https://your-matterwave.example")Both arguments fall back to the environment: MATTERWAVE_API_KEY and MATTERWAVE_BASE_URL. The base URL is the origin the app is served from; the OpenAI-style form ending in /v1 is accepted too. Keys are created under Settings > API keys in the app and shown once.
Three examples
Complete
python
completion = client.chat.completions.create(
messages=[{"role": "user", "content": "Say hello in five words."}],
)
print(completion.content) # the reply
print(completion.usage.total_tokens) # what it cost
print(completion.request_id) # the server's X-Request-Idmodel may be omitted; the deployment's default is used. client.models.list() says what is served.
Stream
python
stream = client.chat.completions.create(
messages=[{"role": "user", "content": "Tell me a short story."}],
stream=True,
)
for text in stream.text():
print(text, end="", flush=True)Iterating the stream itself yields ChatCompletionChunk objects instead of strings. Pass include_usage=True to receive a final chunk with the token counts (its choices is empty). Closing the stream early releases the connection.
Async, with error handling
python
import asyncio
from matterwave_sdk import AsyncMatterWave, RateLimitError, APIError
async def main() -> None:
async with AsyncMatterWave() as client:
try:
completion = await client.chat.completions.create(
messages=[{"role": "user", "content": "Name one animal."}],
temperature=0.2,
)
print(completion.content)
except RateLimitError as err:
# `rate_limit_exceeded` has already been retried with backoff;
# `insufficient_quota` is the organisation's budget and is not.
print(err.code, err.retry_after, err.request_id)
except APIError as err:
print(err.status_code, err.type, err.code, err.request_id)
asyncio.run(main())Errors and retries
Every refusal is an APIError subclass by status: BadRequestError (400), AuthenticationError (401), PermissionDeniedError (403), NotFoundError (404), ConflictError (409), RateLimitError (429), ServerError (5xx). Each carries the API's envelope (message, type, code, param) and request_id; a RateLimitError also carries rate_limit (limit, remaining, reset seconds) from the X-RateLimit-* headers. An error that arrives inside a stream, after the 200, is an APIError with status_code=None. Connection failures are APIConnectionError; timeouts APITimeoutError.
A 429 whose code is rate_limit_exceeded and any 5xx (and connection errors) are retried up to max_retries times (default 2) with exponential backoff and jitter, or for exactly Retry-After when the server sends one. A 429 whose code is insufficient_quota is raised at once: waiting does not refill a budget. A streamed request is retried only before its stream starts.
Tools, structured output and embeddings
tools, tool_choice and response_format are passed to the model as sent and the answer is relayed: a tool call comes back in completion.tool_calls (or as delta.tool_calls fragments on a stream) for your code to run, never run on the server.
python
completion = client.chat.completions.create(
messages=[{"role": "user", "content": "Weather in Oslo?"}],
tools=[{"type": "function", "function": {"name": "weather", "parameters": {"type": "object"}}}],
)
for call in completion.tool_calls:
print(call.id, call.function.name, call.function.arguments)client.embeddings.create(["one", "two"]) returns one embedding per input from the deployment's embedding model; client.index().capabilities["embeddings"] says whether one is configured.
Recording a turn into a conversation
python
client.chat.completions.create(
messages=[...],
conversation_id="<a conversation of the key owner's>",
)The request is still the whole prompt; the server splices in no history. Without conversation_id nothing is stored.
Conversations
client.conversations is the app's own conversation API, the one the web app uses: the server keeps the history, builds the prompt and applies the instructions, so a conversation made here shows up in the browser and the other way round. A key acts as its owner.
python
from matterwave_sdk import frames
conv = client.conversations.create(title="Plans")
with client.conversations.send(conv.id, "Draft a three-line agenda.") as stream:
for frame in stream:
if isinstance(frame, frames.DeltaFrame) and frame.content:
print(frame.content, end="", flush=True)
elif isinstance(frame, frames.ContextFrame):
meter = (frame.prompt_tokens, frame.context_window)
elif isinstance(frame, (frames.ErrorFrame, frames.ModerationBlockFrame)):
print(frame.detail, frame.code)
print(stream.result.id if stream.result else None) # the saved assistant messagelist(filter=, limit=, cursor=) is one page of the sidebar (next_cursor on the result; pages() iterates them all), and search(q), get(id, full=), tree(id), set_active_leaf(id, message_id), fork(id, message_id), edit(id, message_id, content) and regenerate(id, message_id) cover the rest of the sidebar and the branch switcher; update(id, title=, default_model=) renames the conversation and keeps the model its turns use (None clears the model); context(id) says how full the model's window is, and compact(id) folds the earlier messages into a summary now (a ConflictError when there is nothing to fold yet). client.me() says who the key acts as.
The stream is named server-sent events, read into typed frames in matterwave_sdk.frames: CompactionFrame, ContextFrame, MessageFrame (the user turn as saved), TitleFrame, PartStartFrame / DeltaFrame / PartEndFrame per part (text, reasoning, tool call, tool result), NoticeFrame, and one terminal frame: DoneFrame (the assistant message), ErrorFrame or ModerationBlockFrame. An event the client does not know is an UnknownFrame; the server's heartbeat comments are skipped. stream.text() yields just the answer's words.
If the connection drops before the terminal frame, the stream reconnects once with Last-Event-ID and the server replays the rest of the same generation; the prompt is never sent twice. A drop before any resumable frame, or a second drop, is an APIConnectionError. The async client is the same with await and async for.
Keys, usage, webhooks, attachments, library and shares
The app's own settings routes, one method per route, sync and async alike. A key acts as its owner, within its scopes: managing keys needs keys:manage, webhooks needs webhooks:manage, and usage.org / usage.export_csv need usage:read (a session needs none). None of them is in a key's default scopes; pick them when minting the key. The scope never widens the owner's role: webhooks also needs the key's owner to be an org owner or admin, and a member's usage:read key reports only their own row. A key missing either raises PermissionDeniedError (403).
python
# Keys: the secret is on the answer to create and rotate, and nowhere else.
key = client.keys.create("ci", scopes=["chat:read", "chat:write"], expires_in_days=90)
store_somewhere_safe(key.key)
client.keys.update(key.id, allowed_cidrs=["203.0.113.0/24"], expires_in_days=None) # None clears
rotated = client.keys.rotate(key.id) # old secret works until rotated.previous_valid_until
client.keys.revoke(key.id)
# Usage: the balance, the month per member, and the same month as CSV.
balance = client.usage.get()
print(balance.used, "of", balance.included, balance.unit)
report = client.usage.org(period="2026-10")
Path("usage-2026-10.csv").write_text(client.usage.export_csv(period="2026-10"))
# Webhooks: the signing secret is on create and rotate_secret only.
hook = client.webhooks.create("https://hooks.example.com/mw", ["message.completed"])
client.webhooks.test(hook.id)
failed = client.webhooks.deliveries(hook.id, status="failed")
# Attachments, then a turn that uses one.
doc = client.attachments.upload(Path("notes.pdf").read_bytes(), "notes.pdf")
stream = client.conversations.send(conversation_id, "Summarise this.", attachment_ids=[doc.id])
summary = "".join(stream.text())
data = client.attachments.download(client.attachments.get(doc.id))
# Library: folders, tags and paged search.
folder = client.library.create_folder("Work")
client.library.move(conversation_id, folder.id)
page = client.library.search("invoice", limit=20)
# Shares: the token is on create only; view needs no key.
link = client.shares.create(conversation_id, expires_in_s=7 * 86400)
client.shares.revoke(link.share.id)keys: list, create, update, rotate, revoke. usage: get, org, export_csv. webhooks: list, create, get, update, delete, rotate_secret, test, deliveries, redeliver. attachments: upload, get, delete, download, limits. library: get, search, create_folder, update_folder, delete_folder, move, create_tag, delete_tag, set_tags. shares: create, list, list_mine, revoke, view. A delete or revoke returns None; a missing id is a NotFoundError.
Device login
For a terminal or an editor with no browser of its own (RFC 8628): show the person a code, they approve it in any browser, and the poll returns an API key that acts as them.
python
token = client.auth.device.login(
on_code=lambda code: print(f"Open {code.verification_uri} and enter {code.user_code}"),
timeout=300,
)
signed_in = MatterWave(api_key=token.access_token)login polls at the server's interval and slows down when told to; it raises DeviceAuthError (code is access_denied or expired_token) or APITimeoutError. start() and poll(device_code) are the two steps on their own. The SDK never writes the token anywhere: store it in the OS keyring or the editor's secret storage, not a dotfile.
Development
uv sync --extra dev
uv run pytest
uv run ruff check . && uv run mypytests/test_openapi_contract.py checks this client against docs/developers/openapi.json at the repo root, so a new operation on the API and a new method here have to land together. That document is the /v1 contract; the conversation, device-login and native routes are listed apart in APP_OPERATIONS and checked against it only where it names their paths. tests/test_native_clients.py also holds each native route in APP_OPERATIONS to the routers in api/matterwave, when they are beside the SDK. The conversation stream's frames are the exception: the document lists them under x-sse-events, and frames.FRAMES_BY_EVENT is held to that list, event by event and field by field.