<?xml version="1.0" encoding="UTF-8"?>
<rss version="2.0" xmlns:atom="http://www.w3.org/2005/Atom" xmlns:dc="http://purl.org/dc/elements/1.1/">
  <channel>
    <title>DEV Community: Sahvendra</title>
    <description>The latest articles on DEV Community by Sahvendra (@sahvi).</description>
    <link>https://dev.to/sahvi</link>
    <image>
      <url>https://media2.dev.to/dynamic/image/width=90,height=90,fit=cover,gravity=auto,format=auto/https:%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Fuser%2Fprofile_image%2F4233055%2Fbdabaa8b-6787-43b4-ac83-3ab39d047b61.png</url>
      <title>DEV Community: Sahvendra</title>
      <link>https://dev.to/sahvi</link>
    </image>
    <atom:link rel="self" type="application/rss+xml" href="https://dev.to/feed/sahvi"/>
    <language>en</language>
    <item>
      <title>A Screenshot Is a Claim: Building Private Money Agents for Indian Groups on Switch</title>
      <dc:creator>Sahvendra</dc:creator>
      <pubDate>Sun, 08 Nov 2026 03:34:50 +0000</pubDate>
      <link>https://dev.to/sahvi/a-screenshot-is-a-claim-building-private-money-agents-for-indian-groups-on-switch-18o</link>
      <guid>https://dev.to/sahvi/a-screenshot-is-a-claim-building-private-money-agents-for-indian-groups-on-switch-18o</guid>
      <description>&lt;p&gt;&lt;em&gt;Every person gets their own AI agent, a clerk does the group's maths in plain code, and Switch's server keeps each agent out of rooms it doesn't belong to, so nobody's bank statement has to reach the group chat. Built for the Flip the Switch hackathon.&lt;/em&gt;&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Live demo video (2:58):&lt;/strong&gt; &lt;a href="https://youtu.be/YmwLhxHsZ6c" rel="noopener noreferrer"&gt;https://youtu.be/YmwLhxHsZ6c&lt;/a&gt; · &lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/Sahvendra7/Kitty" rel="noopener noreferrer"&gt;https://github.com/Sahvendra7/Kitty&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;  &lt;iframe src="https://www.youtube.com/embed/YmwLhxHsZ6c" width="710" height="399"&gt;
  &lt;/iframe&gt;
&lt;/p&gt;

&lt;p&gt;Meera says she paid Arjun ₹1,500. Arjun's bank shows nothing.&lt;/p&gt;

&lt;p&gt;This is usually where a group trip's settling-up turns awkward. Somebody has to show a screenshot of their bank app, with their balance and everything else that month on it. Or someone quietly lets it go.&lt;/p&gt;

&lt;p&gt;In the system we built, Meera's own agent goes back through her statement in a room only she can see, and tells her: the money left at 3:02 PM and came back at 3:09 PM, with the same reference. The payment failed. She withdraws the claim, and the group sees one line: "The payment failed and never arrived." Neither statement was shown to the group.&lt;/p&gt;

&lt;p&gt;It matters more than it sounds. In our test trip, Arjun ends up owed ₹391.67. If that failed payment had counted, he'd have owed ₹1,108.33. A bank glitch would have flipped who owes whom.&lt;/p&gt;

&lt;p&gt;This post covers how we built it on &lt;a href="https://github.com/sandbox-quantum/switch" rel="noopener noreferrer"&gt;Switch&lt;/a&gt;, what you need to do to run several separately owned agents on one machine, and the things that went wrong. Most of what we learned came from the failures.&lt;/p&gt;

&lt;h2&gt;
  
  
  What we built, in one minute
&lt;/h2&gt;

&lt;p&gt;Kitty is private group-money reconciliation for Indian groups. It does two jobs: splitting shared expenses on a trip, and running a &lt;strong&gt;pot&lt;/strong&gt;, where one treasurer collects everyone's UPI contributions. Every person gets their own agent, which reads only their own statement in a room only they share with it. The group room holds the people and a &lt;strong&gt;clerk&lt;/strong&gt;, which is ordinary Python, not a model. Only claims a person has approved, or payments the recipient has confirmed, ever reach it.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7k26aodheowgahca3aum.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F7k26aodheowgahca3aum.png" alt="Kitty's rooms on Switch: each person's private room, their desk, and the group room where only the clerk is an agent" width="800" height="496"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;This post is built around the four things Switch gives you that made this possible. If you're new to Switch, they're the four to learn first:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;
&lt;strong&gt;Separately owned agents&lt;/strong&gt; (section 3): each person's agent belongs to that person, with their own key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Rooms the server enforces&lt;/strong&gt; (sections 4 and 7): an agent that isn't a member gets a 403, whatever its prompt says.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Sender ids the server sets&lt;/strong&gt; (sections 8, 9 and 12): "who said this" comes from Switch, not from the message text.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;The Mattermost bridge&lt;/strong&gt; (sections 5 and 11): real people and agents in ordinary team-chat channels.&lt;/li&gt;
&lt;/ol&gt;

