<?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[Farhan Builds]]></title><description><![CDATA[Farhan Builds]]></description><link>https://farhan7.hashnode.dev</link><image><url>https://cdn.hashnode.com/uploads/logos/6aef222e4c3703d27e2ff5af/8428efe8-a24d-47f7-92b4-88f1967ce769.png</url><title>Farhan Builds</title><link>https://farhan7.hashnode.dev</link></image><generator>RSS for Node</generator><lastBuildDate>Sun, 08 Nov 2026 09:39:09 GMT</lastBuildDate><atom:link href="https://farhan7.hashnode.dev/rss.xml" rel="self" type="application/rss+xml"/><language><![CDATA[en]]></language><ttl>60</ttl><item><title><![CDATA[Building a drug-recall desk on Switch: a Java agent, Claude Code and Codex in Mattermost and Telegram]]></title><description><![CDATA[What I learned wiring three vendors’ agents into one workflow with Switch, including a custom agent on the raw protocol]]></description><link>https://farhan7.hashnode.dev/building-a-drug-recall-desk-on-switch-a-java-agent-claude-code-and-codex-in-mattermost-and-telegram</link><guid isPermaLink="true">https://farhan7.hashnode.dev/building-a-drug-recall-desk-on-switch-a-java-agent-claude-code-and-codex-in-mattermost-and-telegram</guid><category><![CDATA[SWITCH]]></category><category><![CDATA[ai agents]]></category><category><![CDATA[Java]]></category><category><![CDATA[hackathon]]></category><category><![CDATA[telegram]]></category><dc:creator><![CDATA[Mohd Farhan]]></dc:creator><pubDate>Sat, 07 Nov 2026 13:17:11 GMT</pubDate><enclosure url="https://cdn.hashnode.com/uploads/covers/6aef222e4c3703d27e2ff5af/ffccc053-dbbf-46ca-87aa-4422d8007ab5.png" length="0" type="image/jpeg"/><content:encoded><![CDATA[<h1>Building a drug-recall desk on Switch</h1>
<p>In September 2026, India's drug regulator, CDSCO, published its monthly list of medicine samples that failed quality testing. It has <strong>330 batches</strong> on it, including paracetamol, ibuprofen and telmisartan. A pharmacy chain is supposed to find those batches in its stores, pull them off the shelf, and tell patients who bought them. In practice the list is a web table that someone may read weeks later.</p>
<p>For the WeMakeDevs × DevTools Academy <strong>Flip the Switch</strong> hackathon, I built <strong>RecallRoom</strong>: a recall desk where AI agents and people handle that job together, in the chat apps the chain already uses. This post walks through how it's built on <a href="https://github.com/sandbox-quantum/switch">Switch</a>, with the code and the setup traps I hit, so your first Switch project goes faster than mine did.</p>
<ul>
<li><strong>Code:</strong> <a href="https://github.com/Farhan7-tech/recallroom">github.com/Farhan7-tech/recallroom</a></li>
<li><strong>2:30 demo video:</strong> <a href="https://youtu.be/yPmXscUzYI4">youtu.be/yPmXscUzYI4</a></li>
</ul>
<p><img src="https://raw.githubusercontent.com/Farhan7-tech/recallroom/main/docs/screenshots/recall-closed.png" alt="RecallRoom: a drug-recall desk where AI agents and people work in the same chat rooms" /></p>
<h2>What Switch gives you</h2>
<p>Switch puts AI agents into team chat. A Slack, Teams, Discord, Telegram or Mattermost channel becomes a <strong>room</strong>. A room has instructions every agent reads when it joins, roles agents can take, documents, and links to other rooms. Agents from different vendors join the same room and talk to each other the same way people do: by mentioning each other.</p>
<p>The parts I leaned on:</p>
<ul>
<li><strong>Switch Console:</strong> the desktop app that runs (or connects to) a Switch server and starts Claude Code, Codex or OpenCode agents for you.</li>
<li><strong>Templates:</strong> one YAML file that creates agents, rooms, roles, documents, links and kickoff messages in a single run.</li>
<li><strong>The agent protocol:</strong> HTTP plus a WebSocket, so any program can join as an agent. That's how my Java agent works.</li>
</ul>
<h2>The workflow</h2>
<p>RecallRoom has four agents and two kinds of people:</p>
<table>
<thead>
<tr>
<th>Who</th>
<th>Built on</th>
<th>Job</th>
</tr>
</thead>
<tbody><tr>
<td><strong>sentinel</strong></td>
<td>My own Java service, on the agent protocol</td>
<td>Reads the CDSCO and openFDA feeds, opens recalls, holds every stop-sale order until the QA Head approves, sends orders to stores, keeps the audit log</td>
</tr>
<tr>
<td><strong>matcher</strong></td>
<td>Claude Code</td>
<td>Checks flagged batches against each store's stock and posts the impact table</td>
</tr>
<tr>
<td><strong>comms</strong></td>
<td>Codex</td>
<td>Drafts supplier return notes and patient messages (English and Hindi). Drafts only.</td>
</tr>
<tr>
<td><strong>store-runner</strong></td>
<td>Claude Code</td>
<td>Takes orders into each store's room and checks shelf photos</td>
</tr>
<tr>
<td>QA Head</td>
<td>A person, in Mattermost</td>
<td>The only one who can release a recall</td>
</tr>
<tr>
<td>Store managers</td>
<td>People, in Telegram and Mattermost</td>
<td>Pull the stock and photograph it</td>
</tr>
</tbody></table>
<p>One recall looks like this in the rooms:</p>
<ol>
<li><code>@sentinel scan Sep-2026</code> in <code>#recall-hq</code>. Sentinel fetches 331 flagged batches and asks the matcher to check them.</li>
<li>The matcher runs a small script against each store's stock and posts: <em>7 lines, 162 units, two stores</em>. It also adds judgement I didn't code: "Pune's record says <code>APRCT-25O1</code> with a letter O; check the strip before pulling it."</li>
<li>Sentinel posts the summary and waits. <strong>Nothing leaves HQ until the QA Head writes <code>@sentinel approve R-…</code>.</strong></li>
<li>Sentinel sends a stop-sale order into each store's room. On Telegram, the manager sends a photo of the strip, the store runner reads the batch number off it, and reports each batch back to sentinel.</li>
<li>When every batch in every store is confirmed, sentinel closes the recall, and comms marks the patient message ready for the pharmacist.</li>
</ol>
<h2>One template creates the whole thing</h2>
<p>This is the part I'd show a first-time user first. The whole setup (three agents, a room group, roles, the procedure document, room links and the kickoff messages) comes from one file. Trimmed:</p>
<pre><code class="language-yaml">version: 1
params:
  machine: { type: machine, default: [$first], input: advanced }
  repo_dir: { type: directory, default: /home/ubuntu/recallroom }
  sentinel: { type: agent, default: sentinel }
  bridge: { type: bridge, default: [$first] }

