@sandagent/daemon
v0.9.15
Published
SandAgent Daemon - Unified API gateway for sandbox services (file, git, volumes)
Readme
@sandagent/daemon
Unified API gateway for sandbox containers. Runs inside the sandock Next.js app — either embedded as a Next.js route handler (local/dev mode) or as a standalone process inside a sandbox container (production).
Where this runs
~/Documents/kapps/apps/buda/ ← buda.im Next.js app
app/api/daemon/[...path]/
route.ts ← embeds @sandagent/daemon via createNextHandler()
(local dev mode, no extra process)
sandbox container ← production
sandagent-daemon :3080 ← standalone process
(accessed via sandock.ai proxy)The same @sandagent/daemon package works in both modes — Next.js embed for local development, standalone HTTP server for production containers.
SandAgent container image (vikadata/sandagent)
The Dockerfiles under docker/sandagent-claude/ (Dockerfile, Dockerfile.local, Dockerfile.template) install @sandagent/daemon and start sandagent-daemon in the background when the container starts (alongside the existing CDP / sandagent CLI setup). The HTTP API listens on 0.0.0.0:3080 by default (EXPOSE 3080). Override with SANDAGENT_DAEMON_HOST, SANDAGENT_DAEMON_PORT, and SANDAGENT_ROOT if needed.
Runner environment
POST /api/coding/run starts the runner with the daemon process process.env. Configure API keys and runner settings on the daemon (or container image env), not via per-request HTTP headers from @sandagent/manager.
Architecture
1. Big Picture — How Buda talks to a sandbox
buda.im
│
│ HTTPS
▼
sandock.ai
/api/v1/sandbox/http/proxy/{sandbox-id}/3080/
│
│ HTTP (proxied)
▼
┌─────────────────────────────────────────────────────┐
│ sandbox container │
│ │
│ ┌────────────────────────────────────┐ │
│ │ sandagent-daemon :3080 │ │
│ │ (unified API gateway) │ │
│ └────────────────────────────────────┘ │
│ │
│ chromium :9223 (internal) --remote-allow-origins=*│
│ nginx :9222 → :9223 (rewrites Host: localhost) │
└─────────────────────────────────────────────────────┘External callers only ever see one port: 3080. Everything else is internal.
2. Inside the daemon — request routing
incoming HTTP request
│
▼
┌───────────────────────────────────────────────────────┐
│ sandagent-daemon │
│ │
│ POST /api/coding/run ──────────────────────────┐ │
│ │ │
│ GET|POST /api/fs/* ──────────────────────────┐ │ │
│ GET|POST /api/git/* ──────────────────────┐ │ │ │
│ GET|POST /api/volumes/* ──────────────┐ │ │ │ │
│ GET /healthz ─────────────────────┐ │ │ │ │ │
│ │ │ │ │ │ │
│ ▼ ▼ ▼ │ │ │
│ ┌─────────────┐ │ │ │
│ │ DaemonRouter│ │ │ │
│ │ (core logic)│ │ │ │
│ └──────┬──────┘ │ │ │
│ │ │ │ │
│ ┌───────────────────────┤ │ │ │
│ ▼ ▼ ▼ │ │
│ node:fs/promises spawn git SSE │ │
│ (file ops) (git CLI) stream │ │
│ │ │
│ @sandagent/ │ │
│ runner-core ◄───┘ │
│ claude/pi/ │
│ gemini/codex │
└───────────────────────────────────────────────────────┘3. Package dependency graph
packages/
│
├── runner-claude ──┐
├── runner-codex ──┤
├── runner-gemini ──┼──► runner-core ◄──┬── apps/runner-cli
├── runner-pi ──┤ │
└── runner-opencode ──┘ └── apps/sandagent-daemonrunner-core is the shared dispatch layer — no I/O, no stdout, just createRunner() → AsyncIterable<string>.
4. Deployment modes
┌──────────────────────────────────────────────────────────────────┐
│ Mode A: Standalone process (container / local) │
│ │
│ entrypoint.sh │
│ ├── chromium :9223 (internal) --remote-allow-origins=* & │
│ ├── nginx :9222 → :9223 (rewrites Host: localhost) & │
│ └── sandagent-daemon ← node process, listens :3080 │
│ │
│ caller: curl / Buda SDK / any HTTP client │
│ → http://sandbox:3080/api/fs/read?path=file.txt │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Mode B: Embedded in Next.js (local dev / sandock-cli) │
│ │
│ app/api/daemon/[...path]/route.ts │
│ └── createNextHandler({ root: process.cwd() }) │
│ │
│ No extra process. DaemonRouter runs inside Next.js. │
│ caller: browser / fetch │
│ → /api/daemon/fs/read?path=file.txt │
└──────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────────┐
│ Mode C: runner-cli (local terminal, no daemon needed) │
│ │
│ sandagent run --runner claude -- "Build a REST API" │
│ └── runner-core → stdout (AI SDK UI NDJSON stream) │
│ │
│ Runs directly on local filesystem. No HTTP server. │
└──────────────────────────────────────────────────────────────────┘5. Internal code structure
apps/sandagent-daemon/
├── src/
│ ├── cli.ts entry point — reads env, starts http.Server
│ ├── server.ts createDaemon() — http.Server wrapping DaemonRouter
│ ├── router.ts DaemonRouter — framework-agnostic route table
│ ├── nextjs.ts createNextHandler() — Next.js adapter
│ ├── utils.ts path safety, ApiEnvelope helpers
│ └── routes/
│ ├── health.ts GET /healthz
│ ├── fs.ts GET|POST /api/fs/*
│ ├── volumes.ts GET|POST /api/volumes/*
│ ├── git.ts POST /api/git/* (spawns git CLI)
│ └── coding.ts POST /api/coding/run (SSE, uses runner-core)
└── src/__tests__/
└── daemon.test.ts 13 integration tests (no mocks, real fs + git)Usage
Option A: runner-cli (local terminal)
cd templates/coder
npx sandagent run -- "Build a REST API"
npx sandagent run --runner pi -- "Analyze this dataset"
npx sandagent run --runner gemini --model gemini-2.0-flash -- "Review my code"
npx sandagent run --resume <session-id> -- "Continue"Output: raw AI SDK UI NDJSON stream to stdout.
Option B: daemon standalone (container)
# see docs/entrypoint.example.sh for the full script
#
# Chromium runs on internal port 9223 with --remote-allow-origins=* so
# the WebSocket origin check passes. nginx proxies 0.0.0.0:9222 → 9223
# and rewrites the Host header to "localhost" to satisfy Chromium's
# DNS-rebinding security check. Without this rewrite, external clients
# (Host: container-ip:9222) are rejected even when the port is open.
chromium --headless --no-sandbox \
--remote-debugging-port=9223 \
--remote-allow-origins=* &
nginx # proxies :9222 → :9223 with Host rewrite
exec sandagent-daemon# Run an agent — SSE stream
curl -N -X POST http://localhost:3080/api/coding/run \
-H 'Content-Type: application/json' \
-d '{"runner":"claude","userInput":"List files in /workspace"}'
# File ops
curl -X POST http://localhost:3080/api/fs/write \
-H 'Content-Type: application/json' \
-d '{"path":"hello.txt","content":"hello world"}'
curl "http://localhost:3080/api/fs/read?path=hello.txt"
# Git
curl -X POST http://localhost:3080/api/git/clone \
-H 'Content-Type: application/json' \
-d '{"repo_parent":".","url":"https://github.com/user/repo","depth":1}'Option C: embed in Next.js
// app/api/daemon/[...path]/route.ts
import { createNextHandler } from "@sandagent/daemon/nextjs";
const handler = createNextHandler({ root: process.cwd() });
export const GET = handler;
export const POST = handler;Covers /api/fs/*, /api/git/*, /api/volumes/* at /api/daemon/*. No extra process.
Option D: just run an agent directly (no daemon needed)
If you only need to run an agent — no file API, no HTTP server — use runner-core directly:
import { createRunner } from "@sandagent/runner-core";
const stream = createRunner({
runner: "claude", // or "pi", "gemini", "codex", "opencode"
model: "claude-sonnet-4-20250514",
userInput: "Build a REST API",
cwd: "/workspace",
env: process.env as Record<string, string>,
});
// Collect all chunks into a full response
const chunks: string[] = [];
for await (const chunk of stream) {
chunks.push(chunk);
}
const fullResponse = chunks.join("");
// Or parse each NDJSON line as it arrives
for await (const chunk of stream) {
for (const line of chunk.split("\n").filter(Boolean)) {
const msg = JSON.parse(line);
console.log(msg);
}
}runner-core is the shared core used by both runner-cli and sandagent-daemon. Use it directly when you don't need the HTTP gateway.
API Reference
All JSON responses: { "ok": true, "data": {}, "error": null }
Agent /api/coding/*
POST /api/coding/run
Run an agent and stream the output as SSE (AI SDK UI NDJSON format).
Request body:
| Field | Type | Default | Description |
|-------|------|---------|-------------|
| userInput | string | required | The task / prompt |
| runner | string | "claude" | claude · codex · gemini · pi · opencode |
| model | string | "claude-sonnet-4-20250514" | Model name for the runner |
| cwd | string | SANDAGENT_ROOT | Working directory inside the sandbox |
| systemPrompt | string | — | Override system prompt |
| maxTurns | number | — | Max agent turns |
| allowedTools | string[] | — | Restrict which tools the agent can use |
| resume | string | — | Session ID to resume |
| skillPaths | string[] | — | Extra skill paths (pi runner) |
Example:
# Stream with curl (-N disables buffering)
curl -N -X POST http://localhost:3080/api/coding/run \
-H 'Content-Type: application/json' \
-d '{
"runner": "claude",
"userInput": "List all TypeScript files and summarize what each does",
"cwd": "/workspace/myproject"
}'
# Use pi runner with a different model
curl -N -X POST http://localhost:3080/api/coding/run \
-H 'Content-Type: application/json' \
-d '{
"runner": "pi",
"model": "gemini-2.0-flash",
"userInput": "Refactor this codebase to use async/await"
}'Response: application/x-ndjson chunked stream — each line is an AI SDK UI message, compatible with Vercel AI SDK useChat / streamText.
Filesystem /api/fs/*
| Method | Path | Params |
|--------|------|--------|
| GET | /api/fs/list | ?path=src&volume=vol-001 |
| GET | /api/fs/read | ?path=file.txt |
| GET | /api/fs/stat | ?path=file.txt |
| GET | /api/fs/exists | ?path=file.txt |
| GET | /api/fs/find | ?pattern=todo&limit=100 |
| POST | /api/fs/write | {"path":"a.txt","content":"hello"} |
| POST | /api/fs/append | {"path":"log.txt","content":"line\n"} |
| POST | /api/fs/mkdir | {"path":"a/b/c"} |
| POST | /api/fs/remove | {"path":"tmp","recursive":true} |
| POST | /api/fs/move | {"from":"a.txt","to":"b.txt"} |
| POST | /api/fs/copy | {"from":"a.txt","to":"b.txt"} |
| POST | /api/fs/upload | multipart/form-data — see below |
POST /api/fs/upload
Upload one or more files via multipart/form-data.
Form fields:
| Field | Type | Required | Description |
|-------|------|----------|-------------|
| path | string | No | Target directory (default: .) |
| volume | string | No | Volume name for multi-tenant isolation |
| create_dirs | string | No | Create parent dirs (default: "true") |
| file | file | Yes | One or more files to upload |
Example:
# Upload a single file
curl -X POST http://localhost:3080/api/fs/upload \
-F "path=uploads" \
-F "[email protected]"
# Upload multiple files
curl -X POST http://localhost:3080/api/fs/upload \
-F "path=data" \
-F "[email protected]" \
-F "[email protected]"
# Upload to a specific volume
curl -X POST http://localhost:3080/api/fs/upload \
-F "path=docs" \
-F "volume=vol-001" \
-F "[email protected]"Response:
{
"ok": true,
"data": {
"files": [
{ "fieldname": "file", "filename": "report.csv", "path": "/workspace/data/report.csv", "size": 1234 }
]
},
"error": null
}All fs endpoints accept optional volume for multi-tenant isolation.
Git /api/git/*
| Method | Path | Body |
|--------|------|------|
| POST | /api/git/status | {"repo":"myrepo"} |
| POST | /api/git/exec | {"repo":"myrepo","args":["log","--oneline"]} |
| POST | /api/git/clone | {"repo_parent":".","url":"https://...","depth":1} |
| POST | /api/git/init | {"repo":"myrepo","initial_branch":"main"} |
Volumes /api/volumes/*
| Method | Path | Body |
|--------|------|------|
| GET | /api/volumes/list | |
| POST | /api/volumes/ensure | {"volume":"vol-001"} |
| POST | /api/volumes/remove | {"volume":"vol-001"} |
Health
GET /healthzEnvironment Variables
| Variable | Default | Description |
|----------|---------|-------------|
| SANDAGENT_DAEMON_PORT | 3080 | Listen port |
| SANDAGENT_ROOT | ./.sandagent-daemon (under cwd) | Daemon filesystem root; Docker images set e.g. /workspace |
| ANTHROPIC_API_KEY | — | For claude runner |
| GEMINI_API_KEY | — | For gemini / pi runner |
| OPENAI_API_KEY | — | For codex runner |
Development
cd apps/sandagent-daemon
pnpm install && pnpm build
SANDAGENT_ROOT=/tmp/test sandagent-daemon
curl http://localhost:3080/healthz
pnpm test # 13 integration tests