---
title: "Workspace connections and end-user connections"
description: "Let each of your users connect their own account at a provider, name the user from your server on every call, and give each user their own limits, approvals and receipts."
---

Anlyon holds two kinds of connected account. Both are credentials Anlyon keeps and your agent never sees. They differ in whose account it is.

| | Workspace connection | End-user connection |
| --- | --- | --- |
| Whose account | Your company's | One of your users' |
| Belongs to | An environment | An environment, an integration and one end user |
| Connected by | Your team, in the console, the CLI or the API | The user, through a connect link your app sends them to |
| Used when | A call names no end user | A call names that end user |
| Limits | Counted for the environment | Counted for the environment, or for each user |
| Approvals | Decided by your team | Decided by your team, or by the user in your app |

Workspace connections are covered in [Connected accounts](/execution/connected-accounts). This page is about end-user connections.

## Why act in your users' own accounts through Anlyon

When your product's agent sends email from a user's mailbox or edits their CRM, three things go wrong if you build it yourself: tokens for thousands of users have to be stored and refreshed safely, one user's agent must never touch another user's account, and one runaway agent must not exhaust everyone's allowance or act without the user's consent.

- **Risk.** Your server, not the model, names the user on each call, and the tools the model sees have no user field, so a prompt cannot switch accounts. A connection is attached to a user only after your server confirms it for the person signed in, which stops a forwarded link from attaching someone else's account.
- **Cost.** A limit can give each user their own allowance ("20 emails a day per user"), so one user's agent cannot use up the others'. A user can approve their own agent's risky steps in your app, without your team in the loop.
- **Effort.** Anlyon hosts the connect pages and the provider sign-in, stores and refreshes each user's token, checks keys once a day, and answers a call for an unconnected user with a link to fix it. Erasing a user is one call.

## Name the user from your server

An **end user** is a person using your product. You name them with your own id, such as your user id: 1 to 128 characters of letters, digits and `. _ : @ -`. Anlyon stores it as given. It never signs your users in.

- Every call that acts for a user carries the header `Anlyon-End-User: <id>` and is made with your API key. The key stays on your server.
- The SDKs bind the user when the client is built: `anlyon.forUser(id)` in TypeScript, `client.for_user(id)` in Python. That client cannot be switched to another user.
- The tools you hand a model, through the AI SDK, LangChain or LlamaIndex adapters or the MCP server, have no user field. A prompt cannot make the agent act as someone else.
- A key can **require an end user**. It then refuses any call without the header (`400 END_USER_REQUIRED`), never uses a workspace connection, and lists and reads only its own end user's records. Use one for a product with many users. See [API keys](/authentication#keys-that-act-for-your-users).

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

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

export async function createIssueFor(userId: string, title: string) {
  const forUser = anlyon.forUser(userId); // from your own session, never from the model
  try {
    const { data } = await forUser.actions.invoke('github-create-issue', { owner: 'acme', repo: 'app', title });
    return { status: data!.status, grade: data!.grade };
  } catch (error) {
    if (error instanceof EndUserConnectionRequiredError) {
      return { connect: error.integration, connectUrl: error.connectUrl };
    }
    throw error;
  }
}
```

```bash curl
curl -X POST https://api.anlyon.com/api/v2/actions/github-create-issue/invoke \
  -H "Authorization: Bearer $ANLYON_API_KEY" \
  -H "Anlyon-End-User: user_123" \
  -H "Content-Type: application/json" \
  -d '{ "input": { "owner": "acme", "repo": "app", "title": "Login page is blank" } }'
```

```python Python
import os
from anlyon import Client, EndUserConnectionRequiredError

anlyon = Client(api_key=os.environ["ANLYON_API_KEY"])

def create_issue_for(user_id: str, title: str):
    for_user = anlyon.for_user(user_id)  # from your own session, never from the model
    try:
        result = for_user.actions.invoke("github-create-issue", {"owner": "acme", "repo": "app", "title": title})
        return {"status": result.data["status"]}
    except EndUserConnectionRequiredError as error:
        return {"connect": error.integration, "connect_url": error.connect_url}
