React

August 28, 2026 · View on GitHub

Idiomatic React bindings: a provider, hooks for chat/messages/conversations, declarative helpers for functions, navigation and context, plus ready-made UI components.

npm install @useago/sdk react react-dom
import {
  AgoProvider,
  useChat,
  useAgoFunction,
  ChatWidget,
} from "@useago/sdk/react";

React (>=17) is an optional peer dependency. The @useago/sdk/react entry only loads if React is installed.


1. Wrap your app in <AgoProvider>

The provider creates one AgoClient and shares it with every hook/component below.

import { AgoProvider } from "@useago/sdk/react";

function Root() {
  return (
    <AgoProvider baseUrl="https://YOUR-DOMAIN.useago.com" agent="support-bot">
      <App />
    </AgoProvider>
  );
}

Declarative config

The provider can wire app-wide tools, pre-built helpers and page context for you:

import { AgoProvider } from "@useago/sdk/react";
import { lookupOrder, cancelOrder } from "./agoFunctions";

<AgoProvider
  baseUrl="https://YOUR-DOMAIN.useago.com"
  tools={[lookupOrder, cancelOrder]}     // registered app-wide
  helpers={{
    copyToClipboard: true,               // use the built-in handler
    showToast: (args) => toast(args.message as string), // custom handler
  }}
  pageContext="auto"                      // auto-capture URL + title
>
  <App />
</AgoProvider>

Bring your own client (e.g. tests)

<AgoProvider client={myClient}>
  <App />
</AgoProvider>

2. The fastest UI: <ChatWidget>

A complete, styled chat panel. Drop it anywhere under the provider.

import { ChatWidget } from "@useago/sdk/react";

function Support() {
  return (
    <ChatWidget
      title="Support"
      welcomeMessage="Hi! How can I help?"
      placeholder="Ask anything…"
      allowFiles
      height={600}
      logoUrl="/logo.svg"
      showAgentName
      onMessageSent={(text) => console.log("sent", text)}
      onMessageReceived={(m) => console.log("received", m.content)}
    />
  );
}
PropTypeDefault
client?AgoClientfrom provider
conversationId?string
title?string"Chat"
welcomeMessage?stringgreeting
placeholder?string"Type a message..."
allowFiles?booleanfalse
allowStop?booleantrue (see Stop button)
height?string | number500
logoUrl?string
showAgentName?booleanfalse
forms?Array<CreateFormCollectorOptions | LoadFormCollectorOptions>
onFollowUpClick?((reply) => void) | falsesends the reply
className?string""
onMessageSent?(content) => void
onMessageReceived?({ id, content }) => void

Showing what the agent is doing

While the agent works, the widget shows a status row naming the client function it is running rather than a bare typing indicator. That gap is widest during a pause/resume loop: the agent calls a function, the page fetches, and the per-message dots have already gone away because the first tokens arrived.

<ChatWidget
  functionLabels={{
    lookupInvoices: "Looking up invoices",
    setPageState: "Updating the page",
  }}
/>

A slow sheen travels across the label while the agent works, and each new step fades in as its own element, so a long step still looks alive and a change of step reads as a continuation. Re-skin it with --ago-activity-color, --ago-activity-sheen and --ago-activity-bg; the animation stops under prefers-reduced-motion.

The label renders simple inline Markdown (**bold**, *italic*, `code`) and shows no animated dots beside it: the text is the progress indicator. While the row is up, the not-yet-written answer bubble drops its dots too, so you never get both at once. Building your own list? <Message showStreamingDots={false}> does the same thing.

The row keeps each step on screen until another one replaces it or the turn ends, rather than clearing when the call returns: a function that takes 40 ms would otherwise flash and read as nothing. The agent's reasoning steps appear in the same row (showReasoning={false} to leave them out), so it does not go blank between two calls.

Unmapped function names fall back to a prettified version of the name, so the row works without configuring anything. Pass a function for full control ((name, args) => string), or functionLabels={false} to keep the plain indicator. Building your own UI? useAgoActivity is the same data source, with approvals and server-side tool calls included.

Stop button

While the agent answers, <ChatWidget> turns its send button into a Stop button. Clicking it closes the stream and tells the backend to stop generating, so the partial answer stays in the transcript as CANCELED instead of the agent finishing in the background. Pass allowStop={false} to keep the send button disabled while answering instead.

Building your own input? <ChatInput> takes an onStop prop: when it is set and disabled is true (the agent is answering), the button becomes an enabled Stop button.

