Skip to content
OpenComputer
Build an application

React integration

Create sessions on your server; attach, submit work and reopen them with useAgent

@opencomputer/react provides useAgent: a session’s conversation, turn activity and input controls. Your server creates sessions and checks access; the hook reads their event log and sends turns through your routes. The API key stays on the server.

npm install @opencomputer/react

Create the session on your server

Use the management client from trusted code. Address the agent by its environment alias, with one key per submission:

import { OpenComputer } from "@opencomputer/sdk/agents";

const oc = new OpenComputer({ apiKey: process.env.OPENCOMPUTER_API_KEY! });
const { session } = await oc.sessions.create(
  { agentId: "<agent-id>@development", labels: { request: taskId } },
  { idempotencyKey: taskId },
);
// Return session.id to the browser; use it when reopening this conversation.

taskId belongs to one submission. The platform chooses the deployment active in the environment and records it on the session, so retry an uncertain create with the same parameters and key: it returns the same session even after a redeploy. Nothing in your application handles deployment ids; pinning one is an advanced option of sessions.create. Creating a session and sending its first turn are separate calls with separate keys.

Expose three routes

The hook needs these management API routes, relative to its basePath:

RoutePurpose
GET /sessions/<id>/events?after=<seq>Read conversation history and new events
POST /sessions/<id>/turnsSubmit { input, idempotencyKey, payload?, answers? }
POST /sessions/<id>/interruptRequest Stop for the running turn

Authenticate each request and check access to the session before forwarding it with your organization API key. For example, a Next.js route handler:

// app/api/agent/sessions/[id]/[action]/route.ts
const allowed: Record<string, string[]> = {
  GET: ["events"],
  POST: ["turns", "interrupt"],
};

async function proxy(
  request: Request,
  { params }: { params: Promise<{ id: string; action: string }> },
) {
  const { id, action } = await params;
  const user = await requireUser(request);
  if (!allowed[request.method]?.includes(action) || !(await userOwnsSession(user, id))) {
    return new Response(null, { status: 404 });
  }
  const upstream = await fetch(
    `https://app.opencomputer.dev/api/managed-agents/sessions/${encodeURIComponent(id)}/${action}${new URL(request.url).search}`,
    {
      method: request.method,
      headers: {
        "x-api-key": process.env.OPENCOMPUTER_API_KEY!,
        "content-type": "application/json",
      },
      body: request.method === "POST" ? await request.text() : undefined,
      redirect: "error",
    },
  );
  return new Response(upstream.body, {
    status: upstream.status,
    headers: { "content-type": "application/json" },
  });
}

export const GET = proxy;
export const POST = proxy;

requireUser and userOwnsSession are your application’s access checks. The latter must enforce the application’s project, environment and agent scope as well as the user’s access. Forward JSON bodies and response statuses unchanged: the hook supplies the turn key in the body and reads the admission receipt or error envelope from the response.

Attach and submit

Keep each submission’s text and key until its receipt arrives. If the reply is lost, Retry sends the same submission; it must not generate a new key.

import { useState } from "react";
import { useAgent } from "@opencomputer/react";

type Submission = { text: string; idempotencyKey: string };

export function Chat({ sessionId }: { sessionId: string }) {
  const { messages, turns, send, stop, isReplaying, error } = useAgent({
    sessionId,
    basePath: "/api/agent",
  });
  const [draft, setDraft] = useState("");
  const [pending, setPending] = useState<Submission | null>(null);
  const [sending, setSending] = useState(false);

  async function submit() {
    const submission = pending ?? {
      text: draft.trim(),
      idempotencyKey: crypto.randomUUID(),
    };
    setPending(submission);
    setSending(true);
    try {
      await send(submission.text, { idempotencyKey: submission.idempotencyKey });
      setPending(null);
      setDraft("");
    } catch {
      // The hook exposes the error. Keep this submission for retry.
    } finally {
      setSending(false);
    }
  }

  return (
    <section>
      {isReplaying ? <p>Loading the conversation…</p> : null}
      {messages.map((message) => (
        <p key={message.id}><strong>{message.role}:</strong> {message.text}</p>
      ))}
      <input
        value={draft}
        disabled={sending || pending !== null}
        onChange={(event) => setDraft(event.target.value)}
      />
      <button disabled={sending || (!pending && !draft.trim())} onClick={() => void submit()}>
        {sending ? "Sending…" : pending ? "Retry" : "Send"}
      </button>
      <button disabled={!turns.some((turn) => turn.status === "running")} onClick={() => void stop()}>
        Stop
      </button>
      {error ? <p role="alert">{error}</p> : null}
    </section>
  );
}

