All guides
Updated
Reading
11 min
By
Anlyon Team

How to let your AI agent act in your users' accounts

Each user connects their own HubSpot, Slack or Notion. Your agent acts there under that user's limit, asks for their yes on risky steps, and keeps a receipt.

  • end users
  • oauth
  • per-user limits
  • approvals
  • multi-tenant agents

To let your AI agent act in your users' accounts, have each user connect their own account, then make every agent call with a client your server binds to that user. Anlyon holds the user's token, makes the call, counts it against that user's limit, waits for that user's yes on risky steps and keeps a receipt for that user.

This guide walks through the whole path with HubSpot as the example: connect, confirm, invoke, handle a user who has not connected, set a limit per user, let the user approve, read their receipts and erase them. The code is TypeScript, with one Python block. The setup docs are at docs.anlyon.com/guides/act-for-your-users.

What changes when your agent starts writing in a user's account?

Reading is easy. Your agent summarises a user's pipeline, drafts a Slack reply, lists their Linear issues. Then a customer asks for the next step: "move the Acme deal to contract sent", "post this in #sales", "file the bug". The agent goes from reading a user's account to writing in it, and three needs arrive in the same week.

  1. OAuth per user. Each user signs in to their own HubSpot or Slack. Their tokens have to be stored, encrypted, refreshed, and revoked when they leave.
  2. A limit per user. One user's runaway conversation should not post 400 Slack messages, and it should not use up the allowance of everyone else on your platform.
  3. The user's own yes. Closing a deal or archiving a contact is the user's call, in your app, not your ops team's call in a dashboard.

Each one is a project. Together they are weeks of work before the agent writes a single CRM field. Anlyon gives you all three behind one call, anlyon.forUser(userId).

What does the architecture look like?

The agent never holds the user's token. It names an action. Your server names the user. Anlyon makes the call.

Without Anlyon
  agent -> your code -> user's OAuth token (your DB, your refresh job) -> HubSpot API
  you build: OAuth per provider, token storage and refresh, a counter per user,
             an approval backend, an audit trail, a delete path

With Anlyon
  agent -> named action "hubspot-update-deal-stage"
        -> your server: anlyon.forUser(session.user.id)
        -> Anlyon: that user's connection, that user's limit, that user's approval
        -> HubSpot API
        -> receipt, graded from HubSpot's own record
NeedBuilt by handWith Anlyon
Connect each user's accountOAuth app, callback, token table, refresh job per providercreateConnectLink, then confirmConnection
Make the callYour process loads the token and calls the providerforUser(id).actions.invoke(name, input); Anlyon calls the provider
A limit per userCounter table, race handling, resetsOne impact limit with scope: 'end_user'
The user approvesApproval table, binding, expiry, dispatch after approvalA policy with decidedBy: 'end_user', two routes in your app
What happenedYour logsInvocations and receipts filtered by endUserId
User leavesFind and delete tokens, counters, pending workendUsers.erase(id)

How do you connect a user's account?

Your server creates a link for the signed-in user and the integration they want.

// app/connect/hubspot/route.ts
import { Client } from '@anlyonhq/sdk';
import { requireSession } from '@/lib/auth';

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

export async function GET() {
  const user = await requireSession();
  const { data } = await anlyon.endUsers.createConnectLink({
    endUserId: user.id,
    integration: 'hubspot',
    redirectUrl: 'https://app.example.com/connections/callback',
  });
  return Response.redirect(data!.url); // works once, for 10 minutes
}

For an OAuth integration the link opens a consent page that names your workspace, then the provider's own sign-in. With your own OAuth app configured for the environment, the provider's screen shows your app's name. For an API-key integration, such as Attio or Jira, the link opens a hosted form where the user pastes their key, and Anlyon checks it with the provider where the catalogue supports a check. Your app never sees the key. If your app already holds a user's key, store it directly with endUsers.setConnection(user.id, 'attio', { token }).

2. Confirm the connection on your server

The user comes back to redirectUrl with anlyon_status, anlyon_flow and integration in the query. Your callback confirms the flow for the user in your own session.

// app/connections/callback/route.ts
import { parseConnectRedirect } from '@anlyonhq/sdk';
import { anlyon } from '@/lib/anlyon';
import { requireSession } from '@/lib/auth';

export async function GET(request: Request) {
  const user = await requireSession();
  const result = parseConnectRedirect(request.url);
  if (result?.status === 'pending_confirmation' && result.flowId) {
    await anlyon.endUsers.confirmConnection(user.id, result.flowId);
  }
  return Response.redirect(new URL('/settings/connections', request.url));
}