import { ChatInput, useMessages } from "@useago/sdk/react";

function Composer() {
  const { sendMessage, stop, isLoading } = useMessages();

  return (
    <ChatInput
      onSend={(text, files) => void sendMessage(text, files)}
      onStop={() => void stop()}
      disabled={isLoading}
    />
  );
}

Suggested replies

When the agent returns follow-up suggestions, the widget renders them as buttons below the message. By default clicking one sends it as the next user message. Pass onFollowUpClick to handle clicks yourself, or onFollowUpClick={false} to render them non-interactive.

Feedback ("this answer doesn't work")

feedback puts a thumbs up / thumbs down under every finished answer. On a thumbs-down, a panel asks what went wrong (four reason chips and a comment box).

<ChatWidget feedback />

The thumb is sent the moment it is clicked, so the signal survives a user who ignores the panel; sending the panel then files the detailed report, which is what shows up in the AGO feedback dashboard. Pass the <MessageFeedback> props to translate the strings or watch what goes out:

<ChatWidget
  feedback={{
    labels: { notHelpful: "Pas utile", reasons: { inaccurate: "Inexact" } },
    onSubmit: ({ rating, reasons }) => track(rating, reasons),
  }}
/>

Building your own message list? Drop the row in yourself, or drive it by hand:

import { MessageFeedback, useFeedback } from "@useago/sdk/react";

<MessageFeedback messageId={message.id} />;

const { ratings, submitFeedback, submitConversationFeedback } = useFeedback();
await submitFeedback(message.id, "negative", { reasons: ["inaccurate"] });
await submitConversationFeedback(conversationId, "negative", {
  comment: "It never answered my question.",
});

Conversational forms (form creator)

Pass forms to let the agent collect and submit a structured form during the chat. Each entry is installed as a form collector (see createFormCollector / useFormCollector): the agent gets update_<name> / submit_<name> functions plus two context entries. A stable one carries the full form schema (including the requiredWhen conditions for conditional fields) once per conversation in the cacheable part of the prompt; a small per-message one carries the data collected so far and which required fields are still missing.

<ChatWidget
  title="Book a demo"
  welcomeMessage="Hi! Tell me a bit about your team and I'll set up a demo."
  forms={[
    {
      name: "demo_request",
      description: "A request to book a product demo.",
      schema: {
        type: "object",
        properties: {
          name: { type: "string" },
          email: { type: "string" },
          company: { type: "string" },
          teamSize: { type: "number" },
        },
        required: ["name", "email", "company"],
      },
      // Relay to a server-configured destination (URL + secret stay server-side):
      submit: { via: "backend" },
    },
  ]}
/>

Keep the forms array stable (declare it outside render or memoize it); the collectors are reinstalled when a form's name, schema, description, or submit target changes. For full control over the live form state (e.g. a side panel that updates as fields fill in), use the useFormCollector hook directly instead of the forms prop.

To keep the schema in the backend instead of inline, pass an entry with just a name ({ name: "demo_request" }): the widget fetches the definition via loadFormCollector. The same works with the hook: useFormCollector({ name }) fetches the definition and exposes loading until it resolves.

Building your own UI? The widget is composed from exported building blocks you can reuse: <Message> (accepts onFollowUpClick), <ChatInput> and <Markdown content={...} /> (GitHub-flavored markdown, zero external CSS).


3. Custom UI with useChat

All-in-one state for a custom chat interface; composes useMessages + useConversation.

import { useChat } from "@useago/sdk/react";

function Chat() {
  const {
    messages,
    sendMessage,
    stop,
    isLoading,
    error,
    conversations,
    selectConversation,
    startNewConversation,
  } = useChat();

  return (
    <div>
      {messages.map((m) => (
        <p key={m.id}><b>{m.role}:</b> {m.content}</p>
      ))}
      {isLoading ? (
        <button onClick={() => void stop()}>Stop</button>
      ) : (
        <button onClick={() => sendMessage("Hello!")}>Send</button>
      )}
      {error && <p role="alert">{error.message}</p>}
    </div>
  );
}

messages updates token-by-token as the reply streams in (optimistic user message included). sendMessage(content, files?) returns the final message or null on error.

stop() interrupts the answer being generated: the stream closes and the backend is told to stop, the partial text stays in messages with status CANCELED, and isLoading goes back to false. It is a no-op when nothing is generating. See Stop the answer.