&lt;h2&gt;
  
  
  1. The problem
&lt;/h2&gt;

&lt;p&gt;Group trips are fun, and settling up isn't. Splitwise and its cousins work if somebody types every expense as it happens, and on most trips nobody does. Bank-import features exist, but they move one person's data into a shared app, and nobody on the trip can tell whether two people's stories agree.&lt;/p&gt;

&lt;p&gt;Two honest caveats. The one number we found, that &lt;a href="https://mustsharenews.com/singaporeans-fight-group-trip-expenses/" rel="noopener noreferrer"&gt;45% of those who front money spend over a day settling up&lt;/a&gt;, comes from a survey by a company selling a split feature. And we did no user research. We built this because the mechanism interested us, and demand is unproven.&lt;/p&gt;

&lt;h2&gt;
  
  
  2. Why one shared chatbot can't do this
&lt;/h2&gt;

&lt;p&gt;The obvious design is one assistant that reads everyone's statements and works out who owes what. But then one system holds everybody's bank data, and a persuasive prompt is all that stands between Priya's secret birthday gift for Arjun and Arjun.&lt;/p&gt;

&lt;p&gt;We wanted three different things, with three different kinds of trust:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;A personal agent per person.&lt;/strong&gt; It reads one statement and works for one person. It asks "Zomato, 11 PM, ₹850. Was this shared?" because only Priya knows that Meera had gone to bed.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A clerk that isn't an LLM.&lt;/strong&gt; Money arithmetic and "who approved this" shouldn't be probabilistic. It's under 400 lines of ordinary Python with integer paise.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;A group room&lt;/strong&gt; that holds only what people have approved.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;What makes this work is that the walls between them aren't instructions. They're enforced by a server. That's what Switch gives you, so that's the first thing we tested.&lt;/p&gt;

&lt;h2&gt;
  
  
  3. Running Switch with more than one owner
&lt;/h2&gt;

&lt;p&gt;The first thing to know: a local server run by Switch Console has one owner, and all its agents belong to you. That's fine for most projects. For separately owned agents, run from source. &lt;a href="https://github.com/martinvibes/airlock" rel="noopener noreferrer"&gt;Airlock&lt;/a&gt; reported this route working on a Mac, and we followed it.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;git clone https://github.com/sandbox-quantum/switch.git switch-src
&lt;span class="nb"&gt;cd &lt;/span&gt;switch-src
git checkout fa933012b   &lt;span class="c"&gt;# the commit we tested; main moves several times a day&lt;/span&gt;
just init-env
just up        &lt;span class="c"&gt;# Postgres and Mattermost in Docker&lt;/span&gt;
just migrate   &lt;span class="c"&gt;# easy to forget; `just run` won't do this for you&lt;/span&gt;
just run       &lt;span class="c"&gt;# switch-core on :8000&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Wait until &lt;code&gt;just up&lt;/code&gt;'s setup container finishes. It registers Mattermost as your default bridge, and a room created before that won't have a channel.&lt;/p&gt;

&lt;p&gt;Create real owners through the Gateway, logged in as the admin that &lt;code&gt;just init-env&lt;/code&gt; printed. &lt;strong&gt;Use &lt;code&gt;role: user&lt;/code&gt;, not admin.&lt;/strong&gt; An admin bypasses the checks you're trying to test, and most things will appear to work.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/gateway/users&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;email&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;email&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;password&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;pw&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;role&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;user&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;
&lt;span class="c1"&gt;# Then log in as that user, and mint their own registration key:
&lt;/span&gt;&lt;span class="n"&gt;owner&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/gateway/api-keys&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;label&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;kitty&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt;  &lt;span class="c1"&gt;# returns {"key": ...}
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Each owner registers their agents with their own key, so the agent inherits that owner's permissions:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;httpx&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;post&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;BASE&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/agents&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;reg_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;},&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;name&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meera-agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;description&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Meera&lt;/span&gt;&lt;span class="sh"&gt;'&lt;/span&gt;&lt;span class="s"&gt;s private agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connector_type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;external&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
    &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;integration_profile&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;connection_model&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;always_on&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;message_exchange&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
        &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pre_invocation_mediation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;post_invocation_mediation&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[],&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event_reporting&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;[]},&lt;/span&gt;