```

`github-create-issue` stands for any action that uses `{{connection:github}}`, such as one you switch on from the GitHub integration.

## Choose whose credential a call uses

- In an action, `{{connection:github}}` is the user's own GitHub account when the call names a user, and the environment's GitHub account when it does not.
- A secret bound to an integration (a connected account referenced as `{{secret:NAME}}`) also resolves to the user's own account for that integration on a call that names a user.
- A secret with no integration, such as a key you stored with `secrets.put()`, is always your workspace's.
- A key that requires an end user never falls back to a workspace connection.

## Let a user connect their account

1. Your server makes a connect link for the user and the integration, or uses the `connectUrl` from a `409` (below).
2. The user opens it.
   - With [your own OAuth app](#use-your-own-oauth-app), the link goes straight to the provider's consent screen, which shows your app's name. The user sees no Anlyon page.
   - With Anlyon's shared app, a short Anlyon page comes first. It names **your workspace** and says the provider's screen will show Anlyon, connecting for your app. The user continues to the provider, or cancels and returns to your redirect URL with `anlyon_status=declined`.
   - For an integration that connects with an API key, the link opens a form on an Anlyon page where the user pastes their own key.
3. The provider sends the user back to your redirect URL with `anlyon_status=pending_confirmation`, `anlyon_flow` and `integration`. The end-user id is never in that URL.
4. Your server confirms the flow for the signed-in user. The connection is now active, and replaces the user's previous connection for that integration.

Creating links and confirming need `end-users:write`. No key receives it by default; it belongs on your server's key, not an agent's.

### Create the link

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

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

const { data: link } = await anlyon.endUsers.createConnectLink({
  endUserId: session.user.id,
  integration: 'github',
  redirectUrl: 'https://app.example.com/api/anlyon/connected',
});
// Send the user to link.url. It works once, for 10 minutes.
```

```bash curl
curl -X POST https://api.anlyon.com/api/v2/end-users/user_123/connect-links \
  -H "Authorization: Bearer $ANLYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "integration": "github", "redirectUrl": "https://app.example.com/api/anlyon/connected" }'
```

```python Python
link = anlyon.end_users.create_connect_link(
    end_user_id=user.id,
    integration="github",
    redirect_url="https://app.example.com/api/anlyon/connected",
).data
# Send the user to link["url"]. It works once, for 10 minutes.
```

It answers `201`:

```json
{
  "success": true,
  "data": { "url": "https://api.anlyon.com/api/v2/connection-consent/link?token=…", "expiresAt": "2026-10-27T09:10:00.000Z", "integration": "github" }
}
```

Treat `url` as opaque and send the user to it as given; its host and path are Anlyon's to choose.

- **A link lasts 10 minutes and works once.** Opening it does not use it up, so a chat or email preview leaves it working; it is used when the provider sends the user back. A key link takes up to 5 refused keys before it stops working.
- **It returns only to an allowed redirect URL.** Set the environment's **allowed redirect URLs** (up to 20) and an optional default under **Integrations → Connect settings** in the console. Each is an origin and a path prefix; a redirect matches when its origin is the same and its path is the entry's path or continues it at a `/`. `https` only, except `http` on `localhost`, `127.0.0.1` or `[::1]` for local work. A link cannot become an open redirect.
- **A redirect is required** while connect confirmation is on: pass `redirectUrl`, or set a default. With neither, the answer is `400 REDIRECT_URL_REQUIRED`. Confirmation can be turned off only on an environment whose kind is `development` and that is not protected (`400 VALIDATION_ERROR` otherwise); the user then sees an Anlyon page saying the account is connected, and it is theirs at once.
- **At most 10 links per minute per user, and 300 per environment** (`429` past either).

See [Create a connect link](/api-reference/end-users/create-end-user-connect-link).

### Confirm the connection in your callback

