Skip to main content

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:
Each actor member is one operation with operationId <Actor>.<Member>. durable.protocol (GET /protocol) and durable.commandIds (POST /command-ids) are the protocol routes.

Command ids

Every command requires an Idempotency-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 from GET /protocol’s now and retryWindowMs, 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’s securitySchemes 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.commandIds mints a command id.
  • Arguments: id (the actor’s key; absent for a singleton), commandId (commands and reducers only), and input.
  • A command’s commandId is the same id Idempotency-Key carries. Mint it once with durable.commandIds, keep it with the pending call, and send it with the same input on every retry. The JSON-RPC id is 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: false with the output as JSON text (and as structuredContent when the tool has an outputSchema), or isError: true with 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 to isRetryable and retryAfter. Failed authentication is an HTTP 401 with the Unauthorized body, 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):
The package has 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_url is the origin: each method carries its full path, basePath included. A method takes the actor id (unless the actor is a singleton), the input (unless the member has none), and, for commands, a keyword-only command_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.
  • token is a bearer string or a function called before every attempt; a command refused with 401 expired is retried once, with a fresh call to it and the same id.
  • Failures are exceptions: framework reasons are ActorError subclasses (CommandExpired, CommandConflict, Unauthorized, …) with tag, code, status, is_retryable, and retry_after_ms; a declared failure is DeclaredError with tag and body (DECLARED_ERRORS maps each operation to its tags); a defect is Defect with trace_id; no reply after every attempt is TransportError. CommandExpired is raised, never replaced.
  • OPERATIONS maps 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-version read-your-writes tokens.