Switchboard
A small agent runtime that links a team's support, chat, issue and CRM tools, where every write is idempotent and risky writes wait for a human in Slack
- Role
- Sole designer and builder
- Status
- Sandbox deploy
- Period
- Oct 2026
- Stack
- TypeScript, Cloudflare Workers, D1
Problem
At the B2B SaaS company where I work, a customer signal crosses four tools. A bug report arrives in Intercom, gets discussed in Slack, becomes a Linear issue, and belongs to a customer record in Attio. Every link between them is made by hand, and items drop between tools.
The goal I wrote down was "every customer signal reaches the right tool and person, linked end to end, with no copy-paste and no unapproved writes." I built Switchboard in my own repository as a tool for that team.
Constraints
- No unapproved writes. An agent that can create issues and post to channels on its own is a liability. Writes the team cares about must wait for a person.
- Everything retries. Webhooks redeliver, queues redeliver, and a model run that fails halfway gets run again. A write must happen once no matter how many times its run executes.
- Tokens are secrets. OAuth tokens for four providers sit in the database.
- Free-plan budget. It runs on Cloudflare's free tier. The dashboard bundle is 2,920 KiB gzipped against a 3,072 KiB limit.
Architecture
Each tool is connected once as a connector. Slack, Linear and Attio take events in and perform actions out; Intercom triggers agents and offers read-only actions. An agent declares the events that start it, the actions it may use, which writes need approval, and the outcome it exists to produce.
One Cloudflare Worker holds the webhook routes, Slack interactions, an admin API and the dashboard API. It consumes its own queue, retrying up to five times before a dead-letter queue, and runs a cron every five minutes. A Next.js dashboard with Sign in with Slack runs as a second Worker through OpenNext.
A request, end to end.
- Intake.
POST /webhooks/:connectorverifies the signature over the raw body, normalizes the payload, stores it once per connector and external ID, queues a dispatch job, and replies 200. - Dispatch. One run row per matching agent, then one run job each. Events caused by Switchboard's own writes are marked as self-originated and skipped, so it never triggers itself.
- Run. The agent calls
ctx.tools.linear.createIssueand the like through a proxy that exposes only the actions it declared; anything else throws before a provider is called. Reads run immediately. Writes go to the ledger. - Approve. If the agent's approval rule covers the write, a Block Kit card goes to Slack and the row waits. Otherwise the write runs inline.
- Outcome. The result returns to the agent that proposed it as an executed, rejected or failed event, and the agent carries on from there.
The first agent watches for an Intercom conversation tagged bug. It finds the Attio company by email domain, searches Linear for a duplicate, and proposes a Linear issue for approval. Once approved and created, it opens a Slack thread, links the three records, and adds a note in Attio. Later status changes in Linear are posted into the same thread.
The runtime has a model layer, a structured classify call and a tool loop over the Anthropic SDK, and every tool call a model makes goes through the same declared tools and the same ledger. The first agent's decision is deterministic, so it does not call a model at all.
Key decisions
-
Connectors never call each other. All logic lives in agents. That avoids a mesh of point-to-point integrations, and it means no agent has write access to a tool it did not declare.
-
Approval pauses the write, not the run. A run that needs approval ends; the result comes back later as a new event. Runs stay stateless and safe to retry. I rejected Cloudflare Workflows for this as more moving parts than the problem needed.
-
The write key is the step, not the input. Each write is keyed by
sha256(runId, action, callIndex). If the input were part of the key, a retried model run that rephrased an issue title would write twice. The trade-off: two genuinely identical calls in one run are two writes. -
A sweeper instead of Durable Objects. One scheduled handler closes every crash gap from rows already in D1: undispatched events, stuck runs, and writes left executing for more than 30 minutes, which are marked failed. No new infrastructure.
-
Fail at deploy, not at runtime. The agent registry is validated when the Worker starts. A typo in an approval list would otherwise silently skip approval; now the deploy fails. Dashboard config fails closed the same way: with its allowlists empty, every call is rejected and nobody is an admin.
-
Secrets stay narrow. OAuth uses PKCE, with the state bound to the Slack user and a hashed browser cookie. Tokens are stored with AES-256-GCM using the connector ID as associated data, refreshed single-flight, and redacted before anything is logged. The activity feed shows object references only, never raw provider responses.
-
One provider. I moved the dashboard off Vercel so that no second provider handles its data. The cost is living close to the free-plan bundle limit.
How it was built
I wrote the plan and acceptance criteria, then ran each workstream through Claude subagents under my agentic workflow, with independent review passes between them. An audit before the reliability work found five themes; the independent review raised twelve findings, of which nine were fixed in full or in part, two deferred, and one rejected. The test count went from 234 after the first day to 646 at the end: 602 in the Worker and packages, 44 in the dashboard, running in Miniflare.
What broke
Most of these came out of that review, before any real traffic:
- A retried run could write twice. Fixed by keying writes on the run step.
- A rejected write looked pending forever. It was reported back as awaiting approval, so the conversation could never be proposed again. Rejection and failure are now their own events, and the agent unlinks the proposal so a re-tag starts fresh.
- Saving a row and queueing its job were not atomic. A crash between them stranded work. A
dispatched_atcolumn plus the sweeper closes the gap. - A Linear issue could be created twice when a create was retried after a provider 5xx. Creates now carry a client ID derived from the action ID.
- Token failures were misread. A revoked Slack token looked like a plain 400; it now marks the connection for reconnect. A 401 on a token that could be refreshed was acknowledged and dropped; it now waits for the refresh and retries.
- No timeouts. Provider calls now have a 20 second deadline.
- Customer text could ping a whole channel. Broadcast mentions in Intercom messages are now escaped before they reach Slack.
- The feed leaked across issues. Its link walk went through the shared Attio company record and pulled in every other issue for that customer. It no longer hops through company records and reads at most 50 links.
- Queries off their indexes.
EXPLAIN QUERY PLANcaught them. A feed page went from about 151 queries to 2. - Framework edges. OpenNext returned 500 on every dynamic route under Next.js 16.4 until a pinned one-line patch. Server Actions accepted requests with no
Originheader, so each action now refuses them. The Worker imported Zod without declaring it and silently got version 3.
Results
57 commits over four days, 10 to 13 October 2026. A write that needs no approval costs at most four D1 round trips. Raw payloads are kept 30 days and approvals expire after seven.
A sandbox Worker is deployed without provider secrets. The dashboard and the reliability work are not deployed yet, and Switchboard has not run end to end against the real tools: connecting the company's accounts needs a security sign-off first.
What I'd change
- Run it for real. Until one bug report goes from Intercom to an approved Linear issue in production, the design is a well-tested hypothesis.
- Replace the shared secret. The dashboard proves identity to the Worker with a 60-second, single-use signed assertion, but anyone holding the signing secret can act as an admin. That risk was accepted for a sandbox, not for production.
- Give an agent a model. The next agent should be one that needs judgment, such as reading a conversation and deciding where it belongs. That is where
classifyearns its place.