Broomva

lifegw

The edge gateway daemon — terminates TLS, verifies identity JWTs, mints capability tokens, and proxies RPCs to lifed.

lifegw

lifegw (Life Gateway) is the stateless, internet-facing edge daemon of the Life Agent OS. It sits at the boundary between the public internet and the privileged lifed daemon, handling all concerns that belong at the network edge: TLS termination, identity JWT verification, capability token minting, rate limiting, and WebSocket session establishment.

lifed never touches the internet. lifegw never touches privileged state. The two daemons communicate exclusively over a Unix domain socket.

Spec C₃ shipped. lifegw is fully implemented across M7 and the lifegw-specific wave. 199 integration tests pass (151 unit + 19 anima_custody + 29 others). All Anima custody routes, the WebSocket stream endpoint, JWKS-based auth, rate limiting, and the admin plane UDS are live on main.

Position in the topology

Internet
    │  TLS 1.3 (rustls)

 lifegw   ← stateless, unprivileged, horizontally scalable
    │  Unix Domain Socket

  lifed   ← privileged, stateful, manages substrate connections

    ├── chronosd    (temporal substrate)
    ├── animad      (identity substrate, via soma UDS)
    ├── lago        (event journal)
    ├── haima       (payments)
    └── vigil       (observability)

lifegw knows nothing about agents, sessions, or substrate state. It knows how to verify a JWT, mint a scoped capability token, and forward a request. That is the entirety of its job.

Public endpoints

SurfaceURL
Canonicalhttps://life.broomva.tech
Railway aliashttps://lifegw-production.up.railway.app
Healthhttps://life.broomva.tech/healthz

