How to Build a Slack Message Router AI Agent With Jev
Slack has no route-this-message endpoint. Routing means reading a channel, deciding where something belongs, and then doing the smallest possible thing about it. Here is the full build, with the verified method IDs and the reason your first action should be an emoji.
Key takeaways
- -The Slack methods live under a slack_web prefix, not slack. The ones you need are slack_web.conversations.history.list, slack_web.chat.postmessage.create and slack_web.reactions.add.create.
- -Replying in a thread is not a separate method. It is chat.postMessage with a thread_ts, which is also the difference between a quiet reply and a message everyone in the channel sees.
- -Start by reacting, not posting. A reaction is reversible, invisible to notifications, and lets you measure the router's accuracy for a week before it says anything out loud.
- -Ask one choice question for the destination with an explicit none option, plus nouls for the things that should override the routing entirely.
- -Slack has no built-in idea of a handled message, so you need your own marker. A reaction doubles as that marker, which is why it is a good first action.
Slack does not have a routing endpoint. There is no conversations.route, no way to say "this message belongs to the billing team" and have Slack do something about it. We went looking, because it would have made this post shorter.
What routing actually means in Slack is three separate things: read messages from somewhere, decide where each one belongs, and then take an action that communicates that decision to a human. The third part is where every Slack bot we have used gets annoying, because the obvious action is to post a message, and a message is the loudest thing you can do in Slack. It notifies people, it cannot be unsaid, and if your router is wrong 15% of the time then 15% of its output is noise in a channel someone has to read.
So this build does the decision properly and then does the quietest possible thing about it. The first version does not post at all.
This follows the same shape as our Gmail triage agent, and if you have read that one you can skim to the methods table. If you have not used Jev before, the question types guide covers what choice, score and noul return.
What we are building
A router that watches one busy channel and, for each new human message, decides:
- which team it belongs to, if any
- how urgent it is
- whether the person is explicitly asking for a human
- whether it mentions anything that should bypass routing entirely
Then it reacts with an emoji for the team, and only escalates to an actual message when urgency and confidence are both high. A sample run over 60 messages from a #help channel:
60 messages read
41 routed by reaction only
11 skipped (bot messages, thread replies, already reacted)
6 escalated to #support-escalations
2 flagged for review (confidence below threshold)Six messages out of sixty generated a notification. The other 41 decisions are sitting there as emoji, which is what you want for the first week.
Setup
No .env file, and no key in your code. Credentials go into Swytchcode's own store and the execution mode is a project setting.
npx swytchcode
swy login
swy init --mode=sandboxswy init asks for your editor and the execution mode once, and writes both into .swytchcode/tooling.json. Credentials land in ~/.swytchcode/credentials.db, outside the project entirely.

