Skip to content

React: use-agentenkit ​

bash
bun add use-agentenkit

A hook that owns the client state machine — hydrate from the snapshot, show the run stream in flight, then follow the thread record and the run streams — and leaves every endpoint, string and transport to you.

The smallest thing that works ​

tsx
'use client';
import { useAgentThread } from 'use-agentenkit';

export function Chat() {
  const { entries, run, stop, agentState, pendingInputs, respondToInput } = useAgentThread();
  const running = agentState === 'QUEUED' || agentState === 'RUNNING' || agentState === 'WAITING_FOR_INPUT';

  return (
    <>
      {entries.map((e) => (
        <p key={e.id}>{e.role}: {e.text}</p>
      ))}

      {pendingInputs.map((input) => (
        <button key={input.toolCallId} onClick={() => respondToInput(input.toolCallId, true)}>
          Approve {input.toolName}
        </button>
      ))}

      <button onClick={() => (running ? stop() : run('hello'))}>
        {running ? 'Stop' : 'Send'}
      </button>
    </>
  );
}

With no configuration it calls the default routes. Nothing else is assumed.

What it returns ​

State

threadIdthe open thread, or undefined
entriesthe conversation — see below
agentStateIDLE · QUEUED · RUNNING · WAITING_FOR_INPUT · COMPLETED · CANCELLED · FAILED — QUEUED is accepted and waiting for a worker
activity{ phase, label, detail } — a live status line
historyLoadinghydration in progress
pendingInputsrequests the run is parked on: reason: 'approval' waits on a human (show a card); any other reason is a tool waiting on work it started (show a status)
subagentsnested runs, with status and text
threads, threadsLoadingthe thread list, for a sidebar
usagetokens spent, money spent, and context load
currentRunthe thread's latest run: { id, startedAt?, endedAt? }, or null before the first
connectionthe live stream: connecting · open · reconnecting · closed
errorwhy the last send did not go through, or why the server refused the run (RUN_REFUSED); null otherwise

currentRun is what a "running for 1:32" timer counts from. It is hydrated from the snapshot's runs and then kept from STATE_CHANGE alone: every one names its run and carries startedAt while the run is running or waiting, or endedAt once it ended. A server that leaves a clock off still gets a timer: the event's own time stands in. A client never refetches history for timing.

connection says where the stream stands. A dropped connection is reconnecting while the transport retries on its own. One the transport gave up on (a 401, a 404) is closed: the hook then reads the thread again and opens a new stream from where it stands, waiting longer each time (1 s, doubling, up to 30 s). An event the hook already has — a replay, or a transport that resent after reconnecting — is dropped before anything sees it, so text is never shown twice.

The hook opens the stream with two query params: cursor (where it is, <seq> <streamId> <offset>) and lastMessageId (the last message it has). It also sends since, the record seq alone, for an older server. If the stream it was reading is gone by the time it reconnects, the server sends one SNAPSHOT frame on the same connection, and the hook merges its newer messages in place — no second request, no reload. See HTTP API.

Actions

run, stop, respondToInput, newThread, selectThread, deleteThread, loadThreads, loadUsage.

run() is refused while a run is queued, running or waiting on an approval, and while an earlier send is still on its way: a second send would wipe the first run's approval cards, and two fast sends on a new thread would make two threads. A send that fails — refused by the server, or lost in transit — puts the conversation back exactly as it was and sets error; it does not mark the thread FAILED. The next accepted send clears error. An edit of a turn the server has not confirmed yet is refused too: it has no id the server knows.