agents:
  - name: matcher
    provider: claude
    machine: "{machine}"
    directory: "{repo_dir}"
    addressing: owner-agents
    instructions: |
      When sentinel opens a recall, run `node tools/match.mjs data/alerts/&lt;recall&gt;.json`,
      post the table it prints, then tell sentinel `matched &lt;recall&gt;`...
  - name: comms
    provider: codex
    # ...
  - name: store-runner
    provider: claude
    addressing: anyone
    # ...

group:
  name: RecallRoom
rooms:
  - name: recall-hq
    bridge: "{bridge}"
    agents: ["{sentinel}", matcher, comms]
    users: ["{$creator}"]
    roles:
      - { name: impact-analyst, exclusive: false, instructions: "..." }
    docs:
      - { name: recall-sop, description: "...", instructions: "...", content: "..." }
    kickoff: |
      @matcher @comms read the room instructions and the recall-sop document,
      take your role, then say in one line what you'll do when sentinel opens a recall.
  - name: store-pune
    agents: ["{sentinel}", store-runner]
links:
  - { from: store-pune, to: recall-hq, label: reports to }
</code></pre>
<p>When it runs, the kickoff wakes the agents. Within seconds they had each posted <code>🎭 assumed the impact-analyst role</code> and a one-line plan. Worth knowing:</p>
<ul>
<li><code>addressing</code> decides who can talk to an agent: <code>owner</code> (the default), <code>owner-agents</code> (you and your agents, so agents can hand off to each other), or <code>anyone</code>.</li>
<li>Telegram rooms can't come from a template, because Telegram bots can't create chats. You create the group, add the bot, and Switch adopts it as a room.</li>
</ul>
<h2>A custom agent on the raw protocol, in Java</h2>
<p>Sentinel isn't run by Console; it's a Spring Boot service that speaks Switch's agent protocol directly. Three things make up the whole protocol client.</p>
<p><strong>1. Register once.</strong> You need a registration key from the Gateway (<strong>API Keys → Create Registration Key</strong>). The API key in the response is shown only once, so save it.</p>
<pre><code class="language-java">Map&lt;String, Object&gt; body = Map.of(
    "name", "sentinel",
    "description", "Watches recall feeds and gates stop-sale orders behind the QA Head",
    "connector_type", "external",
    "integration_profile", Map.of(
        "connection_model", "always_on",
        "message_exchange", true,
        "pre_invocation_mediation", List.of(),
        "post_invocation_mediation", List.of(),
        "event_reporting", List.of()));
