Skip to content
Pyrula
Pre-release: the packages are not on PyPI yet, so pip install does not resolve. Build from source to try it today.

HTTP

create_app returns a FastAPI app. llm is required. Omit agents to serve every imported @agent (none imported: ValueError). Omit store and you get an InMemoryStore with a warning; nothing survives a restart.

from pyrula.agents import create_app
from pyrula.agents.llm import OpenAILLM
app = create_app(
agents=[support], # or omit to use the registry
store=store,
llm=OpenAILLM(model="gpt-6-luna"),
)
Terminal window
uvicorn myapp:app

Under the prefix (default /agents). Submit takes the agent’s parameters as a JSON body, plus an optional metadata object:

Terminal window
curl -X POST localhost:8000/agents/support \
-H 'content-type: application/json' -H 'Idempotency-Key: order-1042' \
-d '{"message": "where is my order?", "metadata": {"source": "web"}}'
# {"turn_id": "...", "stream_path": "/agents/support/stream/...", "status": "..."}

A repeated Idempotency-Key ([A-Za-z0-9._-], up to 255, kept idempotency_ttl_s) returns the original turn; reusing it with a different body is a 409.

MethodPathPurpose
POST/agents/{agent}Submit a turn. Returns a turn id and stream path.
GET/agents/{agent}/stream/{turn_id}Follow the turn live over SSE.
GET/agents/{agent}/turns/{turn_id}Current status.
GET/agents/{agent}/turns/{turn_id}/eventsStored events, paged.
GET/agents/{agent}/turns/{turn_id}/replayReplay events from the start.
POST/agents/{agent}/turns/{turn_id}/resumeAnswer a ctx.interrupt wait.
POST/agents/{agent}/turns/{turn_id}/signalDeliver a signal to ctx.wait_for_signal.
DELETE/agents/{agent}/stream/{turn_id}Cancel: stop at the next step boundary (needs a worker).
POST/agents/{agent}/turns/{turn_id}/terminateEnd now without waiting for a worker; optional {"reason": ...}.
GET/agents/agentsList served agents.
GET/agents/turnsList turns, paged.
POST/agents/threadsCreate a thread ({"agent_name": ...}).
GET / DELETE/agents/threads/{thread_id}Read or delete a thread.
GET/agents/threads/{thread_id}/turnsA thread’s turns.
POST/agents/{agent}/threads/{thread_id}Submit a turn on a thread.

A dropped client can reconnect to the stream or replay and catch up from its last event id (Last-Event-ID header). In separate mode, events reach the stream at step boundaries or in 64-event batches.

/resume needs the action and the wait_id from the run:interrupted event. A stale wait_id (already answered, or superseded) gets a 409.

Terminal window
curl -X POST localhost:8000/agents/support/turns/$TURN_ID/resume \
-H 'content-type: application/json' \
-d '{"action": "approve", "wait_id": "b6f2...b0"}'

A thread groups turns into a conversation. A turn on a thread reads earlier ones with ctx.thread.last_result() and ctx.thread.history(limit=...) (completed turns only, oldest first, never the calling turn).

A thread id is 1 to 128 characters of letters, digits, ., _ and -; anything else is 422 on create or submit and 404 on read or delete. POST /agents/threads takes an optional thread_id. Repeating the same create returns the thread. A taken id answers 409 thread_exists, which reveals that the id exists even for someone else’s thread, so prefer generated ids.

An agent name is a top-level URL segment. @agent refuses agents, threads, turns, a2a, branches, approvals, ag-ui and any name starting with _; new framework routes use only those.

With auth on, another caller’s turn or thread answers 404, and idempotency keys belong to their caller.

Everything sent to a client (stream, replay, events) is redacted on the way out; the stored journal keeps raw events.

Error text quotes whatever the failure touched, so by default these fields are replaced with [withheld] and the event carries details_withheld: true: step:error.error, tool:result.error, agent:call_error.error, run:update_failed.detail, the error, cause and output of a failed tool step:done, and the run:error message. A run:error detail keeps its structure (error class or code, status, counters, each validation error’s type and loc); other values become [withheld]. A kept string must look like an identifier: at most 64 characters, with no run of 16+ letters and digits containing a digit, so UUIDs and names like S3UploadFailedError are withheld too. Tracebacks never ship. AppOptions(expose_error_details=True) sends the text instead, redacted and capped.

AppOptions(redaction_hook=...) runs last, on a copy of each payload. A hook that raises or returns a non-dict or non-JSON value gives the client {"redacted": true, "reason": "redaction_failed"} for that event. The hook affects only HTTP clients; events sent to Pyrula Cloud follow the capture level and the process-wide redaction policy (write an equivalent RedactionPolicy(mask=...) to mask the same field there).

Each router allows sse_max_streams (256) open streams and, with auth on, sse_max_streams_per_owner (16) per caller. More get a 503 with Retry-After.

import os
from pyrula.agents import create_app, AppOptions, APIKeyAuth
app = create_app(
store=store,
llm=llm,
options=AppOptions(
prefix="/agents",
auth=APIKeyAuth({"web-app": os.environ["PYRULA_API_KEY"]}),
cors_origins=["https://app.example.com"],
deployment_mode="separate",
),
)

Other fields: idempotency_ttl_s (24 h), sse_keepalive_interval (15 s), sse_max_duration_s (1 h; the stream ends cleanly and the client resumes from its last event id), cancel_on_reader_disconnect, cloud (default on), the probes (include_health, include_ready, health_path, ready_path; /readyz goes 503 while draining, on a store binding mismatch or an unreachable store), deps (handed to every turn as ctx.deps; declare its type with @agent(deps_type=...)), and max_queue_depth (a submit gets 429 with Retry-After while that many turns are pending).

Binding a non-loopback address with no auth: pass host= so create_app can refuse it (Auth).

deployment_mode is "embedded" (default; turns run in the web process, as in pyrula dev) or "separate" (the app only submits and streams; workers execute). Other values raise ValueError.

Use AgentRouter, the APIRouter create_app mounts:

from pyrula.agents import AgentRouter, AgentRouterConfig
router = AgentRouter(worker=worker, config=AgentRouterConfig(...))
router.attach(existing_app, prefix="/agents")

Use router.attach, not include_router: attach wires the router’s startup and shutdown into the app’s lifespan, which an embedded worker needs to recover in-flight turns at start and drain tasks at stop.

View this page as Markdown