Skip to content

Latest commit

 

History

History

Folders and files

NameName
Last commit message
Last commit date

parent directory

..
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 

README.md

@coder/ai-sdk-sandbox

Run CLI coding agents from the Vercel AI SDK v7 HarnessAgent inside a real Coder workspace instead of an ephemeral cloud sandbox.

  • Implements the HarnessV1SandboxProvider contract from @ai-sdk/harness: pass it as the sandbox to a HarnessAgent, 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/harness peer range in package.json).

Install

pnpm add @coder/ai-sdk-sandbox @ai-sdk/harness @ai-sdk/harness-claude-code @ai-sdk/provider-utils

Choose 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

Quick start

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.

Native transport

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_URL and CODER_SESSION_TOKEN, so new CoderNativeTransport() is enough when both are set.
  • The token is sent only to Coderd in the Coder-Session-Token header. It is never copied into the workspace.

Creating workspaces on demand

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. resumeSession re-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 parameters entry of the same name (Coder's behavior). Set a given value via the preset or parameters, not both.
  • Every unset non-ephemeral parameter must be supplied via parameters, parameterFile, or a preset, unless useParameterDefaults accepts 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. Set validate: false to skip.

Create settings

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
});

Provisioning a workspace without a session

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.

Terminal UI

For an interactive terminal chat instead of one-shot generate() calls, wrap the agent with @ai-sdk/tui:

pnpm add @ai-sdk/tui

The 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).

Workspace requirements

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.

Template authoring: zero-install sessions

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/.

Settings

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,
  // }),
});

Lifecycle modes

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: false for "create-if-missing but never delete".

Ports

  • The adapter binds its bridge to the port from createClaudeCode({ port }) or, by default, sandbox.ports[0]. Expose it via ports (default [4000]).
  • getPortUrl asks the configured transport for a local TCP forward and returns a loopback ws:// URL. The CLI transport uses OpenSSH -L; the native transport multiplexes TCP over its Coderd WebSocket.
  • The forward is plaintext on loopback, so https/wss requests resolve to their http/ws loopback equivalent.

How it works

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:

  1. It bootstraps a small, dependency-free Node relay.
  2. It switches the PTY to raw/no-echo mode.
  3. 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).

Limitations & notes

  • setNetworkPolicy is 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.
  • CoderNativeTransport currently targets POSIX workspaces with bash, stty, base64, and Node.js. Its default relay executable is node; override relayNodeCommand when 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-bash cannot 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.

Development

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 docker
What 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.

License

Apache-2.0