Show the source docs the agent retrieved

Each assistant message carries the knowledge sources it used in m.sources (an AgoSource[], each { id, title, url? }). Render them as links to display the URL of every retrieved doc:

function Chat() {
  const { messages, sendMessage, isLoading } = useChat();

  return (
    <div>
      {messages.map((m) => (
        <div key={m.id}>
          <p><b>{m.role}:</b> {m.content}</p>

          {m.sources?.length ? (
            <ul>
              {m.sources.map((s) => (
                <li key={s.id}>
                  {s.url ? (
                    <a href={s.url} target="_blank" rel="noreferrer">
                      {s.title || s.url}
                    </a>
                  ) : (
                    s.title
                  )}
                </li>
              ))}
            </ul>
          ) : null}
        </div>
      ))}
      <button onClick={() => sendMessage("Hello!")} disabled={isLoading}>
        Send
      </button>
    </div>
  );
}

Finer-grained hooks

HookReturns
useMessages({ conversationId? }){ messages, isLoading, error, sendMessage, stop, clearMessages, conversationId }
useConversation({ autoLoad? }){ conversations, currentConversation, isLoading, error, selectConversation, startNewConversation, refreshConversations }
useChat(options)both of the above combined
useFeedback(){ ratings, submitFeedback, submitConversationFeedback, isSubmitting, error }

All hooks read the client from context by default; pass { client } to override.

Need the raw client?

import { useAgoClient } from "@useago/sdk/react";

const client = useAgoClient(); // throws if outside <AgoProvider>
// or useOptionalAgoClient() → AgoClient | null

4. Let the agent call your code: useAgoFunction

Registers a client-side function on mount and cleans it up on unmount.

import { useAgoFunction } from "@useago/sdk/react";

function OrdersPanel() {
  useAgoFunction({
    name: "lookupOrder",
    description: "Look up an order by ID",
    parameters: {
      type: "object",
      properties: { id: { type: "string", description: "Order ID" } },
      required: ["id"],
    },
    handler: async (args) => fetchOrder(args.id as string),
  });

  return <OrdersTable />;
}

Reuse a definition created with defineFunction:

import { defineFunction } from "@useago/sdk";

const lookupOrder = defineFunction({ name: "lookupOrder", /* … */ });
useAgoFunction(lookupOrder);

See Client-side functions for schema details and the catalogue of pre-built helpers.


5. Let the agent navigate: useAgoNavigation

Wire AGO into your router. Works great with react-router's useNavigate.

import { useAgoNavigation } from "@useago/sdk/react";
import { useNavigate } from "react-router-dom";

function AppShell() {
  const navigate = useNavigate();
  useAgoNavigation(navigate, [
    { name: "dashboard", path: "/dashboard", description: "Main dashboard" },
    { name: "settings", path: "/settings", description: "User settings" },
  ]);
  return <Outlet />;
}

Define each route once

React Router's <Route> has no description prop (it ignores unknown props, and TypeScript rejects them), so the path and the agent description live in two different places. Keep one route table and read both the router and useAgoNavigation off it: add or rename a page in a single spot and they stay in sync.

import { Routes, Route, useNavigate } from "react-router-dom";
import { useAgoNavigation } from "@useago/sdk/react";

// One source of truth: path + the description the agent reads to pick the page.
const ROUTES = {
  dashboard: { name: "dashboard", path: "/dashboard", description: "KPIs and recent activity" },
  invoices: { name: "invoices", path: "/invoices", description: "List and download invoices" },
  settings: { name: "settings", path: "/settings", description: "Account, billing and team" },
} as const;

function AppShell() {
  const navigate = useNavigate();

  // The agent gets every route's path + description from the same object.
  useAgoNavigation(navigate, Object.values(ROUTES));

  return (
    <Routes>
      <Route path={ROUTES.dashboard.path} element={<Dashboard />} />
      <Route path={ROUTES.invoices.path} element={<Invoices />} />
      <Route path={ROUTES.settings.path} element={<Settings />} />
    </Routes>
  );
}

A detail page with a param (/invoices/:id) can be registered as-is. The placeholder becomes a top-level id argument of navigateToPage, so "open invoice 42" navigates to /invoices/42. One route covers every record:

useAgoNavigation(navigate, [
  ...Object.values(ROUTES),
  { name: "invoiceDetail", path: "/invoices/:id", description: "One invoice's detail page" },
]);