Anyone holding a link can finish its consent. If an attacker made a link for their own id and got your user to open it, your user's account would be attached to the attacker. So the grant stays unusable until your server confirms it, with the user from **your own session**, never from the URL.

```typescript TypeScript
// app/api/anlyon/connected/route.ts
import { Client, parseConnectRedirect } from '@anlyonhq/sdk';
import { auth } from '@/lib/auth';

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

export async function GET(request: Request) {
  const session = await auth();
  if (!session) return new Response('Sign in first', { status: 401 });

  const result = parseConnectRedirect(request.url);
  if (result?.status === 'pending_confirmation' && result.flowId) {
    await anlyon.endUsers.confirmConnection(session.user.id, result.flowId);
  }
  return Response.redirect(new URL('/settings/connections', request.url));
}
```

```bash curl
curl -X POST https://api.anlyon.com/api/v2/end-users/user_123/connections/confirm \
  -H "Authorization: Bearer $ANLYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "flowId": "flw_3kTq9ZbW2xYv8RmNcPd5LsA1hGfE7j" }'
```

```python Python
from anlyon import parse_connect_redirect

@app.get("/api/anlyon/connected")
def connected(request: Request, user: User = Depends(current_user)):
    result = parse_connect_redirect(str(request.url))
    if result and result.status == "pending_confirmation" and result.flow_id:
        anlyon.end_users.confirm_connection(user.id, result.flow_id)
    return RedirectResponse("/settings/connections")
```

Confirming answers `{ "integration": "github", "status": "active" }`. A flow is single use, and one nobody confirms is deleted after 10 minutes.

| Answer | Meaning |
| --- | --- |
| `403 END_USER_MISMATCH` | The flow was started for a different user. Nothing is connected, and the grant is revoked and deleted. |
| `404 CONNECT_FLOW_NOT_FOUND` | The flow was already confirmed, expired or never existed. Make a new link. |

`anlyon_status` can also be `declined` (the user cancelled) or `error`; nothing was connected.

### Store a key your app already holds

If your app already has the user's key for an API-key integration, store it with `endUsers.setConnection(userId, integration, { token })` (TypeScript), `end_users.set_connection(user_id, integration, token=...)` (Python) or `PUT /api/v2/end-users/{endUserId}/connections/{integration}`. It is checked with the provider first (`422 CONNECTION_KEY_REJECTED` if refused), replaces the user's previous key, and is never returned. No confirmation step: your server supplies the key. At most 10 keys per minute per user.

## Handle a user who has not connected

A call that needs an account the user has not connected is refused before an invocation exists and before anything is sent:

- `409 END_USER_NOT_CONNECTED`: the user never connected the integration.
- `409 END_USER_RECONNECT_REQUIRED`: the provider revoked access, a refresh failed for good, or the daily check found the user's key refused.

```json
{
  "success": false,
  "error": {
    "code": "END_USER_NOT_CONNECTED",
    "message": "This end user has not connected GitHub. Nothing was sent. Send them to connectUrl.",
    "integration": "github",
    "endUserId": "user_123",
    "connectUrl": "https://api.anlyon.com/api/v2/connection-consent/link?token=…",
    "expiresAt": "2026-10-27T09:10:00.000Z"
  },
  "requestId": "req_0123456789abcdef0123456789abcdef"
}
```

`connectUrl` is a fresh connect link on the environment's **default redirect URL**, a sign-in link or a key form as the integration needs. A retrying agent gets the same link while it has more than 3 minutes left. `connectUrl` is absent when the environment has no default redirect URL (make a link yourself with `createConnectLink`), or when the integration needs your own OAuth app and the environment has none.

In the SDKs, both codes raise `EndUserConnectionRequiredError` (subclasses `EndUserNotConnectedError` and `EndUserReconnectRequiredError`) with `integration`, `connectUrl` and `expiresAt`. With the AI SDK adapter, the tool returns `status: "connect_required"` or `"reconnect_required"` and the `connectUrl` instead of throwing, so your app can show a **Connect** button and the agent can tell the user what to do.

