HTTP server

Expose runners over HTTP and SSE with an axum server that works with the adk-web dev UI out of the box.

The server feature exposes one or more Runners over HTTP and Server-Sent Events. The endpoint surface implements the wire contract the adk-web Angular UI expects — camelCase JSON, {"detail": ...} errors, data: <json> SSE framing — so existing ADK web frontends and API clients talk to an adk-rs server unchanged.

Three pieces make up the server: AppState (the runners plus auth and CORS settings), build_router (the axum Router), and serve / serve_with (bind-and-listen with a loopback safety guard). All live in adk_rs::server.

AppState

AppState carries a map of agent name → Arc<Runner>, an optional bearer token, and the CORS allow-list. Apps are addressed by Runner::app_name() on the wire, so the map key only matters for the legacy /list-agents route.

fn unauthenticated(runners: Arc<HashMap<String, Arc<Runner>>>) -> AppState
No authentication. serve will then refuse non-loopback binds (see below).
fn with_bearer_token(runners: Arc<HashMap<String, Arc<Runner>>>, token: impl Into<String>) -> AppState
Require Authorization: Bearer <token> on every request. Comparison is constant-time.
fn with_allow_origins(self, origins: impl IntoIterator<Item = String>) -> Self
Emit CORS headers for the given origins (use "*" for any). Needed when the adk-web UI runs on a different origin, e.g. http://localhost:4200. Empty (the default) emits no CORS headers.

build_router, serve, serve_with

fn build_router(state: AppState) -> Router
Build the axum router. When state.auth_token is set, every route is wrapped in the bearer check. Useful for embedding into a larger app or driving with tower::ServiceExt::oneshot in tests.
async fn serve(addr: SocketAddr, state: AppState) -> Result<()>
Bind and serve with ServeOptions::default().
async fn serve_with(addr: SocketAddr, state: AppState, opts: ServeOptions) -> Result<()>
Like serve but with explicit options. Refuses a non-loopback bind that has no auth token unless opts.dangerously_allow_unauthenticated_remote is true, and always logs a warning when listening on a non-loopback interface.
Serving two agentsrust
use adk_rs::server::{AppState, ServeOptions, serve_with};
use std::collections::HashMap;
use std::sync::Arc;

let mut runners = HashMap::new();
runners.insert("greeter".to_string(), Arc::new(greeter_runner));
runners.insert("researcher".to_string(), Arc::new(research_runner));

let state = AppState::with_bearer_token(Arc::new(runners), std::env::var("ADK_WEB_TOKEN")?)
    .with_allow_origins(["http://localhost:4200".to_string()]);

serve_with("0.0.0.0:8000".parse()?, state, ServeOptions::default()).await?;

Endpoint reference

MethodPathPurpose
GET/healthLiveness: {"status": "ok"}.
GET/versionCrate version plus "language": "rust".
GET/list-appsBare JSON array of registered app names (sorted, deduplicated).
GET/list-agentsLegacy adk-rs route: {"agents": [...]} keyed by registration name.
POST/runRun one turn to completion; returns a bare JSON array of wire events.
POST/run_sseSame body as /run, but events stream as SSE data: frames.
GET/apps/:app/users/:user/sessionsList sessions (array of session objects without state/events).
POST/apps/:app/users/:user/sessionsCreate a session; body may carry sessionId, state, and events. Duplicate explicit id → 409.
GET/apps/:app/users/:user/sessions/:sessionFetch one session including its full event log. Missing → 404.
POST/apps/:app/users/:user/sessions/:sessionCreate a session with this explicit id; body is an optional initial state map.
PATCH/apps/:app/users/:user/sessions/:sessionApply a stateDelta via a synthetic user event; returns the updated session.
DELETE/apps/:app/users/:user/sessions/:sessionDelete the session; returns 200 with a null body.
GET.../sessions/:session/artifactsList artifact names for the session.
GET.../sessions/:session/artifacts/:nameLoad the latest artifact version (or ?version=N) as a Part JSON object.
DELETE.../sessions/:session/artifacts/:nameDelete an artifact; returns null.
GET.../sessions/:session/artifacts/:name/versionsList version numbers.
GET.../sessions/:session/artifacts/:name/versions/:versionLoad a specific version.
PATCH/apps/:app/users/:user/memoryBody {"sessionId": ...} — add that session to the configured memory service.
GET/debug/trace/:event_idTrace stub: always 404 {"detail": "Trace not found"}.
GET/debug/trace/session/:sessionTrace stub: always [] so the UI trace tab degrades gracefully.
GET/dev/apps/:app/debug/trace/:event_id2.x-path alias of the trace stub.
GET/dev/apps/:app/debug/trace/session/:session2.x-path alias; returns [].
GET/apps/:app/eval_sets, /apps/:app/eval_resultsEval stubs: [] (run evals via the CLI instead).
GET/dev/apps/:app/eval_sets, /dev/apps/:app/eval_results2.x-path aliases; return [].
GET/dev/apps/:app/metrics-infoReturns {"metricsInfo": []}.

