Skip to main content

Documentation Index

Fetch the complete documentation index at: https://mintlify.com/jaypopat/cf_ai_duet/llms.txt

Use this file to discover all available pages before exploring further.

Overview

Duet uses Cloudflare Sandboxes (container-based Durable Objects) to provide isolated, secure command execution environments. Each room gets its own dedicated sandbox instance that persists for the lifetime of the room.

Sandbox Architecture

Sandboxes are implemented as container-based Durable Objects:
[durable_objects]
bindings = [
  { name = "DUET_AGENT", class_name = "DuetAgent" },
  { name = "Sandbox", class_name = "Sandbox" },
]

[[containers]]
class_name = "Sandbox"
image = "./Dockerfile"

[[migrations]]
tag = "v2"
new_sqlite_classes = ["Sandbox"]
Source: ~/workspace/source/cf-worker/wrangler.toml:6-21 Each sandbox:
  • Runs in an isolated container environment
  • Has its own filesystem state
  • Is bound to a specific room via naming convention
  • Persists across multiple command executions

Sandbox Naming

Sandboxes are named using the pattern sandbox-{roomId}:
const sandbox = getSandbox(this.env.Sandbox, `sandbox-${roomId}`);
Source: ~/workspace/source/cf-worker/index.ts:196 This ensures:
  • Each room has its own isolated sandbox
  • Multiple users in the same room share the same sandbox
  • Different rooms cannot access each other’s sandboxes

Command Execution

Direct Execution Endpoint

The /sandbox/exec endpoint allows direct command execution:
private async handleSandboxExec(roomId: string, rawBody: unknown): Promise<Response> {
  const parseResult = SandboxExecRequestSchema.safeParse(rawBody);

  if (!parseResult.success) {
    return Response.json(
      {
        error: "invalid request",
        details: z.flattenError(parseResult.error).fieldErrors,
      },
      { status: 400 }
    );
  }

  const data = parseResult.data;
  const sandboxName = `sandbox-${roomId}`;

  try {
    const sandbox = getSandbox(this.env.Sandbox, sandboxName);
    const result = await sandbox.exec(data.cmd);

    return Response.json({ result, sandboxName });
  } catch (error) {
    return Response.json(
      {
        error: `sandbox execution failed: ${error instanceof Error ? error.message : "unknown error"}`,
      },
      { status: 500 }
    );
  }
}
Source: ~/workspace/source/cf-worker/index.ts:210-242

Request Validation

Commands are validated using Zod schema:
const SandboxExecRequestSchema = z.object({
  cmd: z.string().min(1, "Command cannot be empty"),
});
Source: ~/workspace/source/cf-worker/index.ts:14-16

Execution Result

Sandbox execution returns stdout and stderr:
const { stderr, stdout } = await sandbox.exec(cmd);
The result structure:
type ExecResult struct {
  Stdout string `json:"stdout"`
  Stderr string `json:"stderr"`
}

type ExecResponse struct {
  Result      ExecResult `json:"result"`
  SandboxName string     `json:"sandboxName"`
  Error       string     `json:"error,omitempty"`
}
Source: ~/workspace/source/internal/ai/client.go:54-65

AI-Triggered Execution

The AI can trigger sandbox commands automatically using <run> tags:
private async executeCommands(text: string, roomId: string): Promise<string> {
  const matches = Array.from(text.matchAll(/<run>([\s\S]*?)<\/run>/g));
  let result = text;

  for (const match of matches) {
    const cmd = match[1]?.trim();
    if (!cmd) {
      continue;
    }

    try {
      const sandbox = getSandbox(this.env.Sandbox, `sandbox-${roomId}`);
      const { stderr, stdout } = await sandbox.exec(cmd);

      const summary =
        stdout.slice(0, 500) || stderr.slice(0, 500) || "[no output]";
      result += `\n\nOutput (${cmd}):\n${summary}`;
    } catch (e) {
      const msg = e instanceof Error ? e.message : String(e);
      result += `\n\nError (${cmd}):\n${msg}`;
    }
  }
  return result.replace(/<run>[\s\S]*?<\/run>/g, "").trim() || "";
}
Source: ~/workspace/source/cf-worker/index.ts:185-208 The execution flow:
  1. Extract commands - Regex finds all <run>...</run> tags
  2. Execute each command - Run in the room’s sandbox
  3. Capture output - Get first 500 chars of stdout/stderr
  4. Append to response - Add output after AI’s explanation
  5. Remove tags - Strip <run> tags from final response