<a id="over-mcp"></a>

## Act for a user over MCP

A server-side agent that talks to Anlyon over MCP can act for one user too. The rules are the same: your server names the user, and no tool has a user argument.

- **Hosted:** connect to `https://api.anlyon.com/mcp`, or to one integration's [MCP server](/ai-tools/mcp-servers), with your API key and the `Anlyon-End-User` header. The user is fixed for that connection.
- **Your own process:** run `npx -y @anlyonhq/mcp-server --end-user <id>`, or set `ANLYON_END_USER`. Every call carries the header.

```typescript
import { Client } from '@modelcontextprotocol/sdk/client/index.js';
import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js';

const transport = new StreamableHTTPClientTransport(new URL('https://api.anlyon.com/mcp'), {
  requestInit: {
    headers: {
      Authorization: `Bearer ${process.env.ANLYON_API_KEY}`,
      'Anlyon-End-User': session.user.id,
    },
  },
});
const mcp = new Client({ name: 'my-agent', version: '1.0.0' });
await mcp.connect(transport);
```

The connection is refused when:

- the id is malformed: `400 INVALID_END_USER`.
- the key requires an end user and the header is missing: `400 END_USER_REQUIRED`.
- the header comes with an OAuth sign-in, such as Claude Code or Cursor after "Login with Anlyon": `400 END_USER_HEADER_NOT_ALLOWED`. Those connections act for you, with workspace connections.

When the user has not connected the account an action needs, the tool returns a result, not an error. The model can pass on `message`, and your app can show `connectUrl` as a **Connect** button:

```json
{
  "status": "connect_required",
  "integration": "github",
  "connectUrl": "https://api.anlyon.com/api/v2/connection-consent/link?token=…",
  "message": "The user has not connected github yet, so nothing was done. Ask them to connect it, then try again."
}
```

`reconnect_required` means the provider revoked access or a refresh failed. The same fields are in the result's `structuredContent`.

## Give each user their own limits, approvals and receipts

- **Limits.** A limit with the scope **Each end user** gives every user the whole amount: "each user's agent may send 20 emails a day". Calls without a user count against the environment. See [Impact limits](/execution/impact-limits).
- **Approvals.** A policy **decided by the end user** sends the approval to that user. It does not reach your team's inbox. Your app lists and decides it through the end-user approvals API, below. The console shows it read-only under **Approvals**, in **Waiting on your users**. The preview, the expiry and the receipt work as for any approval. Your [workspace defaults](/trust-control/workspace-defaults) decide who decides a user's call when no policy says.
- **Records.** Invocations, receipts, approvals and audit records carry the end user. Lists filter by it. Webhook and event payloads include it.
- **The kill switch** still stops everything, end-user calls included.

### Let a user decide their own approvals

Listing and deciding need `end-users:approvals`, which no key receives by default.

```typescript TypeScript
const { data: pending } = await anlyon.endUsers.approvals.list(session.user.id, { status: 'pending' });

// The user pressed Approve in your app:
const { data: decided } = await anlyon.endUsers.approvals.decide(session.user.id, pending![0].id, {
  decision: 'approve',
});
console.log(decided!.approval.status, decided!.invocation?.status);
```

```bash curl
curl "https://api.anlyon.com/api/v2/end-users/user_123/approvals?status=pending" \
  -H "Authorization: Bearer $ANLYON_API_KEY"

curl -X POST https://api.anlyon.com/api/v2/end-users/user_123/approvals/apr_01JABCDEF/decide \
  -H "Authorization: Bearer $ANLYON_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "decision": "approve" }'
```

```python Python
pending = anlyon.end_users.approvals.list(user.id, status="pending").data

decided = anlyon.end_users.approvals.decide(user.id, pending[0]["id"], decision="approve").data
```