A refused run() resolves to { accepted: false, error }, plus reason (active_run, queue_full, billing, or your route's own code) and retryAfterSeconds when the server gave them. The error may be a string or { error: { code, message } }; the wait comes from the body's retryAfterSeconds or a Retry-After header.

After a reload, a tool call's card carries its stored result, as it does live. isToolError(result) reads a failure from an object, from error: … text, from JSON text, or from a list of results. isToolParked(result) spots the marker a tool leaves while it waits for a person.

The turn a send adds right away is swapped for the real one by an id the hook sends with it (clientMessageId, echoed on MESSAGE_APPENDED), never by matching its text.

stop() and respondToInput() return true when the server confirms the action, and false on refusal or a transport failure. Failures appear in activity.label and activity.detail; agentState still reflects the run. Approval cards stay visible until delivery is confirmed. Customize the error labels with labels.stopFailed and labels.responseFailed.

Entries ​

ts
{ id, kind: 'text' | 'tool' | 'reasoning', role, text, agentId, parts }

text is the flat rendering. parts is the structure behind it, for a UI that draws tool cards, thumbnails, or a foldable thought:

ts
type EntryPart =
  | { type: 'text'; text }
  | { type: 'reasoning'; text }
  | { type: 'image'; image; mimeType? }
  | { type: 'tool-call'; toolCallId; toolName; args; state: 'running' | 'done' | 'error'; result? }
  | { type: 'tool-result'; toolCallId; toolName?; result };

A tool call's state flips to done (or error) in place when its result arrives, live or on reload, so a card can settle without a second lookup. Live, the run stream carries a TOOL_CALL_RESULT for every tool it ran, before the step commits. On reload the state is derived from the durable tool messages, which is how a denied approval, a stop, or an approval that ran the tool later gets its state: those never stream a result. error means the result reports a failure ({ error }, or the error: … text a failed tool's result carries); a denial or a cancellation is an answer, so the call is done. Parts are filled the same way from the durable snapshot and from the stream: a reload renders what the live run did.

kind is what a UI branches on:

tsx
{entries.map((e) =>
  e.kind === 'reasoning' ? <Thought key={e.id} text={e.text} /> :
  e.kind === 'tool'      ? <ToolLine key={e.id} text={e.text} /> :
                           <Bubble key={e.id} {...e} />
)}

Streaming thought ​

Models that expose their reasoning stream it separately from the answer. The hook keeps the two apart, so you can show thinking live and fold it away once the answer starts.

It reads the same on a reload as it did live: a persisted message's reasoning parts are lifted into their own entry rather than folded into the answer text.

Whether you see anything depends on the model. Providers that do not expose reasoning never send it, and no reasoning entries appear — OpenAI's gpt-4o is one of those, and the o-series reports reasoning token counts without the text. Anthropic extended thinking and DeepSeek R1 do send it.

Sending ​

ts
await run('what is the weather?');
await run('…', { model: 'gpt-4o-mini' });
await run('corrected text', { editMessageId: someUserMessageId });
await run('…', { providerOptions: { openai: { serviceTier: 'flex' } } });
await run('…', { orgId: 'acme' });   // extra fields merge into the request body
await run('what is this?', { attachments: [{ url: 'https://cdn/cat.png', mediaType: 'image/png' }] });
await run('…', { runId: myRunId, maxSteps: 8 });

attachments show on the optimistic turn as image parts and reach the model natively. runId names the run so your own records can be keyed by it before the server answers; maxSteps caps the run below the server's ceiling. Your run route must pass them through to run(); both runtimes accept them.

The hook adds the user's turn locally before the request goes out, then replaces it with the durable one when the server confirms — so the id is real and the message can be edited.

Routes ​

RouteMethodDefault
runPOST/api/agent/run
stopPOST/api/agent/control
respondPOST/api/agent/respond
streamGET (SSE)/api/agent/stream — params { threadId, since, cursor, lastMessageId }
historyGET/api/agent/history
usageGET/api/agent/usage
threadsGET/api/threads
deleteThreadDELETE/api/threads

Override any subset. A route is either a path the hook appends its own query to, or a function that builds the whole URL — the function form covers path parameters, a different query vocabulary, or another host:

ts
useAgentThread({
  baseUrl: 'https://api.example.com',
  routes: {
    run: '/v2/agent/start',
    history: ({ threadId }) => `/v2/threads/${threadId}`,
  },
});

