Content

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.

AI AgentOct 21, 2026

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=sandbox

swy 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.

Terminal running swytchcode init and choosing an editor and execution mode

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 status

The 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 wantCanonical IDDescription in the registry
Read a channel's messagesslack_web.conversations.history.listRetrieve a conversation's message history
Add a reactionslack_web.reactions.add.createAdd a reaction to an item
Remove a reactionslack_web.reactions.remove.createRemove a reaction from an item
Post a messageslack_web.chat.postmessage.createSend a message to a channel
Schedule a messageslack_web.chat.schedulemessage.createSchedule a message to be sent to a channel
List channelsslack_web.conversations.list.listList 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.create

There 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.

Claude Code running swytchcode list and swytchcode info commands to find the right methods before executing

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 policy

With 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