Tools
Define TypeScript tools and expose them with useTool
A tool is TypeScript code the model may call while it works. Use tools for
actions such as searching an API, looking up an order, or updating an external
system. The model decides when a tool fits; your run function controls what
happens.
Define your first tool
Create a tool beside the agent, for example
opencomputer/agents/support/tools/lookup-order.ts:
import { defineTool } from "@opencomputer/agent";
export const lookupOrder = defineTool({
name: "lookup_order",
description: "Look up an order by ID and return its current status",
input: {
type: "object",
properties: {
orderId: { type: "string", description: "The customer order ID" },
},
required: ["orderId"],
additionalProperties: false,
},
async run({ input }) {
const orderId = String(input.orderId);
return { orderId, status: "processing" };
},
});Then attach it from agent.ts:
import { useModel, useTool } from "@opencomputer/agent";
import { lookupOrder } from "./tools/lookup-order";
export default function Agent() {
useModel("anthropic/claude-sonnet-4.6");
useTool(lookupOrder);
return "Help customers check orders. Ask for an order ID before lookup.";
}The model sees the tool’s name, description, and input schema. When it calls
the tool, OpenComputer runs run() in the managed agent runtime and returns
the result to the model.
Tool definition
| Field | Required | Purpose |
|---|---|---|
name | yes | ID the model calls; letters, numbers, underscores, and hyphens only |
description | yes | Explains what the tool does and when to use it |
input | no | JSON Schema for tool arguments |
output | no | JSON Schema describing the result; required on the result tool |
result | no | true marks this tool as the agent’s result tool; one per agent, and it must have run |
run | yes* | Synchronous or asynchronous implementation |
preview / apply | no | Supply these instead of run to wait for a person |
* A tool has either run, or preview and apply — never both, and never
just one of the pair.
Descriptions are the model’s documentation. Include the action, when it is appropriate, and any important precondition.
Execution context
The run function receives:
| Value | Purpose |
|---|---|
input | Arguments created for the tool call |
sessionId | Current session ID |
messageId | Message containing the tool call |
toolCallId | ID of this invocation, unique within the session |
agentId | Agent executing the call |
signal | Optional cancellation signal for abortable work |
reportProgress(metadata) | Emits JSON-compatible progress metadata |
Use the cancellation signal with fetch() and report progress before slow
steps:
export const importCatalog = defineTool({
name: "import_catalog",
description: "Import the latest product catalog; use only when asked",
async run({ signal, reportProgress }) {
await reportProgress({ phase: "downloading" });
const response = await fetch("https://example.com/catalog.json", {
signal,
});
await reportProgress({ phase: "processing" });
return { imported: response.ok };
},
});Tool results and progress metadata must be JSON-compatible values.
For an external write, ${sessionId}:${toolCallId} can identify the invocation
in a downstream idempotency key. It stays the same when that invocation is
retried. A new call from the model has a new ID, even if it requests the same
action; use a business-level key when those calls must also converge.
The session result
An agent may mark one tool as its result tool. Its latest committed output
is the session’s result: the structured outcome an application reads
instead of parsing the final message. This tool checks a URL itself rather
than accepting a claimed status from the model:
import { defineTool } from "@opencomputer/agent";
const checkResult = {
type: "object",
properties: {
url: { type: "string" },
status: { type: "integer" },
},
required: ["url", "status"],
additionalProperties: false,
} as const;
export const checkUrl = defineTool({
name: "check_url",
description: "Check an HTTPS URL and report its HTTP status",
input: {
type: "object",
properties: { url: { type: "string" } },
required: ["url"],
additionalProperties: false,
},
output: checkResult,
result: true,
async run({ input, signal }) {
const url = new URL(String(input.url));
if (url.protocol !== "https:") throw new Error("Use an HTTPS URL");
const response = await fetch(url, { method: "HEAD", signal });
return { url: response.url, status: response.status };
},
});The build enforces three rules: an agent declares at most one result tool,
a result tool declares output, and it runs with run() rather than
waiting for a person. The schema is
recorded with the deployment, and every value the tool returns is validated
against it.
A call to the result tool succeeds only once its output is committed to the session:
- The value
run()returns is validated againstoutputand committed before the model is told the call succeeded. The session’sresultbecomes{ turnId, callId, reportedAt, data }, and the call’stool.completedevent carriesresult: true(Session events). - A value that does not match
output, or exceeds 8 KiB of JSON, fails the call; the model sees the reason and can call again. The previous result stands. - A call from a turn that was interrupted or has ended, or from a runtime the session no longer runs on, is refused the same way.
- A later call replaces the entire value. Reporting does not finish the turn. A subsequent turn that fails or is cancelled does not clear the result, and the event log keeps every call.
The result says what the agent reported, not that the work succeeded.
Verify claims inside run(), where a tool can check that a branch exists
or a pull request is open, and throw to reject them; let the application
check what the result references. Read it from
GET /sessions/<id>. For a review-ready UI, also
check current activity and whether result.turnId belongs to the last settled
turn. An existing result may describe earlier work.
If the commit acknowledgement is lost, the tool can fail even though its
result was saved. OpenComputer retries the commit for the same tool call;
that deduplicates the saved result, not external writes inside run(). Prefer
tools that verify and report existing work. Give any external write its own
idempotency key.
Schema declarations
The result tool’s output is copied into the deployment without evaluating
code. Use one of these forms:
- An object literal in the
defineTool()call. - A
constobject literal in the same module, as above. - A named import of such a
constfrom a module inside the agent directory.
Function calls, let bindings, namespace members such as schemas.report,
package imports and re-exports are not supported for this output schema.
The build error names the unsupported form. input may use these static
forms or a schema constructed when the tool module loads; it is not subject
to result-output extraction. Neither schema infers the TypeScript type of
run’s input, which remains Record<string, unknown>. as const preserves
the schema’s literal types for your own typing and validation.
Write name, result and output as ordinary properties on the
defineTool() object. Spreads, computed property names, and a result value
other than literal true or false fail the build. A tool module whose
result declaration disagrees with its deployment fails to load.
Conditional tools
useTool() may be conditional. This keeps sensitive or specialized actions
out of the tool set until they are relevant:
import { useInput, useTool } from "@opencomputer/agent";
import { issueRefund } from "./tools/issue-refund";
export default function Agent() {
const input = useInput();
if (input.text?.toLowerCase().includes("refund")) {
useTool(issueRefund);
}
return "Resolve the support request. Confirm the order before a refund.";
}useTool() also accepts a tool ID such as useTool("web-search") when the
runtime already provides that tool.
Give an agent a sandbox shell
Select the built-in sandbox_exec tool when an agent needs to run commands,
inspect files, or use installed CLI packages:
import { useTool } from "@opencomputer/agent";
export default function Agent() {
useTool("sandbox_exec");
return "Use the sandbox for shell commands and keep durable work in /workspace.";
}The agent runtime starts without a VM. The first sandbox_exec call lazily
acquires an isolated computer for the session, and later calls in that session
reuse it. Files that must survive restarts belong in /workspace; temporary
paths such as /tmp do not provide that persistence guarantee.
If your organization has an Enterprise custom package image, sandbox_exec
uses that image automatically. The agent definition still selects the same
tool ID, and cannot choose or override the image itself. You can see the
package image assigned to your organization under Settings → Customize VM
packages.
Call external APIs safely
For authenticated HTTP APIs, declare a connection, store the key as an
OpenComputer secret, and call the connection’s fetch() method. This keeps
the credential outside the agent runtime; see
Secrets and outbound requests.
Managed GitHub connections are the exception: they supply
short-lived environment credentials so Git and gh work directly. Never
embed credentials in tool source or return them to the model.
For Google services, GitHub, or Linear, the credential belongs to whoever
connected the account and the platform refreshes it — call those with
callService instead.
Ask a question
ask is the platform’s question tool. Select it with useTool("ask") when a
turn may need to stop and ask a person before going on:
import { useInput, useTool } from "@opencomputer/agent";
export default function Agent() {
const input = useInput();
if (input.answer) {
return `The answer was ${input.answer.value ?? input.answer.text}. Proceed accordingly.`;
}
useTool("ask");
return "Read the request, propose an approach, and ask whether to go ahead.";
}The model calls it with:
| Field | Bounds |
|---|---|
text | The question, 1 to 4000 characters |
options | Optional, at most six { label, value } choices; each label and value 1 to 80 characters |
Calling ask ends the turn with an open question. The turn completes with
outcome: "question", the session’s question holds what was asked, and
question.asked is recorded in the event log.
The reply arrives as the next input with
answer set, and anything written
meanwhile arrives with it as steering, within
bounds. A session has at most one open
question; the turn that interprets the answer may ask again.
How the question reaches a person depends on where the session lives: an application answers it through the API or the React hook; a Slack or Linear session shows it in the thread with the options as buttons, and a click or a typed reply answers it.
ask records that the agent asked. It does not undo or forbid anything the
turn did before asking. A turn meant only to propose should select ask and
tools that cannot write, and select the writing tools on the turn that
carries the answer.
The id ask is reserved: defineTool({ name: "ask" }) is refused, both by
defineTool and by the CLI’s build.
ask is different from waiting for a person:
ask ends a turn with an open question the agent reasons about when it is
answered, while an approval tool records one exact write that runs, unchanged,
once someone approves it.
Wait for a person before writing
Some writes should not happen because a model decided they should. Such a tool proposes instead: the model calls it, nothing is written, and the person the agent is talking to sees a card with the exact change and decides.
There is no separate primitive for this. Give defineTool a preview and an
apply instead of a run, and the model’s call becomes a proposal. run is
written for you. useTool selects the tool exactly as it selects any other,
and the model cannot tell the difference — waiting for approval is a property
of a tool, not a different kind of thing.
import { defineTool } from "@opencomputer/agent";
import { billing } from "./connections/billing";
export const attach = defineTool({
name: "attach",
description: "Move a customer onto a plan",
input: {
type: "object",
required: ["customerId", "planId"],
properties: {
customerId: { type: "string" },
planId: { type: "string" },
},
},
// What the person sees. It may read; it must not write.
async preview({ input, signal }) {
const customer = await billing
.fetch(`/customers/${String(input.customerId)}`, { signal })
.then((response) => response.json());
return {
title: `Move ${customer.name} to ${String(input.planId)}`,
facts: [
{ label: "Today", value: customer.plan },
{ label: "After", value: String(input.planId) },
{ label: "Charged now", value: customer.nextCharge },
],
};
},
// Runs only once somebody approves, from the arguments on the card.
async apply({ input, decision, signal }) {
return billing.fetch("/subscriptions", {
method: "POST",
headers: { "Idempotency-Key": decision.id },
body: JSON.stringify({
customer: input.customerId,
plan: input.planId,
}),
signal,
});
},
});The model calls attach like any other tool and is told the change was
recorded for approval, so it stops rather than reporting the work as done.
apply runs later, with the arguments that were on the card — no second pass
through the model, so what happens is what was agreed to. The proposing session
is usually gone by then, and that is fine: apply needs its arguments, not the
conversation.
Always pass decision.id
decision.id identifies this approval, and it is the same value on every
attempt to carry it out. Give it to whatever you call as an idempotency key.
If a write never reports back — the runtime died mid-flight, the answer was lost — it is recorded as unconfirmed rather than failed, because it may well have happened. That is only recoverable if running it again is safe, and this is what makes it safe.
What this does and does not guarantee
Approval is a convention your code cooperates with. Nothing stops a tool from
writing inside preview. What the platform guarantees is narrower and
still worth having: an approved write runs once, from the arguments the
person agreed to, whether or not the session that proposed it still exists.
A tool that waits needs a conversation to ask in. Called from the playground, a schedule or a webhook, there is nobody to ask, and the call fails saying so.
Computer commands
A session gets an isolated computer when it needs one; conversation alone
does not require a computer. The model runs commands through the built-in
shell tool. A command runs in the session’s workspace and takes command
and an optional timeoutSeconds: 120 by default, at
most 1800, and never more than the computer’s remaining lifetime allows; a
value out of range fails the call before anything runs. A command is not
started on a computer that cannot host it for its full timeout; a fresh
computer is used instead.
When the timeout passes, the command and every process it started are stopped, and the call fails as a tool error the model sees: it names the command and the timeout and carries the output so far. The turn continues, and files the command wrote before it was stopped remain.
A command counts as stopped only once that is confirmed: every process it started, detached ones included, is gone, or the computer itself was terminated. When the computer stops answering while a command runs, its outcome is unknown, and the call fails with that as the reason; the model is never told a command was stopped when that was not confirmed. That computer runs no further command until the unknown one is confirmed stopped or the computer is replaced by a fresh one with the session’s workspace, which the next command does on its own. See End and interrupt for cancellation guarantees and the compatibility limit for sessions using the older runtime.
Tools versus skills and MCP
- A tool executes TypeScript you own.
- The same tool with
previewandapplyinstead ofrunwaits for a person before it does it. - A skill teaches the agent a reusable procedure.
- An MCP server supplies a remote collection of tools.