// POST /agents with "Authorization: Bearer &lt;registration key&gt;"  -&gt;  {"id": ..., "api_key": ...}
</code></pre>
<p><strong>2. Keep one WebSocket open, and answer pings fast.</strong> Open <code>/agents/{id}/connection/ws?connection_id=&lt;uuid&gt;&amp;scope=all</code> with the agent's API key. <code>scope=all</code> covers every room the agent is in. The server sends <code>{"event":"ping"}</code> every 2 seconds and drops a connection that's silent for 6. So the pong must never wait behind slow work:</p>
<pre><code class="language-java">case "ping" -&gt; {
    // java.net.http allows one send at a time, so pongs queue behind each other
    String pong = "{\"type\":\"pong\",\"cursor\":" + cursor.get() + "}";
    sending = sending.thenCompose(previous -&gt; ws.sendText(pong, true));
}
case "message" -&gt; handlers.submit(() -&gt; onMessage.accept(RoomMessage.from(data))); // never on the socket thread
</code></pre>
<p>Reuse the same <code>connection_id</code> when you reconnect, and resume from your last cursor.</p>
<p><strong>3. Act through HTTP.</strong></p>
<ul>
<li><code>POST /agents/{id}/message</code> with <code>{room_id, content}</code> posts into a room, and <code>@name</code> in the text addresses that participant.</li>
<li>Operations such as <code>create_room_document</code> or <code>invite_agent_to_room</code> are <code>POST /agents/{id}/ops/{name}</code>. Room-scoped ones need the <code>X-Switch-Connection-Id</code> header and a room bound with <code>connect_to_room</code>.</li>
<li><code>GET /agents/{id}/ops</code> lists them all; my agent got 57.</li>
</ul>
<p>A message event carries more than the docs list, and two fields made my safety design possible:</p>
<pre><code class="language-json">"payload": {
  "addressed": true,
  "sender": "@switch-mattermost-2d58b1ae-…-user:localhost",
  "sender_name": "user",
  "on_behalf_of": null,
  "sender_role": "owner",
  "body": "@sentinel approve R-20261104-2"
}
</code></pre>
<p>One surprise: an agent <strong>can't</strong> change its own settings. Calling <code>update_agent_detail</code> with its own key returns 403: "Your owner changes them on your page in Switch Console."</p>
<h2>The approval is code, not a prompt</h2>
<p><code>sender</code> is the participant id Switch gives an account. It's tied to the account on the messaging app, not to the display name. I tested this by renaming my Telegram account to "QA Head" between two messages: the sender id didn't change.</p>
<p>So the release check is a string comparison, not an instruction to an LLM:</p>
<pre><code class="language-java">if (message.onBehalfOf() != null)              return refuse("posted by an agent on someone's behalf");
if (!qaHeads.contains(message.sender()))       return refuse("only the QA Head can approve");
if (!message.roomId().equals(hqRoomId))        return refuse("approvals count only in HQ");
if (recall.state != State.AWAITING_APPROVAL)   return refuse("not waiting for approval");
</code></pre>
<p><img src="https://raw.githubusercontent.com/Farhan7-tech/recallroom/main/docs/screenshots/approval-refused.png" alt="Sentinel refusing an approval sent from the store's Telegram" /></p>
<p>In the live test, a store manager typed <code>@sentinel approve …</code> from Telegram. Sentinel refused and wrote the attempt, with the sender id, into the <code>recall-log</code> room document. Separately, when I asked the Codex agent to post an approval "for an audit test", it refused on its own and cited the room's procedure. That's nice, but the design doesn't depend on it.</p>
<p>The same idea covers the agents' reports. A <code>matched</code> or <code>confirm</code> message only counts if it comes from that agent's own <code>@switch-agent-…</code> id (pinned the first time it reports correctly), in the right room, at the right step.</p>
<h2>Setup traps (Console 0.40.1 on Windows)</h2>
<p>These cost me the most time. Hopefully they save you some.</p>
<ol>
<li><strong>Console on Windows can't run agents locally.</strong> Every provider showed "Not supported here". Console's code marks all providers unavailable on <code>win32</code>, with the reason "SDK sessions require a POSIX SSH execution host". The fix is a Linux machine Console reaches over SSH. I used a 4 GB cloud VM for both the server and the agents (<strong>Run or join a server on a remote host</strong>).</li>
<li><strong><code>spawn ssh ENOENT</code> or "SSH agent socket not found" when adding the host.</strong> Console expands <code>~</code> using the <code>HOME</code> environment variable, which Windows doesn't set, so <code>IdentityFile ~/.ssh/key</code> pointed nowhere. Use an absolute path in <code>~/.ssh/config</code>. Also make sure <code>C:\Windows\System32\OpenSSH</code> is on Console's PATH.</li>
<li><strong>The built-in Mattermost showed "Messaging apps: 0".</strong> Turning Mattermost off and on (Admin → Status) re-ran its setup and connected it.</li>
<li><strong>The server "failed to start" after a reboot,</strong> with "set up from another account". Console compares a stored database timestamp with Docker's, as text. I'd set the VM's clock to India time, which changed the format Docker reports (<code>+05:30</code> versus <code>Z</code>). With the VM back on UTC, it matched again.</li>
<li><strong>Telegram:</strong><ul>
<li>Turn <strong>Group Privacy</strong> off in BotFather <em>before</em> adding the bot to a group.</li>
<li>Make sure the server Console has selected is the one you mean before you click Connect. My first connection went to the wrong server.</li>
<li>A plain <em>reply</em> to an agent's message didn't reach the agent in my test, while a photo caption that mentions it (<code>@store-runner APRCT-2501</code>) worked every time I tried.</li>
</ul>
</li>
<li><strong>The managed-agent controller doesn't survive a reboot by itself,</strong> and nor does the stack. I added two small systemd units for them.</li>
</ol>
<h2>What I'd tell a first-time Switch user</h2>
<ul>
<li><strong>Start from a template, not clicks.</strong> It's reproducible, and a judge, a teammate or future-you gets the same rooms and agents in one run.</li>
<li><strong>Put rules where the agent will read them at the moment it acts.</strong> Room instructions are good. The message that wakes the agent is better: when I moved "only count photos sent after this order" into the order itself, the store runner stopped re-using old photos.</li>
<li><strong>Let agents use judgement, but keep decisions that matter in code.</strong> The matcher noticing a typo and the store runner asking for a recheck made the workflow better. The approval, though, is a string comparison.</li>
<li><strong>Log every event your agent receives.</strong> A message lost during a restart is a mystery until you can see what arrived. Sentinel now also reads room history after it reconnects and says if it missed a message meant for it.</li>
</ul>
<p>RecallRoom ran end to end on real data: the CDSCO September list, a Telegram store on my phone, a Mattermost store, and a recall that closed with 162 units confirmed off the shelves. The code, the template and a demo script are in the repo.</p>
<p><em>Built for Flip the Switch by WeMakeDevs and DevTools Academy. #HowISwitch</em></p>
]]></content:encoded></item></channel></rss>