Skip to content
Playground

Tools

Last updated 9 days ago · 1db78aa

↑ Updated 9 days ago

"docs(guides): dual-syntax tabbed code examples across all guide pages" · 1db78aa · 2026-10-02

  • ## Built-in Tools vs Custom Tools
  • ### Using Built-in Tools
  • ### Registering Custom Tools
  • ### With Reasoning (ReAct)
  • ### Without Reasoning (Direct LLM Loop)
  • ## Built-in Tools

The tools layer lets agents call external functions, APIs, and MCP servers. Tools integrate directly with the reasoning loop — when an agent thinks it needs information or wants to take an action, it calls a tool and uses the real result.

.withTools() alone gives an agent zero built-in tools. Built-ins are registered internally (callable by name, and discoverable via the discover-tools meta-tool), but excluded from the model’s prompt-level tool list unless you opt in with builtins: true (all of them) or builtins: [...] (a named subset). This is deliberate: unscoped built-in descriptions like “write to file” cause weaker models to reach for them on unrelated tasks. See Built-in Tools below for the full opt-in behavior. You can also register custom tools at build time by passing options.

import { createAgent } from "reactive-agents";
const agent = await createAgent({
name: "my-agent",
provider: "anthropic",
tools: { builtins: true }, // opt in to all built-in tools
reasoning: {}, // Tools work with or without reasoning
});
const result = await agent.run("What is the population of Tokyo times 3?");

The canonical way to author a tool is defineTool — a schema plus a plain async handler whose args are typed from the schema. No Effect knowledge and no Record<string, unknown> casts required (handlers are functions, not serializable data, so custom-tool registration is builder-only — there is no declarative config equivalent):

import { ReactiveAgents } from "reactive-agents";
import { defineTool } from "@reactive-agents/tools";
import { Schema } from "effect";
const calculator = defineTool({
name: "calculator",
description: "Perform arithmetic calculations",
input: Schema.Struct({
expression: Schema.String,
}),
// args is typed as { expression: string }
handler: async (args) => String(eval(args.expression)),
});
const agent = await ReactiveAgents.create()
.withProvider("anthropic")
.withTools({ tools: [calculator] })
.withReasoning()
.build();

The input field accepts an Effect Schema.Struct (canonical — full parameter metadata is extracted from the AST) or any Standard Schema — Zod, Valibot, ArkType, or Schema.standardSchemaV1(...):

import { z } from "zod";
const addTool = defineTool({
name: "add",
description: "Add two numbers",
input: z.object({ a: z.number(), b: z.number() }),
handler: async ({ a, b }) => a + b, // { a: number; b: number }
});

Raw arguments are validated against the schema at runtime before the handler runs; validation failures surface as ToolExecutionError, never a crash. Optional config fields mirror the raw ToolDefinition: riskLevel (default "low"), timeoutMs (default 30_000), requiresApproval (default false), category, returnType, isCacheable, cacheTtlMs.

defineTool also fails fast on malformed options: passing intuitive-but-wrong keys (parameters instead of input, execute instead of handler) throws a typed ToolDefinitionError naming the correct field — not a raw TypeError.

Advanced: Effect handlers and raw definitions

Section titled “Advanced: Effect handlers and raw definitions”

For Effect-native authors, the handler may return an Effect instead of a Promise — both are normalised at runtime:

import { Effect, Schema } from "effect";
const searchTool = defineTool({
name: "search",
description: "Search the web",
input: Schema.Struct({ query: Schema.String }),
handler: (args) => Effect.succeed(`Results for: ${args.query}`),
});

You can also pass raw { definition, handler } pairs directly to the tools option — the pre-defineTool shape, still fully supported:

import { ReactiveAgents } from "reactive-agents";
import { Effect } from "effect";
const agent = await ReactiveAgents.create()
.withProvider("anthropic")
.withTools({
tools: [{
definition: {
name: "calculator",
description: "Perform arithmetic calculations",
parameters: [{ name: "expression", type: "string", description: "Math expression", required: true }],
riskLevel: "low",
timeoutMs: 5_000,
requiresApproval: false,
source: "function",
},
handler: (args) => Effect.try(() => String(eval(String(args.expression)))),
}],
})
.withReasoning()
.build();