Until this call, no connection exists. The confirm step is what stops a forwarded link from attaching one person's HubSpot to another person's account in your app: a flow started for a different user throws EndUserMismatchError (403 END_USER_MISMATCH) and connects nothing. Can a prompt make your agent act as another user? covers that check and the others.

3. Invoke actions for that user

Install the HubSpot pack once, from a deploy script or with anlyon packs install ./packs/hubspot --yes. Its actions install as hubspot-<action>, such as hubspot-update-deal-stage. The pack's request header uses {{connection:hubspot}}, so a call from a forUser client uses that user's HubSpot.

const result = await anlyon
  .forUser(user.id)
  .actions.invoke('hubspot-update-deal-stage', { dealId: '123', dealstage: 'contractsent' });

console.log(result.data!.status, result.data!.grade); // 'succeeded' 'confirmed'

With the Vercel AI SDK, hand the bound client to the adapter. Each action becomes a tool, and no tool's input schema has a user field.

// app/api/chat/route.ts
import { convertToModelMessages, stepCountIs, streamText, type UIMessage } from 'ai';
import { anlyonTools } from '@anlyonhq/ai-sdk';
import { anlyon } from '@/lib/anlyon';
import { model } from '@/lib/model';
import { requireSession } from '@/lib/auth';

export async function POST(request: Request) {
  const session = await requireSession();
  const { messages } = (await request.json()) as { messages: UIMessage[] };

  const tools = await anlyonTools({
    client: anlyon.forUser(session.user.id), // your session names the user, never the model
    actions: ['hubspot-search-deals', 'hubspot-update-deal-stage'],
  });

  const result = streamText({
    model,
    tools,
    messages: await convertToModelMessages(messages), // async in AI SDK v7
    stopWhen: stepCountIs(5),
  });
  return result.toUIMessageStreamResponse();
}

The Vercel AI SDK per-user tools guide goes deeper on the tool results. For HubSpot specifics, see HubSpot actions for each of your users, and for chat and issue trackers, Slack and Linear per user.

In Python, the same binding is for_user:

import os
from anlyon import Client, EndUserConnectionRequiredError

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

def move_deal(session_user_id: str, deal_id: str):
    user = anlyon.for_user(session_user_id)
    try:
        return user.actions.invoke(
            "hubspot-update-deal-stage", {"dealId": deal_id, "dealstage": "contractsent"}
        )
    except EndUserConnectionRequiredError as error:
        return {"connect_url": error.connect_url}

The Notion and Python guide builds a full agent this way.

4. Handle a user who has not connected yet

A user who never connected HubSpot gets EndUserNotConnectedError (409 END_USER_NOT_CONNECTED). A user whose token was revoked or failed to refresh gets EndUserReconnectRequiredError (409 END_USER_RECONNECT_REQUIRED). In both cases nothing was sent, and the error carries a fresh connectUrl.

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

try {
  await anlyon.forUser(user.id).actions.invoke('hubspot-update-deal-stage', input);
} catch (error) {
  if (error instanceof EndUserConnectionRequiredError) {
    return { connect: error.integration, url: error.connectUrl };
  }
  throw error;
}

The AI SDK tools do not throw here. They return status: 'connect_required' or 'reconnect_required' with connectUrl and a sentence the model can relay, so your chat UI can render a Connect button in place.

How do you put a limit on each user?

Create one impact limit with scope: 'end_user'. Each user gets the whole amount to themselves.

// ops holds an operator key with impact-limits:write. Never the agent's key.
await ops.impactLimits.create({
  name: 'CRM changes per user per day',
  dimension: 'resource_mutations',
  period: 'day',
  amount: 20,
  scope: 'end_user',
});

Every HubSpot pack write declares one resource mutation. The limit is reserved before the call, so a user's 21st change of the UTC day is refused before anything reaches HubSpot, and every other user still has their 20. The limit applies to governed actions, which every pack write is. Per-user limits for AI agents covers scopes, dimensions and how the agent should handle a refusal.

How does the user approve a risky step?

Create a policy with decidedBy: 'end_user'. The approval then belongs to the user the call was made for, and your app shows it to them.

await ops.approvalPolicies.create({
  name: 'User approves closing a deal',
  matchKind: 'action',
  matchActionName: 'hubspot-update-deal-stage',
  condition: "input.dealstage == 'closedwon' || input.dealstage == 'closedlost'",
  effect: 'require_approval',
  decidedBy: 'end_user',
});

Moving a decision from your team to the user counts as loosening a control, so when this policy is created with an API key, the call throws SignOffRequiredError and the policy waits until a workspace admin approves it at error.signOffUrl. An admin can also create it directly in the console under Controls → Policies. An agent can never loosen its own guardrails.

