Axum Server Runtime
paigasus-helikon-runtime-axum mounts one or more Agents on an axum router and exposes them over HTTP, Server-Sent Events (SSE), and WebSocket. It is the self-hosted alternative to paigasus-helikon-runtime-tokio's in-process runner — suitable when you need a network-accessible agent server with replayable runs.
Enable it via the runtime-axum facade feature:
cargo add paigasus-helikon --features openai,runtime-axum
Or depend on the crate directly:
cargo add paigasus-helikon-runtime-axum
Quick start
use std::sync::Arc;
use paigasus_helikon::runtime_axum::AgentServer;
#[tokio::main]
async fn main() -> anyhow::Result<()> {
let server = AgentServer::<()>::builder()
.with_default_context() // Ctx = () satisfies Default
.agent(Arc::new(my_agent))
.build()?;
server.serve("0.0.0.0:8080").await?;
Ok(())
}
AgentServer::builder() returns an AgentServerBuilder that lets you chain configuration. Once built, call .serve(addr) to bind and start serving, or .router() to embed the axum Router inside a larger application.
HTTP endpoints
The server exposes four routes (six endpoint modes) under a flat prefix (no configurable base path):
| Method | Path | Purpose |
|---|---|---|
POST | /agents/{name}/runs | Start a run — one-shot (default), SSE (?stream=sse), or async (?mode=async) |
GET | /agents/{name}/runs/{id}/events | Replay a run's event log over WebSocket |
GET | /agents | List all registered agents and their descriptions |
GET | /openapi.json | OpenAPI 3.1 schema (requires the openapi feature, enabled by default) |
Response shapes for POST /agents/{name}/runs
The ?stream= and ?mode= query parameters select the response transport:
| Query | Status | Body |
|---|---|---|
| (none) | 200 OK | RunResponse JSON — full event list + final output, after run completes |
?stream=sse | 200 OK | text/event-stream — each AgentEvent as an SSE frame, streamed live |
?mode=async | 202 Accepted | AsyncAccepted JSON — { "run_id": "…" } returned immediately |
All responses include an X-Run-Id response header carrying the UUID of the run.
Request body
POST /agents/{name}/runs accepts JSON in either of two shapes:
{ "input": "What is my dining budget this month?" }
or an explicit multi-turn message list:
{ "messages": [ { "type": "user_message", "content": [{ "type": "text", "text": "…" }] } ] }
Session affinity
Callers pass X-Session-Id: <opaque-string> to pin a run to a named session. The default InMemorySessionProvider maps that header to a shared MemorySession; two requests with the same X-Session-Id share history and are serialised (the second waits until the first run completes) to avoid race conditions on the shared session state.
Requests without X-Session-Id receive a fresh anonymous session that is never stored.
Replayable runs
Every run — regardless of the transport used to start it — drains into an in-memory EventLog. The key properties:
- One-shot mode subscribes to the log and blocks until the run is terminal.
- SSE mode subscribes and streams events as they arrive; a client reconnect to
GET /agents/{name}/runs/{id}/events(WebSocket) replays already-emitted events before tailing live ones. - Async mode returns
202immediately and the run continues in a background task. The log survives connection close. - Cancellation: one-shot and SSE responses hold a
CancellationTokendrop-guard so a client disconnect cancels the run. The async mode deliberately does not, so the run outlives the connection. - Stream error frames: if a run ends without a real terminal event — e.g. its runner fails to start after the run is registered, or its stream ends early — the SSE and WebSocket transports emit a final synthetic
run_failedevent before closing, so a streaming client always observes a terminal frame. (One-shot mode instead returns HTTP500on a start error, or200with a partial result when a started run ends without a terminal event.)
Completed runs are retained for a configurable period and count:
| Builder method | Default | Effect |
|---|---|---|
.run_retention(Duration) | 5 minutes | How long completed runs stay in the registry |
.max_retained_runs(usize) | 1 024 | Cap on retained completed runs (oldest evicted first) |
.max_sessions(usize) | 4 096 | Cap on tracked named sessions (oldest evicted first) |
A background sweeper task is started by .serve() / .serve_with_listener() to prune expired entries.
Provider traits
Three traits are the extension points for operator customisation:
SessionProvider
#[async_trait]
pub trait SessionProvider: Send + Sync {
async fn session(&self, id: Option<&str>) -> Result<Arc<dyn Session>, ServerError>;
}
Maps the X-Session-Id header value to a Session. The built-in InMemorySessionProvider is the default; swap it for a PostgresSession or RedisSession backend via .session_provider(Arc::new(...)) on the builder.
ContextProvider<Ctx>
#[async_trait]
pub trait ContextProvider<Ctx>: Send + Sync {
async fn build(
&self,
parts: &axum::http::request::Parts,
session: Arc<dyn Session>,
cancel: CancellationToken,
) -> Result<RunContext<Ctx>, ServerError>;
}
Builds the per-request RunContext. Implement this to inject request-scoped data into Ctx — for example, JWT-parsed tenant identity — and to tighten the permission posture for network clients (see the security note below). When Ctx: Default, use the convenience shortcut .with_default_context() on the builder instead of supplying a custom implementation.
AuthLayer
#[async_trait]
pub trait AuthLayer: Send + Sync {
async fn authenticate(&self, parts: &mut axum::http::request::Parts) -> Result<(), AuthRejection>;
}
Called before every request. Return Ok(()) to allow; return Err(AuthRejection { status, message }) to reject. On success, you may insert an identity value into parts.extensions — the ContextProvider receives the same parts, creating the auth→context bridge. When .auth(...) is not called on the builder, all requests are admitted without authentication.
Security note
The DefaultContextProvider leaves all RunContext settings at their core defaults. For production deployments:
- Implement
ContextProviderand call.with_permission_mode(PermissionMode::Deny)to prevent agents from escalating tool permissions at runtime. - Supply a custom
ApprovalHandlerthat enforces your tenant's access-control list. - Attach a
HookRegistryfor telemetry and policy enforcement.
The openapi feature
The openapi feature (enabled by default) activates the GET /openapi.json endpoint, which serves an OpenAPI 3.1 schema generated with utoipa. Disable it if you do not need the schema endpoint:
cargo add paigasus-helikon-runtime-axum --no-default-features
Embedding in a larger router
.router() returns a plain axum Router without binding any socket. Use this to nest the agent endpoints under a prefix or combine them with your own routes:
let app = axum::Router::new()
.nest("/api/v1", server.router())
.route("/healthz", axum::routing::get(|| async { "ok" }));
axum::serve(listener, app).await?;
API reference
Full per-item documentation: paigasus_helikon_runtime_axum.
Facade re-export: enable the runtime-axum feature on paigasus-helikon and import via paigasus_helikon::runtime_axum::*.