For zero-schema quick tools there is also the tool(name, description, handlerOrOptions) helper (untyped args), and the ToolBuilder fluent API below.

You can also register tools after build() on the agent facade: await agent.registerTool(definition, handler) and await agent.unregisterTool("name") (non-builtin tools only).

When reasoning is enabled, the agent uses a Think → Act → Observe loop. Tools are passed to the LLM via the provider’s native function calling API parameter. The model returns structured tool_use blocks — no text parsing. The framework:

  1. Receives the structured tool_use block from the LLM response
  2. Validates input against the tool’s schema
  3. Executes the tool in a sandbox
  4. Returns the real result as a tool_result message
  5. The LLM continues reasoning with the new information

Without reasoning, tool calling uses the LLM provider’s native function calling:

  1. Tool definitions are converted to the provider’s format (Anthropic tools, OpenAI function_calling, Gemini function declarations)
  2. When the LLM responds with stopReason: "tool_use", the framework executes the requested tools
  3. Results are appended to the message history as tool results
  4. The LLM is called again with the updated context
  5. Loop continues until the LLM stops requesting tools

Both paths produce the same outcome — the agent uses tools to accomplish its task.

These tools are registered internally as soon as you call .withTools(), but each one only reaches the model’s prompt-level tool list if you opt in: pass builtins: true for all of them, builtins: [...] for a named subset, or list a tool by name in allowedTools/requiredTools (those bypass the opt-in gate on their own):

ToolCategoryDescriptionRequires
web-searchsearchSearch the web using Tavily APITAVILY_API_KEY
http-gethttpMake HTTP GET requests—
file-readfileRead file contents (path-traversal protected)—
file-writefileWrite file contents (requires approval)—
file-editfileReplace text in existing files (use instead of file-write for edits; requires approval)—
code-executecodeExecute code in a subprocess (Bun.spawn, cwd: "/tmp", minimal env)—
crypto-pricedataGet current prices for 30+ cryptocurrencies via CoinGecko’s free public API—
git-clivcsRun any git subcommand (e.g. status, log, diff)git in $PATH
gh-clivcsRun any gh subcommand via the GitHub CLIgh in $PATH
gws-cliproductivityRun any gws subcommand via the Google Workspace CLIgws in $PATH

Ad-hoc note builtins were removed from the default tool list. Use the recall meta-tool (Conductor’s Suite) for working-memory writes, reads, search, and listing. If you use .withDocuments(), ingestion uses rag-ingest and retrieval is typically routed through find rather than a standalone rag-search builtin.

Fetches current cryptocurrency prices from CoinGecko’s free public API. No API key or account required.

Parameters:

ParameterTypeRequiredDescription
coinsstring[]yesArray of coin symbols, e.g. ["BTC", "ETH", "SOL"]. Case-insensitive. Always batch multiple coins into a single call.
currencystringnoQuote currency. Default: "usd". Also accepts: eur, gbp, jpy, btc, eth.

Supported symbols: BTC, ETH, XRP, XLM, SOL, ADA, DOGE, DOT, AVAX, MATIC/POL, LINK, LTC, BCH, UNI, ATOM, NEAR, ARB, OP, SUI, APT, TRX, TON, SHIB, PEPE, FIL, ICP, VET, ALGO, HBAR.

Prices are cached for 60 seconds — rapid repeated calls within a session return immediately without hitting the network. Responses include a notFound: true flag for any unrecognized symbol rather than failing the whole call.

const agent = await createAgent({
name: "prices-agent",
provider: "anthropic",
tools: { allowedTools: ["crypto-price"] },
reasoning: {},
});
const result = await agent.run("What are the current prices of BTC, ETH, and SOL in USD?");

The model is instructed to batch all needed coins into one call. The tool returns { prices: [{ symbol, name, price, currency }], currency, source: "coingecko" }.

Runs any git subcommand in the agent’s current working directory. Requires git to be installed and in $PATH.

Parameters:

ParameterTypeRequiredDescription
commandstringyesThe git subcommand plus any flags — without the leading git keyword. E.g. "log --oneline -10", "diff HEAD~1", "branch -a".

Output longer than 32 KB is truncated and the model is told how many bytes were cut. Non-zero exit codes surface as errors so the model knows the command failed.

The tool uses execFile (no shell expansion), so shell operators like | and > are not available. For pipelines, use code-execute or shell-execute.

const agent = await createAgent({
name: "repo-agent",
provider: "anthropic",
tools: { allowedTools: ["git-cli"] },
reasoning: {},
});
const result = await agent.run("Summarize the last 10 commits in this repo.");

Runs any GitHub CLI (gh) command. Requires gh to be installed, in $PATH, and authenticated (gh auth login).

Parameters:

ParameterTypeRequiredDescription
commandstringyesThe gh subcommand plus flags — without the leading gh keyword. E.g. "pr list --state open", "issue view 42", "run list --limit 5".

Adding --json <fields> to the command returns machine-readable JSON, which the model can process directly. Output longer than 32 KB is truncated.

const agent = await createAgent({
name: "github-agent",
provider: "anthropic",
tools: { allowedTools: ["gh-cli"] },
reasoning: {},
});
const result = await agent.run("List open PRs and summarize what each one changes.");

Runs Google Workspace CLI (gws) commands, providing access to Gmail, Google Calendar, Google Drive, and other Workspace services. Requires gws to be installed, in $PATH, and authenticated (gws auth login).

Parameters:

ParameterTypeRequiredDescription
commandstringyesThe gws subcommand plus flags — without the leading gws keyword. E.g. "calendar events list", "gmail messages list --query unread", "drive files list".

If gws is not installed, the tool returns a clear error immediately — the model is instructed not to retry and to report the missing binary instead.

const agent = await createAgent({
name: "workspace-agent",
provider: "anthropic",
tools: { allowedTools: ["gws-cli"] },
reasoning: {},
});
const result = await agent.run("What meetings do I have today?");

These are registered by the kernel with live state — not part of the static builtinTools list:

ToolDescription
context-statusZero-parameter introspection: iteration budget, tools used/pending, stored keys, tokens, etc.
final-answerHard-gate meta-tool: structured deliverable + format + confidence — the sole termination path for exiting the ReAct loop cleanly under native function calling.

Conductor’s Suite (opt-in beyond the task-facing defaults)

Section titled “Conductor’s Suite (opt-in beyond the task-facing defaults)”

When .withTools() is enabled, the default meta-tool surface is deliberately task-facing: recall plus the built-in harness skill (tier-aware), and find auto-enables only when you ingest documents via .withDocuments(). The planning/reflection meta-tools (brief, pulse) — and find’s web-egress default — load only on request: enable them with .withMetaTools({ brief: true, pulse: true, find: true, … }), or pass false to disable the suite entirely.

All of the above are invoked via the provider’s native function calling path (tool_use / tool_calls → executed → tool_result in the thread).

Two .withTools() options control which tools the agent can reach, with different strictness:

// Hard restriction — the agent can ONLY see/call these tools.
agent.withTools({ allowedTools: ["github/list_commits", "file-write"] })
// Soft focus — all tools remain callable, but these are surfaced/prioritized.
agent.withTools({ focusedTools: ["crypto-price"] })
  • allowedTools is a hard allowlist. Anything outside it is pruned before the model sees it — use it to lock an agent to a known surface (and to give local-model tool selection a safety net).
  • focusedTools is soft guidance. The full toolset stays available, but the focused names are highlighted so the model gravitates to them without being blocked from others. Resolution order: focusedTools (soft guidance) → allowedTools (hard restriction) → all tools.

Policy is enforced at execution, on every strategy

Section titled “Policy is enforced at execution, on every strategy”

Schema pruning is only the first line. As of v0.14 the tool policy — allowedTools plus the forbidden-tools deny-list from .withContract() — is also enforced at the shared tool-execution choke point that every strategy passes through, including planned steps (plan-execute, blueprint), hallucinated tool names the model invents, and code generated by the code-action sandbox. A violating call is blocked before dispatch and recorded as a failed observation the model can route around — it is never executed. The deny-list beats everything; a non-empty allowedTools is a hard whitelist; kernel meta-tools (final-answer, recall, …) always pass so the loop can still terminate.