The invoke returns pendingApproval: true with an approvalId. Your server lists the user's pending approvals with anlyon.endUsers.approvals.list(user.id, { status: 'pending' }) and records their answer with anlyon.endUsers.approvals.decide(user.id, approvalId, { decision: 'approve' }). Approving runs the call. The approval is bound to a digest of the exact request, so what the user approved is what runs. Let your users approve an AI agent's action inside your app has the routes and a React card.

How do you see what the agent did for one user?

Every call made with forUser carries the user's id on its invocation, its receipt and its approval.

const { data: calls } = await anlyon.actions.invocations({ endUserId: 'user_42', status: 'unknown' });
const { data: receipts } = await anlyon.actions.effects({ endUserId: 'user_42', grade: 'confirmed', limit: 50 });

A receipt's grade says what is known: confirmed when a read-back of the provider's own record matched, acknowledged when the provider accepted and nothing read it back, unknown when there was no usable response. Anlyon does not resend an unknown call. failed, refused and denied mean it did not run. HubSpot pack writes declare a read-back, so a successful change grades confirmed. In the console, the Users page lists each user with connections, calls and pending approvals. Query your agent's actions per user has more queries.

How do you erase a user?

One call.

const { data } = await anlyon.endUsers.erase(user.id);
// { connectionsDeleted, approvalsCancelled, countersCleared, erasedAs }

Connections are revoked at the provider where it supports that, then deleted. Pending approvals are cancelled. Per-user limit counters are cleared. Kept records (invocations, receipts, approvals, audit) carry a keyed one-way hash in place of the user id. See erase a user's AI agent data for what is kept and why.

Which integrations can your users connect today?

How the user connectsIntegrations
Their own sign-in (OAuth)HubSpot, Slack, Linear, GitHub, Notion
Their own API key, on Anlyon's hosted form or set by your serverAttio, Asana, Airtable, ClickUp, Todoist, Trello, Monday, Jira, Zendesk, Intercom, Front, Pipedrive, Close, Mailchimp, Postmark, Resend, SendGrid, Twilio, Cal.com, Calendly, GitLab, Vercel, Sentry, PagerDuty, Discord, Stripe, Linear (key)

Gmail and Google Calendar are on the way. OAuth for AI agents explains how the sign-in path works and where tokens live.

What you don't build

  • An OAuth app, callback and consent screen for each provider.
  • A token table, encryption, a refresh job and revocation on disconnect.
  • A hosted form for users who connect with an API key.
  • A counter per user with reservation under concurrency.
  • An approval backend: storage, expiry, binding to the request, dispatch after a yes.
  • A receipt per call, graded from the provider's record.
  • A delete path across tokens, counters and pending work.

What you write: a connect route, a callback, forUser(session.user.id) where the agent runs, and the screen where your user approves. Comparing options? See agent auth platforms compared.

Limits of this approach

  • Your server has to name the user. Anlyon trusts the id your server sends. If your code passes a user id taken from model output into forUser, the binding means nothing.
  • Calls go through your server. A browser or edge runtime cannot act for a user alone. The React components use a short-lived session token that can connect accounts and decide approvals, and cannot invoke actions.
  • Limits and receipts cover governed actions. Pack writes are governed. A template action with no impact declaration has no limit and no receipt, and a call your code makes directly to a provider is outside Anlyon.
  • The MCP server and CLI do not act for end users in this build. Use the HTTP API, the TypeScript or Python SDK, or the AI SDK, LangChain and LlamaIndex adapters.

Connect a test user and run one action in a few minutes: start in the console, no credit card.

Frequently asked questions

How do I let my AI agent act on behalf of my users?
Have each user connect their own account through a connect link, confirm the connection on your server, then make the agent's calls with a client bound to that user, such as anlyon.forUser(session.user.id). The call runs with that user's account, counts against that user's limit and waits for that user's approval when a policy says so.
Do I need to build OAuth for every user and every provider myself?
Not with Anlyon. Anlyon runs the OAuth flow, keeps each user's tokens encrypted and refreshes them. Your server creates a connect link, confirms the connection when the user comes back, and never handles a token.
Can my AI agent use each user's own HubSpot or Slack account?
Yes. HubSpot, Slack, Linear, GitHub and Notion connect with the user's own sign-in, and more than twenty other tools, such as Attio, Asana, Jira and Stripe, connect with the user's own API key. The agent calls a named action and Anlyon makes the request with that user's connection.
Can a prompt make the agent act in a different user's account?
Not through Anlyon's tools when your server binds the user. The tools have no user field, and a client from forUser() cannot be switched to another user. If your code passes a user id taken from model output, you lose that protection.

Let your agent act for your users, safely.

A few lines of server code, then send a user a connect link. Nothing to host.

$ claude mcp add --transport http anlyon https://api.anlyon.com/mcp