- Updated
- Reading
- 8 min
- By
- Anlyon Team
Let your users approve an AI agent's action inside your app
Your agent asks, your user says yes in your app, and the exact request they saw is what runs. One end_user policy, two Next.js routes, a React card.
- end users
- human in the loop
- approvals
- react
- next.js
To let your users approve an AI agent's action inside your app, create an approval policy with decidedBy: 'end_user' and show that user's pending approvals in your UI. The approval belongs to the user the call was made for, it is bound to the exact request, and approving it is what sends the call.
Here is the scene. Your sales assistant agent works in each customer's HubSpot. A customer types "we lost Acme, close it out". Moving a deal to closedlost is their call, not your ops team's, and not the model's. The agent asks. The customer sees "Move Acme deal to Closed lost" in your app with Approve and Decline. They approve, and that exact change is what reaches HubSpot.
Why should the user decide, not your ops team?
An ops-inbox approval asks someone on your team. That works for actions in your own accounts: a refund from your Stripe, a deploy to your servers. It is the wrong person for an action in a customer's account.
- The user owns the account. Your ops team cannot tell whether closing that deal is right. The customer can.
- It does not scale. Ten thousand users each closing a few deals a week is not a queue a team can review. It becomes a rubber stamp; see why approval prompts become a rubber stamp.
- The user is already there. They are in your app, in the conversation where they asked for it.
Team approval (decidedBy: 'team') | User approval (decidedBy: 'end_user') | |
|---|---|---|
| Who decides | Someone on your team | The user the call was made for |
| Where | Anlyon console, Slack, or a key with approvals:decide | Your app, through your server or the React components |
| Shows in the team inbox | Yes | No. The console shows it under "Waiting on your users", to view, not decide |
| Approvers needed | requiredApprovals from the policy | One decision |
| A call made with no user | Team decides | Team decides |
How do you require the user's approval for one kind of call?
An operator creates the policy once. The condition is CEL over the call's input. Here, any deal stage change to won or lost waits for the user.
// setup.ts, run with an operator key. Never the agent's key.
import { Client } from '@anlyonhq/sdk';
const ops = new Client({ apiKey: process.env.ANLYON_OPERATOR_KEY! });
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.
A stage change to contractsent runs at once. A change to closedlost parks. A condition that errors at run time, for example on a missing field, requires approval rather than skipping, so guard optional fields with has().
The HubSpot pack ships a similar suggested policy, installed disabled and decided by your team. For the user to decide, create the policy above with decidedBy: 'end_user'.
What does the agent see?
The agent's call is made with a client your server binds to the signed-in user, so the approval knows whose it is.
const { data } = await anlyon
.forUser(session.user.id)
.actions.invoke('hubspot-update-deal-stage', { dealId: '8812', dealstage: 'closedlost' });
if (data!.pendingApproval) {
// data!.approvalId: 'apr_...'. Nothing has been sent to HubSpot.
}
With the Vercel AI SDK adapter, the tool returns status: 'pending_approval' and the approvalId, with a message telling the model it will run once approved and not to call it again. After the user decides, the model reads the outcome with the anlyon_check_request tool the adapter adds.
How do you list and decide approvals in a Next.js app?
Two routes. Both take the user from your session and pass it on every call. The key needs the end-users:approvals scope.
// app/api/approvals/route.ts
import { anlyon } from '@/lib/anlyon';
import { requireSession } from '@/lib/auth';
export async function GET() {
const user = await requireSession();
const { data } = await anlyon.endUsers.approvals.list(user.id, { status: 'pending' });
return Response.json(data ?? [], { headers: { 'Cache-Control': 'no-store' } });
}
// app/api/approvals/[id]/decide/route.ts
import { anlyon } from '@/lib/anlyon';
import { requireSession } from '@/lib/auth';
export async function POST(request: Request, { params }: { params: Promise<{ id: string }> }) {
const user = await requireSession();
const { id } = await params;
const { decision } = (await request.json()) as { decision: 'approve' | 'deny' };
const { data } = await anlyon.endUsers.approvals.decide(user.id, id, { decision });
// On approve, data.invocation is the call that ran; data.invocation.effectId is its receipt.
return Response.json({
status: data!.approval.status,
result: data!.invocation?.status ?? null,
grade: data!.invocation?.grade ?? null,
});
}
The approval id comes from the browser, and that is fine. The user does not. An approval that belongs to someone else answers 404, so a user who guesses or forwards an id gets nothing.
To show the receipt after approval, read it with the same user's client: await anlyon.forUser(user.id).actions.effect(effectId) returns the outcome and its grade. A HubSpot write declares a read-back, so a successful change grades confirmed.
How do you build the approval card in React?
The list returns each approval with a title, a description and a payload that carries actionName and the input that will be sent. Show the input as the user would read it, not as JSON, and keep the agent's reasoning out of the card. What the user is approving is the request.
'use client';
import { useEffect, useState } from 'react';
type Pending = { id: string; payload: { actionName?: string; input?: Record<string, unknown> }; expiresAt: string };
const STAGES: Record<string, string> = { closedwon: 'Closed won', closedlost: 'Closed lost' };
export function DealApprovals() {
const [items, setItems] = useState<Pending[]>([]);
const [busy, setBusy] = useState<string | null>(null);
useEffect(() => {
fetch('/api/approvals').then((r) => r.json()).then(setItems);
}, []);
async function decide(id: string, decision: 'approve' | 'deny') {
setBusy(id);
await fetch(`/api/approvals/${id}/decide`, { method: 'POST', body: JSON.stringify({ decision }) });
setItems((all) => all.filter((a) => a.id !== id));
setBusy(null);
}
return (
<ul>
{items.map((a) => {
const input = a.payload.input ?? {};
return (
<li key={a.id}>
<p>
Move deal {String(input.dealId)} to {STAGES[String(input.dealstage)] ?? String(input.dealstage)}
</p>
<p>Expires {new Date(a.expiresAt).toLocaleString()}</p>
<button disabled={busy === a.id} onClick={() => decide(a.id, 'approve')}>Approve</button>
<button disabled={busy === a.id} onClick={() => decide(a.id, 'deny')}>Decline</button>
</li>
);
})}
</ul>
);
}
In a real app, look up the deal name with a read action such as hubspot-get-deal and show it beside the id. What a reviewer should see on an approval screen covers the rest.
Can you use a prebuilt approval inbox instead?
Yes. @anlyonhq/react ships ApprovalInbox, ApprovalPrompt and the hooks under them. The browser gets a short-lived session token for one user, minted by your server with endUsers.createSession. The token can list and decide that user's approvals and manage their connections. It cannot call actions, read secrets or see another user. The package is at version 0.1, so its API can change before 1.0.
The server route uses createSessionHandler, which calls createSession for whoever is signed in:
// app/api/anlyon/session/route.ts
import { createSessionHandler } from '@anlyonhq/react/server';
import { anlyon } from '@/lib/anlyon';
import { auth } from '@/lib/auth';
export const POST = createSessionHandler({
client: anlyon,
getEndUserId: async () => (await auth())?.user.id,
});
Then the provider and the inbox:
'use client';
import { AnlyonProvider, ApprovalInbox } from '@anlyonhq/react';
import '@anlyonhq/react/styles.css';
export function Approvals({ userId }: { userId: string }) {
return (
<AnlyonProvider
key={userId}
getToken={() => fetch('/api/anlyon/session', { method: 'POST' }).then((r) => r.json())}
>
<ApprovalInbox
title="Waiting for you"
approvalTitle={(a) => (a.actionName === 'hubspot-update-deal-stage' ? 'Change a deal stage' : undefined)}
onDecided={(decision, approval) => console.log(decision, approval.id)}
/>
</AnlyonProvider>
);
}
For an approval inside a chat, render <ApprovalPrompt approvalId={result.approvalId} /> under the tool result that carries it. Each row opens to the action, its input, the preview and when it expires, with Approve and Decline. The token lasts 15 minutes by default (60 to 3600 seconds), and the provider fetches a new one before it expires.
What does "bound to the exact request" mean?
When the call parks, Anlyon computes a SHA-256 digest of the request, its target, the versions involved, its preconditions and its expiry. When the user approves, Anlyon recomputes that digest before dispatch. Any difference, and the call is refused. The agent cannot swap closedlost for closedwon between the card and the send, and an edit to the action definition after the card was shown does not ride through on the old yes. What is a bound approval? explains the binding in full.
Approving dispatches the call whether or not the agent is still running. The user does not have to keep the chat open.
Limits of this approach
- Your app renders the approval. Anlyon shows your users no screen of its own. You show the approval in your UI, or with the optional React components.
- The user is whoever your server names. Take the id from your session for
forUser,listanddecide, never from the request body or the model. - One decision, from one person. An end-user approval is decided by that user alone. For a step that also needs your team, use a separate team-decided policy.
- Approvals expire. An approval nobody answers in time ends as
expired, and the call does not run. The agent should say so and let the user ask again.
For the full end-user path, from connecting accounts to erasing a user, read how to let your AI agent act in your users' accounts. Ready to try it? Create the policy in a test environment from the Anlyon console, or follow the React components docs.
Frequently asked questions
- How do I make my AI agent ask the user for approval before it acts?
- Create an approval policy with decidedBy 'end_user' and a condition for the risky calls, and make the agent's calls with a client bound to that user. Matching calls wait for an approval that belongs to that user, and your app shows it to them to approve or deny.
- Can the user approve an AI agent action inside my own app instead of a separate dashboard?
- Yes. Your server lists the user's pending approvals with endUsers.approvals.list and records their answer with endUsers.approvals.decide. You can render them with your own components or with the prebuilt ApprovalInbox from @anlyonhq/react.
- What stops the agent from changing the request after the user approves it?
- The approval is bound to a SHA-256 digest of the exact request, target and expiry. Anlyon recomputes it before dispatch and refuses the call on any difference, so what the user approved is what runs.
- Can one user approve another user's pending agent action?
- No. Every list and decide call names the user, and an approval that belongs to someone else answers 404. Your server takes the user from its own session.