Both URLs front the same lifegw deployment on Railway (TLS termination at Railway's edge → Caddy in-container → lifegw HTTPS on 127.0.0.1:8443). The life.broomva.tech CNAME points at Railway's edge; Let's Encrypt issues the cert on Railway's side. Use the canonical URL in client code; the Railway alias is for diagnostics.

Stack

ComponentTechnology
HTTP serveraxum
TLSrustls (TLS 1.3 only — TLS 1.2 disabled)
gRPC proxytonic-web (Connect protocol)
JWT verificationJWKS (ES256) via JwksCache
WebSocketaxum WS upgrade + bidi pump
Rate limitingToken-bucket per-user + per-IP
Admin planeUnix domain socket + SO_PEERCRED

Routes

Health

RouteMethodDescription
/healthzGETProbes upstream lifed reachability. Returns 200 only if lifed responds on the UDS.

Auth bootstrap

RouteMethodDescription
/life/v1/auth/bootstrapPOSTMint a Tier-2 capability token from a verified Tier-1 bearer. Body: {"user_id": "<user>"}. Returns {"access_token", "token_type", "user_id", "expires_in", "expires_at"} (15-minute TTL). Use this when a non-agent route needs an explicit Tier-2 cap. The /v1/agent/* routes auto-mint internally and do NOT require a pre-bootstrap.

Anima custody

RouteMethodDescription
/anima/custody/sign_authPOSTSign an auth challenge with the agent's Ed25519 auth key.
/anima/custody/sign_walletPOSTSign an EVM transaction with the agent's secp256k1 wallet key.
/anima/custody/get_auth_pubkeyPOSTRetrieve the public Ed25519 auth key.
/anima/custody/get_wallet_pubkeyPOSTRetrieve the public secp256k1 wallet key.
/anima/custody/mint_session_capPOSTMint a 15-minute Tier-User capability token for session use.
/anima/custody/enroll_passkeyPOSTWebAuthn passkey enrollment.

Agent

The chat surface is a two-phase flowcreate_session returns a server-issued sid, then the WSS upgrade attaches a stream to that session. The session id passed in the WS query string is the canonical handle for resumability.

RouteMethodDescription
/v1/agent/create_sessionPOSTCreate a chat session. Body: {"user_id", "project_id", "label"?, "resume_sid"?, "model"?} (#[serde(deny_unknown_fields)]). model is the inference model id (e.g. "anthropic/claude-opus-4-7", "openai/gpt-4o"); pinned on the routing-cache entry for the lifetime of the session. Empty / whitespace-only strings normalise to None server-side (Postel's-law). When omitted, the production default applies (currently claude-sonnet-4-6). Returns {"sid", "agent_id", "user_id", "project_id", "created_at_unix"}. Server-issued sid is the canonical session id — use it on the WSS upgrade below.
/v1/agent/streamGET → WS upgradeWebSocket upgrade. Query params: ?sid=<sid>&last_seq_no=<n>. sid comes from create_session; last_seq_no is the last agent_event.seq_no the client received (omit on first connect; pass on reconnect to replay events from that point).

Per-route scope enforcement. Each Anima custody route enforces a scope intersection check. A TierUserCap with anima.user.sign_auth scope cannot call /anima/custody/sign_wallet. claims.sub is bound to body.user_id — a token issued for user A cannot be used to sign on behalf of user B.

Authentication

Tier-1: Identity JWTs

Incoming requests carry a Tier-1 JWT — an ES256 JWT issued by the Vercel identity provider (or a dev-mode HS256 token). lifegw verifies these against a JWKS endpoint:

JwksCache → fetch JWKS → verify ES256 signature → extract claims

JwksCache uses a FlightCoalescer cohort pattern — concurrent requests for a JWKS refresh coalesce into a single fetch, preventing thundering-herd cache misses. The cache is updated atomically.

Tier-2: Capability tokens

After verifying a Tier-1 JWT, lifegw can mint a Tier-2 capability token via TierUserMinter:

  • Audience: anima.user-cap
  • TTL: 15 minutes
  • Scope: per-route (e.g. anima.user.sign_auth, anima.user.sign_wallet)
  • Subject binding: claims.sub must match body.user_id on every request

TierUserMinter is a sibling of Tier2Minter (which mints service-to-service capability tokens). Both use the same KmsSigner trait, which abstracts over StaticKeystore, VaultTransit, AwsKms, and GcpKms.

WebSocket bearer auth

WebSocket connections carry their JWT via the Sec-WebSocket-Protocol: bearer.<jwt> subprotocol header. The subprotocol value is parsed by lifegw before the upgrade completes. Invalid bearer values (malformed characters, oversized tokens) are rejected with a 400 before the WebSocket handshake.

Client: Sec-WebSocket-Protocol: bearer.eyJhbGciOiJFUzI1NiJ9...
lifegw: parse → verify → upgrade → open bidi stream

Rate limiting

Token-bucket rate limiting is applied at two levels:

LevelBucket keyBehavior
Per-userclaims.sub from verified JWTLimits authenticated users independently of source IP
Per-IPRemote socket addressLimits unauthenticated or pre-auth requests

Rate limit responses return HTTP 429 with a Retry-After header. The token bucket parameters (capacity, refill rate) are configurable in lifegw.toml.

Chat session flow

Chat is a two-phase wire: HTTP create_session followed by a WSS upgrade attached to the returned sid. Native clients (Rust, server-to-server) authenticate via Authorization: Bearer <jwt>; browser clients authenticate via the Sec-WebSocket-Protocol: bearer.<jwt> subprotocol (browsers cannot set arbitrary headers on WebSocket constructors).

Create session. POST /v1/agent/create_session with Authorization: Bearer <tier1-jwt> and JSON body {"user_id", "project_id", "label"?, "resume_sid"?, "model"?}. lifegw's middleware verifies the Tier-1 JWT, mints a Tier-2 cap internally, and forwards to lifed over the UDS. The optional "model" field (e.g. "anthropic/claude-opus-4-7") pins the inference model on the routing-cache entry for the lifetime of the session — every subsequent ArcanCall dispatched on this sid inherits it. Empty or whitespace-only model values normalise to None server-side; when omitted, the production default applies. Returns {"sid", "agent_id", "user_id", "project_id", "created_at_unix"}.

Upgrade. GET /v1/agent/stream?sid=<sid>&last_seq_no=<n> with Upgrade: websocket headers. Native: same Authorization: Bearer <jwt> header. Browser: Sec-WebSocket-Protocol: bearer.<jwt> subprotocol.

lifegw verifies the bearer, applies rate limits, returns 101 Switching Protocols, and opens a bidi pump to lifed.

Client sends WireOutbound::SendMessage envelopes; server emits WireInbound::AgentEvent { seq_no, record, agent_kind } envelopes. agent_kind{TOKEN, FINISH, ERROR, TOOL_CALL_PENDING, TOOL_RESULT, APPROVAL_REQUIRED, HIBERNATE} — token deltas live in record.payload.text.

On disconnect, reconnect with the same sid and last_seq_no=<last_received_seq_no>. lifegw forwards to lifed, which replays events from that point. No events are lost across reconnects.

Wire envelope shapes

DirectionVariants
WireOutbound (client → server)SendMessage, ApproveDispatch, CancelDispatch, Ping, Close
WireInbound (server → client)AgentEvent { seq_no, record, agent_kind }, Pong, Closing

agent_kind enum (server-emitted):

KindMeaning
TOKENStreaming token delta — append record.payload.text to the current message
FINISHTurn complete — current message is final
ERRORTurn failed — record.payload carries the error
TOOL_CALL_PENDINGAgent invoked a tool; arguments in record.payload
TOOL_RESULTTool finished — result in record.payload
APPROVAL_REQUIREDServer is waiting for an ApproveDispatch from the client
HIBERNATEServer idled the session; reconnect to resume

Authentication shape per route

RouteWhat the client sendsWhat lifegw does internally
/life/v1/auth/bootstrapAuthorization: Bearer <tier1>Mints a Tier-2 cap, returns it in the response body
/v1/agent/* (incl. /create_session, /stream)Authorization: Bearer <tier1> (native) or Sec-WebSocket-Protocol: bearer.<tier1> (browser, WS only)Middleware auto-mints a Tier-2 cap from the Tier-1 and forwards the rewritten Authorization header to lifed over the UDS
/anima/custody/*Authorization: Bearer <tier2> (explicit; obtain via /life/v1/auth/bootstrap first)Verifies the Tier-2 cap and intersects per-route scope (see callout above)

The pump handles:

  • Heartbeats: 30-second ping/pong interval. 3 missed pongs close the connection with CloseCode::ProtocolError.
  • Unknown frames: rejected immediately with CloseCode::UnsupportedFrame (close code 1003 per Spec C₃ §6.5).
  • Unknown frame characters: bearer subprotocol values with control characters or non-ASCII are rejected before the upgrade.

Admin plane

lifegw exposes a Unix domain socket for privileged management operations:

/run/life/lifegw-admin.sock

Authentication uses SO_PEERCRED — the kernel verifies the connecting process's UID/GID. Only processes in the life-runtime group are admitted. This is enforced via getgrouplist with a negative cache for failed lookups. Group-lookup failures are fail-closed (admission denied, not granted).

The admin plane supports:

  • JWKS cache flush (force re-fetch from provider)
  • Rate limit bucket inspection
  • Graceful drain (stop accepting new connections, finish active streams)
  • TLS certificate hot-swap via shared CertReloader

Security properties

PropertyImplementation
TLS 1.3 onlyrustls cipher suite configuration — TLS 1.2 removed
Multi-audience JWT verificationJwksCache::verify_capability_token verifies aud against the expected audience per route — a token issued for one route cannot be replayed on another
Per-route scope intersectionMiddleware checks that the token's scope set intersects the route's required scope set; empty intersection → 403
Subject bindingclaims.sub from the JWT must equal body.user_id in the request body — prevents cross-user impersonation
Upstream error sanitizationsanitize_upstream strips tonic::Status internals from 502 responses — the soma UDS path is never leaked in error bodies
Per-RPC timeouttokio::time::timeout(10s) wraps every upstream RPC — hung lifed connections do not hold client connections open indefinitely
Input validationvalidate_user_id rejects empty, oversized, and control-character-containing user IDs; deny_unknown_fields on all request bodies

Configuration

lifegw is configured via lifegw.toml:

[tls]
cert_path = "/etc/life/lifegw.crt"
key_path  = "/etc/life/lifegw.key"
# CertReloader polls every 5s — certificate rotation is hot-swap, no restart required

[auth]
jwks_url = "https://your-vercel-project.vercel.app/.well-known/jwks.json"
dev_mode  = false  # true: accept HS256 dev tokens without JWKS verification

[rate_limit]
per_user_capacity = 100
per_user_refill_per_sec = 10
per_ip_capacity = 200
per_ip_refill_per_sec = 20

[lifed]
uds_path = "/run/life/lifed.sock"

[admin]
uds_path = "/run/life/lifegw-admin.sock"
group    = "life-runtime"

TLS certificate rotation. The CertReloader polls for certificate changes every 5 seconds and hot-swaps the TLS acceptor via an Arc-wrapped shared reference. Existing connections continue with the old certificate; new connections use the new certificate. No process restart is required for certificate renewal.

Relation to other subsystems

SubsystemRelationship
lifedlifegw is the public face of lifed. All privileged operations (agent sessions, substrate state, saga management) live in lifed. lifegw forwards via UDS after auth.
AnimaThe Anima custody routes are the primary stateful surface lifegw exposes. animad (the Anima daemon, running inside lifed's trust boundary via soma UDS) handles the actual key operations.
HaimaNot directly wired to lifegw. Payment operations go through lifed after lifegw verifies the JWT.
Vigillifegw emits OTLP spans and metrics for every request — JWT verification latency, upstream RPC latency, rate limit hit rate, WS connection duration.
Spec JSpec J (ANTHROPIC_BASE_URL=lifegw) routes Claude Code's Anthropic API calls through lifegw, making every Claude Code session anima-bound, lago-event-sourced, vigil-instrumented, and haima-billed.

On this page