Configuring once ​

tsx
'use client';
import { AgentRunProvider } from 'use-agentenkit';

export function Providers({ children }: { children: React.ReactNode }) {
  return (
    <AgentRunProvider config={{ routes: { run: '/v2/agent/start' } }}>
      {children}
    </AgentRunProvider>
  );
}

A hook's own options still win, section by section — a component can replace one route without restating the rest.

Everything else you can change ​

OptionDefaultFor
fetchglobal fetchauth wrappers, retries, a test double
headersnonesent with every request; a function is called per request, so a rotating token stays fresh
openStreambrowserEventStreamhow the stream is opened — see below
defaultModelnonethe model run() sends when the call names none; unset sends no model, and the server uses the agent's own
persistencebrowserPersistence()where the open thread id is remembered; false keeps it in memory
labelsEnglishevery user-facing string the hook produces
formatplain texthow tool calls, results and subagent notices render
onEventnonesee every event first; return true to claim it
onCustomnone(name, value) for your own events, once each, live or durable
threadsRefreshMs30000background thread-list refresh; false disables
loadThreadsOnMounttrueoff for a single-thread embed

Authentication on the stream ​

headers does not reach the event stream. The browser's EventSource cannot send headers — an API limitation, not a package one. Use a cookie, or supply your own opener:

ts
useAgentThread({
  openStream: (url, { onMessage, onError, onOpen, onClose, getCursor }) => {
    const source = new MyAuthedEventSource(url, { token });
    source.onopen = () => onOpen?.();
    source.onmessage = (e) => onMessage(e.data);
    source.onerror = onError; // retrying on its own
    source.onclose = () => onClose?.(); // gave up: the hook re-reads and reopens
    return { close: () => source.close() };
  },
});

The same hook takes a WebSocket or fetch-streaming transport. One that reconnects on its own should resume from getCursor(): the cursor string <seq> <streamId> <offset>, the id of the last frame the hook applied. Send it as Last-Event-ID or as the cursor query param. The hook drops anything it already has anyway.

The default persistence keeps the thread id in the URL with history.replaceState, passing the page's own history state through, so it does not disturb the Next.js router.

Your own event types ​

The simplest way is onCustom. It gets each of your events once, by name, whether it came live on the run stream or from the thread record:

ts
useAgentThread({
  onCustom: (name, value) => {
    if (name === 'MY_APP_EVENT') handle(value);
  },
});

onEvent sees every event, the platform's too, before the built-in reducer. Your events reach it with type set to their name and payload set to their value:

ts
useAgentThread({
  onEvent: (event) => {
    if (event.type === 'MY_APP_EVENT') {
      handle(event.payload);
      return true;   // handled — skip the built-in reducer
    }
  },
});

Returning true also lets you override a built-in type. Your own events come from publishEvent on the server; Custom events walks through a full reducer.

Translating ​

ts
useAgentThread({
  labels: { thinking: 'Denkt na', waitingApproval: 'Wacht op goedkeuring' },
});

Unlisted labels keep their defaults.

Multiple tabs ​

Two tabs on the same thread stay in sync: a message sent in one appears in the other, an edit truncates both, an approval answered in one drops its card in the other (HITL_RESPONSE), a thread deleted in one resets the other (THREAD_DELETED), and a tab opened mid-run rebuilds what has happened so far and then follows along. You do not have to do anything for this — it falls out of hydrate-then-follow.

Notes ​

  • The server is the only source of truth.
  • Switching threads clears the old thread's messages, cards and run at once, and a slow answer for a thread you already left never lands.
  • Streamed text is shown once per animation frame, not re-rendered per token.
  • A generate-text agent streams nothing; its whole answer arrives as one TEXT_RESULT and is shown as one entry.
  • Config is read fresh on every request, but an open stream keeps the URL it was opened with. Change routes at mount, not mid-run.
  • The hook is client-side. In Next.js App Router, the component using it needs 'use client', and so does any provider above it.