- Updated
- Reading
- 12 min
- By
- Anlyon Team
Vercel AI SDK: tools that act in each user's own account
A Next.js and AI SDK tutorial: each user connects their own Linear, the agent files issues there under a per-user limit, and archiving waits for the user's yes.
- end users
- vercel ai sdk
- next.js
- linear
- oauth
- per-user limits
- approvals
To give a Vercel AI SDK agent tools that act in each user's own account, build the tools per request with anlyonTools({ client: anlyon.forUser(session.user.id) }). Each user connects their own account once, and every tool call runs with that user's connection, under that user's limit, and waits for that user's yes where a policy says so.
This tutorial builds it in a Next.js App Router app with Linear. A user asks the assistant to file a bug. If they have not connected Linear, the chat shows a Connect Linear button. Once connected, the agent files the issue in their own Linear workspace. Archiving an issue waits for the user to approve it in your app, and each step comes back with a receipt.
For the model behind this, read the pillar guide on letting your AI agent act in your users' accounts.
What you will build
- A connect route and a callback that attach each user's own Linear account to their user id in your app.
- A chat route whose tools are bound to the signed-in user: list teams, search issues, create an issue, archive an issue.
- A per-user limit: each user can create 10 Linear issues a day through your agent.
- A user-decided approval: archiving an issue waits for the user's own yes in your app.
- A chat UI that renders
connect_requiredas a Connect button andpending_approvalas an approval card, then shows the receipt.
Prerequisites
- A Next.js app (App Router) with sign-in. The code calls
auth()for the session. Use whatever your auth library gives you. - AI SDK 5 or later (
ai),@ai-sdk/reactand a model provider. The code uses@ai-sdk/openai. - An Anlyon workspace, an operator key with
actions:writeandimpact-limits:write, and a Linear account to test with.
npm install @anlyonhq/sdk @anlyonhq/ai-sdk ai @ai-sdk/react @ai-sdk/openai
1. Install the Linear pack
The Linear pack gives you Linear's reads and writes as named actions. Each write declares that it changes one record and carries a read-back. Install it once, with your operator key:
ANLYON_API_KEY=$ANLYON_OPERATOR_KEY npx @anlyonhq/cli packs install linear --yes
The actions install with the pack's prefix: linear-list-teams, linear-search-issues, linear-create-issue, linear-archive-issue and the rest. Their requests send Authorization: Bearer {{connection:linear}}. On a call made for a user, that resolves to that user's own Linear token. Your app never holds it.
The pack's suggested policy, "linear: approve archiving an issue", installs disabled. Step 2 turns it on and hands the decision to the user. See the packs docs for every install option.
2. Set the per-user limit and the user-decided approval
Run this once from a deploy script, with the operator key. The agent's key never gets these scopes.
// scripts/setup-anlyon.ts
import { Client, SignOffRequiredError } from '@anlyonhq/sdk';
const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });
// Each user gets 10 new Linear issues a day through your agent. One user cannot use up another's.
await ops.impactLimits.create({
name: 'Linear issues per user per day',
dimension: 'resource_mutations',
period: 'day',
amount: 10,
scope: 'end_user',
actionName: 'linear-create-issue',
});
// The pack's archive policy: enable it, and let the user the call is for decide it.
const { data: policies } = await ops.approvalPolicies.list({ actionName: 'linear-archive-issue' });
const archive = policies?.find((p) => p.name === 'linear: approve archiving an issue');
try {
await ops.approvalPolicies.update(archive!.id, { decidedBy: 'end_user', enabled: true });
} catch (error) {
if (!(error instanceof SignOffRequiredError)) throw error;
console.log('A workspace admin approves this change at', error.signOffUrl);
}
The limit is counted and reserved before each call. The 11th create of the day for one user is refused before anything reaches Linear, and other users keep their own 10. More in per-user limits for AI agents.
Moving a decision from your team to your users counts as loosening a guardrail. Made from an API key, the change waits for a workspace admin to sign off in the console, which is what SignOffRequiredError reports. Made by an admin in the console (Controls → Policies, Decided by: End user), it applies at once.
3. Create the app's key and client
In Settings → API keys, create a key for this environment from the Standard preset (actions:read, actions:invoke), then add end-users:write (connect links and confirms) and end-users:approvals (list and decide a user's approvals). Turn on Require an end user: every action call with this key must name a user, and it can never use your workspace's own connections.
// lib/anlyon.ts
import { Client } from '@anlyonhq/sdk';
export const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! });
The key stays on your server. A browser cannot act for a user on its own: calls go through your routes.
4. Send the user to connect Linear
A connect link opens Anlyon's consent page, which names your workspace, then Linear's own sign-in. It works once, for 10 minutes.
// app/connect/linear/route.ts
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 { data } = await anlyon.endUsers.createConnectLink({
endUserId: session.user.id,
integration: 'linear',
redirectUrl: 'https://app.example.com/connections/callback',
});
return Response.redirect(data!.url);
}
The redirectUrl must be one of the environment's allowed redirect URLs (Integrations → Connect settings). Set it as the default there and the tools can mint connect links on their own, as step 6 shows.
5. Confirm the connection in the callback
Linear sends the user back to your callback with anlyon_status=pending_confirmation. The connection does not exist until your server confirms it for the user in your own session.
// app/connections/callback/route.ts
import { EndUserMismatchError, 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) {
// declined or error: nothing was connected.
return Response.redirect(new URL('/chat?connect=failed', request.url));
}
try {
// The user from your session, never one read from the URL.
await anlyon.forUser(session.user.id).connections.confirm(result.flowId);
} catch (error) {
if (error instanceof EndUserMismatchError) {
return Response.redirect(new URL('/chat?connect=wrong-user', request.url));
}
throw error;
}
return Response.redirect(new URL('/chat?connect=ok', request.url));
}
The confirm is what stops a forwarded link from attaching someone else's Linear to your user. A flow started for another user answers 403 END_USER_MISMATCH and connects nothing.
6. Build the chat route with per-user tools
// 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();
// Bound to this user. No tool has a user field the model could fill in.
const tools = await anlyonTools({
client: anlyon.forUser(session.user.id),
actions: [
'linear-list-teams',
'linear-search-issues',
'linear-create-issue',
'linear-archive-issue',
],
});
const result = streamText({
model: openai('gpt-4o'),
system:
'You file and tidy Linear issues for the user. Search before you create, so you do not file a duplicate. ' +
'If a tool says the user must connect Linear, tell them to use the button shown. ' +
'If a tool says approval is pending, tell the user and wait for them.',
messages: await convertToModelMessages(messages),
tools,
stopWhen: stepCountIs(6),
});
return result.toUIMessageStreamResponse();
}
anlyonTools reads each action's own input schema, so the model sees team_id, title, description, priority and the rest exactly as the pack declares them. It also adds anlyon_check_request, which reads what happened to a call after the user decided, so the model never calls the action again to find out.
The tools never throw for the cases your UI cares about. They return one of these:
{ "status": "connect_required", "integration": "linear", "displayName": "Linear", "connectUrl": "https://...", "message": "The user has not connected Linear yet, so nothing was done. Ask them to connect it, then try again." }
{ "status": "pending_approval", "invocationId": "inv_...", "approvalId": "apr_...", "grade": null, "message": "Action \"linear-archive-issue\" needs human approval before it runs. ..." }
{ "status": "succeeded", "invocationId": "inv_...", "grade": "confirmed", "effectId": "eff_...", "endUserId": "user_42", "message": "..." }
A user whose Linear access was revoked, or whose token failed to refresh, gets reconnect_required with the same fields. connectUrl is null when the environment has no default redirect URL; send the user to your step 4 route in that case.
7. Render the Connect button and the approval card
In AI SDK 5 and later, a tool call arrives as a message part of type tool-<name>, with output once it has run.
// app/chat/page.tsx
'use client';
import { useChat } from '@ai-sdk/react';
import { useState } from 'react';
import { ApprovalCard } from './approval-card';
type ToolOutput = {
status?: string;
displayName?: string;
connectUrl?: string | null;
approvalId?: string | null;
grade?: string | null;
};
export default function Chat() {
const { messages, sendMessage } = useChat();
const [input, setInput] = useState('');
return (
<main>
{messages.map((message) => (
<div key={message.id}>
{message.parts.map((part, i) => {
if (part.type === 'text') return <p key={i}>{part.text}</p>;
if (!part.type.startsWith('tool-') || !('output' in part)) return null;
const output = part.output as ToolOutput;
if (output.status === 'connect_required' || output.status === 'reconnect_required') {
const label = output.status === 'connect_required' ? 'Connect' : 'Reconnect';
return (
<a key={i} href={output.connectUrl ?? '/connect/linear'}>
{label} {output.displayName ?? 'Linear'}
</a>
);
}
if (output.status === 'pending_approval' && output.approvalId) {
return <ApprovalCard key={i} approvalId={output.approvalId} />;
}
if (output.grade) return <small key={i}>Receipt: {output.grade}</small>;
return null;
})}
</div>
))}
<form
onSubmit={(event) => {
event.preventDefault();
sendMessage({ text: input });
setInput('');
}}
>
<input value={input} onChange={(event) => setInput(event.target.value)} />
</form>
</main>
);
}
8. Let the user decide, and show the receipt
Two server routes, each acting for the signed-in user. An approval that belongs to someone else answers 404.
// app/api/approvals/route.ts
import { anlyon } from '@/lib/anlyon';
import { auth } from '@/lib/auth';
export async function GET() {
const session = await auth();
if (!session) return new Response('Sign in.', { status: 401 });
const { data } = await anlyon.endUsers.approvals.list(session.user.id, { status: 'pending' });
return Response.json(data ?? []);
}
// app/api/approvals/[id]/route.ts
import { anlyon } from '@/lib/anlyon';
import { auth } from '@/lib/auth';
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const session = await auth();
if (!session) return new Response('Sign in.', { status: 401 });
const { id } = await params;
const { decision } = (await request.json()) as { decision: 'approve' | 'deny' };
const { data } = await anlyon.endUsers.approvals.decide(session.user.id, id, { decision });
// Approving runs the exact call the user saw. Read its receipt.
const effectId = data?.invocation?.effectId;
const receipt = effectId ? (await anlyon.forUser(session.user.id).actions.effect(effectId)).data : null;
return Response.json({
status: data?.approval.status,
receipt: receipt && { id: receipt.id, outcome: receipt.outcome, grade: receipt.grade },
});
}
// app/chat/approval-card.tsx
'use client';
import { useEffect, useState } from 'react';
type Approval = { id: string; title: string; payload: { input?: { issue_id?: string } } };
type Decided = { status: string; receipt: { id: string; outcome: string; grade: string } | null };
export function ApprovalCard({ approvalId }: { approvalId: string }) {
const [approval, setApproval] = useState<Approval | null>(null);
const [decided, setDecided] = useState<Decided | null>(null);
useEffect(() => {
fetch('/api/approvals')
.then((r) => r.json())
.then((list: Approval[]) => setApproval(list.find((a) => a.id === approvalId) ?? null));
}, [approvalId]);
const decide = async (decision: 'approve' | 'deny') => {
const r = await fetch(`/api/approvals/${approvalId}`, { method: 'POST', body: JSON.stringify({ decision }) });
setDecided(await r.json());
};
if (decided?.receipt) {
return <p>Archived. Receipt {decided.receipt.id}: {decided.receipt.outcome}, graded {decided.receipt.grade}.</p>;
}
if (decided) return <p>Not archived.</p>;
if (!approval) return null;
return (
<section>
<p>Your assistant wants to archive {approval.payload.input?.issue_id} in your Linear. Approve?</p>
<button type="button" onClick={() => decide('approve')}>Approve</button>
<button type="button" onClick={() => decide('deny')}>Deny</button>
</section>
);
}
The approval is bound to a digest of the exact request. If anything about the call changed between the card and the dispatch, it is refused, so what the user approved is what runs. Prefer prebuilt UI? @anlyonhq/react has ConnectButton and ApprovalInbox. The end-user approval guide covers both paths.
Test it
Sign in as a test user who has not connected Linear, open /chat, and run through these:
- "File a bug in the Web team: the Safari login button is misaligned." The tool returns
status: "connect_required"and the chat shows Connect Linear. Nothing was sent to Linear. - Click it, approve in Linear, land back on
/chat?connect=ok.anlyon.endUsers.listConnections(userId)now showslinearwithstatus: "active". - Ask again. The model calls
linear-list-teams, thenlinear-search-issues, thenlinear-create-issue. The create returnsstatus: "succeeded"andgrade: "confirmed": Anlyon read the issue back from Linear by id, and its title and team matched. - "Archive that issue." The tool returns
status: "pending_approval"with anapprovalId, and the card appears. Approve: the receipt readssucceeded, gradedconfirmed. Deny instead: the invocation isdeniedand Linear is untouched. - Create an 11th issue for the same user today. The tool returns
status: "failed"withcode: "impact_limit_exceeded", and its receipt is gradedrefused. A second test user can still create theirs.
In the console, Users lists each user with their connections, calls and pending approvals, and Controls → Approvals → Waiting on your users shows the archive request while it waits.
Limits
- Anlyon governs the calls that go through it. A Linear call your app makes with its own token has no limit and no receipt.
- Linear accepts two issues with the same title. If a create ends
unknown, Anlyon does not resend it; check Linear before asking the agent again. - A
confirmedgrade means the read-back of the provider's own record matched. It does not mean a person has seen the issue. - If your code passes a user id taken from model output into
forUser, you give back the protection the binding provides. Take it from your session.
Next steps
- Act for your users: the full model, with connections, limits, approvals, receipts and erase.
- Query one user's actions and receipts for a support screen or an audit.
- Let an AI agent post in a user's Slack and file issues in their Linear, with two connections per user.
- Let an AI sales assistant update deals in each rep's own HubSpot.
- Docs: Act for your users · Packs
Frequently asked questions
- How do I give a Vercel AI SDK agent tools that act in each user's own account?
- Build the tools with anlyonTools({ client: anlyon.forUser(session.user.id), actions: [...] }) inside your route handler. Each tool call then runs with that user's own connection, counts against that user's limit and waits for that user's approval when a policy asks for one.
- How does an AI SDK tool handle a user who has not connected their account?
- The tool does not throw. It returns status connect_required with a connectUrl, and your chat UI renders that as a Connect button. Nothing is sent to the provider until the user connects.
- Can the model switch which user's account an AI SDK tool acts in?
- No. The tools from @anlyonhq/ai-sdk have no user field in their input schema, and a forUser client cannot be switched to another user. Your server picks the user from its own session.
- How do I add user approval to an AI SDK tool call?
- Create an approval policy with decidedBy set to end_user for the action. The tool then returns status pending_approval with an approvalId, your app shows an approval card, and your server records the user's decision with endUsers.approvals.decide.