Output Truncation

To prevent response bloat, output is limited:
const summary = stdout.slice(0, 500) || stderr.slice(0, 500) || "[no output]";
Source: ~/workspace/source/cf-worker/index.ts:199-200 This ensures:
  • Responses remain reasonably sized
  • Users see immediate feedback
  • Long outputs don’t overwhelm the UI

Error Handling

Execution errors are caught and included in the response:
try {
  const sandbox = getSandbox(this.env.Sandbox, `sandbox-${roomId}`);
  const { stderr, stdout } = await sandbox.exec(cmd);
  // ...
} catch (e) {
  const msg = e instanceof Error ? e.message : String(e);
  result += `\n\nError (${cmd}):\n${msg}`;
}
Source: ~/workspace/source/cf-worker/index.ts:195-205 Errors are displayed to the user rather than failing silently.

Sandbox Lifecycle

Creation

Sandboxes are created on-demand when first accessed:
const sandbox = getSandbox(this.env.Sandbox, `sandbox-${roomId}`);
The getSandbox function from @cloudflare/sandbox handles lazy initialization.

Persistence

Sandboxes persist across multiple command executions, maintaining:
  • Filesystem state
  • Working directory
  • Installed packages or files
This allows multi-step workflows like:
  1. Create a file
  2. Modify it
  3. Run it

Cleanup

When a room is deleted, its sandbox is destroyed:
private async handleCleanup(roomId: string): Promise<Response> {
  const errors: string[] = [];

  // Reset agent state
  this.setState({ messages: [] });

  // Terminate sandbox
  try {
    const sandbox = getSandbox(this.env.Sandbox, `sandbox-${roomId}`);
    await sandbox.destroy();
  } catch (e) {
    errors.push(`sandbox: ${e instanceof Error ? e.message : String(e)}`);
  }

  if (errors.length > 0) {
    return Response.json({ cleaned: true, errors }, { status: 207 });
  }

  return Response.json({ cleaned: true, roomId });
}
Source: ~/workspace/source/cf-worker/index.ts:244-266 Cleanup errors are collected and returned with a 207 Multi-Status response.

Client API

The Go client provides a method to execute commands:
func (c *Client) ExecCommand(ctx context.Context, roomID, cmd string) (*ExecResponse, error) {
  url := fmt.Sprintf("%s/api/rooms/%s/sandbox/exec", c.baseURL, roomID)

  body := ExecRequest{
    Cmd: cmd,
  }

  jsonBody, err := json.Marshal(body)
  if err != nil {
    return nil, fmt.Errorf("marshal request: %w", err)
  }

  req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
  if err != nil {
    return nil, fmt.Errorf("create request: %w", err)
  }
  req.Header.Set("Content-Type", "application/json")

  resp, err := c.http.Do(req)
  if err != nil {
    return nil, fmt.Errorf("send request: %w", err)
  }
  defer resp.Body.Close()

  var result ExecResponse
  if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
    return nil, fmt.Errorf("decode response: %w", err)
  }

  if result.Error != "" {
    return nil, fmt.Errorf("sandbox error: %s", result.Error)
  }

  return &result, nil
}
Source: ~/workspace/source/internal/ai/client.go:128-162

Example Usage

Direct Command Execution

POST /api/rooms/room-123/sandbox/exec
Content-Type: application/json

{
  "cmd": "echo 'Hello from sandbox'"
}
Response:
{
  "result": {
    "stdout": "Hello from sandbox\n",
    "stderr": ""
  },
  "sandboxName": "sandbox-room-123"
}

AI-Triggered Execution

User: “Create a hello.txt file with ‘Hello World’” AI Response:
I'll create the file for you.
<run>echo 'Hello World' > hello.txt</run>
Final Response to User:
I'll create the file for you.

Output (echo 'Hello World' > hello.txt):
[no output]

Next Steps

Build docs developers (and LLMs) love