swy init asks for the mode once. It is not an environment variable and your code never reads it.
Then fetch the integrations and connect them:
swy get slack
swy get jev
swy auth connect slack # opens a browser for OAuth
swy auth connect jev # prompts for your API key
swy auth statusThe methods, verified
Only methods you explicitly add can run, which is the part of Swytchcode we lean on hardest in a post like this. An agent that has been given four methods cannot delete a channel, because deleting a channel is not one of the four.
swy add method <jev-method-id> # find it with: swy list methods jev
swy add method slack_web.conversations.history.list # read a channel's messages
swy add method slack_web.reactions.add.create # add an emoji reaction
swy add method slack_web.chat.postmessage.create # post a message or thread reply| What you want | Canonical ID | Description in the registry |
|---|---|---|
| Read a channel's messages | slack_web.conversations.history.list | Retrieve a conversation's message history |
| Add a reaction | slack_web.reactions.add.create | Add a reaction to an item |
| Remove a reaction | slack_web.reactions.remove.create | Remove a reaction from an item |
| Post a message | slack_web.chat.postmessage.create | Send a message to a channel |
| Schedule a message | slack_web.chat.schedulemessage.create | Schedule a message to be sent to a channel |
| List channels | slack_web.conversations.list.list | List all channels in a Slack team |
Note the slack_web prefix. We wrote slack.chat.postmessage.create from memory in an earlier post and it is wrong, because that is not what the registry calls it. Always confirm:
swy discover "add a reaction to an item" --library slack
swy list methods slack
swy info slack_web.chat.postmessage.createThere is also no separate method for replying in a thread. A thread reply is chat.postMessage with a thread_ts, which matters more than it sounds and we come back to it.
Reading the channel
import { exec } from "@swytchcode/runtime";
const CHANNEL = "C0123456789";
export async function recentMessages(limit = 60) {
const res = await exec("slack_web.conversations.history.list", {
params: { channel: CHANNEL, limit }
});
return res.messages ?? [];
}Three things in that response will trip you up.
Everything is keyed by ts, a string. It looks like a float, "1791556513.325100", and it is a message's identity as well as its timestamp. Do not parse it into a number and do not round it, because you need it back exactly as given to react to the message or reply to it.
Bot messages are in there. Anything with a bot_id, or a subtype like channel_join, is not a human asking for help. Our first run routed four "has joined the channel" messages to the billing team, with reasonable-looking confidence, because we gave Jev the text and the text was a sentence.
Thread replies come back too, carrying a thread_ts that differs from their own ts. Routing a reply as if it were a new request means the same conversation gets routed several times.
function isRoutable(m) {
if (m.bot_id || m.subtype) return false; // not a human message
if (m.thread_ts && m.thread_ts !== m.ts) return false; // a reply, not a new ask
if (!m.text || m.text.trim().length < 12) return false;// nothing to judge
if ((m.reactions ?? []).some(r => OURS.has(r.name))) return false; // handled
return true;
}That last line is the one worth copying. Slack has no concept of a handled message. There is no read flag you can set, no label you can attach. If your router runs every two minutes and reads the last 60 messages, it will see the same message thirty times unless you keep your own record of what you have dealt with. Using your own reaction as that record means the state lives in Slack, survives your process restarting, and is visible to the humans in the channel.
The questions
One choice for the destination, one score for urgency, two noul questions for the things that should override routing.
const TEAMS = {
billing: "Invoices, charges, refunds, payment methods, plan changes, tax documents.",
technical: "Errors, failed integrations, API questions, something is broken or behaving wrongly.",
account: "Logins, permissions, seats, SSO, access to a workspace or a resource.",
none: "Chat, thanks, acknowledgements, or anything that is not a request for help."
};
const URGENCY = [
"No time pressure. A question or a comment.",
"Normal. They would like an answer today or tomorrow.",
"Soon. Their work is slowed and they have said so.",
"Now. They are blocked, or something is actively failing in production."
];
export async function route(text) {
const { answers } = await exec("<jev-method-id>", {
body: {
model: "jev-latest",
state: text,
questions: {
team: { type: "choice", instructions: "Which team should handle this message?", criteria: TEAMS },
urgency: { type: "score", instructions: "How time-critical is this message?", criteria: URGENCY },
wants_human: { type: "noul", instructions: "The sender is explicitly asking to talk to a person." },
sensitive: { type: "noul", instructions: "The message mentions legal action, a security incident, or someone's personal data." }
}
}
});
return answers;
}That none option in TEAMS is not optional. Without it, "thanks, that worked" has to be billing, technical or account, and it will be one of them with a confidence around 0.6 that looks entirely plausible in your logs. An escape option is the cheapest accuracy improvement available in a choice question.
The sensitive question is deliberately broad, and it is not there to route anything. It is there to stop the router acting at all. A message mentioning a security incident should not get a cheerful billing emoji, whatever the routing confidence says.
The plan, and why rule one ignores confidence
const EMOJI = { billing: "moneybag", technical: "wrench", account: "key" };
const CONFIDENT = 0.80;
export function planFor(a) {
// 1. Sensitive content never gets routed, at any confidence.
if (a.sensitive.noul > 0.5) {
return { action: "review", reason: "mentions legal, security or personal data" };
}
// 2. Someone asking for a person gets a person.
if (a.wants_human.noul > 0.85) {
return { action: "escalate", reason: "explicitly asked for a human" };
}
// 3. Not a request for help.
if (a.team.choice === "none" && a.team.confidence > 0.6) {
return { action: "skip", reason: "not a help request" };
}
// 4. Unsure about the destination.
if (a.team.confidence < CONFIDENT) {
return { action: "review", reason: `team confidence ${a.team.confidence.toFixed(2)}` };
}
// 5. Confident and urgent: say something out loud.
if (a.urgency.score >= 2.5) {
return { action: "escalate", team: a.team.choice, reason: `urgency ${a.urgency.score.toFixed(2)}` };
}
// 6. Confident and not urgent: react and move on.
return { action: "react", team: a.team.choice, emoji: EMOJI[a.team.choice] };
}Rule one has no confidence term in it, and that is on purpose. There is no number Jev could return that makes it correct to slap an emoji on a message about a data breach. The whole reason to write the plan as a function rather than a chain of if (confidence > x) checks is so rules like that can exist, and we went through the general case in confidence thresholds.
Rule five thresholds on 2.5 against a 0 to 3 scale, which catches everything in the top band plus the strong cases just below it. That is only possible because a score answer is a probability-weighted mean and can land between levels. Rounding it first would throw that away.
Acting, quietly then loudly
Three actions, in increasing order of how much they bother people.
export async function reactTo(message, emoji) {
await exec("slack_web.reactions.add.create", {
body: { channel: CHANNEL, timestamp: message.ts, name: emoji }
});
}
export async function replyInThread(message, text) {
await exec("slack_web.chat.postmessage.create", {
body: { channel: CHANNEL, thread_ts: message.ts, text, reply_broadcast: false }
});
}
export async function escalate(message, team, reason) {
const link = `https://slack.com/archives/${CHANNEL}/p${message.ts.replace(".", "")}`;
await exec("slack_web.chat.postmessage.create", {
body: {
channel: "C_ESCALATIONS",
text: `*${team}* needed: ${reason}\n${link}`
}
});
}replyInThread and escalate are the same canonical method. The only difference is thread_ts, and that one field is the difference between a reply tucked under a message and a message in the channel that notifies everyone watching. Setting reply_broadcast: false explicitly is worth doing even though it is the default, because the day someone sets it to true to make a reply "more visible" is the day your router starts shouting.

