Skip to content
Anlyon
Esc
↑↓navigate↵open⌘Jpreview
On this page

Workspace connections and end-user connections

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. 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.
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;
  }
}
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" } }'
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, 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.

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.
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" }'
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:

{
  "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.

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.

// 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));
}
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" }'
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.
{
  "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.

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, 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.
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:

{
  "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.
  • 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 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.

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);
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" }'
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.

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:
{ "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 use a short-lived session token that can connect accounts and decide approvals, never call actions.

Was this page helpful?