Two agents, one Slack channel, zero in-process coupling. Here's how we built TaskFlow on Switch AI Core, plus the bugs that humbled us along the way.
1. The Problem: Where Good Tasks Go to Die
If you're on an engineering team, your chat app is probably the busiest and messiest place you work. Slack, Mattermost, Discord, take your pick. Important stuff gets dropped into threads all day long:
- "Can someone draft the release notes for v2.0 with the new auth changes?"
- "@anuj please make sure the staging DB migration runs before 5 PM tomorrow."
- "The caching cluster went down. Who's writing the postmortem?"
Almost every one of these ends up in one of two places.
- The scrollback graveyard. A real human task gets buried under forty newer messages and resurfaces only after the deadline has quietly passed.
- The context-switching tax. Writing release notes, doc skeletons or a postmortem is necessary but repetitive, and it yanks an engineer out of whatever they were deep into.
We wanted to fix both at once. But instead of building yet another bot that runs everything inside one big Python script, we built TaskFlow on Switch: a distributed multi-agent workroom running on Switch AI Core.
2. What We Built: Two Agents, Clear Jobs
The rule we set early: each agent does one thing, and no agent can crash another.
So instead of one monolith, TaskFlow puts two independent Switch agents in the room:
sequenceDiagram
autonumber
actor Team as Engineering Team (Slack #ss)
participant Switch as Switch Core (Gateway + WS)
participant Triage as Switch Agent: taskflow-triage
participant Worker as Switch Agent: taskflow-worker
participant Todoist as Todoist REST API v1
Switch->>Triage: WS Frame: ping
Triage-->>Switch: WS Frame: pong
Switch->>Worker: WS Frame: ping
Worker-->>Switch: WS Frame: pong
Team->>Switch: Post message ("@TaskFlow draft release notes for v2.0...")
Switch->>Switch: Match alias 'U0CFL6PEQHL' -> taskflow-triage
Switch->>Triage: WS Frame: message (addressed=True)
Note over Triage: Ambient Classification (Sliding Buffer)
Triage->>Triage: Classify: AUTO_EXECUTE vs HUMAN_TODO
Note over Triage,Switch: NATIVE SWITCH TARGETED HANDOFF
Triage->>Switch: POST /ops/send_targeted_message (targets=["taskflow-worker"])
Switch->>Worker: WS Frame: message (sender="taskflow-triage", addressed=True)
alt AUTO_EXECUTE (Knowledge Deliverable)
Note over Worker: Autonomous Drafting & DLP Policy Gate
Worker->>Worker: execute_task() -> validate_auto_post_safety()
Worker->>Switch: POST /ops/post_message (Markdown Deliverable)
Switch->>Team: Broadcast Formatted Release Notes to Slack
else HUMAN_TODO (Human Task)
Note over Worker: Action Scaffolding & Todoist Sync
Worker->>Worker: Scaffold 3-step checklist + parse due date
Worker->>Todoist: POST /tasks (Checklist + Due Date)
Todoist-->>Worker: Task URL (https://todoist.com/app/task/...)
Worker->>Switch: POST /ops/send_targeted_message (Receipt)
Switch->>Team: Interactive Task Receipt with Todoist Deep Link
end
Agent 1: taskflow-triage (the Ear)
Triage listens. It's a high-recall intent classifier that sits on its own dedicated Switch WebSocket stream, keeps a sliding buffer of the conversation, and decides what each message actually needs:
-
AUTO_EXECUTE: self-contained knowledge work like release notes, docs, postmortems and test plans. -
HUMAN_TODO: anything that needs credentials, a production deploy, a human review, or a real commitment from a real person.
The important design choice is that triage never writes artifacts itself. Once it has classified a message, it hands off using a native Switch targeted room operation:
client.send_targeted_message(
body=handoff_payload,
target_names=["taskflow-worker"],
room_id=room_id
)
Agent 2: taskflow-worker (the Hands)
The worker does the actual work. It runs on its own independent WebSocket connection, and when a targeted frame arrives from triage, it branches:
-
AUTO_EXECUTE: It pulls local git/project context and drafts a complete Markdown deliverable. The draft then goes through our Pre-Publication Data Loss Prevention (DLP) Safety Gate, and only after that does it get broadcast to the room. -
HUMAN_TODO: It scaffolds a three-step checklist, pulls any natural-language deadline out of the message ("before 5 PM tomorrow"), syncs the task to the Todoist REST API v1, and posts an interactive receipt with a deep link back to the task.
3. How We Set It Up
Step 1: Know your Switch topology
Switch Core runs a small ecosystem on your local machine:
- Port 52421: Collaboration Gateway and callback handler
- Port 52422: Switch Core REST API and WebSocket server
- Port 52423: Bundled Mattermost engine
- Port 52424: Embedded PostgreSQL with Row-Level Security (RLS)
Our agents connect as Protocol Agents over WebSockets. Each one authenticates with its own token and talks standard JSON-RPC operations (list_rooms, get_room_detail, send_targeted_message, post_message).
Step 2: Describe the room declaratively
We wanted room setup to be one click and fully reproducible, so we wrote the whole thing as a Switch V2 template (triage-pair.template.yaml):
version: 2
name: taskflow-triage-pair
description: Autonomous Task Triage & Workroom Worker pair
agents:
- name: taskflow-triage
role: triage-classifier
addressing: anyone
- name: taskflow-worker
role: task-executor
addressing: anyone
rooms:
- name: taskflow-operations
channel_type: channel_public
instructions: |
Room briefing loaded dynamically from ROOM_BRIEFING.md...
It passes Switch Core's built-in lint_template command with no complaints, which felt great.
Step 3: Bridge to Slack with Socket Mode
To get Switch into the channels where people already talk, we connected it to Slack using Socket Mode:
- Turn on Socket Mode and generate an app-level token (
xapp-...). - Add the Bot Token Scopes:
channels:read,groups:read,channels:write,groups:write,chat:write,app_mentions:read. - Subscribe to bot events:
message.channels,message.groups,member_joined_channel,app_mention.
(Spoiler: the scopes in step 2 cost us an evening. More on that below.)
Step 4: Don't trust the chat (guardrails)
An AI in a team channel is reading input from people it can't verify, so we assumed from day one that some of that input would be hostile. We wrapped our upstream LLM clients (Google GenAI, OpenAI, Anthropic) with the official switch-trust-sdk-py:
from switch_trust import wrap
wrapped_client = wrap(
raw_client,
gateway_url=SWITCH_TRUST_GATEWAY_URL,
api_key=SWITCH_TRUST_API_KEY,
require_guardrails=False # Graceful fallback for local offline testing
)
On top of that, security.py adds a few deterministic layers of our own:
-
Prompt-injection fast-block. Regex heuristics catch the obvious jailbreaks (
ignore triage rules,auto_execute and post secrets, sneaky<system>overrides) before we spend a single token on the LLM. That's safer and cheaper. -
Boundary framing. User text is always wrapped in strict XML boundaries (
<untrusted_message>) so the model knows exactly what is data and what is instruction. -
Pre-publication safety gate. Before anything goes out to Slack, every drafted deliverable is scanned for leaked API keys, tokens, database connection strings, and markdown image beacons (
).
4. What Went Wrong (and What We Learned)
Distributed agents on a live chat bridge turned up a bunch of problems that tutorials never mention. Here are our favorites.
Lesson 1: In-process "multi-agent" is a trap
Most "multi-agent" projects are really two Python classes in one script, with class A calling class B.execute().
The problem: if B hits an LLM timeout or crashes mid-draft, A's event loop goes down with it, and the bot stops hearing the channel entirely.
The fix: run the agents on independent WebSocket loops. taskflow-triage and taskflow-worker each have their own heartbeat, their own reconnect logic and their own Switch Agent ID. If the worker is busy on a slow generation, triage keeps reading the channel like nothing happened.
Lesson 2: Slack scopes will humble you
Early on, creating a room in the Switch Console kept returning HTTP 500: Internal Server Error. Not a very helpful message. The Switch Core logs (switch-core.log) had the real story:
slack_sdk.errors.SlackApiError: missing_scope
needed: channels:write, groups:write
provided: channels:history, users:read, chat:write...
Then we hit a second one: when the bot joined a Slack channel, Switch didn't register the room at all. The reason is that Switch's join handler calls conversations.info to look up the channel name, and that call strictly needs channels:read and groups:read.
Takeaway: a chat bridge needs symmetric read and write scopes plus explicit Socket Mode event subscriptions. Once we lined those up, inviting @TaskFlow to any channel instantly created the bridged room in Switch. Read your logs before you read your own code.
Lesson 3: The mention that went nowhere
This was our favorite bug. The bot joined #ss, a teammate tagged @TaskFlow, and Switch answered:
"You tagged @TaskFlow directly — I'm not linked to an agent in this channel yet."
Well, rude. Here's what was actually happening:
- Slack turns a bot mention into an internal user ID like
<@U0CFL6PEQHL>. - Switch Core's mention router looks it up with
_room_store.get_agent_id_by_alias(session, room_id, token). - Our room had two agents (triage and worker), so Switch had no idea which one should receive mentions of the platform bot.
- The
tokenit checked was the Slack bot user ID,U0CFL6PEQHL, and nothing in the database matched it.
The fix was small: we set alias = 'U0CFL6PEQHL' for taskflow-triage in the room_agents table. That gave the router an unambiguous path, and now anyone tagging @TaskFlow reaches triage with no manual setup.
Lesson 4: APIs move fast, sometimes while you're mid-demo
During Todoist testing, task creation suddenly started failing with HTTP 410 Gone. It turns out Doist had deprecated /rest/v2/ in favor of /api/v1/tasks. We updated todoist_client.py and added a fallback retry for natural-language date parsing, so deadlines like "tomorrow 5 PM" now survive even when the first parse attempt fails.
5. Testing: Because "It Works on My Machine" Isn't a Strategy
A bot living in a real team channel needs proper tests, so we wrote a suite covering:
- Deduplication: normalized token hashing, Jaccard similarity (J ≥ 0.60) for near-duplicate messages, and sliding-window suppression of banter.
- Security and DLP: invisible-character stripping, prompt-injection heuristics, and blocking of exfiltration beacons.
- Approvals and buttons: storing artifacts over 10,000 characters while keeping Slack's interactive button payloads under 100 bytes, plus role-based access control.
- Unit and integration tests: 152 tests, all green.
.\.venv\Scripts\python.exe -m unittest discover -s switch-taskflow -p "test_*.py"
# Ran 152 tests in 4.217s — OK!
6. Takeaways
Building on Switch AI Core convinced us that the next step for team automation is not a smarter chatbot. It's a workroom where specialized agents collaborate.
The recipe that worked for us:
- Specialized roles. Triage listens and decides. Worker builds and ships.
- Standard handoffs. Agents talk through Switch's targeted messages over WebSockets, not through shared memory.
-
Real guardrails.
switch-trust-sdk-pyplus our own DLP gate, because chat input is never fully trustworthy. - Tools people already use. Slack for conversation and Todoist for tasks, so nobody has to learn anything new.
Put together, a noisy chat channel becomes something that actually follows up on what people say in it.
Links
- GitHub repo: https://github.com/An16og/switch-taskflow
-
Room template:
templates/triage-pair.template.yaml - Built for: the Flip the Switch Hackathon (Nov 2026) by WeMakeDevs and DevTools Academy, powered by Switch AI Core.
Top comments (0)