&lt;span class="p"&gt;})&lt;/span&gt;  &lt;span class="c1"&gt;# returns {"id": ..., "api_key": ...}, and the key is shown only once
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  4. Make your rooms actually private
&lt;/h2&gt;

&lt;p&gt;This one matters if you take nothing else from this post: &lt;strong&gt;&lt;code&gt;create_room&lt;/code&gt; defaults to public visibility.&lt;/strong&gt; We pass private explicitly:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;agent&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;op&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;create_room&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;name&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meera-private&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;description&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;...&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;agent_names&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meera-agent&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;
         &lt;span class="n"&gt;user_names&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;meera&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt; &lt;span class="n"&gt;bridge_id&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="n"&gt;bridge&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;channel_type&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;channel_private&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;
         &lt;span class="n"&gt;read_visibility&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;private&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;write_visibility&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;private&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;)&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;&lt;code&gt;user_names&lt;/code&gt; takes the person's Mattermost username, not their Switch name.&lt;/p&gt;

&lt;p&gt;Then test the boundary, because a room appearing in a UI doesn't prove anything. We wrote &lt;code&gt;scripts/gate1_spike.py&lt;/code&gt; to try every door we could find:&lt;/p&gt;

&lt;div class="table-wrapper-paragraph"&gt;&lt;table&gt;
&lt;thead&gt;
&lt;tr&gt;
&lt;th&gt;Attempt, as Meera's agent&lt;/th&gt;
&lt;th&gt;Server's answer&lt;/th&gt;
&lt;/tr&gt;
&lt;/thead&gt;
&lt;tbody&gt;
&lt;tr&gt;
&lt;td&gt;Read the group room&lt;/td&gt;
&lt;td&gt;403 &lt;code&gt;Agent is not a member of this room&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Post to Arjun's private room&lt;/td&gt;
&lt;td&gt;403&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Invite herself into the group room&lt;/td&gt;
&lt;td&gt;403 &lt;code&gt;Not authorized to change this room's membership&lt;/code&gt;
&lt;/td&gt;
&lt;/tr&gt;
&lt;tr&gt;
&lt;td&gt;Open a connection on the group room&lt;/td&gt;
&lt;td&gt;refused, status 403&lt;/td&gt;
&lt;/tr&gt;
&lt;/tbody&gt;
&lt;/table&gt;&lt;/div&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fipdaplf21drc84da0dwl.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fipdaplf21drc84da0dwl.png" alt="Meera's private room in Mattermost: just Meera and her own agent" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  5. Connecting a custom client
&lt;/h2&gt;

&lt;p&gt;There's no client library, but the protocol is small.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;The transport surprised us.&lt;/strong&gt; The protocol doc in the repo at our commit describes a server-sent event stream, and so does the hackathon page. Our first listener sent a plain GET and got a &lt;strong&gt;200&lt;/strong&gt;, on a room the agent wasn't in. We thought we'd found a leak. We hadn't: without &lt;code&gt;Accept: text/event-stream&lt;/code&gt;, that endpoint falls back to a long poll of the agent's own events. The live connection is a WebSocket:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;url&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ws://localhost:8000/agents/&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;agent_id&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;/connection/ws?connection_id=&lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;cid&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="s"&gt;&amp;amp;scope=all&amp;amp;filter=all&amp;amp;start_from=head&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;
&lt;span class="k"&gt;with&lt;/span&gt; &lt;span class="nf"&gt;connect&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;url&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;additional_headers&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Authorization&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sa"&gt;f&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;Bearer &lt;/span&gt;&lt;span class="si"&gt;{&lt;/span&gt;&lt;span class="n"&gt;api_key&lt;/span&gt;&lt;span class="si"&gt;}&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;})&lt;/span&gt; &lt;span class="k"&gt;as&lt;/span&gt; &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
    &lt;span class="k"&gt;while&lt;/span&gt; &lt;span class="bp"&gt;True&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
        &lt;span class="n"&gt;frame&lt;/span&gt; &lt;span class="o"&gt;=&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;loads&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;recv&lt;/span&gt;&lt;span class="p"&gt;())&lt;/span&gt;
        &lt;span class="k"&gt;if&lt;/span&gt; &lt;span class="n"&gt;frame&lt;/span&gt;&lt;span class="p"&gt;[&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;event&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;]&lt;/span&gt; &lt;span class="o"&gt;==&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;ping&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;
            &lt;span class="n"&gt;ws&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;send&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;dumps&lt;/span&gt;&lt;span class="p"&gt;({&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;type&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;pong&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;cursor&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="n"&gt;cursor&lt;/span&gt;&lt;span class="p"&gt;}))&lt;/span&gt;   &lt;span class="c1"&gt;# this is your heartbeat
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Keep the &lt;code&gt;connection_id&lt;/code&gt; across reconnects. Posting is one call, with no connection needed: &lt;code&gt;POST /agents/{id}/message&lt;/code&gt; with &lt;code&gt;{room_id, content}&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;Two more things that cost us time:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Agents don't get an echo of their own posts.&lt;/strong&gt; Check with &lt;code&gt;read_context&lt;/code&gt;.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Treat sender ids as opaque.&lt;/strong&gt; We asserted the word "user" appears in a person's id, and it doesn't: Mattermost people have ids like &lt;code&gt;@switch-mattermost-&amp;lt;bridge&amp;gt;-&amp;lt;name&amp;gt;:localhost&lt;/code&gt;. Learn the id by watching a real post from that person, then compare ids.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;To link a Mattermost account to its owner, post to &lt;code&gt;/gateway/collaborations/{bridge}/identities&lt;/code&gt;, then confirm with that account's own session token at &lt;code&gt;/identities/{id}/verify-session&lt;/code&gt;. Until then, Switch can't tell who that person is.&lt;/p&gt;