This example retains the submission for the life of the component. To retry across navigation or a reload, retain the same envelope there too. Include payload in it when sending structured input. An explicit rejection such as session_ended requires resolving that error; a new key starts new work and is not recovery of an uncertain request.

Turns sent while another runs are queued. send resolves on admission, not when the agent finishes. It returns { sessionId, turnId, status, duplicate }:

  • A new turn is queued or running.
  • A repeated key returns the existing turn and its current status, including completed, failed or cancelled, with duplicate: true.
  • Keep both the text and payload unchanged when retrying a key.

The input appears in messages after admission. Its event-log record confirms that message instead of adding a second one.

Send options and errors

OptionPurpose
idempotencyKeyOne key per submission, reused on retries. Without it, each call generates a new key and can admit a new turn.
payloadStructured JSON available to the agent as useInput().payload, beside the text
answersThe id of the open question this input answers; answer() sets it

send rejects with a SendError. Rejection alone does not establish whether a turn was admitted:

codeMeaningstatus
The API’s code, such as session_ended, memory_admission_unconfirmed or insufficient_creditsAn error response; follow that code’s recovery contractHTTP status
network_errorTransport or response-reading failure; admission may have succeedednone
invalid_responseThe response was not a turn receipt; admission may have succeedednone
empty_inputBlank text; nothing sentnone
busyCreate mode only: a turn already runs; nothing sentnone

The hook also puts the message in error. A turn that fails after admission is separate: turn.failed sets the turn’s failure and the hook’s error.

Display activity and results

turns comes from the same durable event log as messages. Use its statuses to distinguish queued work from running and settled turns:

{turns.map((turn) => (
  <section key={turn.id}>
    <h3>{turn.input} — {turn.status}</h3>
    {turn.toolCalls.map((call) => (
      <p key={call.callId}>{call.title}: {call.status}</p>
    ))}
    {turn.result !== undefined ? <pre>{JSON.stringify(turn.result, null, 2)}</pre> : null}
    {turn.failure ? <p role="alert">{turn.failure.message}</p> : null}
  </section>
))}
Turn fieldMeaning
id, status, inputTurn ID, lifecycle status and admitted text
messagesThis turn’s messages, also present in the hook’s messages
toolCalls{ callId, tool, title, input?, output?, status } in start order; values are decoded JSON
resultThis turn’s latest committed result-tool output, if any; ordinary tool output does not become a result
failure{ code, message } from turn.failed
outcome"question" when the turn completed by asking

Reporting a result does not finish a turn. The session’s latest result may also belong to an earlier turn; see result provenance before treating a session as ready for review.

A tool call is running, completed, failed or cancelled. Calls still running when their turn settles are settled too. The hook exposes failure status but does not currently retain tool.failed.message or settledBy on ToolCall; those details remain in the event log, also available through onEvent. Older tool records may lack a name or output.

isRunning is an activity hint, not the session’s lifecycle status. It also includes this hook instance’s unsettled admission receipts. After reopening, a queued turn is visible in turns but does not by itself set isRunning. Use turns for queued/running counts and the session API for session status.

Answer a question

An agent can end a turn by asking a question. The hook exposes the open question as question, { id, text, options, turnId? }, or null, and answer(questionId, text, options?) sends the reply. The asking turn’s outcome is "question".

const { question, answer } = useAgent({ sessionId, basePath: "/api/agent" });

{question ? (
  <section>
    <p>{question.text}</p>
    {question.options.map((option) => (
      <button
        key={option.value}
        onClick={() => void answer(question.id, option.value, { idempotencyKey: `${question.id}:${option.value}` })}
      >
        {option.label}
      </button>
    ))}
  </section>
) : null}

