Vercel AI SDK
Give an AI SDK model your Anlyon actions as tools with @anlyonhq/ai-sdk, bind them to one of your users, and handle connect, approval and refusal results in your app.
Your app’s assistant should send the email, book the meeting or update the CRM record, in each user’s own account. Doing it yourself means storing every user’s OAuth tokens, writing a tool per API, and hoping the model never sends the wrong thing or sends it twice.
@anlyonhq/ai-sdk turns your Anlyon actions into Vercel AI SDK tools. The model calls a tool with the action’s own input. Anlyon adds the credential at dispatch, so neither your app nor the model holds a provider token. Each call counts toward impact limits, waits for an approval when a policy says so, and returns a receipt grade. Bind the tools to a signed-in user with forUser(), and every call uses that user’s own connected account, limits and approvals.
This covers calls routed through Anlyon. A tool your own code calls directly is not governed by Anlyon.
Install
npm install @anlyonhq/ai-sdk @anlyonhq/sdk ai zod @ai-sdk/anthropic
- Needs
aiv7 (^7.0.0),zod(^3.25.76or^4.1.8) and Node 22 or later. The package is ESM only, asaiv7 is. - Any AI SDK provider works. The examples use
@ai-sdk/anthropic. - On
aiv4, stay on@anlyonhq/ai-sdk@1.x.
Create the key
Create a key in the console under Settings → API keys, for the environment your app runs in. Put it in ANLYON_API_KEY on your server. It never goes to the browser.
| What the tools do | Scope |
|---|---|
| List the actions to build the tools | actions:read |
| Run an action | actions:invoke |
Read a call’s outcome (anlyon_check_request) |
actions:read |
| Read the approval’s status in that result | approvals:read. Without it, the outcome still comes from the call |
All four are in the Standard preset. To act for your users, turn on Require an end user when you create the key: every action call must then name a user, and the key never uses your workspace’s own connections. Add end-users:write if your server makes connect links and confirms connections, and end-users:approvals if your app lists and decides the user’s approvals. Both are privileged. See Keys that act for your users.
Give the model one tool per action
anlyonTools() lists your actions and returns one tool per action, with the action’s own input schema and description, plus anlyon_check_request:
import { generateText, stepCountIs } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { Client } from '@anlyonhq/sdk';
import { anlyonTools } from '@anlyonhq/ai-sdk';
const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! });
const tools = await anlyonTools({
client: anlyon,
actions: ['update-ticket-priority'],
});
const { text } = await generateText({
model: anthropic('claude-sonnet-5-5'),
tools,
prompt: 'Set ticket T-1042 to priority high, then tell me what happened.',
stopWhen: stepCountIs(5),
});
| Option | Default | Meaning |
|---|---|---|
client |
Required | The client the tools call. A forUser() client acts for that user |
actions |
Every enabled action | The action names to expose. A name that does not exist or is disabled throws |
checkTool |
'anlyon_check_request' |
The name of the check tool. false leaves it out |
maxBodyChars |
8000 |
How much of the destination’s response a result carries |
An action’s tool name is its name with any character other than letters, digits, _ and - replaced by _, cut at 64 characters. Two actions that map to one name throw: pass actions to choose.
Without a forUser() client, calls use your workspace’s own connections and your team’s approvals.
Act for the signed-in user
Build the tools per request, from a client bound to the user in your own session:
// app/api/chat/route.ts
import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from 'ai';
import { anthropic } from '@ai-sdk/anthropic';
import { Client } from '@anlyonhq/sdk';
import { anlyonTools } from '@anlyonhq/ai-sdk';
import { auth } from '@/lib/auth';
const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! });
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 for good. No tool has a user field the model could set.
const tools = await anlyonTools({
client: anlyon.forUser(session.user.id),
actions: ['gmail-send'],
});
const result = streamText({
model: anthropic('claude-sonnet-5-5'),
tools,
// In AI SDK v7, convertToModelMessages is async.
messages: await convertToModelMessages(messages),
stopWhen: stepCountIs(5),
});
return result.toUIMessageStreamResponse();
}
forUser()sendsAnlyon-End-User: <id>on every request. The id is your own user id: 1 to 128 letters, digits and. _ : @ -. A malformed id throws before anything is sent.- A
forUser()client cannot be switched to another user. Build a new one from the base client per request. - The user’s limits, approvals and receipts are their own.
anlyon_check_requestreads only that user’s calls.
For the whole app, with the connect callback, an approval card and the receipt, follow Act for your users.
Read what a tool returns
Every action tool returns an object with a message the model relays and fields your code branches on:
| Field | Meaning |
|---|---|
status |
succeeded, failed, unknown, running, pending_approval, denied or expired |
invocationId |
The call, inv_... |
approvalId |
The approval, apr_..., while a person decides |
responseStatus, body, bodyTruncated |
The destination’s answer. body is cut at maxBodyChars with a …[truncated: N more characters] marker |
grade, effectId |
A governed action’s receipt grade (confirmed, acknowledged, …) and receipt id |
endUserId |
The user the call was made for, or null |
code |
Why the call was refused: impact_limit_exceeded, policy_denied, approval_denied or approval_expired. null when it was not |
limits |
For impact_limit_exceeded: [{ name, requested, available }] for each limit that had too little left |
requestId |
The API request’s id, for support |
What the model is told:
succeeded: the response, and the grade for a governed action.unknown: the request may have reached the destination. Do not call the action again; it has to be reconciled in Anlyon first. See When the outcome is unknown.- A refusal (
codeset): nothing was sent, and do not retry now.
Show a Connect button
When the user has not connected the integration an action needs, the tool does not throw. It returns:
{
"status": "connect_required",
"integration": "gmail",
"displayName": "Gmail",
"connectUrl": "https://api.anlyon.com/connect/...",
"endUserId": "user_42",
"message": "The user has not connected Gmail yet, so nothing was done. Ask them to connect it, then try again."
}
status is reconnect_required when the provider revoked access or a refresh failed. Nothing was sent in either case. Render connectUrl as a Connect Gmail link. It works once, for 10 minutes, and brings the user back to your redirect URL, where your server confirms the connection for the signed-in user:
import { parseConnectRedirect } from '@anlyonhq/sdk';
const result = parseConnectRedirect(request.url);
if (result?.status === 'pending_confirmation' && result.flowId) {
// The user from your session, never one read from the URL.
await anlyon.endUsers.confirmConnection(session.user.id, result.flowId);
}
connectUrl is null when the environment has no default redirect URL. Make a link with anlyon.endUsers.createConnectLink({ endUserId, integration: 'gmail', redirectUrl }) instead. See Workspace connections and end-user connections.
Handle a pending approval
A gated action returns status: 'pending_approval' with invocationId and approvalId. Nothing has run yet. The message tells the model not to call the action again, and to call anlyon_check_request with the invocationId once the user says they decided.
- Your UI shows the approval. With a policy decided by the end user, the user approves in your app, for example with
<ApprovalPrompt approvalId={result.approvalId} />from@anlyonhq/react(see Components), or through your own routes withanlyon.endUsers.approvals.decide(). Otherwise your team decides in the console or in Slack. - Approving runs the exact request that was reviewed. Denying or letting it expire means it is never sent.
- The model calls
anlyon_check_requestwith{ "invocationId": "inv_..." }. It returns the same fields as an action tool, plusactionandapprovalStatus, with a message for each case: approved and succeeded, declined, expired, still waiting, unknown outcome, or failed with the reason. A call that was just approved gets up to 6 seconds to finish; a call still waiting for a decision is answered at once. An id this client cannot see returnsstatus: 'not_found'.
To build the check tool on its own, use anlyonCheckRequestTool(client, { settleMs }).
Use five generic tools instead
createAnlyonTools(client) returns five tools that take an action name rather than one tool per action:
| Tool | What the model can do |
|---|---|
listActions |
See the actions, their input schemas and which need approval |
invokeAction |
Invoke an action by name: { name, input }. Returns the same connect_required result as above |
requestApproval |
Ask a person to decide before a step that is not an action |
checkApproval |
Read an approval’s status |
waitForApproval |
Wait for the decision inside the turn, 60 seconds by default, 120 at most a call |
import { Client } from '@anlyonhq/sdk';
import { createAnlyonTools } from '@anlyonhq/ai-sdk';
const client = new Client({ apiKey: process.env.ANLYON_API_KEY! });
const tools = createAnlyonTools(client);
These return sentences rather than structured results. Prefer anlyonTools() in an app: the model sees each action’s real schema, and your UI gets fields to branch on. checkApproval and waitForApproval need approvals:read, and requestApproval needs approvals:write.
Every invoke carries an idempotency key the SDK generates, so the SDK’s own retry after a network error, a timeout, or a 429, 500, 502, 503 or 504 is answered from the recorded call, not run again. A model that calls a tool twice makes two calls. To tie a call to your own operation, call client.actions.invoke() with your own idempotencyKey in a tool you write.
Handle errors
A refusal, a pending approval and a missing connection are results. These throw, from anlyonTools() or from a tool’s execute:
| Error | Why | What to do |
|---|---|---|
AuthenticationError (401) |
The key is unknown, revoked or expired | Check ANLYON_API_KEY |
AnlyonError with code AUTHORIZATION_ERROR (403): API key is missing required scope: ... |
The key lacks a scope in the table above | Add the scope |
EndUserRequiredError (400 END_USER_REQUIRED) |
The key requires an end user and the client was not built with forUser() |
Use anlyon.forUser(id) |
These actions do not exist or are disabled in this environment: ... |
A name in actions is wrong, or the action is off |
Check the action in the console, and that the key’s environment is the one you expect |
Two actions map to the tool name "..." |
Two action names sanitize to one tool name | Pass actions to choose one |
This client already acts for an end user and cannot be switched |
forUser() was called on a bound client |
Call it on the base client |
Related
- Act for your users: the complete Next.js app
- Workspace connections and end-user connections: who names the user and how connections work
- TypeScript SDK: the client these tools call
- Approvals: policies, including decided by the end user
- MCP servers: the same actions over MCP

