---
title: "Vercel AI SDK"
description: "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."
icon: "triangle"
---

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](https://ai-sdk.dev) 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](/execution/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

```bash
npm install @anlyonhq/ai-sdk @anlyonhq/sdk ai zod @ai-sdk/anthropic
```

- Needs `ai` v7 (`^7.0.0`), `zod` (`^3.25.76` or `^4.1.8`) and Node 22 or later. The package is ESM only, as `ai` v7 is.
- Any AI SDK provider works. The examples use `@ai-sdk/anthropic`.
- On `ai` v4, stay on `@anlyonhq/ai-sdk@1.x`.

## Create the key

Create a key in the [console](https://console.anlyon.com) 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](/authentication#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`:

```typescript
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:

```typescript
// 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()` sends `Anlyon-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_request` reads only that user's calls.

For the whole app, with the connect callback, an approval card and the receipt, follow [Act for your users](/guides/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](/execution/when-the-outcome-is-unknown).
- **A refusal** (`code` set): 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:

```json
{
  "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:

```typescript
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](/execution/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.

1. 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](/sdks/react)), or through your own routes with `anlyon.endUsers.approvals.decide()`. Otherwise your team decides in the console or in Slack.
2. Approving runs the exact request that was reviewed. Denying or letting it expire means it is never sent.
3. The model calls `anlyon_check_request` with `{ "invocationId": "inv_..." }`. It returns the same fields as an action tool, plus `action` and `approvalStatus`, 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 returns `status: '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 |

```typescript
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](/guides/act-for-your-users): the complete Next.js app
- [Workspace connections and end-user connections](/execution/end-user-connections): who names the user and how connections work
- [TypeScript SDK](/sdks/typescript): the client these tools call
- [Approvals](/trust-control/approvals): policies, including **decided by the end user**
- [MCP servers](/agents/mcp-servers): the same actions over MCP
