Tutorial: Build a Slack Bot

Build a persistent Slack bot that responds to DMs and @mentions, gates dangerous tools with trust approval in the thread, and assigns different permissions based on who's talking. By the end you'll understand how Socket Mode connects without inbound ports, how trust prompts appear in Slack threads, how roles map identity to permissions, and how $global shares config across authority boundaries.

What You'll Build

A Slack bot that:

  • Connects via Socket Mode (outbound only — no public URL needed)
  • Responds to DMs and @mentions with AI-powered answers
  • Prompts for approval in the thread before running dangerous tools
  • Gives admins full access and restricts everyone else to read-only tools

Prerequisites

  • Node.js 22+
  • AWS credentials with Bedrock access — see AWS & Bedrock Setup
  • A Slack workspace where you can create apps

Step 1: Create the Slack App

  1. Go to api.slack.com/appsCreate New AppFrom scratch
  2. Name it (e.g., "My Agent") and select your workspace

Enable Socket Mode

Socket Mode lets your bot connect to Slack without exposing a public URL. The connection is outbound — your bot calls Slack, not the other way around. This means it works behind firewalls, on laptops, in containers — anywhere with internet access.

  1. Settings → Socket Mode → Enable
  2. Create an app-level token with connections:write scope
  3. Save the token (starts with xapp-)

Add Bot Scopes

Features → OAuth & Permissions → Bot Token Scopes:

  • app_mentions:read — see @mentions
  • chat:write — send messages
  • im:history — read DM history
  • im:read — see DMs
  • im:write — send DMs

Enable Events

Features → Event Subscriptions → Enable, subscribe to:

  • message.im — DM messages
  • app_mention — @mentions in channels

Install to Workspace

Settings → Install App → Install. Save the Bot User OAuth Token (starts with xoxb-).

Amazon workspaces: Before installing, go to Settings → Collaborators and add opus-amazon-prod as a collaborator. This is required for Slack apps in Amazon workspaces.

Step 2: Create the Project

mkdir slack-bot && cd slack-bot
agent-apps init

Update main.skill.md:

---
name: slack-bot
description: Slack bot with trust and roles
allowed-tools: "*"
metadata:
  role: main
  paths: [./skills]
  model:
    id: us.anthropic.claude-sonnet-4-6
    region: us-east-1
    persistent: true
  slack:
    app-token: xapp-1-YOUR-APP-TOKEN
    bot-token: xoxb-YOUR-BOT-TOKEN
    routes:
      - ref: slack-bot
        type: "*"
  trust:
    rules: "file-write code shell"
    store: file
---

You are a helpful assistant. Respond to messages from your team.

Don't hardcode tokens. Use environment variables instead:

export AGENT_APPS_LOCAL_SLACK__APP_TOKEN=xapp-1-...
export AGENT_APPS_LOCAL_SLACK__BOT_TOKEN=xoxb-...

The double underscores (__) create nesting: SLACK__APP_TOKEN{ slack: { "app-token": "..." } }. Single underscores become hyphens.

Step 3: Run It

agent-apps

You'll see:

Slack connected: team

DM the bot or @mention it in a channel. The persistent: true model config keeps the agent running indefinitely.

You: What files are in the current directory?
Bot: ⠋ Thinking...
     ✓ file-list — Completed in 0.1s
     Here's what's in the project directory:
     - main.skill.md
     - skills/
     - package.json

How Socket Mode Works

Unlike webhooks (which require a public URL), Socket Mode establishes an outbound WebSocket connection from your bot to Slack's servers. Messages arrive over this connection in real time.

When a message arrives:

  1. The slack middleware receives the event over the WebSocket.
  2. It checks allowed-users (if configured) to filter who can talk to the bot.
  3. It sets up a gateway on nonlocals.gateway with Slack-specific transport:
    {
      name: "team",
      description: "Slack",
      send: "team-send-a1b2c3d4",       // callback ref → posts to thread
      receive: "team-receive-e5f6g7h8",  // callback ref → waits for reply
      id: "U01ABC123"
    }
    
  4. It invokes the main skill (or a route-matched skill) with the message.

The send callback ref has the channel ID and thread timestamp baked in, so gateway-send replies in the same thread automatically. The agent doesn't know it's talking to Slack — it just uses the gateway.

Step 4: Trust in Action

When the agent tries to use file-write, code, or shell, the trust middleware intercepts:

You: Create a file called notes.txt with my meeting notes
Bot: 🔒 Tool requires approval: file-write
     Args: {"path":"notes.txt","content":"Meeting notes..."}

     Allow? [y/n/a/v]
You: a
Bot: ✓ file-write — Completed in 0.1s
     Done! I've created notes.txt with your meeting notes.

