A host tool runs in your app, with your database and secrets. The agent in the sandbox calls it. The tool code does not run in the sandbox.
Pass your server tools to chat(). TanStack AI bridges them into the sandbox. The agent calls them like any MCP tool, and each tool runs on your host.
import { chat, toolDefinition } from '@tanstack/ai'
import { GROK_CLI_INSTALL_COMMAND, grokBuildText } from '@tanstack/ai-grok-build'
import {
createSecrets,
defineSandbox,
defineWorkspace,
githubRepo,
withSandbox,
} from '@tanstack/ai-sandbox'
import { dockerSandbox } from '@tanstack/ai-sandbox-docker'
import { z } from 'zod'
// Lives on your host. The sandbox never sees it.
const todos = [{ id: '1', userId: 'u1', title: 'Fix the login bug' }]
const getTodos = toolDefinition({
name: 'getTodos',
description: 'List the todos of a user.',
inputSchema: z.object({ userId: z.string() }),
}).server(async ({ userId }) => todos.filter((t) => t.userId === userId))
const repoSandbox = defineSandbox({
id: 'repo-agent',
provider: dockerSandbox({ image: 'node:22' }),
workspace: defineWorkspace({
source: githubRepo({ repo: 'owner/app' }),
setup: [GROK_CLI_INSTALL_COMMAND],
secrets: createSecrets({ XAI_API_KEY: process.env.XAI_API_KEY ?? '' }),
}),
})
const stream = chat({
threadId: 'thread-1',
adapter: grokBuildText('grok-build'),
messages: [{ role: 'user', content: 'Fix the bugs in the todos of user u1.' }],
tools: [getTodos],
middleware: [withSandbox(repoSandbox)],
})The agent can now call getTodos. The tool reads todos on your host and sends the result into the sandbox.
A server tool can read and write the sandbox of its run. Call context.get(SandboxCapability). It returns the SandboxHandle that withSandbox opened for this run.
import { chat, toolDefinition } from '@tanstack/ai'
import { grokBuildText } from '@tanstack/ai-grok-build'
import { SandboxCapability, withSandbox } from '@tanstack/ai-sandbox'
import type { SandboxDefinition } from '@tanstack/ai-sandbox'
import { z } from 'zod'
// The definition from the step above.
declare const repoSandbox: SandboxDefinition
const readPackageName = toolDefinition({
name: 'readPackageName',
description: 'Return the name in package.json of the repo.',
inputSchema: z.object({}),
}).server(async (_input, context) => {
const sandbox = context?.get?.(SandboxCapability)
if (!sandbox) return 'No sandbox in this run.'
const pkg: unknown = JSON.parse(
await sandbox.fs.read('/workspace/package.json'),
)
return typeof pkg === 'object' && pkg !== null && 'name' in pkg
? String(pkg.name)
: 'unknown'
})
const stream = chat({
threadId: 'thread-1',
adapter: grokBuildText('grok-build'),
messages: [{ role: 'user', content: 'What is this package called?' }],
tools: [readPackageName],
middleware: [withSandbox(repoSandbox)],
})For other capabilities, see Read a middleware capability.
The agent always has its own native tools (Bash, file edits, search). They run on the sandbox filesystem. Your chat() tools go over a host-side MCP tool proxy:
execute() never goes into the sandbox. Only the call and the result cross the boundary.
A random bearer token for each run protects the bridge. Only the agent of this run has that token, so only it can call your tools.
Note
This page is about your own tools, bridged back to your server. To give the agent third-party MCP servers that it calls directly, declare them on the workspace. See Provisioning. For server tools in general, see Server Tools.
The bridge is an HTTP endpoint that the sandbox calls back to. So the sandbox must be able to connect to your server.
| Where it runs | Host the sandbox calls | Setup |
|---|---|---|
| Local process | localhost | None. |
| Docker container (dockerSandbox) | host.docker.internal | None. |
| Docker Sandboxes (sbxSandbox) | host.docker.internal | A deny or ask allowlist must include localhost. sbxSandbox() adds it when it writes a real allowlist. |
| Deployed server (production) | A URL your provisioner advertises | Provide a bridge provisioner. |
| Cloud sandbox, driven from your laptop | Your laptop, which has no public URL | Tunnel the bridge with withNgrokBridge. |
The sandbox runs on the same machine as your server. It reaches it on localhost (local process) or host.docker.internal (Docker container). Bridged tools need no extra setup. If the HTTP listener cannot start, bridge startup rejects with the original Node.js error.
The guest still calls host.docker.internal. The host HTTP proxy changes that host to localhost before it checks sbx policy. If you write a deny or ask allowlist, allow localhost (or let sbxSandbox() add it). denyNetwork alone does not write that allowlist.
The default provisioner (nodeHttpBridgeProvisioner) binds loopback and tells the sandbox to call 127.0.0.1 (host.docker.internal for Docker). It does not read your public host from the request. So a remote sandbox cannot reach it, even from a deployed server.
Provide your own ToolBridgeProvisioner from a chat middleware with provideToolBridgeProvisioner(ctx, provisioner). Its provision() returns the bridge with a url the sandbox can reach, for example your server's public URL. withNgrokBridge from @tanstack/ai-sandbox/ngrok is a working example. Every call still needs the bearer token for the run, so a public endpoint is safe.
The Cloudflare package ships its own provisioner, so the edge setup needs no extra step. See Cloudflare.
A cloud provider (Daytona, Vercel, Sprites, and the others) runs a remote VM. In local dev, that VM cannot call your localhost, and your laptop has no public URL.
The @tanstack/ai-sandbox/ngrok subpath tunnels the bridge through ngrok:
npm i @ngrok/ngrokpnpm add @ngrok/ngrokyarn add @ngrok/ngrokbun add @ngrok/ngrokimport { chat } from '@tanstack/ai'
import { grokBuildText } from '@tanstack/ai-grok-build'
import { withSandbox } from '@tanstack/ai-sandbox'
import { withNgrokBridge } from '@tanstack/ai-sandbox/ngrok'
import { repoSandbox } from './sandbox'
import { getTodos } from './tools'
chat({
threadId: 'thread-1',
adapter: grokBuildText('grok-build'),
messages: [{ role: 'user', content: 'Fix the bugs in the todos of user u1.' }],
tools: [getTodos],
// Cloud provider in local dev: tunnel the bridge so the remote sandbox can
// reach it. Local process and Docker do not need this.
middleware: [withSandbox(repoSandbox), withNgrokBridge],
})withNgrokBridge is for local dev only. In production your deployed server is already reachable, so you ship without it.