&lt;h2&gt;
  
  
  6. The bug that looked like flakiness
&lt;/h2&gt;

&lt;p&gt;Our third setup run failed: a person's message never arrived. It had worked twice, so a retry looked like the obvious fix. The server log told a different story, and its first error was:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight plaintext"&gt;&lt;code&gt;Unable to create the new team membership because the team has reached the limit of members
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;Mattermost allows 50 members per team by default, and every test run added eight accounts. The bridge carries on past that error, so bots then can't join channels, and messages just don't arrive. If you run a template repeatedly, raise it:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight python"&gt;&lt;code&gt;&lt;span class="n"&gt;mm_admin&lt;/span&gt;&lt;span class="p"&gt;.&lt;/span&gt;&lt;span class="nf"&gt;put&lt;/span&gt;&lt;span class="p"&gt;(&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;/api/v4/config/patch&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt; &lt;span class="n"&gt;json&lt;/span&gt;&lt;span class="o"&gt;=&lt;/span&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;TeamSettings&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="s"&gt;MaxUsersPerTeam&lt;/span&gt;&lt;span class="sh"&gt;"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt; &lt;span class="mi"&gt;2000&lt;/span&gt;&lt;span class="p"&gt;}})&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;h2&gt;
  
  
  7. Our design was wrong, and a test showed us
&lt;/h2&gt;

&lt;p&gt;The plan put the clerk in every private room, so it could hear claims. Then the spike confirmed what the docs say: &lt;strong&gt;every agent in a room can read all of it.&lt;/strong&gt; The clerk would have received every "Zomato, 11 PM" message, in a system whose whole promise is that it doesn't.&lt;/p&gt;

&lt;p&gt;We could have had the clerk ignore what it read. But that's a promise made in code, when a server boundary was available. So each person got two rooms:&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;code&gt;meera-private&lt;/code&gt;: Meera and her agent. &lt;strong&gt;The clerk gets a 403.&lt;/strong&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;code&gt;meera-desk&lt;/code&gt;: Meera, her agent and the clerk. Typed claims only.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;It's more channels, but "the clerk can't read it" is now something the server says, not something we say.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Steal this:&lt;/strong&gt; if an agent mustn't read something, don't tell it not to. Put it in a room it isn't a member of, and test the 403.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;h2&gt;
  
  
  8. Claims with nowhere to hide a merchant name
&lt;/h2&gt;

&lt;p&gt;A claim is a strict schema. Unknown fields are rejected, every string is a closed set or a narrow pattern, and rejections carry a fixed code so the clerk can never echo private text into a room.&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight json"&gt;&lt;code&gt;&lt;span class="p"&gt;{&lt;/span&gt;&lt;span class="nl"&gt;"v"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;1&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"type"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"expense"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"ref"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"p2"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"trip_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"goa-2026"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"owner_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"priya"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"payer_id"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"priya"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"date"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"2026-10-10"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"amount_paise"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;85000&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"currency"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"INR"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"category"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="s2"&gt;"food"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="w"&gt;
 &lt;/span&gt;&lt;span class="nl"&gt;"participant_ids"&lt;/span&gt;&lt;span class="p"&gt;:[&lt;/span&gt;&lt;span class="s2"&gt;"priya"&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="s2"&gt;"arjun"&lt;/span&gt;&lt;span class="p"&gt;],&lt;/span&gt;&lt;span class="nl"&gt;"shares"&lt;/span&gt;&lt;span class="p"&gt;:{&lt;/span&gt;&lt;span class="nl"&gt;"priya"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42500&lt;/span&gt;&lt;span class="p"&gt;,&lt;/span&gt;&lt;span class="nl"&gt;"arjun"&lt;/span&gt;&lt;span class="p"&gt;:&lt;/span&gt;&lt;span class="mi"&gt;42500&lt;/span&gt;&lt;span class="p"&gt;}}&lt;/span&gt;&lt;span class="w"&gt;
