---
title: "Act for your users"
description: "Your users connect their own Gmail. Your agent sends email from it, under a daily limit for each user, after that user approves it in your app. Each send has a receipt. A complete Next.js and AI SDK walkthrough."
---

Your product has users. Your agent should act in **their** accounts, not yours. This guide builds that in a Next.js app with the Vercel AI SDK:

- A user asks your assistant to send an email.
- If they have not connected Gmail, the chat shows a **Connect Gmail** button.
- The send waits for **that user** to approve it, in your app.
- Each user can send at most 20 emails a day through your agent.
- After the send, the app shows the receipt.

Read [Workspace connections and end-user connections](/execution/end-user-connections) for the model behind it.

## Before you start

- A Next.js app (App Router) where people sign in. The code calls `auth()` for the signed-in session. Use whatever your auth library gives you.
- The AI SDK (`ai` v7) and a model provider. The code uses `@ai-sdk/openai`.
- An Anlyon workspace.

```bash
npm install @anlyonhq/sdk @anlyonhq/ai-sdk ai @ai-sdk/react @ai-sdk/openai
```

## How it fits together

- **Your server names the user.** It builds `anlyon.forUser(session.user.id)` from your own session. The model never sees or sets a user id.
- **Each user connects their own account.** Their Gmail is an end-user connection. It belongs to them, in one environment.
- **Anlyon holds the token.** Your app and the model never see it.
- **Limits, approvals and receipts are per user.** The limit counts each user on their own. The approval goes to the user it acts for.

## 1. Set up the environment

In the console, open **Integrations → Connect settings** and pick the environment.

1. **Default redirect URL:** `https://app.example.com/connections/callback`. This is the page users come back to after they connect. Connect links are refused without it, unless your server passes its own URL. Saving it also allows it.
2. **Your own Gmail app:** open **Integrations → Gmail → Sign-in**. Create an OAuth client in Google Cloud, register the redirect URI the console shows, then paste the client id and secret. Your users then go straight from the connect link to Google's consent screen, which shows your app's name, with no Anlyon page in between, and Google issues tokens to your app. Google needs your own app in production.
3. **Connect confirmation:** leave it on **Required**. Step 5 explains why.

## 2. Create the app's key

Open **Settings → API keys** and create a key for this environment.

