Generating clients
Responsibility: explain how to call a served actor from a client generated in any language.Authority: API guidance.
Owner role: API/SDK. Change policy: a change requires compatibility review against docs/api/versioning.md.
Actors.serve with openapi: { path: "/openapi.json" } serves an OpenAPI 3.1 document (JSON Schema 2020-12) generated from the served actors. Any OpenAPI 3.1 generator can produce a client from it, for example:
operationId <Actor>.<Member>. durable.protocol (GET /protocol) and durable.commandIds (POST /command-ids) are the protocol routes.
Command ids
Every command requires anIdempotency-Key header, the command id v1.<issuedAtMs>.<expiresAtMs>.<uuidv4>. The server never mints one for a command, because a client that lost the reply to a server-minted id could not retry it.
- Mint an id before the first attempt, either with
POST /command-ids(authenticated like a command; answers{ commandId }from the database clock and writes nothing) or locally fromGET /protocol’snowandretryWindowMs, with a fresh UUIDv4. - Store the id with the pending operation, and send the same id and the same body on every retry. The same id with the same body returns the stored result without running the command again; the same id with a different body is
409 CommandConflict. - The response echoes the id in
x-request-id. - An id older than the retry window is
410 CommandExpired: surface it to the caller rather than minting a replacement, because an earlier attempt may have committed.
Retries
Error bodies are{ _tag: "ActorError", reason, isRetryable, retryAfter? }. Retry with the same id, never a new one:
Stop retrying once the id’s
expiresAt is less than a second away on the client’s clock corrected by durable-now.
Queries take no Idempotency-Key and can be retried freely.
The Promise client and the Python runtime run one corpus of scripted exchanges, protocol/exchanges.json, and must agree on every attempt count, command id, outcome and wait bound in it. One intended difference remains: the Promise client retries until its call timeout (60 seconds by default) or the id’s expiry bound, while the Python runtime also stops after max_attempts (8 by default), because it has no call deadline; and the Python runtime refreshes an expired credential only when token is a function it can call again.
Authentication
The document’ssecuritySchemes come from the server’s auth provider, and every operation except durable.protocol lists them as alternatives, any one of which authenticates:
A server under
Auth.none declares no schemes. Never put a credential in the URL. A 401 carries www-authenticate: Bearer whatever the scheme.
MCP
Actors.serve with mcp: { path: "/mcp" } serves the same members as MCP tools, derived from the same OpenAPI document (ADR 0060). The endpoint speaks MCP revision 2026-07-28 over Streamable HTTP and nothing earlier: send each JSON-RPC message as its own POST with MCP-Protocol-Version, Mcp-Method, and (for tools/call) Mcp-Name headers, and the protocol version and client capabilities in params._meta. Send the same credentials as any route.
- Tool names are operation ids,
<Actor>.<Member>.durable.commandIdsmints a command id. - Arguments:
id(the actor’s key; absent for a singleton),commandId(commands and reducers only), andinput. - A command’s
commandIdis the same idIdempotency-Keycarries. Mint it once withdurable.commandIds, keep it with the pending call, and send it with the sameinputon every retry. The JSON-RPCidis not a command id and may change on each attempt. A call retried over HTTP with the same id replays the same receipt. - A result is
isError: falsewith the output as JSON text (and asstructuredContentwhen the tool has anoutputSchema), orisError: truewith the text holding exactly the error body of the HTTP route: a declared error,{ _tag: "ActorError", reason, isRetryable, retryAfter? }, or{ _tag: "Defect", traceId }. The retry table above applies toisRetryableandretryAfter. Failed authentication is an HTTP401with theUnauthorizedbody, not a JSON-RPC error. - Internal commands, connections, streams, feeds, and content have no tool, and a call to one is
-32602 Unknown tool.
Python
packages/python-client generates a Python 3.9+ package, using only the standard library, from an OpenAPI document (ADR 0060):
models.py (a TypedDict for every schema the public members use), client.py (one class per actor, one method per public command, reducer, and query, in snake case), and _runtime.py, the fixed runtime that holds the command-id rules above.
base_urlis the origin: each method carries its full path,basePathincluded. A method takes the actor id (unless the actor is a singleton), the input (unless the member has none), and, for commands, a keyword-onlycommand_id. Without one, the runtime mints an id and keeps it across every retry of that call. Keep an id you mint yourself with the pending operation.tokenis a bearer string or a function called before every attempt; a command refused with401 expiredis retried once, with a fresh call to it and the same id.- Failures are exceptions: framework reasons are
ActorErrorsubclasses (CommandExpired,CommandConflict,Unauthorized, …) withtag,code,status,is_retryable, andretry_after_ms; a declared failure isDeclaredErrorwithtagandbody(DECLARED_ERRORSmaps each operation to its tags); a defect isDefectwithtrace_id; no reply after every attempt isTransportError.CommandExpiredis raised, never replaced. OPERATIONSmaps each operation id to its attribute and method, so the names agree with OpenAPI and the other clients. Internal commands, streams, feeds, connections, and content have no method.- Not included:
durable-min-versionread-your-writes tokens.