BusyBro is the Busymate assistant — one warm, concise teammate that knows the monorepo cold. It runs in two surfaces (the dashboard and the Telegram bot) off a single shared brain, can read live data, search the web, reverse-engineer captured traffic, and perform capability-gated writes scoped to your role and the devices you own. It routes every question through a silent specialist lens — including per-component specialists and per-service specialist agents you configure yourself.
This page is the deep dive: how the shared brain is wired, the three capability tiers, the specialist roster (component + per-service agents), web access, how it learns who you are, how it's grounded in live data, and the streaming protocol behind the typing dots. For a quick feature overview see the BusyBro reference; to actually use it see the BusyBro how-to.
TL;DR
| Shared brain | supabase/functions/_shared/busybro.ts — persona, specialist routing, the tool loop, prompt context, streaming. |
| Two surfaces, three hosts | surface: "web" (the ask Edge Function — dashboard) + surface: "telegram" (the team telegram-bot /ask and the standalone busybro-bot). Same brain, different formatting + db client. |
| Three tiers | READ live data · REVERSE-ENGINEER captured traffic (inspect_requests, secrets redacted) · WRITE (capability-gated + ownership-scoped, incl. managing per-service specialist agents). |
| Specialists | A silent lens per question: 9 component specialists (incl. 🛡️ Security & ⚡ Performance) + per-service specialist agents you link from the busybro_agents registry under Settings → Services → Agents (one primary = the lens). |
| Web access | Anthropic's native web_search server tool — BusyBro reaches current/external info beyond the repo + docs and cites it. |
| Identity | Dashboard: the logged-in OAuth session. Telegram: the linked dashboard account (see Telegram account linking). |
| Grounding | Curated repo docs (GitHub contents API) + live Supabase reads via SELECT-only tools + the web. |
| Model | claude-sonnet-4-6, streamed via the Anthropic Messages API. |
| Streaming | SSE on the dashboard (text / tool / done / error); a "typing…" chat action on Telegram. |
One brain, two surfaces
The whole agentic answer path — persona, the silent specialist lens, conciseness rules, the read + write tools, the agentic loop, prompt caching, and the project-context loader — lives in one module, _shared/busybro.ts. Three Edge Functions host it:
ask— the dashboard surface (surface: "web"). Behind the dashboard login, RLS-scoped to the calling user.telegram-bot— the team Telegram bot's/askcommand + group discussion-mode (surface: "telegram"). Also does/bug,/feature,/issues.busybro-bot— the standalone BusyBro bot (surface: "telegram"): a dedicated bot identity, DM-first, pure chat (every message is a turn, no/ask). Linked accounts only (gate before any LLM call), full role-gated read+write. Multi-turn memory persists inpublic.busybro_threads(one row per chat, service-role only);/resetclears it. Both Telegram bots share_shared/telegram.ts(the token-parameterized API client + the strict Telegram-HTML sanitizer) and the sametelegram_linkslinking, so a user links once for both.
Only two things differ between surfaces, and both are parameters on answerWithBusyBro(…):
surfacecontrols only the formatting section of the system prompt and the signature markup. The web surface emits standard Markdown (the dashboard renders it); the Telegram surface emits Telegram-HTML, which the bot post-processes with its own sanitizer. Everything else — persona, routing, tool guidance — is shared byte-for-byte.dbis the Supabase client the tools run against. The SQL is identical; the dashboard passes a user-scoped client (RLS-enforced), Telegram passes the service-role client (so the per-user boundary is enforced inside the write tools instead — see below).
The three capability tiers
1 · Read live data
BusyBro answers questions grounded in current system state — device counts, online status, recent traffic, effective settings, workspaces, tags, service groups, users, breakpoints — through a set of SELECT-only tools advertised on every call. They are read-only by construction; no phrasing can make a read tool mutate anything, and each handler caps its result (row limits + string truncation) so a tool_result stays small. The agentic loop is hard-capped at 6 iterations.
| Tool | Reads |
|---|---|
list_devices | Device roster — name, model, OS, last seen, country, app build/source (returns uuid for id deep-links). |
device_status | One device's live status — online/offline, VPN state, heartbeat, IP country. |
count_entries | Captured-entry count in a time window, optionally per device / host. |
recent_entries | Newest captured requests — time, device, method, status, host, path. |
effective_settings | A device's merged settings (device override over global), or the global defaults. |
list_workspaces | Workspaces — id, slug, name, retention. |
list_tags | Tags — id, name, color, match patterns. |
list_service_groups | Service groups — id, icon, domain count + sample, and each group's linked BusyBro agents (the primary_agent + ordered agents[] from the service_group_agents join). |
list_users | Dashboard users — id, email, display name, role. View-gated: advertised only when you hold users:view. |
list_breakpoints | Recent breakpoint events — id, kind, device, paused-at, active/outcome. View-gated: needs devices:view. |
Most reads are always available; the two view-gated reads (list_users, list_breakpoints) are advertised only when your role holds the matching <section>:view capability — and the handler re-checks it at execution (defense in depth). All list tools that select a row return its id/uuid so BusyBro can deep-link the specific item (see id deep-links below).
On the dashboard these reads run on a user-scoped client, so they are RLS-enforced — BusyBro only ever sees what your role permits.
2 · Reverse-engineer captured traffic
The headline power. Point BusyBro at a host you've captured and it reads the actual requests/responses for that host and explains how the app talks to its backend — the auth handshake, token exchange, refresh, signed headers, payload shapes — then hands you a runnable curl / fetch repro. This is the inspect_requests tool, the same one documented in the MCP reference.
Two design points matter:
hostmust be a full hostname (it must contain a dot, e.g.identity.doordash.com). Theentriestable is huge and partitioned with an index on(workspace_id, host), so an exact host match is a fast index scan; a bare brand word (doordash) would force a multi-second cross-partition scan and is rejected with guidance so the model re-calls with concrete candidates (identity.<brand>.com,api.<brand>.com, …).- Secrets are redacted before they reach the model. The value of any sensitive header —
authorization,cookie,set-cookie,x-api-key, and a heuristic set of*token*/*secret*/*apikey*/*session*names — is replaced with<redacted len=NN>before the captured row enters the model context. The header name and value length survive (so BusyBro can still explain the auth shape — which header carries the credential, roughly how long it is), but the live secret never reaches the LLM. The repro it emits uses placeholders like<access_token>/<cookie>, never the captured value.
3 · Capability-gated writes
For a linked, authenticated user, BusyBro can do things — not just describe them — at exactly your role level, and only on devices you own. The write tools are a separate, opt-in set, advertised to the model only when your role permits the tool's capability section's edit action.
| You can ask for | Tool | Capability section |
|---|---|---|
| Set/clear a device Live Activity message | set_live_activity_message | devices |
| Rename a device | rename_device | devices |
| VPN on / off | vpn_on_device / vpn_off_device | devices |
Per-device connection type (vpn/pac) | set_device_connection_type | devices |
| Global connection type | set_global_connection_type | global |
| Create/update/delete a service group | upsert_service_group / delete_service_group | services |
| Link BusyBro agents to a service group (full-replace, one primary) | set_service_group_agents | services |
| Create/update/delete a tag | upsert_tag / delete_tag | tags |
set_service_group_agents is the service-agent manager: it full-replaces the agents linked to a group (the service_group_agents join → the canonical busybro_agents registry). Its agents argument is the complete desired set [{ agent_id, is_primary?, position? }] — exactly one must be primary (the first is auto-promoted if none is flagged), and an empty array clears all links. It is services:edit + confirm: true gated (see Per-service specialist agents below). The legacy service_groups.ai_agent jsonb (and the deprecated service_groups.agent_id FK) have been dropped — the join is the single source of truth; upsert_service_group still accepts an ai_agent arg for back-compat but ignores it.
Each write is gated twice and scoped:
- Advertised —
buildToolList(writeContext)only includes a write tool whencanEdit(wc, section)is true (mirrors the DBhas_capability(section, 'edit'):grants_allpasses everything, else the section'seditflag must be exactlytrue). Absent awriteContext, BusyBro is offered only the read tools and stays read-only. - Re-checked at execution — every write handler runs
canEdit(…)again (defense in depth). - Ownership-scoped — device-targeting writes call
assertDeviceOwned(…): unless yougrants_all, the resolved device'sowner_user_idmust be yours. This is critical on Telegram, where the brain runs against the service-role client (which bypasses RLS) — the ownership check, not RLS, is what stops a linked non-admin from touching someone else's device.
Deliberately excluded from free-text execution, even for admins: unpair/delete devices, wipe entries, send push, role/user CRUD, workspace/tab CRUD, arbitrary settings JSON, breakpoint patterns, snapshots. Use the dashboard or the MCP server directly for those.
How BusyBro learns who you are
The brain takes a per-call identity and an optional writeContext; each surface resolves them differently, and they ride in the uncached trailing block of the prompt (so the cached [instruction, docs] prefix stays byte-identical across calls).
- Dashboard (
ask). The dashboard sendsAuthorization: Bearer <oauth-access-token>— the same Supabase-signed JWT the MCP server issues. The function validates it withauth.getUser(token), requiresrole=authenticated, then readsprofilesfor the friendlydisplay_name+role+ the user's BusyBro response preferences. The role'srolesrow (grants_all,capabilities) becomes thewriteContext. Tool reads run on a separate user-scoped client so RLS double-enforces. - Telegram (
/ask). The bot looks up the Telegram user's linked dashboard account. Linked → BusyBro knows their dashboard name, email, and role, and gets awriteContextbuilt from that role's capabilities. Unlinked → BusyBro still answers (identity-aware of the Telegram name) but is read-only, and warmly invites the user to run/loginto link.
Either way, the resolved role both authorizes writes (its capabilities) and scopes device writes (the owned-device check keys off the same userId).
How it's grounded
Two grounding sources keep answers accurate rather than invented:
- Curated repo docs.
loadProjectContext()fetches a fixed set of files via the GitHub contents API (works on the private repo) —CLAUDE.md,README.md, and the architecture docs for contracts, the iOS helper, and the web proxy — truncates each, joins them, and caches the result module-side with a 10-minute TTL. This feeds the "how things work" questions. - Live tool results. The read tools above answer the "current state" questions. The system prompt steers the model to use the docs context for how-it-works questions and the tools when the latest message needs current state — and to admit it doesn't know rather than fabricate file paths or table names.
- The web. For anything that lives outside the codebase — a third-party API change, a library/SDK doc, an error-message lookup, a service's public API behaviour — BusyBro can call Anthropic's native
web_searchserver tool (see Web access).
The specialist lens
BusyBro routes every question through a silent specialist lens that shapes focus + expertise (never voice — it's always BusyBro), then signs off with a one-line — BusyBro · {emoji} {label} signature. There are two distinct kinds of specialist.
Component specialists
Nine fixed lenses, one per component plus a cross-cutting guide. Each mirrors a .claude/agents/<key>.md definition — BusyBro is the chat-facing one. When a user asks "what AI agents exist?" / "the specialists" / "an agents summary", BusyBro answers by summarizing this roster, not the device fleet.
| Lens | Covers |
|---|---|
| 📱 iOS specialist | BusymateHelper: Swift/SwiftUI, NEPacketTunnelProvider VPN + MITM, RealtimeSubscriber, device JWTs. |
| 🖥️ Dashboard specialist | Next.js + shadcn capture viewer, per-tab state, Realtime, auth. |
| 🔌 Proxy-server specialist | Node Charles-style MITM, TrafficLogEntry, CA/leaf, control channel. |
| 🗄️ Supabase specialist | Postgres schema/RLS/Realtime triggers, Edge Functions, the MCP server. |
| 🌐 CDP-connector specialist | The bmc CLI, Chrome DevTools Protocol capture, ingest. |
| 📚 Docs specialist | The docs/ tree + the docs site. |
| 🛡️ Security specialist | Auth/RLS, service-role exposure, open-proxy/flood/DoS, SSRF, secrets, MCP tool safety; register in security/. |
| ⚡ Performance specialist | Project-wide perf across UX/DB/network: Core Web Vitals, Postgres slow queries/indexes/RLS-initplan/N+1, proxy throughput, payload + Realtime fan-out cost; register in performance/. |
| 🧭 Architecture guide | Cross-cutting / overview / anything not clearly one component. |
The 🛡️ Security and ⚡ Performance lenses are the two cross-cutting specialists; each has a sibling findings workspace at the repo root (security/, performance/).
Per-service specialist agents
Distinct from the component roster, every service group can link one or more BusyBro specialist agents — reusable personas defined once in the unified busybro_agents registry (authored under BusyBro → Agents) and attached to a group through the service_group_agents join ({ service_group_id, agent_id, is_primary, position }; RLS mirrors service_groups — services:view to read, services:edit to write; a partial-unique index enforces exactly one primary per group; rows are ordered by position). Each group has one primary agent and any number of extras.
When a user asks about "service agents", "the agent for <service>", or works with a specific service's traffic, BusyBro calls list_service_groups, reads the group's primary_agent, and adopts its instructions as the lens — so reverse-engineering DoorDash traffic can reason with a DoorDash-tuned specialist. The non-primary linked agents are the pool the brain's run_task can delegate sub-tasks to (primary first, then by position). A user manages the links in the dashboard (Settings → Services → a group → Agents tab) or by asking BusyBro (the set_service_group_agents write tool, services:edit + confirm-gated). The older single inline service_groups.ai_agent jsonb has been retired — the column was dropped, and the join is now the only source of truth.
Web access
BusyBro is offered Anthropic's native web_search server tool on every call (alongside the project tools). It runs the search server-side and feeds results back to the model inline within the turn — the outer agentic loop only dispatches BusyBro's custom tool_use blocks, so web searches pass through untouched. The system prompt steers it to prefer project tools/docs for anything internal and reach for the web only when the answer lives outside the codebase (third-party API changes, SDK docs, error lookups, recent events), and to cite sources briefly when it used the web.
id deep-links
Answers are navigable: when BusyBro names a dashboard section it links the section name to its route, and when it mentions a specific entity it deep-links that entity to its own id-route so one click lands the user right on it. The list tools return the id/uuid expressly so the model can compose these links:
| Entity | id-route | Source tool |
|---|---|---|
| device | /devices?uuid=<uuid> | list_devices |
| service group | /services?id=<id> | list_service_groups |
| tag | /tags?id=<id> | list_tags |
| user | /users?id=<id> | list_users |
| breakpoint | /breakpoints?id=<id> | list_breakpoints |
On the web surface these are relative paths (the dashboard intercepts them as in-app navigation); on Telegram the same routes are prefixed with DASH_ORIGIN to make absolute <a href> URLs (no in-app router there). Routes always key off the id/uuid, never a display name (names are user-mutable). This is the same id-route discipline the rest of the dashboard follows.
Streaming, cancel, retry
Dashboard — SSE
When the dashboard sends Accept: text/event-stream, the ask function streams the answer as Server-Sent Events, one frame per brain event:
| Event | Payload | Client behavior |
|---|---|---|
text | { delta } | Append to the live answer bubble (token-by-token Markdown). |
tool | { label } | Show an ephemeral progress line (e.g. 🔍 Inspecting identity.doordash.com…). |
done | { answer } | Finalize the bubble to the authoritative answer. |
error | { message } | Swap in a friendly, retryable error bubble (the thread survives). |
A : ping comment frame every ~15s holds the connection open while tools run. The dashboard's AskBusyBro component is a from-scratch SSE consumer — it reads res.body.getReader(), splits on the blank-line record delimiter, and parses each frame by hand. A per-send AbortController powers the Stop button: aborting the fetch halts the server loop (the function watches req.signal), and the client keeps whatever streamed and tags it (stopped). Finalized answers get a Regenerate affordance; error bubbles get Retry.
The agentic loop survives a max_tokens stop by continuing (a fresh assistant turn picks up the partial answer) up to a total-turn cap; on exhausting iterations it does one final tool_choice: "none" turn and, failing that, yields done with the best partial text — it never throws to the caller.
Telegram — typing indicator
Telegram has no SSE channel, so the bot streams presence instead of tokens: startTyping(chatId) fires a sendChatAction: typing immediately and re-sends it every ~4.5s (Telegram clears the indicator after ~5s or when a message lands) for the entire time the tool loop + model are working. When the answer is ready it's sanitized to Telegram-HTML and sent in chunks, which clears the indicator.
Telegram discussion mode
Beyond the explicit /ask <question> command, BusyBro joins the conversation without an /ask prefix — three paths all sharing the same allow-list + answer path:
- 1:1 DM — every plain (non-command) message is treated as a question.
- Group / supergroup @mention — a plain message that
@-mentions the bot is a question; the mention is stripped before asking, and a bare mention with no question gets a friendly nudge. - Reply-to-continue — replying to one of BusyBro's own messages (detected via
reply_to_message.from.id === botId) continues the thread; BusyBro's prior reply (HTML stripped) rides along aspriorAnswerfor continuity.
Plain group chatter that doesn't address BusyBro is ignored. A message starting with a slash always falls through to normal command dispatch (the command wins).
See also
- BusyBro reference — the feature at a glance + worked asks.
- BusyBro how-to — open it, ask questions, link Telegram, write actions.
- Telegram account linking — link so BusyBro knows your identity + role.
- MCP server (busymate-net) — the full tool surface, including
inspect_requests. - Roles & permissions — the capability model that bounds every write.