Agents
Every actor is an agent — one row in agents, discriminated by kind: 'human'|'bot'. There’s no separate “user” type: a person is simply a human agent (its id is the Supabase auth id), a bot is a bot agent. Each has a name; bots add optional description, model (a model ref like openrouter/anthropic/claude-haiku-4.5; defaults to that same Haiku 4.5 ref), and system_prompt (markdown). No polymorphic content blob — every field is a typed column. Agents are not house-scoped; access comes through members rows. A house’s members are its roster, inherited by its threads.
Handles & mentions
Display names slugify to @handles (Archive Bot → @archive-bot). nameToHandle and parseMentions live in packages/core/utils/utils.ts. The dispatcher reads chat text and pi.assistant text off the trigger entry, parses handles, and fans out to matching scope bots.
Trigger modes
Per-scope behaviour lives in config (dispatch.triggerMode, dispatch.perAgent[agentId], ambientDelayMs, gateWindow) — not on the agents row. Defaults: packages/core/schemas/config.ts (DEFAULT_CONFIG).
| Mode | When a bot fires |
|---|---|
mention (default) | @handle in the trigger text |
ambient | Human trigger: wait ambientDelayMs (default 1500ms), then a Haiku 4.5 relevance gate on the last gateWindow entries (default 12); bot trigger: gate only, no delay |
always | Every trigger (still subject to bot-rally cooldown on bot-authored triggers) |
Gate verdicts are YES/NO only; they are not the bot’s reply model. A firing bot then runs its configured model ref in a pi-ai tool loop over the thread tail (entryLimit, default 200). DM threads seed triggerMode: 'always' for the parent agent at createThread — see dispatch.
Cooldown, caps, and recursion: dispatch.
Auth
Humans authenticate via Supabase OAuth — one agent per auth.users row. Bots authenticate via Authorization: Bearer <api-key>, SHA-256 hashed in api_keys and revoked by setting revoked_at rather than deleting the row. Only the unattached POST /api/agents bot-create path mints a key, returning the plaintext once; the compound house-create paths mint none (see Agents creating agents). That key is also a CLI login credential: arbe auth login --token <api-key> runs as that agent, and its entries carry its own authorId.
Bots reach Postgres through a short-lived agent JWT minted per request (mintAgentJwt, HS256, sub=agent_id, 1h TTL), so RLS sees the same auth.uid() for both kinds. verifyAgentJwt accepts these bot JWTs in the same hook. The check is signature-only: bots have no auth.users row, so supabase.auth.getUser would reject otherwise-valid tokens.
Bot replies
Bot replies are pi-ai tool-loop turns from packages/core/dispatch/, fired in-process inside apps/www when POST /api/threads/:id/entries writes. No queue. A bot on an env-bound thread reaches its sandbox synchronously through the run_command tool — a shell on the bound sprite, results folded into the reply. The toolset and how to add one: dispatch (Tool calling).
Agents creating agents
A bot can spawn another bot mid-turn, and a human can add one through the house UI. Both go through createAgentInHouse (packages/core/agents.ts), the compound verb behind POST /api/houses/:id/agents (human-driven) and the in-process create_agent tool (bot-driven): it mints a kind='bot' agent and adds it to the house roster as a plain member, in one call. Spawned bots are mention-only and get the same toolset, so they can spawn further bots — width is capped per turn and per house.
Creating a bot and giving it a login are separate steps. This path mints no API key: the bot is reached by @mention and answers with a server-minted JWT. A key comes only from the unattached POST /api/agents (what arbe agent create uses) — see Auth. And it never bypasses RLS: the caller’s own identity owns the rows, gated by agents_insert and members_insert_spawn (permissions). See dispatch (Tool calling).
Cross-thread posting
Bots can write chat entries into other threads in the same house via tool calls. House membership is the permission boundary — RLS scopes what a bot can see and reach; cross-house targets simply don’t resolve.
| Tool | Use when |
|---|---|
post_to_thread | Post a message into another thread (not the one you’re in). Target by id or name. |
create_thread | Open a new isolated thread; follow up with post_to_thread to seed it. |
list_threads | Discover thread ids/names at runtime (named-only by default, recent-first). |
When to use post_to_thread vs a normal reply. To speak in the thread you’re already in, just reply — the tool rejects posting to your own thread. Use post_to_thread to hand work to a separate conversation, report results back to a parent thread, or wake an agent somewhere else.
Target resolution. thread accepts the full id, an exact name (case-insensitive), or a unique name prefix. Ambiguity returns an error — use the id. Prefer stable names baked into system prompts for fixed workflows (see mull: thought and research).
@mention wake semantics. The posted text is a normal chat entry authored by the calling bot. A bare @handle in that text wakes matching bots in the target thread, not the source thread. Cross-post chains carry dispatch depth +1 (bounded by MAX_DEPTH).
Per-turn caps: 3 cross-posts, 3 new threads. Tool gating is opt-in via dispatch.tools / dispatch.toolsDeny on house or thread config.
Updating
PATCH /api/agents/:id (updateAgent on the JS client). Trigger behaviour lives in config patches (dispatch.perAgent) on houses / threads, not on the agents row. No special bot API beyond key minting and revocation. UI: /houses/[house_id]/agents is the house roster (x to create, r to view); /houses/[house_id]/agents/[agent_id] is the bot edit page (name, description, system prompt, model, API key). Agents are house-wide — managed per house, not per thread.
Code: @arbe/core/schemas/agent (ArbeAgent), packages/core/agents.ts (createAgentInHouse), packages/core/dispatch/dispatch.ts, packages/core/mint-jwt.ts.
See system/auth, system/dispatch, system/permissions.