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/reactCreate 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:
| Route | Purpose |
|---|---|
GET /sessions/<id>/events?after=<seq> | Read conversation history and new events |
POST /sessions/<id>/turns | Submit { input, idempotencyKey, payload?, answers? } |
POST /sessions/<id>/interrupt | Request 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
queuedorrunning. - A repeated key returns the existing turn and its current status, including
completed,failedorcancelled, withduplicate: 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
| Option | Purpose |
|---|---|
idempotencyKey | One key per submission, reused on retries. Without it, each call generates a new key and can admit a new turn. |
payload | Structured JSON available to the agent as useInput().payload, beside the text |
answers | The 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:
code | Meaning | status |
|---|---|---|
The API’s code, such as session_ended, memory_admission_unconfirmed or insufficient_credits | An error response; follow that code’s recovery contract | HTTP status |
network_error | Transport or response-reading failure; admission may have succeeded | none |
invalid_response | The response was not a turn receipt; admission may have succeeded | none |
empty_input | Blank text; nothing sent | none |
busy | Create mode only: a turn already runs; nothing sent | none |
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 field | Meaning |
|---|---|
id, status, input | Turn ID, lifecycle status and admitted text |
messages | This turn’s messages, also present in the hook’s messages |
toolCalls | { callId, tool, title, input?, output?, status } in start order; values are decoded JSON |
result | This 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:webThe first provides the development bridge at the default basePath; the
second starts the web application. Credentials are not bundled into browser
code.
Hook reference
| Value | Purpose |
|---|---|
messages | User and assistant messages in log order |
turns | Turns 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 |
question | The 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 |
sessionId | Attached session, or the created one after the first send |
isRunning | Running activity or this instance’s unsettled admissions; not a session status |
isReplaying | Attach mode: existing history is still loading |
error | Latest reported request or turn error |
memorySaves | Observed memory.saved events |
cursor | Last applied event position |
| Option | Purpose |
|---|---|
sessionId | Attach to an existing session |
agent | Create mode: agent-id@alias |
basePath | Route prefix; default /api/opencomputer/managed-agents |
fetch | Custom fetch, for example to add a CSRF header |
after | Attach mode: skip events through this position; default 0 |
pollIntervalMs | Attach mode: empty-poll interval; default 1000 ms, 500 ms while running |
onEvent | Every applied event, including replayed history |
onMemorySaved | Each observed memory.saved event |
source | Create mode: input source |