When your AI agent serves more than one person, every tool call must answer: who's the agent acting for? Let's learn how to solve this by building an AI agent that connects with Slack and GitHub.

A Slack read uses that user's workspace. A GitHub issue is created as that user, in a repository they can access. An agent can make the wrong call, but it must never act with the wrong user's access.

The fix has two parts, and both appear in the first half of this tutorial:

  • Each user grants access separately.Alice authorizes Slack for herself. Bob authorizes it for himself.
  • Your agent passes an identifier, not a token.A string like- alice@example.comselects whose grant to use. One function turns it into a token at the moment of the call, and that token never reaches your model inputs, your tool schemas, or your logs.

Most agent tutorials stop before either point. They hand you an API key, wire up one function, and the model calls it. The design works until a second person shows up.

To make the pattern concrete, you'll build a command-line agent that watches a Slack channel, decides on its own which messages describe real work, files a GitHub issue for those, and replies in the Slack thread with the issue link. Every call runs as one user's own OAuth grant.

You'll write the OAuth flow yourself: the consent redirect, the state check, the token exchange, an encrypted store, and the refresh path. None of it is long, and seeing it whole is what makes the identity argument checkable instead of a claim you take on faith.

Two topics stay out of scope here: we won't cover Model Context Protocol servers or voice or realtime hosts. The identity pattern holds in both settings, but the surrounding plumbing deserves its own article.

Table of Contents

What You'll Build

The agent is called channel-watcher-agent. Each run does four things:

  • Reads recent messages from a Slack channel.
  • Asks a model, message by message, whether the text describes a bug or a concrete action item.
  • Files a GitHub issue for the messages that qualify.
  • Replies in the original Slack thread with a link to the new issue.

Nobody clicks a button to start any of it. Slack already ships a "create an issue from this message" action, which is a different product. Here the agent reads the channel, forms its own judgment, and acts only on what it judges worth acting on.

The stack stays small on purpose:

| Piece | Role |
|---|---|
| Node.js, plain ES modules | No web framework, no queue |
| node:http | The OAuth callback server |
| node:crypto | Token encryption |
| node:sqlite | The token store, with no dependency to install |
| Vercel AI SDK | The model call and the tool loop |

Three of those five ship with Node. The only packages you install are the AI SDK and its friends.

By the end you'll have:

  • Two OAuth apps, Slack and GitHub, that a user consents to once.
  • An encrypted token store keyed by user and provider.
  • An agent that resolves the current user to an identifier and never lets a token reach the model.
  • A tool loop where the model decides whether to file an issue at all.
  • A demonstration that a second user's run stops instead of reading the first user's data.

The finished code lives at github.com/saif-shines/channel-watcher-agent.

Prerequisites

Accounts and tools:

  • Node.js 22.13 or newer, plus npm. The token store uses- node:sqlite, which is stable from that version on.
  • A Slack workspacewhere you can install apps, and a channel to watch. A throwaway channel works best.
  • A GitHub accountand a repository that can absorb test issues.
  • An API key for a model providerthe AI SDK supports. Anthropic is used in the examples.
  • mkcert, to issue a local HTTPS certificate. How to Register the Slack and GitHub OAuth Apps explains why an ordinary- http://localhostcallback will not do.

Useful background, though none of it is a hard requirement:

  • asyncand- await, and reading a small Node script.
  • OAuth 2.0 at a high level: an app redirects a user to a provider, the user consents, the app receives a token.
  • Tool calling, sometimes called function calling. The next section covers what the tutorial needs.

One warning before starting: The agent writes to real systems. It opens real GitHub issues and posts real Slack messages. Use a test Slack channel and a throwaway GitHub repository while you're still checking that it only acts on messages you intend.

What Are AI Agent Tools?

A tool is a function you hand the model along with your input. The model can't run that function itself. It can only ask: call fileGithubIssue with this title and this body. Your code performs the call, returns the result, and the model uses that result to choose the next step.

Request, execute, return. The exchange is the whole mechanism, and everything called an "agent" is a loop around it.

How a Tool Differs from an API

Tools and APIs wrap the same call but are written for different readers.

An API is written for you. It assumes you read the documentation, and that you know thread_ts is the field that turns a Slack message into a threaded reply.

A tool is written for a model that has read nothing. So a tool carries its own explanation:

  • A - namethe model can reason about, like- fileGithubIssue.
  • A - descriptionin plain language, including when not to use the tool.
  • A - schemafor the inputs, so the model knows- titleis a required string.

Below is one tool from the project. Most of the code is explanation rather than logic:

const fileGithubIssue = tool({ description: 'File a GitHub issue for an actionable Slack message', inputSchema: z.object({ title: z.string(), body: z.string(), }), execute: async ({ title, body }) => { // ... the actual API call goes here }, });
The description and inputSchema are the parts the model sees. The execute function is yours alone. Identity gets settled inside execute, so the model never learns which account the call ran against.

Why Models Handle Tools Better Than Raw API Calls

Pasting a curl command into the input and asking the model to fill in the blanks is possible. But this approach fails in predictable ways.

Tools work better for three reasons:

  • The schema is enforced before your code runs. A malformed tool call gets rejected and retried by the SDK. A malformed URL fails at runtime instead.
  • Results return to the model. After - fileGithubIssuereturns, the model can read the new issue URL and use it in the Slack reply. The chaining is what makes the second step possible.
  • Credentials stay out of the conversation. The model asks for an action by name and never sees a token. A token it never sees can't leak into a completion, a log line, or a prompt-injection payload.

Reason three is what the rest of this tutorial builds toward. You'll keep tokens out of the model on purpose: the agent holds an identifier, and a token appears only at the moment of the provider call.

Most Agents Need More Than One App

Few useful agents talk to a single app. A support agent reads Zendesk and updates Salesforce. A standup agent reads GitHub and posts to Slack. A scheduling agent reads Gmail and writes to Google Calendar.

Each app brings its own OAuth registration, scope names, token lifetime, and refresh behavior. Multiply the list by every user of the agent, and the real problem appears.

Why a Shared Token Breaks

One shared credential for everybody works in a demo and fails once a second person shows up. Picture the quick version of the Slack half: create a Slack app, install it, copy the bot token into .env, and let every tool call use it.

Three problems arrive together.

First, every run uses the same permissions. The bot sees every channel it was invited to, no matter who triggered the run. Ask the agent about a channel you were never in, and the bot reads it anyway. The agent has become a way around your own workspace permissions.

Second, the audit trail is also wrong. Every GitHub issue says the bot opened it. Every Slack reply comes from the bot. Asked why an issue exists, the honest answer is "an agent filed it for somebody, and we can't tell who."

And third, revocation stops working. A user leaves the company and their Slack account is deactivated. The agent keeps running, because it never used their credentials.

The alternative is per-user grants. Each user authorizes the apps for themselves. That creates a new requirement, though: somewhere to keep those grants.

The Distinction is One Field in One Response

Slack makes the difference unusually easy to see. When a user finishes the consent screen, the token exchange returns both kinds of token in the same JSON object:

{ "ok": true, "access_token": "xoxb-REDACTED-BOT-TOKEN", "token_type": "bot", "authed_user": { "id": "U0A1B2C3D", "scope": "channels:history,chat:write,users:read", "access_token": "xoxp-REDACTED-USER-TOKEN", "token_type": "user" } }
The top-level access_token is the bot. The nested authed_user.access_token is the person who just consented. Reading conversations.history with the first one returns every channel the app was invited to. Reading it with the second returns only the channels that users can already see. The same split governs writes: chat.postMessage with a user token posts under that person's name.

Two fields, one letter apart in the prefix, and the entire permission model of your agent hangs on which one you store. This tutorial requests only user scopes, so Slack issues no bot token at all.

Tokens Must Stay Out of the Model and the Logs

Per-user tokens become the most sensitive data in the system. Two destinations are off limits:

  • The model:Keep tokens out of inputs, tool descriptions, and tool return values. A model that has seen a token can repeat it, and prompt injection turns any tool result into untrusted input.
  • Your logs:Tool inputs and outputs are exactly what you want to log while debugging an agent. Tokens traveling in those payloads land in your log store permanently.

This tutorial keeps tokens on one narrow path. Your code passes an identifier, a stable reference to one user. One helper turns that identifier into a token, and from there the token goes straight into a provider call and nowhere else. It's never named in a tool schema, never attached to anything the model can read, and never returned from a tool.

Why You Own the OAuth Apps and the Store

The point of writing the flow yourself isn't the plumbing. It's control over who may use whose grant.

In this tutorial the users are teammates. Each person connects their own Slack and GitHub, and the agent acts as whoever triggered the run. The same design holds when those users are customers of your product: each person still has their own grant, and a wrong mapping means one person's run using someone else's access. Only the source of the identifier changes. A session for teammates, a tenant record for customers.

Architecture Overview

Two flows matter, and they happen at different times. Keeping them separate is most of the work.

Connection time happens once per user, per app. The user consents, and tokens land in your store. The agent isn't running.

Runtime happens on every execution. The agent resolves the current user to an identifier and does its work. No consent screens and no browser.

CONNECTION TIME (once per user, per app) Your user connect.js Slack / GitHub | | | |-- "connect Slack" --->| | |<--- consent link -----| | |----------------------- OAuth consent ---------->| | |<--- redirect + code ----| | |---- exchange code ----->| | |<---- tokens ------------| | | | | [encrypt, store | | under (identifier, | | provider)] | | | | RUNTIME (every agent run) Your agent Token store Slack / GitHub | | | [resolve identifier | | from your own session] | | | | | |-- getAccessToken( --->| | | identifier, | | | provider ) | | |<---- token -----------| | | | | |------------------ API call as user ------------>| |<----------------- result -----------------------| | | | [model sees result, | | never a token] | |
Three properties follow from the shape.

The identifier replaces the token in your agent code. Everything above the token store handles a string like alice@example.com or user_8f21c. The string is worthless on its own: without the store and its encryption key, it opens nothing.

One identity spans many apps. A single identifier has a Slack row and a GitHub row beneath it. A third app doesn't create a third identity to reconcile.

Authorization stays in your code. The store answers which tokens belong to an identifier. The store can't know whether the request deserved an answer. Deciding that the caller may act as that identifier happens before any call.

One rule follows, and bending it defeats the whole design: resolve the identifier server-side from an authenticated session. Never accept an identifier from a request body, a query parameter, or a browser. An identifier accepted from a client is an "act as any user" endpoint.

How to Register the Slack and GitHub OAuth Apps

The walkthrough uses Slack and GitHub as the two providers end to end. Both need the same three things: a registered app, a redirect URI, and a set of scopes. The details differ enough to be worth walking through separately.

The Redirect URI Has to Use HTTPS

Most tutorials that touch OAuth hand you http://localhost:3000/callback and move on. Slack rejects it. Slack's documentation states flatly that "a Redirect URL must also use HTTPS", and it makes no exception for localhost. GitHub is more relaxed and accepts either, so a single HTTPS callback satisfies both.

The rule looks pedantic, because on localhost the request never leaves your machine and there's nothing on the wire to intercept. Slack applies it uniformly anyway, and a uniform rule with no exemptions is a defensible choice for a provider handing out credentials: every exemption is a branch somebody has to get right, and "is this really localhost" is a question that has been answered incorrectly before.

mkcert issues a certificate signed by a local authority it adds to your system trust store, so the browser accepts it without a warning:

mkcert -install mkcert localhost
That writes localhost.pem and localhost-key.pem into the current directory. A tunneling service such as ngrok also works, but its free URLs rotate, which means re-editing both app registrations every session.

The Slack App, and the One Setting That Matters

At api.slack.com/apps, create an app in your workspace. Then open OAuth & Permissions and set two things.

Add https://localhost:3000/callback under Redirect URLs.

Then find the scopes. The page has two sections, and choosing the wrong one silently rebuilds the shared-bot design:

| Section | What it grants | Use it here? |
|---|---|---|
| Bot Token Scopes | A xoxb-token that acts as the app | No |
| User Token Scopes | A xoxp-token that acts as the person | Yes |

Under User Token Scopes, add:

  • channels:history: read messages in public channels the user belongs to
  • chat:write: post as the user
  • users:read: turn user IDs into names

Leave Bot Token Scopes empty. Copy the Client ID and Client Secret from Basic Information.

The GitHub OAuth App

Under Settings → Developer settings → OAuth Apps → New OAuth App, set the Authorization callback URL to the same https://localhost:3000/callback, then generate a client secret. GitHub documents the web application flow in full if you want the surrounding detail.

GitHub's scope for issue creation depends on the repository:

  • repocovers private repositories, and grants read and write access to code along with it.
  • public_repois the narrower choice, and enough when your test repository is public.

Take the narrower one when you can. A scope you didn't need is a scope you have to explain later.

The Environment File

Both apps produce a client ID and a client secret, and the store needs an encryption key. Generate the key first:

node -e "console.log(require('node:crypto').randomBytes(32).toString('base64'))"
Then fill in .env:

OAUTH_REDIRECT_URI=https://localhost:3000/callback TLS_CERT_PATH=./localhost.pem TLS_KEY_PATH=./localhost-key.pem SLACK_CLIENT_ID= SLACK_CLIENT_SECRET= GITHUB_CLIENT_ID= GITHUB_CLIENT_SECRET= TOKEN_ENCRYPTION_KEY= SLACK_CHANNEL_ID=C0XXXXXXXXX GITHUB_REPO=your-name/your-test-repo
Those client secrets authenticate your application to the providers. They're not user credentials, and they never belong in a browser.

How to Run the Consent Flow

Everything provider-specific belongs in one place, so that adding a third provider later means adding an entry rather than a branch.

Step 1: Describe Each Provider Once

const REDIRECT_URI = process.env.OAUTH_REDIRECT_URI; export const providers = { slack: { label: 'Slack', authorizeUrl: 'https://slack.com/oauth/v2/authorize', tokenUrl: 'https://slack.com/api/oauth.v2.access', // These go in `user_scope`, not `scope`. Scopes listed under `scope` grant // a bot token, and a bot token is what this project exists to avoid. userScopes: ['channels:history', 'chat:write', 'users:read'], buildAuthorizeUrl(state) { const url = new URL(this.authorizeUrl); url.searchParams.set('client_id', process.env.SLACK_CLIENT_ID); url.searchParams.set('user_scope', this.userScopes.join(',')); url.searchParams.set('redirect_uri', REDIRECT_URI); url.searchParams.set('state', state); return url.toString(); }, // exchangeCode and refresh follow below }, };
The user_scope parameter is the whole argument in one line. Slack reads scope for bot permissions and user_scope for user permissions. This project sets only the second, so the response comes back with no bot token in it at all.

The state parameter isn't optional. It's a random string you generate, send to the provider, and check on the way back. Without it, any page on the internet can point a browser at your callback URL with an attacker's code attached, and your server will happily exchange it and store the attacker's token under your user's identifier.

Step 2: Exchange the Code, and Take the Right Token

async exchangeCode(code) { const response = await fetch(this.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ code, client_id: process.env.SLACK_CLIENT_ID, client_secret: process.env.SLACK_CLIENT_SECRET, redirect_uri: REDIRECT_URI, }), }); const json = await response.json(); // Slack answers HTTP 200 even when the exchange failed. The `ok` field // is the real status. if (!json.ok) { throw new Error(`Slack token exchange failed: ${json.error}`); } return normalizeSlackTokens(json.authed_user); }
Two details in that function cost real debugging time when missed.

Slack returns HTTP 200 for failures. Checking response.ok tells you the HTTP request succeeded, which it did. The json.ok field is the one that reports whether the OAuth exchange worked.

json.authed_user, not json. This is the fork from the section above, expressed as one property access. Reading json.access_token here would compile, run, store a token, and quietly give every user of your agent the same bot identity.

Normalizing the result keeps the rest of the codebase provider-agnostic:

function normalizeSlackTokens(authedUser) { return { accessToken: authedUser.access_token, refreshToken: authedUser.refresh_token ?? null, expiresAt: authedUser.expires_in ? Date.now() + authedUser.expires_in * 1000 : null, scope: authedUser.scope, }; }
GitHub's version of the same function differs in two ways worth noting:

async exchangeCode(code) { const response = await fetch(this.tokenUrl, { method: 'POST', // Without this header GitHub answers with a form-encoded body. headers: { 'Content-Type': 'application/x-www-form-urlencoded', Accept: 'application/json', }, body: new URLSearchParams({ code, client_id: process.env.GITHUB_CLIENT_ID, client_secret: process.env.GITHUB_CLIENT_SECRET, redirect_uri: REDIRECT_URI, }), }); const json = await response.json(); if (json.error) { throw new Error( `GitHub token exchange failed: ${json.error_description ?? json.error}` ); } // OAuth App tokens carry no expiry, so there is nothing to refresh. return { accessToken: json.access_token, refreshToken: null, expiresAt: null, scope: json.scope, }; }
The Accept: application/json header is easy to skip and produces a confusing failure: response.json() throws on a body that came back as access_token=gho_...&scope=repo.

Step 3: Catch the Redirect

OAuth needs somewhere to land. For a command-line tool, a server that starts, handles one callback per provider, and exits is enough. Because Slack demands HTTPS, the scheme in OAUTH_REDIRECT_URI decides which kind of server to start:

function createCallbackServer(handler) { if (redirect.protocol !== 'https:') { return createHttpServer(handler); } try { return createHttpsServer( { cert: readFileSync(process.env.TLS_CERT_PATH), key: readFileSync(process.env.TLS_KEY_PATH), }, handler ); } catch (err) { throw new Error( `Could not read the TLS certificate (${err.code ?? err.message}).\n` + 'Generate a locally-trusted one with mkcert:\n' + ' mkcert -install\n' + ' mkcert localhost\n' + 'then point TLS_CERT_PATH and TLS_KEY_PATH at the two files it writes.' ); } }
A missing certificate is going to happen to somebody, and ENOENT on its own explains nothing about OAuth. The catch block spends four lines saying what to run instead.

The handler itself is where state gets checked:

const pending = new Map(); function handleCallback(request, response) { const url = new URL(request.url, redirect.origin); if (url.pathname !== redirect.pathname) { response.writeHead(404).end('Not found'); return; } const state = url.searchParams.get('state'); const entry = pending.get(state); if (!entry) { response.writeHead(400).end('State mismatch. Start the flow again.'); return; } pending.delete(state); const error = url.searchParams.get('error'); if (error) { response.writeHead(400).end(`Authorization denied: ${error}`); entry.reject(new Error(`[${entry.provider}] authorization denied: ${error}`)); return; } entry.finish(url.searchParams.get('code'), response); }
The pending map is the state check. A state value gets into that map only when this process generated it, and it's deleted the moment it's used. An unrecognized state means the callback didn't come from a flow you started, and a state that arrives twice means a replay. Both fall out of one Map lookup.

Generating the state and waiting for its callback:

function connect(providerName) { const provider = providers[providerName]; const state = randomBytes(16).toString('hex'); console.log(`\n[${providerName}] authorize as "${IDENTIFIER}":`); console.log(provider.buildAuthorizeUrl(state)); return new Promise((resolve, reject) => { pending.set(state, { provider: providerName, reject, async finish(code, response) { const tokens = await provider.exchangeCode(code); saveGrant(IDENTIFIER, providerName, tokens); response .writeHead(200, { 'Content-Type': 'text/html' }) .end(`<p>${provider.label} connected. You can close this tab.</p>`); resolve(); }, }); }); }
randomBytes(16) and not Math.random(). A predictable state parameter is the same as no state parameter.

Running it walks each unconnected provider in turn:

[slack] authorize as "alice@example.com": https://slack.com/oauth/v2/authorize?client_id=123.456&user_scope=channels%3Ahistory%2Cchat%3Awrite%2Cusers%3Aread&redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&state=1159699dbf1a808fd33ba31c7b643505
Notice what that URL doesn't contain: any scope parameter. Slack has no instruction to mint a bot token, so it won't.

How to Store Tokens Encrypted, Keyed by User

The store answers one question: which token belongs to this user, for this provider? Everything else about it follows from keeping that answer safe.

node:sqlite has shipped with Node since v22.5, and stopped requiring a flag in v22.13. That makes a real database available with nothing to install:

import { DatabaseSync } from 'node:sqlite'; import { createCipheriv, createDecipheriv, randomBytes } from 'node:crypto'; const KEY = Buffer.from(process.env.TOKEN_ENCRYPTION_KEY ?? '', 'base64'); if (KEY.length !== 32) { throw new Error( 'TOKEN_ENCRYPTION_KEY must be 32 bytes, base64-encoded. ' + `Got ${KEY.length} bytes.` ); } const db = new DatabaseSync( process.env.TOKEN_DB_PATH ?? new URL('../tokens.db', import.meta.url).pathname ); // One row per user, per provider. expires_at stays outside the ciphertext so // a token's freshness can be checked without decrypting it. db.exec(` CREATE TABLE IF NOT EXISTS grants ( identifier TEXT NOT NULL, provider TEXT NOT NULL, ciphertext BLOB NOT NULL, iv BLOB NOT NULL, auth_tag BLOB NOT NULL, expires_at INTEGER, PRIMARY KEY (identifier, provider) ) `);
The composite primary key is the isolation guarantee, written down. (identifier, provider) means Alice's Slack row and Bob's Slack row can't collide, and no query that supplies both parts can return somebody else's grant.

expires_at sits outside the ciphertext deliberately. Checking whether a token needs refreshing is something you do before every call. Decrypting to find out would mean decrypting constantly, so the one field that isn't a secret stays readable.

Encryption is AES-256-GCM, which authenticates as well as encrypts:

function encrypt(payload) { const iv = randomBytes(12); const cipher = createCipheriv('aes-256-gcm', KEY, iv); const ciphertext = Buffer.concat([ cipher.update(JSON.stringify(payload), 'utf8'), cipher.final(), ]); return { ciphertext, iv, authTag: cipher.getAuthTag() }; } function decrypt({ ciphertext, iv, authTag }) { const decipher = createDecipheriv('aes-256-gcm', KEY, iv); decipher.setAuthTag(authTag); const plaintext = Buffer.concat([ decipher.update(ciphertext), decipher.final(), ]); return JSON.parse(plaintext.toString('utf8')); }
Three rules govern that pair, and breaking any one of them is worse than not encrypting at all, because it looks like it worked:

  • A fresh IV per encryption:Reusing an initialization vector with GCM is a catastrophic failure, not a minor one.- randomBytes(12)on every call, stored beside the ciphertext.
  • Keep the auth tag:GCM produces a tag that proves the ciphertext wasn't altered. Without- setAuthTagon the way back, you have encryption without integrity, and- decipher.final()won't complain.
  • Encrypt the whole token object, not each field.One ciphertext for- { accessToken, refreshToken, scope }means one IV and one tag to manage rather than three of each.

Writing and reading are then unremarkable:

export function saveGrant(identifier, provider, tokens) { const { ciphertext, iv, authTag } = encrypt(tokens); db.prepare( `INSERT INTO grants (identifier, provider, ciphertext, iv, auth_tag, expires_at) VALUES (?, ?, ?, ?, ?, ?) ON CONFLICT (identifier, provider) DO UPDATE SET ciphertext = excluded.ciphertext, iv = excluded.iv, auth_tag = excluded.auth_tag, expires_at = excluded.expires_at` ).run(identifier, provider, ciphertext, iv, authTag, tokens.expiresAt ?? null); }
The ON CONFLICT clause matters more than it looks. Re-consenting has to replace a grant rather than fail or duplicate it, and re-consenting is exactly what a user does after a revocation or a scope change.

The encryption key itself lives in .env here, which is right for a tutorial and wrong for production, where it belongs in a secrets manager or a KMS. Losing it makes every stored grant unreadable and forces every user to consent again. That is a real outage, but it's a better one than the alternative: a stolen database file that hands over working tokens for every user of your agent.

How to Run Tool Calls as the Current User

Runtime has three moves: resolve the identifier, fetch a token with it, and wrap the whole thing as a tool.

Step 1: Resolve the Identifier, Then Authorize

An identifier is any stable string that represents one user, an email address, a user ID, a tenant-scoped key.

// In a real app this comes from your authenticated session, resolved // server-side. Never accept it from client input. const IDENTIFIER = process.argv[2] ?? 'channel-watcher-agent';
Reading the identifier from argv keeps the demo runnable without a login, and it makes the isolation test later in this tutorial a single command. A real application replaces the line:

// Real app: resolve from your authenticated session, server-side. const session = await getSession(request); // your auth const identifier = await lookupIdentifier(session.userId); // your database
Order matters in those two lines. Authenticate the caller first, then look up which identifier the caller may act as. An identifier arriving from a client turns the endpoint into a reader of any user's Slack.

Step 2: Turn the Identifier into a Token, Late

One function stands between the identifier and every provider call:

const REFRESH_WINDOW_MS = 60_000; export async function getAccessToken(identifier, providerName) { const grant = readGrant(identifier, providerName); if (!grant) { throw new Error( `[${providerName}] no grant for "${identifier}".\n` + `Connect it first: node src/connect.js ${identifier}` ); } const expiringSoon = grant.expiresAt !== null && grant.expiresAt !== undefined && grant.expiresAt - Date.now() < REFRESH_WINDOW_MS; if (!expiringSoon) { return grant.accessToken; } if (!grant.refreshToken) { throw new Error( `[${providerName}] token for "${identifier}" expired and no refresh ` + 'token is stored. The user has to consent again.' ); } const refreshed = await providers[providerName].refresh(grant.refreshToken); saveGrant(identifier, providerName, refreshed); return refreshed.accessToken; }
Call this immediately before the API call, not once at startup. A long agent run can outlive a twelve-hour token, and resolving tokens up front means discovering that at the least convenient moment. Fetching late costs one cheap database read and removes the whole class of problem.

Also, the sixty-second window isn't padding for its own sake. A token with four seconds left passes a naive expiry check and then expires in flight. Refreshing anything inside the window means the token handed back is good for at least a minute of work.

Finally, a missing grant raises an error rather than falling back. There's nothing sensible to fall back to. The correct outcome for an unconnected user is a stop, with a message saying how to connect.

Step 3: Wrap Provider Calls as Tools

Identity gets injected here, one layer below anything the model can influence:

export function buildTools(identifier) { const [owner, repo] = process.env.GITHUB_REPO.split('/'); const fileGithubIssue = tool({ description: 'File a GitHub issue for an actionable Slack message', inputSchema: z.object({ title: z.string(), body: z.string(), }), execute: async ({ title, body }) => { const token = await getAccessToken(identifier, 'github'); return createIssue(token, owner, repo, { title, body }); }, }); const replyInSlackThread = tool({ description: 'Reply in the original Slack thread (e.g. with the created issue link)', inputSchema: z.object({ text: z.string(), thread_ts: z.string(), }), execute: async ({ text, thread_ts }) => { const token = await getAccessToken(identifier, 'slack'); return postThreadReply( token, process.env.SLACK_CHANNEL_ID, text, thread_ts ); }, }); return { fileGithubIssue, replyInSlackThread }; }
Compare what the model controls against what it can't. The model chooses title, body, and text. The model can't choose the user. identifier is a closure argument, fixed before the model ran, and it appears in no inputSchema. There's no input that makes the model file an issue as somebody else, because the account isn't one of its inputs.

Return values deserve one audit each. createIssue returns the issue number, URL, and title. postThreadReply returns a timestamp. Neither returns a token, and neither returns the raw provider response, which is where a token would hide if one were going to.

The provider calls themselves are ordinary HTTP:

export async function createIssue(token, owner, repo, { title, body }) { const response = await fetch( `https://api.github.com/repos/${owner}/${repo}/issues`, { method: 'POST', headers: { Authorization: `Bearer ${token}`, Accept: 'application/vnd.github+json', 'X-GitHub-Api-Version': '2022-11-28', 'Content-Type': 'application/json', }, body: JSON.stringify({ title, body }), } ); const json = await response.json(); if (!response.ok) { // 403 here usually means the grant is missing the `repo` scope. throw new Error( `GitHub issue creation failed (${response.status}): ${json.message}` ); } return { number: json.number, url: json.html_url, title: json.title }; }

Step 4: Read the Channel

Slack's conversations.history returns clean JSON, with one gap: messages carry a user ID, never a display name. Turning those into names means a users.info call each, which is what users:read was in the scope list for.

export async function readChannel(token, channelId, limit = 20) { const { messages } = await slackCall(token, 'conversations.history', { channel: channelId, limit: String(limit), }); const authors = await resolveAuthors( token, messages.filter((m) => m.user).map((m) => m.user) ); return messages .filter((message) => message.text) .map((message) => ({ author: authors.get(message.user) ?? 'unknown', userId: message.user, text: message.text, ts: message.ts, })) .reverse(); // oldest first }
Three small decisions in that function:

  • Names cost one- users.info- call per unique author.Caching them per run keeps a channel full of one person's messages from producing twenty identical lookups. A lookup that fails falls back to the user ID rather than throwing, since an unresolvable name isn't a reason to abandon the run.
  • Messages without- text- get dropped.Channel joins and purpose changes arrive as message objects with no body, and there's nothing for the model to triage in them.
  • .reverse()- isn't cosmetic.Slack returns newest first. A model reading a conversation backwards will misread which message answered which.

The ts field then does double duty. It identifies a message, which makes it both the thread anchor for replies and the key for remembering what the agent already handled:

const state = await loadState(); const processed = new Set(state[IDENTIFIER]?.processedTs ?? []); const newMessages = messages.filter((m) => !processed.has(m.ts));
Key that state by identifier, as the snippet does. A single flat list lets one user's processed messages hide another's, which reintroduces cross-user bleed in the one place the whole design exists to prevent.

Step 5: Run the Tool Loop

Hand the model both tools and let it decide:

const { text } = await generateText({ model: anthropic(process.env.MODEL), tools, stopWhen: stepCountIs(5), prompt: `You triage messages from a dev team's Slack channel. Message from ${message.author}: "${message.text}" Message timestamp (thread_ts): ${message.ts} Decide if this message is actionable (a bug report or concrete action item) or just noise (chit-chat, join notices, already-resolved chatter). If actionable: file a GitHub issue with a clear title and body drafted from the message, then reply in the original Slack thread (use the exact thread_ts above) with a short note and the created issue's URL. If not actionable: do nothing and briefly say why.`, });
The loop is what makes the second step possible. The model reads the message and may call fileGithubIssue. The AI SDK runs the tool, feeds the result back into context along with the new issue URL, and calls the model again. Now the model can reply in the thread with a URL it couldn't have known on the first pass. Then it stops.

stopWhen: stepCountIs(5) caps the rounds. Without a bound, a confused model can retry a failing tool indefinitely. Five rounds is generous for two tools.

A deterministic version is also reasonable: classify with a structured-output call, then call both tools yourself in a fixed order when the message qualifies.

The fixed sequence is easier to test and gives up real flexibility. A loop lets the model skip the reply, or file without replying, and adding a third tool needs no new branching. Choose the loop when the set of actions varies per input, and the fixed sequence when it never does.

One note on the provider line, for accuracy about what ran. The snippet above uses @ai-sdk/anthropic, which suits a direct Anthropic API key. My own tests went through an OpenAI-compatible gateway, which changes only the provider construction:

import { createOpenAICompatible } from '@ai-sdk/openai-compatible'; const gateway = createOpenAICompatible({ name: 'gateway', baseURL: `${process.env.GATEWAY_BASE_URL}/v1`, apiKey: process.env.GATEWAY_API_KEY, }); // then: model: gateway(process.env.MODEL)
The tools, the loop, and the token handling are identical either way. Only the model argument changes.

How to Handle Refresh and Revocation

Tokens end in two different ways, and only one of them is your code's problem. Expiry is routine and recoverable. Revocation is a decision somebody made, and the correct response is to ask for consent again.

The two providers in this tutorial sit at opposite ends of the range, which makes them a useful pair.

GitHub: Tokens That Don't Expire, Until They Do

An OAuth App user token has no expiry timestamp. There's no refresh token to store and no refresh call to make, which is why github.refresh() in this project does nothing but explain itself:

async refresh() { throw new Error( 'GitHub OAuth App tokens do not expire. A failure here means the ' + 'grant was revoked — send the user through consent again.' ); }
"Does not expire" is not the same as "lasts forever," and GitHub revokes tokens for several reasons worth knowing:

  • The user revokes the authorization from their account settings.
  • The token goes unused for one year.
  • The token gets pushed to a public repository or gist, at which point GitHub revokes it automatically.
  • The app accumulates more than ten tokens for the same user and scope combination, and the oldest are revoked.

The third one deserves a moment. GitHub scans public pushes for its own token formats and kills what it finds. That is a safety net, not a strategy, and the one thing it can't protect is a token in a private repository or a log file.

GitHub Apps behave differently from OAuth Apps, which is a common source of confusion when reading GitHub's documentation. A GitHub App's user access token expires in eight hours and comes with a refresh token good for six months. If you build on GitHub Apps instead, the Slack-shaped refresh path below is the one you want.

Slack: Rotation is Opt-in and Permanent

By default, a Slack user token doesn't expire either. Token rotation changes that, and it comes with a warning worth repeating: rotation can't be turned off once it's turned on. Enable it on a test app first.

With rotation on, tokens live twelve hours and arrive with a refresh token. The refresh call reuses the same endpoint as the initial exchange, with a different grant type:

async refresh(refreshToken) { const response = await fetch(this.tokenUrl, { method: 'POST', headers: { 'Content-Type': 'application/x-www-form-urlencoded' }, body: new URLSearchParams({ grant_type: 'refresh_token', refresh_token: refreshToken, client_id: process.env.SLACK_CLIENT_ID, client_secret: process.env.SLACK_CLIENT_SECRET, }), }); const json = await response.json(); if (!json.ok) { throw new Error(`Slack token refresh failed: ${json.error}`); } return normalizeSlackTokens(json.authed_user ?? json); }
Store the new refresh token, not just the new access token. Refresh tokens rotate too. Writing back only the access token leaves you holding a spent refresh token, and the failure arrives twelve hours later, which is a long time to wait to learn something.

That write-back is why getAccessToken calls saveGrant after refreshing rather than returning the token and moving on.

Treat a Dead Grant as a Normal State

A revoked grant isn't an exception in the exceptional sense. Users leave, administrators tighten scopes, and people change their minds about what an agent may do.

The shape that works is the one getAccessToken already uses: catch the failure, and surface a fresh authorization link rather than a stack trace. connect.js with the same identifier lets the user re-consent, ON CONFLICT overwrites the dead row, and nothing else in your user record changes.

How to Add a Second Provider

A second provider costs one OAuth app, one entry in the providers object, and one tool. Keeping identity in a single string is what buys the discount.

The agent has used two providers all along. Worth noticing is what the second one didn't require: no second identity, no second consent server, and no second token table.

export const providers = { slack: { /* ... */ }, github: { /* ... */ }, };
Google Calendar as a third means a third entry with its own authorizeUrl, tokenUrl, scopes, and exchangeCode. The consent server loops over Object.keys(providers), so it picks the new one up without modification. The store already keys on (identifier, provider), so it needs no migration. Then one more tool:

const createCalendarEvent = tool({ description: 'Create a calendar event', inputSchema: z.object({ summary: z.string(), start: z.string() }), execute: async ({ summary, start }) => { const token = await getAccessToken(identifier, 'google-calendar'); // ...one more provider call }, });
The identifier doesn't change, your user table doesn't change, and the model's view of the world grows by exactly one tool.

The cost that doesn't scale down is the provider-specific knowledge. Each new provider brings its own scope vocabulary, its own error format, and its own answer to whether tokens expire. Slack and GitHub disagreed on all three, and a third will disagree differently. The registry pattern contains that knowledge in one object per provider rather than spreading it through your agent, but it doesn't make the knowledge unnecessary.

One caveat on consent: A grant is per user, per provider. Alice connecting Slack but not Calendar means her calendar tool calls fail, and failure is correct there, since she never consented. Treat it as a prompt to connect rather than an error, a point the Failure Modes section returns to.

Full Walkthrough

Clone the repository, install, and fill in .env:

```
git clone https://github.com/saif-shines/channel-watcher-agent.git
cd channel-watcher-agent
npm install
cp .env.example .env

fill in both client IDs and secrets, the encryption key, channel ID, repo

```
Then connect. The command starts the callback server and prints one link per unconnected provider:

npm run connect
[slack] authorize as "channel-watcher-agent": https://slack.com/oauth/v2/authorize?client_id=123.456&user_scope=channels%3Ahistory%2Cchat%3Awrite%2Cusers%3Aread&redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&state=1159699dbf1a808fd33ba31c7b643505 [slack] connected. [github] authorize as "channel-watcher-agent": https://github.com/login/oauth/authorize?client_id=Iv1.abc&scope=repo&redirect_uri=https%3A%2F%2Flocalhost%3A3000%2Fcallback&state=e6d13461099c391367266235f8313630 [github] connected. All providers connected. Run: node src/index.js channel-watcher-agent
Open each link, consent, and the tab confirms. The state parameter in those URLs is checked on the way back. A callback carrying anything else gets a 400 and never reaches the token exchange.

Then run the agent against a channel holding ordinary chatter:

node src/index.js
The output from that run, against a channel with three unremarkable messages:

[channel-watcher-agent] 3 messages fetched, 3 new. --- Alex: "Sending draft message" --- The message "Sending draft message" is noise — it appears to be a test or accidental send, not a bug report or concrete action item. ...
No tools called, and no issues filed. The negative case matters more than it looks. An agent with write access that can't say no is a liability, and a run over ordinary chatter is the cheapest available test of its restraint.

Now post an actual bug report in the channel:

hey the /export endpoint is timing out for any file over 50MB, been happening since yesterday's deploy

Run the agent again, and the state file keeps the earlier messages from being triaged twice.

Authorship is the part that matters. The GitHub account behind the identifier opens the issue, using that user's own OAuth grant, not a shared bot. The Slack reply comes from that person too. Revoke their access and the next run fails at getAccessToken, which is the correct outcome.

What Changes for a Second User

The identifier comes from the command line, so isolation is testable without building a login first:

node src/index.js # the identifier you already authorized node src/index.js alice@example.com # a different user entirely
The second command never reads the channel. It stops:

[slack] no grant for "alice@example.com". Connect it first: node src/connect.js alice@example.com
The refusal is the whole point. Nothing about the agent changed between the two commands. Same providers, tools, and code. Only the identifier differed, and Alice hasn't consented, so no row exists to decrypt and the run stops before touching Slack.

A shared-bot version behaves differently. The second command would read the channel and file an issue as the bot, because no per-user grant was ever involved.

Once Alice consents, everything downstream follows her grant. readGrant returns her row. getAccessToken decrypts her token. The Slack read returns the channels she can see, and her GitHub account authors the issue.

Production replaces argv with a session lookup:

const identifier = await lookupIdentifier(session.userId);

Testing the Isolation Without Credentials

The repository includes a test suite that replaces fetch with stand-in Slack and GitHub endpoints, so the request building, response parsing, storage, and refresh logic all run without a single OAuth app registered:

npm test
Three of those tests are worth naming, because they check the claims this tutorial makes rather than the code's internals:

  • Slack exchange keeps the user token and discards the bot token.The fixture returns both. The test asserts the stored value is the- xoxp-one.
  • Two users get two different tokens from identical tool inputs.Same- text, same- thread_ts, two identifiers, two different- Authorizationheaders reaching the provider.
  • A tool built for an unconnected user fails instead of falling back.It also asserts that zero provider calls were attempted, since failing after leaking a request isn't much of a failure.

Tests that pass on the first run are worth distrusting, so I checked these by breaking the code on purpose. Substituting the bot token for the user token, ignoring the identifier in buildTools, and exposing identifier in the model-visible schema each fail at least one test.

How to Apply the Pattern to Other Use Cases

Nothing in the pattern is specific to Slack triage. The shape is: read from one app, decide with a model, write to another app, all as one user.

Swapping the providers produces a different product:

| Read from | Write to | Result |
|---|---|---|
| Slack | GitHub | Triage channel chatter into issues, as in this tutorial |
| Gmail | Linear | Turn support email into tracked work |
| Google Calendar | Notion | Meeting prep notes, drafted before the meeting |
| Zendesk | Salesforce | Log support signals against the right account |
| GitHub | Slack | A digest of what changed, in the channel that cares |

Every row uses the same three pieces: a provider entry, a token lookup by identifier, and a tool. Only three things change: the OAuth app registrations, the API calls inside execute, and the input you write for the model.

The input is where your product lives. OAuth is plumbing. Deciding which messages deserve an issue, and what the issue should say, is judgment, and judgment is the part worth your weeks.

The same code supports two deployment shapes:

  • Internal team agent:The identifier is the teammate who triggered the run. Runs on a schedule or a command.
  • Customer-facing agent:The identifier comes from your tenant and user records. Runs on customer data, inside customer accounts.

The code stays identical. The consequences of a wrong identifier do not.

What Went Wrong When I Built This

These are problems I hit while building the project, in roughly the order they showed up. If you hit the same ones, the fix is usually small.

Slack Won't Save the Redirect URL

The symptom arrives before any code runs: the Slack app configuration page refuses to accept http://localhost:3000/callback.

Slack requires HTTPS on redirect URLs with no exception for localhost. Issue a local certificate with mkcert, register the https:// form, and point TLS_CERT_PATH and TLS_KEY_PATH at the files it wrote. GitHub accepts either scheme, so the same HTTPS URL works for both apps.

The Browser Warns That the Certificate Isn't Trusted

mkcert -install is the step that adds mkcert's local authority to your system trust store, and skipping it leaves a certificate no browser recognises.

Running it once fixes every certificate mkcert issues afterwards. A self-signed certificate made with openssl will always warn, since nothing trusts it.

The Redirect URI Doesn't Match

Both providers compare the redirect_uri you send against the one registered with the app, and the comparison is exact. A trailing slash, 127.0.0.1 in place of localhost, http where you registered https, or a different port all fail.

The error arrives before consent, on the provider's own page, which at least makes it easy to spot. Keep OAUTH_REDIRECT_URI as the single source and pass it in both the authorize URL and the token exchange, as the provider registry does.

The Callback Port is Already in Use

connect.js binds the port from OAUTH_REDIRECT_URI, and port 3000 is popular. An unhandled EADDRINUSE produces a stack trace that says nothing about OAuth, so the project catches it and says what to do instead.

Changing the port means changing it in three places: .env, the Slack app's redirect URLs, and the GitHub app's callback URL. Missing one produces the previous failure.

The State Check Rejects a Legitimate Callback

State values live in memory and are deleted once used. Restarting connect.js after opening the link, or refreshing the callback tab, both produce a state that's no longer in the map.

Both are correct rejections. Generate a fresh link and start again.

Tool Calls Return Permission Errors or Empty Results

A missing scope or a revoked grant causes both.

GitHub answers 403 with "Resource not accessible" when the grant lacks repo. Slack answers 200 with ok: false and an error like missing_scope. Fix the scope list, then send the user through consent again, since an existing grant doesn't gain scopes retroactively.

A partially-scoped grant fails at the point of use rather than at connection time, which is what makes the symptom look mysterious. The consent screen succeeded, the token stored fine, and the failure arrives during a tool call hours later.

The Agent Reads Channels it Shouldn't

The single most likely cause is storing json.access_token instead of json.authed_user.access_token during the Slack exchange. Both are strings, both are truthy, and both work (one works as the app rather than the person).

The tell is the scope of what comes back. A user token returns only that person's channels. If conversations.history returns a channel the current user was never in, a bot token is in the store.

A Tool Call Runs as the Wrong User

Passing a token or identifier belonging to somebody else will do the wrong thing correctly.

Two habits prevent it. Resolve the identifier server-side after authenticating the caller, never from client input. Then take the identifier as a closure argument in buildTools and let each execute fetch its own token, so no code path can pass a stray credential.

Refresh Works Once and Then Stops

Refresh tokens rotate. A refresh that writes back the new access token but keeps the old refresh token succeeds immediately and fails on the following cycle, which puts twelve hours between the bug and its symptom.

saveGrant takes the whole normalized token object for this reason. Write back everything the refresh returned.

The Agent Files Duplicate Issues

Two causes. A missing or unwritten state file makes every run triage everything again. Or stopWhen allows enough rounds for a confused model to retry a tool that already succeeded.

Check the state file first. Then check whether the tool's return value clearly signals success, because an ambiguous result invites a retry.

Conclusion

You've built an agent that reads a Slack channel, judges which messages describe real work, files GitHub issues for those, and closes the loop with a threaded reply. Every call ran as one specific user's own OAuth grant, through an OAuth flow and a token store you wrote yourself.

Five ideas carry over to any provider:

  • A tool is an API call plus an explanation for a model, and the explanation is most of the work.
  • The identifier replaces the token in your agent code.Everything above one small function handles a reference to a user rather than a credential, so tokens never reach your model inputs or your logs.
  • Connection time and runtime are separate flows.Consent happens once per user, per app. Runtime resolves an identifier and fetches a token late.
  • Authorization stays yours.A token store answers which tokens belong to an identifier. Whether a caller may act as that identifier is a question only your code can answer.
  • Multi-provider support is a registry problem, not an architecture problem, once identity lives in one string.

The detail that carries the most weight is also the smallest: authed_user.access_token rather than access_token. One property access decides whether your agent respects the permissions your workspace already has or quietly routes around them.

From here, keep the shape and swap the providers. Point the read half at Gmail and the write half at Linear, then rewrite the input for the model. The identity plumbing doesn't change.

The full source is at github.com/saif-shines/channel-watcher-agent.

This write-up reconstructs what we learned building Scalekit, a hosted version of the token vault you just built.