Skip to content

Agents service

The agents service owns the core runtime loop for every AI agent in paperboard (the one-word marketing product name). It is the only service that touches the agents Postgres schema. Gateway, runtime, and identity all call agents — none of them own any part of the agent lifecycle.

We put agent CRUD, session state, LLM streaming, and tool dispatch into one service because they share a tight data dependency: the session event log is the source of truth for replay, billing, and debugging, and splitting those concerns across services would require cross-schema joins or an event-bus round-trip on every token delivered to the client.

The scaling profile of SSE streaming (long-lived connections, backpressure to the LLM provider) is also different from the request/reply profile of identity or billing. A single deployment unit lets us tune concurrency limits and timeouts independently.

  1. Stores agent definitions — name, slug, model, and system prompt — in agents.agents.
  2. Creates sessions scoped to an org and an agent in agents.sessions.
  3. Streams LLM completions from Anthropic over Server-Sent Events (SSE) through POST /v1/sessions/{id}/prompt.
  4. Dispatches tool_use blocks from the LLM to the compute service via gRPC and feeds tool_result messages back into the conversation loop.
  5. Persists every turn as a session_event row for replay and auditing.
  6. Emits metering events into agents.usage_events (Phase 3, PB-62) so the platform service can aggregate cost and quota data in Phase 4.
CallerProtocolWhat it asks for
gatewayREST/HTTPRoutes public traffic; Phase 7 only — direct in 1–6
runtimegRPCLifecycle management; Phase 3+
identitygRPCJWT/API-key validation via AuthServiceClient
computegRPCExecutes tool calls inside gVisor sandboxes
CLI / testsREST/HTTPIntegration testing and developer tooling

Four tables ship as of Phase 3 (migration 000003):

TablePurpose
agents.agentsAgent definitions (name, slug, model, system prompt)
agents.sessionsSession lifecycle (active / completed / failed)
agents.session_eventsAppend-only event log per session
agents.usage_eventsWide metering table; Phase 4 outbox consumer target

Cross-schema foreign keys are forbidden (ADR-0003). Identity, billing, and platform reference agents rows by UUID only.

flowchart LR
client["Client\n(HTTP/SSE)"]
api["agents\nHTTP API"]
core["SessionService\n(core)"]
llm{{"Anthropic API"}}
compute["compute\ngRPC"]
pg[("agents\nPostgres")]
usage[("usage_events")]
client -->|"POST /v1/sessions/{id}/prompt"| api
api --> core
core -->|"Stream()"| llm
llm -->|"tool_use block"| core
core -->|"ExecCommand gRPC"| compute
compute -->|"tool_result"| core
core -->|"SSE chunks"| api
api -->|"text event-stream"| client
core --> pg
core --> usage
classDef controlPlane fill:#10b981,stroke:#047857,color:#fff
classDef dataPlane fill:#3b82f6,stroke:#1d4ed8,color:#fff
classDef sandbox fill:#f97316,stroke:#c2410c,color:#fff
classDef external fill:#ef4444,stroke:#b91c1c,color:#fff
classDef persistence fill:#6b7280,stroke:#374151,color:#fff
class core controlPlane
class api dataPlane
class compute sandbox
class llm external
class pg,usage persistence

Consumer-owned interfaces. The AgentReader, AgentWriter, SessionReader, SessionWriter, LLMClient, and ComputeClientFactory interfaces are defined in internal/core — not in the adapters that satisfy them. This prevents circular imports and lets the store and LLM packages evolve independently.

Advisory lock id 3. The migrator holds Postgres advisory lock 3 during schema migrations. Lock ids are cluster-wide constants: identity=1, billing=2, agents=3, platform=4.

PgBouncer split. DATABASE_URL (port 6432, PgBouncer transaction pool) goes to the server. MIGRATION_DB_URL (port 5432, direct Postgres) goes to the migrator. Mixing the two breaks advisory locks at migration time and loses prepared statements at server scale.

SSE timeout separate from request timeout. SSE_TIMEOUT defaults to 300 s; REQUEST_TIMEOUT defaults to 30 s. Long LLM responses would time out under the regular request timeout on streaming routes.

Metering Day 1. usage_events was introduced in Phase 3 so that billing (Phase 5) has a complete event history from the first session. The outbox consumer columns (consumed_at, consumer_id, consumer_attempts) are schema-ready but unpopulated until the platform outbox worker ships in Phase 4.