Skip to content
Hypermetron
← All posts
#AI#Engineering

Running an eve agent on Slack Socket Mode instead of webhooks

We built a support assistant on eve, Vercel’s open-source agent framework, and connected it to Slack over Socket Mode instead of eve’s built-in Slack connector. Here’s why, and what the wiring looks like.

We recently built a support assistant that lives in Slack. A support agent mentions the bot in a thread, the assistant investigates with read-only access to the systems it needs, and it replies in the same thread. Anything that would change data waits for a person to approve it.

The agent runs on eve, Vercel’s open-source agent framework. eve ships with a Slack channel that handles mentions, DMs, threaded replies and approval buttons. We used almost all of eve, but not its Slack transport. This post explains why, and shows how little code it took.

What eve gives you out of the box

eve’s Slack channel is webhook-based. Slack posts events to /eve/v1/slack, and you point the app’s Event Subscriptions and Interactivity Request URL there. The recommended setup is Vercel Connect, which manages the bot token, verifies inbound requests and handles rotation. Off Vercel, you supply the bot token and signing secret yourself.

That’s the right shape for a serverless deployment. Ours wasn’t one. The assistant keeps state on disk, including eve’s durable workflow store, and runs as one long-lived process on a VM. We didn’t want to give that process a public endpoint just so Slack could reach it.

Why Socket Mode

Slack’s Socket Mode reverses the connection. Your app opens an outbound WebSocket to Slack, and events arrive over it. That gave us three things:

  • No public ingress. There is no Request URL, no tunnel and no open port. For an agent with access to internal systems, a smaller attack surface was worth a lot.
  • Identical dev and prod. A laptop connects the same way the server does. Give developers their own Slack app, so local runs and production never compete for the same events.
  • Nothing inbound to verify. The socket is authenticated with an app-level token, so there’s no request signing to get wrong.

The trade-off is real: the process has to stay up, so it can’t run serverless. For a stateful agent that was already true.

The wiring: a listener and a loopback

In eve, conversations are started from a channel’s route handlers. A route gets from(address), which binds send, respond and friends to a conversation. A Socket Mode client doesn’t live inside a request, so it has no route context to call them from.

We didn’t reach into eve’s internals. We defined a small custom channel with an internal route, and the listener posts to it on localhost. The listener is just a translator from Slack to HTTP:

import { SocketModeClient } from "@slack/socket-mode";

const socket = new SocketModeClient({ appToken: process.env.SLACK_APP_TOKEN });

socket.on("app_mention", async ({ event, ack }) => {
  // Ack immediately. Slack retries unacknowledged events,
  // and an agent turn can take minutes.
  await ack();

  await fetch("http://127.0.0.1:3000/internal/slack", {
    method: "POST",
    headers: { "content-type": "application/json" },
    body: JSON.stringify({
      channel: event.channel,
      thread: event.thread_ts ?? event.ts,
      user: event.user,
      text: event.text,
    }),
  });
});

await socket.start();

On the eve side, the channel uses channel:thread as the conversation address, so each Slack thread maps to one session and a follow-up in the thread continues it:

import { defineChannel, POST } from "eve/channels";

export default defineChannel({
  routes: [
    POST("/internal/slack", async (request, { from }) => {
      const { channel, thread, text } = await request.json();

      await from(`${channel}:${thread}`).send(text, { auth: null });

      return new Response(null, { status: 202 });
    }),
  ],
  events: {
    "message.completed"(event, channel) {
      // channel.continuation.token is the "channel:thread" address;
      // post the reply back into that Slack thread.
    },
  },
});

Bind the server to loopback and that route is never reachable from outside the machine. In a real deployment you would also check the Slack user against an allowlist before calling send, and pass a real auth principal so every action has a named actor.

What we learned the hard way

Owning the transport means owning its edge cases. A few worth knowing before you start:

  • Run exactly one process per app token. Slack spreads events across open Socket Mode connections. A stray second process doesn’t crash anything. It quietly takes some of the mentions, which looks like the bot randomly ignoring people.
  • Mentions can arrive twice. If you also subscribe to channel messages, a mention comes through as both an app_mention and a message event. Dedupe on channel and timestamp, or you start two turns for one question.
  • Filter message subtypes by name. Skipping every message with a subtype also skips file_share, which is how Slack delivers any message with an attachment. List the subtypes you mean to ignore instead.
  • Make failures loud. A Slack API call that fails inside an event handler shouldn’t fail the agent’s turn, but it must not vanish either. Log every failure, and log why every dropped message was dropped.
  • Slack isn’t Markdown. Convert model output to Slack’s mrkdwn before posting, or users see raw **bold** and [text](url).

Would we do it again?

Yes, for this shape of system. If you deploy on Vercel, eve’s built-in Slack channel with Vercel Connect is less code and handles the operational details for you. If you self-host a stateful agent and would rather not open a port, a Socket Mode listener plus one loopback route is a small amount of code, and the agent never has to be reachable from the internet.