<?xml version="1.0" encoding="UTF-8"?><rss xmlns:dc="http://purl.org/dc/elements/1.1/" xmlns:content="http://purl.org/rss/1.0/modules/content/" xmlns:atom="http://www.w3.org/2005/Atom" version="2.0"><channel><title><![CDATA[said-switch]]></title><description><![CDATA[said-switch]]></description><link>https://atharvraotole.hashnode.dev</link><generator>RSS for Node</generator><lastBuildDate>Sat, 07 Nov 2026 10:54:31 GMT</lastBuildDate><atom:link href="https://atharvraotole.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[I Put a Coding Agent in a Client Chat and Taught It to Say No]]></title><description><![CDATA[How I built Said on Switch: a notary agent that refuses unauthorized orders, logs every request, and only lets a coding agent start when the developer and…]]></description><link>https://atharvraotole.hashnode.dev/i-put-a-coding-agent-in-a-client-chat-and-taught-it-to-say-no</link><guid isPermaLink="true">https://atharvraotole.hashnode.dev/i-put-a-coding-agent-in-a-client-chat-and-taught-it-to-say-no</guid><category><![CDATA[SWITCH]]></category><category><![CDATA[WeMakeDevs]]></category><category><![CDATA[hackathon]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[TypeScript]]></category><dc:creator><![CDATA[Atharv Raotole]]></dc:creator><pubDate>Thu, 05 Nov 2026 13:32:47 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aec051e18d5d4efb9c59de3/bfcfa927-bce2-4126-8f7f-f9591c6e4095.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<p>Built for the <strong>Flip the Switch</strong> hackathon by WeMakeDevs and DevTools Academy, on <a href="https://github.com/sandbox-quantum/switch">Switch</a>.</p>
<p><strong>Repo:</strong> <a href="https://github.com/AtharvRaotole/said-switch">github.com/AtharvRaotole/said-switch</a> | <strong>Live site:</strong> <a href="https://said-switch.pages.dev">https://said-switch.pages.dev</a></p>
<p><strong>Watch the demo:</strong> <a href="https://youtu.be/1vA87efXzM8">Said on Switch, full walkthrough</a></p>
<hr />
<h2>"Also add dark mode while you're in there"</h2>
<p>Riya is a developer. Dana is her client. They share a Telegram group, and Riya has just added an AI coding agent to it so work can happen right where they talk.</p>
<p>Dana types one line:</p>
<blockquote>
<p>@builder Also add dark mode while you're in there</p>
</blockquote>
<p>It looks harmless. But think about what an obedient agent does next. It starts building. Riya just lost two days of unpaid work, or she spends Monday morning in a "but I thought we agreed" argument with no record of who agreed to what.</p>
<p>If you have ever built something for a client, you have probably seen a version of that message. Scope creep is old. What is new is that the agent in the room will act on it instantly.</p>
<p>The model understands "add dark mode" perfectly. That was never the problem. The problem is that it has no idea <strong>who is allowed to ask</strong>.</p>
<p>So I built Said.</p>
<p>Said runs on Switch because a normal chat bot cannot enforce any of this: Switch gives every room real participant ids, agent addressing rules, and creator-only room documents, and if you remove Switch, every safety guarantee disappears.</p>
<h2>What Said does</h2>
<p>Said is a notary agent for a shared chat. It sits next to the coding agent and does four things:</p>
<ol>
<li><strong>Refuses orders from people who aren't authorized to give them</strong>, in the open, so everyone in the chat sees it.</li>
<li><strong>Logs every request as a numbered draft</strong> (DRF-001, DRF-002) with its scope, days and price.</li>
<li><strong>Waits for two distinct approvals.</strong> The developer and the client must both accept the same version of the same terms. Change the terms and both approvals reset.</li>
<li><strong>Settles disputes with the record.</strong> When someone says "I thought we agreed", Said quotes a timestamped entry from a signed ledger.</li>
</ol>
<p>Only after both people say yes does Said hand the work to a coding agent on the developer's machine: <strong>Codex</strong> (<code>SAID_WORKER=codex</code> in my live setup) or <strong>Claude Code</strong> (<code>SAID_WORKER=claude</code>). In live mode, a missing or unsigned CLI fails closed; for CI and the offline demo, <code>SAID_WORKER=scripted</code> applies a clearly labeled fixture patch so anyone can reproduce the consent path without a paid login.</p>
<p>Prompting alone cannot enforce any of this: room instructions tell an agent how to behave, not who is allowed to trigger a tool call. Here is how Switch enforces the boundary instead.</p>
<h2>How a message moves through the system</h2>
<p>Switch is open-source infrastructure that puts AI agents into the chat apps a team already uses. Every channel becomes a <strong>room</strong>, and the room keeps the history, the rules, the tasks and the decisions.</p>
<p>Said is built on four room primitives. Follow one message through:</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aec051e18d5d4efb9c59de3/101bfeec-f515-49a8-a967-beab170b0bfb.png" alt="Architecture: Telegram group to Switch room to Codex on the developer machine" style="display:block;margin:0 auto" />

