BusyBro internals

The shared brain behind the dashboard chat and Telegram bot: identity + role, live-data grounding, secret redaction, and capability-gated writes.

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 brainsupabase/functions/_shared/busybro.ts — persona, specialist routing, the tool loop, prompt context, streaming.
Two surfaces, three hostssurface: "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 tiersREAD live data · REVERSE-ENGINEER captured traffic (inspect_requests, secrets redacted) · WRITE (capability-gated + ownership-scoped, incl. managing per-service specialist agents).
SpecialistsA 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 accessAnthropic's native web_search server tool — BusyBro reaches current/external info beyond the repo + docs and cites it.
IdentityDashboard: the logged-in OAuth session. Telegram: the linked dashboard account (see Telegram account linking).
GroundingCurated repo docs (GitHub contents API) + live Supabase reads via SELECT-only tools + the web.
Modelclaude-sonnet-4-6, streamed via the Anthropic Messages API.
StreamingSSE 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 /ask command + 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 in public.busybro_threads (one row per chat, service-role only); /reset clears it. Both Telegram bots share _shared/telegram.ts (the token-parameterized API client + the strict Telegram-HTML sanitizer) and the same telegram_links linking, so a user links once for both.

Only two things differ between surfaces, and both are parameters on answerWithBusyBro(…):

  1. surface controls 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.
  2. db is 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).
Dashboardask fn · surface: webTelegram /asktelegram-bot · surface: telegram_shared/busybro.tspersona · routing · tool loopprompt context · streamingAnthropic Messagesclaude-sonnet-4-6Supabaseread + write tools

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.

ToolReads
list_devicesDevice roster — name, model, OS, last seen, country, app build/source (returns uuid for id deep-links).
device_statusOne device's live status — online/offline, VPN state, heartbeat, IP country.
count_entriesCaptured-entry count in a time window, optionally per device / host.
recent_entriesNewest captured requests — time, device, method, status, host, path.
effective_settingsA device's merged settings (device override over global), or the global defaults.
list_workspacesWorkspaces — id, slug, name, retention.
list_tagsTags — id, name, color, match patterns.
list_service_groupsService 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_usersDashboard users — id, email, display name, role. View-gated: advertised only when you hold users:view.
list_breakpointsRecent 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:

  • host must be a full hostname (it must contain a dot, e.g. identity.doordash.com). The entries table 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 forToolCapability section
Set/clear a device Live Activity messageset_live_activity_messagedevices
Rename a devicerename_devicedevices
VPN on / offvpn_on_device / vpn_off_devicedevices
Per-device connection type (vpn/pac)set_device_connection_typedevices
Global connection typeset_global_connection_typeglobal
Create/update/delete a service groupupsert_service_group / delete_service_groupservices
Link BusyBro agents to a service group (full-replace, one primary)set_service_group_agentsservices
Create/update/delete a tagupsert_tag / delete_tagtags

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:

  • AdvertisedbuildToolList(writeContext) only includes a write tool when canEdit(wc, section) is true (mirrors the DB has_capability(section, 'edit'): grants_all passes everything, else the section's edit flag must be exactly true). Absent a writeContext, 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 you grants_all, the resolved device's owner_user_id must 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 sends Authorization: Bearer <oauth-access-token> — the same Supabase-signed JWT the MCP server issues. The function validates it with auth.getUser(token), requires role=authenticated, then reads profiles for the friendly display_name + role + the user's BusyBro response preferences. The role's roles row (grants_all, capabilities) becomes the writeContext. 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 a writeContext built 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 /login to 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:

  1. 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.
  2. 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.
  3. 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_search server 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.

LensCovers
📱 iOS specialistBusymateHelper: Swift/SwiftUI, NEPacketTunnelProvider VPN + MITM, RealtimeSubscriber, device JWTs.
🖥️ Dashboard specialistNext.js + shadcn capture viewer, per-tab state, Realtime, auth.
🔌 Proxy-server specialistNode Charles-style MITM, TrafficLogEntry, CA/leaf, control channel.
🗄️ Supabase specialistPostgres schema/RLS/Realtime triggers, Edge Functions, the MCP server.
🌐 CDP-connector specialistThe bmc CLI, Chrome DevTools Protocol capture, ingest.
📚 Docs specialistThe docs/ tree + the docs site.
🛡️ Security specialistAuth/RLS, service-role exposure, open-proxy/flood/DoS, SSRF, secrets, MCP tool safety; register in security/.
⚡ Performance specialistProject-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 guideCross-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_groupsservices: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.

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:

Entityid-routeSource 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:

EventPayloadClient 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 as priorAnswer for 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