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_appfrom pyrula.agents.llm import OpenAILLM
app = create_app( agents=[support], # or omit to use the registry store=store, llm=OpenAILLM(model="gpt-6-luna"),)uvicorn myapp:appRoutes
Section titled “Routes”Under the prefix (default /agents). Submit takes the agent’s parameters as a JSON body, plus an optional metadata object:
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.
| Method | Path | Purpose |
|---|---|---|
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}/events | Stored events, paged. |
GET | /agents/{agent}/turns/{turn_id}/replay | Replay events from the start. |
POST | /agents/{agent}/turns/{turn_id}/resume | Answer a ctx.interrupt wait. |
POST | /agents/{agent}/turns/{turn_id}/signal | Deliver 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}/terminate | End now without waiting for a worker; optional {"reason": ...}. |
GET | /agents/agents | List served agents. |
GET | /agents/turns | List turns, paged. |
POST | /agents/threads | Create a thread ({"agent_name": ...}). |
GET / DELETE | /agents/threads/{thread_id} | Read or delete a thread. |
GET | /agents/threads/{thread_id}/turns | A 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.
curl -X POST localhost:8000/agents/support/turns/$TURN_ID/resume \ -H 'content-type: application/json' \ -d '{"action": "approve", "wait_id": "b6f2...b0"}'Threads
Section titled “Threads”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.
Reserved agent names
Section titled “Reserved agent names”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.
What a client sees
Section titled “What a client sees”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.
Options
Section titled “Options”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.
Mounting into an existing app
Section titled “Mounting into an existing app”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.