Skip to content

feat: On-demand Sub-Agent mode — invoke sandbox headlessly from Claude Code, Open WebUI, or Codex #5

@christestet

Description

@christestet

Summary

The sandbox currently runs OpenCode as an interactive TUI for a single human user. This issue proposes extending it to support on-demand invocation as a sub-agent from host-side agents like Claude Code, Open WebUI, or Codex CLI — without changing the existing security model, Dockerfile, or interactive mode.

A host agent delegates a task via tool call → Docker starts the sandbox ephemerally → OpenCode executes the task headlessly against the local vLLM server → structured JSON is returned → container stops automatically.

No data leaves your network. No cloud API keys required. No changes to the hardened security setup.


Why

Problem Today After
A host agent (Claude Code etc.) can delegate tasks to the sandbox
Headless / non-interactive execution
Structured JSON output for machine-readable results
Ephemeral start only when needed

Benefits:

  • Data sovereignty — even delegated sub-agent tasks stay fully local
  • Security isolation — Docker enforces kernel-level separation between host agent and sub-agent; a misbehaving prompt cannot escape to the host
  • Cost efficiency — all inference runs against local vLLM, no token costs even at scale
  • Reuse — existing commands (/refactor-audit, /git-commit etc.) become available to all calling agents with zero extra work

What needs to change

Only 3 files need to be touched. The Dockerfile, compose.yml, and the entire security model remain completely unchanged.

1. entrypoint.sh — add a TASK branch at the end

Replace the final exec gosu ... line with:

export HOME="/home/opencode/workspace"
export OPENCODE_WORKSPACE="/home/opencode/workspace"

if [[ -n "${TASK}" ]]; then
    echo "> [Entrypoint] Sub-Agent headless mode" >&2
    exec gosu "$APP_USER:$APP_GROUP" \
        opencode run "${TASK}" \
        --format json \
        --quiet
else
    exec gosu "$APP_USER:$APP_GROUP" opencode
fi

Everything before this (PUID/PGID handling, chown, gosu setup) stays identical.

2. New file: compose.headless.yml

services:
  opencode:
    build: .
    environment:
      TASK: ${TASK}
      PUID: ${PUID:-1000}
      PGID: ${PGID:-1000}
    volumes:
      - ./workspace:/home/opencode/workspace
      - ./data:/home/opencode/.local/share/opencode
      - ./config/opencode.json:/home/opencode/.config/opencode/opencode.json:ro
      - ./config/auth.json:/home/opencode/.config/opencode/auth.json:ro
      - ./config/AGENTS.md:/home/opencode/.config/opencode/AGENTS.md:ro
      - ./.opencode/commands:/home/opencode/.config/opencode/commands:ro
      - ./.opencode/skills:/home/opencode/.config/opencode/skills:ro
    cap_drop: [ALL]
    cap_add: [CHOWN, SETUID, SETGID, DAC_OVERRIDE]
    security_opt: [no-new-privileges:true]
    stdin_open: false
    tty: false
    restart: "no"

3. New file: tools/mcp_sandbox_server.py (for Claude Code)

import asyncio, os, subprocess
from mcp.server import Server
from mcp.server.stdio import stdio_server
from mcp.types import Tool, TextContent

SANDBOX_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__)))
app = Server("coding-agent-sandbox")

@app.list_tools()
async def list_tools():
    return [Tool(
        name="run_in_sandbox",
        description=(
            "Runs a coding task in the hardened Docker sandbox "
            "against local vLLM. Returns JSON with agent output."
        ),
        inputSchema={
            "type": "object",
            "properties": {
                "task": {"type": "string"},
                "timeout": {"type": "integer", "default": 300}
            },
            "required": ["task"]
        }
    )]

@app.call_tool()
async def call_tool(name: str, arguments: dict):
    result = subprocess.run(
        ["docker", "compose", "-f", "compose.headless.yml", "run", "--rm", "opencode"],
        env={**os.environ, "TASK": arguments["task"]},
        capture_output=True, text=True,
        timeout=arguments.get("timeout", 300),
        cwd=SANDBOX_DIR
    )
    output = result.stdout.strip()
    if result.returncode != 0:
        output = f'{{"error": "exit {result.returncode}", "stderr": {repr(result.stderr)}}}'
    return [TextContent(type="text", text=output)]

if __name__ == "__main__":
    asyncio.run(stdio_server(app))

Register with Claude Code:

claude mcp add coding-agent-sandbox -- python3 ./tools/mcp_sandbox_server.py

Smoke test

TASK="List all files in the workspace and summarize their purpose." \
  docker compose -f compose.headless.yml run --rm opencode

Notes

  • opencode run --format json --quiet is the official non-interactive mode — no hacking required
  • For high-frequency sub-agent calls, opencode serve --port 4096 + --attach eliminates per-task container start overhead
  • The Open WebUI variant is ~20 lines of Python using subprocess.run — no MCP dependency needed

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions