---
name: anlyon-end-users
description: Builds product features where an AI agent acts in each end user's own accounts (for example GitHub or Notion) through Anlyon, with a limit per user, approvals that user decides inside the app, and a receipt per call. Use when code needs anlyon.forUser or client.for_user, the Anlyon-End-User header, connect links and the connect callback, connect_required or END_USER_NOT_CONNECTED handling, end-user approvals, end-user session tokens, or the @anlyonhq/react components (ApprovalInbox, ApprovalPrompt, ConnectButton, Connections, ConnectPrompt).
license: MIT
metadata:
  author: Anlyon
  version: "1.0.0"
  docs: https://docs.anlyon.com/skills/anlyon-end-users
---

# Acting for end users with Anlyon

Each user of the product connects their own account. The agent acts in that account through Anlyon actions. Anlyon holds the token, counts the user's limit, sends risky calls to that user for approval in the app, and records a receipt carrying the user's id.

## The trust model (never break these)

1. **The server names the user, from its own session.** Build the client with `anlyon.forUser(session.user.id)` (Python: `client.for_user(user.id)`). Never take the user id from a request body, a URL, a tool argument or model output. No tool you give the model has a user field.
2. **The API key stays on the server.** The browser gets, at most, a short-lived end-user session token.
3. **Confirm every new connection** in the callback route, for the user signed in to your app. A connect link can be forwarded; confirming with your own session is what stops someone attaching their account to another user.
4. **The production key has "Require a user" on.** A call without a user then fails loudly (`400 END_USER_REQUIRED`) instead of using the company's own accounts.

## Checklist

```
- [ ] 1. Console: redirect URL, own OAuth app, confirmation Required
- [ ] 2. Server key with end-user scopes, in ANLYON_API_KEY
- [ ] 3. Actions use {{connection:<integration>}}; per-user limit and end-user policy
- [ ] 4. Server client module
- [ ] 5. Connect callback route that confirms
- [ ] 6. Agent tools bound with forUser
- [ ] 7. UI for connect_required and pending_approval
- [ ] 8. Disconnect and erase
```

### 1. Environment (the user does this in the console)

**Integrations → Connect settings**, per environment:
- **Default redirect URL**, for example `https://app.example.com/connections/callback`. Saving it also allows it. A link with no redirect URL and no default is refused (`400 REDIRECT_URL_REQUIRED`); a `redirectUrl` outside the allowed list is `400 REDIRECT_URL_NOT_ALLOWED`.
- **Own OAuth app** for each sign-in integration (**Integrations → <integration> → Sign-in**): register the redirect URI the console shows. Users then see the app's own name on the provider's consent screen. An integration Anlyon does not offer with its own app needs yours, in development too: connecting answers `400 OWN_OAUTH_APP_REQUIRED` until one is set. An integration that cannot be connected at all is `400 VALIDATION_ERROR` ("No integration named … can be connected"). The console's **Integrations** page shows which are available.
- **Connect confirmation: Required.**

### 2. Key

Standard preset + `end-users:read` + `end-users:write` + `end-users:approvals`, **Require a user** on. Store it in `ANLYON_API_KEY` on the server. Never print or log it.

Such a key reads only the named user's invocations, receipts and approvals. Admin reads across users (`actions.invocations({ endUserId })`, the console's **Users** page) need a second key without **Require a user**.

### 3. Actions, limit, policy

An action reaches the user's account with `{{connection:<integration>}}`, for example `Authorization: Bearer {{connection:github}}`. With a user named it is that user's account; a key that requires a user never falls back to the workspace's. The quickest actions come from the integration's pack: switching on GitHub's operations under **Integrations → GitHub** in the console creates `github-create-issue` and others (see the `anlyon-governed-actions` skill for actions of your own), each declaring `impact: { dimension: 'resource_mutations', amount: '1' }` with a read-back.

Policies and limits are written with a separate operator key in a setup script, never with the server's key:

```typescript
const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! }); // policies:write, impact-limits:write

// Each user can open at most 20 issues a day
await ops.impactLimits.create({ name: 'Issues per user per day', dimension: 'resource_mutations', period: 'day', amount: 20, actionName: 'github-create-issue', scope: 'end_user' });

// The user approves each one, in your app
await ops.approvalPolicies.create({ name: 'User approves new issues', matchKind: 'action', matchActionName: 'github-create-issue', effect: 'require_approval', decidedBy: 'end_user' });
```