Agents can issue multiple tool calls from a single thought step via native function calling.

The model can return multiple tool_use blocks in a single response. The framework executes them concurrently:

  • Results are numbered and returned as separate tool_result messages.
  • Capped at 3 simultaneous tool calls to prevent runaway fan-out.
  • Side-effect tools (create_*, delete_*, send_*, push_*, etc.) are automatically forced to single mode.

For sequential tool calls where the output of one feeds into the next, the model issues a single tool_use block per turn. The framework returns the tool_result, and the model issues the next call in a subsequent turn with the prior result available in its context.

  • Execution is sequential; the model sees each result before deciding the next call.
  • Capped at 3 chained steps per tool execution phase.

The web-search tool requires a Tavily API key. Without it, calls to web-search return an error telling the agent the tool is inactive:

.env
TAVILY_API_KEY=tvly-...

When the key is set, web search makes real API calls and returns { title, url, content } results. When missing, the agent sees an explicit error message explaining that TAVILY_API_KEY is not configured.

All tool execution runs in a sandbox with:

  • Timeout — Default 30s per tool call, configurable
  • Error containment — Tool failures don’t crash the agent; errors are reported as observation text
  • Result wrapping — All outputs are wrapped in ToolExecutionResult with success/failure status

The code-execute tool uses subprocess isolation via Bun.spawn() with cwd: "/tmp" and a minimal environment (PATH only). This prevents spawned code from reading environment variables (API keys, secrets) or accessing files outside /tmp.

Tool inputs are validated against their schemas before execution:

  • Required parameter checking
  • Type validation (string, number, boolean, array, object)
  • Enum validation
  • Default value injection for optional parameters

Invalid inputs are rejected before the tool handler runs.

The ToolBuilder provides a fluent API for building ToolDefinition schema objects without raw literals. It validates at build() time (a missing description throws). The execution handler — (args: Record<string, unknown>) => Effect<...> — is supplied when you register the tool with .withTools({ tools }):

import { ReactiveAgents } from "reactive-agents";
import { ToolBuilder } from "@reactive-agents/tools";
import { Effect } from "effect";
// Basic tool
const { definition: calculatorDef } = ToolBuilder.create("calculator")
.description("Perform arithmetic calculations")
.param("expression", "string", "Math expression to evaluate", { required: true })
.riskLevel("low")
.timeout(5_000)
.build();
// Tool with multiple params and enum
const { definition: fileOpDef } = ToolBuilder.create("file-operation")
.description("Perform a file system operation")
.param("path", "string", "File path", { required: true })
.param("operation", "string", "Operation to perform", { required: true, enum: ["read", "write", "delete"] })
.param("content", "string", "Content for write operations", { required: false })
.riskLevel("medium")
.requiresApproval(true)
.timeout(10_000)
.build();
const agent = await ReactiveAgents.create()
.withProvider("anthropic")
.withReasoning()
.withTools({
tools: [
{
definition: calculatorDef,
handler: (args) => Effect.try(() => String(args.expression)),
},
{
definition: fileOpDef,
handler: (args) =>
Effect.tryPromise(async () => {
// ... implementation using args.path / args.operation / args.content
return "done";
}),
},
],
})
.build();

Prefer defineTool when you want typed, schema-validated handler args and plain async handlers — ToolBuilder shines when you are assembling definitions imperatively.

MethodDescription
ToolBuilder.create(name)Start a new tool definition (equivalent to new ToolBuilder(name))
.description(text)Set the tool description (shown to LLM) — required, build() throws without it
.param(name, type, description, options?)Add a parameter. type: "string" | "number" | "boolean" | "object" | "array". options: { required?, enum?, default? }
.riskLevel(level)"low" | "medium" | "high" | "critical" (default "low")
.timeout(ms)Execution timeout in milliseconds (default 30,000)
.requiresApproval(bool)Sets the approval-required flag on the definition (metadata — see Durable HITL for enforcement)
.returnType(text)Human-readable return type description
.category(cat)"search" | "file" | "code" | "http" | "data" | "system" | "custom" | "vcs" | "productivity"
.handler(fn)Optional: attach an untyped function (...args: unknown[]) => unknown to carry alongside the definition for custom pipelines. The agent runtime does not execute this — pass an Effect handler to .withTools({ tools }) instead
.build()Produce { definition, handler? } — use the definition with .withTools({ tools })

