# HTTP

> create_app serves your agents over HTTP and SSE.

Source: https://docs.pyrula.io/agents/serving/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.

```python
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"),
)
```

```bash
uvicorn myapp:app
```

## Routes

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

```bash
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.

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

### 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

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

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](https://docs.pyrula.io/getting-started/what-leaves-your-runtime.md) 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

```python
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](https://docs.pyrula.io/agents/serving/auth.md#running-without-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](https://docs.pyrula.io/agents/serving/workers.md) execute). Other values raise `ValueError`.

## Mounting into an existing app

Use `AgentRouter`, the `APIRouter` `create_app` mounts:

```python
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.
