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

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.

Was this page helpful?