Convert plain functions into tool definitions:

import { adaptFunction } from "@reactive-agents/tools";
import { Effect } from "effect";
const tool = adaptFunction({
name: "calculate",
description: "Perform arithmetic",
parameters: [
{ name: "a", type: "number", description: "First operand", required: true },
{ name: "b", type: "number", description: "Second operand", required: true },
{
name: "op",
type: "string",
description: "Operation to perform",
required: true,
enum: ["add", "sub", "mul", "div"],
},
],
fn: (args) => {
const { a, b, op } = args as { a: number; b: number; op: string };
switch (op) {
case "add": return Effect.succeed(a + b);
case "sub": return Effect.succeed(a - b);
case "mul": return Effect.succeed(a * b);
default: return Effect.succeed(a / b);
}
},
});

Connect to Model Context Protocol servers for external tool discovery and execution. MCP tools are automatically prefixed with {serverName}/ (e.g. filesystem/read_file) and injected into the agent’s reasoning loop alongside built-in tools.

Four transports are supported, covering every MCP server deployment pattern:

TransportWhen to use
"stdio"Local subprocess — npm packages, Docker, Python scripts, any executable
"streamable-http"Modern remote servers (MCP spec 2025-03-26) — Claude.ai, Cursor, Stripe, cloud providers
"sse"Legacy remote servers (MCP spec 2024-11-05) — older self-hosted setups
"websocket"Real-time bidirectional servers

Launches a subprocess and communicates via JSON-RPC over stdin/stdout. The subprocess inherits the parent process environment by default.

const agent = await createAgent({
name: "fs-agent",
provider: "anthropic",
mcpServers: [{
name: "filesystem",
transport: "stdio",
command: "bunx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "."],
}],
reasoning: {},
});

Use env to inject secrets without relying on the global environment. These are merged on top of the parent process environment — only specify what differs:

.withMCP({
name: "github",
transport: "stdio",
command: "bunx",
args: ["-y", "@modelcontextprotocol/server-github"],
env: {
GITHUB_PERSONAL_ACCESS_TOKEN: process.env.GH_TOKEN ?? "",
},
})

Set cwd to control where the subprocess starts. Useful when the MCP server reads relative paths:

.withMCP({
name: "project-tools",
transport: "stdio",
command: "node",
args: ["./mcp-server.js"],
cwd: "/home/user/my-project",
})

command accepts any executable — docker works directly. Docker networking flags go in args:

.withMCP({
name: "my-server",
transport: "stdio",
command: "docker",
args: [
"run", "-i", "--rm",
"--network", "my-bridge-network",
"-e", "INTERNAL_VAR=value", // container-only env (not secret)
"ghcr.io/myorg/mcp-server:latest",
],
env: { SECRET_KEY: process.env.SECRET_KEY ?? "" }, // passed to docker CLI, not container
})

Registry-resolved servers (Docker Hub, with more registries planned)

Section titled “Registry-resolved servers (Docker Hub, with more registries planned)”

