agent-webkit
Guides

React frontend

A complete walkthrough of useAgentSession() — messages, permissions, AskUserQuestion, errors, and reconnect.

If you're shipping a React app, @agent-webkit/react is what you want. It owns the SSE iterator, reconciles streaming deltas into a typed message list, and surfaces permission / question state slots as plain reactive state.

npm install @agent-webkit/react @agent-webkit/core

The shape of a session

import { useAgentSession } from "@agent-webkit/react";

function Chat() {
  const session = useAgentSession({
    baseUrl: "https://api.example.com",
    token: useAuthToken(),
  });

  return (
    <>
      <MessageList messages={session.messages} />
      <Composer
        onSend={session.send}
        disabled={session.status !== "idle"}
      />
      <PermissionModal session={session} />
      <QuestionDialog session={session} />
      <ErrorBanner error={session.lastError} />
    </>
  );
}

Everything you need is on the returned object. Full surface in the API reference.

Rendering messages

session.messages is a discriminated union of three kinds: user, assistant, and tool_result. The reducer handles delta reconciliation for you — streaming: true means more text is on the way.

function MessageList({ messages }: { messages: DisplayMessage[] }) {
  return (
    <ul>
      {messages.map((m) => {
        switch (m.kind) {
          case "user":
            return <UserBubble key={m.id} content={m.content} />;
          case "assistant":
            return (
              <AssistantBubble
                key={m.id}
                content={m.content}
                streaming={m.streaming}
              />
            );
          case "tool_result":
            return <ToolResult key={m.id} output={m.output} isError={m.is_error} />;
        }
      })}
    </ul>
  );
}

The assistant block's content is ContentBlock[]. Filter to type === "text" for plain text, or render tool_use blocks inline if you want to show the agent reasoning about tool calls.

Permission UI

When the model wants to use a tool, the server pauses and emits a permission_request. The hook places it on session.pendingPermission:

function PermissionModal({ session }: { session: UseAgentSessionReturn }) {
  const p = session.pendingPermission;
  if (!p) return null;

  return (
    <Modal>
      <h2>Allow tool: <code>{p.tool_name}</code>?</h2>
      <pre>{JSON.stringify(p.input, null, 2)}</pre>
      <button onClick={() => session.approve(p.correlation_id)}>Allow</button>
      <button onClick={() => session.deny(p.correlation_id, { message: "Not now" })}>
        Deny
      </button>
    </Modal>
  );
}

Things to note:

  • The slot clears as soon as the response is acked (or another tab wins the race).
  • approve() accepts { updatedInput } if you want the user to edit the tool args before allowing.
  • deny({ interrupt: true }) aborts the entire turn, not just this tool call.

If two browser tabs race, the loser's approve() rejects. See Permissions guide for the full pattern.

AskUserQuestion

Same pattern, separate slot:

function QuestionDialog({ session }: { session: UseAgentSessionReturn }) {
  const q = session.pendingQuestion;
  if (!q) return null;

  return (
    <Modal>
      {q.questions.questions.map((item, i) => (
        <Field key={i} item={item} />
      ))}
      <button onClick={() => session.answer(q.correlation_id, gatherAnswers())}>
        Submit
      </button>
    </Modal>
  );
}

The double-nested q.questions.questions is the SDK's tool-input shape. Each item has a question string, a header, and multiSelect options.

Status

session.status is your "is it busy?" signal:

StatusComposer disabled?Render
idlenocomposer enabled
streamingyestyping indicator
awaiting_permissionyespermission modal
awaiting_questionyesquestion dialog
awaiting_hookyes(rare; use L1 for UI)
errordependserror banner

Errors

session.lastError carries the most recent error event. Transport errors (network, transient) trigger automatic reconnect under the hood and don't surface here. What you'll see here is server-emitted protocol errors and fatal transport failures.

Show, then clear when the user sends again — easiest UX.

Reconnects, multi-tab

Free with the hook:

  • Reconnect after Wi-Fi blip — the underlying SSE iterator re-establishes using Last-Event-ID. Messages reconcile cleanly because message_id and tool_use_id are stable.
  • Multiple tabs, same session — pass the same sessionId to both. They share the stream; whichever responds to a permission first wins.

To resume on a fresh page load:

const session = useAgentSession({
  baseUrl,
  token,
  sessionId: storedSessionId, // from localStorage, your URL, etc.
  resumeFromEventId: storedLastEventId,
});

If the gap is too big for the server's ring buffer, you'll get a fatal error and need to start a new session. See Resume & reconnect.

Manual control

Set autoStart: false if you need to gate the session on a condition (auth ready, feature flag, user gesture). Flip it back to true when you're ready and the hook will open the session. You can also pre-construct an AgentClient with createAgentClient and pass it via client — useful for tests and for sharing one client across multiple hook instances.

Where to next

On this page