- Turn on **Require an end user**. Every action call made with this key must name a user. The key can never use your workspace's own connections.
- Start from the **Standard** preset, which includes `actions:read` and `actions:invoke` to run actions, then add the privileged `end-users:write` (make connect links and confirm them) and `end-users:approvals` (list and decide the user's approvals).

Put it in `ANLYON_API_KEY` on your server. It never goes to the browser.

## 3. Declare the send action

Do this once, with an operator key or from your deploy script, not with the app's key.

```typescript
// scripts/setup-anlyon.ts
import { Client } from '@anlyonhq/sdk';

const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });

await ops.actions.create({
  name: 'gmail-send',
  description: 'Send one email from the user\'s own Gmail.',
  method: 'POST',
  urlTemplate: 'https://gmail.googleapis.com/gmail/v1/users/me/messages/send',
  // The user's own Gmail when the call names a user. Never your workspace's.
  headers: { Authorization: 'Bearer {{connection:gmail}}' },
  bodyTemplate: { raw: '{{input.raw}}' },
  inputSchema: {
    type: 'object',
    properties: {
      raw: {
        type: 'string',
        description: 'The whole email (To, Subject, a blank line, then the body), base64url-encoded.',
      },
    },
    required: ['raw'],
  },
  // One email per call, so a limit can count them.
  impact: { dimension: 'emails', amount: '1', bound: 'exact' },
});
```

Gmail takes the whole message as one `raw` string. The approval carries that exact string, so step 8 decodes it for the user to read.

## 4. Add the limit and the approval

**Limit.** Open **Controls → Limits → New limit**:

- Caps: A declared unit, `emails`. Amount: 20. Resets: Per day.
- Scope: **Each end user**. Each user gets 20 a day. One user cannot use up another's.
- Only this action: `gmail-send`.

Or over the API:

```json
POST /api/v2/impact-limits
{ "name": "Emails per user per day", "dimension": "emails", "period": "day", "amount": 20, "actionName": "gmail-send", "scope": "end_user" }
```

**Approval.** Open **Controls → Policies → Create**: action `gmail-send`, **A person must approve the call**, **Decided by: End user**. The approval goes to the user, not to your team's inbox. Over the API, the policy carries `"decidedBy": "end_user"`.

## 5. The callback that confirms a connection

When a user connects Gmail, Google sends them back to your default redirect URL with `anlyon_status=pending_confirmation`. The connection is not theirs yet. Your server confirms it for the user who is signed in.

```typescript
// 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) {
    // The user from your session, never one read from the URL.
    await anlyon.forUser(session.user.id).connections.confirm(result.flowId);
  }
  return Response.redirect(new URL('/chat', request.url));
}
```

Why this step exists: a connect link is a URL, and URLs get forwarded. Without the confirm, someone who opened another person's link could attach **their** Gmail to **that person's** account, and your agent would then send from the wrong mailbox. Confirming with the user from your own session means a connection always joins the person who started it. A flow started for another user answers `403 END_USER_MISMATCH`, and nothing is connected.

## 6. The chat route

```typescript
// lib/anlyon.ts
import { Client } from '@anlyonhq/sdk';

export const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! });
```

```typescript
// 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 for good. The tools carry no user field the model could change.
  const tools = await anlyonTools({
    client: anlyon.forUser(session.user.id),
    actions: ['gmail-send'],
  });

  const result = streamText({
    model: openai('gpt-4o'),
    system:
      'You help the user with email. To send one, call gmail-send. ' +
      'If a tool says the user must connect an account, tell them to use the button shown.',
    messages: await convertToModelMessages(messages),
    tools,
    stopWhen: stepCountIs(5),
  });

  return result.toUIMessageStreamResponse();
}
```

When the user has not connected Gmail, the tool does not throw. It returns:

```json
{ "status": "connect_required", "integration": "gmail", "connectUrl": "https://…", "message": "The user has not connected gmail yet, so nothing was done. Ask them to connect it, then try again." }
```

When the send waits for the user's approval, it returns `status: "pending_approval"` with an `approvalId`.

## 7. The chat page

The page renders the messages. A `connect_required` result becomes a **Connect Gmail** link. A pending approval shows the email and two buttons.

```tsx
// 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;
  integration?: string;
  connectUrl?: string | null;
  approvalId?: 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.connectUrl) {
              return (
                <a key={i} href={output.connectUrl}>
                  Connect Gmail
                </a>
              );
            }
            if (output.status === 'pending_approval' && output.approvalId) {
              return <ApprovalCard key={i} approvalId={output.approvalId} />;
            }
            return null;
          })}
        </div>
      ))}
      <form
        onSubmit={(event) => {
          event.preventDefault();
          sendMessage({ text: input });
          setInput('');
        }}
      >
        <input value={input} onChange={(event) => setInput(event.target.value)} />
      </form>
    </main>
  );
}
```

`connectUrl` is a fresh link that lasts 10 minutes and works once. It sends the user back to your default redirect URL, where step 5 confirms it.

## 8. Approve in your own UI

Prefer not to build this screen? [Components (beta)](/guides/components) has a prebuilt approval inbox and connect buttons for React.

Two routes on your server. Each acts for the signed-in user and no one else. The API answers `404` for an approval that belongs to anyone else.

```typescript
// 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 ?? []);
}
```

```typescript
// 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 user = anlyon.forUser(session.user.id);
  const { data } = await anlyon.endUsers.approvals.decide(session.user.id, id, { decision });

  // Approving runs the send. Read its receipt.
  const effectId = data?.invocation?.effectId;
  const receipt = effectId ? (await user.actions.effect(effectId)).data : null;
  return Response.json({
    status: data?.approval.status,
    receipt: receipt && { id: receipt.id, outcome: receipt.outcome, grade: receipt.grade, impact: receipt.impact },
  });
}
```

The card shows the email the agent wants to send, decoded from the exact request, and the receipt once it ran.

```tsx
// app/chat/approval-card.tsx
'use client';

import { useEffect, useState } from 'react';

type Approval = { id: string; title: string; payload: { input?: { raw?: string } } };
type Receipt = { id: string; outcome: string; grade: string; impact: { dimension: string; amount: number }[] };

/** Gmail's raw message, as the user will read it. */
function decode(raw: string | undefined): string {
  if (!raw) return '';
  const base64 = raw.replace(/-/g, '+').replace(/_/g, '/');
  const bytes = Uint8Array.from(atob(base64), (c) => c.charCodeAt(0));
  return new TextDecoder().decode(bytes);
}

export function ApprovalCard({ approvalId }: { approvalId: string }) {
  const [approval, setApproval] = useState<Approval | null>(null);
  const [result, setResult] = useState<{ status: string; receipt: Receipt | null } | null>(null);

  useEffect(() => {
    fetch('/api/approvals')
      .then((response) => response.json())
      .then((list: Approval[]) => setApproval(list.find((a) => a.id === approvalId) ?? null));
  }, [approvalId]);

  const decide = async (decision: 'approve' | 'deny') => {
    const response = await fetch(`/api/approvals/${approvalId}`, {
      method: 'POST',
      body: JSON.stringify({ decision }),
    });
    setResult(await response.json());
  };

  if (result?.receipt) {
    return (
      <section>
        <p>Sent. Receipt {result.receipt.id}: {result.receipt.outcome}, graded {result.receipt.grade}.</p>
      </section>
    );
  }
  if (result) return <p>Not sent.</p>;
  if (!approval) return null;

  return (
    <section>
      <p>Your assistant wants to send this email. Approve?</p>
      <pre>{decode(approval.payload.input?.raw)}</pre>
      <button type="button" onClick={() => decide('approve')}>Approve</button>
      <button type="button" onClick={() => decide('deny')}>Deny</button>
    </section>
  );
}
```

A Gmail send has no read-back, so its receipt is graded `acknowledged`: Gmail accepted it. See [Receipts and grades](/execution/outcomes).

## What happens at the limit

The 21st send of the day for one user is refused before anything reaches Gmail. Other users are not affected. See [When the limit is hit](/execution/when-the-limit-is-hit).

## Check it

- **Users** in the console lists each user with their connections, calls and pending approvals. A user's page can revoke a connection or erase the user.
- **Invocations**, **Receipts** and **Approvals** filter by end user, and show calls with your own accounts and calls for your users apart. **Controls → Approvals → Waiting on your users** lists what your users decide in your app, read-only.

## Not in this build

- The CLI does not act for end users. It uses workspace connections. For an agent that uses MCP, see [Over MCP](/execution/end-user-connections#over-mcp).
- A browser or edge runtime cannot act for a user on its own. Calls go through your server, which holds the key.