Instead of hand-writing the docker run config above, pass a catalog name as a plain string. .withMCP() resolves it through a pluggable MCPRegistry — Docker Hub’s mcp/* catalog (200+ verified server images) is the first registry, chosen via the registry option (defaults to "docker-hub"), so additional registries (an npm/npx-based catalog, a private org registry) can plug into the same .withMCP() call later without an API change. Image, transport, and args are resolved for you (the catalog-name string form is builder-only — declarative mcpServers accepts config objects, not catalog names):

const agent = await ReactiveAgents.create()
.withProvider("anthropic")
.withReasoning()
.withMCP("filesystem") // single catalog name
.withMCP("brave-search", { env: { BRAVE_API_KEY: process.env.BRAVE_API_KEY! } })
.build();

A string[] batches multiple catalog names with no per-entry options — .withMCP(["filesystem", "brave-search"]).

A bare string and an MCPServerConfig object are unambiguous at the type level (a string can never be a config object), so both forms compose freely — chain .withMCP() as many times as you need. One call cannot mix a string with an object in the same array (.withMCP(["name", { name: "x", command: "echo" }]) is rejected, both by the TypeScript overloads and at runtime) — call .withMCP() once per input shape instead.

options on the single-name form:

OptionTypePurpose
registrystringRegistry id to resolve against. Defaults to "docker-hub".
envRecord<string, string>Env vars the resolver validates against the catalog entry’s declared requirements — throws if a required one is missing.
volumes{ host, container, readOnly? }[]Dynamic bind mounts for servers that need host filesystem access (e.g. filesystem, git).
requireApprovalbooleanWhether the approval gate below applies to this call. Defaults to true.

The standard transport for modern remote and cloud-hosted MCP servers (MCP spec 2025-03-26). Uses a single POST endpoint — the server responds with either a plain JSON object or an SSE stream depending on the operation.

.withMCP({
name: "stripe",
transport: "streamable-http",
endpoint: "https://mcp.stripe.com",
headers: { Authorization: `Bearer ${process.env.STRIPE_SECRET_KEY}` },
})

Session management is handled automatically: the session ID returned in the Mcp-Session-Id response header is captured and forwarded on all subsequent requests. When the agent is disposed, an HTTP DELETE is sent to cleanly terminate the session.

Pass headers to send authentication credentials on every request. Use for Bearer tokens (OAuth, JWT, PAT), API keys, or any per-server auth:

// Bearer token (OAuth, PAT, JWT)
headers: { Authorization: "Bearer ghp_..." }
// API key header
headers: { "x-api-key": process.env.MCP_API_KEY ?? "" }
// Multiple headers
headers: {
Authorization: "Bearer token",
"X-Tenant-Id": "my-org",
}

For remote servers (streamable-http/sse) protected by OAuth 2.1, pass an auth config and RA drives the whole flow itself — discovery, token exchange, automatic refresh, and secure on-disk storage. The auth config has no declarative mcpServers equivalent yet, so these examples are builder-only. This is not available for stdio servers: a stdio config with auth set is a startup error — stdio servers get credentials from the environment via env (see Per-server environment variables above), not OAuth.

SituationGrant
Unattended / production agent (cron, server, CI) — no human presentclient_credentials
Same, but the server requires asymmetric client auth (JWT-signed) instead of a shared secretprivate_key_jwt
A human is running an agent that needs to act on their own accountauthorization_code

client_credentials and private_key_jwt never open a browser or need a human — the agent authenticates as itself:

const agent = await ReactiveAgents.create()
.withMCP({
name: "billing",
transport: "streamable-http",
endpoint: "https://mcp.example.com/mcp",
auth: {
type: "client_credentials",
clientId: process.env.MCP_CLIENT_ID!,
clientSecret: process.env.MCP_CLIENT_SECRET!,
},
})
.build();

authorization_code delegates access to a specific user. It needs a one-time interactive login before the agent runs — see rax mcp login below:

const agent = await ReactiveAgents.create()
.withMCP({
name: "my-calendar",
transport: "streamable-http",
endpoint: "https://mcp.example.com/mcp",
auth: { type: "authorization_code" }, // run `rax mcp login my-calendar` once first
})
.build();

interactive defaults to false. An unattended agent run must never unexpectedly try to open a browser or bind a local port — if credentials aren’t already on disk (or in your tokenStore) when an authorization_code server connects, the connection fails closed with an error instead of popping a browser. Only rax mcp login (or explicitly passing interactive: true) triggers the interactive flow.

Secrets are plain config — you source them

Section titled “Secrets are plain config — you source them”

clientSecret and privateKey (for private_key_jwt) are ordinary config values. RA does not do any environment-variable interpolation — read them from process.env yourself, as in the example above. Never hardcode a secret in source.

By default, tokens are persisted to ~/.reactive-agents/mcp-auth/ — one file per resource, named by a hash of the canonical resource URL (never the raw URL or client id). The directory is created chmod 0700 and each credential file chmod 0600; both get() and list() refuse to read a file whose permissions have been loosened. You can supply your own store instead — e.g. an in-memory store for tests/CI so nothing touches disk:

import { createMemoryTokenStore } from "@reactive-agents/tools"
const agent = await ReactiveAgents.create()
.withMCP({
name: "billing",
transport: "streamable-http",
endpoint: "https://mcp.example.com/mcp",
auth: { type: "client_credentials", clientId: "...", clientSecret: process.env.MCP_CLIENT_SECRET! },
tokenStore: createMemoryTokenStore(),
})
.build();

Run once, ahead of time, for any server using authorization_code:

Terminal window
rax mcp login <name|--url <endpoint>> [--client-id <id>] [--client-secret <secret>] [--scope <scope>] [--redirect-port <port>] [--no-browser] [--timeout <ms>] [--mcp-config <path>]
rax mcp logout <name|--url <endpoint>> [--mcp-config <path>]
rax mcp status
  • login — runs the interactive authorization flow and stores the resulting tokens. <name> resolves against .rax/mcp.json (or --mcp-config <path>); use --url <endpoint> for an ad-hoc server with no config file. --no-browser still prints the authorization URL (for headless/SSH sessions) but doesn’t try to launch one. --client-id skips dynamic client registration when the server needs a statically registered client. --client-secret is for a confidential OAuth client — some authorization servers (e.g. Google’s “Web application” client type) issue a secret alongside the client ID rather than a public/native client. --redirect-port pins the loopback callback to a fixed port instead of an ephemeral one; required by any authorization server that validates redirect_uri by exact pre-registered match rather than accepting any loopback port — register http://127.0.0.1:<port>/callback as that server’s authorized redirect URI.
  • logout — deletes the stored credentials for a server, attempting revocation first (best-effort; local deletion always happens even if the revocation endpoint is unreachable).
  • status — lists every stored credential (resource, scope, expiry, whether a refresh token is present). Never prints access or refresh tokens.
  • Host an OAuth authorization server — RA is a client only; you point it at a server you (or a third party) already run.
  • Anything beyond the MCP SDK’s own dynamic client registration (RA doesn’t add a second registration layer on top).
  • OS keychain storage (macOS Keychain, libsecret, Windows Credential Manager) — the default store is a permissioned file on disk. Bring your own tokenStore if you need keychain-backed storage.
  • RFC 8693 token exchange (delegating a user’s token to a sub-agent with attenuated scope).
  • Tool-description pinning or re-consent when a server’s tool list mutates mid-session.

Pass an array to connect multiple servers at build time:

const agent = await createAgent({
name: "multi-mcp-agent",
provider: "anthropic",
mcpServers: [
{
name: "filesystem",
transport: "stdio",
command: "bunx",
args: ["-y", "@modelcontextprotocol/server-filesystem", "."],
},
{
name: "github",
transport: "stdio",
command: "bunx",
args: ["-y", "@modelcontextprotocol/server-github"],
env: { GITHUB_PERSONAL_ACCESS_TOKEN: process.env.GH_TOKEN ?? "" },
},
{
name: "stripe",
transport: "streamable-http",
endpoint: "https://mcp.stripe.com",
headers: { Authorization: `Bearer ${process.env.STRIPE_KEY}` },
},
],
reasoning: {},
});

MCP stdio servers run as subprocesses — the process will hang if they aren’t shut down. Always dispose the agent when done. See Resource Management for full patterns.

// Option A: await using (recommended) — auto-disposes on scope exit
await using agent = await ReactiveAgents.create()
.withMCP({ name: "fs", transport: "stdio", command: "bunx", args: ["-y", "@modelcontextprotocol/server-filesystem", "."] })
.build();
// Option B: runOnce — build + run + dispose in one call
const result = await ReactiveAgents.create()
.withMCP({ name: "fs", transport: "stdio", command: "bunx", args: ["-y", "@modelcontextprotocol/server-filesystem", "."] })
.runOnce("What files are in this project?");

The MCP client is spec-compliant with MCP 2025-03-26:

  • Sends notifications/initialized after the handshake (required by spec before any tool calls)
  • Negotiates protocol version 2025-03-26 (servers may respond with an older supported version)
  • Tool results are extracted from the MCP content array format — the model receives clean text, not raw JSON
  • isError: true results from servers surface as tool execution errors in the agent loop

Register other agents (local or remote) as callable tools. This enables hierarchical agent architectures where a coordinator delegates subtasks to specialists.

const agent = await ReactiveAgents.create()
.withName("coordinator")
.withProvider("anthropic")
.withRemoteAgent("researcher", "https://research-agent.example.com")
.withReasoning()
.build();
// The coordinator can now call the researcher as a tool during reasoning

The remote agent is discovered via its A2A Agent Card and called via JSON-RPC message/send.

const agent = await ReactiveAgents.create()
.withName("coordinator")
.withProvider("anthropic")
.withAgentTool("specialist", {
name: "data-analyst",
description: "Analyzes data and produces insights",
})
.build();

See the A2A Protocol docs for full details.

The framework automatically converts between the tools package format and the LLM provider’s native format using toFunctionCallingFormat():

// Internal: tools package format
{ name: "search", description: "...", parameters: [...] }
// Converted to: LLM provider format
{ name: "search", description: "...", inputSchema: { type: "object", properties: {...} } }

This conversion happens automatically in the execution engine — you don’t need to worry about format differences between providers.

Large tool results (e.g. an MCP list_commits returning 31K characters) are automatically compressed so the agent receives accurate, structured data instead of garbled truncated JSON.

When a tool result exceeds the configured budget (default: 800 chars), the framework:

  1. Detects the result type (JSON array, JSON object, or plain text)
  2. Generates a structured preview — compact, accurate, fits within budget
  3. Stores the full result in working memory under _tool_result_N
  4. Injects the preview + storage key into context

Example — JSON array (github/list_commits, 30 items, 31K chars):

[STORED: _tool_result_1 | github/list_commits]
Type: Array(30) | Schema: sha, commit.message, author.login, date
Preview (first 3):
[0] sha=e255a5d msg="chore: update bun.lock" date=2026-02-27
[1] sha=59bae87 msg="feat(examples): unified runner" date=2026-02-27
[2] sha=efc816e msg="fix(examples): maxIterations" date=2026-02-27
...27 more — use recall("_tool_result_1") or | transform: to access full data

The agent can retrieve the stored result using the recall meta-tool (via native function calling):

// The model calls recall via its tool_use block:
// { name: "recall", input: { key: "_tool_result_1" } }

For agents that anticipate the response shape, a code-transform pipe lets them extract exactly what they need — before the result enters context. The pipe syntax is appended to the tool call args as a _transform field:

// The model calls github/list_commits with a transform expression
// { name: "github/list_commits", input: { owner: "...", repo: "...", _transform: "result.slice(0,5).map(c => ({sha: c.sha.slice(0,7), msg: c.commit.message.split('\\n')[0]}))" } }

The expression is evaluated in-process with result bound to the parsed tool output. Only the transform output enters context. On error, the framework falls back to the standard preview and includes the error message.

Tune compression behavior via .withTools():

.withTools({
resultCompression: {
budget: 1200, // chars before overflow triggers (default: 800)
previewItems: 5, // array items shown in preview (default: 3)
autoStore: true, // store oversized tool previews under stable keys (surfaced to the model via human-readable labels; `recall` can read them)
codeTransform: true, // enable | transform: pipe syntax (default: true)
}
})
OptionDefaultDescription
budget800Character threshold before compression kicks in
previewItems3Number of array items shown in the preview
autoStoretrueWhether to store the full result for later retrieval
codeTransformtrueWhether the | transform: pipe syntax is enabled

When tools are executed during reasoning, the results are automatically logged as episodic memories:

// This happens automatically when both .withTools() and .withMemory() are enabled
// Each tool result is logged with:
// - Action taken
// - Tool name and input
// - Result content
// - Timestamp

This means the agent can recall past tool results in future sessions.