React components (beta)
Prebuilt React components and hooks from @anlyonhq/react: your end users approve what your agent wants to do, and connect their own accounts, inside your app. A short-lived session token keeps your API key on your server.
@anlyonhq/react gives you the screens Act for your users otherwise has you build by hand: an approval inbox, an inline approval, connect buttons and a connected-accounts list. They follow your shadcn/ui theme, or your own CSS variables.
This page documents @anlyonhq/react 0.1.0, a beta release. Its API may change before 1.0.
npm install @anlyonhq/react @anlyonhq/sdk
React 18 or 19. No runtime dependencies beyond React. The package has three entry points:
| Import | What it holds |
|---|---|
@anlyonhq/react |
The provider, the components, the hooks and helpers. Runs in the browser. |
@anlyonhq/react/server |
The two route handlers for your server. No React. |
@anlyonhq/react/styles.css |
The bundled styles. |
How it fits together
- Your server mints a session token for the user who is signed in, with your API key. The key never reaches the browser.
- The provider fetches the token from your route, keeps it in memory, and fetches a new one before it expires.
- The components call Anlyon from the browser with that token. It acts for that one user, in one environment.
A session token can:
- list the user’s connected accounts,
- create a connect link (it returns to one of your allowed redirect URLs, and nowhere else),
- disconnect one of the user’s accounts,
- list the user’s own approvals, and approve or decline them, when your key has
end-users:approvals.
Nothing else. It cannot call actions, read secrets, see other users, or confirm a connection. It lasts 15 minutes by default, at most 1 hour, and never past the key that minted it. Revoking that key ends its sessions. Each one is recorded in the audit log as end_user.session_created.
1. Set up the environment
In the console, open Integrations → Connect settings and pick the environment. Add your callback URL to the allowed redirect URLs, for example https://app.example.com/api/anlyon/connected.
In a browser, a session token works from the origins of these URLs and no others. A request from any other origin is refused with 403 ORIGIN_NOT_ALLOWED, so another website cannot use a token it gets hold of. Outside a browser there is no origin to check: like any bearer token, a copied token works until it expires, for that one user, and for nothing beyond the operations above. Keep the lifetime short. For local work, add your local callback, such as http://localhost:5173/api/anlyon/connected, to a separate environment.
Create an API key with end-users:write, and end-users:approvals for the approval components.
2. The session route
// app/api/anlyon/session/route.ts
import { Client } from '@anlyonhq/sdk';
import { createSessionHandler } from '@anlyonhq/react/server';
import { auth } from '@/lib/auth';
const anlyon = new Client({ apiKey: process.env.ANLYON_API_KEY! });
export const POST = createSessionHandler({
client: anlyon,
getEndUserId: async () => (await auth())?.user.id,
});
The user always comes from your own session, never from the request. The handler takes a web-standard Request and returns a Response, so Next.js route handlers, Remix, Hono, Bun and Deno take it as it is.
createSessionHandler(options) options:
| Option | Type | Default | Meaning |
|---|---|---|---|
client |
SessionMintingClient |
required | An @anlyonhq/sdk Client, or any object with endUsers.createSession(endUserId, { ttlSeconds }). Its key needs end-users:write, and end-users:approvals for the approval components. |
getEndUserId |
(request: Request) => string | null | undefined | Promise<string | null | undefined> |
required | The signed-in user’s id, from your own session. Return null or undefined when nobody is signed in. |
ttlSeconds |
number |
the API’s, 900 | The token’s lifetime, 60 to 3600 seconds. |
What it answers, always with Cache-Control: no-store:
| Status | Body | When |
|---|---|---|
200 |
{ token, expiresAt } |
A session was minted. <AnlyonProvider getToken> takes this JSON as it is. |
401 |
{ error } |
getEndUserId returned nothing. |
502 |
{ error } |
Anlyon did not mint a session. The reason goes to your server log, not to the browser. |
Without the helper, call endUsers.createSession yourself:
const { data } = await anlyon.endUsers.createSession(session.user.id, { ttlSeconds: 600 });
return Response.json({ token: data!.token, expiresAt: data!.expiresAt }, { headers: { 'Cache-Control': 'no-store' } });
session = client.end_users.create_session(user.id, ttl_seconds=600)
3. The connect callback
When a user connects an account, the provider sends them back to your callback URL. Your server confirms the connection for the user who is signed in, so a forwarded link cannot attach someone else’s account. The helper does that, then closes the popup or redirects.
// app/api/anlyon/connected/route.ts
import { createConnectCallbackHandler } from '@anlyonhq/react/server';
// `anlyon` and `auth` as in the session route.
export const GET = createConnectCallbackHandler({
client: anlyon,
getEndUserId: async () => (await auth())?.user.id,
redirectTo: '/settings/connections',
});
createConnectCallbackHandler(options) options:
| Option | Type | Default | Meaning |
|---|---|---|---|
client |
ConnectConfirmingClient |
required | An @anlyonhq/sdk Client, or any object with endUsers.confirmConnection(endUserId, flowId). |
getEndUserId |
(request: Request) => string | null | undefined | Promise<string | null | undefined> |
required | The signed-in user’s id, as in the session route. |
redirectTo |
string | ((outcome: { status: ConnectOutcomeStatus; integration: string | null }) => string) |
required | Where to send the browser afterwards in redirect mode. It gets anlyon_status and integration in its query. In popup mode the popup closes instead. |
ConnectOutcomeStatus is connected, declined or error. When Anlyon returns with anlyon_status=pending_confirmation, the handler confirms the flow for the user getEndUserId returns. With nobody signed in, or when the confirmation fails, the outcome is error. In popup mode the handler answers a small page that passes the outcome to your app’s tab, on the same origin, and closes itself. In redirect mode it answers 303 to redirectTo.
4. The provider
// app/providers.tsx
'use client';
import { AnlyonProvider } from '@anlyonhq/react';
import '@anlyonhq/react/styles.css';
export function Providers({ children }: { children: React.ReactNode }) {
return (
<AnlyonProvider
getToken={() => fetch('/api/anlyon/session', { method: 'POST' }).then((r) => r.json())}
baseUrl={process.env.NEXT_PUBLIC_ANLYON_BASE_URL} // unset: https://api.anlyon.com
connect={{ redirectUrl: 'https://app.example.com/api/anlyon/connected' }}
>
{children}
</AnlyonProvider>
);
}
AnlyonProvider props:
| Prop | Type | Default | Meaning |
|---|---|---|---|
getToken |
() => Promise<SessionTokenResult> |
required | Fetches a session token from your server. Return the JSON createSessionHandler answers with, or the token string. Called again a minute before the token expires, and after the API refuses one. |
baseUrl |
string |
https://api.anlyon.com |
Set it for another environment’s API host. |
connect |
{ mode?: 'popup' | 'redirect'; redirectUrl?: string } |
{ mode: 'popup' } |
How connect links open. A blocked popup falls back to a redirect. redirectUrl is your callback route from step 3; without it, the environment’s default redirect URL is used. |
pollInterval |
number |
30000 |
How often open approvals are checked while the page is visible, in ms. 0 turns it off. |
locale |
string |
the browser’s | For amounts and dates. |
children |
ReactNode |
Your app. |
SessionTokenResult is string | { token: string; expiresAt?: string | number | Date | null }. Without expiresAt, the provider reads the expiry from the token.
The provider holds one user’s session. When the signed-in user changes without a page load, remount it with that user’s id as its key, so nothing from the previous user stays on screen.
5. Put it on a page
With the two routes from steps 2 and 3 in place, this page shows a connect button and the user’s approvals:
// app/settings/page.tsx
'use client';
import { AnlyonProvider, ApprovalInbox, ConnectButton } from '@anlyonhq/react';
import '@anlyonhq/react/styles.css';
export default function SettingsPage() {
return (
<AnlyonProvider
getToken={() => fetch('/api/anlyon/session', { method: 'POST' }).then((r) => r.json())}
connect={{ redirectUrl: 'https://app.example.com/api/anlyon/connected' }}
>
<h2>GitHub</h2>
<ConnectButton integration="github" />
<ApprovalInbox title="Waiting for you" />
</AnlyonProvider>
);
}
In a larger app, put the provider in your layout once, as in step 4, and use the components on any page below it.
6. The components
Every component takes two more props, className and unstyled. See Your own styles.
Approval inbox
import { ApprovalInbox } from '@anlyonhq/react';
<ApprovalInbox />
<ApprovalInbox status="all" limit={50} title="Requests" />
Every approval waiting for this user, newest first. Each row opens to what the agent wants: the action in words, its input, the preview (fields, a diff, the amount), and when it expires. The user approves or declines, with an optional note.
| Prop | Type | Default | Meaning |
|---|---|---|---|
status |
'pending' | 'all' |
'pending' |
all includes answered and expired approvals. |
limit |
number |
20 |
How many to list, newest first. At most 100. |
title |
ReactNode |
'Approvals' |
The heading. null hides the header. |
approvalTitle |
(approval: Approval) => ReactNode | undefined |
Each row’s headline in your app’s words. Return undefined to keep the default, the action’s name in words. |
|
empty |
ReactNode |
Nothing to approve |
Shown when nothing waits for an answer. |
onDecided |
(decision: Decision, approval: Approval) => void |
Called after each answer lands. Decision is 'approve' | 'deny'. |
The user sees each action’s name and description as you wrote them, so write them for that person, not for your team. Use approvalTitle to give a row your own headline:
<ApprovalInbox
approvalTitle={(a) => (a.actionName === 'send-invoice' ? 'Send the invoice' : undefined)}
/>
Approvals reach the user when a policy is Decided by: End user. See Act for your users.
One approval
import { ApprovalPrompt } from '@anlyonhq/react';
<ApprovalPrompt approvalId={toolResult.approvalId} onDecided={(decision) => console.log(decision)} />
Use it inline, for example under the chat message whose tool call is waiting.
| Prop | Type | Default | Meaning |
|---|---|---|---|
approvalId |
string |
required | The approval to show, such as the approvalId a tool result carries. |
title |
ReactNode |
the action’s name in words | Replaces the headline. |
onDecided |
(decision: Decision, approval: Approval) => void |
Called after the user’s answer lands. |
Both approval components show each state as it is: waiting, sending, approved (with the action’s outcome), declined, expired, and failed with the API’s own message. If someone else decided before the user’s answer arrived, the component shows what actually happened.
Connect button
import { ConnectButton } from '@anlyonhq/react';
<ConnectButton integration="github" />
Opens the connect link in a popup. It shows Reconnect GitHub when the connection stopped working, and GitHub connected once it works.
| Prop | Type | Default | Meaning |
|---|---|---|---|
integration |
string |
required | The catalogue id, such as github. |
name |
string |
the catalogue’s, such as GitHub |
The integration’s name in labels. |
children |
ReactNode |
Connect GitHub |
Replaces the button label. |
showConnected |
boolean |
true |
Once connected, show GitHub connected. false renders nothing. |
mode |
'popup' | 'redirect' |
the provider’s | Overrides the provider’s connect.mode. |
redirectUrl |
string |
the provider’s | Overrides the provider’s connect.redirectUrl. |
onConnected |
(integration: string) => void |
Called once the account is connected, in popup mode. |
Connected accounts
import { Connections } from '@anlyonhq/react';
<Connections integrations={['github', 'slack', 'notion']} />
The user’s accounts with their status. Reconnect where needed. Disconnect asks before it acts.
| Prop | Type | Default | Meaning |
|---|---|---|---|
integrations |
string[] |
[] |
Integrations to offer before the user has connected them. |
title |
ReactNode |
'Connected accounts' |
The heading. null hides the header. |
empty |
ReactNode |
No connected accounts |
Shown when there is nothing to list. |
mode |
'popup' | 'redirect' |
the provider’s | Overrides the provider’s connect.mode. |
redirectUrl |
string |
the provider’s | Overrides the provider’s connect.redirectUrl. |
Connect prompt for AI SDK tool results
import { ConnectPrompt } from '@anlyonhq/react';
{message.parts.map((part) =>
part.type.startsWith('tool-') && part.state === 'output-available' ? (
<ConnectPrompt key={part.toolCallId} result={part.output} />
) : null,
)}
When an @anlyonhq/ai-sdk tool returns connect_required or reconnect_required, it renders a connect card. For any other result it renders nothing, so it can sit in every tool result’s slot.
| Prop | Type | Default | Meaning |
|---|---|---|---|
result |
unknown |
required | The tool result. |
name |
string |
the catalogue’s | The integration’s name in labels. |
mode |
'popup' | 'redirect' |
the provider’s | Overrides the provider’s connect.mode. |
redirectUrl |
string |
the provider’s | Overrides the provider’s connect.redirectUrl. |
onConnected |
(integration: string) => void |
Called once the account is connected, in popup mode. Send the user’s message again from there. |
Hooks
Each component is built on a hook, for your own markup. Every hook works inside an <AnlyonProvider>. Errors are AnlyonUIError, with the API’s status, code and message.
import { useApprovals, useConnect } from '@anlyonhq/react';
function ApprovalBadge() {
const { pendingCount, loading } = useApprovals();
return loading ? null : <span>{pendingCount} waiting</span>;
}
function ConnectGitHub() {
const { status, phase, error, connect } = useConnect('github');
if (status === 'connected') return <span>GitHub connected</span>;
return (
<>
<button onClick={() => void connect()} disabled={phase !== 'idle'}>
{status === 'needs_reconnect' ? 'Reconnect GitHub' : 'Connect GitHub'}
</button>
{error ? <p role="alert">{error.message}</p> : null}
</>
);
}
useApprovals(options?)
The user’s approvals, checked again while the page is visible.
| Option | Type | Default | Meaning |
|---|---|---|---|
status |
'pending' | 'all' |
'pending' |
all includes answered and expired approvals. |
limit |
number |
20 |
How many to list, newest first. At most 100. |
| Returns | Type | Meaning |
|---|---|---|
items |
ApprovalItem[] |
Newest first. Answers given on this page stay in the list, with their outcome, until the page reloads. |
pendingCount |
number |
Items still waiting for an answer. |
loading |
boolean |
The first load is in progress. |
error |
AnlyonUIError | null |
The last read failed. |
refresh |
() => Promise<void> |
Reads the list again. |
decide |
(approvalId: string, decision: Decision, reason?: string) => Promise<Approval | null> |
Sends an answer, with an optional note. Resolves to the decided approval, or null when the answer did not count; the item says why. It does not throw. |
An ApprovalItem is { approval, state, decision, error, resultStatus }. state is pending, deciding, approved, declined, expired or failed. resultStatus is the call’s status when approving ran it, such as succeeded.
useApproval(approvalId)
One of the user’s approvals, by id. Checked again while it waits for an answer.
| Returns | Type | Meaning |
|---|---|---|
item |
ApprovalItem | null |
The approval and its state. |
loading |
boolean |
The first load is in progress. |
error |
AnlyonUIError | null |
The read failed. APPROVAL_NOT_FOUND for an approval that is not this user’s. |
refresh |
() => Promise<void> |
Reads it again. |
decide |
(decision: Decision, reason?: string) => Promise<Approval | null> |
As in useApprovals. |
useConnections()
The user’s connected accounts. Never a token. Checked again when the page comes back into view.
| Returns | Type | Meaning |
|---|---|---|
connections |
Connection[] |
Each with integration, displayName, kind, status (active, needs_reconnect or revoked), scopes, connectedAt and updatedAt. |
loading |
boolean |
The first load is in progress. |
error |
AnlyonUIError | null |
The last read failed. |
refresh |
() => Promise<void> |
Reads the list again. |
disconnect |
(integration: string) => Promise<void> |
Revokes at the provider where it can, then deletes the connection. Throws an AnlyonUIError when it fails. |
useConnect(integration, options?)
Connect, or reconnect, one integration.
| Option | Type | Default | Meaning |
|---|---|---|---|
mode |
'popup' | 'redirect' |
the provider’s | Overrides the provider’s connect.mode. |
redirectUrl |
string |
the provider’s | Overrides the provider’s connect.redirectUrl. |
onConnected |
(integration: string) => void |
Called once the account is connected, in popup mode. |
| Returns | Type | Meaning |
|---|---|---|
status |
'loading' | 'not_connected' | 'connected' | 'needs_reconnect' |
Where the user stands with this integration. |
phase |
'idle' | 'starting' | 'waiting' |
starting while the link is made, waiting while the popup is open. |
connection |
Connection | null |
The user’s connection, when there is one. |
error |
AnlyonUIError | null |
The last attempt failed. |
connect |
() => Promise<void> |
Starts connecting. Call it from a click: the popup opens before anything is awaited, so the browser does not block it. |
cancel |
() => void |
Stops waiting for the popup. |
useAnlyon()
The provider’s context: connect ({ mode, redirectUrl }), pollInterval and locale, plus the session client and cache the other hooks use. Throws outside an <AnlyonProvider>.
Other exports
| Export | Meaning |
|---|---|
AnlyonUIError |
The error class the hooks report, with status, code and message. |
isConnectRequiredResult(value) |
true for a connect_required or reconnect_required tool result that names an integration. |
approvalHeadline(approval) |
The headline the components show. github-create-issue becomes GitHub: Create issue; a custom approval keeps its own title. |
formatMoney(amount, currency, locale?) |
Minor units as money: 1250, 'usd' gives $12.50. |
formatImpact(quantity, locale?) |
One impact quantity in words, such as $12.50 or Up to 3 emails. |
Types: each component’s props (ApprovalInboxProps, ApprovalPromptProps, ConnectButtonProps, ConnectionsProps, ConnectPromptProps, AnlyonProviderProps, StyleProps), each hook’s options and result, and Approval, ApprovalDecision, ApprovalPreview, ApprovalStatus, Connection, ConnectionStatus, ConnectLink, ConnectRequiredResult, ImpactQuantity, PreviewBlock and SessionTokenResult. @anlyonhq/react/server exports GetEndUserId, SessionHandlerOptions, ConnectCallbackOptions, ConnectOutcomeStatus, SessionMintingClient and ConnectConfirmingClient.
Theming
With shadcn/ui
Nothing to do. Each color reads the shadcn token of the same role (--background, --foreground, --primary, --muted, --border, --ring, --destructive, --radius), so the components follow your theme, light and dark. Tokens in OKLCH, hex or rgb() work as they are. If your theme stores bare HSL channels (--background: 0 0% 100%), add class="anlyon-shadcn-hsl" to <html>.
With CSS variables
Set --anlyon-* variables. They win over shadcn tokens.
:root {
--anlyon-primary: #4f46e5;
--anlyon-primary-fg: #ffffff;
--anlyon-radius: 10px;
}
The variables are --anlyon-bg, -fg, -muted, -muted-fg, -accent, -border, -primary, -primary-fg, -destructive, -destructive-fg, -ring, -radius, -success and -warning.
Dark mode follows the system setting. A .dark or [data-theme="dark"] ancestor forces dark, and .light or [data-theme="light"] forces light.
Your own styles
| Prop | Type | Default | Meaning |
|---|---|---|---|
className |
string |
Added to the component’s root element. | |
unstyled |
boolean |
false |
Drops the bundled styles. The anlyon-* class names and data-state attributes stay, for your own CSS. |
The bundled rules weigh no more than one class, so yours win. The package README lists the class names.
What stays on your server
- Your API key, and minting session tokens.
- Confirming a connection.
- Calling actions, and everything else a session token cannot do.
If a session token leaks, it works for one user, until it expires, and for nothing beyond the operations above. Browsers on other websites cannot use it; a script outside a browser can. Revoking the key that minted it ends it at once, and so does erasing that user.
Errors
| Code | Status | Meaning |
|---|---|---|
END_USER_SESSION_REQUIRED |
401 | No token was sent. |
END_USER_SESSION_EXPIRED |
401 | The token expired. The provider fetches a new one and retries once. |
END_USER_SESSION_INVALID |
401 | The token was altered, or its key was revoked. |
END_USER_SESSION_SCOPE |
403 | The key that minted it lacks end-users:approvals. |
ORIGIN_NOT_ALLOWED |
403 | The page’s origin is not one of the allowed redirect URLs’ origins. |
END_USER_SESSION_NOT_ALLOWED |
403 | A session token was sent to any other route. |
END_USER_SESSION_API_KEY_REQUIRED |
403 | A session was requested with a credential other than an API key. Your server mints sessions with an API key. |
Related
- Act for your users: the full flow these components sit in.
- TypeScript SDK: the server client the route handlers use.
- Vercel AI SDK: the tools whose results
ConnectPromptrenders.

