A Coder workspace sandbox provider for the Vercel AI SDK v7 HarnessAgent. It lets you run CLI coding agents inside a real Coder workspace instead of an ephemeral cloud sandbox. Claude Code is verified end-to-end here; Codex is expected to work via the same bridge mechanism but is not yet verified in this repo.
It implements the HarnessV1SandboxProvider contract from @ai-sdk/harness, so
you pass it as the sandbox to a HarnessAgent exactly like
@ai-sdk/sandbox-vercel.
Status: experimental. This provider tracks the stable AI SDK v7 harness packages (
@ai-sdk/harness@^1.0.23).
npm add @coder/ai-sdk-sandbox @ai-sdk/harness @ai-sdk/harness-claude-code @ai-sdk/provider-utilsOn the host you also need:
- the
coderCLI on PATH and authenticated (coder login); for non-ambient auth, configure the transport explicitly withnew CoderCliTransport({ url, token })(see Settings); - an OpenSSH client (
ssh) on PATH — exec runs through it (see "How it works").
Wrap an existing, running workspace and run Claude Code in it:
import { HarnessAgent } from "@ai-sdk/harness/agent";
import { createClaudeCode } from "@ai-sdk/harness-claude-code";
import { createCoderWorkspace } from "@coder/ai-sdk-sandbox";
const agent = new HarnessAgent({
harness: createClaudeCode({ thinking: "adaptive" }),
sandbox: createCoderWorkspace({ workspace: "my-dev-workspace" }),
instructions: "You are a careful coding assistant.",
});
const session = await agent.createSession();
try {
const result = await agent.generate({
session,
prompt: "Create a short TODO.md in the repo root.",
});
console.log(result.text);
} finally {
await session.destroy();
}See examples/claude-code.ts for a runnable version.
Instead of pointing at an existing workspace, you can have the provider create
one from a template — with parameters and/or a preset — and tear it down when
the session ends. Add a create block:
const agent = new HarnessAgent({
harness: createClaudeCode({ thinking: "adaptive" }),
sandbox: createCoderWorkspace({
create: {
template: "docker", // required: the template to create from
preset: "Large", // optional: a template version preset
parameters: { cpus: 8, region: "us-west-2" },
useParameterDefaults: true, // accept template defaults for the rest
stopAfter: "8h", // auto-stop TTL
},
}),
});By default this is fresh-per-session: the workspace name is derived from the
harness sessionId (e.g. agent-1a2b3c4d5e6f), so each session gets its own
workspace, and session.destroy() deletes it. resumeSession re-derives the
same name and reattaches. The provider waits for the workspace agent to finish
connecting and running its startup script (lifecycle_state: ready) before the
harness runs — a successful build is not enough on its own.
You can also get-or-create a named workspace by combining workspace with
create: if it exists the provider attaches to it (and never deletes it); if it
doesn't, the provider creates it (and, by default, owns it).
createCoderWorkspace({
workspace: "my-agent-ws",
create: { template: "docker", ifExists: "attach" }, // 'attach' (default) | 'error'
});Parameters vs. presets. A preset's parameter values take precedence over an
overlapping parameters entry of the same name (this is Coder's behavior), so
set a given value via the preset or parameters, not both. Required
parameters (those without a template default) must be supplied via parameters,
parameterFile, a preset, or useParameterDefaults — otherwise creation
fails (it can't prompt non-interactively). If you set a preset, the provider
preflight-validates the name against the template's presets and fails fast with
the available names (set validate: false to skip).
createCoderWorkspace({
create: {
template: "docker", // required
templateVersion: undefined, // default: the template's active version
preset: undefined, // 'none' forces no preset
parameters: {}, // { name: value }; numbers/bools stringified
parameterFile: undefined, // path to a YAML rich-parameter file
useParameterDefaults: false, // accept template defaults where unset
ephemeralParameters: {}, // one-time build parameters
stopAfter: undefined, // e.g. '8h' (auto-stop TTL)
automaticUpdates: undefined, // 'always' | 'never'
org: undefined, // --org, for ambiguous template names
owner: undefined, // owner for a derived name (owner/name)
ifExists: "attach", // 'attach' | 'error'
namePrefix: "agent", // prefix for the derived per-session name
validate: true, // preflight-check the preset name
},
readyTimeoutMs: 300_000, // wait budget for the agent to become ready
});ensureCoderWorkspace(settings) runs the same get-or-create → start-if-stopped
→ wait-until-ready pipeline as create mode, but without creating a harness
sandbox session — use it to provision a workspace for other tools to bind
to. It takes an explicit workspace name ([owner/]workspace; there is no
sessionId to derive one from), an optional create block (same shape as
above; namePrefix/owner are unused), plus readyTimeoutMs, transport, and
abortSignal. A stopped workspace is always started. Without create, the
workspace must already exist.
It returns an EnsuredCoderWorkspace: the workspace's final (ready) status
snapshot plus created (whether this call created it) and — when the transport
reports one — id, the workspace UUID. That id is the handle other Coder
packages bind to. @coder/ai-sdk-agent is intentionally not a dependency of
this package; the two compose by a plain string handoff:
import { ensureCoderWorkspace } from "@coder/ai-sdk-sandbox";
import { CoderAgent } from "@coder/ai-sdk-agent";
const ws = await ensureCoderWorkspace({
workspace: "agent-ws",
create: { template: "docker" },
});
const agent = new CoderAgent({
baseUrl: process.env.CODER_URL!,
token: process.env.CODER_SESSION_TOKEN!,
organizationId: "<org-uuid>",
workspaceId: ws.id!, // binds the chat's workspace-scoped tools
});The non-null assertion is safe on coder CLIs that emit id in
coder list -o json; old CLIs omit it, so guard
(if (ws.id === undefined) throw …) when you can't pin the CLI version.
For an interactive chat in your terminal instead of one-shot generate() calls,
wrap the same agent with the AI SDK terminal UI (@ai-sdk/tui):
npm add @ai-sdk/tuiThe TUI drives a session-less agent, so adapt the HarnessAgent (whose
generate/stream take a session) by injecting the session for the TUI's
lifetime:
import { HarnessAgent, type HarnessAgentSession } from "@ai-sdk/harness/agent";
import { createClaudeCode } from "@ai-sdk/harness-claude-code";
import { runAgentTUI, type AgentTUIAgent } from "@ai-sdk/tui";
import { createCoderWorkspace } from "@coder/ai-sdk-sandbox";
const agent = new HarnessAgent({
harness: createClaudeCode({ thinking: "adaptive" }),
sandbox: createCoderWorkspace({ workspace: "my-dev-ws" }),
// or, to create a fresh workspace per session from a template:
// sandbox: createCoderWorkspace({ create: { template: 'claude-code-test' } }),
});
const toTUIAgent = (agent: HarnessAgent, session: HarnessAgentSession): AgentTUIAgent => ({
version: "agent-v1",
id: agent.id,
tools: agent.tools,
generate: (request) => agent.generate({ ...request, session }),
stream: (request) => agent.stream({ ...request, session }),
});
const session = await agent.createSession();
try {
await runAgentTUI({ title: "Claude Code @ Coder", agent: toTUIAgent(agent, session) });
} finally {
await session.destroy();
}See examples/claude-code-tui.ts for a runnable
version: CODER_WORKSPACE=my-dev-ws npx tsx examples/claude-code-tui.ts (exit with
Esc or Ctrl+C).
Because the bridge runs inside the workspace, the workspace image must have:
- Node.js (the docs use
node24). The bridge isnode bridge.mjs. - pnpm available on PATH (e.g. via
corepack enable) — the adapter uses it to bootstrap the bridge. - Outbound network access to the npm registry (the adapter
pnpm installs the bridge's dependencies + the Claude Code CLI on first use) and to the model API (api.anthropic.comfor Claude Code,api.openai.comfor Codex). Bake the dependencies into the image to avoid per-session installs. - The model API key available to the bridge —
ANTHROPIC_API_KEY/OPENAI_API_KEY. Configure it through the adapter'sauthoption or ensure it is present in the workspace environment. bashandbase64(standard on any Linux dev image).
At least one of workspace or create is required (you may set both — see
Creating workspaces on demand).
import { createCoderWorkspace, CoderCliTransport } from "@coder/ai-sdk-sandbox";
createCoderWorkspace({
// One of these is required (TypeScript enforces it):
workspace: "my-ws", // fixed name, or (sessionId) => `agent-${sessionId}`
create: undefined, // create from a template; see "Creating workspaces"
readyTimeoutMs: 300_000, // wait budget for the agent to become ready
ports: [4000], // exposed ports; ports[0] is the bridge port
defaultWorkingDirectory: "/home/coder", // default: resolved from $HOME, else /home/coder
ownsLifecycle: false, // see "Lifecycle modes" below
ensureStarted: false, // run `coder start` before attaching
// Transport. Defaults to an ambient-login CoderCliTransport. Configure the CLI
// transport (binary paths, url/token, env, login shell, wait mode) — or supply
// a non-CLI/test transport — by constructing one explicitly:
transport: new CoderCliTransport({
// coderBinary: 'coder', sshBinary: 'ssh',
// url: process.env.CODER_URL, token: process.env.CODER_SESSION_TOKEN,
// env: {}, loginShell: true, waitMode: 'no',
}),
});- Wrap an existing workspace (default when there's no
create,ownsLifecycle: false).stop()anddestroy()only release host-side resources (port-forwards); the workspace keeps running. The natural fit for long-lived dev workspaces. - Own the workspace (
ownsLifecycle: true).stop()runscoder stopanddestroy()runscoder delete. - Create mode (
createset).ownsLifecycledefaults totrue, so a workspace the provider creates is deleted ondestroy()andonFirstCreateruns as its bootstrap hook. As a safety measure, a workspace the provider only attached to (an explicitly-named, pre-existing one) is never deleted — only ones it actually created. A per-session derived name is always treated as owned. SetownsLifecycle: falsefor "create-if-missing but never delete".
The adapter binds its bridge to a port and resolves it from
createClaudeCode({ port }) or, by default, sandbox.ports[0]. Expose that port
via ports (default [4000]); getPortUrl opens an OpenSSH -L local forward
(over the same coder ssh --stdio ProxyCommand) to it on demand and returns a
loopback ws:// URL. The forward is plaintext on loopback, so https/wss
requests resolve to their http/ws loopback equivalent.
A HarnessAgent doesn't run the agent CLI directly. For bridge-backed adapters
(Claude Code, Codex) it installs a small Node "bridge" program inside the
sandbox, spawns it, and talks to it over an authenticated WebSocket. The
bridge runs the vendor SDK in-workspace and streams events back to the host.
This provider maps that contract onto Coder primitives:
| Harness contract | Coder implementation |
|---|---|
run / spawn |
OpenSSH bash -lc '…' over a coder ssh --stdio ProxyCommand |
readFile / writeFile / read*/write* |
base64 piped over the SSH connection (binary-safe) |
getPortUrl({ port, protocol }) |
OpenSSH -L <local>:127.0.0.1:<port> over the same ProxyCommand → ws://127.0.0.1:<local> |
ports / setPorts |
the workspace's exposed port set |
createSession / resumeSession / id |
attach to a workspace by name |
stop / destroy |
coder stop / coder delete (only when it owns the lifecycle) |
Why OpenSSH and not coder ssh <ws> -- cmd? coder ssh allocates a PTY for
the command, which rewrites newlines to CRLF, merges stdout and stderr onto one
stream, and does not reliably propagate exit codes — all fatal for programmatic
use and for the bridge's stdout parsing. coder ssh's own help recommends
coder config-ssh "for users who need the full functionality of SSH"; this
provider does the programmatic equivalent, running real OpenSSH over a
coder ssh --stdio ProxyCommand. That yields clean, separated streams and
correct exit codes (verified against a live workspace).
The WebSocket the harness opens against getPortUrl(...) is the critical path,
and it needs no wildcard access URLs — the host running HarnessAgent is already
a Coder client. We forward via OpenSSH -L rather than coder port-forward:
the bridge sends an unprompted bridge-hello frame immediately after the WS
upgrade, and in testing a freshly-created coder port-forward tunnel did not
reliably deliver that first server-initiated frame to the first WS client,
whereas SSH local forwarding does. This path is verified end-to-end against a
real workspace — both a synthetic WebSocket round-trip (scripts/verify-real.ts)
and a full Claude Code turn with tool use (scripts/e2e-claude.ts).
setNetworkPolicyis not implemented (omitted) — egress is governed by your Coder template/deployment, not this provider.bridgePortsis intentionally left undefined: this provider binds one workspace per session rather than leasing ports from a shared sandbox.- File reads buffer the whole file (binary content moves as base64). Fine for bootstrap-sized files; not intended for streaming very large files.
@ai-sdk/sandbox-just-bashcannot expose ports and is rejected by bridge-backed adapters — this provider exists precisely to provide that port.- To run Claude Code / Codex, the workspace image needs Node.js (the adapter
installs the bridge + CLI on first use) and egress to the npm registry and the
model API. Provide the API key via the adapter's
authoption.
npm install
npm run typecheck # tsc against the real harness types
npm test # vitest: unit + local integration (fake `coder` + `ssh`)
npm run build # tsup → dist/ (ESM + d.ts)
# Formatting & linting (Biome):
npm run format # biome format --write . (apply formatting)
npm run lint # biome lint . (report lint issues)
npm run check # biome check . (format + lint, read-only; for CI)
# End-to-end against a real workspace (needs the coder CLI + a running workspace):
npm run verify:real -- my-ws
# End-to-end of create mode (creates a throwaway workspace, then deletes it):
npm run verify:create -- dockerThe local integration tests exercise the real transport (argument building,
stdin, base64 file round-trips, streaming, the port-forward lifecycle, and the
create/status/presets JSON paths) against fake coder/ssh executables that run
commands locally — no Coder deployment needed. scripts/verify-real.ts runs the
same surface — plus a real WebSocket-over-SSH round-trip — against an actual
workspace; scripts/verify-create.ts creates a throwaway workspace from a
template, waits for readiness, runs a command in it, and deletes it.
Apache-2.0