---
title: "React components (beta)"
description: "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](/guides/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.

```bash
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

```typescript
// 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`](/api-reference) yourself:

```typescript
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' } });
```

```python
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.

```typescript
// 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

```tsx
// 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:

```tsx
// 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](#your-own-styles).

### Approval inbox

```tsx
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:

```tsx
<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](/guides/act-for-your-users#4-add-the-limit-and-the-approval).

### One approval

```tsx
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

```tsx
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

```tsx
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

```tsx
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`](/sdks/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`.

```tsx
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.

```css
: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](/guides/act-for-your-users): the full flow these components sit in.
- [TypeScript SDK](/sdks/typescript): the server client the route handlers use.
- [Vercel AI SDK](/sdks/ai-sdk): the tools whose results `ConnectPrompt` renders.
