- Updated
- Reading
- 9 min
- By
- Anlyon Team
Let an AI agent post in a user's Slack and file issues in their Linear
Each user connects their own Slack and Linear. Your agent files issues and posts there, under a daily limit per action and with their yes before each post.
- end users
- slack
- linear
- oauth
- per-user limits
- approvals
- vercel ai sdk
To let an AI agent post in a user's Slack and file issues in their Linear, have the user connect both accounts, then build the agent's tools from anlyon.forUser(userId) with actions from both packs. Each call uses that user's own connection, counts against that user's limit for that action, and a channel post waits for that user's yes.
Here is the scene. A user tells your assistant: "File the Safari login bug in Linear and post it in #web-team." The agent creates the issue in their Linear, then drafts the Slack post. A card in your app shows the exact text and channel. The user approves, the message lands, and both steps come back with receipts. This tutorial builds it with Next.js and the Vercel AI SDK. The model behind it is in the pillar guide on letting your AI agent act in your users' accounts.
What you will build
- Two connections per user, Slack and Linear, each through its own connect link and one shared callback.
- One tool set spanning both packs: list Linear teams, search and create issues, post a Slack message.
- A daily limit per action per user: 15 Linear issues and 25 Slack posts.
- An approval, decided by the user, on every Slack channel post. Issue creation goes straight through.
- Handling for
reconnect_requiredwhen a user revokes access or a token stops refreshing.
Prerequisites
- A Next.js app (App Router) with sign-in, the AI SDK (
ai5 or later) and a model provider. - An Anlyon workspace with an operator key (
actions:write,impact-limits:write) and an app key: Standard preset plusend-users:writeandend-users:approvals, with Require an end user on. - A Slack workspace and a Linear workspace to test with.
npm install @anlyonhq/sdk @anlyonhq/ai-sdk ai @ai-sdk/react @ai-sdk/openai
1. Install the Slack and Linear packs
export ANLYON_API_KEY=$ANLYON_OPERATOR_KEY
npx @anlyonhq/cli packs install slack --yes
npx @anlyonhq/cli packs install linear --yes
You get slack-post-message, slack-update-message, slack-add-reaction and the channel topic and purpose actions, plus linear-list-teams, linear-search-issues, linear-create-issue, linear-assign-issue, linear-move-issue and more. Every write declares one resource_mutations and a read-back. Each request sends {{connection:slack}} or {{connection:linear}}, which resolves to the calling user's own token. See the packs docs.
2. Connect two accounts per user
One route makes a connect link for either integration. An allowlist keeps it to the two you support.
// app/connect/[integration]/route.ts
import { anlyon } from '@/lib/anlyon';
import { auth } from '@/lib/auth';
const SUPPORTED = new Set(['slack', 'linear']);
export async function GET(request: Request, { params }: { params: Promise<{ integration: string }> }) {
const session = await auth();
if (!session) return Response.redirect(new URL('/login', request.url));
const { integration } = await params;
if (!SUPPORTED.has(integration)) return new Response('Unknown integration.', { status: 404 });
const { data } = await anlyon.endUsers.createConnectLink({
endUserId: session.user.id,
integration,
redirectUrl: 'https://app.example.com/connections/callback',
});
return Response.redirect(data!.url);
}
// app/connections/callback/route.ts
import { parseConnectRedirect } from '@anlyonhq/sdk';
import { anlyon } from '@/lib/anlyon';
import { auth } from '@/lib/auth';
export async function GET(request: Request) {
const session = await auth();
if (!session) return Response.redirect(new URL('/login', request.url));
const result = parseConnectRedirect(request.url);
if (result?.status === 'pending_confirmation' && result.flowId) {
await anlyon.forUser(session.user.id).connections.confirm(result.flowId);
}
return Response.redirect(new URL(`/settings/connections?done=${result?.integration ?? ''}`, request.url));
}
lib/anlyon.ts exports new Client({ apiKey: process.env.ANLYON_API_KEY! }). The confirm uses the user in your session, so a forwarded link cannot attach someone else's Slack to this user: it answers 403 END_USER_MISMATCH and connects nothing.
A connections page reads both at once:
const { data: connections } = await anlyon.endUsers.listConnections(session.user.id);
// [{ integration: 'slack', status: 'active', scopes: [...] }, { integration: 'linear', status: 'needs_reconnect', ... }]
Each entry carries status: active, needs_reconnect or revoked. Never a token.
How the Slack connection works. Connecting Slack installs Anlyon's Slack app in the user's workspace with chat:write, channels:read, channels:history, channels:write.topic and the reaction scopes. Messages come from that app, not under the user's own name, and it posts in public channels it has been added to. Ask users to add the app to the channels they want it in.
3. One tool set across both packs
// app/api/chat/route.ts
import { openai } from '@ai-sdk/openai';
import { anlyonTools } from '@anlyonhq/ai-sdk';
import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from 'ai';
import { anlyon } from '@/lib/anlyon';
import { auth } from '@/lib/auth';
export async function POST(request: Request) {
const session = await auth();
if (!session) return new Response('Sign in.', { status: 401 });
const { messages }: { messages: UIMessage[] } = await request.json();
const tools = await anlyonTools({
client: anlyon.forUser(session.user.id),
actions: [
'linear-list-teams',
'linear-search-issues',
'linear-create-issue',
'slack-post-message',
],
});
const result = streamText({
model: openai('gpt-4o'),
system:
'You file Linear issues and post updates in Slack for the user. Search Linear before creating an issue. ' +
'Slack channels are ids such as C0123ABCDE. If a tool says to connect or reconnect, point the user to the button. ' +
'If a post is pending approval, say so and wait.',
messages: await convertToModelMessages(messages),
tools,
stopWhen: stepCountIs(8),
});
return result.toUIMessageStreamResponse();
}
Each tool takes the pack's own input schema. linear-create-issue takes team_id (from linear-list-teams), title, and optionally description, priority, assignee_id, state_id, label_ids and due_date. slack-post-message takes channel, a Slack channel id matching ^[CG][A-Z0-9]{8,12}$, and text of up to 3,000 characters. No tool has a user field, so a prompt cannot move the call to another user's account.
4. A limit per action, per user
// scripts/setup-anlyon.ts
import { Client, SignOffRequiredError } from '@anlyonhq/sdk';
const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });
await ops.impactLimits.create({
name: 'Linear issues per user per day',
dimension: 'resource_mutations',
period: 'day',
amount: 15,
scope: 'end_user',
actionName: 'linear-create-issue',
});
await ops.impactLimits.create({
name: 'Slack posts per user per day',
dimension: 'resource_mutations',
period: 'day',
amount: 25,
scope: 'end_user',
actionName: 'slack-post-message',
});
Each limit is counted and reserved before the call, per user, per UTC day. One user's busy afternoon cannot spend another user's allowance, and a looping agent stops at 25 posts with nothing past that reaching Slack. More in per-user limits for AI agents.
5. Ask the user before the agent posts to a channel
try {
await ops.approvalPolicies.create({
name: 'User approves Slack channel posts',
matchKind: 'action',
matchActionName: 'slack-post-message',
effect: 'require_approval',
decidedBy: 'end_user',
});
} catch (error) {
if (!(error instanceof SignOffRequiredError)) throw error;
console.log('A workspace admin approves this policy at', error.signOffUrl);
}
The pack's post-message schema takes a channel id matching ^[CG][A-Z0-9]{8,12}$ and has no way to address a person directly, so every post is a channel post and this policy covers all of them. linear-create-issue has no policy and runs at once.
Want fewer prompts? Add a CEL condition on the input. This one asks the user for posts that notify a whole channel and lets ordinary updates through:
condition: "input.text.contains('<!channel>') || input.text.contains('<!here>') || input.text.contains('<!everyone>')",
Do not try to tell public from private channels by the id's opening letter: Slack gives newer private channels C ids too.
A policy decided by the user, rather than your team, counts as loosening a guardrail, so created from an API key it waits for a workspace admin's sign-off in the console. Created by an admin in the console under Controls → Policies, it applies straight away.
The post tool now returns status: 'pending_approval' with an approvalId. Your app lists it with anlyon.endUsers.approvals.list(userId, { status: 'pending' }), shows payload.input.channel and payload.input.text, and records the answer with anlyon.endUsers.approvals.decide(userId, approvalId, { decision }). The approval is bound to a digest of the exact request: the text the user read is the text that posts. The end-user approval guide has the card and the routes.
6. Handle reconnect_required
A user can remove the Slack app or revoke Linear's access at any time, and a refresh can fail. The next call then returns this, with nothing sent:
{ "status": "reconnect_required", "integration": "slack", "displayName": "Slack", "connectUrl": "https://...", "message": "The user's Slack connection has stopped working, so nothing was done. Ask them to reconnect it, then try again." }
Render it as a button, next to the never-connected case:
if (output.status === 'connect_required' || output.status === 'reconnect_required') {
const verb = output.status === 'connect_required' ? 'Connect' : 'Reconnect';
return (
<a href={output.connectUrl ?? `/connect/${output.integration}`}>
{verb} {output.displayName}
</a>
);
}
connectUrl is a fresh link for this user that works once, for 10 minutes, and comes back through the same callback. It is null when the environment has no default redirect URL, which is why the fallback points at your step 2 route. To catch it before the agent runs, check listConnections for needs_reconnect and show the button on the connections page.
Test it
- Connect Linear but not Slack. Ask the assistant to file and post.
linear-create-issuereturnsstatus: 'succeeded',grade: 'confirmed': Anlyon read the issue back by id and its title and team matched.slack-post-messagereturnsconnect_requiredwith aconnectUrl. - Connect Slack and add the app to
#web-team. Ask again for the post. It returnspending_approvalwith anapprovalId. - Approve in your app. The receipt is
succeeded, gradedconfirmedwhen the read-back found the message text in the channel. Deny instead: the invocation isdeniedand nothing is posted. - Revoke Linear's access in Linear's settings. The next
linear-create-issuereturnsreconnect_required, andlistConnectionsshowslinearasneeds_reconnect. - Post a 26th message for the same user today. The tool returns
status: 'failed',code: 'impact_limit_exceeded', receipt gradedrefused. Another user still posts.
List one user's receipts across both packs with anlyon.actions.effects({ endUserId: userId }). See querying one user's agent actions.
Limits
- Slack read-back rate. Until Anlyon's Slack app is listed in the Slack Marketplace, Slack allows the
conversations.historycall the read-back uses once a minute per workspace. A post whose read-back is refused for that reason staysacknowledged. If you bring your own Slack app, activate public distribution so other workspaces can install it; the same once-a-minute rate applies until it is listed. - Slack errors. Slack answers most errors with HTTP 200 and
ok: false, so a refused post is recordedsucceededand gradedacknowledged. The read-back is what keeps it fromconfirmed. Check the grade, not the status. - No provider idempotency. Neither Slack nor Linear dedupes. Anlyon never resends an
unknownwrite, so check the channel or search Linear before asking the agent to try again. - Outside Anlyon. A call your code makes with its own Slack or Linear token has no limit, no approval and no receipt.
Next steps
- Act for your users: the full model, including erase.
- Vercel AI SDK tools that act in each user's account: the complete chat page and approval card.
- Python: an AI agent that writes to each user's Notion, with FastAPI.
- Docs: Act for your users · Packs
Frequently asked questions
- How do I let an AI agent post in each user's own Slack workspace?
- Have each user connect Slack through an Anlyon connect link, confirm the connection on your server, then call slack-post-message with a client bound to that user, anlyon.forUser(userId). Anlyon posts with the token from that user's own install and reads the message back from the channel.
- Does the agent's Slack message appear under the user's own name?
- No. The Slack connection is an app install in the user's workspace, so messages appear from that app, in channels the app has been added to. The user decides where the app is and, with an approval policy, approves each channel post.
- How can an AI agent create Linear issues for each user?
- Install Anlyon's Linear pack, have each user connect Linear with OAuth, and call linear-create-issue with team_id and title from a forUser client. The issue is created in the user's own Linear workspace and read back by id to grade the receipt.
- What happens when a user revokes the agent's Slack or Linear access?
- The next call returns reconnect_required with a fresh connectUrl, and nothing is sent. listConnections shows that connection as needs_reconnect, so your app can show a Reconnect button before the agent tries.
