Run CLI coding agents from the Vercel AI SDK v7 HarnessAgent inside a real Coder workspace instead of an ephemeral cloud sandbox.
- Implements the
HarnessV1SandboxProvidercontract from@ai-sdk/harness: pass it as thesandboxto aHarnessAgent, exactly like@ai-sdk/sandbox-vercel. - Claude Code is verified end-to-end. Codex is expected to work via the same bridge mechanism but is not yet verified in this repo.
Status: experimental. Tracks the stable AI SDK v7 harness packages (see the
@ai-sdk/harnesspeer range inpackage.json).
pnpm add @coder/ai-sdk-sandbox @ai-sdk/harness @ai-sdk/harness-claude-code @ai-sdk/provider-utilsChoose one host transport:
| Transport | Host requirements |
|---|---|
CoderCliTransport (default) |
coder CLI, an authenticated coder login, and an OpenSSH client (ssh) on PATH |
CoderNativeTransport |
A deployment URL and token. No coder or ssh binary. See Native transport |
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: { type: "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();
}Runnable version: examples/claude-code.ts.
Use CoderNativeTransport when the host should not depend on the Coder CLI or
OpenSSH:
import { CoderNativeTransport, createCoderWorkspace } from "@coder/ai-sdk-sandbox";
const transport = new CoderNativeTransport({
url: process.env.CODER_URL!,
token: process.env.CODER_SESSION_TOKEN!,
});
const sandbox = createCoderWorkspace({
workspace: "my-dev-workspace",
transport,
});
// On application shutdown, close cached relay WebSockets:
await transport.close();- The constructor falls back to
CODER_URLandCODER_SESSION_TOKEN, sonew CoderNativeTransport()is enough when both are set. - The token is sent only to Coderd in the
Coder-Session-Tokenheader. It is never copied into the workspace.
Add a create block to create a workspace from a template (with parameters
and/or a preset) and tear it down when the session ends:
const agent = new HarnessAgent({
harness: createClaudeCode({ thinking: { type: "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. session.destroy()deletes it.resumeSessionre-derives the same name and reattaches.- The harness runs only after the workspace agent finishes connecting and
running its startup script (
lifecycle_state: ready). A successful build is not enough on its own.
To get-or-create a named workspace, combine 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
parametersentry of the same name (Coder's behavior). Set a given value via the preset orparameters, not both. - Every unset non-ephemeral parameter must be supplied via
parameters,parameterFile, or apreset, unlessuseParameterDefaultsaccepts its template default. - Parameters marked required have no usable default and must always be supplied; otherwise creation fails because it cannot 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. Setvalidate: falseto 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) provisions a workspace for other tools to
bind to. It runs the same get-or-create → start-if-stopped → wait-until-ready
pipeline as create mode, without creating a harness sandbox session.
| Setting | Notes |
|---|---|
workspace |
Required, explicit [owner/]workspace name (there is no sessionId to derive one from). |
create |
Optional; same shape as above (namePrefix/owner are unused). Without it, the workspace must exist. |
readyTimeoutMs, transport, abortSignal |
Optional. |
A stopped workspace is always started. The call returns an
EnsuredCoderWorkspace:
- the workspace's final (ready) status snapshot;
created: whether this call created it;id: the workspace UUID, when the transport reports one. Other Coder packages bind to this handle.
@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 terminal chat instead of one-shot generate() calls, wrap
the agent with @ai-sdk/tui:
pnpm add @ai-sdk/tuiThe TUI drives a session-less agent, so inject 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: { type: "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();
}Runnable version: CODER_WORKSPACE=my-dev-ws npx tsx examples/claude-code-tui.ts
(source; exit with Esc or Ctrl+C).
The bridge runs inside the workspace, so the workspace image needs:
| Requirement | Why |
|---|---|
| Node.js ≥ 22 on the login-shell PATH | The bridge is node bridge.mjs, the native transport's relay is node -e, and the pinned @anthropic-ai/claude-code bridge dependency requires Node 22+. |
pnpm on the login-shell PATH (e.g. npm install -g pnpm) |
The adapter's bootstrap runs pnpm install --frozen-lockfile to install the bridge's dependencies. |
| Outbound access to the npm registry | On first use, the bootstrap downloads the bridge's dependencies, including the Claude Code CLI's ~250 MB platform binary (shipped as an npm package). Pre-bake to skip the registry entirely: see Template authoring. |
| Outbound access to the model API | api.anthropic.com for Claude Code, api.openai.com for Codex. |
The model API key (ANTHROPIC_API_KEY / OPENAI_API_KEY) |
Available to the bridge: configure it through the adapter's auth option or put it in the workspace environment. |
bash and base64 |
Remote commands run through bash -lc; file I/O is base64-encoded. |
stty |
The native transport's relay bootstrap. |
bash, base64, and stty are standard on any Linux dev image.
Pre-bake the adapter's bootstrap into the workspace image so sessions start
with zero runtime install latency. The
template authoring guide covers the Dockerfile,
main.tf, sizing, and verification. A validated pair lives in
examples/template/.
At least one of workspace or create is required. You may set both; see
Creating workspaces on demand.
import {
createCoderWorkspace,
CoderCliTransport,
CoderNativeTransport,
} 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. Construct one to
// configure it, or supply a non-CLI/test transport:
transport: new CoderCliTransport({
// coderBinary: 'coder', sshBinary: 'ssh',
// url: process.env.CODER_URL, token: process.env.CODER_SESSION_TOKEN,
// env: {}, loginShell: true, waitMode: 'no',
}),
// Or connect directly to Coderd with no host CLI/OpenSSH dependency:
// transport: new CoderNativeTransport({
// url: process.env.CODER_URL,
// token: process.env.CODER_SESSION_TOKEN,
// }),
});| Mode | Selected by | Behavior |
|---|---|---|
| Wrap an existing workspace | Default without create (ownsLifecycle: false) |
stop() and destroy() 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() runs coder stop; destroy() runs coder delete. |
| Create mode | create set |
ownsLifecycle defaults to true: a workspace the provider creates is deleted on destroy(), and onFirstCreate runs as its bootstrap hook. |
Create-mode safety rules:
- 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.
- Set
ownsLifecycle: falsefor "create-if-missing but never delete".
- The adapter binds its bridge to the port from
createClaudeCode({ port })or, by default,sandbox.ports[0]. Expose it viaports(default[4000]). getPortUrlasks the configured transport for a local TCP forward and returns a loopbackws://URL. The CLI transport uses OpenSSH-L; the native transport multiplexes TCP over its Coderd WebSocket.- The forward is plaintext on loopback, so
https/wssrequests resolve to theirhttp/wsloopback equivalent.
For bridge-backed adapters (Claude Code, Codex), a HarnessAgent doesn't run
the agent CLI directly. 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 | CLI transport | Native transport |
|---|---|---|
run / spawn |
OpenSSH over coder ssh --stdio |
versioned process relay over Coderd's agent PTY WebSocket |
readFile / writeFile / read*/write* |
base64 over SSH | base64 over the native process relay |
getPortUrl({ port, protocol }) |
OpenSSH -L |
multiplexed TCP channels over the relay |
createSession / resumeSession / id |
CLI workspace lookup | Coderd v2 REST API |
stop / destroy |
coder stop / coder delete when lifecycle-owned |
Coderd workspace-build transitions |
The bridge WebSocket needs no wildcard access URLs: the host running
HarnessAgent is already a Coder client.
Why OpenSSH instead of coder ssh <ws> -- cmd
coder ssh allocates a PTY for the command. The PTY 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: it
runs real OpenSSH over a coder ssh --stdio ProxyCommand. That yields clean,
separated streams and correct exit codes (verified against a live workspace).
How the native relay stays byte-clean
Coderd's browser-terminal endpoint is a PTY, which by itself merges stdout/stderr and has no process exit-code channel. The native transport uses it only as a carrier:
- It bootstraps a small, dependency-free Node relay.
- It switches the PTY to raw/no-echo mode.
- It exchanges versioned newline-delimited frames with base64 byte payloads.
The relay launches commands with separate pipes and also opens TCP sockets for
getPortUrl. It does not bind a workspace port or persist credentials/files.
One relay is cached per selected workspace agent; transport.close() tears it
down.
Why OpenSSH -L instead of coder port-forward
The WebSocket the harness opens against getPortUrl(...) is the critical path.
The bridge sends an unprompted bridge-hello frame immediately after the WS
upgrade. 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.- File reads buffer the whole file (binary content moves as base64). Fine for bootstrap-sized files; not intended for streaming very large files.
CoderNativeTransportcurrently targets POSIX workspaces withbash,stty,base64, and Node.js. Its default relay executable isnode; overriderelayNodeCommandwhen Node lives at a fixed nonstandard path.- A workspace with multiple agents must be selected as
workspace.agent; the native transport refuses to guess. @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 must meet the workspace requirements.
Setup and repo-wide gates are in CONTRIBUTING.md.
Package-specific commands, run from packages/sandbox:
# Unit + local integration tests against fake `coder`/`ssh` executables (no deployment):
pnpm test
# End-to-end against a real workspace (needs the coder CLI + a running workspace):
pnpm verify:real my-ws
# The same contract through Coderd directly. The CLI only mints a token for
# this shell; CoderNativeTransport never invokes it:
CODER_URL=https://coder.example.com \
CODER_SESSION_TOKEN="$(coder tokens create --name ai-sdk-sandbox)" \
pnpm verify:native my-ws
# Create mode: creates a throwaway workspace from a template, waits for
# readiness, runs a command in it, and deletes it:
pnpm verify:create dockerWhat the local integration tests cover
They 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. scripts/verify-real.ts runs the same surface, plus a
real WebSocket-over-SSH round-trip, against an actual workspace.
Apache-2.0