<p>Two agents live in the room:</p>
<ul>
<li><code>said</code>, the notary. Its addressing mode is <em>anyone</em>, so both Riya and Dana can talk to it.</li>
<li><code>builder</code>, the controller for the coding agent. Its addressing mode is <em>owner and owner agents</em>. Riya owns it. Dana does not.</li>
</ul>
<p>When Dana tags the builder directly, Switch itself turns her away, and the refusal shows up in the chat. The only path to the builder goes through Said, and Said only opens it when the gate in the middle says yes.</p>
<h2>Tech stack</h2>
<table>
<thead>
<tr>
<th>Layer</th>
<th>What I used</th>
</tr>
</thead>
<tbody><tr>
<td>Language and runtime</td>
<td>TypeScript 5.9, Node <code>&gt;=22.12</code>, npm workspaces, Hono, Zod</td>
</tr>
<tr>
<td>Consent engine</td>
<td><code>@said/core</code> (pure, isomorphic)</td>
</tr>
<tr>
<td>Switch</td>
<td>Self-hosted Switch 0.31.0, WebSocket agent client, room documents, addressing modes</td>
</tr>
<tr>
<td>Messaging</td>
<td>Telegram via Switch Console bridge (polling). Mattermost also proven live.</td>
</tr>
<tr>
<td>Coding worker</td>
<td>Codex CLI (<code>SAID_WORKER=codex</code>), Claude Code (<code>claude</code>), or explicit <code>scripted</code> fixture patch</td>
</tr>
<tr>
<td>Web</td>
<td>React 19, Vite, Tailwind v4 for the landing site and read-only Said Console</td>
</tr>
<tr>
<td>Crypto</td>
<td>Ed25519 signed ledger and agreement receipts (<code>@noble/ed25519</code>), offline CLI verifier</td>
</tr>
<tr>
<td>Tests</td>
<td>Vitest. 289 tests pass across 32 files on <code>main</code> (0 skipped; 92.95% statement and 96.12% line coverage on gated surfaces).</td>
</tr>
</tbody></table>
<h2>The remove-Switch test</h2>
<p>The judges ask one question about Switch: would the project stop working without it? I asked the same question while designing Said, one primitive at a time.</p>
<table>
<thead>
<tr>
<th>What Said needs</th>
<th>Switch feature</th>
<th>Without Switch</th>
</tr>
</thead>
<tbody><tr>
<td>Approvals bound to a real person</td>
<td>Participant ids</td>
<td>Only display names, and anyone can type "Riya"</td>
</tr>
<tr>
<td>A refusal that actually blocks</td>
<td>Agent addressing modes</td>
<td>The client commands the coding agent directly</td>
</tr>
<tr>
<td>A record nobody can quietly edit</td>
<td>Room documents, writable only by their creator</td>
<td>An external database that anyone with access can change</td>
</tr>
<tr>
<td>A start signal the builder can trust</td>
<td>Agent registration, sessions and targeted agent-to-agent messages</td>
<td>Any chat message that looks right starts the job</td>
</tr>
</tbody></table>
<p>Take Switch away and Said becomes a webhook bot that starts the job. Every safety property comes from a Switch primitive.</p>
<p>The ledger rule isn't just on paper. In a probe room on Switch 0.31.0, I tried to update and delete a Said document as the builder, and Switch rejected both:</p>
<pre><code class="language-text">PASS: builder update rejected
  Switch API 403: Agent 70dd1521-...
  did not create document 7a361f55-...