&lt;/span&gt;&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;A release counts only if the &lt;code&gt;sender&lt;/code&gt; that Switch set on the event is that person's own account, on their own desk. We attacked the live clerk: Meera's agent typing &lt;code&gt;release c9&lt;/code&gt; got "Refused: Meera's agent can't do that. Only Meera can." Arjun typing it, from two different rooms, got nowhere either.&lt;/p&gt;

&lt;blockquote&gt;
&lt;p&gt;&lt;strong&gt;Steal this:&lt;/strong&gt; never decide who approved something from the message text. Decide it from the sender id the server stamped on the event. An agent can type anything; it can't change who Switch says sent it.&lt;/p&gt;
&lt;/blockquote&gt;

&lt;p&gt;The model's position is the most restrictive of all. It runs with no tools and no MCP servers, from an empty directory:&lt;br&gt;
&lt;/p&gt;

&lt;div class="highlight js-code-highlight"&gt;
&lt;pre class="highlight shell"&gt;&lt;code&gt;claude &lt;span class="nt"&gt;-p&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$PROMPT&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--model&lt;/span&gt; claude-haiku-5-5 &lt;span class="nt"&gt;--system-prompt&lt;/span&gt; &lt;span class="s2"&gt;"&lt;/span&gt;&lt;span class="nv"&gt;$SYSTEM&lt;/span&gt;&lt;span class="s2"&gt;"&lt;/span&gt; &lt;span class="nt"&gt;--tools&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="se"&gt;\&lt;/span&gt;
  &lt;span class="nt"&gt;--strict-mcp-config&lt;/span&gt; &lt;span class="nt"&gt;--setting-sources&lt;/span&gt; &lt;span class="s2"&gt;""&lt;/span&gt; &lt;span class="nt"&gt;--no-session-persistence&lt;/span&gt;
&lt;/code&gt;&lt;/pre&gt;

&lt;/div&gt;



&lt;p&gt;It can't post, read a file or reach Switch. Plain code holds the key, checks the model's output against the real statement lines, and builds every message itself. Even when we made a test model obey a prompt injection and return a fake line with &lt;code&gt;&amp;lt;@all&amp;gt; SECRET MERCHANT&lt;/code&gt; as its title, nothing reached the desk.&lt;/p&gt;

&lt;h2&gt;
  
  
  9. The ₹1,500
&lt;/h2&gt;

&lt;p&gt;Here's the flow, each step in a different place:&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;Meera's agent sees a debit to Arjun and files a transfer claim on her desk. She releases it.&lt;/li&gt;
&lt;li&gt;The group sees: "Meera says they paid Arjun ₹1,500. Not counted until Arjun confirms."&lt;/li&gt;
&lt;li&gt;The clerk asks Arjun's agent, on Arjun's desk. Arjun's agent checks his statement in plain code and finds nothing.&lt;/li&gt;
&lt;li&gt;That "no" is a fact about Arjun's statement, so &lt;strong&gt;Arjun releases it himself&lt;/strong&gt;. We kept this extra step because it's the principle.&lt;/li&gt;
&lt;li&gt;The group sees: "NOT CONFIRMED."&lt;/li&gt;
&lt;li&gt;The clerk asks Meera's agent to re-check. It finds a credit with the same amount and reference, and tells her privately.&lt;/li&gt;
&lt;li&gt;She withdraws it. The group sees: "The payment failed and never arrived."&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;The payment was never in the ledger, so nothing needs undoing. A transfer changes a balance only once both people have released it. Being balanced on paper and being actually paid are different numbers, and we keep them apart.&lt;/p&gt;

&lt;p&gt;Finding the reversal is deterministic. A model never sees payments between friends.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4xkwlegshbth4vqkinu2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2F4xkwlegshbth4vqkinu2.png" alt="Meera's agent, in her private room, finding that the payment came back" width="420" height="860"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  10. What our privacy tests prove, and what they don't
&lt;/h2&gt;

&lt;p&gt;Twice we wrote a test that passed for the wrong reason. Both are worth stealing the lesson from.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A model "resisted" an attack because it never ran.&lt;/strong&gt; We told a model with no tools to read a friend's file. It made no tool calls and leaked nothing. All green. Then the positive control, the same test with &lt;code&gt;Read&lt;/code&gt; enabled, should have found the secret, and it didn't. The model had replied "Not logged in". The script now fails unless the model really ran.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;A verifier found no leaks because it read no messages.&lt;/strong&gt; Ours parsed &lt;code&gt;messages&lt;/code&gt;, where the API returns &lt;code&gt;root&lt;/code&gt; and &lt;code&gt;replies&lt;/code&gt;. It scanned nothing and reported no leaks. Now it fails if there's nothing to scan. To prove it can fail, we planted &lt;code&gt;TANISHQ GOA JEWELLERS&lt;/code&gt; in the group room and required it to fail, and it did.&lt;/p&gt;