The trust prompt appears as a message in the Slack thread. The user replies in the thread, and gateway-receive picks up the response. Trust decisions with store: file persist in .agent-apps/trust.json, so once you say "always" for a tool, it won't ask again.

This works because trust uses gateway-receive for the approval prompt, and the Slack middleware has already set up a gateway with the receive callback ref. The trust middleware doesn't know it's in Slack — it just asks the gateway.

Step 5: Add Roles

Different users should have different permissions. Admins get full tool access; everyone else gets read-only.

Update main.skill.md metadata to add roles:

metadata:
  roles:
    key: "nonlocals.gateway.id"
    rules:
      - match: "U01ABC*"
        name: admins
        metadata:
          frontmatter: { allowed-tools: "*" }
          trust: { rules: "#meta-internal($policy=allow) *", store: file }
      - match: "*"
        name: everyone
        metadata:
          frontmatter: { allowed-tools: "file-read file-list skill-list" }

How Roles Work

When a message arrives, the roles middleware:

  1. Reads the identity key: pathGet(ctx, "nonlocals.gateway.id")"U01ABC123" (the Slack user ID)
  2. Matches against each entry's match pattern in order. U01ABC* is a glob — it matches any user ID starting with U01ABC.
  3. For the first match, it injects all metadata keys from that entry as middleware. This means:
    • frontmatter: { allowed-tools: "*" } overrides the skill's allowed-tools for this invocation
    • trust: { rules: "..." } overrides the trust rules for this invocation

For admins (U01ABC*): Full tool access. Internal tools (#meta-internal) are auto-approved, everything else prompts for trust.

For everyone else (*): Only file-read, file-list, and skill-list. They can ask the bot to read files and list skills, but can't write files, run shell commands, or use other tools.

The identity comes from nonlocals.gateway.id, which the Slack middleware sets to the Slack user ID. For email, it would be the sender address. For web, it could be a header or session value. The roles middleware doesn't care about the transport — it just reads the key.

Step 6: Route by Message Type

Instead of one skill handling everything, route DMs and @mentions to different handlers:

Create skills/support.skill.md:

---
name: support
description: Handle DM support questions
allowed-tools: "file-read skill-introspect"
---

You are a support agent. Answer the user's question helpfully. Use skill-introspect to look up framework documentation if needed.

Create skills/code-review.skill.md:

---
name: code-review
description: Review code shared in channels
allowed-tools: "file-read file-write"
---

Review the code the user shared. Provide constructive feedback on:
1. Correctness — does it do what it claims?
2. Style — does it follow conventions?
3. Edge cases — what could go wrong?

Keep feedback concise and actionable.

Then update the main skill's slack: config to route by message type:

metadata:
  slack:
    app-token: xapp-1-...
    bot-token: xoxb-...
    routes:
      - ref: support
        type: dm
      - ref: code-review
        type: thread

How Slack Routing Works

The slack middleware's routes: config dispatches messages to skills:

  • type: dm — matches direct messages only
  • type: thread — matches threaded messages (including @mentions in channels)
  • type: * — matches everything (or omit type)
  • from: "U01ABC*" — optional sender filter (glob pattern on user ID)

DMs go to support. @mentions go to code-review. If neither matches, the main skill handles it.

The transport middleware enriches the skill's prompt with context about the message:

This skill handles Slack messages on profile "team".
Message type: dm
From: U01ABC123
Reply via gateway-send (appears as a thread reply).

What You Built

slack-bot/
  main.skill.md                 ← Main skill: Slack + trust + roles config
  skills/
    support.skill.md          ← DM handler
    code-review.skill.md      ← @mention handler
  .agent-apps/
    trust.json                ← Persistent trust decisions

What You Learned

  • Socket Mode connects outbound. No public URL needed. Works behind firewalls, on laptops, in containers.
  • Trust prompts appear in the Slack thread. The trust middleware uses gateway-receive, which the Slack middleware routes to thread replies. Users approve or deny by replying in the thread.
  • Roles map identity to permissions. The roles middleware reads nonlocals.gateway.id (Slack user ID), matches against glob patterns, and injects metadata overrides — different allowed-tools and trust rules per user.
  • $global crosses authority boundaries. Without it, model, trust, and mcp wouldn't reach the agent provider or hub packages. $global makes them universal.
  • Slack routes: dispatches by message type. DMs, @mentions, and specific senders can each go to a different skill. First match wins.
  • Environment variables avoid hardcoded tokens. AGENT_APPS_LOCAL_SLACK__APP_TOKEN{ slack: { "app-token": "..." } }.
  • The agent code is transport-agnostic. The same gateway-send/gateway-receive calls work in CLI, Slack, and email. Only the channel config changes.

Next Steps

Ask AI