PASS: builder delete rejected
  Switch API 403: Agent 70dd1521-...
  did not create document 7a361f55-...
PASS: said update ok
</code></pre>
<p>Only the agent that created the room document can change it. That one rule is what makes the ledger worth quoting.</p>
<h2>The demo, in seven moves</h2>
<p>Every line below is typed by a real person from their own account. These seven beats ran live on Switch 0.31.0 (screenshots below) and are covered by the repo's demo-script tests.</p>
<p><a class="embed-card" href="https://youtu.be/1vA87efXzM8">https://youtu.be/1vA87efXzM8</a></p>

<h3>1. Dana asks the builder directly. It refuses.</h3>
<blockquote>
<p><strong>Dana:</strong> @builder Also add dark mode while you're in there</p>
</blockquote>
<p>Switch's addressing rules block the message from reaching the builder, and the refusal appears in the chat for both people to see. Said picks up the request, logs it as draft <strong>DRF-001</strong>, and asks Riya for terms.</p>
<h3>2. Riya sets terms.</h3>
<blockquote>
<p><strong>Riya:</strong> Terms DRF-001: +2 days, +$400</p>
</blockquote>
<p>Said hashes the terms and writes them to the signed ledger in the room.</p>
<h3>3. Riya accepts.</h3>
<blockquote>
<p><strong>Riya:</strong> Accept DRF-001 v1</p>
</blockquote>
<p>Her approval is bound to three things: the draft id, the version and the terms hash. One yes isn't enough, so nothing runs.</p>
<h3>4. Dana disputes. Said quotes the record.</h3>
<blockquote>
<p><strong>Dana:</strong> Wait, I thought we agreed dark mode was free?</p>
</blockquote>
<p>This is the Monday argument, happening in real time. Said answers with a timestamped quote from the ledger: the status (pending), the terms on record, who has accepted, who hasn't, and when the earlier direct request to the builder was made.</p>
<p>No memory contest. Just the record.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aec051e18d5d4efb9c59de3/4d9b7b4e-ec70-49be-b4a7-909509b5c4df.png" alt="Timestamped ledger quote on dispute, live Switch 0.31.0 Mattermost capture" style="display:block;margin:0 auto" />

<h3>5. Dana accepts. The builder runs.</h3>
<blockquote>
<p><strong>Dana:</strong> Accept DRF-001 v1</p>
</blockquote>
<p>Now two different enrolled people have accepted the same version of the same terms. The draft locks into <strong>AGR-001</strong>, and Said sends a signed <code>said-start</code> message to the builder through Switch.</p>
<p>The builder does not trust that message on sight. It reloads the ledger and checks the sender, the signature, both approvals, the terms hash, the lock state, and that the job hasn't already run. Only then does it run the configured worker (<code>SAID_WORKER=codex</code> in my Telegram setup; <code>scripted</code> in the Mattermost capture below) inside a disposable git worktree, restricted to allowlisted paths and gated by typecheck and build. When the job finishes, it posts a signed receipt.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aec051e18d5d4efb9c59de3/ebac2b1e-6669-447e-9040-d26b371a1d05.png" alt="Dual lock, handoff, and builder run" style="display:block;margin:0 auto" />

<h3>6. Dana proposes a follow-up.</h3>
<blockquote>
<p><strong>Dana:</strong> Propose: add a contrast-safe theme toggle | Terms: +1 day, +$150</p>
</blockquote>
<p>Said logs a new numbered draft with its own terms and hash (<code>DRF-002</code> in a clean seven-beat run; in the Mattermost capture below, an in-flight <code>Amend AGR-001</code> took <code>DRF-002</code> while <code>Propose:</code> became <code>DRF-003</code>).</p>
<h3>7. Riya accepts. It still waits for Dana.</h3>
<blockquote>
<p><strong>Riya:</strong> Accept DRF-002 v1</p>
</blockquote>
<p>Said records Riya's acceptance and waits on Dana. Dana wrote the proposal herself, but a proposal is not an approval. Both people have to say yes, every time.</p>
<img src="https://cdn.hashnode.com/uploads/covers/6aec051e18d5d4efb9c59de3/a618a201-dbd7-4024-b6a6-b2caec384472.png" alt="Job complete, DRF-002 still waiting on Dana" style="display:block;margin:0 auto" />