&lt;p&gt;So: every negative test needs a positive control.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's proven, live, against Switch:&lt;/strong&gt; agents and the clerk are refused rooms they aren't in; only an owner's account can release; 87 merchant words, references and account numbers from the statements never appear in the group room or on the desks; balances recomputed from the room's own text net to zero.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What isn't:&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Statements still reach the model provider, after plain code strips balances, account numbers, references, out-of-trip lines and payments between friends. Merchant names go.&lt;/li&gt;
&lt;li&gt;Whoever hosts Switch can read private rooms. This is fine among friends, and not for parties who don't trust each other.&lt;/li&gt;
&lt;li&gt;It runs on one laptop, under one OS user. Switch's boundaries are real, but the separation between folders here isn't a security boundary.&lt;/li&gt;
&lt;li&gt;There's no end-to-end encryption, and we don't claim any.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;With the model really running,&lt;/strong&gt; the attack test passes 8 of 8: zero tools, zero tool calls, secret not leaked. The positive control is the striking part. Given only &lt;code&gt;Read&lt;/code&gt;, run from Meera's folder, the same model read the file in Arjun's folder. A working directory and a tool list wouldn't have protected her. Having no tools does.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;How good is the model at this?&lt;/strong&gt; Across 3 runs of 16 lines, it made 48 judgments. The first scoring gave 35 correct decisions, 13 where it asked the person, and none wrong. A later scoring gave 39, 9 and none. It asks about different lines on different runs, so our scripted test people had to answer whatever came up. That's a small sample on data we wrote and labelled ourselves, so it says the demo works and not much more.&lt;/p&gt;

&lt;h2&gt;
  
  
  11. From a script to something six people can use
&lt;/h2&gt;