When the agent should pick a record by meaning instead of by id ("the invoice for Acme"), it needs to know which ids exist. For a small, stable set, keep the single parameterized route and list the ids in its description, derived from your data so they stay in sync (examples/glacier does this for its origin pages). For larger or user-specific sets, register concrete paths derived from your data instead:

const invoiceRoutes = invoices.map((inv) => ({
  name: `invoice-${inv.id}`,
  path: `/invoices/${inv.id}`,
  description: `Invoice ${inv.number} for ${inv.customer}`,
}));

useAgoNavigation(navigate, [...Object.values(ROUTES), ...invoiceRoutes]);

5b. Let the agent change the page: useAgoPageState

The mirror of useAgoNavigation. Instead of moving the user to another page, let the agent change the state of the page they're on (filters, sort, view mode…) and read the current state back.

import { useAgoPageState } from "@useago/sdk/react";

function InvoiceList() {
  const [status, setStatus] = useState("all");
  const [sort, setSort] = useState("newest");

  useAgoPageState([
    {
      name: "statusFilter",
      description: "Filter the list by invoice status",
      schema: { type: "string", enum: ["all", "paid", "overdue"] },
      get: () => status,
      set: setStatus,
    },
    {
      name: "sort",
      description: "Sort order of the list",
      schema: { type: "string", enum: ["newest", "oldest"] },
      get: () => sort,
      set: setSort,
    },
  ]);

  return /* … */;
}

Each control becomes one optional property of a single synthesized setPageState function, so the agent sets only what the user asked for. Every control's current get() value is sent as context, so the agent knows the state before it changes it. Pass { functionName } to rename the function. The hook re-registers when the client, function name, or a control's model-visible signature changes (name, description, schema, clearable). set/get closures can change every render without churn. The glacier example dogfoods this: the ice cream's cone, scoops and toppings are page-state controls.

The SDK validates each field before calling set(): type and enum mismatches are rejected, unknown control names are rejected, and a value equal to the current get() is skipped. null, undefined, and "" are silently dropped (unless the control is a clearable string). The result envelope reports each field in applied, unchanged, or rejected. See Page state shortcut for the full envelope shape, clearable, and tagged setter outcomes.

Give the agent back what the page shows

On its own, setPageState tells the agent what happened to its arguments, not what appeared on screen. Add a data source and the rows come back as the result of its own call, in the same turn.

const { data: invoices, isFetching } = useQuery({
  queryKey: ["invoices", status, sort],
  queryFn: fetchInvoices,
});

useAgoPageState(controls, {
  data: {
    description: "The invoices matching the current filters.",
    get: () => invoices ?? [],
    isLoading: () => isFetching,     // isFetching, not isLoading
  },
});

setPageState then returns { success, applied, unchanged, rejected?, data }, and a read-only readPageData function is registered alongside it for "what's on screen?" questions.

Better still, return a promise from get() and the SDK awaits it instead of polling a flag, which removes the timing guess entirely:

useAgoPageState(controls, {
  data: {
    description: "The invoices matching the current filters.",
    get: () => queryClient.ensureQueryData({ queryKey: ["invoices", status], queryFn: fetchInvoices }),
  },
});

If you stay on isLoading, pass isFetching: TanStack Query's isLoading is only true on the very first load, so a background refetch would read as idle. Note the poll only gives the flag ~100 ms to go up, so a debounced fetch needs the promise form. Over the size ceiling (maxResultBytes, default 50 000 bytes) the snapshot keeps the first whole rows that fit and adds a truncation field ({ truncated, returnedItems, totalItems, hint }) beside them. The verdict fields (success, applied, unchanged, rejected) are never dropped. The round trip needs clientFunctionsMode: "pause" (the default). See functions and context.

A cross-page request ("open the parfums page and show only lactose-free, sorted by price") needs the agent to navigate, then set the new page's state. The destination only registers its controls once it mounts, so it can't happen in one turn. Mount this hook once (near your router) to bridge it:

function AppShell() {
  useAgoNavigation(navigate, routes);
  useAgoAutoContinueAfterNavigation();
  return <Outlet />;
}