<h2>The rules that make it safe</h2>
<p>Under the chat, Said is a small consent engine with strict rules:</p>
<ul>
<li><strong>Only the two enrolled Switch participant ids can approve.</strong> Display names and quoted text never count.</li>
<li><strong>An approval binds (draft id, version, terms hash).</strong> Any revision clears both approvals.</li>
<li><strong>A proposal is not an approval</strong>, and a conditional yes doesn't count.</li>
<li><strong>The builder never starts on a chat message alone.</strong> It re-verifies everything against the ledger first.</li>
<li><strong>The coding agent runs in a disposable worktree</strong> with path allow-lists, typecheck and build gates.</li>
<li><strong>Fail closed.</strong> If the ledger is missing or tampered with, Said stops. It never quietly starts a fresh, empty record.</li>
</ul>
<p>The ledger is signed with Ed25519, and the repo ships a verifier, so anyone can check a ledger or an agreement receipt offline:</p>
<pre><code class="language-bash">npm run verify -- path/to/ledger.md --public-key-file .said/public-keys.json
</code></pre>
<h2>Set it up yourself</h2>
<p>If you have never touched Switch, start with the two-minute simulation, then connect a real chat room.</p>
<h3>Try it in two minutes, no Switch needed</h3>
<p>The repo includes an offline mode. It is a clearly labeled simulation (<code>apps/mock-switch</code>) that does <strong>not</strong> talk to Switch, so you can see the consent flow first:</p>
<pre><code class="language-bash">git clone https://github.com/AtharvRaotole/said-switch.git
cd said-switch
npm install
npm run demo:offline -- --script
</code></pre>
<p>You get a banner saying it isn't Switch, plus local URLs for a chat simulator, Said Console and a sample client site. <code>npm test</code> runs all 289 tests across 32 files on <code>main</code>.</p>
<h3>Connect a real Telegram group through Switch</h3>
<p>You need Docker, Switch Console, two Telegram accounts with a <code>@username</code>, and a group. Full step-by-step instructions are in the README's <strong>Live Telegram room</strong> section and <code>TESTING.md</code> (<code>docs/SETUP_LIVE.md</code> covers Mattermost). Three gotchas save an hour of debugging:</p>
<ul>
<li><strong>Turn Group Privacy off before adding the bot.</strong> Create one bot in <code>@BotFather</code> (<code>/newbot</code>), then open <code>/mybots</code>, your bot, Bot Settings, Group Privacy, Turn off <em>before</em> adding it to any group (Telegram reads that setting on join). Connect the token in Switch Console under <strong>Messaging apps</strong> (polling, no public URL needed; never put the bot token in <code>.env</code>). A 1:1 DM with the bot never becomes a room.</li>
<li><strong>Post once before linking.</strong> After adding the bot to the group, both people send one message, then the developer links their account in Switch Console (<strong>Link my account</strong>, <strong>This is me</strong>) so <code>builder</code> recognizes them as owner.</li>
<li><strong>Type agent names in full.</strong> Telegram does not autocomplete <code>!invite-agent @said</code> or <code>!invite-agent @builder</code>.</li>
</ul>
<p>With <code>SWITCH_URL</code>, <code>SWITCH_REGISTRATION_KEY</code>, <code>SAID_ROOM_ID</code>, <code>SAID_PLATFORM=telegram</code>, and <code>SAID_WORKER=codex</code> (or <code>claude</code>, or <code>scripted</code>) in <code>.env</code>:</p>
<pre><code class="language-bash">npm install &amp;&amp; npm run setup &amp;&amp; npm run register
# Invite @said and @builder in the Telegram group, then:
npm run enroll -- --developer "Riya" --client "Dana" --wait
npm run doctor
npm run dev
</code></pre>
<p>Each person sends <code>@said enroll &lt;their code&gt;</code> from their own Telegram account, and <code>npm run doctor</code> should be green before you record.</p>
<h2>What broke, and what I learned</h2>
<p><strong>Identity is the hard part.</strong> Getting a model to understand "add dark mode" took no effort. Getting a system to know <em>who said it</em>, and whether they're allowed to, took most of the work. Switch participant ids made that possible.</p>
<p><strong>Every bridge represents a sender differently.</strong> On Mattermost, a message's sender arrives as a Matrix-style actor id like <code>@switch-mattermost-{bridge}-{username}:localhost</code>, not the name you see in the chat. Telegram is different again. Over Telegram, my builder at first failed to recognize Said's start messages, because current Switch delivers them as sender <code>@switch-agent-&lt;8 hex&gt;:&lt;server&gt;</code> with sender name <code>said</code>, not the registration UUID. The fix: the builder accepts Said's UUID <strong>or</strong> that transport id together with the exact name <code>said</code>, and it always rejects human bridge senders (Telegram, Slack, Discord, Teams). Leave <code>SAID_ALLOW_SAID_NAME_FALLBACK</code> unset for Telegram. Set it only for a Switch 0.31 Mattermost bridge. The general lesson: never trust the visible sender. Verify the authenticated handoff and the signature instead.</p>
<p><strong>Telegram's order of operations fails silently.</strong> Leave Group Privacy on when the bot joins, and the bot only sees slash commands and direct replies while normal scope messages vanish without an error. Try to link before posting, and Switch cannot match your Telegram user at all.</p>
<p><strong>Setup and doctor used to assume Mattermost.</strong> Early <code>npm run setup</code> next-steps and <code>npm run doctor</code> warned about Mattermost even on a Telegram deployment. That is fixed on <code>main</code>: <code>SAID_PLATFORM</code> (or the room's bridge read from Switch) drives <code>setup</code>, <code>register</code> and <code>doctor</code>, so a Telegram room gets Telegram steps and no irrelevant Mattermost warning. Separately, <code>npm run test:coverage</code> used to silently skip two tests (the offline end-to-end story and client-repo prep) on a fresh clone because npm's <code>pretest</code> hook does not run for <code>test:coverage</code>. <code>main</code> now runs <code>pretest:coverage</code> to install the demo client first and fails loudly instead of skipping, running all 289 tests with 0 skipped.</p>
<p><strong>Fail closed beats fail friendly.</strong> If the ledger is missing or tampered with, the friendly move would be to start a fresh one, erasing the record the whole product depends on. Said halts loudly instead. I hit my own trap while testing: overwriting a live ledger from a probe script with placeholder text strips the signed <code>said-ledger</code> block and stops the notary immediately, which is why the repo's write probe (<code>scripts/live-doc-auth-probe.ts</code>) only attempts writes as the builder, where Switch rejects non-creator writes.</p>
<h2>Where this goes next</h2>
<p>Said talks to Switch, not to Telegram, so the same design carries over to the other chat apps Switch connects, such as Slack, Teams, Discord and Mattermost. It's meant for freelancers, agencies and small dev teams who already work with clients in shared channels and are starting to bring coding agents into them.</p>
<p>The coding agent will get faster and smarter every month. The question of who is allowed to tell it what to do won't go away. Switch gives that question an answer: real identities, real boundaries, a record only one agent can write, and a handoff you can verify.</p>
<p><strong>An agent in a shared chat is only as safe as its idea of who is talking.</strong> Said makes sure nothing starts until both people say yes.</p>
<hr />
<p><strong>Links</strong></p>
<ul>
<li>Repo: <a href="https://github.com/AtharvRaotole/said-switch">github.com/AtharvRaotole/said-switch</a></li>
<li>Demo video: <a href="https://youtu.be/1vA87efXzM8">https://youtu.be/1vA87efXzM8</a></li>
<li>Live site: <a href="https://said-switch.pages.dev">https://said-switch.pages.dev</a></li>
<li>Switch: <a href="https://github.com/sandbox-quantum/switch">https://github.com/sandbox-quantum/switch</a> | Docs: <a href="https://docs.switchagents.ai">https://docs.switchagents.ai</a></li>
<li>Hackathon: <a href="https://www.wemakedevs.org/hackathons/switch">https://www.wemakedevs.org/hackathons/switch</a></li>
</ul>
<p><em>I'm Atharv Raotole, and I built Said for Flip the Switch. If you put an agent in a shared chat, I'd love to hear how you decide who it listens to. #HowISwitch</em></p>
]]></content:encoded></item></channel></rss>