Approving runs the parked call, and the answer carries the invocation it ran. An approval of another user, or one your team decides, answers `404`. To build these screens without writing them, use the [prebuilt React components](/guides/components).

## Use your own OAuth app

For each environment and sign-in integration, an admin can set your own OAuth client id and secret on the integration's page in the console, under **Integrations → Sign-in**. Register the redirect URI the page shows in your app. One app serves your own accounts and your users'. Your users then get a direct, fully branded flow: the link opens the provider's consent screen with your app's name, with no Anlyon page, and tokens are issued to your app. Google needs this in production, because its app review belongs to your app.

Where Anlyon offers its own app for an integration, people sign in with it until you add yours. Where it does not, the integration needs your app before anyone can connect it, and a link answers `400 OWN_OAUTH_APP_REQUIRED`. The integration's page says which. The client secret is stored encrypted and never returned.

## Remove a user's data

- **Disconnect one account:** `endUsers.deleteConnection(userId, integration)`, `end_users.delete_connection(user_id, integration)` or `DELETE /api/v2/end-users/{endUserId}/connections/{integration}`. Anlyon revokes access at the provider where the provider supports it, then deletes the connection. `404` when the user has no account for the integration.
- **Erase an end user:** `endUsers.erase(userId)`, `end_users.erase(user_id)` or `DELETE /api/v2/end-users/{endUserId}`. It revokes and deletes every connection they hold, cancels their pending approvals (the parked calls are never sent), clears their settled per-user limit counters, and refuses the session tokens issued to them so far. Invocations, receipts, approvals, function runs and audit records are kept for the audit trail, with the id replaced by a keyed one-way hash, returned as `erasedAs`:

```json
{ "success": true, "data": { "connectionsDeleted": 2, "approvalsCancelled": 1, "countersCleared": 3, "erasedAs": "erased~…" } }
```

Both are also in the console under **Users**. Each integration's page under **Integrations → Your users** lists the users connected to it. Both need `end-users:write`.

## Handle end-user errors

| Status and code | Meaning | What to do |
| --- | --- | --- |
| `400 INVALID_END_USER` | The id is not 1 to 128 characters of letters, digits and `. _ : @ -`. | Use your own stable user id. |
| `400 END_USER_REQUIRED` | The key requires an end user and the call named none. | Use `forUser()` or `for_user()`. |
| `400 END_USER_HEADER_MISMATCH` | The `Anlyon-End-User` header names another user than the path. | Send one user. |
| `400 REDIRECT_URL_REQUIRED` | No `redirectUrl` and no default redirect URL. | Pass one of the allowed redirect URLs, or set a default. |
| `400 OWN_OAUTH_APP_REQUIRED` | The integration signs in only through your own OAuth app. | Add your app on the integration's page. |
| `403 END_USER_MISMATCH` | A flow confirmed for another user. Nothing was connected. | Confirm with the user from your own session. |
| `404 CONNECT_FLOW_NOT_FOUND` | The flow was used, expired or unknown. | Make a new link. |
| `409 END_USER_NOT_CONNECTED`, `409 END_USER_RECONNECT_REQUIRED` | The user has no working account for the integration. Nothing was sent. | Send them to `connectUrl`. |
| `429 RATE_LIMIT_EXCEEDED` | More than 10 links or keys a minute for one user, or 300 links a minute in the environment. | Retry shortly. |

## Know what end-user connections do not cover

- The CLI does not act for end users. It uses workspace connections.
- A browser cannot call actions for a user on its own. Calls go through your server. The [prebuilt components](/guides/components) use a short-lived session token that can connect accounts and decide approvals, never call actions.

## Related

**[Act for your users](/guides/act-for-your-users)**

A full example, from the connect button to a governed call.

**[Components](/guides/components)**

Prebuilt React screens for connecting accounts and deciding approvals.

**[Impact limits](/execution/impact-limits)**

Give each user their own allowance.

**[API reference: End users](/api-reference/end-users/create-end-user-connect-link)**

Every end-user endpoint, with request and response schemas.
