Appearance
The MatterWave API: /v1
MatterWave speaks the OpenAI chat-completions wire format. Anything that takes an OpenAI base_url works against it unmodified.
base_url https://<your-matterwave>/v1
api_key mw_xxxxxxxx_... (Settings -> API keys, or POST /api/keys)A key needs the chat:write scope to complete and embed, and chat:read to list models. The default key has both. Keys are shown once, at creation.
curl
bash
curl https://<host>/v1/chat/completions \
-H "Authorization: Bearer $MATTERWAVE_API_KEY" \
-H "Content-Type: application/json" \
-d '{"model": "grt-nano", "messages": [{"role": "user", "content": "Say hello in five words."}]}'json
{"id":"chatcmpl-01a0e95c-e346-75fa-b74d-e0e7518a4a47","object":"chat.completion","created":1790621573,
"model":"grt-nano","choices":[{"index":0,"message":{"role":"assistant","content":"\"Hello, how are you today?\""},
"finish_reason":"stop"}],"usage":{"prompt_tokens":41,"completion_tokens":9,"total_tokens":50}}Add "stream": true for text/event-stream chunks ending in data: [DONE]; add "stream_options": {"include_usage": true} to get token counts in the final chunk.
Python
python
import openai
client = openai.OpenAI(base_url="https://<host>/v1", api_key="mw_...")
for chunk in client.chat.completions.create(
model="grt-nano", messages=[{"role": "user", "content": "Name one animal."}], stream=True
):
print(chunk.choices[0].delta.content or "", end="")Or set OPENAI_BASE_URL and OPENAI_API_KEY and construct openai.OpenAI() with no arguments.
CLIs
llm:
yaml
# ~/.config/io.datasette.llm/extra-openai-models.yaml
- model_id: matterwave
model_name: grt-nano
api_base: https://<host>/v1
api_key_name: matterwavethen llm keys set matterwave and llm -m matterwave "hello".
aichat: in config.yaml, a client with type: openai-compatible, api_base: https://<host>/v1, api_key: mw_....
MatterWave for VS Code: the chat view in the editor, over the same /api conversations as the web app rather than /v1; set matterwave.baseUrl to https://<host> and sign in by device code or with an mw_... key.
Capabilities
GET /v1 (no auth) reports what the deployment can do under capabilities, from its configuration alone. The table is what the current cluster (grt-nano through LiteLLM and a CPU llama-server) answers:
| Capability | GET /v1 says | What happens |
|---|---|---|
chat_completions.streaming | true | SSE chunks, data: [DONE] |
chat_completions.response_format | ["text", "json_object", "json_schema"] | relayed to the gateway; llama.cpp enforces a schema as a grammar |
chat_completions.tools.pass_through | true | tools / tool_choice go upstream as sent; tool_calls come back as sent |
chat_completions.tools.server_side_execution | false | nothing is executed here: you run the tool and send the tool message |
chat_completions.image_input.accepted | true | image_url parts go upstream (see Images); models lists the ids the gateway declares vision-capable (none today), max_image_bytes, max_images_per_message and media_types are the limits |
embeddings.enabled | false | POST /v1/embeddings is a 404 until MW_EMBEDDINGS_MODEL names a model |
embeddings.model | null | the configured model's id when enabled |
bash
curl -s https://<host>/v1 | jq .capabilitiesjson
{"chat_completions":{"streaming":true,"response_format":["text","json_object","json_schema"],
"tools":{"pass_through":true,"tool_choice":["none","auto","required","function"],"server_side_execution":false},
"image_input":{"accepted":true,"models":[],"max_image_bytes":20971520,"max_images_per_message":10,
"media_types":["image/gif","image/jpeg","image/png","image/webp"],
"detail":"image_url parts (a data: URI or an http(s) URL) go to the models listed and to any the gateway does not describe; a model the gateway declares text-only refuses them with a 400"}},
"embeddings":{"enabled":false,"model":null}}Structured output: response_format
{"type": "json_object"} and {"type": "json_schema", "json_schema": {"name", "schema", "strict"?}} are relayed to the gateway. The shape is checked here (a json_schema type needs a json_schema object whose schema is an object); what the schema means is the gateway's verdict. llama.cpp compiles it to a grammar, so the output cannot leave the schema; a schema it cannot compile is a 400 upstream_rejected carrying the gateway's reason verbatim. {"type": "text"} is the default and is not sent.
bash
curl https://<host>/v1/chat/completions \
-H "Authorization: Bearer $MATTERWAVE_API_KEY" -H "Content-Type: application/json" \
-d '{"model": "grt-nano", "messages": [{"role": "user", "content": "Give a person."}],
"max_tokens": 200, "temperature": 0.2,
"response_format": {"type": "json_schema", "json_schema": {"name": "person",
"schema": {"type": "object", "properties": {"name": {"type": "string"}, "age": {"type": "integer"}},
"required": ["name", "age"], "additionalProperties": false}}}}'json
{"id":"chatcmpl-...","object":"chat.completion","created":1791057533,"model":"grt-nano",
"choices":[{"index":0,"message":{"role":"assistant","content":"{\n \"name\" : \"name\",\n \"age\" : 5\n}"},
"finish_reason":"stop"}],"usage":{"prompt_tokens":30,"completion_tokens":114,"total_tokens":144}}The grammar guarantees the shape, not the sense: grt-nano is a 20M-parameter research model and fills the fields with whatever it has. Give it max_tokens to finish the object -- a finish_reason of "length" is a truncated object, which is your budget, not the schema. Streaming works the same way; the chunks are the JSON text in pieces.
A schema the gateway refuses:
bash
curl ... -d '{"model": "grt-nano", "messages": [{"role": "user", "content": "x"}],
"response_format": {"type": "json_schema", "json_schema": {"name": "p",
"schema": {"type": "object", "properties": {"a": {"type": "nonsense"}}}}}}'json
{"error":{"message":"litellm.BadRequestError: OpenAIException - Unable to generate parser for this template. Automatic parser generation failed: JSON schema error at #/properties/a: unrecognized type nonsense. Received Model Group=grt-nano\nAvailable Model Group Fallbacks=None",
"type":"invalid_request_error","param":null,"code":"upstream_rejected"}}Every structured-output turn is metered like any other.
Tool calling: tools and tool_choice
Tool definitions ({"type": "function", "function": {"name", "description"?, "parameters"?, "strict"?}}) and tool_choice ("none", "auto", "required", or {"type": "function", "function": {"name": "..."}}) are relayed to the gateway as sent. A tool call the model makes comes back in OpenAI's shape -- message.tool_calls[] with finish_reason: "tool_calls", or streamed delta.tool_calls[] fragments (the first for an index carries id and function.name, the rest function.arguments pieces) -- and nothing runs server-side: you run the tool and send the result back as a {"role": "tool", "tool_call_id": ..., "content": ...} message, after the assistant message that carries the tool_calls. Both are relayed whole on the next request. The legacy functions / function_call form is refused (unsupported_parameter).
A named tool_choice must name one of the tools; tool_choice without tools is a 400 invalid_value.
What the current cluster does. grt-nano is served by llama-server without --jinja, which means its chat template has no tool syntax: the server accepts tools, ignores them, and answers in prose with finish_reason: "stop" or "length". Neither llama.cpp nor LiteLLM reports this on the request (LiteLLM's /model/info says supports_function_calling: false, but /v1/models, the list this API relays, carries nothing), so the API cannot refuse tools up front without inventing a capability. It handles the two cases differently:
tool_choiceabsent,"auto"or"none": a text answer is an answer -- that is whatautomeans -- and is returned as text. Withtoolson this cluster that is what you will get.tool_choice: "required"or a named function: you asked for a call, prose is not one, and the response is a 400tool_calls_unsupportedsaying the model answered in text. In a stream the text is withheld and the error object ends the stream instead. The turn is still metered: the tokens were generated.
bash
curl https://<host>/v1/chat/completions \
-H "Authorization: Bearer $MATTERWAVE_API_KEY" -H "Content-Type: application/json" \
-d '{"model": "grt-nano", "messages": [{"role": "user", "content": "Weather in Paris?"}],
"tool_choice": "required",
"tools": [{"type": "function", "function": {"name": "get_weather",
"parameters": {"type": "object", "properties": {"city": {"type": "string"}}, "required": ["city"]}}}]}'json
{"error":{"message":"`tool_choice` required a tool call but the model `grt-nano` answered with text instead; it does not emit tool calls through this gateway. The turn was still metered.",
"type":"invalid_request_error","param":"tool_choice","code":"tool_calls_unsupported"}}Against a gateway whose model does call tools (a LiteLLM route to a hosted provider, or llama-server with --jinja and a tool-capable template), the same request answers:
json
{"id":"chatcmpl-...","object":"chat.completion","created":1791057600,"model":"<model>",
"choices":[{"index":0,"message":{"role":"assistant","content":null,
"tool_calls":[{"id":"call_1","type":"function","function":{"name":"get_weather","arguments":"{\"city\": \"Paris\"}"}}]},
"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":34,"completion_tokens":12,"total_tokens":46}}and the round trip is:
json
{"model": "<model>", "tools": [...],
"messages": [
{"role": "user", "content": "Weather in Paris?"},
{"role": "assistant", "content": null, "tool_calls": [{"id": "call_1", "type": "function",
"function": {"name": "get_weather", "arguments": "{\"city\": \"Paris\"}"}}]},
{"role": "tool", "tool_call_id": "call_1", "name": "get_weather", "content": "sunny, 21C"}]}Tool calls are not recorded in a conversation named by metadata.conversation_id; the turn's text is.
Images
A user message may be a parts array with {"type": "image_url", "image_url": {"url", "detail"?}} entries beside its {"type": "text"} parts, OpenAI's vision shape. The URL is a data:<type>;base64,<payload> URI or an http(s):// address; detail (auto, low, high) is relayed when given. A message that carries an image goes to the gateway as the parts array it was sent as, in order; a message without one is sent as a plain string, byte-identical to before.
Which models. The gate is the one the app's own attachments use (vision.py): a model the gateway declares text-only (supports_vision: false on /v1/models) refuses the image with a 400 unsupported_content_part that names the models the gateway declares vision-capable; a model the gateway says nothing about is let through, because a bare llama.cpp or vLLM declares nothing and the upstream's own answer is the honest one there. GET /v1 lists the declared ids under capabilities.chat_completions.image_input.models. On the current cluster that list is empty and grt-nano declares nothing, so an image reaches it and the answer is whatever a text model makes of the parts array.
Limits. The same as an upload in the app: one image decodes to at most 20 MB (max_image_bytes, counted from the base64 payload without decoding it), a message carries at most 10 images (max_images_per_message), and a data: URI must be one of image/png, image/jpeg, image/gif, image/webp (media_types). An http(s):// URL is passed to the gateway, which fetches it; the app never does, and counts no bytes for it. The refusals come before any token is bought: 413 image_too_large, 422 too_many_images, 422 unsupported_image_type, and a 400 invalid_value for a URL that is neither a base64 data: URI nor an http(s) address.
bash
curl https://<host>/v1/chat/completions \
-H "Authorization: Bearer $MATTERWAVE_API_KEY" -H "Content-Type: application/json" \
-d '{"model": "<vision model>", "max_tokens": 200,
"messages": [{"role": "user", "content": [
{"type": "text", "text": "What is in this picture?"},
{"type": "image_url", "image_url": {"url": "data:image/png;base64,iVBORw0KGgo...", "detail": "low"}}]}]}'With the openai client:
python
import base64
image = base64.b64encode(open("photo.png", "rb").read()).decode()
client.chat.completions.create(
model="<vision model>",
messages=[{"role": "user", "content": [
{"type": "text", "text": "What is in this picture?"},
{"type": "image_url", "image_url": {"url": f"data:image/png;base64,{image}"}},
]}],
)In a conversation named by metadata.conversation_id the user turn records the message's text; the image is not stored, since it is not an attachment the app holds.
Embeddings: POST /v1/embeddings
Proxied to the gateway's embedding model when the deployment names one in MW_EMBEDDINGS_MODEL (the LiteLLM model_name of an embedding entry). OpenAI's body: input (a string, a list of strings, or token arrays), optional encoding_format, dimensions, user. model is accepted for client compatibility; the configured model is what goes upstream, and naming any other is a 404 model_not_found. The response is OpenAI's list of embedding objects with the gateway's usage.
Metering is the chat path's: can_spend before the call, charge after with the gateway's prompt_tokens and zero completion tokens, so the model's price is the input half of its MW_MODEL_PRICES_USD_PER_M row (text-embed=0.02:0:1, say -- dollars per million tokens and a multiplier; an unlisted model pays the dear default). chat:write scope.
The current cluster has no embedding model: llama-server runs without --embeddings and answers 501 to /v1/embeddings (This server does not support embeddings. Start it with --embeddings), so MW_EMBEDDINGS_MODEL is unset and the route says so:
bash
curl https://<host>/v1/embeddings \
-H "Authorization: Bearer $MATTERWAVE_API_KEY" -H "Content-Type: application/json" \
-d '{"model": "text-embed", "input": "hello world"}'json
{"error":{"message":"Embeddings are not configured on this deployment: no embedding model is served (MW_EMBEDDINGS_MODEL is unset).",
"type":"invalid_request_error","param":null,"code":"embeddings_not_configured"}}With a model configured, the same call answers:
json
{"object":"list","data":[{"object":"embedding","index":0,"embedding":[0.0123,-0.0456,...]}],
"model":"text-embed","usage":{"prompt_tokens":2,"total_tokens":2}}Models
GET /v1/models lists what the gateway serves, in OpenAI's shape (object: list, entries with id, object: "model", owned_by: "matterwave"). GET /v1/models/{id} retrieves one. A completion for a model not in that list is a 404 model_not_found. The list is the gateway's: an organisation's own model allow-list, set in the app, is not applied to /v1 yet.
GRT and partner models. MatterWave's own models are grt-nano, grt-nano-pro and grt-nano-max (1x, 5x and 20x; list prices in MW_MODEL_PRICES_USD_PER_M and on /pricing). A gateway may also serve another provider's model -- a partner model (MW_PARTNER_MODELS), served for testing. Partner models are opt-in per plan: a turn on one is refused with 402 unless the caller's plan names it in allowed_models, and no seeded plan does. A plan with no list of its own ("every model") still excludes them. Which models a plan includes, and the price of each, is GET /api/plans (data[].models, models[], each with partner).
An entry carries context_window when the gateway states one (or the deployment's MW_MODEL_CONTEXT_WINDOWS names one); it is null when neither does.
The conversation list: GET /api/conversations
One page of the caller's conversations, pinned first, most recent first -- the sidebar's list. Parameters:
filter:default(unarchived; what the sidebar shows),all,pinnedorarchived.limit: rows per page, 1 to 200; 50 when omitted.cursor: a previous page'snext_cursor. Opaque; a value this list did not write is a 422.
The answer is {"data": [...], "filter", "next_cursor"}; next_cursor is null on the last page. The cursor is a position in the ordering, not an offset, so a conversation created or touched between two requests lands on the first page of a fresh read and never shifts a later page. Both SDKs take limit and cursor on conversations.list and walk every page with conversations.pages.
Long conversations: compaction
grt-nano answers with a 1024-token window, which a conversation in the app fills in two or three turns. The app's own chat routes (/api/conversations/..., not /v1, which is stateless) therefore estimate each next prompt -- the last turn's real token counts plus four characters a token for what follows -- and when it would leave less than a quarter of the window for the answer, fold the older turns into a summary written by the same model before the turn runs. The summary is a message in the conversation (role: "system", one part {"type": "summary", "text", "covers_from", "covers_to", "tokens_before", "tokens_after"}), hung under the leaf it was computed from; covers_from..covers_to are the turns it replaces in the prompt, the turns between covers_to and the summary are kept verbatim, and every original stays in the tree. Summaries are charged like any completion and appear in usage as compaction. A model with no known window is never compacted.
Two SSE events on the send stream (POST /api/conversations/{id}/messages); clients that do not know them ignore them:
compaction{"message": <the summary message>, "covers_from", "covers_to", "tokens_before", "tokens_after"}, before themessageframe of the user turn it made room for. A fold that could not run sendsnotice{"detail", "code": "compaction"}instead and the turn proceeds uncompacted; an org that cannot buy the summary gets the usual budgeterrorand nothing is written. Edit and regenerate never fold; they send the samenoticewhen the branch is over the limit, and the next send folds it.context{"prompt_tokens", "completion_tokens", "context_window"}, just beforedone, whenever the window or the counts are known: the last completion's real size, for a context meter. Adonemessage cut off by the model's token limit hastruncated: trueandfinish_reason: "length".
POST /api/conversations/{id}/compact (optional body {"model"}) runs the same fold now and returns the summary message, the new leaf. 409 when there is nothing to fold (no more than two user/assistant pairs after the latest summary) or the model states no window; 402 when the org's budget is used up; 502 when the model could not summarise, with nothing changed.
GET /api/conversations/{id}/context (optional ?model=) returns {"model", "context_window", "used_tokens", "estimated", "compactable", "keep_pairs"} for a client that draws a meter without a stream. keep_pairs is how many recent user/assistant pairs a fold keeps verbatim (MW_COMPACTION_KEEP_PAIRS), for a client that explains a 409. used_tokens is the last real count after the latest summary, else that summary's tokens_after estimate (estimated: true), else null; context_window is null when the model states none.
The conversation stream: SSE frames
POST /api/conversations/{id}/messages, .../messages/{message_id}/edit and .../messages/{message_id}/regenerate answer 200 text/event-stream with named events, unlike /v1, whose stream is data: lines only. Each frame is event: <name> and one data: line of JSON; : mw-heartbeat comments arrive between frames and a client skips them. A turn reads, in order:
(compaction | notice)? message title? (part_start delta* part_end)* context? (done | error)or a lone moderation_block. Nothing follows done, error or moderation_block.
| Event | Data | When |
|---|---|---|
compaction | {"message", "covers_from", "covers_to", "tokens_before", "tokens_after"} | older turns were folded before this turn was sent (above) |
notice | {"detail", "code": "compaction"} | a fold that could not run, or a branch over the limit that an edit or regenerate cannot fold; detail is the sentence to show |
message | the user message as saved (the shape GET /api/conversations/{id} returns) | the turn's user message, or the edited one |
title | {"title"} | the conversation was named from this turn's first line |
part_start | {"index", "type"}; a tool_call adds id, name, arguments; a tool_result adds id, output, is_error | a part of the assistant message opens; type is text, reasoning, tool_call or tool_result |
delta | {"index", "content"} or {"index", "reasoning"} | text for the open part |
part_end | {"index"} | the part closes |
context | {"prompt_tokens", "completion_tokens", "context_window"}, each null when unknown | just before done, whenever the window or the counts are known |
done | the assistant message as saved; truncated: true and finish_reason: "length" when the token limit cut it | the turn ended |
error | {"detail"}, with "code": "budget" when the org cannot buy the turn | the upstream died (what arrived is saved as a truncated message), the budget is spent, or a resume found nothing |
moderation_block | {"detail", "code": "moderation", "request_id"} | MatterWave would not send the message to a model; nothing was appended. A different fact from the model declining, so a different frame |
Resume. The frames of the generation itself (part_start, delta, part_end, context) carry id: <generation>:<seq>. A client whose body drops before the terminal frame re-sends the same request to the send route with Last-Event-ID set to the last id it saw; the server replays the rest of the same generation and then its terminal frame, and never generates again. An id that no longer resolves (a restart, a pruned buffer) is an error frame, not a fresh turn. A drop before any frame carried an id cannot be resumed without re-sending, which would bill the prompt twice; the SDKs refuse to.
Unknown events are data. A client ignores (or surfaces as unknown) an event it does not know, so a frame can be added without breaking one. The frame set and each frame's schema are in docs/developers/openapi.json under x-sse-events, which both SDKs' contract tests hold their typed frames to: a new frame lands in the server, this section, the document and both SDKs together.
What is OpenAI-compatible
Request: model, messages (roles system, user, assistant, tool; content as a string or as {"type": "text"} and {"type": "image_url"} parts; tool_calls, tool_call_id and name on the tool-loop messages), stream, stream_options.include_usage, max_tokens / max_completion_tokens, temperature, user, tools, tool_choice, response_format. Unknown fields are ignored, as OpenAI ignores them.
Response: chat.completion and chat.completion.chunk objects (with tool_calls where the model made them), data: [DONE], the {"error": {"message", "type", "param", "code"}} envelope on every error. finish_reason is the gateway's: stop, length, or tool_calls whenever calls came back.
| Status | type | code | When |
|---|---|---|---|
| 400 | invalid_request_error | invalid_value | a malformed body (OpenAI's 400, not FastAPI's 422): a json_schema without a schema, a tool_choice naming a tool that is not in tools, a tool message without tool_call_id |
| 400 | invalid_request_error | unsupported_content_part | an image_url part sent to a model the gateway declares text-only (the message names the models it declares vision-capable, or says there are none), or a part of any type but text and image_url |
| 413 | invalid_request_error | image_too_large | a data: image past max_image_bytes (20 MB decoded) |
| 422 | invalid_request_error | too_many_images | more than max_images_per_message (10) images in one message |
| 422 | invalid_request_error | unsupported_image_type | a data: URI whose media type is not one of media_types |
| 400 | invalid_request_error | unsupported_parameter | the legacy functions / function_call form, n > 1 |
| 400 | invalid_request_error | upstream_rejected | the gateway refused the request with a 400 of its own; the message is the gateway's reason (a schema it cannot compile, a prompt past the context window) |
| 400 | invalid_request_error | tool_calls_unsupported | tool_choice demanded a call and the model answered in text |
| 401 | authentication_error | invalid_api_key | no key, or a wrong, expired or revoked one |
| 403 | permission_error | insufficient_scope | a key without chat:write (or chat:read for models) |
| 404 | invalid_request_error | model_not_found | a model the gateway does not serve; on /v1/embeddings, a model other than the configured one |
| 404 | invalid_request_error | embeddings_not_configured | /v1/embeddings with MW_EMBEDDINGS_MODEL unset |
| 404 | invalid_request_error | conversation_not_found | metadata.conversation_id is not the key owner's |
| 429 | insufficient_quota | insufficient_quota | the org cannot spend: its included tokens are used up on a plan without overage, or its overage cap is reached; or the key's owner is at the monthly spend cap an org admin set for them (the message says which) |
| 429 | rate_limit_error | rate_limit_exceeded | the key's window is full: its own rpm_limit, or 60 a minute when it has none (Retry-After and the X-RateLimit-* headers are set; see Rate limits) |
| 502 | server_error | upstream_error | the model gateway failed, or refused with something other than a 400 |
In a stream, a failure after the headers are sent arrives as the same {"error": ...} object in a data: frame, and the stream ends without [DONE].
What differs from OpenAI
- Stateless by default. A call stores nothing. To record the turn in a conversation the key's owner can see in the app, send
"metadata": {"conversation_id": "<uuid>"}. The request'smessagesare still the whole prompt; stored history is never spliced in. - Every call is metered and budgeted exactly like a turn in the app: refused before any token is bought when the org cannot spend (included tokens used up on a plan without overage, or the overage cap reached), charged with the gateway's token counts after.
user, when sent, is recorded on the usage event beside the key's owner. Embeddings are charged on prompt tokens alone. - Tools are never executed here. The app's own tool loop (web search and the rest) belongs to turns taken in the app; on this endpoint
toolsare yours, and so is running them. - Custom instructions do not apply. A user's custom instructions and a conversation's own system prompt shape turns taken in the app; this endpoint relays the
messagesyou send as the whole prompt, and nothing is prefixed to them. Seedocs/custom-instructions.md. - Every key is rate-limited, at its own
rpm_limitor at 60 a minute; the limit is reported on every response (Rate limits, below). top_pandstopare accepted and ignored.temperaturedefaults to 0.7, the app's own default, not 1.0.modelmay be omitted; the deployment's default model is used.GET /v1(no auth) describes the API, its capabilities and the version promise below.
Rate limits
A key's window is a sliding minute, counted per key. The ceiling is the key's own rpm_limit, set when the key is created or changed later (Settings -> API keys, or "rpm_limit" on POST /api/keys and PATCH /api/keys/{id}; a change applies to the next request) and unset by default. On /v1 a ceiling of 60 requests a minute per key applies as well, so a key without its own limit gets 60 there, and a key with one is held to whichever is tighter. Off /v1 (/api/conversations* and the rest) only the key's own rpm_limit applies. The organisation's plan limits still apply at the model gateway, below the key's.
Every response that passed a limiter -- a 200, a stream, or the 429 that refused it -- carries the window that applied (the tightest, when more than one did), in the headers OpenAI and Anthropic send:
| Header | Meaning |
|---|---|
X-RateLimit-Limit | the ceiling of the window that applied, requests per minute |
X-RateLimit-Remaining | requests left in it after this one; 0 on a 429 |
X-RateLimit-Reset | seconds until the oldest request in the window ages out and a slot is back |
Retry-After | on a 429 only: the same number of seconds as X-RateLimit-Reset |
Pace from Remaining and Reset rather than from the 429. Both SDKs put the three on the RateLimitError they raise for a 429 (rate_limit / rateLimit), beside retry_after / retryAfter. The headers are exposed to cross-origin browser callers (Access-Control-Expose-Headers).
The version promise
v1 is a namespace, not a release train. Within it: additive changes ship any time; no field is ever removed or retyped; deprecations carry Deprecation and Sunset headers (RFC 8594) with 12 months notice; anything uncertain lives under /v1/preview/ and may break without notice. Clients must tolerate unknown fields in every response -- that is what makes additive evolution possible.
Device login
A terminal or an editor without a browser gets a key by a code the person approves in one (RFC 8628's shape, served by the API in every auth mode). The key it receives is an ordinary API key for the approving person, chat:read and chat:write, named Device: <client_name>; it is listed in Settings -> API keys and revoked there. On /api/conversations* a key needs chat:read for a GET and chat:write for anything else; a session is unaffected.
POST /api/auth/device(no auth; 20 an hour per address), body optional{"client_name": "matterwave-tui on laptop"}->{"device_code", "user_code": "ABCD-2345", "verification_uri": "https://<host>/device", "verification_uri_complete": "https://<host>/device?user_code=ABCD-2345", "expires_in": 600, "interval": 5}. Show the person the URL and the code; keepdevice_codeto yourself.POST /api/auth/device/token(no auth; 60 a minute per address),{"device_code"}(and optionally"grant_type": "urn:ietf:params:oauth:grant-type:device_code"). Until there is an answer: 400{"error", "error_description", "interval"}witherrorone ofauthorization_pending(keep polling),slow_down(polled early;intervalgrew by 5, use it),expired_token(unknown, expired or already delivered: start again) oraccess_denied. Once approved, exactly one poll gets 200{"token_type": "Bearer", "access_token": "mw_...", "scopes": ["chat:read", "chat:write"], "expires_in": null}; the next getsexpired_token. Over the limit is the usual 429 withRetry-After; a store outage is 503.POST /api/auth/device/approveand/deny(a signed-in browser session with the CSRF header; an API key gets 403),{"user_code", "name"?}->{"status": "approved" | "denied", "client_name"}; approve also returns the new key's listing (key, never its value). 404 for an unknown or expired code, 409 once decided.POST /api/auth/device/lookup{"user_code"}->{"user_code", "client_name", "expires_in"}is what the/devicepage shows first.
GET /api/auth-config reports device_authorization_endpoint: "/api/auth/device", and under OIDC with MW_OIDC_NATIVE_CLIENT_ID set, native_client_id and issuer_device_authorization_endpoint for a client that signs in at the IdP instead.
python
import time, httpx
api = httpx.Client(base_url="https://<host>")
start = api.post("/api/auth/device", json={"client_name": "my-script"}).json()
print(f"Open {start['verification_uri']} and enter {start['user_code']}")
interval, deadline = start["interval"], time.monotonic() + start["expires_in"]
while time.monotonic() < deadline:
time.sleep(interval)
resp = api.post("/api/auth/device/token", json={"device_code": start["device_code"]})
if resp.status_code == 200:
key = resp.json()["access_token"] # store it (a keyring, not a dotfile)
break
error = resp.json()["error"]
if error == "slow_down":
interval = resp.json()["interval"]
elif error != "authorization_pending":
raise SystemExit(error) # expired_token or access_denied: start againThe native API
Everything the web app does goes through /api/*, and the parts a script needs are in docs/developers/openapi-full.json (also downloadable from the developer pages): every route except the operator console, the sign-in and sign-up flows, the health probes, the edge mirror and Stripe's inbound webhook, filed by area (keys, webhooks, usage, library, shares, attachments, account, mfa, org, members, billing, budget, conversations, device-login, ...). openapi.json is the same document filtered to /v1. An API test fails when a route is added without being exported or named as internal, and when the committed file differs from a fresh export, so the document is the route table.
Each operation lists the credentials it accepts (security):
apiKey: a MatterWave key asAuthorization: Bearer mw_.... An empty scope list means any key./api/conversations*needschat:readfor a GET andchat:writeotherwise; minting, editing and rotating keys needskeys:manage;/api/org/webhooks*needswebhooks:manageand/api/org/usage/usage.csvneedusage:read. Those two scopes are on top of the org role the route asks of a session, checked for the key's owner: a scope never widens what the owner may do.sessionCookie/idpToken: a person, by the browser session or by an access token from the deployment's identity provider sent as a bearer. Routes that manage the organisation (members, invites, SSO and domains, org settings, setting budgets, invoices, checkout and the portal) take only these and answer an API key with 403. A cookie session must echo themw_csrfcookie inX-CSRF-Tokenon every non-GET request; a bearer request carries no cookie and needs neither.
The examples use $MW for the host, $MW_KEY for an API key and $MW_TOKEN for a person's IdP access token. Errors on /api/* are FastAPI's {"detail": ...}, not OpenAI's envelope.
Keys
GET /api/keys lists the org's keys (prefix, scopes, expiry, last_used_at, revoked_at; never the value); a key without keys:manage sees only its own row. POST /api/keys takes name and optionally scopes (default chat:read, chat:write), expires_in_days or expires_at, allowed_cidrs and rpm_limit, and returns the value as key, once. PATCH /api/keys/{key_id} changes the same fields; DELETE /api/keys/{key_id} revokes (a key may always revoke itself). Rotating keeps the id, name, scopes and limits and returns a new key once; the old secret keeps working until previous_valid_until:
sh
curl -X POST "$MW/api/keys/$KEY_ID/rotate" -H "Authorization: Bearer $MW_KEY" # needs keys:manage
# -> {"id", "name", "prefix", "scopes", ..., "previous_valid_until": "...", "key": "mw_..."}Webhooks
An owner or admin (a session, or an API key with webhooks:manage whose owner is one) registers an endpoint for any of api_key.revoked, budget.exhausted, budget.warn, conversation.compacted, conversation.created, member.joined, member.removed, message.completed and plan.changed. The signing secret is in the create and POST /api/org/webhooks/{endpoint_id}/rotate responses only. .../test sends a ping, GET .../deliveries is the delivery log and .../deliveries/{delivery_id}/redeliver retries one.
sh
curl -X POST "$MW/api/org/webhooks" -H "Authorization: Bearer $MW_KEY" \
-H "Content-Type: application/json" \
-d '{"url": "https://example.com/hooks/mw", "events": ["budget.warn", "member.joined"], "description": "ops"}'
# -> 201 {"id", "url", "events", "enabled": true, ..., "secret": "...", "shown_once": true, "verification": {...}}Usage
GET /api/me/usage (any credential, API keys included) is the org's balance this period: used, included, remaining, percent, warn, in_overage, exhausted, period_start, resets_at and plan, with the per-day history. GET /api/org/usage?period=2026-10 splits one calendar month per member (weighted_tokens, tokens, cost_micros, turns, last activity, and totals); owners and admins see everyone, a member their own row, and an API key needs usage:read and sees what its owner would. GET /api/org/usage.csv is the same rows as a file, with the columns subject, email, name, weighted_tokens, tokens, cost_usd, turns and last_active:
sh
curl "$MW/api/me/usage" -H "Authorization: Bearer $MW_KEY"
curl -o usage-2026-10.csv "$MW/api/org/usage.csv?period=2026-10" -H "Authorization: Bearer $MW_KEY" # usage:readLibrary
GET /api/library returns folders, tags and assignments (which tags each conversation carries) in one read. POST /api/library/folders and POST /api/library/tags take {"name"}; PUT /api/library/conversations/{conversation_id}/folder {"folder_id"} (null to unfile) and .../tags {"tag_ids": [...]} file a conversation. GET /api/search?q=...&limit=20&cursor=... is full-text search over titles and messages: {"data": [...], "query", "next_cursor"}.
sh
curl -X POST "$MW/api/library/folders" -H "Authorization: Bearer $MW_KEY" \
-H "Content-Type: application/json" -d '{"name": "Research"}'Shares
POST /api/conversations/{conversation_id}/share snapshots the conversation's active path and returns the link's token once, with path (/share/<token>) and the share row; optional expires_in_s (60 to 31536000). GET /api/conversations/{conversation_id}/shares and GET /api/me/shares list links (status, view_count, expires_at; never the token), DELETE /api/shares/{share_id} revokes, and GET /api/share/{token} is the anonymous read.
sh
curl -X POST "$MW/api/conversations/$CONV_ID/share" -H "Authorization: Bearer $MW_KEY" \
-H "Content-Type: application/json" -d '{"expires_in_s": 604800}' # needs chat:writeAttachments
POST /api/attachments is a multipart upload (file, optional ?conversation_id=); the type is decided from the bytes, not the part header. The answer is the row (id, filename, content_type, size_bytes, scan_status, ...) with a short-lived download_url, and GET /api/attachments/{attachment_id} mints a fresh one. The URL is signed for the uploader and still needs their credential. GET /api/attachments/limits/me reports the caller's limits.
sh
curl -X POST "$MW/api/attachments" -H "Authorization: Bearer $MW_KEY" -F "file=@report.pdf"Account
GET /api/me is who the credential belongs to; PATCH /api/me {"full_name"} renames; GET/PUT /api/me/preferences, GET /api/me/sessions and DELETE /api/me/sessions/{session_id} manage the rest. GET /api/me/export is everything the account has stored, as one JSON document (a key needs chat:read). For a large account, POST /api/me/export builds the same document in the background instead: it answers 202 with an id, GET /api/me/export/{id} reports pending, ready (with a signed download_url), failed or expired, and the link is mailed too. The download needs the link's signature and the owner's credential, and works for 7 days; one export at a time per person. DELETE /api/me deletes the account. Changing the password (POST /api/me/password) is a person's, never a key's.
sh
curl -o matterwave-export.json "$MW/api/me/export" -H "Authorization: Bearer $MW_KEY"Org
GET /api/org is the organisation and the caller's role; PATCH /api/org {"name"} renames it. Members (/api/org/members), invites (/api/org/invites), SSO (/api/org/idp, /api/org/domains, /api/org/sso), required MFA (/api/org/mfa), the model allowlist (/api/org/models) and the audit log (/api/org/audit, /api/org/audit.csv) live under the same prefix; all of them refuse an API key.
sh
curl -X PATCH "$MW/api/org" -H "Authorization: Bearer $MW_TOKEN" \
-H "Content-Type: application/json" -d '{"name": "Acme Research"}'Billing
GET /api/billing/status (any member, any credential) is the plan, its entitlements, billing_enabled, how the org pays and any dunning state. GET /api/plans (no auth) is the price list. An owner or admin gets GET /api/billing/invoices, and POST /api/billing/checkout{"plan", "annual"} and POST /api/billing/portal, each answering {"url"} to redirect to.
sh
curl "$MW/api/billing/status" -H "Authorization: Bearer $MW_KEY"