With clientFunctionsMode: "pause" (the default, needs backend pause/resume support) the backend pauses the turn on the navigation call and the SDK resumes it once the result is submitted; the hook only delays the resume until the destination's useAgoPageState registered — same turn, no extra prompt, and continuationPrompt/maxDepth are unused. In the legacy placeholder mode the hook instead waits for the destination to register, then sends a hidden continuation so the agent applies the state in a second turn; it only continues if the destination has editable state, caps continuations per gesture, and cancels if the user takes a new turn. Options: navigationFunctions, continuationPrompt, maxDepth, readinessTimeoutMs, enabled. The glacier example wires this to its parfums page.


6. Give the agent context: useAgoContext

Expose what the user is looking at, sent with every message. A unique key is generated per component via useId().

import { useAgoContext } from "@useago/sdk/react";

// Static object, captured from props/state
function OrderPage({ order }) {
  useAgoContext({
    name: "Order detail",
    description: "The user is viewing a specific order",
    data: { orderId: order.id, status: order.status },
  });
  return <OrderView order={order} />;
}

// Dynamic function, evaluated on every send (fresh data from a store)
function App() {
  useAgoContext(() => ({
    name: "App shell",
    data: { userId: store.getState().auth.userId },
  }));
}

// Share/reference context with an explicit key
useAgoContext({ name: "Sidebar filter", data: { filter } }, "sidebar-filter");

7. Reactive external state: useAgoStore

If you hold shared UI/request state in a core createStore (handy when client-side functions and your components mutate the same value), useAgoStore reads it reactively: the component re-renders on every store.set. It's a thin useSyncExternalStore wrapper, so it's SSR-safe and batches correctly.

import { createStore } from "@useago/sdk";
import { useAgoStore } from "@useago/sdk/react";

const cart = createStore({ items: [] as string[] });

function CartBadge() {
  const { items } = useAgoStore(cart); // re-renders when the store changes
  return <span>{items.length}</span>;
}

// Mutate through the store, from anywhere, including a registered AGO function:
cart.set({ items: [...cart.get().items, "SKU-1"] });

8. Subscribe to events

Use the client directly from useAgoClient() and the standard on / off API inside an effect:

import { useEffect } from "react";
import { useAgoClient } from "@useago/sdk/react";

function Notifier() {
  const client = useAgoClient();
  useEffect(() => {
    const handler = (m) => toast(`AGO: ${m.content}`);
    client.on("message:complete", handler);
    return () => client.off("message:complete", handler);
  }, [client]);
  return null;
}

9. Show what the agent is doing: useAgoActivity

A live, normalized feed of the agent's actions (navigations, page-state changes, forms, confirmations, status, progress) merged from tool calls and client function invocations, plus the controls to approve/reject/submit the items that wait on the user.

import { useAgoActivity } from "@useago/sdk/react";

function ActivityFeed() {
  const { items, latest, approve, reject, submitForm } = useAgoActivity();

  return (
    <ul>
      {items.map((it) => (
        <li key={it.id}>
          {it.label} ({it.status})
          {it.status === "awaiting-approval" && (
            <>
              <button onClick={() => approve(it.id)}>Approve</button>
              <button onClick={() => reject(it.id)}>Reject</button>
            </>
          )}
        </li>
      ))}
    </ul>
  );
}

latest is the most recent item (handy for a collapsed "latest action" line) and isAwaitingApproval is true while anything waits on the user. Pass labelFor to map function/route names to friendly copy, and includeReasoning: true to show the model's chain-of-thought (off by default). Approval controls need the client's approval gate (pause mode).

The feed survives a page refresh. When you restore a conversation with client.getConversation(...), the hook rebuilds the already-resolved activity from the stored tool calls (steps still waiting on the user are left out; resume those with resumePendingClientFunctions).


Full example

A runnable React example lives in examples/simple-react.


Exports cheat-sheet (@useago/sdk/react)

  • Provider/context: AgoProvider, useAgoClient, useOptionalAgoClient
  • Hooks: useAgo, useChat, useMessages, useConversation, useAgoFunction, useAgoNavigation, useAgoPageState, useAgoAutoContinueAfterNavigation, useAgoActivity, useAgoContext, useAgoStore, useFormCollector, useFeedback
  • Components: ChatWidget, Message, ChatInput, Markdown, MessageFeedback
  • Forms: createFormCollector (+ CreateFormCollectorOptions, SubmitConfig, …)
  • Testing: createMockClient
  • Types: AgoConfig, AgoMessage, Conversation, AgoAgent, AgoSource, ToolCallData, plus per-export prop/option types (AgoAttachment lives on the root @useago/sdk entry, not the /react subpath)

See also: Client functions & context · Testing · Configuration