Build a Slack bot that can do work with you and your team
The agent is the easy part. Pi does the reasoning; Composio gives it 1000+ apps to act on. In three lines you have an agent that can open a PR, check a calendar, or search a Notion workspace for one user.
The work is everything around it: putting that agent in Slack, where a whole team talks to it, and making it act as each person while posting as one bot. That's a handful of Composio pieces:
- Triggers deliver every Slack message to your server as a webhook.
- Sessions give each user their own scoped toolset, so the agent acts as them.
- A shared connection lets the bot speak as the workspace bot, with one install for everyone.
- Redirected auth links keep OAuth out of the channel: when an app isn't connected, the bot DMs the user a link and resumes on approval.
- The proxy reaches the Slack Web API endpoints the toolkit doesn't wrap as tools.
trigger webhook
Verifies the webhook signature, then calls the session's tools.
- Slackworkspace botshared
- GitHubAlicelinked
- GmailAlicelinked
Posts to Slack as the workspace bot; acts in every other app as the user.
Below you build the whole thing from scratch: a basic agent first, then a piece at a time up to the full server, then a browse of the real source. You bring a Composio API key and an agent runtime. Composio brings the workspace.
Setup
You need a Composio API key, a publicly reachable URL for your server, and Bun.
bun add @composio/core @composio/experimental @earendil-works/pi-coding-agentInstall the bot
A Slack bot needs a Slack app to authenticate as and a stream of events. Composio gives you both, so you never register a webhook with Slack or hold a bot token. The slackbot toolkit ships with Composio-managed OAuth, and you install it as one shared connection for the whole workspace.
This is install.ts, run once, built up three steps at a time:
Create a Composio-managed auth config for the slackbot toolkit. No Slack app of your own to register.
1234567891011121314import { Composio } from '@composio/core';
const composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });
// The scopes the bot needs. The slackbot toolkit ships Composio-managed OAuth,// so you never register your own Slack app.const authConfig = await composio.authConfigs.create('slackbot', { type: 'use_composio_managed_auth', name: 'workspace-bot', credentials: { scopes: ['app_mentions:read', 'channels:history', 'chat:write', 'reactions:write', 'users:read'], user_scopes: ['search:read'], },});Start a setup session and authorize slackbot as a SHARED connection, so a single approval serves every user.
10 unmodified lines1112131415161718192021222324252610 unmodified lines scopes: ['app_mentions:read', 'channels:history', 'chat:write', 'reactions:write', 'users:read'], user_scopes: ['search:read'], },});
// One connection for the whole workspace: authorize it as SHARED.const setup = await composio.create('setup:workspace-bot', { toolkits: ['slackbot'], authConfigs: { slackbot: authConfig.id }, manageConnections: true,});const request = await setup.authorize('slackbot', { callbackUrl: `${process.env.APP_URL}/setup/callback`, experimental: { accountType: 'SHARED' },});console.log('Approve the install:', request.redirectUrl);On the callback, open the ACL to the workspace, subscribe your webhook, and create the message triggers.
22 unmodified lines2324252627282930313233343522 unmodified lines callbackUrl: `${process.env.APP_URL}/setup/callback`, experimental: { accountType: 'SHARED' },});console.log('Approve the install:', request.redirectUrl);
// On the OAuth callback: open the ACL, subscribe your webhook, create triggers.// Persist connectedAccountId as SLACK_CONNECTION_ID for the bot server.export async function onSetupCallback(connectedAccountId: string) { await composio.connectedAccounts.updateAcl(connectedAccountId, { allowAllUsers: true }); await composio.triggers.setWebhookSubscription({ webhookUrl: `${process.env.APP_URL}/webhooks/composio` }); await composio.triggers.create('setup:workspace-bot', 'SLACKBOT_CHANNEL_MESSAGE_RECEIVED', { triggerConfig: { is_bot_message: false } }); await composio.triggers.create('setup:workspace-bot', 'SLACKBOT_DIRECT_MESSAGE_RECEIVED', { triggerConfig: {} });}A webhook subscription is the pipe; each trigger is a tap. Together they stream channel messages and DMs to your server. The connected account id that comes back from the OAuth callback is the SLACK_CONNECTION_ID the server pins into every session.
Build the bot
bot.ts starts as a bare three-line agent and grows into the server, one Composio concept at a time. Each diff below is exactly what that concept adds.
Start with a basic agent
The whole idea, before any Slack: create a session for a user, hand the Pi provider the session so it can search and execute, and run a prompt. This already acts across every app that user has connected.
1234567891011121314151617181920212223242526272829303132333435import { Composio } from '@composio/core';import { PiProvider } from '@composio/experimental';import { createAgentSession, SessionManager } from '@earendil-works/pi-coding-agent';
const composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });const piProvider = new PiProvider();
// Run the Pi agent over a session's tools and return its final text.async function runPi(tools: unknown, prompt: string) { const { session: pi } = await createAgentSession({ sessionManager: SessionManager.inMemory(process.cwd()), customTools: tools, tools: ['composio_search_tools', 'composio_manage_connections', 'composio_execute_tool'], }); let reply = ''; pi.subscribe((e) => { if (e.type === 'message_update' && e.assistantMessageEvent.type === 'text_delta') { reply += e.assistantMessageEvent.delta; } }); await pi.prompt(prompt); pi.dispose(); return reply;}
// The smallest agent: one session, its tools, one prompt.export async function runAgent(userId: string, prompt: string) { const session = await composio.create(userId); const tools = piProvider.createSessionTools({ sessionId: session.sessionId, search: (params) => session.search(params), execute: (slug, args, options) => session.execute(slug, args, options), }); return runPi(tools, prompt);}Put it in a Slack thread
Turn the one-shot agent into a handler. Each Slack thread gets its own session, reused so the agent keeps context, and the reply goes back with the SLACKBOT_SEND_MESSAGE tool. The session is keyed to the Slack user, so when Alice asks for a GitHub issue it opens as Alice, against her GitHub connection.
1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484910 unmodified lines6061626326272829303132646566676869707172737475347677import { Composio } from '@composio/core';import type { IncomingTriggerPayload } from '@composio/core';import { PiProvider } from '@composio/experimental';import { createAgentSession, SessionManager } from '@earendil-works/pi-coding-agent';
const composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });const piProvider = new PiProvider();const callbackUrl = `${process.env.APP_URL}/connections/callback`;
// One session per Slack thread, reused so the agent keeps context, with a short// transcript for memory across turns.const threads = new Map<string, { sessionId: string; history: { role: string; content: string }[] }>();const threadKey = (event: IncomingTriggerPayload) => `${event.payload?.channel}:${event.payload?.thread_ts ?? event.payload?.ts}`;
async function sessionForThread(event: IncomingTriggerPayload) { const key = threadKey(event); const existing = threads.get(key); if (existing) return { session: await composio.use(existing.sessionId), memory: existing };
const session = await composio.create(event.userId, { manageConnections: { enable: true, callbackUrl, waitForConnections: true }, }); const memory = { sessionId: session.sessionId, history: [] as { role: string; content: string }[] }; threads.set(key, memory); return { session, memory };}
function toolsForSession(session) { return piProvider.createSessionTools({ sessionId: session.sessionId, callbackUrl, search: (params) => session.search(params), execute: (slug, args, options) => session.execute(slug, args, options), connections: { getToolkitStates: (toolkits) => session.toolkits({ toolkits }), authorizeToolkit: async (toolkit) => { const request = await session.authorize(toolkit, { callbackUrl }); return { status: 'needs_connection', redirectUrl: request.redirectUrl }; }, isConnected: (state) => state.connection?.isActive ?? false, }, });}
// Run the Pi agent over a session's tools and return its final text.async function runPi(tools: unknown, prompt: string) { const { session: pi } = await createAgentSession({ sessionManager: SessionManager.inMemory(process.cwd()),10 unmodified lines pi.dispose(); return reply;}
// The smallest agent: one session, its tools, one prompt.export async function runAgent(userId: string, prompt: string) { const session = await composio.create(userId); const tools = piProvider.createSessionTools({ sessionId: session.sessionId, search: (params) => session.search(params), execute: (slug, args, options) => session.execute(slug, args, options),// Reply to one Slack message as the user who sent it.async function handleSlackMessage(event: IncomingTriggerPayload) { const { session, memory } = await sessionForThread(event); const prompt = [...memory.history.map((m) => `${m.role}: ${m.content}`), `user: ${event.payload?.text}`].join('\n');
const reply = await runPi(toolsForSession(session), prompt);
await session.execute('SLACKBOT_SEND_MESSAGE', { channel: event.payload?.channel, thread_ts: event.payload?.thread_ts, text: reply, }); return runPi(tools, prompt); memory.history.push({ role: 'user', content: event.payload?.text ?? '' }, { role: 'assistant', content: reply });}Share one workspace connection
By default a connected account is PRIVATE: only its creator can use it. The install authorized the Slack connection as SHARED, so you pin it into every session. Now Alice's session has her GitHub connection but the workspace's Slack connection. It posts as the bot, and acts everywhere else as Alice.
5 unmodified lines678910111213141516174 unmodified lines22232425262728293031325 unmodified linesconst composio = new Composio({ apiKey: process.env.COMPOSIO_API_KEY });const piProvider = new PiProvider();const callbackUrl = `${process.env.APP_URL}/connections/callback`;
// One Slack connection, shared by the whole workspace. The bot posts as this// identity while acting in every other app as the individual user.const SHARED_SLACK_CONNECTION_ID = process.env.SLACK_CONNECTION_ID;
// One session per Slack thread, reused so the agent keeps context, with a short// transcript for memory across turns.const threads = new Map<string, { sessionId: string; history: { role: string; content: string }[] }>();const threadKey = (event: IncomingTriggerPayload) =>4 unmodified lines const existing = threads.get(key); if (existing) return { session: await composio.use(existing.sessionId), memory: existing };
const session = await composio.create(event.userId, { // Pin the shared Slack connection; the session still resolves every other // toolkit against this user's own connections. connectedAccounts: { slackbot: [SHARED_SLACK_CONNECTION_ID] }, manageConnections: { enable: true, callbackUrl, waitForConnections: true }, }); const memory = { sessionId: session.sessionId, history: [] as { role: string; content: string }[] }; threads.set(key, memory);Reach the gaps with the proxy
Most Slack actions are SLACKBOT_* tools. The few that aren't, like the typing indicator and opening a DM channel, drop down to session.proxyExecute, which calls the Slack Web API with the pinned connection's auth so you never touch a token.
31 unmodified lines3233343536373839404142434445464748495051525354555657585960616230 unmodified lines939495967497989910010110210331 unmodified lines threads.set(key, memory); return { session, memory };}
// Anything the toolkit doesn't wrap as a tool, reach via the proxy: it calls the// Slack Web API with the pinned connection's auth, so you never touch a token.async function setStatus(session, event: IncomingTriggerPayload, status: string) { await session .proxyExecute({ toolkit: 'slackbot', endpoint: 'https://slack.com/api/assistant.threads.setStatus', method: 'POST', body: { channel_id: event.payload?.channel, thread_ts: event.payload?.thread_ts, status }, }) .catch(() => {});}
async function openDm(session, userId: string): Promise<string> { const res = await session.proxyExecute({ toolkit: 'slackbot', endpoint: 'https://slack.com/api/conversations.open', method: 'POST', body: { users: userId }, }); return res.data?.channel?.id;}
function toolsForSession(session) { return piProvider.createSessionTools({ sessionId: session.sessionId, callbackUrl,30 unmodified lines
// Reply to one Slack message as the user who sent it.async function handleSlackMessage(event: IncomingTriggerPayload) { const { session, memory } = await sessionForThread(event); const prompt = [...memory.history.map((m) => `${m.role}: ${m.content}`), `user: ${event.payload?.text}`].join('\n'); await setStatus(session, event, 'Working on it…');
const prompt = [...memory.history.map((m) => `${m.role}: ${m.content}`), `user: ${event.payload?.text}`].join('\n'); const reply = await runPi(toolsForSession(session), prompt);
await session.execute('SLACKBOT_SEND_MESSAGE', { channel: event.payload?.channel,Redirect auth links
The payoff. When the agent reaches for an app the user hasn't connected, the tool result carries a one-time Composio connect URL. You never want it in the channel or in the model's context. The bot extracts it, redacts it from the tool output, DMs it to the user privately, and the run resumes the moment they approve, because the session was created with waitForConnections.
54 unmodified lines555657585959606162636465666768697071727374757677787980818283848586878889909192649394959697989910010110210310423 unmodified lines12812913013110013213313413513654 unmodified lines }); return res.data?.channel?.id;}
function toolsForSession(session) {// Redirect auth links. When a tool hits an app the user hasn't connected, the// result carries a one-time Composio connect URL. Never let the model or the// channel see it: redact it from the tool output and DM the user privately. The// session's manageConnections + waitForConnections resumes the run on approval.const CONNECT_LINK = /https:\/\/(?:connect\.composio\.dev|[^\s"']*composio[^\s"']*\/link)\/[^\s"')]+/gi;
function redactLinks<T>(value: T): T { if (typeof value === 'string') return value.replace(CONNECT_LINK, '[connection link sent via DM]') as T; if (Array.isArray(value)) return value.map(redactLinks) as T; if (value && typeof value === 'object') { return Object.fromEntries(Object.entries(value).map(([k, v]) => [k, redactLinks(v)])) as T; } return value;}
async function handleAuthLinks<T>(session, event: IncomingTriggerPayload, value: T): Promise<T> { const links = [...new Set([...JSON.stringify(value ?? '').matchAll(CONNECT_LINK)].map((m) => m[0]))]; if (links.length > 0) { const dm = await openDm(session, event.userId); for (const url of links) { await session.execute('SLACKBOT_SEND_MESSAGE', { channel: dm, text: `*Connection needed.* Approve access and I'll continue automatically:\n<${url}|Connect>`, }); } } return redactLinks(value); // hand the model a result with the raw URL stripped}
function toolsForSession(session, event: IncomingTriggerPayload) { return piProvider.createSessionTools({ sessionId: session.sessionId, callbackUrl, search: (params) => session.search(params), execute: (slug, args, options) => session.execute(slug, args, options), // Every tool result passes through handleAuthLinks: connect URLs get DM'd to // the user and redacted before the model ever sees them. execute: async (slug, args, options) => handleAuthLinks(session, event, await session.execute(slug, args, options)), connections: { getToolkitStates: (toolkits) => session.toolkits({ toolkits }), authorizeToolkit: async (toolkit) => { const request = await session.authorize(toolkit, { callbackUrl }); await handleAuthLinks(session, event, request.redirectUrl); return { status: 'needs_connection', redirectUrl: request.redirectUrl }; }, isConnected: (state) => state.connection?.isActive ?? false, },23 unmodified lines const { session, memory } = await sessionForThread(event); await setStatus(session, event, 'Working on it…');
const prompt = [...memory.history.map((m) => `${m.role}: ${m.content}`), `user: ${event.payload?.text}`].join('\n'); const reply = await runPi(toolsForSession(session), prompt); const reply = await runPi(toolsForSession(session, event), prompt);
await session.execute('SLACKBOT_SEND_MESSAGE', { channel: event.payload?.channel, thread_ts: event.payload?.thread_ts,Serve the webhook
Verify each trigger's signature with composio.triggers.verifyWebhook, then hand the payload to handleSlackMessage off the response path so a slow handler doesn't get retried. That's the whole server.
136 unmodified lines137138139140141142143144145146147148149150151152153154155156157158159136 unmodified lines text: reply, }); memory.history.push({ role: 'user', content: event.payload?.text ?? '' }, { role: 'assistant', content: reply });}
Bun.serve({ port: 3000, async fetch(req) { const url = new URL(req.url); if (req.method === 'POST' && url.pathname === '/webhooks/composio') { const { payload } = await composio.triggers.verifyWebhook({ payload: await req.text(), secret: process.env.COMPOSIO_WEBHOOK_SECRET, id: req.headers.get('webhook-id'), timestamp: req.headers.get('webhook-timestamp'), signature: req.headers.get('webhook-signature'), }); void handleSlackMessage(payload); return Response.json({ ok: true }); } return new Response('Not found', { status: 404 }); },});The whole project
The two files above are the spine. The real project rounds them out with grouped auth-link DMs, per-user routing, message chunking, reaction acks, and durable storage. Here's a slice of the actual source, with the Composio touch-points highlighted. Browse the tree, read the files:
12345678910111213141516171819202122232425262728import { Composio } from '@composio/core';
import { createAuthCallbackHandler } from './api/auth-callback';import { createComposioWebhookHandler } from './api/composio-webhook';import { createApiHandler } from './api/router';import { createSlackComposioAgent } from './agent/slack-composio-agent';import { createSlackbotSetupService } from './composio/slackbot-setup';import { loadConfig } from './config/env';import { createSlackTransport } from './slack/transport';import { createFileStore } from './store/file-store';
const config = loadConfig();const store = await createFileStore(config.dataDir);const composio = new Composio({ apiKey: config.composioApiKey });const slack = createSlackTransport({ config, store });const agent = createSlackComposioAgent({ config, slack, store });const slackbotSetup = createSlackbotSetupService({ composio, config, store });const composioWebhook = createComposioWebhookHandler({ agent, config, store });const authCallback = createAuthCallbackHandler({ agent, slack, store });
const server = Bun.serve({ hostname: config.host, port: config.port, fetch: createApiHandler({ authCallback, composioWebhook, slackbotSetup }),});
console.log(`Composio Slack Pi bot listening on http://${server.hostname}:${server.port}`);console.log(`Setup URL: ${config.appUrl}/setup/slackbot/start`);The complete project lives on GitHub: composio-slack-bot.
Run it
Run bun install.ts once to set up the bot, start the server with bun bot.ts, then @mention the bot in any channel. It opens a session as you, finds the tool it needs, runs it against your connections, and replies in thread as the workspace bot, usually within a few seconds. Ask it to do something in an app you haven't connected yet and it DMs you a link first, then continues once you approve.