Looking up a method and checking its inputs before running it. Worth doing for chat.postMessage specifically, since the difference between a thread reply and a channel-wide post is one optional field.
Then the loop:
export async function runOnce() {
const messages = await recentMessages(60);
const log = [];
for (const m of messages.filter(isRoutable)) {
const answers = await route(m.text);
const plan = planFor(answers);
if (plan.action === "react") await reactTo(m, plan.emoji);
if (plan.action === "escalate") {
await reactTo(m, "rotating_light");
await escalate(m, plan.team ?? "someone", plan.reason);
}
if (plan.action === "review") await reactTo(m, "eyes");
// "skip" does nothing at all, deliberately
log.push({ ts: m.ts, ...plan, confidence: answers.team.confidence, urgency: answers.urgency.score });
}
return log;
}Note that escalate reacts first. If the post to the escalations channel fails, the message is still marked, so the next run does not escalate it a second time.
Roll it out as reactions first
The advice we would give anyone building this: run it for a week with rules five and two disabled. Every message gets a reaction and nothing else. No posts, no notifications, no thread replies.
You get three things from that week. You find out your real accuracy, by looking at the emoji in a channel you already read. You find out your confidence distribution, so CONFIDENT = 0.80 becomes a number from your data rather than a guess. And nobody in the channel has to care, because an emoji is reversible and notifies no one.
Then turn on escalation, and keep the reaction. A rotating_light next to a message is how a human in the channel knows the router already handled it, which stops two people picking up the same thing.
The policy underneath
Everything above is your own logic, which means a refactor can change it. For anything you want to hold regardless, a policy sits outside the code and is checked before the call leaves:
{
"id": "no-channel-posts-outside-escalations",
"target": ["slack_web.chat.postmessage.create"],
"when": { "field": "channel", "operator": "!=", "value": "C_ESCALATIONS" },
"action": { "type": "REQUIRES_APPROVAL", "message": "Posting outside the escalations channel needs a human" },
"approval_timeout": "2h"
}swy policy add
swy audit policyWith that in place, a bug in planFor cannot post into #general, because posting into #general is not something the agent is allowed to do without a person saying yes. Reactions are unaffected, so the quiet path keeps working while the loud path is gated. How to set up policy guardrails covers the rule syntax, and a prompt is not a policy makes the case for why this belongs outside the model's reach.
A real run
From a #help channel over an afternoon, with the escalation path on:
ts=1791556513.325100 team=technical conf=0.91 urg=2.84 -> escalate (urgency 2.84)
ts=1791556544.118200 team=billing conf=0.88 urg=1.20 -> react :moneybag:
ts=1791556602.904100 team=none conf=0.79 urg=0.10 -> skip (not a help request)
ts=1791556661.550300 team=account conf=0.71 urg=1.90 -> review (team confidence 0.71)
ts=1791556720.337800 team=technical conf=0.94 urg=0.80 -> react :wrench:
ts=1791556788.221400 team=billing conf=0.83 urg=2.10 -> react :moneybag:
ts=1791556840.009900 team=technical conf=0.96 urg=3.00 -> escalate (explicitly asked for a human)
ts=1791556901.774600 team=none conf=0.55 urg=0.30 -> review (team confidence 0.55)The fourth line is the system working. An account question at 0.71 confidence got an eyes emoji instead of a key one, and a human sorted it in about four seconds. The eighth line is the same thing from the other direction: a message that was probably chat, but not confidently enough to ignore silently.
Things we got wrong
We used the wrong method prefix. slack.chat.postmessage.create does not exist. It is slack_web.chat.postmessage.create. We had the short form written down from an earlier project, used it in several places, and only caught it when swy discover returned the real ID. The lesson we keep relearning is that a canonical ID you remember is a canonical ID you should check.
We routed join messages. Four channel_join events got routed to billing on the first run, with confidences in the 0.6s. They are messages, they have text, and Jev had no way of knowing that "Priya has joined the channel" was not a request. Filtering on subtype and bot_id fixed it.
We re-routed the same messages for an hour. Slack has no handled flag and we had not built one, so every two-minute run re-processed the whole window. The second run double-reacted, which Slack rejects, which is how we found out. Checking for our own reaction before acting is both the fix and a free audit trail.
We parsed ts as a float. It looks like one. Doing that loses precision, and then reactions.add fails because the timestamp no longer identifies a message. Keep it as the string Slack gave you.
We broadcast a thread reply. Early on we set reply_broadcast: true while testing, reasoning that a reply nobody sees is useless. It put every thread reply into the channel. The whole point of a thread reply is that it is quiet.
FAQ
What is the canonical ID for posting a Slack message?
slack_web.chat.postmessage.create. The Slack methods are under a slack_web prefix rather than slack. Confirm with swy list methods slack or swy info slack_web.chat.postmessage.create rather than trusting a remembered ID.
How do I reply in a thread rather than the channel?
Use the same slack_web.chat.postmessage.create method and pass thread_ts set to the parent message's ts. There is no separate thread-reply method. Leave reply_broadcast off unless you genuinely want the reply in the channel as well.
How does the router know it has already handled a message?
It has to keep its own record, because Slack has no handled state. The approach here uses its own reaction as the marker: before acting, check whether one of the router's emoji is already on the message. That keeps the state in Slack, where it survives restarts and is visible to people.
Should the router post messages or add reactions?
Start with reactions only, for about a week. They are reversible, they notify nobody, and they let you measure accuracy against a channel you already read. Turn on posting once you have a confidence threshold derived from real data.
How do I stop it routing bot messages and join notices?
Filter out anything with a bot_id or a subtype before you call Jev. Those are not human requests, and Jev will route them anyway because they contain plausible sentences.
Can it route messages into different channels per team?
Yes, but we would gate it. A policy restricting chat.postMessage to an approved channel list means a routing bug cannot post somewhere unexpected. Reactions can stay ungated since they are reversible.
What happens if Slack rate limits me?
exec retries rate-limit responses with backoff rather than failing immediately. For a backlog, keep the read window small and the loop interval honest; re-reading 60 messages every two minutes is 30 reads an hour, which is nothing.
Does it need to run on a schedule or can it be event-driven?
Both work. Polling conversations.history on an interval is simpler to build and reason about, and the handled-marker check makes it safe to run as often as you like. Event-driven via Slack's Events API is less latency but more moving parts.
Which question type should the destination be?
A choice, with an explicit none option. Several noul questions asking "is this billing?", "is this technical?" can all come back high or all low, and then you are writing tie-break logic by hand.
Wrapping up
Slack has no routing endpoint, so a router is a decision plus the smallest action that communicates it. Getting the decision right is the easy half now: one choice with an escape option, a score you threshold on the fraction, and a couple of noul questions that can override everything.
The half worth thinking about is what the agent does with the answer. An emoji is reversible and silent. A channel post is neither. Build the reversible version first, measure it for a week against a channel you already read, and put a policy underneath the loud path so that a refactor cannot make it louder.
The Jev integration is live at swytchcode.com/apis/jev, and the Slack integration page covers the action side. For the same pattern against an inbox rather than a channel, the Gmail triage build is the closest relative, and the Stripe refund approver shows what changes when the action costs money.
npx swytchcode installs the CLI, and swytchcode.com/skills.md is the agent-readable setup file you can hand to a coding agent so it wires this up with the right commands instead of guessing.
More content
OpenAI Dots vs Instinct: Two Ways to Build a Personal Agent
Two personal-agent products launched within a week of each other and solved the same problem differently. Dots gives an agent its own computer inside your workspace; Instinct sends yours into other people's group chats. Both had to invent a permission layer, and neither exposes one to developers.
MCP 2026-07-28 Spec Guide: What Changed, Migration Code, and 6 Production Patterns
The 2026-07-28 revision removed sessions, the initialize handshake, ping, and SSE stream resumability. Here is every change that affects a server you already run, what to do about each one, and the six patterns worth adopting.
How to Triage GitHub Issues and Pull Requests With Jev
A triage bot that labels issues instead of commenting on them, built on verified canonical method IDs. Includes the GitHub API quirk that makes every pull request look like an issue, and why that breaks naive triage.
