Agents
This document is about “agents”, PostgreSQL “records” with type: agent.
Every participant is an agent — a record with type: 'agent'. Some are humans, some are bots. The permission model doesn’t distinguish between them.
Agent records
AgentRecord in packages/core/types.ts narrows content to AgentContent, defined in packages/core/schemas/record-content.ts.
All agents have a kind ("human" or "bot") and a name. Bots add optional fields: description, model (an AI SDK model ID — Anthropic like "claude-sonnet-4-20250514" or Workers AI like "@cf/meta/llama-4-scout-17b-16e-instruct"), system_prompt (the bot’s personality, in markdown), and trigger_mode ("mention" or "ambient", defaults to "mention" when absent).
Bots belong to a house (parent_id = house_id). Humans gain access to houses through permissions, not parentage.
Updating a bot is just updating a record’s content through the normal mutation path. No special bot API.
Mentions
Display names are slugified into @mention handles: “Archive Bot” becomes @archive-bot. nameToHandle and parseMentions in packages/core/utils.ts handle this. The dispatch pipeline detects mentions in filterAgents (packages/www/src/lib/server/agent-dispatch.ts).
Authentication
Humans authenticate via OAuth (Supabase). External agents authenticate with an API key in the Authorization: Bearer <key> header. Keys are stored hashed (SHA-256) in the api_keys table, shown once at creation, never retrievable after. Revoking sets revoked_at rather than deleting the row.
Bot DOs authenticate to Supabase as themselves via raw fetch — no SDK, no service key. On creation, the app pushes the bot’s Supabase credentials (email + password) to the DO via configure(). The DO stores them in SQLite, authenticates via POST /auth/v1/token?grant_type=password, and caches the access token. All PostgREST queries use the bot’s own session with RLS enforced. Stream reads and writes still use the worker-level DURABLE_STREAMS_SECRET.
Agent runtime
Every agent is a Cloudflare Durable Object — a single-threaded, addressable process with persistent SQLite. One class (HusAgent in packages/worker/src/agent.ts) serves as the universal runtime. Behavior comes from the agent’s AgentContent, not from code.
Durable Objects are a compute primitive, unrelated to durable streams despite the shared word. A durable stream is an append-only byte log (message storage). A Durable Object is an isolate with state (agent compute).
State
Two layers. The room’s durable stream is the shared record — the conversation everyone sees. The DO’s private SQLite is the agent’s own mind. Four tables:
agent_cache— cached agent ID, content, and parent_id (config pushed by the app)bot_auth— Supabase credentials and cached access/refresh tokenshouse_cache— cached parent house content (5-minute TTL), used to prepend house description to the system promptprocessed_messages— event dedup by message IDroom_offsets— per-room stream read position (room ID → opaque offset string)room_messages— cached messages per room (trimmed to 100), used to build LLM context from incremental reads
The stream is what happened. The SQLite is what the agent remembers when it thinks.
AgentState in packages/core/agents.ts defines the shape exposed to WebSocket clients (status, lastActivity, agentName). AgentIntrospection extends it with config and dedup stats for tests and admin.
Configuration
Config is pushed, not pulled. The app POSTs to /activate/:agentId on the agents worker before every activation — dispatchToAgents reads the agent’s current record from Postgres, sends the latest AgentContent plus the activation event, and the worker configures the DO before calling handleStreamEvent(). Edits to name, system prompt, model, or trigger mode take effect on the next message.
getIntrospection() returns full internal state (config, dedup count, status) for tests and admin UI.
Activation
Agents are purely push-activated — no polling or self-tailing. The DO hibernates between events.
The app decides who to wake. The DO decides whether to respond. When a message is posted to a room, the dispatch pipeline (packages/www/src/lib/server/agent-dispatch.ts) runs inside ctx.waitUntil():
parseMessage— extract the text message from the raw POST body, skip non-messagesfindBotsInScope— find agents withkind: 'bot'that have permission on this room or its parent house (delegates togetAgentsInScopeinpackages/www/src/lib/server/scope-agents.ts)filterAgents— keep mentioned agents (any mode) plus ambient-mode agents, exclude the authordispatchAgents— wake every agent’s DO in parallel with the appropriate trigger ('mention'or'ambient')
The input to each agent is an ActivationEvent (packages/core/agents.ts) — everything it needs without database access:
interface ActivationEvent { trigger: 'mention' | 'ambient' messageId: string text: string roomId: string authorId: string durableStreamId: string}handleStreamEvent() reads incrementally from the saved offset (falling back to full history on first visit), caches messages in SQLite, then builds LLM context from the cache. For mention triggers, it goes straight to the LLM. For ambient triggers, two gates run first:
- Cooldown — if the agent authored any of the last 5 messages, skip. No inference cost.
- Relevance —
checkRelevance()sends the last 5 messages and the agent’s system prompt to a cheap Workers AI model (@cf/meta/llama-4-scout-17b-16e-instruct) with a YES/NO prompt. If NO, skip.
The LLM sees the last 50 messages mapped to { role, content } pairs (agent’s own messages become assistant, everything else user). The system prompt is built by prepending the parent house’s description (if any) to the agent’s system_prompt. The house record is fetched via the bot’s own Supabase session and cached for 5 minutes. The reply is posted directly to the durable stream via appendToStream(), bypassing the app proxy.
Self-triggering is prevented at two levels: filterAgents excludes the message author, and agent replies bypass the proxy so dispatchToAgents never fires for them.
Agents have a trigger_mode on their AgentContent: 'mention' (default) or 'ambient'. Mention-only agents respond when @mentioned. Ambient agents also respond to relevant conversation without a mention. @mention always works regardless of mode.
LLM providers
resolveModel() routes by model ID prefix. IDs starting with @cf/ use Workers AI (bound via wrangler.jsonc, no API key). Everything else uses Anthropic (ANTHROPIC_API_KEY secret). Default: claude-sonnet-4-20250514.
Replies
Bot replies are atomic — the full response is a single StreamMessage appended to the durable stream with type: "message", author_id set to the bot’s agent ID, and body: { format: "markdown", text }. No streaming or ephemeral path yet.
Webhooks (stub)
onRequest() accepts POST but is a stub — logs and returns 200. When built out, it will need the app to push config first (the DO can’t self-fetch).
Package structure
packages/worker is a separate Cloudflare Worker with its own wrangler.jsonc. Hosts the HusAgent Durable Object and imports @arbe/core for types, schemas, and stream access. Message dispatch uses authenticated HTTP to the worker’s /activate/:agentId route so local dev can hit localhost:8990 and production can hit the deployed worker URL without cross-worker DO RPC.
Testing
packages/worker uses @cloudflare/vitest-pool-workers to run integration tests against the real Cloudflare runtime (miniflare). Tests exercise the DO via RPC — configure(), handleStreamEvent(), getIntrospection(). Run with bun run test from the worker package.
Activation log
Every pipeline run writes one row to the DO’s activations SQLite table. This is the durable, queryable record of what the agent did — complementing the ephemeral CF worker logs.
An activation row captures: which message triggered it (triggerId), which room (roomId), the trigger type (mention or ambient), the outcome (replied, skipped, or error), why it was skipped if applicable (cooldown, relevance, dedup), LLM metrics if it replied (model, tokens, duration), and any error message. Rows older than 30 days are pruned on each handleStreamEvent() call.
Where rows are written:
runFullPipeline()— at the end, after reply posted or error caught (mention trigger)processAmbient()— at each exit point: cooldown check, relevance check, reply posted, or error caught (ambient trigger)handleStreamEvent()— on dedup detection, before returning
For ambient triggers, triggerId is the message ID from the last scheduleAmbient() call before the debounce timer fired. If 5 messages arrive in 3 seconds, one activation row covers all of them and points to the last.
Query via GET /api/agents/[id]/activations (requires r on the agent’s parent house):
GET /api/agents/bot-1/activations?roomId=<room_id>&limit=10GET /api/agents/bot-1/activations?triggerId=<message_id>GET /api/agents/bot-1/activations?since=<unix_ms>The endpoint proxies to getActivations() on the DO’s RPC surface (HusAgentRpc in packages/core/agents.ts). The response is an array of Activation objects.
Management UI
/house/[house_id]/agents is the registry. Lists all agents, lets admins create bots. Requires x on the house; r can view.
/house/[house_id]/agents/[agent_id] is the bot edit page — name, description, system prompt, model, API key management.
Agents are house-wide — a bot with permission on the house is active in all its rooms. There is no per-room agent management page.