{"slug": "switch-from-zero-ship-a-human-in-the-loop-data-quality-agent-to-your-team-chat", "title": "Switch from zero: ship a human-in-the-loop data quality agent to your team chat", "summary": "A developer built Shield, a human-in-the-loop data quality agent, during the Flip the Switch hackathon on Switch 0.31.0, running it in a GitHub Codespace with fictional data. The agent checks a daily table, reports findings in a team chat channel, and only applies fixes after a person approves. The writeup documents self-hosting Switch via Docker Compose, registering a custom always-on agent through POST /agents, and connecting over a Server-Sent Events stream keyed by a client-generated connection id with a 6-second heartbeat TTL.", "body_md": "I spent the Flip the Switch hackathon building **Shield**, an agent that checks a daily table, explains what it finds in a team channel and only fixes data after a person approves. This post is the guide I wish I had on day one: how to self-host Switch with no desktop app, how a custom agent joins a room, and the three things that will block you the first time.\n\nEverything here was run on Switch 0.31.0 in a GitHub Codespace. All the data is fictional.\n\n**Shield at a glance**\n\n**Watch it work:** [3-minute demo](https://github.com/jenniferevangelista-de/shield-for-data/releases/download/v0.1.0/shield_demo.mp4) · [77-second explainer](https://github.com/jenniferevangelista-de/shield-for-data/releases/download/v0.1.0/shield_explainer.mp4)\n\n[Switch](https://github.com/sandbox-quantum/switch) turns a chat channel (Slack, Teams, Discord, Telegram or Mattermost) into a **room** where people and AI agents work together. Switch ships no agents and no models. It gives you the room, the membership, the shared documents and a wire protocol, and your agent brings the brains. That split is why it fits a \"human in the loop\" workflow so well: the approval happens where the team already talks.\n\nThere are two ways in:\n\nYou need Docker with Compose v2. The server, its database, the Gateway (admin surface) and a bundled Mattermost all come from one compose file in the Switch repo.\n\n```\ngit clone https://github.com/sandbox-quantum/switch.git && cd switch\ncp .env.example .env\nbash deploy/local/generate-env-secrets.sh .env local   # fills every password and token\nsed -i 's/^SWITCH_VERSION=.*/SWITCH_VERSION=0.31.0/' .env\ndocker compose -f deploy/local/standalone-docker-compose.yml --project-directory . \\\n  --profile collab --profile gateway up -d\n```\n\nThree things worth knowing:\n\n`SWITCH_VERSION`.` collab` profile is Mattermost; `gateway` is the admin surface.`setup` container creates a Mattermost admin, a normal user called `user`, a team called `switch`, and links Mattermost as the default bridge. When `docker wait` on that container returns 0, you are ready.\nAn agent is registered once, out of band, with the server's `AGENT_REGISTRATION_TOKEN` from `.env`. For a process that is always running (rather than a session Console starts on demand), use the lower-level `POST /agents` with an `always_on` connection model:\n\n``` python\nimport requests\n\nr = requests.post(f\"{SWITCH}/agents\", headers={\"Authorization\": f\"Bearer {REGISTRATION_TOKEN}\"}, json={\n    \"name\": \"shield\",                      # what people type after @\n    \"description\": \"Data quality teammate\",\n    \"connector_type\": \"custom-python\",\n    \"integration_profile\": {\n        \"connection_model\": \"always_on\",\n        \"message_exchange\": True,\n        \"pre_invocation_mediation\": [], \"post_invocation_mediation\": [], \"event_reporting\": [],\n        \"command_capabilities\": {\"reset\": \"unsupported\", \"compact\": \"unsupported\", \"interrupt\": \"unsupported\"},\n    },\n    \"tools\": [], \"models\": [], \"metadata\": {}, \"overwrite\": True,\n})\nagent_id, api_key = r.json()[\"id\"], r.json()[\"api_key\"]   # the key is shown once: store it\n```\n\nThe convenience route `POST /agents/register-known` only knows Claude Code, Codex, OpenCode and Antigravity, so a custom agent goes through `POST /agents`.\n\nDown is one Server-Sent Events stream, up is plain HTTP. The part that trips people up is that **the connection is identified by an id you generate**, not by the socket:\n\n```\nconn = str(uuid.uuid4())   # generate once, reuse on every reconnect\nstream = session.get(f\"{SWITCH}/agents/{agent_id}/events\",\n                     params={\"connection_id\": conn, \"scope\": \"single\", \"filter\": \"addressed\",\n                             \"rooms\": room_id},\n                     headers={\"Accept\": \"text/event-stream\"}, stream=True)\n```\n\n`connection_state`. Read the `rooms` and `cursor` from it rather than assuming.`POST /agents/{id}/connection/beat` and `{connection_id, cursor}`. The TTL is 6 seconds. A 404 or 409 on the beat means \"reopen the stream\", not \"back off\".`filter=addressed` means you only receive messages that mention your agent. That is what you want for a chat agent.`rooms=`). A room subscribed after the stream opens can miss its buffered events.\nShield's whole client is about 150 lines: [`shield/switch_client.py`](https://github.com/jenniferevangelista-de/shield-for-data/blob/main/shield/switch_client.py).\n\nOperations are `POST /agents/{id}/ops/{name}` with the arguments as the body, plus an `X-Switch-Connection-Id` header when the operation needs a connection. `create_room` on the default bridge creates the Mattermost channel for you:\n\n```\nroom = op(\"create_room\", {\n    \"name\": \"data-quality\",\n    \"description\": \"Data quality incidents for sales.orders\",\n    \"agent_names\": [\"shield\"],          # include yourself, or you are not a member\n    \"user_names\": [\"user\"],             # Mattermost usernames\n    \"channel_type\": \"channel_public\",\n    \"instructions\": \"Shield never changes data without an approval in this room.\",\n}, connection_id=conn)\n```\n\nTo answer in the room, `POST /agents/{id}/message` with `{room_id, content}` needs no connection at all. Markdown renders in Mattermost.\n\n**1. \"You're not permitted to direct messages to me in this room.\"** A new agent answers only its owner, and an agent registered with the registration token is owned by a bootstrap account, not by you. Nobody can talk to it. The Gateway admin (credentials in `.env`) can clear the policy:\n\n```\ns = requests.Session()\ns.post(f\"{SWITCH}/gateway/auth/login\", json={\"email\": ADMIN_EMAIL, \"password\": ADMIN_PASSWORD})\ns.put(f\"{SWITCH}/gateway/agents/{agent_id}/addressing-policy\", json={\"policy\": None})\n```\n\nWith no policy, room membership decides who can reach the agent. In production, set a policy that names the people allowed to approve.\n\n**2. \"I'm currently offline: my connector isn't reporting in.\"** Switch answers on your agent's behalf until it sees heartbeats. If your test posts a message in the first seconds after the agent starts, the agent never sees it. My end-to-end test simply asks `@shield help` until the real answer comes back.\n\n**3. Nothing happens and there is no error.** Messages that do not `@mention` the agent are delivered to nobody when you use `filter=addressed`. Check the Switch logs (` docker logs <project>-switch-1`): lines from `switch_core.delivery.addressing` tell you exactly why a message was or was not delivered.\n\nThis is the part that makes it a workflow rather than a bot. Shield follows one rule: **a scan only records findings; only an approval changes data.**\n\nEach finding gets an id and three possible answers, all plain messages in the room:\n\n`@shield approve F-1` runs the proposed actions, checks again, and posts the result.`@shield edit F-1 hold_dashboard+notify_owners` changes the plan before approving.`@shield reject F-1 known test load` closes it with a reason and touches nothing.\nSwitch passes the sender's name with every message, so the audit trail records who decided. Shield writes that trail to a **room document** (`create_room_document`, then `update_room_document`), which every member of the room can read later.\n\nThe main check is the business-date gate from my article [Your pipeline ran green and shipped last week's data anyway](https://dev.to/jenniferevangelistade/your-pipeline-ran-green-and-shipped-last-weeks-data-anyway-1il3): the greatest attempt per job and date wins, and a date is ready only when every required job's newest attempt succeeded for that date. The other five checks (schema, row count, nulls, duplicates, late files) are a Great Expectations suite, and owners come from a catalog adapter shaped like OpenMetadata's API.\n\nShield's language model is a small open model, Gemma 3 4B, served on the same machine by [Ollama](https://ollama.com) through its OpenAI-compatible API, so it costs nothing and no data leaves the host. It has two jobs:\n\n`@shield are today's numbers ok to publish?` runs a scan. `@shield sure, go ahead with F-1` does That boundary is the design choice I would repeat in any agent that touches production data: let the model read and explain, and keep every write behind an exact, human command.\n\nMattermost has a plain REST API, so an end-to-end test can log in as the demo user, post `@shield break stale_business_date`, `@shield scan`, `@shield approve F-1`, and wait for each reply. In my repo, that runs on a fresh GitHub Actions runner on every push: start Switch, register Shield, play every scenario. If the test passes, the README is telling the truth.\n\nThe code, the setup script and the end-to-end test are here: [github.com/jenniferevangelista-de/shield-for-data](https://github.com/jenniferevangelista-de/shield-for-data).", "url": "https://wpnews.pro/news/switch-from-zero-ship-a-human-in-the-loop-data-quality-agent-to-your-team-chat", "canonical_source": "https://dev.to/jenniferevangelistade/switch-from-zero-ship-a-human-in-the-loop-data-quality-agent-to-your-team-chat-4cm9", "published_at": "2026-11-03 22:40:24+00:00", "updated_at": "2026-11-03 22:52:27.163459+00:00", "lang": "en", "topics": ["ai-agents", "ai-tools", "developer-tools"], "entities": ["Switch", "Shield", "Mattermost", "GitHub Codespaces", "Docker", "Slack", "Teams"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/switch-from-zero-ship-a-human-in-the-loop-data-quality-agent-to-your-team-chat", "markdown": "https://wpnews.pro/news/switch-from-zero-ship-a-human-in-the-loop-data-quality-agent-to-your-team-chat.md", "text": "https://wpnews.pro/news/switch-from-zero-ship-a-human-in-the-loop-data-quality-agent-to-your-team-chat.txt", "jsonld": "https://wpnews.pro/news/switch-from-zero-ship-a-human-in-the-loop-data-quality-agent-to-your-team-chat.jsonld"}}