Artifact routes return 404 {"detail": "Artifact service is not configured"} when the runner has no artifact service; the memory route returns 400 without a memory service. Both snake_case and camelCase request fields are accepted (sessionId / session_id), mirroring pydantic’s populate_by_name.

Wire format

Responses use the wire dialect implemented in src/server/wire.rs: events flatten the LlmResponse fields (content, finishReason, errorCode, errorMessage, usageMetadata, cacheMetadata, …) next to event-level fields, null fields are omitted, and id / invocationId / author / timestamp / actions are always present. Inside actions, four maps are always emitted — stateDelta, artifactDelta, requestedAuthConfigs, requestedToolConfirmations — because the UI reads them unconditionally. Errors take the shape {"detail": "Session not found"} with the matching HTTP status.

On /run_sse every frame is data: <one-line JSON> followed by a blank line. An event that carries both content parts and a non-empty artifactDelta is split into two frames (content first, then the delta) — a deliberate framing quirk the adk-web UI depends on. A mid-stream failure emits a final data: {"error": "..."} frame and closes.

Calling /run and /run_sse

curl: create a session, then runbash
# Create the session first — /run does not auto-create.
curl -X POST http://127.0.0.1:8000/apps/test-app/users/alice/sessions \
  -H 'content-type: application/json' \
  -d '{"sessionId": "s-1"}'

curl -X POST http://127.0.0.1:8000/run \
  -H 'content-type: application/json' \
  -d '{
    "appName": "test-app",
    "userId": "alice",
    "sessionId": "s-1",
    "newMessage": {"role": "user", "parts": [{"text": "hello"}]},
    "streaming": false
  }'
# -> [{"content":{"parts":[{"text":"..."}],"role":"model"},"author":"greet",
#      "invocationId":"...","timestamp":...,"actions":{"stateDelta":{},...}}]
curl: streaming over SSEbash
curl -N -X POST http://127.0.0.1:8000/run_sse \
  -H 'content-type: application/json' \
  -d '{
    "appName": "test-app",
    "userId": "alice",
    "sessionId": "s-1",
    "newMessage": {"role": "user", "parts": [{"text": "hello"}]},
    "streaming": true
  }'
# data: {"content":{"parts":[{"text":"..."}],"role":"model"},...}

The request body carries: appName (optional when exactly one app is registered), userId, sessionId, newMessage, streaming (true selects StreamingMode::Sse partial events), optional stateDelta applied before the run, and optional invocationId to resume a paused invocation instead of starting a new one.

Connecting the adk-web UI

  • Run the adk-web Angular dev UI and point its runtime-config.json backendUrl at this server.
  • Pass the UI’s origin to AppState::with_allow_origins(["http://localhost:4200".into()]) so CORS preflights succeed.
  • The trace and eval tabs degrade gracefully against the stub routes; everything else (chat, sessions, state, artifacts) works end to end.

  • Embedded CLImy-app web starts this server with one flag.
  • Security model — bind policy and bearer-token details.
  • Events — the native event shape that the wire format serializes.