answer(id, text, options) is send(text, { ...options, answers: id }) and resolves with the same receipt. options is empty for a free-text question; answer it with the person’s text. question clears when the log records question.answered or question.closed for it. Answering a question that is no longer open rejects with a SendError whose code is question_stale; the hook’s question shows the current one.

While a question is open, send without answers resolves with a held receipt, { status: "held", questionId } and no turnId: the message stays in messages, rebuilt from the log’s message.held events after a reload, and reaches the agent with the answer. dismiss(questionId) closes the question without an answer; held messages then run as ordinary turns.

Stop and reopen

stop() requests an interrupt without spending a model turn. It resolves once the request was accepted and rejects with a SendError when it was not, after setting error, so a control that showed a stopping state can be re-enabled and the request retried; a resolved promise is not confirmation that execution has stopped. Watch the running turn settle in turns. Stop affects that turn, so the next queued turn can start afterwards. The interrupt contract describes settlement and remote-command guarantees.

The hook does not expose stopping or ended session status. Read the session through your server when your interface needs that lifecycle detail. It also never suspends, resumes or ends an attached session; those operations belong to the server that created it.

Reopen by mounting the hook with the same sessionId. It reads history from after (default 0), including work completed while the page was closed, then polls for new events. A failed poll sets error, backs off and resumes from the same cursor; a successful recovery clears that polling error.

Changing sessionId replays the new session. A send still in flight for the previous session settles for its caller without changing the new session’s view. Remount your composer for the new session, for example <Chat key={sessionId} sessionId={sessionId} />, so a retained submission cannot be retried into another session.

after skips earlier events; it is not a snapshot. Start at 0 to reconstruct the conversation and turn activity. Use another cursor only when the earlier view is already retained elsewhere. See Sessions and turns for the distinction between reopening a conversation and recovering failed execution.

Bind memory

Choose memory bindings during server-side session creation; the browser cannot change them. To create a document and bind it in one helper call, see startOnDocument and its retry limits. For pinned session creation, use the explicit document and session calls.

Pass onMemorySaved to refresh your document panel through an authenticated owner route:

const agent = useAgent({
  sessionId,
  basePath: "/api/agent",
  onMemorySaved: () => refreshNotes(),
});

memorySaves also contains those events, with resource, documentId, revision and bytes. Save events are best-effort; refresh on opening the panel too, because documents can change without an event in this session.

Local development

For a local application, pass agent-id@alias instead of a session ID:

const { messages, send, sessionId, isRunning, error } = useAgent(
  "hello-world@development",
);

The first send creates a session through the authenticated development bridge. Later calls resume that session; each call streams a turn and then suspends it. Unlike attach mode, it normally waits through the turn and suspension before resolving. Failures after admission appear through error; the returned receipt still describes admission, not successful completion. Created sessions have no memory bindings, and the hook does not recover their identity across a page reload. Use attach mode for an application users will reopen.

Run the watched agent deployment and web application separately:

npm run deploy -- --watch
npm run dev:web

The first provides the development bridge at the default basePath; the second starts the web application. Credentials are not bundled into browser code.

Hook reference

ValuePurpose
messagesUser and assistant messages in log order
turnsTurns with status, input, messages, tool calls, result and failure
send(text, options?)Submit a turn; resolve with a receipt or reject with SendError
stop()Request an interrupt; rejects with SendError on a failed request, settlement arrives in turns
questionThe open question, { id, text, options, turnId? }, or null
answer(questionId, text, options?)Answer the open question; same receipt and errors as send
dismiss(questionId)Close the open question without an answer
sessionIdAttached session, or the created one after the first send
isRunningRunning activity or this instance’s unsettled admissions; not a session status
isReplayingAttach mode: existing history is still loading
errorLatest reported request or turn error
memorySavesObserved memory.saved events
cursorLast applied event position
OptionPurpose
sessionIdAttach to an existing session
agentCreate mode: agent-id@alias
basePathRoute prefix; default /api/opencomputer/managed-agents
fetchCustom fetch, for example to add a CSRF header
afterAttach mode: skip events through this position; default 0
pollIntervalMsAttach mode: empty-poll interval; default 1000 ms, 500 ms while running
onEventEvery applied event, including replayed history
onMemorySavedEach observed memory.saved event
sourceCreate mode: input source