&lt;p&gt;The first version needed a command to create a trip, and worked with three people. Making it a product found more than we expected.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Provisioning has to survive failing halfway.&lt;/strong&gt; Each person is a Switch owner, a Mattermost account linked by that account's own session, a registered agent, two rooms, and sender ids learned by watching real posts. Any of those can fail, and Switch can't delete a room (issue #1202), so rollback isn't available. What worked: write each password down before the call that uses it, save after every step, and on retry skip what's done. One person's failure doesn't stop the others, and the group room waits. If you build on Switch, plan for resuming rather than undoing.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Don't show a server's error text to a user.&lt;/strong&gt; It could hold anything. Our status file has fixed codes and sentences only, and a test plants a password in a failure and checks that it never comes out.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Validate a statement without quoting it.&lt;/strong&gt; A CSV error normally says what was wrong with what. Ours says "Line 3, txn_time: use a 24-hour time like 20:15", and never repeats a value, so errors can be logged and shown safely.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Show people exactly what the model provider gets.&lt;/strong&gt; Before anything is sent, a person can open the exact lines. It calls the same function the agent uses, and a test compares the two against what a recording model really receives. It also shows something uncomfortable: a personal pharmacy purchase in the trip's dates is in there, because that's how the model tells it apart.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;Six people found things three didn't.&lt;/strong&gt;&lt;/p&gt;

&lt;ul&gt;
&lt;li&gt;Two people's statements showed the same dinner. The clerk held both until one dropped theirs, and our verifier had no idea how to read that. It failed 58 of 60 with the board correct, and it was right to complain. It was missing a rule, so we taught it the rule and didn't relax the check.&lt;/li&gt;
&lt;li&gt;The model filed a rafting trip for all six. It can't know Dev skipped it. The owner reads the chit, drops it, and says so, which is the design. A wrong "shared" could already be fixed that way, but a wrong "personal" had no way back, so we added one.&lt;/li&gt;
&lt;li&gt;One message in about 700 reached Mattermost and never reached Switch, and we still don't know why. A theory we liked died when we measured it: 648 more messages, none lost. So the app now shows "Sending", and says when it's safe to press again, rather than pretending this can't happen.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;&lt;strong&gt;The results,&lt;/strong&gt; earlier in the build: three real-model runs in a row with six people, each with verify at 61 of 61 and balances matching numbers we worked out by hand first. A run with every process stopped mid-trip and a release sent while they were down also finished clean. The original three-person story still passes 37 of 37. It's a small test, on statements we wrote ourselves.&lt;/p&gt;

&lt;h2&gt;
  
  
  12. The Pot: when one person holds the money
&lt;/h2&gt;

&lt;p&gt;Splitting expenses is only half of how Indian groups handle money. Just as often, one person collects: everyone sends ₹5,000 over UPI to the organiser, and the organiser pays for the stay. The pain there isn't arithmetic. It's the stream of payment screenshots, and the organiser scrolling their own UPI history to check each one.&lt;/p&gt;

&lt;p&gt;So Kitty has a second mode, built on the same engine: a trip with a &lt;strong&gt;treasurer&lt;/strong&gt;. Its rule fits in one line: &lt;strong&gt;a screenshot is a claim, and the recipient's confirmation is what changes the ledger.&lt;/strong&gt;&lt;/p&gt;

&lt;ol&gt;
&lt;li&gt;A member files "I paid ₹5,000 on 28 Sep" on their desk. The group sees a pending claim. Nothing is counted.&lt;/li&gt;
&lt;li&gt;The treasurer's own agent checks the treasurer's private (synthetic, in our runs) incoming transactions, in plain code with no model, and labels a line a &lt;strong&gt;possible match&lt;/strong&gt;. That's a candidate, not a confirmation. Kitty has no bank or UPI API, and doesn't pretend to verify anything.&lt;/li&gt;
&lt;li&gt;The treasurer taps "Confirm it arrived" or "It didn't arrive". Switch stamps that message as sent by the treasurer's own account.&lt;/li&gt;
&lt;li&gt;Only then does the clerk count it, and the board moves.&lt;/li&gt;
&lt;/ol&gt;

&lt;p&gt;A confirmation with no matching line behind it still counts, but everyone sees it marked as being on the treasurer's word.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq970nyhlyp62k26gl8e2.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fq970nyhlyp62k26gl8e2.png" alt="The treasurer's desk: each payment claim, the agent's possible match, and &amp;quot;Confirm it arrived&amp;quot;" width="800" height="500"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;p&gt;The claim doesn't move the ledger. The agent's match doesn't move it. The treasurer's confirmation does. We attacked this live: confirming as a member, and as each agent, was refused every time. We also stopped every process mid-collection and sent a confirmation while they were down. The six-person pot finished clean, with &lt;code&gt;verify&lt;/code&gt; at 68 of 68 on the current code.&lt;/p&gt;

&lt;p&gt;&lt;a href="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw4csw2ka1r4jvdq8ygqb.png" class="article-body-image-wrapper"&gt;&lt;img src="https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fw4csw2ka1r4jvdq8ygqb.png" alt="The pot board once every payment is confirmed: everyone's square" width="800" height="611"&gt;&lt;/a&gt;&lt;/p&gt;

&lt;h2&gt;
  
  
  13. When Switch dropped the line
&lt;/h2&gt;

&lt;p&gt;The last failure is worth telling because we didn't fully solve it. Setting up a six-person trip means creating a lot of rooms, and twice, the sixth person's room failed with a 500 from Switch's &lt;code&gt;create_room&lt;/code&gt;. Retrying blindly would have hidden it, so we read Switch's own log. Each time, every Switch-to-Mattermost bot connection (about 255 websockets) had dropped at once, for 10 to 15 seconds. A room created inside that window fails.&lt;/p&gt;

&lt;p&gt;We didn't find what triggers the drop. So we fixed what we could: Kitty now reports this as Switch's error rather than its own, and retries that one step once, after 25 seconds. A fresh six-person set-up then went live in 76 seconds, with no retry needed, and &lt;code&gt;verify&lt;/code&gt; passed 61 of 61. We call it mitigated, not fixed: the retry has unit tests but hasn't yet run during a real outage.&lt;/p&gt;

&lt;h2&gt;
  
  
  14. Giving it a face
&lt;/h2&gt;

&lt;p&gt;Late on, Kitty worked and looked like a prototype. So it got a cat: a kitty is a pot of money, after all. She's a ginger street cat with a nick in one ear and a rupee coin on her collar, drawn as one SVG. Her moods are all CSS on those same shapes, so she can't drift off-model.&lt;/p&gt;

&lt;p&gt;The interesting part was deciding what she isn't allowed to do. In an app about who can see what, and who has said money arrived, a chatty assistant would be the wrong thing. So she has no network access. She reads what the page already has, explains it, and points at things. A test fails if her code ever contains a request or a command. On the treasurer's desk she'll tell you why a payment is unmatched, and she can't confirm it.&lt;/p&gt;

&lt;p&gt;She also only reacts to things that happen. A board that was already square when you opened it gets a calm cat with a stamped chit. Petals fall only when you watch the last payment land. And what her magnifying glass finds is labelled "possible match", with a dashed outline and "not counted yet" beside it, because the difference between a candidate and a confirmed payment is the whole product.&lt;/p&gt;

&lt;p&gt;Two smaller lessons. Self-hosting the fonts removed a request to a font service from every private page. And our automated checks were clean on several things that looked wrong the moment we read real screenshots: a green ₹0.00, and a count that could include the same payment twice.&lt;/p&gt;

&lt;p&gt;Everything under the interface stayed as it was. All 632 earlier tests pass unmodified, and so do the end-to-end runs. After a final code review and the set-up fix, the suite stands at 754 tests.&lt;/p&gt;

&lt;h2&gt;
  
  
  15. What we learned
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Test the boundary before building on it.&lt;/strong&gt; The test changed our architecture on day one.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Put limits in the server, then in code, and last in prompts.&lt;/strong&gt; We ended up with no prompt-based security at all.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Let the model propose and let code decide.&lt;/strong&gt; The model is good at "&lt;code&gt;ZOMATO*ORD 8812&lt;/code&gt; might be shared, better ask". It has no business doing arithmetic or holding a key.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Asking a person is a feature.&lt;/strong&gt; "Yes, but Meera had gone to bed" is the best line in the demo, and no statement contains it.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Read the server log first.&lt;/strong&gt; A retry would have papered over a 50-member limit, and later over Switch dropping its Mattermost connections.&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Say "candidate", not "verified".&lt;/strong&gt; The difference between a possible match and a confirmed payment is the whole product.&lt;/li&gt;
&lt;/ul&gt;

&lt;h2&gt;
  
  
  16. TL;DR
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;Give every person their own agent, and let Switch's room membership, not your prompts, decide what each one can read.&lt;/li&gt;
&lt;li&gt;Let the model suggest and let plain code decide. Our clerk has no LLM, and our agents' model has no tools.&lt;/li&gt;
&lt;li&gt;Count money only on a human decision the server can attribute: an owner's release, or a recipient's "it arrived".&lt;/li&gt;
&lt;li&gt;Write tests that can fail, and prove they can.&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;If you take one thing from this build: on a shared-chat platform, privacy between agents is a membership problem before it's a prompt problem, and Switch lets you solve it at the membership level.&lt;/p&gt;

&lt;h2&gt;
  
  
  17. Try it
&lt;/h2&gt;

&lt;ul&gt;
&lt;li&gt;
&lt;strong&gt;Live demo video (2:58):&lt;/strong&gt; &lt;a href="https://youtu.be/YmwLhxHsZ6c" rel="noopener noreferrer"&gt;https://youtu.be/YmwLhxHsZ6c&lt;/a&gt;
&lt;/li&gt;
&lt;li&gt;
&lt;strong&gt;Code:&lt;/strong&gt; &lt;a href="https://github.com/Sahvendra7/Kitty" rel="noopener noreferrer"&gt;https://github.com/Sahvendra7/Kitty&lt;/a&gt; (branch &lt;code&gt;feat/kitty-mvp&lt;/code&gt;; Switch from source at commit &lt;code&gt;fa933012b&lt;/code&gt;)&lt;/li&gt;
&lt;/ul&gt;

&lt;p&gt;The no-model tests run in seconds with &lt;code&gt;uv sync &amp;amp;&amp;amp; uv run pytest&lt;/code&gt; (754 of them). The full setup is in the README. Once Switch is running, &lt;code&gt;uv run python -m kitty.home_app&lt;/code&gt; opens the app at &lt;a href="http://127.0.0.1:7000" rel="noopener noreferrer"&gt;http://127.0.0.1:7000&lt;/a&gt;, and &lt;code&gt;scripts/rehearse.sh 1&lt;/code&gt; plays the scripted three-person story and runs &lt;code&gt;verify&lt;/code&gt;.&lt;/p&gt;

&lt;p&gt;&lt;strong&gt;What's missing, plainly:&lt;/strong&gt; real bank connections, converting between currencies, real accounts instead of invite links, an organiser login, adding someone after the trip opens, changing a split after release, running on separate machines, and any evidence that people want this. A real version could read statements on each person's own device, so the host never holds them, and pre-check reversals before anyone has to claim anything.&lt;/p&gt;

&lt;p&gt;Thanks to the Switch team for a server whose boundaries held up when we attacked them, and to the author of Airlock for proving the path.&lt;/p&gt;

&lt;p&gt;What would you want your own agent to settle for you, and what would you never let it tell the group?&lt;/p&gt;

</description>
      <category>ai</category>
      <category>agents</category>
      <category>hackathon</category>
      <category>python</category>
    </item>
  </channel>
</rss>
