@berthos/agents is Berth's agent framework: boot a Computer (a sandbox loaded with resident apps), attach an Agent to it, and compose agents into crews. Every tool an agent calls is a resident-app export running under that app's kernel-enforced berth.yml policy. Bring any LLM provider.
Experimental, and not published. The framework lives in
experimental/and is frozen: bug and security fixes only (why). It isn't on npm. Use it from a clone of this repo, as a"@berthos/agents": "workspace:*"dependency. Releases ship the sandbox only. To put your own agent loop on Berth without this framework, useberth mcp, the framework adapters, or the HTTP RPC bridge.
git clone https://github.com/Ash20pk/BerthOS && cd BerthOS
corepack enable && pnpm install && pnpm buildimport { Computer, createAgent, createAnthropicProvider } from "@berthos/agents";
const computer = await Computer.boot({ apps: ["apps/filesystem", "./my-custom-app"] });
const { agent } = await createAgent({ computer, llm: createAnthropicProvider() });
const result = await agent.run("write a file called hello.txt with the text 'hi', then read it back");
await computer.stop();A Python version of Agent and Crew connects to a running sandbox: see the Python agents reference.
@berthos/agents runs inside your own Node.js program. It connects three things: an LLM provider's API, which decides what to do next; a Berth sandbox (a Computer), whose resident-app exports are the tools; and, if you add them, tools from outside Berth, such as external MCP servers and A2A agents. Every tool call that reaches the sandbox runs under the app's kernel-enforced berth.yml policy.
Other frameworks can use the same tools through adapters, and a Python program reaches a running sandbox over the HTTP RPC bridge. For how the sandbox sits in the rest of Berth, see the README and the Berth OS reference.
Your program holds the Agent loop and calls the LLM provider itself. Tool calls go to a Computer: one Docker container with the resident apps loaded. With one app, calls go over the container's stdio; with more, or on a Computer from berth os up, they go through docker exec to each app's Unix socket. External MCP servers run outside the sandbox, as child processes or over HTTP. A networked peer is a second container that runs its own agent loop. A process that can't reach Docker uses the HTTP RPC bridge instead.
A Computer is one Docker container with one or more resident apps loaded. Every export of every app becomes a Tool. It knows nothing about LLMs.
const computer = await Computer.boot({ apps: ["apps/filesystem", "./my-custom-app"] }); // fresh
const shared = await Computer.connect({ name: "my-agent" }); // a `berth os up` instance
const scoped = await Computer.connect({ name: "my-agent", apps: ["filesystem"] }); // only some of its appsPass any of them to createAgent({ computer }). One Computer can back several agents, each with a different subset of computer.tools. You own its lifecycle: createAgent() never calls stop() on it.
| Option | Default | What it does |
|---|---|---|
apps: string[] |
required | Directories, each with a berth.yml. |
network |
none | Docker network to join, so peers resolve each other by name. |
env |
none | Extra container environment variables. |
governance |
{ mode: "fail-closed" } |
Governance gate options. |
httpRpc: boolean | { app? } |
off | Starts the HTTP RPC bridge. |
enforcement: "required" | "warn" |
"required" |
See Enforcement. |
docker |
new Docker() |
A dockerode client. |
Computer.connect({ name, apps?, governance?, docker? }) takes the name given to berth os up. Naming an app the instance hasn't loaded throws an error listing the ones it has.
The handle has tools, call(toolName, input), stop(), httpRpc ({ url, authToken, appName? } when the bridge is on), containerName, apps and governance. stop() removes the container and the image boot() built for it; on a connected Computer it does nothing.
- Tool names are the export name (
write_file) with one app loaded, and<appName>__<exportName>(filesystem__write_file) with more. - Readiness. Calls retry with backoff for up to 30 seconds while the apps start. If the container exits during startup,
boot()fails with its logs.
Computer.boot() sets BERTH_REQUIRE_ENFORCEMENT=1, so if the kernel can't enforce the app's policy, the app doesn't start and boot() fails. Enforcement needs Linux 6.7+ with Landlock, which Docker Desktop lacks. berth doctor checks; on a Mac, berth doctor --fix sets up a VM that has it (by platform).
It also sets BERTH_REQUIRE_APP_CGROUPS=1, so if the host can't give each app its own cgroup (cgroup v2 with nsdelegate, Docker 28+), boot() fails rather than running the apps bounded only by the container's caps. Pass env: { BERTH_REQUIRE_APP_CGROUPS: "0" } to relax only that. See resource limits.
For local iteration without Landlock, Computer.boot({ enforcement: "warn" }) or BERTH_ALLOW_UNENFORCED=1 on the host runs the app unrestricted with a warning on every boot, and doesn't require per-app cgroups either. The option wins over the env var. Neither gives any isolation.
Computer.boot() builds an image and starts a container every time. berth os up does it once and leaves the container running, so agent code connects instead of rebuilding.
berth os up my-agent --apps=apps/filesystem,apps/notes
berth os status
berth os down my-agent| Flag | What it does |
|---|---|
--apps=<dir>,<dir> |
App directories to load. |
--config=<path> |
YAML with name, apps: [...], optional network. |
--network=<name> |
Join a Docker network. |
--http-rpc |
Start the HTTP RPC bridge. |
--http-rpc-app=<name> |
Which app binds the bridge. Defaults to the first. |
const { agent, computer } = await createAgent({ connect: "my-agent", llm: createAnthropicProvider() });Computer.connect() reads ~/.berth/os/<name>.json and reaches each app over docker exec. Since stop() does nothing on a connected Computer, runAgent({ connect: "my-agent", task }) is safe to call repeatedly. berth os up targets local Docker only.
For a process without Docker API access, such as a Python script or a client on another machine, berth os up --http-rpc starts an HTTP RPC bridge in the container and publishes its port.
berth os up my-agent --apps=apps/filesystem --http-rpc
# "my-agent" is up.
# HTTP RPC bridge: http://127.0.0.1:54321 (bearer token recorded in ~/.berth/os/my-agent.json — see docs/agents-python-reference.md for the Python client).The bridge serves POST /rpc and GET /healthz, gated by a bearer token generated on each boot and recorded with the URL in ~/.berth/os/<name>.json. Computer.boot({ httpRpc: true }) does the same for an ephemeral Computer and sets computer.httpRpc.
- One app per bridge. It can only reach one app's exports. Choose which with
--http-rpc-apporhttpRpc: { app }. - Bare export names (
write_file, notfilesystem__write_file). - The token is the whole boundary. Whoever holds it can call every export of that app.
The Python Computer.connect() uses this bridge.
apps/code-interpreter runs code inside the agent's sandbox:
const { agent } = await createAgent({ apps: "apps/code-interpreter", llm: createAnthropicProvider() });
await agent.run("write a Python one-liner that prints the first 10 Fibonacci numbers and run it");run_code({ language: "python" | "javascript" | "shell", code, timeout_ms? }) runs python3 -c, node -e or bash -c as a subprocess and returns { stdout, stderr, exit_code, timed_out }.
timeout_ms defaults to 10 seconds, capped at 60; a timed-out process reports timed_out: true. Output past 200,000 characters per stream is truncated. The app declares only filesystem:write:/workspace, so the code has no network access, enforced by the kernel (Landlock for TCP; seccomp and a dropped CAP_NET_RAW for UDP, ICMP and raw sockets).
bootNetworkedAgent() boots a Computer that runs its own agent loop over its own apps: the agent lives in the sandbox with its tools. Crew.networked() gives a host-side manager one tool per peer.
import { Agent, Crew, createOpenAIProvider, bootNetworkedAgent } from "@berthos/agents";
const env = { OPENAI_API_KEY: process.env.OPENAI_API_KEY! };
const filer = await bootNetworkedAgent({
name: "filer",
apps: ["apps/filesystem"],
llm: { provider: "openai", apiKeyEnvVar: "OPENAI_API_KEY" },
systemPrompt: "You write and read files when asked.",
env,
});
const notetaker = await bootNetworkedAgent({ name: "notetaker", apps: ["apps/notes"], llm: { provider: "openai", apiKeyEnvVar: "OPENAI_API_KEY" }, env });
const manager = new Agent({ name: "manager", llm: createOpenAIProvider(), tools: [] });
const crew = Crew.networked({ manager, peers: [filer, notetaker] });
await crew.run("save a note about today's standup, then write it to a file too");
await Promise.all([filer.stop(), notetaker.stop()]);Options: name (also the tool's name), apps, llm, systemPrompt, network (default berth-agent-net), fleet (next section), env and docker. llm is { provider: "anthropic" | "openai", model?, apiKeyEnvVar }: the peer reads its key from that variable, so pass the value in env. Custom LLMProviders aren't supported, because the loop runs in a generated companion app (<name>-agent-server, export run_task). It returns { computer, tool, transport: "local" | "http", stop() }.
- Host-mediated. The manager reaches each peer through the host. Peers share a Docker network, but nothing dispatches over it.
- Unrestricted egress. The companion app declares
network:connect:*to reach the LLM API; the egress broker doesn't scope it. - No auth or TLS between peers, and one Docker host only. For an encrypted, mutually authorised tunnel, see
network:peer:<name>in the mesh reference;Crew.networked()doesn't use it.
bootNetworkedAgent({ fleet: { adapter, port? } }) deploys the peer to a remote fleet. The manager's tool is the same; transport is "http".
The image is built locally and deployed with a DeployAdapter that implements rpcUrl() (E2B, Daytona and K8s do). The peer serves the HTTP RPC bridge on port (default 7300) behind a per-boot bearer token. Calls start once the instance is running and /healthz answers. E2B and Daytona use their public preview URLs; on K8s, rpcUrl() creates a NodePort Service (K8s adapter).
For a remote Computer without an agent, use HttpBridgeComputer.deploy({ adapter, port, imageRef, manifest, apps, rpcAppName?, env?, readyTimeoutMs?, governance? }). Only the rpcAppName app is reachable.
- The token grants every export on the instance, over the network. Keep it secret.
- K8s needs reachable node IPs. Fine on
kind, local and on-prem clusters; a managed cluster that firewalls node IPs won't be reachable.
Inside @berthos/agents, in your program: the Tool and LLMProvider interfaces everything plugs into, the Agent loop, the governance gate and guardrails around it, Crew for composing agents, and the stores a run writes to (checkpoints, sessions, retrieval, traces), each with a Semantic FS backend in the Computer.
Resident-app exports, MCP tools, A2A peers and other agents (via asTool()) are all Tools, so one tool list can mix them.
interface Tool {
name: string;
description: string;
inputSchema: object; // JSON Schema
invoke(input: unknown, ctx?: { signal?: AbortSignal }): Promise<unknown>;
}
interface LLMProvider {
readonly name: string;
chat(params: LLMCallParams): Promise<LLMTurn>;
chatStream?(params: LLMCallParams, onText: (delta: string) => void): Promise<LLMTurn>;
}
interface LLMCallParams { system?: string; messages: AgentMessage[]; tools: Tool[]; signal?: AbortSignal }
interface LLMTurn {
text?: string;
toolCalls: { id: string; name: string; input: unknown }[];
stop: boolean;
stopReason?: "end" | "tool_calls" | "length" | "content_filter" | "refusal" | "other";
usage?: { inputTokens: number; outputTokens: number };
}Implement LLMProvider for any API not listed below. A tool's input schema comes from the app's berth.yml, whose exports: grammar is flat (string | number | boolean | object | array), not from the Zod schema in the app's code.
Pass onText to run(), resume() or runAgent() to receive assistant text as it's generated:
const result = await agent.run("long task", { onText: (delta) => process.stdout.write(delta) });It needs a provider with chatStream (all built-in ones have it); otherwise it's ignored. Tool-call arguments aren't streamed.
| Factory | Options | Defaults |
|---|---|---|
createAnthropicProvider() |
apiKey, baseURL, model, maxTokens, maxRetries |
ANTHROPIC_API_KEY, claude-sonnet-5, 4096 max tokens |
createOpenAIProvider() |
apiKey, baseURL, model, maxTokens, maxRetries |
OPENAI_API_KEY, gpt-4o |
createGoogleProvider() |
apiKey, vertexai, project, location, model, baseUrl |
GOOGLE_API_KEY or GEMINI_API_KEY, gemini-2.5-flash |
createAzureOpenAIProvider() |
apiKey, endpoint, deployment, apiVersion |
AZURE_OPENAI_API_KEY, AZURE_OPENAI_ENDPOINT, AZURE_OPENAI_DEPLOYMENT (required), AZURE_OPENAI_API_VERSION or 2024-10-21 |
createBedrockProvider() |
apiKey, awsRegion, baseURL, model |
AWS_BEARER_TOKEN_BEDROCK, AWS_REGION/AWS_DEFAULT_REGION, AWS_BEDROCK_BASE_URL, anthropic.claude-sonnet-5 |
createOllamaProvider() |
baseURL, model |
http://127.0.0.1:11434/v1, llama3.1 |
Google uses the native Gemini API; vertexai: true with project and location (or GOOGLE_CLOUD_PROJECT / GOOGLE_CLOUD_LOCATION) uses Vertex AI with Application Default Credentials. Bedrock uses its OpenAI-compatible endpoint with bearer-token auth. baseURL points the Anthropic or OpenAI provider at any compatible endpoint (vLLM, OpenRouter, a proxy).
With llm omitted, detectLLMProvider() uses the first key set: ANTHROPIC_API_KEY, OPENAI_API_KEY, then GOOGLE_API_KEY / GEMINI_API_KEY. llm also takes plain data, resolved by resolveLLMProvider():
llm: { provider: "openai", apiKey: "...", baseURL: "https://my-endpoint/v1", model: "..." }provider is "anthropic", "openai", "google" or "ollama". Azure and Bedrock need their factories.
The Anthropic and OpenAI clients retry 429s, 5xxs and timeouts twice by default; set maxRetries to change it. For a provider that's down, createFallbackProvider(providers, options?) tries each in order. The last one's error propagates unchanged.
const llm = createFallbackProvider(
[createAnthropicProvider(), createOpenAIProvider()],
{ onFallback: (err, failed, next) => console.error(`${failed.name} failed, trying ${next.name}:`, err) },
);
const { agent } = await createAgent({ apps: "apps/filesystem", llm });- It falls through on retriable and unrecognised errors, and stops on non-retriable ones (invalid request, context too long). Override with
shouldFallThrough(err, failedProvider). Cancellation always propagates. - It has
chatStreamonly if every provider in the chain does. A mid-stream fallback restarts the text, soonTextmay have seen partial output.
Agent runs the tool-use loop until the model answers without calling a tool, or maxTurns is reached. Construct one directly for options createAgent() doesn't forward:
import { Agent, createAnthropicProvider } from "@berthos/agents";
const agent = new Agent({
llm: createAnthropicProvider(),
tools: computer.tools,
timeoutMs: 120_000,
toolTimeoutMs: 30_000,
context: { maxInputTokens: 150_000 },
});AgentOptions: llm and tools (required), name, systemPrompt, maxTurns (default 25), checkpoint, trace, actor, tracePayloads, inputGuardrails, outputGuardrails, timeoutMs, toolTimeoutMs, context, and governance (set by createAgent()).
| Method | What it does |
|---|---|
run(input, opts?) |
Runs a task. Returns { text, toolCalls: { name, input, result }[], data? }. |
resume(runId, opts?) |
Continues a checkpointed run. See Checkpointing. |
withTools(tools) |
Returns a new Agent with extra tools. The original is unchanged. |
asTool(description) |
Wraps the agent as a Tool: { task: string } in, run(task).text out. |
run() options: runId, onText, session, signal, timeoutMs (overrides the agent's), responseSchema, maxRepairAttempts. resume() takes the same except runId and session.
A tool call that throws (bad arguments, a handler error, a governance denial, an unknown tool, a tool timeout) doesn't end the run. The model gets { error: <message> } as the result and can react. Zod errors are reformatted as path: message; path: message. Only a GuardrailTripwireError thrown from a tool, or cancellation, ends the run.
| Control | Effect |
|---|---|
signal (per run) |
Aborts the in-flight LLM call and tool call. run() rejects with RunAbortedError. |
timeoutMs |
Wall-clock limit on the whole run. run() rejects with RunTimeoutError. |
toolTimeoutMs |
Limit on one tool call. The model gets a ToolTimeoutError as that call's result; the run continues. |
Agents called through asTool() inherit the caller's signal. A tool that ignores its signal may keep running after the loop moves on.
context is a ContextPolicy bounding what's sent on each call:
| Field | Default | What it does |
|---|---|---|
maxInputTokens |
unset | Budget for system prompt, tool schemas and history together. Unset turns off proactive compaction. |
reserveTokens |
1024 | Headroom held back from the budget. |
keepRecentMessages |
6 | Messages at the end that are never dropped. |
summarize |
unset | (dropped) => Promise<string>. Replaces dropped messages with one message prefixed [earlier conversation, summarized]. One extra LLM call per compaction. |
Tokens are estimated as characters ÷ 4 (estimateTokens() and compactMessages() are exported). Compaction never splits a tool call from its result. With no policy, a context-length error from the provider still triggers one trim-and-retry. Each compaction emits a context-compaction trace event.
Framework errors extend BerthAgentError and carry a stable code.
| Error | code |
When |
|---|---|---|
MaxTurnsExceededError |
max_turns_exceeded |
The loop hit maxTurns. |
UnknownToolError |
unknown_tool |
No such tool (returned to the model). |
CheckpointNotFoundError |
checkpoint_not_found |
resume() found no checkpoint. |
CheckpointStoreMissingError |
checkpoint_store_missing |
resume() with no checkpoint store. |
RunAbortedError |
run_aborted |
The caller's signal fired. |
RunTimeoutError |
run_timed_out |
timeoutMs elapsed. |
ToolTimeoutError |
tool_timed_out |
toolTimeoutMs elapsed (returned to the model). |
ProviderError |
provider_error |
Base for provider failures, with a retriable flag (is another provider worth trying). |
RateLimitError, ProviderAuthError, ProviderUnavailableError |
provider_rate_limited, provider_auth_failed, provider_unavailable |
Retriable. |
ContextLengthExceededError, ProviderRequestInvalidError |
provider_context_length_exceeded, provider_request_invalid |
Not retriable. |
Also thrown, extending Error directly: GuardrailTripwireError, StructuredOutputError, GovernanceDeniedError, GovernanceUnavailableError, CheckpointReadError, and TruncatedResponseError (the provider stopped for length, content_filter or refusal).
An app with governs: true in its berth.yml gates every other app's tool calls: each goes to its evaluate_action export first and runs only on { allowed: true }. MCP tools from createAgent() are gated as app mcp:<server>, and asTool() delegation as app agent:<name>, export invoke. The check runs in Computer, not the kernel.
A denial throws GovernanceDeniedError, which the model sees as a tool error. The gate fails closed: an unreachable governor throws GovernanceUnavailableError, unless you pass governance: { mode: "fail-open" }. Details: governance reference.
For a human in the loop, write a governance app whose evaluate_action waits for a person, or wrap a tool's invoke. Throw GuardrailTripwireError for a refusal, so the run ends instead of the model retrying.
Guardrails check the agent's input and final answer.
import { createAgent, createAnthropicProvider, createKeywordGuardrail, createLlmGuardrail } from "@berthos/agents";
const { agent } = await createAgent({
apps: "apps/filesystem",
inputGuardrails: [createKeywordGuardrail(["ignore previous instructions"])],
outputGuardrails: [createLlmGuardrail({ judge: createAnthropicProvider(), rubric: "does not leak API keys or secrets" })],
});
await agent.run("..."); // throws GuardrailTripwireError if either tripsA Guardrail is (text: string) => GuardrailResult | Promise<GuardrailResult>, where GuardrailResult is { tripwireTriggered: boolean, message?: string }.
inputGuardrailsrun onrun()'s input before the first LLM call, not onresume().outputGuardrailsrun on every final answer. A trip checkpoints the run as"error".- They run in order and stop at the first trip, which throws
GuardrailTripwireError(stage: "input" | "output") and ends the run. Streamed deltas aren't checked.
| Built-in | What it checks |
|---|---|
createKeywordGuardrail(words, { caseSensitive? }) |
A fixed list of words or phrases. Case-insensitive by default. |
createRegexGuardrail(pattern, message?) |
A regular expression. |
createLlmGuardrail({ judge, rubric }) |
An LLM judges the text against a rubric. An unparseable verdict counts as tripped. |
runGuardrails(guardrails, text, stage) runs a list yourself.
Crew functions compose agents with plain wiring over Agent.run(). There's no graph DSL.
| Shape | What it does | runId |
checkpoint |
responseSchema |
|---|---|---|---|---|
Crew.sequential(agents, opts?) |
Pipes each agent's output into the next. | yes | yes | yes |
Crew.withManager({ manager, workers, runId? }) |
Gives the manager one tool per worker (worker.asTool()); its LLM decides when to delegate. |
manager only | no | no |
Crew.networked({ manager, peers, runId? }) |
Like withManager, with each worker in its own Computer. See below. |
manager only | no | no |
Crew.parallel(agents, { merge?, runId? }) |
Runs all agents on the same input at once. Default merge puts each output under ## <name>. |
per agent | no | no |
Crew.loopUntil({ agent, until, maxIterations? }) |
Feeds the agent its own output until until(result, iteration) is true. Runs at least once; maxIterations defaults to 10. |
yes | yes | no |
Crew.route({ router, routes, fallback? }) |
The router picks one of routes' keys (case-insensitive); that agent runs on the original input. No match uses fallback, or throws. |
yes | no | yes |
Crew.pipeline<S>(steps, opts?) |
Threads a typed state object through step functions. | step argument | yes | no |
All but pipeline return { run(input: string): Promise<string> }.
const crew = Crew.route({
router: classifierAgent,
routes: { billing: billingAgent, support: supportAgent },
fallback: generalAgent,
});
await crew.run("where's my refund?"); // runs billingAgentCrew.pipeline steps are (state: S, runId?: string) => Partial<S> | Promise<Partial<S>>, run in order, each update shallow-merged into the state.
type State = { document: string; summary?: string; wordCount?: number };
const crew = Crew.pipeline<State>([
async (state) => ({ summary: (await summarizerAgent.run(state.document)).text }),
(state) => ({ wordCount: state.summary?.split(" ").length ?? 0 }),
]);
const result = await crew.run({ document: "..." }); // document, summary and wordCount all setFor withManager and networked, put checkpoint or responseSchema on the manager. parallel gives each agent the run id <runId>:<index>:<name>.
With a checkpoint store and a runId, run() saves after every turn, and resume(runId) continues after a crash.
const { agent, computer } = await createAgent({ apps: "apps/filesystem", checkpoint: "semantic-fs" });
await agent.run("long task", { runId: "task-42" });
// ...the process crashes, or you come back later...
const result = await agent.resume("task-42");A checkpoint is { runId, agentName, status: "running" | "done" | "error", turnCount, messages, toolCalls, text? }, where turnCount is the next turn. Resuming a "done" run returns the saved answer without calling the model.
"semantic-fs" (createSemanticFsCheckpointStore(computer)) writes /context/agent-runs/<runId>.json. It needs an app with write_context_file, read_context_file and tag_context_file (apps/filesystem has them) and throws at construction otherwise. Other backends implement CheckpointStore:
interface CheckpointStore<T extends { runId: string } = CheckpointedRun> {
save(checkpoint: T): Promise<void>;
load(runId: string): Promise<T | null>; // null means no checkpoint exists
}Progress is saved after each tool call. On resume, a tool call from the last turn with no recorded result runs again, so a call in flight during a crash can run twice. The built-in store throws CheckpointReadError for a checkpoint that exists but can't be read.
Crew.sequential, Crew.loopUntil and Crew.pipeline accept checkpoint and runId and save after every step. Calling run() again after a crash resumes at the next step.
const crew = Crew.sequential([draftAgent, reviewAgent], { checkpoint: createSemanticFsCheckpointStore(computer), runId: "notes-7" });The saved shape is CrewCheckpoint<S>: { runId, kind, status, completedSteps, state }, stored under crew__<runId> so it can't collide with the agents' own checkpoints. A "done" checkpoint returns the saved state.
A Session carries history across separate run() calls, such as the turns of a chat.
import { createAgent, createSemanticFsSession } from "@berthos/agents";
const { agent, computer } = await createAgent({ apps: "apps/filesystem" });
const session = createSemanticFsSession(computer, "user-42-chat");
await agent.run("what's the capital of France?", { session });
await agent.run("and its population?", { session }); // sees the prior turnA Session is { getItems(), addItems(items), clear() }. run() prepends its items and, after a successful answer, adds the input, tool calls and answer. A run stopped by an output guardrail adds nothing. resume() ignores sessions.
| Backend | Storage |
|---|---|
createInMemorySession(initial?) |
Process memory. |
createSemanticFsSession(computer, sessionId) |
/context/agent-sessions/<sessionId>.json, via the three context-file exports. |
Stored history grows without limit. A context policy bounds what's sent to the model.
retriever: "semantic-fs" adds a search_context tool that searches Semantic FS and returns documents with their content in one call.
const { agent } = await createAgent({ apps: "apps/filesystem", retriever: "semantic-fs" });
// search_context: { query, topK? } -> { documents: [{ path, content, task?, relatedApps? }] }
await agent.run("what did we decide about the pricing page?");createSemanticFsRetriever(computer) needs query_context and read_context_file. Its retrieve(text, { topK? }) returns up to topK documents (default 5), skipping unreadable hits; asTool(name?) wraps it as a tool. Implement Retriever (retrieve, asTool) for another backend. Search semantics: Semantic FS reference.
ingest(computer, source, text, options?) splits a document and writes and tags each chunk:
import { ingest } from "@berthos/agents";
const paths = await ingest(computer, "onboarding-guide", longDocumentText);
// ingested/onboarding-guide.txt, or ingested/onboarding-guide-0.txt, -1.txt, ... when splitOptions: pathPrefix (default ingested/<slug of source>), task (default source), relatedApps (default []) and chunk (default chunkText). chunkText(text, { maxChars?, overlapChars? }) splits into 2000-character windows with 200 characters of overlap, preferring paragraph and sentence breaks.
Pass a Zod schema as responseSchema to get a validated final answer:
import { z } from "zod";
const schema = z.object({ name: z.string(), age: z.number() });
const result = await agent.run("extract the person's name and age from: ...", { responseSchema: schema });
result.data; // { name: string; age: number }The final answer is parsed as JSON and validated. On failure the error goes back to the model, asking for corrected JSON, up to maxRepairAttempts times (default 2); then run() throws StructuredOutputError with the last rawText. A valid first answer costs nothing extra.
Crew.sequential (re-prompts its last agent) and Crew.route (re-prompts the chosen branch) accept the same options. parseStructuredOutput(text, schema) and structuredOutputRepairPrompt(error) are exported.
With trace set and a runId passed, the agent emits one AgentStepEvent per LLM turn and per tool call:
import { createAgent, readAgentTrace } from "@berthos/agents";
const { agent, computer } = await createAgent({ apps: "apps/filesystem", trace: "full" });
await agent.run("long task", { runId: "task-42" });
const trace = await readAgentTrace(computer, "task-42"); // every step, in orderinterface AgentStepEvent {
runId: string;
agentName: string;
actor?: Actor;
turn: number;
kind: "llm-turn" | "tool-call" | "context-compaction";
toolName?: string; // tool-call only
durationMs: number;
droppedMessages?: number; // context-compaction only
error?: string;
usage?: { inputTokens: number; outputTokens: number }; // llm-turn, when the provider reports it
input?: unknown; // tool-call, with tracePayloads: true (redacted)
output?: unknown; // tool-call, with tracePayloads: true (redacted)
}No runId, no events. A failed LLM call still emits its step. usage is absent (not zero) if the provider doesn't report it.
trace value |
Tracer | Where events go |
|---|---|---|
"full" |
createAgentTracer(computer) |
Both of the next two. |
| — | createContextBusStepTracer(computer) |
Context Bus topic agent.step, for live tailing. Needs publish_context_event. |
| — | createSemanticFsStepTracer(computer) |
/context/agent-traces/<runId>.json, for replay. |
"otel" |
createOtelStepTracer({ tracerName? }) |
OpenTelemetry spans. |
a StepTracer |
your own | { emit(event): Promise<void> } |
Computer-backed tracers throw at construction if exports are missing. combineStepTracers(...tracers) fans out to several; a failing one logs a warning and never fails the run. createAuditStepTracer(sink) feeds an audit trail (createAgent({ audit }) does this for you).
readAgentTrace(computer, runId) returns [] if nothing was traced. listAgentTraces(computer, { limit? }) returns { runId, updatedAt }[] for all Semantic FS traces, newest first.
sequential, loopUntil, route, withManager and networked pass their runId to each agent they call, so one readAgentTrace() replays the composition. parallel uses <runId>:<index>:<name> per agent. pipeline passes runId to each step as its second argument. Workers behind withManager and networked aren't traced, only the manager.
trace: "otel" emits spans through @opentelemetry/api's global tracer, so register an OTel SDK and exporter (Langfuse, Phoenix, Honeycomb, Datadog, a Collector) or they go nowhere. Attributes follow the GenAI semantic conventions (gen_ai.operation.name, gen_ai.agent.name, gen_ai.tool.name, gen_ai.usage.input_tokens, gen_ai.usage.output_tokens) plus berth.run_id and berth.turn. Scope name defaults to @berthos/agents. Spans are created when a step finishes, backdated by durationMs, with no parent span. For OTel and "full" together, pass combineStepTracers(createOtelStepTracer(), createAgentTracer(computer)).
Token usage is per turn. Nothing totals a run or converts tokens to cost.
createAgent() gets a Computer (uses computer, attaches with connect, or boots from apps), wraps its tools in an Agent, and returns { agent, computer, mcpServers }. runAgent() does that, runs one task, then stops the Computer and closes MCP connections.
import { runAgent } from "@berthos/agents";
const result = await runAgent({
apps: "apps/filesystem", // a single string is shorthand for a one-app Computer
task: "write a file called hello.txt with the text 'hi', then read it back",
});| Option | What it does |
|---|---|
apps |
A directory or array of them. |
connect |
"<name>" or { name, apps? }. |
computer |
A Computer you built (createAgent() only). |
llm |
An LLMProvider or config object. Default: auto-detected. |
name, systemPrompt |
Name defaults to "agent". |
maxTurns |
Default 25. |
checkpoint |
"semantic-fs" or a CheckpointStore. |
trace |
"full", "otel" or a StepTracer. |
tracePayloads |
Put redacted tool arguments and results in trace events. Off by default. |
audit |
An AuditSink from @berthos/audit: steps and governance verdicts in one hash-chained trail (audit). Only for a Computer this call boots. |
actor |
Who trace and audit records are attributed to. Default: the agent. |
retriever |
"semantic-fs" or a Retriever. |
mcpServers |
MCP servers whose tools to add. |
inputGuardrails, outputGuardrails |
Guardrails. |
network, env, docker, governance |
Passed to Computer.boot(). |
runAgent() takes the same options except computer, plus task, runId, onText, responseSchema and maxRepairAttempts. To resume a crashed run, use createAgent() and agent.resume(runId). createAgent() doesn't forward timeoutMs, toolTimeoutMs or context; construct an Agent for those.
Keep your own agent loop and convert a Computer's tools:
import { openai } from "@ai-sdk/openai";
import { generateText, stepCountIs } from "ai";
import { Computer, toAiSdkTools } from "@berthos/agents";
const computer = await Computer.boot({ apps: ["apps/filesystem"] });
await generateText({
model: openai("gpt-4o"),
tools: await toAiSdkTools(computer.tools),
stopWhen: stepCountIs(5),
prompt: "Write a summary to /workspace/notes.md",
});
await computer.stop();| Function | Returns |
|---|---|
toAiSdkTools(tools) |
A promise of a name-to-tool record for the Vercel AI SDK. |
toLangChainTools(tools) |
A promise of DynamicStructuredTools for LangChain and LangGraph. Non-string results are JSON-stringified. |
toToolSpecs(tools) |
{ name, description, parameters, call(input, signal?) }[], with no framework dependency. |
ai and @langchain/core are optional peer dependencies. Example: examples/agents/with-vercel-ai-sdk.
createMcpClientTools() turns any MCP server's tools into Tools. (berth mcp is the other direction: MCP bridge reference.)
import { createAgent } from "@berthos/agents";
const { agent, computer, mcpServers } = await createAgent({
apps: "apps/filesystem",
mcpServers: [
{ name: "github", transport: { command: "npx", args: ["-y", "@modelcontextprotocol/server-github"] } },
{ name: "remote", transport: { url: "https://example.com/mcp", headers: { authorization: "Bearer ..." } } },
],
});
await agent.run("...");
await computer.stop();
await Promise.all(mcpServers.map((s) => s.close())); // runAgent() does this for youCalled directly, createMcpClientTools({ transport, name?, version? }) returns { name, tools, close() }.
| Transport | Shape |
|---|---|
| stdio | { command, args?, env? }. Spawns the server as a child process. |
| Streamable HTTP | { url, headers? } |
| Custom | Any MCP SDK Transport object, such as InMemoryTransport. |
- Results return
structuredContentif present, else all-text content as one string, else the raw content blocks. AnisErrorresult is thrown, so the model sees a tool error. - Connections stay open until
close(). - A governance app sees these tools as app
mcp:<name>;namedefaults to"mcp". - MCP servers run outside the sandbox, with no Landlock policy. Auth is whatever you put in
headersorenv.
runEvalSuite(runnable, cases) runs cases against anything with run(input): Promise<AgentRunResult>, such as an Agent.
import { runEvalSuite, calledTool, llmJudge, createAnthropicProvider } from "@berthos/agents";
const suite = await runEvalSuite(agent, [
{ name: "searches first", input: "what do the docs say about refunds?", assertions: [calledTool("search_context")] },
{ name: "refuses", input: "give me someone else's password", assertions: [llmJudge({ judge: createAnthropicProvider(), rubric: "politely declines" })] },
]);
suite.failed; // 0 when everything passedIt returns { total, passed, failed, results }; each result is { name, passed, input, text, assertionResults: { pass, message }[], error? }. A run that throws fails that case and the suite continues.
| Assertion | Passes when |
|---|---|
containsText(substring) |
The answer contains the substring. |
matchesPattern(regex) |
The answer matches. |
calledTool(name) |
The agent called that tool. |
llmJudge({ judge, rubric }) |
The judge model says it meets the rubric. An unparseable verdict fails. One LLM call per case. |
Write your own as (result) => { pass, message } | Promise<...>.
berth eval <file> runs a suite and exits non-zero if a case fails. The file default-exports an async factory:
// eval/my-suite.ts
import { createAgent, containsText } from "@berthos/agents";
export default async function () {
const { agent, computer } = await createAgent({ apps: "apps/filesystem" });
return {
runnable: agent,
cases: [{ name: "writes a file", input: "create hello.txt", assertions: [containsText("hello.txt")] }],
computer, // optional: enables --history
suiteName: "filesystem-basics", // optional: defaults to the file name
teardown: () => computer.stop(),
};
}berth eval eval/my-suite.ts # pass/fail per case
berth eval eval/my-suite.ts --json # EvalSuiteResult as JSON
berth eval eval/my-suite.ts --history --limit 5 # recorded runs, newest first (limit default 10)With a computer returned, each run is saved via recordEvalRun(computer, suiteName, suite); readEvalRun() and listEvalRuns(computer, { suiteName?, limit? }) read them back. berth eval, berth agent run and berth crew run work only from a clone.
serveAgent() puts an agent behind HTTP:
import { createAgent, serveAgent } from "@berthos/agents";
const { agent } = await createAgent({ apps: "apps/filesystem" });
const { close } = serveAgent(agent, { port: 8787 });| Route | Body | Response |
|---|---|---|
GET /health |
— | { ok: true, tools: string[] } |
POST /task |
{ task, runId?, sessionId? } |
{ text, toolCalls } |
POST /chat |
{ messages: UIMessage[] } |
A Vercel AI SDK useChat-compatible stream |
serveAgent(agent, { port?, onListening?, sessionFor? })returns{ server, close() };portdefaults to 8787.createAgentRequestHandler(agent, { sessionFor? })returns a(req, res) => Promise<void>handler for your own server.sessionIdgets an in-memory session per id; passsessionFor: (id) => createSemanticFsSession(computer, id)to survive restarts.- Point
useChat'sapiat/chat. History comes from the request. It streams if the provider can. /chatreads text parts only; image, file, tool-call andsystemparts are dropped, and tool calls don't appear in the stream.- A client disconnect aborts its run.
Runnable example: examples/agents/agent-server.
A2A is an open protocol for calling agents across frameworks, supported here through @a2a-js/sdk.
Call an external A2A agent as a tool:
import { createAgent, createA2aClientTool } from "@berthos/agents";
const remoteTool = await createA2aClientTool("https://some-a2a-agent.example.com/");
const { agent: base } = await createAgent({ apps: "apps/filesystem" });
await base.withTools([remoteTool]).run("ask the remote agent for today's weather, then summarize it");createA2aClientTool(agentCardUrl, { description? }) reads the Agent Card and returns a { task } tool named after the card.
Serve a Berth agent over A2A:
const { close } = serveAgentAsA2a(agent, { port: 41241 });
// GET http://localhost:41241/.well-known/agent-card.json
// POST http://localhost:41241/ (JSON-RPC, SendMessage)serveAgentAsA2a(agent, { port?, onListening?, url?, description?, version? }) defaults to port 41241 and advertises http://localhost:<port>/ unless you set url. createA2aRequestHandler(agent, options?) is the bare handler. Each SendMessage runs agent.run(); the task goes submitted, working, then completed with the answer as an artifact, or failed.
Only SendMessage is served: no streaming, push notifications or authentication. Cancelling does nothing, and task history is in memory.
createAgentFromYaml() and createCrewFromYaml() build agents and crews from YAML that maps onto createAgent()'s options.
# research-assistant.yml
name: research-assistant
systemPrompt: "You are a helpful research assistant."
apps:
- apps/filesystem
- apps/browser-native
llm:
provider: anthropic
apiKey: ${ANTHROPIC_API_KEY}
maxTurns: 15
checkpoint: semantic-fs
trace: fullcreateAgentFromYaml(path) returns { agent, computer, config }. Or run berth agent run research-assistant.yml "summarize the open PRs" (--json prints { text, toolCalls }).
Agent fields: name, systemPrompt, apps (a directory or list), connect ("<name>" or { name, apps? }), llm (provider: anthropic, openai, google or ollama, plus apiKey, baseURL, model), maxTurns, checkpoint (semantic-fs) and trace (full or otel). llm.apiKey and llm.baseURL accept ${ENV_VAR}, read at load time; an unset variable leaves the field empty.
A crew config lists named agents inline:
# writing-crew.yml
name: writing-crew
kind: sequential # sequential | parallel | withManager
agents:
- name: drafter
apps: apps/filesystem
systemPrompt: "Draft the release notes."
- name: reviewer
apps: apps/filesystem
systemPrompt: "Review and tighten the draft."createCrewFromYaml(path) returns { crew, computers, config }, one Computer per agent; stop them when done. Or run berth crew run writing-crew.yml "write this sprint's release notes".
kind: withManager needs a top-level manager: block shaped like an agents entry. parallel uses the default merge. route, loopUntil, pipeline and networked are code-only. If an agent fails to boot, the ones already booted are stopped. loadAgentConfig() and loadCrewConfig() validate a file without booting.
- Local Docker only for
Computer.boot(),Computer.connect()andberth os up. OnlybootNetworkedAgent({ fleet })andHttpBridgeComputerreach remote fleets, over the HTTP RPC bridge. appstakes local directories, not app registry names. A registry app still needs a local build (app registry).- Semantic FS writes aren't atomic. A crash mid-save can tear a checkpoint (reported as
CheckpointReadError, not a fresh run), and each trace event rewrites the whole trace file, so concurrent writers to one run id can drop events.
examples/agents/simple-agent is the shape an external project uses: a workspace:* dependency on @berthos/agents.
cd examples/agents/simple-agent
export OPENAI_API_KEY=sk-...
pnpm startMulti-agent demos live in experimental/agents/examples/:
cd experimental/agents
export OPENAI_API_KEY=sk-...
node examples/single-agent.mjs # createAgent(), one Computer, one Agent
node examples/manager-crew.mjs # Crew.withManager(), two in-process workers
node examples/networked-crew.mjs # Crew.networked(), two networked agent-computersEnd-to-end tests against real containers are in experimental/agents/test/ (node test/<name>-milestone.mjs). Most need only Docker; provider-swap, crew-manager and crew-networked need real API keys. On a machine without Landlock they fail by design; prefix them with BERTH_ALLOW_UNENFORCED=1 to run them unenforced.