- A limit counts what an action **declares**: its `dimension` must equal the action's `impact.dimension`, or the limit counts nothing. Check with `ops.actions.get(name)`.
- `scope` is fixed once created.
- `decidedBy: 'end_user'` approvals go to the user, not the team's inbox.

### 4. Server client

```typescript
// lib/anlyon.ts  (server code)
import { Client } from '@anlyonhq/sdk';
export const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! });
```

`anlyon.forUser(id)` validates the id locally (1 to 128 of `A-Z a-z 0-9 . _ : @ -`; otherwise it throws `VALIDATION_ERROR` before any request) and returns a client bound to that user for good. It sends `Anlyon-End-User: <id>` on every request. Do not set that header yourself.

### 5. Connect callback

```typescript
// app/connections/callback/route.ts
import { parseConnectRedirect } from '@anlyonhq/sdk';
import { anlyon } from '@/lib/anlyon';
import { auth } from '@/lib/auth'; // the app's own session helper

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

`anlyon_status` can also be `connected`, `declined` or `error`. A flow started for another user throws `EndUserMismatchError` (`403 END_USER_MISMATCH`); a replayed or expired flow is `404 CONNECT_FLOW_NOT_FOUND`.

To start a connection yourself: `anlyon.endUsers.createConnectLink({ endUserId, integration: 'github', redirectUrl })` returns `{ url, expiresAt }`. A link works once, for 10 minutes.

### 6. Agent tools

With the Vercel AI SDK (`@anlyonhq/ai-sdk`, `ai` v7):

```typescript
import { anlyonTools } from '@anlyonhq/ai-sdk';

const tools = await anlyonTools({
  client: anlyon.forUser(session.user.id),
  actions: ['github-create-issue'],
});
// pass `tools` to streamText / generateText
```

One tool per action, plus `anlyon_check_request`. A user who has not connected gets a tool **result**, not an error: `{ status: 'connect_required', integration, connectUrl, message }` (or `reconnect_required`). Tell the model in the system prompt to pass that on and not to retry.

Without the AI SDK, catch the error:

```typescript
import { EndUserConnectionRequiredError } from '@anlyonhq/sdk';

try {
  const { data } = await anlyon.forUser(userId).actions.invoke('github-create-issue', input);
} catch (error) {
  if (error instanceof EndUserConnectionRequiredError) {
    // error.integration, error.connectUrl (null without a default redirect URL)
  } else throw error;
}
```

Over MCP: connect with the API key plus `Anlyon-End-User`, or run `npx -y @anlyonhq/mcp-server --end-user <id>` (or set `ANLYON_END_USER`). An OAuth MCP connection cannot act for a user (`400 END_USER_HEADER_NOT_ALLOWED`). The CLI uses workspace connections.

### 7. UI

- `connect_required` / `reconnect_required` → render `connectUrl` as a **Connect** button. When `connectUrl` is null, create a link on the server.
- `pending_approval` with `approvalId` → show the request and Approve / Deny, then tell the model the user decided so it calls `anlyon_check_request` with the `invocationId`. Never invoke the action again.

Two ways to build the approval UI:
- **Prebuilt React components** (`@anlyonhq/react`, beta): session route, provider, `<ApprovalPrompt>`, `<ApprovalInbox>`, `<ConnectPrompt>`. See [references/react-components.md](references/react-components.md).
- **Your own routes:** `anlyon.endUsers.approvals.list(userId, { status: 'pending' })` and `anlyon.endUsers.approvals.decide(userId, approvalId, { decision: 'approve' | 'deny', reason? })`. Approving runs the parked call; the response carries `invocation`, whose `effectId` is the receipt. See [references/server-routes.md](references/server-routes.md).

### 8. Disconnect and erase

- `anlyon.endUsers.deleteConnection(userId, 'github')` revokes at the provider where supported, then deletes.
- `anlyon.endUsers.erase(userId)` deletes every connection, clears the user's limit counters and cancels pending approvals. Receipts stay, with the id replaced by a one-way hash (`erasedAs`). Wire it into account deletion.

Python equivalents and the API paths: [references/server-routes.md](references/server-routes.md). Full walkthrough: https://docs.anlyon.com/guides/act-for-your-users.md.
