From b4c8564cb29402e262258dac8abbf6d93fb3ba04 Mon Sep 17 00:00:00 2001 From: Nathan Flurry Date: Tue, 10 Feb 2026 16:05:56 -0800 Subject: [PATCH] feat: acp http adapter --- .claude/commands/post-release-testing.md | 2 +- .github/workflows/ci.yaml | 8 +- .github/workflows/release.yaml | 4 +- CLAUDE.md | 94 +- Cargo.toml | 3 + docs/advanced/acp-http-client.mdx | 67 - docs/agent-sessions.mdx | 306 +- docs/architecture.mdx | 64 + docs/attachments.mdx | 74 +- docs/building-chat-ui.mdx | 370 -- docs/cors.mdx | 4 - docs/credentials.mdx | 116 +- docs/custom-tools.mdx | 312 +- docs/daemon.mdx | 71 +- docs/deploy/cloudflare.mdx | 191 +- docs/deploy/daytona.mdx | 46 +- docs/deploy/docker.mdx | 34 +- docs/deploy/e2b.mdx | 57 +- docs/deploy/local.mdx | 29 +- docs/deploy/vercel.mdx | 57 +- docs/docs.json | 23 +- docs/file-system.mdx | 159 +- docs/manage-sessions.mdx | 265 - docs/mcp-config.mdx | 149 +- docs/multiplayer.mdx | 115 + docs/observability.mdx | 64 + docs/openapi.json | 1373 ++++-- docs/opencode-compatibility.mdx | 129 +- docs/quickstart.mdx | 262 +- docs/sdk-overview.mdx | 174 + docs/sdks/python.mdx | 41 - docs/sdks/typescript.mdx | 267 - docs/security.mdx | 191 + docs/session-persistence.mdx | 183 + docs/session-restoration.mdx | 33 + docs/session-transcript-schema.mdx | 388 -- docs/skills-config.mdx | 109 +- examples/CLAUDE.md | 14 +- examples/cloudflare/Dockerfile | 2 +- examples/daytona/src/daytona-with-snapshot.ts | 8 +- examples/daytona/src/index.ts | 8 +- examples/docker/src/index.ts | 8 +- examples/e2b/src/index.ts | 8 +- examples/file-system/src/index.ts | 6 +- examples/mcp-custom-tool/src/index.ts | 19 +- examples/mcp/src/index.ts | 20 +- examples/mock-acp-agent/README.md | 9 + examples/mock-acp-agent/package.json | 24 + examples/mock-acp-agent/src/index.ts | 100 + examples/mock-acp-agent/tsconfig.build.json | 11 + examples/mock-acp-agent/tsconfig.json | 17 + examples/shared/Dockerfile.dev | 6 +- examples/skills-custom-tool/src/index.ts | 20 +- examples/skills/src/index.ts | 21 +- examples/vercel/src/index.ts | 8 +- frontend/CLAUDE.md | 2 +- frontend/packages/inspector/index.html | 207 +- frontend/packages/inspector/package.json | 10 +- .../src/components/ConnectScreen.tsx | 31 +- .../src/components/SessionCreateMenu.tsx | 741 +-- .../src/components/chat/ChatMessages.tsx | 91 +- .../src/components/chat/ChatPanel.tsx | 144 +- .../src/components/chat/messageUtils.ts | 13 +- .../src/components/chat/renderContentPart.tsx | 93 - .../inspector/src/components/chat/types.ts | 23 +- .../src/components/debug/AgentsTab.tsx | 10 +- .../src/components/debug/ApprovalsTab.tsx | 105 - .../src/components/debug/DebugPanel.tsx | 38 +- .../src/components/debug/EventsTab.tsx | 153 +- .../inspector/src/components/debug/McpTab.tsx | 210 + .../src/components/debug/RequestLogTab.tsx | 10 +- .../src/components/debug/SkillsTab.tsx | 263 + .../src/components/debug/eventUtils.ts | 110 - .../inspector/src/lib/legacyClient.ts | 790 --- .../packages/inspector/src/types/legacyApi.ts | 145 - frontend/packages/inspector/vite.config.ts | 2 +- .../packages/website/public/logos/daytona.svg | 4 +- .../packages/website/public/logos/e2b.svg | 4 +- .../packages/website/public/logos/openai.svg | 2 +- .../website/public/rivet-logo-text-white.svg | 4 +- .../packages/website/src/components/FAQ.tsx | 2 +- .../website/src/components/GetStarted.tsx | 4 +- .../packages/website/src/components/Hero.tsx | 2 +- research/acp/00-delete-first.md | 6 +- research/acp/README.md | 8 +- research/acp/acp-notes.md | 2 +- research/acp/acp-over-http-findings.md | 16 +- research/acp/extensibility-status.md | 4 +- research/acp/friction.md | 48 +- research/acp/inspector-unimplemented.md | 2 +- research/acp/merge-acp.md | 114 +- research/acp/migration-steps.md | 40 +- .../acp/missing-features-spec/01-questions.md | 4 +- .../04-filesystem-api.md | 30 +- .../05-health-endpoint.md | 18 +- .../missing-features-spec/06-server-status.md | 14 +- .../07-session-termination.md | 8 +- .../08-model-variants.md | 6 +- .../missing-features-spec/10-include-raw.md | 6 +- .../missing-features-spec/12-agent-listing.md | 24 +- .../13-models-modes-listing.md | 10 +- .../14-message-attachments.md | 6 +- .../15-session-creation-richness.md | 6 +- .../missing-features-spec/16-session-info.md | 18 +- .../17-error-termination-metadata.md | 8 +- .../missing-features-spec/feature-index.md | 12 +- research/acp/missing-features-spec/plan.md | 18 +- research/acp/old-rest-openapi-list.md | 2 +- research/acp/rfds-vs-extensions.md | 8 +- research/acp/simplify-server.md | 242 + research/acp/spec.md | 102 +- research/acp/todo.md | 43 +- research/acp/ts-client.md | 20 +- research/acp/v1-schema-to-acp-mapping.md | 46 +- research/agents/claude.md | 2 +- research/agents/codex.md | 2 +- research/agents/opencode.md | 12 +- .../snapshots/native/metadata-providers.json | 2 +- .../specs/opencode-adapter-package-plan.md | 200 + resources/vercel-ai-sdk-schemas/.tmp/log.txt | 50 - resources/vercel-ai-sdk-schemas/README.md | 1 - .../artifacts/json-schema/ui-message.json | 1106 ----- resources/vercel-ai-sdk-schemas/package.json | 22 - resources/vercel-ai-sdk-schemas/src/cache.ts | 93 - resources/vercel-ai-sdk-schemas/src/index.ts | 398 -- resources/vercel-ai-sdk-schemas/tsconfig.json | 11 - scripts/agent-configs/dump.ts | 481 ++ scripts/agent-configs/package.json | 19 + scripts/agent-configs/resources/claude.json | 21 + scripts/agent-configs/resources/codex.json | 25 + scripts/agent-configs/resources/cursor.json | 137 + scripts/agent-configs/resources/opencode.json | 254 + scripts/agent-configs/tsconfig.json | 8 + scripts/release/docker.ts | 1 + scripts/release/main.ts | 7 + scripts/release/promote-artifacts.ts | 19 + scripts/release/sdk.ts | 59 +- scripts/sandbox-testing/test-sandbox.ts | 2 +- sdks/acp-http-client/src/index.ts | 214 +- sdks/acp-http-client/tests/smoke.test.ts | 34 +- sdks/persist-indexeddb/package.json | 38 + sdks/persist-indexeddb/src/index.ts | 327 ++ sdks/persist-indexeddb/tests/driver.test.ts | 96 + .../tests/integration.test.ts | 134 + sdks/persist-indexeddb/tsconfig.json | 16 + sdks/persist-indexeddb/tsup.config.ts | 10 + sdks/persist-indexeddb/vitest.config.ts | 9 + sdks/persist-postgres/package.json | 39 + sdks/persist-postgres/src/index.ts | 322 ++ .../tests/integration.test.ts | 250 + sdks/persist-postgres/tsconfig.json | 16 + sdks/persist-postgres/tsup.config.ts | 10 + sdks/persist-postgres/vitest.config.ts | 9 + sdks/persist-rivet/package.json | 45 + sdks/persist-rivet/src/index.ts | 180 + sdks/persist-rivet/tests/driver.test.ts | 236 + sdks/persist-rivet/tsconfig.json | 16 + sdks/persist-rivet/tsup.config.ts | 10 + sdks/persist-sqlite/package.json | 37 + sdks/persist-sqlite/src/index.ts | 306 ++ sdks/persist-sqlite/tests/integration.test.ts | 136 + sdks/persist-sqlite/tsconfig.json | 16 + sdks/persist-sqlite/tsup.config.ts | 10 + sdks/persist-sqlite/vitest.config.ts | 8 + sdks/typescript/package.json | 6 +- .../scripts/patch-openapi-types.mjs | 17 + sdks/typescript/src/client.ts | 1552 +++--- sdks/typescript/src/generated/openapi.ts | 682 ++- sdks/typescript/src/index.ts | 60 +- sdks/typescript/src/spawn.ts | 2 +- sdks/typescript/src/types.ts | 459 +- sdks/typescript/tests/helpers/mock-agent.ts | 140 + sdks/typescript/tests/integration.test.ts | 385 +- server/ARCHITECTURE.md | 2 +- server/CLAUDE.md | 20 +- server/packages/acp-http-adapter/Cargo.toml | 24 + server/packages/acp-http-adapter/README.md | 47 + server/packages/acp-http-adapter/src/app.rs | 132 + server/packages/acp-http-adapter/src/lib.rs | 50 + server/packages/acp-http-adapter/src/main.rs | 55 + .../packages/acp-http-adapter/src/process.rs | 571 +++ .../packages/acp-http-adapter/src/registry.rs | 143 + server/packages/acp-http-adapter/tests/e2e.rs | 338 ++ server/packages/opencode-adapter/Cargo.toml | 20 + .../opencode-adapter/migrations/0001_init.sql | 26 + server/packages/opencode-adapter/src/lib.rs | 4351 +++++++++++++++++ .../opencode-server-manager/Cargo.toml | 15 + .../opencode-server-manager/src/lib.rs | 310 ++ server/packages/sandbox-agent/Cargo.toml | 3 + .../sandbox-agent/src/acp_proxy_runtime.rs | 514 ++ .../sandbox-agent/src/acp_runtime/backend.rs | 330 -- .../sandbox-agent/src/acp_runtime/ext_meta.rs | 123 - .../src/acp_runtime/ext_methods.rs | 1386 ------ .../sandbox-agent/src/acp_runtime/helpers.rs | 573 --- .../sandbox-agent/src/acp_runtime/mock.rs | 425 -- .../sandbox-agent/src/acp_runtime/mod.rs | 1763 ------- server/packages/sandbox-agent/src/cli.rs | 227 +- server/packages/sandbox-agent/src/daemon.rs | 4 +- .../src/opencode_session_manager.rs | 1090 ----- .../sandbox-agent/src/router/support.rs | 599 ++- .../sandbox-agent/src/router/types.rs | 320 +- .../tests/opencode-compat/helpers/spawn.ts | 23 +- .../tests/opencode-compat/models.test.ts | 8 +- .../tests/opencode-compat/permissions.test.ts | 2 +- .../tests/opencode-compat/questions.test.ts | 2 +- .../tests/opencode-compat/real-agent.test.ts | 244 + .../tests/opencode-compat/session.test.ts | 138 + .../sandbox-agent/tests/opencode_openapi.rs | 100 +- .../tests/v1_agent_process_matrix.rs | 237 + .../tests/{v2_api.rs => v1_api.rs} | 101 +- .../tests/v1_api/acp_transport.rs | 307 ++ .../tests/v1_api/config_endpoints.rs | 152 + .../tests/v1_api/control_plane.rs | 218 + .../tests/v2_agent_process_matrix.rs | 628 --- .../tests/v2_api/acp_extensions.rs | 569 --- .../tests/v2_api/acp_transport.rs | 623 --- .../tests/v2_api/control_plane.rs | 366 -- 217 files changed, 18785 insertions(+), 17400 deletions(-) delete mode 100644 docs/advanced/acp-http-client.mdx create mode 100644 docs/architecture.mdx delete mode 100644 docs/building-chat-ui.mdx delete mode 100644 docs/manage-sessions.mdx create mode 100644 docs/multiplayer.mdx create mode 100644 docs/observability.mdx create mode 100644 docs/sdk-overview.mdx delete mode 100644 docs/sdks/python.mdx delete mode 100644 docs/sdks/typescript.mdx create mode 100644 docs/security.mdx create mode 100644 docs/session-persistence.mdx create mode 100644 docs/session-restoration.mdx delete mode 100644 docs/session-transcript-schema.mdx create mode 100644 examples/mock-acp-agent/README.md create mode 100644 examples/mock-acp-agent/package.json create mode 100644 examples/mock-acp-agent/src/index.ts create mode 100644 examples/mock-acp-agent/tsconfig.build.json create mode 100644 examples/mock-acp-agent/tsconfig.json delete mode 100644 frontend/packages/inspector/src/components/chat/renderContentPart.tsx delete mode 100644 frontend/packages/inspector/src/components/debug/ApprovalsTab.tsx create mode 100644 frontend/packages/inspector/src/components/debug/McpTab.tsx create mode 100644 frontend/packages/inspector/src/components/debug/SkillsTab.tsx delete mode 100644 frontend/packages/inspector/src/components/debug/eventUtils.ts delete mode 100644 frontend/packages/inspector/src/lib/legacyClient.ts delete mode 100644 frontend/packages/inspector/src/types/legacyApi.ts create mode 100644 research/acp/simplify-server.md create mode 100644 research/specs/opencode-adapter-package-plan.md delete mode 100644 resources/vercel-ai-sdk-schemas/.tmp/log.txt delete mode 100644 resources/vercel-ai-sdk-schemas/README.md delete mode 100644 resources/vercel-ai-sdk-schemas/artifacts/json-schema/ui-message.json delete mode 100644 resources/vercel-ai-sdk-schemas/package.json delete mode 100644 resources/vercel-ai-sdk-schemas/src/cache.ts delete mode 100644 resources/vercel-ai-sdk-schemas/src/index.ts delete mode 100644 resources/vercel-ai-sdk-schemas/tsconfig.json create mode 100644 scripts/agent-configs/dump.ts create mode 100644 scripts/agent-configs/package.json create mode 100644 scripts/agent-configs/resources/claude.json create mode 100644 scripts/agent-configs/resources/codex.json create mode 100644 scripts/agent-configs/resources/cursor.json create mode 100644 scripts/agent-configs/resources/opencode.json create mode 100644 scripts/agent-configs/tsconfig.json create mode 100644 sdks/persist-indexeddb/package.json create mode 100644 sdks/persist-indexeddb/src/index.ts create mode 100644 sdks/persist-indexeddb/tests/driver.test.ts create mode 100644 sdks/persist-indexeddb/tests/integration.test.ts create mode 100644 sdks/persist-indexeddb/tsconfig.json create mode 100644 sdks/persist-indexeddb/tsup.config.ts create mode 100644 sdks/persist-indexeddb/vitest.config.ts create mode 100644 sdks/persist-postgres/package.json create mode 100644 sdks/persist-postgres/src/index.ts create mode 100644 sdks/persist-postgres/tests/integration.test.ts create mode 100644 sdks/persist-postgres/tsconfig.json create mode 100644 sdks/persist-postgres/tsup.config.ts create mode 100644 sdks/persist-postgres/vitest.config.ts create mode 100644 sdks/persist-rivet/package.json create mode 100644 sdks/persist-rivet/src/index.ts create mode 100644 sdks/persist-rivet/tests/driver.test.ts create mode 100644 sdks/persist-rivet/tsconfig.json create mode 100644 sdks/persist-rivet/tsup.config.ts create mode 100644 sdks/persist-sqlite/package.json create mode 100644 sdks/persist-sqlite/src/index.ts create mode 100644 sdks/persist-sqlite/tests/integration.test.ts create mode 100644 sdks/persist-sqlite/tsconfig.json create mode 100644 sdks/persist-sqlite/tsup.config.ts create mode 100644 sdks/persist-sqlite/vitest.config.ts create mode 100644 sdks/typescript/scripts/patch-openapi-types.mjs create mode 100644 sdks/typescript/tests/helpers/mock-agent.ts create mode 100644 server/packages/acp-http-adapter/Cargo.toml create mode 100644 server/packages/acp-http-adapter/README.md create mode 100644 server/packages/acp-http-adapter/src/app.rs create mode 100644 server/packages/acp-http-adapter/src/lib.rs create mode 100644 server/packages/acp-http-adapter/src/main.rs create mode 100644 server/packages/acp-http-adapter/src/process.rs create mode 100644 server/packages/acp-http-adapter/src/registry.rs create mode 100644 server/packages/acp-http-adapter/tests/e2e.rs create mode 100644 server/packages/opencode-adapter/Cargo.toml create mode 100644 server/packages/opencode-adapter/migrations/0001_init.sql create mode 100644 server/packages/opencode-adapter/src/lib.rs create mode 100644 server/packages/opencode-server-manager/Cargo.toml create mode 100644 server/packages/opencode-server-manager/src/lib.rs create mode 100644 server/packages/sandbox-agent/src/acp_proxy_runtime.rs delete mode 100644 server/packages/sandbox-agent/src/acp_runtime/backend.rs delete mode 100644 server/packages/sandbox-agent/src/acp_runtime/ext_meta.rs delete mode 100644 server/packages/sandbox-agent/src/acp_runtime/ext_methods.rs delete mode 100644 server/packages/sandbox-agent/src/acp_runtime/helpers.rs delete mode 100644 server/packages/sandbox-agent/src/acp_runtime/mock.rs delete mode 100644 server/packages/sandbox-agent/src/acp_runtime/mod.rs delete mode 100644 server/packages/sandbox-agent/src/opencode_session_manager.rs create mode 100644 server/packages/sandbox-agent/tests/opencode-compat/real-agent.test.ts create mode 100644 server/packages/sandbox-agent/tests/v1_agent_process_matrix.rs rename server/packages/sandbox-agent/tests/{v2_api.rs => v1_api.rs} (74%) create mode 100644 server/packages/sandbox-agent/tests/v1_api/acp_transport.rs create mode 100644 server/packages/sandbox-agent/tests/v1_api/config_endpoints.rs create mode 100644 server/packages/sandbox-agent/tests/v1_api/control_plane.rs delete mode 100644 server/packages/sandbox-agent/tests/v2_agent_process_matrix.rs delete mode 100644 server/packages/sandbox-agent/tests/v2_api/acp_extensions.rs delete mode 100644 server/packages/sandbox-agent/tests/v2_api/acp_transport.rs delete mode 100644 server/packages/sandbox-agent/tests/v2_api/control_plane.rs diff --git a/.claude/commands/post-release-testing.md b/.claude/commands/post-release-testing.md index 3622dad..771a1d8 100644 --- a/.claude/commands/post-release-testing.md +++ b/.claude/commands/post-release-testing.md @@ -43,7 +43,7 @@ Manually verify the install script works in a fresh environment: ```bash docker run --rm alpine:latest sh -c " apk add --no-cache curl ca-certificates libstdc++ libgcc bash && - curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh && + curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh && sandbox-agent --version " ``` diff --git a/.github/workflows/ci.yaml b/.github/workflows/ci.yaml index f574def..e009cad 100644 --- a/.github/workflows/ci.yaml +++ b/.github/workflows/ci.yaml @@ -14,7 +14,7 @@ jobs: - uses: dtolnay/rust-toolchain@stable with: components: rustfmt, clippy - - uses: Swatinem/rust-cache@v2 + - uses: Swatinem/rust-cache@main - uses: pnpm/action-setup@v4 - uses: actions/setup-node@v4 with: @@ -23,11 +23,11 @@ jobs: - run: pnpm install - name: Run checks run: ./scripts/release/main.ts --version 0.0.0 --check - - name: Run ACP v2 server tests + - name: Run ACP v1 server tests run: | cargo test -p sandbox-agent-agent-management - cargo test -p sandbox-agent --test v2_api - cargo test -p sandbox-agent --test v2_agent_process_matrix + cargo test -p sandbox-agent --test v1_api + cargo test -p sandbox-agent --test v1_agent_process_matrix cargo test -p sandbox-agent --lib - name: Run SDK tests run: pnpm --dir sdks/typescript test diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index ce5b8a7..102f612 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -147,8 +147,8 @@ jobs: sudo apt-get install -y unzip curl # Install AWS CLI - curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" - unzip awscliv2.zip + curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscli.zip" + unzip awscli.zip sudo ./aws/install --update COMMIT_SHA_SHORT="${GITHUB_SHA::7}" diff --git a/CLAUDE.md b/CLAUDE.md index 5f14192..2934e3a 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,24 +1,24 @@ # Instructions -## ACP v2 Baseline +## ACP v1 Baseline -- v2 is ACP-native. +- v1 is ACP-native. - `/v1/*` is removed and returns `410 Gone` (`application/problem+json`). - `/opencode/*` is disabled during ACP core phases and returns `503`. -- Prompt/session traffic is ACP JSON-RPC over streamable HTTP on `/v2/rpc`: - - `POST /v2/rpc` - - `GET /v2/rpc` (SSE) - - `DELETE /v2/rpc` +- Prompt/session traffic is ACP JSON-RPC over streamable HTTP on `/v1/rpc`: + - `POST /v1/rpc` + - `GET /v1/rpc` (SSE) + - `DELETE /v1/rpc` - Control-plane endpoints: - - `GET /v2/health` - - `GET /v2/agents` - - `POST /v2/agents/{agent}/install` + - `GET /v1/health` + - `GET /v1/agents` + - `POST /v1/agents/{agent}/install` - Binary filesystem transfer endpoints (intentionally HTTP, not ACP extension methods): - - `GET /v2/fs/file` - - `PUT /v2/fs/file` - - `POST /v2/fs/upload-batch` + - `GET /v1/fs/file` + - `PUT /v1/fs/file` + - `POST /v1/fs/upload-batch` - Sandbox Agent ACP extension method naming: - - Custom ACP methods use `_sandboxagent/...` (not `_sandboxagent/v2/...`). + - Custom ACP methods use `_sandboxagent/...` (not `_sandboxagent/v1/...`). - Session detach method is `_sandboxagent/session/detach`. ## API Scope @@ -27,7 +27,7 @@ - ACP extensions may be used for gaps (for example `skills`, `models`, and related metadata), but the default is that agent-facing behavior is implemented by the agent through ACP. - Custom HTTP APIs are for non-agent/session platform services (for example filesystem, terminals, and other host/runtime capabilities). - Filesystem and terminal APIs remain Sandbox Agent-specific HTTP contracts and are not ACP. -- Keep `GET /v2/fs/file`, `PUT /v2/fs/file`, and `POST /v2/fs/upload-batch` on HTTP: +- Keep `GET /v1/fs/file`, `PUT /v1/fs/file`, and `POST /v1/fs/upload-batch` on HTTP: - These are Sandbox Agent host/runtime operations with cross-agent-consistent behavior. - They may involve very large binary transfers that ACP JSON-RPC envelopes are not suited to stream. - This is intentionally separate from ACP native `fs/read_text_file` and `fs/write_text_file`. @@ -51,14 +51,24 @@ ## TypeScript SDK Architecture - TypeScript clients are split into: - - `acp-http-client`: protocol-pure ACP-over-HTTP (`/v2/rpc`) with no Sandbox-specific metadata/extensions. - - `sandbox-agent`: `SandboxAgentClient` wrapper that adds Sandbox metadata/extension helpers and keeps non-ACP HTTP helpers. -- `SandboxAgentClient` constructor is `new SandboxAgentClient(...)`. -- `SandboxAgentClient` auto-connects by default; `autoConnect: false` requires explicit `.connect()`. -- ACP/session methods must throw when disconnected (`NotConnectedError`), and `.connect()` must throw when already connected (`AlreadyConnectedError`). -- A `SandboxAgentClient` instance may have at most one active ACP connection at a time. -- Stable ACP session method names should stay ACP-aligned in the Sandbox wrapper (`newSession`, `loadSession`, `prompt`, `cancel`, `setSessionMode`, `setSessionConfigOption`). -- Sandbox extension methods are first-class wrapper helpers (`listModels`, `setMetadata`, `detachSession`, `terminateSession`). + - `acp-http-client`: protocol-pure ACP-over-HTTP (`/v1/acp`) with no Sandbox-specific HTTP helpers. + - `sandbox-agent`: `SandboxAgent` SDK wrapper that combines ACP session operations with Sandbox control-plane and filesystem helpers. +- `SandboxAgent` entry points are `SandboxAgent.connect(...)` and `SandboxAgent.start(...)`. +- Stable Sandbox session methods are `createSession`, `resumeSession`, `resumeOrCreateSession`, `destroySession`, `sendSessionMethod`, and `onSessionEvent`. +- `Session` helpers are `prompt(...)`, `send(...)`, and `onEvent(...)`. +- Cleanup is `sdk.dispose()`. + +### Docs Source Of Truth + +- For TypeScript docs/examples, source of truth is implementation in: + - `sdks/typescript/src/client.ts` + - `sdks/typescript/src/index.ts` + - `sdks/acp-http-client/src/index.ts` +- Do not document TypeScript APIs unless they are exported and implemented in those files. +- For HTTP/CLI docs/examples, source of truth is: + - `server/packages/sandbox-agent/src/router.rs` + - `server/packages/sandbox-agent/src/cli.rs` +- Keep docs aligned to implemented endpoints/commands only (for example ACP under `/v1/acp`, not legacy `/v1/sessions` APIs). ## Source Documents @@ -76,5 +86,43 @@ - Regenerate `docs/openapi.json` when HTTP contracts change. - Keep `docs/inspector.mdx` and `docs/sdks/typescript.mdx` aligned with implementation. - Append blockers/decisions to `research/acp/friction.md` during ACP work. -- TypeScript SDK tests should run against a real running server/runtime over real `/v2` HTTP APIs, typically using the real `mock` agent for deterministic behavior. +- TypeScript SDK tests should run against a real running server/runtime over real `/v1` HTTP APIs, typically using the real `mock` agent for deterministic behavior. - Do not use Vitest fetch/transport mocks to simulate server functionality in TypeScript SDK tests. + +## Docker Examples (Dev Testing) + +- When manually testing bleeding-edge (unreleased) versions of sandbox-agent in `examples/`, use `SANDBOX_AGENT_DEV=1` with the Docker-based examples. +- This triggers `examples/shared/Dockerfile.dev` which builds the server binary from local source and packages it into the Docker image. +- Example: `SANDBOX_AGENT_DEV=1 pnpm --filter @sandbox-agent/example-mcp start` + +## Install Version References + +- Channel policy: + - Sandbox Agent install/version references use a pinned minor channel `0.N.x` (for curl URLs and `sandbox-agent` / `@sandbox-agent/cli` npm/bun installs). + - Gigacode install/version references use `latest` (for `@sandbox-agent/gigacode` install/run commands and `gigacode-install.*` release promotion). + - Release promotion policy: `latest` releases must still update `latest`; when a release is `latest`, Sandbox Agent must also be promoted to the matching minor channel `0.N.x`. +- Keep every install-version reference below in sync whenever versions/channels change: + - `README.md` + - `docs/acp-http-client.mdx` + - `docs/cli.mdx` + - `docs/quickstart.mdx` + - `docs/sdk-overview.mdx` + - `docs/session-persistence.mdx` + - `docs/deploy/local.mdx` + - `docs/deploy/cloudflare.mdx` + - `docs/deploy/vercel.mdx` + - `docs/deploy/daytona.mdx` + - `docs/deploy/e2b.mdx` + - `docs/deploy/docker.mdx` + - `frontend/packages/website/src/components/GetStarted.tsx` + - `.claude/commands/post-release-testing.md` + - `examples/cloudflare/Dockerfile` + - `examples/daytona/src/index.ts` + - `examples/daytona/src/daytona-with-snapshot.ts` + - `examples/docker/src/index.ts` + - `examples/e2b/src/index.ts` + - `examples/vercel/src/index.ts` + - `scripts/release/main.ts` + - `scripts/release/promote-artifacts.ts` + - `scripts/release/sdk.ts` + - `scripts/sandbox-testing/test-sandbox.ts` diff --git a/Cargo.toml b/Cargo.toml index 6f71204..14b9903 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -16,6 +16,9 @@ sandbox-agent = { version = "0.2.0", path = "server/packages/sandbox-agent" } sandbox-agent-error = { version = "0.2.0", path = "server/packages/error" } sandbox-agent-agent-management = { version = "0.2.0", path = "server/packages/agent-management" } sandbox-agent-agent-credentials = { version = "0.2.0", path = "server/packages/agent-credentials" } +sandbox-agent-opencode-adapter = { version = "0.2.0", path = "server/packages/opencode-adapter" } +sandbox-agent-opencode-server-manager = { version = "0.2.0", path = "server/packages/opencode-server-manager" } +acp-http-adapter = { version = "0.2.0", path = "server/packages/acp-http-adapter" } # Serialization serde = { version = "1.0", features = ["derive"] } diff --git a/docs/advanced/acp-http-client.mdx b/docs/advanced/acp-http-client.mdx deleted file mode 100644 index 4d93363..0000000 --- a/docs/advanced/acp-http-client.mdx +++ /dev/null @@ -1,67 +0,0 @@ ---- -title: "ACP HTTP Client" -description: "Protocol-pure ACP JSON-RPC over streamable HTTP client." ---- - -`acp-http-client` is a standalone, low-level package for ACP over HTTP (`/v2/rpc`). - -Use it when you want strict ACP protocol behavior with no Sandbox-specific metadata or extension adaptation. - -## Install - -```bash -npm install acp-http-client -``` - -## Usage - -```ts -import { AcpHttpClient } from "acp-http-client"; - -const client = new AcpHttpClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, -}); - -await client.initialize({ - _meta: { - "sandboxagent.dev": { - agent: "mock", - }, - }, -}); - -const session = await client.newSession({ - cwd: "/", - mcpServers: [], - _meta: { - "sandboxagent.dev": { - agent: "mock", - }, - }, -}); - -const result = await client.prompt({ - sessionId: session.sessionId, - prompt: [{ type: "text", text: "hello" }], -}); - -console.log(result.stopReason); -await client.disconnect(); -``` - -## Scope - -- Implements ACP HTTP transport and connection lifecycle. -- Supports ACP requests/notifications and session streaming. -- Does not inject `_meta["sandboxagent.dev"]`. -- Does not wrap `_sandboxagent/*` extension methods/events. - -## Transport Contract - -- `POST /v2/rpc` is JSON-only. Send `Content-Type: application/json` and `Accept: application/json`. -- `GET /v2/rpc` is SSE-only. Send `Accept: text/event-stream`. -- Keep one active SSE stream per ACP connection id. -- `x-acp-agent` is removed. Provide agent via `_meta["sandboxagent.dev"].agent` on `initialize` and `session/new`. - -If you want Sandbox Agent metadata/extensions and higher-level helpers, use `sandbox-agent` and `SandboxAgentClient` instead. diff --git a/docs/agent-sessions.mdx b/docs/agent-sessions.mdx index 754bf70..ac29b9f 100644 --- a/docs/agent-sessions.mdx +++ b/docs/agent-sessions.mdx @@ -1,288 +1,90 @@ --- title: "Agent Sessions" -description: "Create sessions and send messages to agents." +description: "Create sessions, prompt agents, and inspect event history." sidebarTitle: "Sessions" icon: "comments" --- -Sessions are the unit of interaction with an agent. You create one session per task, then send messages and stream events. +Sessions are the unit of interaction with an agent. Create one session per task, send prompts, and consume event history. -## Session Options +For SDK-based flows, sessions can be restored after runtime/session loss when persistence is enabled. +See [Session Restoration](/session-restoration). -`POST /v1/sessions/{sessionId}` accepts the following fields: +## Create a session -- `agent` (required): `claude`, `codex`, `opencode`, `amp`, or `mock` -- `agentMode`: agent mode string (for example, `build`, `plan`) -- `permissionMode`: permission mode string (`default`, `plan`, `bypass`, etc.) -- `model`: model override (agent-specific) -- `variant`: model variant (agent-specific) -- `agentVersion`: agent version override -- `mcp`: MCP server config map (see `MCP`) -- `skills`: skill path config (see `Skills`) +```ts +import { SandboxAgent } from "sandbox-agent"; -## Create A Session - - -```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; - -const client = new SandboxAgentClient({ +const sdk = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); +}); -await client.createSession("build-session", { +const session = await sdk.createSession({ agent: "codex", - agentMode: "build", - permissionMode: "default", - model: "gpt-4.1", - variant: "reasoning", - agentVersion: "latest", -}); -``` - -```bash cURL -curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "agent": "codex", - "agentMode": "build", - "permissionMode": "default", - "model": "gpt-4.1", - "variant": "reasoning", - "agentVersion": "latest" - }' -``` - - -## Send A Message - - -```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; - -const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); - -await client.postMessage("build-session", { - message: "Summarize the repository structure.", -}); -``` - -```bash cURL -curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session/messages" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"message":"Summarize the repository structure."}' -``` - - -## Stream A Turn - - -```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; - -const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); - -const response = await client.postMessageStream("build-session", { - message: "Explain the main entrypoints.", + sessionInit: { + cwd: "/", + mcpServers: [], + }, }); -const reader = response.body?.getReader(); -if (reader) { - const decoder = new TextDecoder(); - while (true) { - const { done, value } = await reader.read(); - if (done) break; - console.log(decoder.decode(value, { stream: true })); - } -} +console.log(session.id, session.agentSessionId); ``` -```bash cURL -curl -N -X POST "http://127.0.0.1:2468/v1/sessions/build-session/messages/stream" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"message":"Explain the main entrypoints."}' +## Send a prompt + +```ts +const response = await session.prompt([ + { type: "text", text: "Summarize the repository structure." }, +]); + +console.log(response.stopReason); ``` - -## Fetch Events +## Subscribe to live events - -```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; +```ts +const unsubscribe = session.onEvent((event) => { + console.log(event.eventIndex, event.sender, event.payload); +}); -const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); +await session.prompt([ + { type: "text", text: "Explain the main entrypoints." }, +]); -const events = await client.getEvents("build-session", { - offset: 0, +unsubscribe(); +``` + +## Fetch persisted event history + +```ts +const page = await sdk.getEvents({ + sessionId: session.id, limit: 50, - includeRaw: false, }); -console.log(events.events); -``` - -```bash cURL -curl -X GET "http://127.0.0.1:2468/v1/sessions/build-session/events?offset=0&limit=50" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" -``` - - -`GET /v1/sessions/{sessionId}/get-messages` is an alias for `events`. - -## Stream Events (SSE) - - -```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; - -const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); - -for await (const event of client.streamEvents("build-session", { offset: 0 })) { - console.log(event.type, event.data); +for (const event of page.items) { + console.log(event.id, event.createdAt, event.sender); } ``` -```bash cURL -curl -N -X GET "http://127.0.0.1:2468/v1/sessions/build-session/events/sse?offset=0" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" -``` - +## List and load sessions -## List Sessions +```ts +const sessions = await sdk.listSessions({ limit: 20 }); - -```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; +for (const item of sessions.items) { + console.log(item.id, item.agent, item.createdAt); +} -const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); - -const sessions = await client.listSessions(); -console.log(sessions.sessions); +if (sessions.items.length > 0) { + const loaded = await sdk.resumeSession(sessions.items[0]!.id); + await loaded.prompt([{ type: "text", text: "Continue." }]); +} ``` -```bash cURL -curl -X GET "http://127.0.0.1:2468/v1/sessions" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" -``` - +## Destroy a session -## Reply To A Question - -When the agent asks a question, reply with an array of answers. Each inner array is one multi-select response. - - -```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; - -const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); - -await client.replyQuestion("build-session", "question-1", { - answers: [["yes"]], -}); +```ts +await sdk.destroySession(session.id); ``` -```bash cURL -curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session/questions/question-1/reply" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"answers":[["yes"]]}' -``` - - -## Reject A Question - - -```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; - -const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); - -await client.rejectQuestion("build-session", "question-1"); -``` - -```bash cURL -curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session/questions/question-1/reject" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" -``` - - -## Reply To A Permission Request - -Use `once`, `always`, or `reject`. - - -```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; - -const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); - -await client.replyPermission("build-session", "permission-1", { - reply: "once", -}); -``` - -```bash cURL -curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session/permissions/permission-1/reply" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{"reply":"once"}' -``` - - -## Terminate A Session - - -```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; - -const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); - -await client.terminateSession("build-session"); -``` - -```bash cURL -curl -X POST "http://127.0.0.1:2468/v1/sessions/build-session/terminate" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" -``` - diff --git a/docs/architecture.mdx b/docs/architecture.mdx new file mode 100644 index 0000000..78585a2 --- /dev/null +++ b/docs/architecture.mdx @@ -0,0 +1,64 @@ +--- +title: "Architecture" +description: "How the client, sandbox, server, and agent fit together." +icon: "microchip" +--- + +Sandbox Agent runs as an HTTP server inside your sandbox. Your app talks to it remotely. + +## Components + +- `Your client`: your app code using the `sandbox-agent` SDK. +- `Sandbox`: isolated runtime (E2B, Daytona, Docker, etc.). +- `Sandbox Agent server`: process inside the sandbox exposing HTTP transport. +- `Agent`: Claude/Codex/OpenCode/Amp process managed by Sandbox Agent. + +```mermaid placement="top-right" + flowchart LR + CLIENT["Sandbox Agent SDK"] + SERVER["Sandbox Agent server"] + AGENT["Agent process"] + + subgraph SANDBOX["Sandbox"] + direction TB + SERVER --> AGENT + end + + CLIENT -->|HTTP| SERVER +``` + +## Suggested Topology + +Run the SDK on your backend, then call it from your frontend. + +This extra hop is recommended because it keeps auth/token logic on the backend and makes persistence simpler. + +```mermaid placement="top-right" + flowchart LR + BROWSER["Browser"] + subgraph BACKEND["Your backend"] + direction TB + SDK["Sandbox Agent SDK"] + end + subgraph SANDBOX_SIMPLE["Sandbox"] + SERVER_SIMPLE["Sandbox Agent server"] + end + + BROWSER --> BACKEND + BACKEND --> SDK --> SERVER_SIMPLE +``` + +### Backend requirements + +Your backend layer needs to handle: + +- **Long-running connections**: prompts can take minutes. +- **Session affinity**: follow-up messages must reach the same session. +- **State between requests**: session metadata and event history must persist across requests. +- **Graceful recovery**: sessions should resume after backend restarts. + +We recommend [Rivet](https://rivet.dev) over serverless because actors natively support the long-lived connections, session routing, and state persistence that agent workloads require. + +## Session persistence + +For storage driver options and replay behavior, see [Persisting Sessions](/session-persistence). diff --git a/docs/attachments.mdx b/docs/attachments.mdx index acc54fe..dcfb412 100644 --- a/docs/attachments.mdx +++ b/docs/attachments.mdx @@ -1,29 +1,27 @@ --- title: "Attachments" -description: "Upload files into the sandbox and attach them to prompts." +description: "Upload files into the sandbox and reference them in prompts." sidebarTitle: "Attachments" icon: "paperclip" --- -Use the filesystem API to upload files, then reference them as attachments when sending prompts. +Use the filesystem API to upload files, then include file references in prompt content. ```ts TypeScript - import { SandboxAgentClient } from "sandbox-agent"; + import { SandboxAgent } from "sandbox-agent"; import fs from "node:fs"; - const client = new SandboxAgentClient({ + const sdk = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); + }); const buffer = await fs.promises.readFile("./data.csv"); - const upload = await client.writeFsFile( - { path: "./uploads/data.csv", sessionId: "my-session" }, + const upload = await sdk.writeFsFile( + { path: "./uploads/data.csv" }, buffer, ); @@ -31,59 +29,33 @@ Use the filesystem API to upload files, then reference them as attachments when ``` ```bash cURL - curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=./uploads/data.csv&sessionId=my-session" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ + curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=./uploads/data.csv" \ --data-binary @./data.csv ``` - The response returns the absolute path that you should use for attachments. + The upload response returns the absolute path. - - + ```ts TypeScript - import { SandboxAgentClient } from "sandbox-agent"; + const session = await sdk.createSession({ agent: "mock" }); - const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); - - await client.postMessage("my-session", { - message: "Please analyze the attached CSV.", - attachments: [ - { - path: "/home/sandbox/uploads/data.csv", - mime: "text/csv", - filename: "data.csv", - }, - ], - }); + await session.prompt([ + { type: "text", text: "Please analyze the attached CSV." }, + { + type: "resource_link", + name: "data.csv", + uri: "file:///home/sandbox/uploads/data.csv", + mimeType: "text/csv", + }, + ]); ``` - - ```bash cURL - curl -X POST "http://127.0.0.1:2468/v1/sessions/my-session/messages" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "message": "Please analyze the attached CSV.", - "attachments": [ - { - "path": "/home/sandbox/uploads/data.csv", - "mime": "text/csv", - "filename": "data.csv" - } - ] - }' - ``` - ## Notes -- Use absolute paths from the upload response to avoid ambiguity. -- If `mime` is omitted, the server defaults to `application/octet-stream`. -- OpenCode receives file parts directly; other agents will see the attachment paths appended to the prompt. +- Use absolute file URIs in `resource_link` blocks. +- If `mimeType` is omitted, the agent/runtime may infer a default. +- Support for non-text resources depends on each agent's ACP prompt capabilities. diff --git a/docs/building-chat-ui.mdx b/docs/building-chat-ui.mdx deleted file mode 100644 index da706ff..0000000 --- a/docs/building-chat-ui.mdx +++ /dev/null @@ -1,370 +0,0 @@ ---- -title: "Building a Chat UI" -description: "Build a chat interface using the universal event stream." -icon: "comments" ---- - -## Setup - -### List agents - -```ts -const { agents } = await client.listAgents(); - -// Each agent exposes feature coverage via `capabilities` to determine what UI to show -const claude = agents.find((a) => a.id === "claude"); -if (claude?.capabilities.permissions) { - // Show permission approval UI -} -if (claude?.capabilities.questions) { - // Show question response UI -} -``` - -### Create a session - -```ts -const sessionId = `session-${crypto.randomUUID()}`; - -await client.createSession(sessionId, { - agent: "claude", - agentMode: "code", // Optional: agent-specific mode - permissionMode: "default", // Optional: "default" | "plan" | "bypass" | "acceptEdits" (Claude: accept edits; Codex: auto-approve file changes; others: default) - model: "claude-sonnet-4", // Optional: model override -}); -``` - -### Send a message - -```ts -await client.postMessage(sessionId, { message: "Hello, world!" }); -``` - -### Stream events - -Three options for receiving events: - -```ts -// Option 1: SSE (recommended for real-time UI) -const stream = client.streamEvents(sessionId, { offset: 0 }); -for await (const event of stream) { - handleEvent(event); -} - -// Option 2: Polling -const { events, hasMore } = await client.getEvents(sessionId, { offset: 0 }); -events.forEach(handleEvent); - -// Option 3: Turn streaming (send + stream in one call) -const stream = client.streamTurn(sessionId, { message: "Hello" }); -for await (const event of stream) { - handleEvent(event); -} -``` - -Use `offset` to track the last seen `sequence` number and resume from where you left off. - ---- - -## Handling Events - -### Bare minimum - -Handle item lifecycle plus turn lifecycle to render a basic chat: - -```ts -type ItemState = { - item: UniversalItem; - deltas: string[]; -}; - -const items = new Map(); -let turnInProgress = false; - -function handleEvent(event: UniversalEvent) { - switch (event.type) { - case "turn.started": { - turnInProgress = true; - break; - } - - case "turn.ended": { - turnInProgress = false; - break; - } - - case "item.started": { - const { item } = event.data as ItemEventData; - items.set(item.item_id, { item, deltas: [] }); - break; - } - - case "item.delta": { - const { item_id, delta } = event.data as ItemDeltaData; - const state = items.get(item_id); - if (state) { - state.deltas.push(delta); - } - break; - } - - case "item.completed": { - const { item } = event.data as ItemEventData; - const state = items.get(item.item_id); - if (state) { - state.item = item; - state.deltas = []; // Clear deltas, use final content - } - break; - } - } -} -``` - -When rendering: -- Use `turnInProgress` for turn-level UI state (disable send button, show global "Agent is responding", etc.). -- Use `item.status === "in_progress"` for per-item streaming state. - -```ts -function renderItem(state: ItemState) { - const { item, deltas } = state; - const isItemLoading = item.status === "in_progress"; - - // For streaming text, combine item content with accumulated deltas - const text = item.content - .filter((p) => p.type === "text") - .map((p) => p.text) - .join(""); - const streamedText = text + deltas.join(""); - - return { - content: streamedText, - isItemLoading, - isTurnLoading: turnInProgress, - role: item.role, - kind: item.kind, - }; -} -``` - -### Extra events - -Handle these for a complete implementation: - -```ts -function handleEvent(event: UniversalEvent) { - switch (event.type) { - // ... bare minimum events above ... - - case "session.started": { - // Session is ready - break; - } - - case "session.ended": { - const { reason, terminated_by } = event.data as SessionEndedData; - // Disable input, show end reason - // reason: "completed" | "error" | "terminated" - // terminated_by: "agent" | "daemon" - break; - } - - case "error": { - const { message, code } = event.data as ErrorData; - // Display error to user - break; - } - - case "agent.unparsed": { - const { error, location } = event.data as AgentUnparsedData; - // Parsing failure - treat as bug in development - console.error(`Parse error at ${location}: ${error}`); - break; - } - } -} -``` - -### Content parts - -Each item has `content` parts. Render based on `type`: - -```ts -function renderContentPart(part: ContentPart) { - switch (part.type) { - case "text": - return {part.text}; - - case "tool_call": - return ; - - case "tool_result": - return ; - - case "file_ref": - return ; - - case "reasoning": - return {part.text}; - - case "status": - return ; - - case "image": - return ; - } -} -``` - ---- - -## Handling Permissions - -When `permission.requested` arrives, show an approval UI: - -```ts -const pendingPermissions = new Map(); - -function handleEvent(event: UniversalEvent) { - if (event.type === "permission.requested") { - const data = event.data as PermissionEventData; - pendingPermissions.set(data.permission_id, data); - } - - if (event.type === "permission.resolved") { - const data = event.data as PermissionEventData; - pendingPermissions.delete(data.permission_id); - } -} - -// User clicks approve/deny -async function replyPermission(id: string, reply: "once" | "always" | "reject") { - await client.replyPermission(sessionId, id, { reply }); - pendingPermissions.delete(id); -} -``` - -Render permission requests: - -```ts -function PermissionRequest({ data }: { data: PermissionEventData }) { - return ( -
-

Allow: {data.action}

- - - -
- ); -} -``` - ---- - -## Handling Questions - -When `question.requested` arrives, show a selection UI: - -```ts -const pendingQuestions = new Map(); - -function handleEvent(event: UniversalEvent) { - if (event.type === "question.requested") { - const data = event.data as QuestionEventData; - pendingQuestions.set(data.question_id, data); - } - - if (event.type === "question.resolved") { - const data = event.data as QuestionEventData; - pendingQuestions.delete(data.question_id); - } -} - -// User selects answer(s) -async function answerQuestion(id: string, answers: string[][]) { - await client.replyQuestion(sessionId, id, { answers }); - pendingQuestions.delete(id); -} - -async function rejectQuestion(id: string) { - await client.rejectQuestion(sessionId, id); - pendingQuestions.delete(id); -} -``` - -Render question requests: - -```ts -function QuestionRequest({ data }: { data: QuestionEventData }) { - const [selected, setSelected] = useState([]); - - return ( -
-

{data.prompt}

- {data.options.map((option) => ( - - ))} - - -
- ); -} -``` - ---- - -## Testing with Mock Agent - -The `mock` agent lets you test UI behaviors without external credentials: - -```ts -await client.createSession("test-session", { agent: "mock" }); -``` - -Send `help` to see available commands: - -| Command | Tests | -|---------|-------| -| `help` | Lists all commands | -| `demo` | Full UI coverage sequence with markers | -| `markdown` | Streaming markdown rendering | -| `tool` | Tool call + result with file refs | -| `status` | Status item updates | -| `image` | Image content part | -| `permission` | Permission request flow | -| `question` | Question request flow | -| `error` | Error + unparsed events | -| `end` | Session ended event | -| `echo ` | Echo text as assistant message | - -Any unrecognized text is echoed back as an assistant message. - ---- - -## Reference Implementation - -The [Inspector UI](https://github.com/rivet-dev/sandbox-agent/blob/main/frontend/packages/inspector/src/App.tsx) -is a complete reference showing session management, event rendering, and HITL flows. diff --git a/docs/cors.mdx b/docs/cors.mdx index 5e50888..d6bfddf 100644 --- a/docs/cors.mdx +++ b/docs/cors.mdx @@ -2,7 +2,6 @@ title: "CORS Configuration" description: "Configure CORS for browser-based applications." sidebarTitle: "CORS" -icon: "globe" --- When calling the Sandbox Agent server from a browser, CORS (Cross-Origin Resource Sharing) controls which origins can make requests. @@ -13,7 +12,6 @@ By default, no CORS origins are allowed. You must explicitly specify origins for ```bash sandbox-agent server \ - --token "$SANDBOX_TOKEN" \ --cors-allow-origin "http://localhost:5173" ``` @@ -36,7 +34,6 @@ Specify the flag multiple times to allow multiple origins: ```bash sandbox-agent server \ - --token "$SANDBOX_TOKEN" \ --cors-allow-origin "http://localhost:5173" \ --cors-allow-origin "http://localhost:3000" ``` @@ -47,7 +44,6 @@ By default, all methods and headers are allowed. To restrict them: ```bash sandbox-agent server \ - --token "$SANDBOX_TOKEN" \ --cors-allow-origin "https://your-app.com" \ --cors-allow-method "GET" \ --cors-allow-method "POST" \ diff --git a/docs/credentials.mdx b/docs/credentials.mdx index 676df7e..d014921 100644 --- a/docs/credentials.mdx +++ b/docs/credentials.mdx @@ -1,55 +1,115 @@ --- title: "Credentials" -description: "How sandbox-agent discovers and exposes provider credentials." -icon: "key" +description: "How Sandbox Agent discovers and uses provider credentials." --- -`sandbox-agent` can discover provider credentials from environment variables and local agent config files. +Sandbox Agent discovers API credentials from environment variables and local agent config files. +These credentials are passed through to underlying agent runtimes. -## Supported providers +## Credential sources -- Anthropic -- OpenAI -- Additional provider entries discovered via OpenCode config +Credentials are discovered in priority order. -## Common environment variables +### Environment variables (highest priority) + +API keys first: | Variable | Provider | -| --- | --- | +|----------|----------| | `ANTHROPIC_API_KEY` | Anthropic | | `CLAUDE_API_KEY` | Anthropic fallback | | `OPENAI_API_KEY` | OpenAI | | `CODEX_API_KEY` | OpenAI fallback | -## Extract credentials (CLI) +OAuth tokens (used when OAuth extraction is enabled): -Show discovered credentials (redacted by default): +| Variable | Provider | +|----------|----------| +| `CLAUDE_CODE_OAUTH_TOKEN` | Anthropic | +| `ANTHROPIC_AUTH_TOKEN` | Anthropic fallback | -```bash -sandbox-agent credentials extract +### Agent config files + +| Agent | Config path | Provider | +|-------|-------------|----------| +| Amp | `~/.amp/config.json` | Anthropic | +| Claude Code | `~/.claude.json`, `~/.claude/.credentials.json` | Anthropic | +| Codex | `~/.codex/auth.json` | OpenAI | +| OpenCode | `~/.local/share/opencode/auth.json` | Anthropic/OpenAI | + +## Provider requirements by agent + +| Agent | Required provider | +|-------|-------------------| +| Claude Code | Anthropic | +| Amp | Anthropic | +| Codex | OpenAI | +| OpenCode | Anthropic or OpenAI | +| Mock | None | + +## Error handling behavior + +Credential extraction is best-effort: + +- Missing or malformed files are skipped. +- Discovery continues to later sources. +- Missing credentials mark providers unavailable instead of failing server startup. + +When prompting, Sandbox Agent does not pre-validate provider credentials. Agent-native authentication errors surface through session events/output. + +## Checking credential status + +### API + +`GET /v1/agents` includes `credentialsAvailable` per agent. + +```json +{ + "agents": [ + { + "id": "claude", + "installed": true, + "credentialsAvailable": true + }, + { + "id": "codex", + "installed": true, + "credentialsAvailable": false + } + ] +} ``` -Reveal raw values: +### TypeScript SDK -```bash -sandbox-agent credentials extract --reveal +```typescript +const result = await sdk.listAgents(); + +for (const agent of result.agents) { + console.log(`${agent.id}: ${agent.credentialsAvailable ? "authenticated" : "no credentials"}`); +} ``` -Filter by agent/provider: +## Passing credentials explicitly + +Set environment variables before starting Sandbox Agent: ```bash -sandbox-agent credentials extract --agent codex -sandbox-agent credentials extract --provider openai +export ANTHROPIC_API_KEY=sk-ant-... +export OPENAI_API_KEY=sk-... +sandbox-agent daemon start ``` -Emit shell exports: +Or with SDK-managed local spawn: -```bash -sandbox-agent credentials extract-env --export +```typescript +import { SandboxAgent } from "sandbox-agent"; + +const sdk = await SandboxAgent.start({ + spawn: { + env: { + ANTHROPIC_API_KEY: process.env.MY_ANTHROPIC_KEY, + }, + }, +}); ``` - -## Notes - -- Discovery is best-effort: missing/invalid files do not crash extraction. -- v2 does not expose legacy v1 `credentialsAvailable` agent fields. -- Authentication failures are surfaced by the selected ACP agent process/agent during ACP requests. diff --git a/docs/custom-tools.mdx b/docs/custom-tools.mdx index a2e2d1b..727fb02 100644 --- a/docs/custom-tools.mdx +++ b/docs/custom-tools.mdx @@ -5,243 +5,159 @@ sidebarTitle: "Custom Tools" icon: "wrench" --- -There are two ways to give agents custom tools that run inside the sandbox: +There are two common patterns for sandbox-local custom tooling: | | MCP Server | Skill | |---|---|---| -| **How it works** | Sandbox Agent spawns your MCP server process and routes tool calls to it via stdio | A markdown file that instructs the agent to run your script with `node` (or any command) | -| **Tool discovery** | Agent sees tools automatically via MCP protocol | Agent reads instructions from the skill file | -| **Best for** | Structured tools with typed inputs/outputs | Lightweight scripts with natural-language instructions | -| **Requires** | `@modelcontextprotocol/sdk` dependency | Just a markdown file and a script | +| **How it works** | Agent connects to an MCP server (`mcpServers`) | Agent follows `SKILL.md` instructions and runs scripts | +| **Best for** | Typed tool calls and structured protocols | Lightweight task-specific guidance | +| **Requires** | MCP server process (stdio/http/sse) | Script + `SKILL.md` | -Both approaches execute code inside the sandbox, so your tools have full access to the sandbox filesystem, network, and installed system tools. - -## Option A: Tools via MCP +## Option A: MCP server (stdio) - - Create an MCP server that exposes tools using `@modelcontextprotocol/sdk` with `StdioServerTransport`. This server will run inside the sandbox. + - ```ts src/mcp-server.ts - import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; - import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; - import { z } from "zod"; +```ts src/mcp-server.ts +import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; +import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; +import { z } from "zod"; - const server = new McpServer({ - name: "rand", - version: "1.0.0", - }); +const server = new McpServer({ name: "rand", version: "1.0.0" }); - server.tool( - "random_number", - "Generate a random integer between min and max (inclusive)", - { - min: z.number().describe("Minimum value"), - max: z.number().describe("Maximum value"), - }, - async ({ min, max }) => ({ - content: [{ type: "text", text: String(Math.floor(Math.random() * (max - min + 1)) + min) }], - }), - ); +server.tool( + "random_number", + "Generate a random integer between min and max", + { + min: z.number(), + max: z.number(), + }, + async ({ min, max }) => ({ + content: [{ type: "text", text: String(Math.floor(Math.random() * (max - min + 1)) + min) }], + }), +); - const transport = new StdioServerTransport(); - await server.connect(transport); - ``` +await server.connect(new StdioServerTransport()); +``` - This is a simple example. Your MCP server runs inside the sandbox, so you can execute any code you'd like: query databases, call internal APIs, run shell commands, or interact with any service available in the container. +```bash +npx esbuild src/mcp-server.ts --bundle --format=cjs --platform=node --target=node18 --outfile=dist/mcp-server.cjs +``` - - Bundle into a single JS file so it can be uploaded and executed without a `node_modules` folder. + - ```bash - npx esbuild src/mcp-server.ts --bundle --format=cjs --platform=node --target=node18 --minify --outfile=dist/mcp-server.cjs - ``` +```ts +import { SandboxAgent } from "sandbox-agent"; +import fs from "node:fs"; - This creates `dist/mcp-server.cjs` ready to upload. +const sdk = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468" }); +const content = await fs.promises.readFile("./dist/mcp-server.cjs"); + +await sdk.writeFsFile({ path: "/opt/mcp/custom-tools/mcp-server.cjs" }, content); +``` + +```bash +curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=/opt/mcp/custom-tools/mcp-server.cjs" \ + --data-binary @./dist/mcp-server.cjs +``` - - Start your sandbox, then write the bundled file into it. + - - ```ts TypeScript - import { SandboxAgentClient } from "sandbox-agent"; - import fs from "node:fs"; +```ts +await sdk.setMcpConfig( + { + directory: "/workspace", + mcpName: "customTools", + }, + { + type: "local", + command: "node", + args: ["/opt/mcp/custom-tools/mcp-server.cjs"], + }, +); - const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); +const session = await sdk.createSession({ + agent: "claude", + sessionInit: { + cwd: "/workspace", + }, +}); - const content = await fs.promises.readFile("./dist/mcp-server.cjs"); - await client.writeFsFile( - { path: "/opt/mcp/custom-tools/mcp-server.cjs" }, - content, - ); - ``` - - ```bash cURL - curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=/opt/mcp/custom-tools/mcp-server.cjs" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - --data-binary @./dist/mcp-server.cjs - ``` - - - - - Point an MCP server config at the bundled JS file. When the session starts, Sandbox Agent spawns the MCP server process and routes tool calls to it. - - - ```ts TypeScript - await client.createSession("custom-tools", { - agent: "claude", - mcp: { - customTools: { - type: "local", - command: ["node", "/opt/mcp/custom-tools/mcp-server.cjs"], - }, - }, - }); - ``` - - ```bash cURL - curl -X POST "http://127.0.0.1:2468/v1/sessions/custom-tools" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "agent": "claude", - "mcp": { - "customTools": { - "type": "local", - "command": ["node", "/opt/mcp/custom-tools/mcp-server.cjs"] - } - } - }' - ``` - +await session.prompt([ + { type: "text", text: "Use the random_number tool with min=1 and max=10." }, +]); +``` -## Option B: Tools via Skills - -Skills are markdown files that instruct the agent how to use a script. Upload the script and a skill file, then point the session at the skill directory. +## Option B: Skills - - Write a script that the agent will execute. This runs inside the sandbox just like an MCP server, but the agent invokes it directly via its shell tool. + - ```ts src/random-number.ts - const min = Number(process.argv[2]); - const max = Number(process.argv[3]); +```ts src/random-number.ts +const min = Number(process.argv[2]); +const max = Number(process.argv[3]); - if (Number.isNaN(min) || Number.isNaN(max)) { - console.error("Usage: random-number "); - process.exit(1); - } +if (Number.isNaN(min) || Number.isNaN(max)) { + console.error("Usage: random-number "); + process.exit(1); +} - console.log(Math.floor(Math.random() * (max - min + 1)) + min); - ``` +console.log(Math.floor(Math.random() * (max - min + 1)) + min); +``` + +````md SKILL.md +--- +name: random-number +description: Generate a random integer between min and max. +--- + +Run: + +```bash +node /opt/skills/random-number/random-number.cjs +``` +```` + +```bash +npx esbuild src/random-number.ts --bundle --format=cjs --platform=node --target=node18 --outfile=dist/random-number.cjs +``` - - Create a `SKILL.md` that tells the agent what the script does and how to run it. The frontmatter `name` and `description` fields are required. See [Skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for tips on writing effective skills. + - ```md SKILL.md - --- - name: random-number - description: Generate a random integer between min and max (inclusive). Use when the user asks for a random number. - --- +```ts +import fs from "node:fs"; - To generate a random number, run: +const script = await fs.promises.readFile("./dist/random-number.cjs"); +await sdk.writeFsFile({ path: "/opt/skills/random-number/random-number.cjs" }, script); - ```bash - node /opt/skills/random-number/random-number.cjs - ``` - - This prints a single random integer between min and max (inclusive). +const skill = await fs.promises.readFile("./SKILL.md"); +await sdk.writeFsFile({ path: "/opt/skills/random-number/SKILL.md" }, skill); +``` - - Bundle the script just like an MCP server so it has no dependencies at runtime. + - ```bash - npx esbuild src/random-number.ts --bundle --format=cjs --platform=node --target=node18 --minify --outfile=dist/random-number.cjs - ``` - +```ts +const session = await sdk.createSession({ + agent: "claude", + sessionInit: { + cwd: "/workspace", + }, +}); - - Upload both the bundled script and the skill file. - - - ```ts TypeScript - import { SandboxAgentClient } from "sandbox-agent"; - import fs from "node:fs"; - - const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); - - const script = await fs.promises.readFile("./dist/random-number.cjs"); - await client.writeFsFile( - { path: "/opt/skills/random-number/random-number.cjs" }, - script, - ); - - const skill = await fs.promises.readFile("./SKILL.md"); - await client.writeFsFile( - { path: "/opt/skills/random-number/SKILL.md" }, - skill, - ); - ``` - - ```bash cURL - curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=/opt/skills/random-number/random-number.cjs" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - --data-binary @./dist/random-number.cjs - - curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=/opt/skills/random-number/SKILL.md" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - --data-binary @./SKILL.md - ``` - - - - - Point the session at the skill directory. The agent reads `SKILL.md` and learns how to use your script. - - - ```ts TypeScript - await client.createSession("custom-tools", { - agent: "claude", - skills: { - sources: [ - { type: "local", source: "/opt/skills/random-number" }, - ], - }, - }); - ``` - - ```bash cURL - curl -X POST "http://127.0.0.1:2468/v1/sessions/custom-tools" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "agent": "claude", - "skills": { - "sources": [ - { "type": "local", "source": "/opt/skills/random-number" } - ] - } - }' - ``` - +await session.prompt([ + { type: "text", text: "Use the random-number skill to pick a number from 1 to 100." }, +]); +``` ## Notes -- The sandbox image must include a Node.js runtime that can execute the bundled files. +- The sandbox runtime must include Node.js (or your chosen runtime). +- For persistent skill-source wiring by directory, see [Skills](/skills-config). diff --git a/docs/daemon.mdx b/docs/daemon.mdx index b76c8f8..f2cc100 100644 --- a/docs/daemon.mdx +++ b/docs/daemon.mdx @@ -1,96 +1,69 @@ --- title: "Daemon" -description: "Background daemon lifecycle, auto-upgrade, and management." -icon: "microchip" +description: "Background daemon lifecycle and management." --- -The sandbox-agent daemon is a background server process that stays running between sessions. Commands like `sandbox-agent opencode` and `gigacode` automatically start it when needed and restart it when the binary is updated. +The sandbox-agent daemon is a background server process. Commands like `sandbox-agent opencode` and `gigacode` can ensure it is running. ## How it works -1. When you run `sandbox-agent opencode`, `sandbox-agent daemon start`, or `gigacode`, the CLI checks if a daemon is already healthy at the configured host and port. -2. If no daemon is running, one is spawned in the background with stdout/stderr redirected to a log file. -3. The CLI writes a PID file and a build ID file to track the running process and its version. -4. On subsequent invocations, if the daemon is still running but was built from a different commit, the CLI automatically stops the old daemon and starts a new one. +1. A daemon-aware command checks for a healthy daemon at host/port. +2. If missing, it starts one in the background and records PID/version files. +3. Subsequent checks can compare build/version and restart when required. -## Auto-upgrade +## Auto-upgrade behavior -Each build of sandbox-agent embeds a unique **build ID** (the git short hash, or a version-timestamp fallback). When a daemon is started, this build ID is written to a version file alongside the PID file. - -On every invocation of `ensure_running` (called by `opencode`, `gigacode`, and `daemon start`), the CLI compares the stored build ID against the current binary's build ID. If they differ, the running daemon is stopped and replaced automatically: - -``` -daemon outdated (build a1b2c3d -> f4e5d6c), restarting... -``` - -This means installing a new version of sandbox-agent and running any daemon-aware command is enough to upgrade — no manual restart needed. +- `sandbox-agent opencode` and `gigacode` use ensure-running behavior with upgrade checks. +- `sandbox-agent daemon start` uses direct start by default. +- `sandbox-agent daemon start --upgrade` uses ensure-running behavior (including version check/restart). ## Managing the daemon ### Start -Start a daemon in the background. If one is already running and healthy, this is a no-op. - ```bash sandbox-agent daemon start [OPTIONS] ``` | Option | Default | Description | |--------|---------|-------------| -| `-H, --host ` | `127.0.0.1` | Host to bind to | -| `-p, --port ` | `2468` | Port to bind to | -| `-t, --token ` | - | Authentication token | -| `-n, --no-token` | - | Disable authentication | +| `-H, --host ` | `127.0.0.1` | Host | +| `-p, --port ` | `2468` | Port | +| `--upgrade` | false | Use ensure-running + upgrade behavior | ```bash -sandbox-agent daemon start --no-token +sandbox-agent daemon start +sandbox-agent daemon start --upgrade ``` ### Stop -Stop a running daemon. Sends SIGTERM and waits up to 5 seconds for a graceful shutdown before falling back to SIGKILL. - ```bash sandbox-agent daemon stop [OPTIONS] ``` | Option | Default | Description | |--------|---------|-------------| -| `-H, --host ` | `127.0.0.1` | Host of the daemon | -| `-p, --port ` | `2468` | Port of the daemon | - -```bash -sandbox-agent daemon stop -``` +| `-H, --host ` | `127.0.0.1` | Host | +| `-p, --port ` | `2468` | Port | ### Status -Show whether the daemon is running, its PID, build ID, and log path. - ```bash sandbox-agent daemon status [OPTIONS] ``` | Option | Default | Description | |--------|---------|-------------| -| `-H, --host ` | `127.0.0.1` | Host of the daemon | -| `-p, --port ` | `2468` | Port of the daemon | - -```bash -sandbox-agent daemon status -# Daemon running (PID 12345, build a1b2c3d, logs: ~/.local/share/sandbox-agent/daemon/daemon-127-0-0-1-2468.log) -``` - -If the daemon was started with an older binary, the status includes an `[outdated, restart recommended]` notice. +| `-H, --host ` | `127.0.0.1` | Host | +| `-p, --port ` | `2468` | Port | ## Files -All daemon state files live under the sandbox-agent data directory (typically `~/.local/share/sandbox-agent/daemon/`): +Daemon state is stored under the sandbox-agent data directory (for example `~/.local/share/sandbox-agent/daemon/`): | File | Purpose | |------|---------| -| `daemon-{host}-{port}.pid` | PID of the running daemon process | -| `daemon-{host}-{port}.version` | Build ID of the running daemon | -| `daemon-{host}-{port}.log` | Daemon stdout/stderr log output | - -Multiple daemons can run on different host/port combinations without conflicting. +| `daemon-{host}-{port}.pid` | PID of running daemon | +| `daemon-{host}-{port}.version` | Build/version marker | +| `daemon-{host}-{port}.log` | Daemon stdout/stderr log | diff --git a/docs/deploy/cloudflare.mdx b/docs/deploy/cloudflare.mdx index 0228f91..5328653 100644 --- a/docs/deploy/cloudflare.mdx +++ b/docs/deploy/cloudflare.mdx @@ -1,21 +1,19 @@ --- title: "Cloudflare" -description: "Deploy the daemon inside a Cloudflare Sandbox." +description: "Deploy Sandbox Agent inside a Cloudflare Sandbox." --- ## Prerequisites -- Cloudflare account with Workers Paid plan -- Docker running locally for `wrangler dev` -- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` for the coding agents +- Cloudflare account with Workers paid plan +- Docker for local `wrangler dev` +- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` -Cloudflare Sandbox SDK is in beta. See [Sandbox SDK docs](https://developers.cloudflare.com/sandbox/) for details. +Cloudflare Sandbox SDK is beta. See [Sandbox SDK docs](https://developers.cloudflare.com/sandbox/). -## Quick Start - -Create a new Sandbox SDK project: +## Quick start ```bash npm create cloudflare@latest -- my-sandbox --template=cloudflare/sandbox-sdk/examples/minimal @@ -24,64 +22,16 @@ cd my-sandbox ## Dockerfile -Create a `Dockerfile` with sandbox-agent and agents pre-installed: - ```dockerfile FROM cloudflare/sandbox:0.7.0 -# Install sandbox-agent -RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh +RUN sandbox-agent install-agent claude && sandbox-agent install-agent codex -# Pre-install agents -RUN sandbox-agent install-agent claude && \ - sandbox-agent install-agent codex - -# Required for local development with wrangler dev EXPOSE 8000 ``` - -The `EXPOSE 8000` directive is required for `wrangler dev` to proxy requests to the container. Port 3000 is reserved for the Cloudflare control plane. - - -## Wrangler Configuration - -Update `wrangler.jsonc` to use your Dockerfile: - -```jsonc -{ - "name": "my-sandbox-agent", - "main": "src/index.ts", - "compatibility_date": "2025-01-01", - "compatibility_flags": ["nodejs_compat"], - "containers": [ - { - "class_name": "Sandbox", - "image": "./Dockerfile", - "instance_type": "lite", - "max_instances": 1 - } - ], - "durable_objects": { - "bindings": [ - { - "class_name": "Sandbox", - "name": "Sandbox" - } - ] - }, - "migrations": [ - { - "new_sqlite_classes": ["Sandbox"], - "tag": "v1" - } - ] -} -``` - -## TypeScript Example - -This example proxies requests to sandbox-agent via `containerFetch`, which works reliably in both local development and production: +## TypeScript proxy example ```typescript import { getSandbox, type Sandbox } from "@cloudflare/sandbox"; @@ -95,158 +45,87 @@ type Env = { const PORT = 8000; -/** Check if sandbox-agent is already running */ async function isServerRunning(sandbox: Sandbox): Promise { try { - const result = await sandbox.exec(`curl -sf http://localhost:${PORT}/v2/health`); + const result = await sandbox.exec(`curl -sf http://localhost:${PORT}/v1/health`); return result.success; } catch { return false; } } -/** Ensure sandbox-agent is running in the container */ async function ensureRunning(sandbox: Sandbox, env: Env): Promise { if (await isServerRunning(sandbox)) return; - // Set environment variables for agents const envVars: Record = {}; if (env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = env.ANTHROPIC_API_KEY; if (env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = env.OPENAI_API_KEY; await sandbox.setEnvVars(envVars); - // Start sandbox-agent server - await sandbox.startProcess( - `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}` - ); + await sandbox.startProcess(`sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`); - // Poll health endpoint until server is ready for (let i = 0; i < 30; i++) { if (await isServerRunning(sandbox)) return; await new Promise((r) => setTimeout(r, 200)); } + + throw new Error("sandbox-agent failed to start"); } export default { async fetch(request: Request, env: Env): Promise { const url = new URL(request.url); - - // Proxy requests: /sandbox/:name/v2/... const match = url.pathname.match(/^\/sandbox\/([^/]+)(\/.*)?$/); - if (match) { - const [, name, path = "/"] = match; - const sandbox = getSandbox(env.Sandbox, name); - await ensureRunning(sandbox, env); - - // Proxy request to container - return sandbox.containerFetch( - new Request(`http://localhost${path}${url.search}`, request), - PORT - ); + if (!match) { + return new Response("Not found", { status: 404 }); } - return new Response("Not found", { status: 404 }); + const [, name, path = "/"] = match; + const sandbox = getSandbox(env.Sandbox, name); + await ensureRunning(sandbox, env); + + return sandbox.containerFetch( + new Request(`http://localhost${path}${url.search}`, request), + PORT, + ); }, }; ``` -## Connect from Client +## Connect from a client ```typescript -import { SandboxAgentClient } from "sandbox-agent"; +import { SandboxAgent } from "sandbox-agent"; -// Connect via the proxy endpoint -const client = new SandboxAgentClient({ +const sdk = await SandboxAgent.connect({ baseUrl: "http://localhost:8787/sandbox/my-sandbox", - agent: "mock", }); -// Wait for server to be ready -for (let i = 0; i < 30; i++) { - try { - await client.getHealth(); - break; - } catch { - await new Promise((r) => setTimeout(r, 1000)); - } -} +const session = await sdk.createSession({ agent: "claude" }); -// Create a session and start coding -await client.createSession("my-session", { agent: "claude" }); - -await client.postMessage("my-session", { - message: "Summarize this repository", +const off = session.onEvent((event) => { + console.log(event.sender, event.payload); }); -for await (const event of client.streamEvents("my-session")) { - // Auto-approve permissions - if (event.type === "permission.requested") { - await client.replyPermission("my-session", event.data.permission_id, { - reply: "once", - }); - } - - // Handle text output - if (event.type === "item.delta" && event.data?.delta) { - process.stdout.write(event.data.delta); - } -} +await session.prompt([{ type: "text", text: "Summarize this repository" }]); +off(); ``` -## Environment Variables - -Use `.dev.vars` for local development: - -```bash -echo "ANTHROPIC_API_KEY=your-api-key" > .dev.vars -``` - - -Use plain `KEY=value` format in `.dev.vars`. Do not use `export KEY=value` - wrangler won't parse the bash syntax. - - - -The `.dev.vars` file is automatically gitignored and only used during local development with `npm run dev`. - - -For production, set secrets via wrangler: - -```bash -wrangler secret put ANTHROPIC_API_KEY -``` - -## Local Development - -Start the development server: +## Local development ```bash npm run dev ``` - -First run builds the Docker container (2-3 minutes). Subsequent runs are much faster. - - -Test with curl: +Test health: ```bash -curl http://localhost:8787/sandbox/demo/v2/health +curl http://localhost:8787/sandbox/demo/v1/health ``` - -Containers cache environment variables. If you change `.dev.vars`, either use a new sandbox name or clear existing containers: -```bash -docker ps -a | grep sandbox | awk '{print $1}' | xargs -r docker rm -f -``` - - -## Production Deployment - -Deploy to Cloudflare: +## Production deployment ```bash wrangler deploy ``` - -For production with preview URLs (direct container access), you'll need a custom domain with wildcard DNS routing. See [Cloudflare Production Deployment](https://developers.cloudflare.com/sandbox/guides/production-deployment/) for setup instructions. diff --git a/docs/deploy/daytona.mdx b/docs/deploy/daytona.mdx index 14e1347..bf81c8f 100644 --- a/docs/deploy/daytona.mdx +++ b/docs/deploy/daytona.mdx @@ -1,63 +1,52 @@ --- title: "Daytona" -description: "Run the daemon in a Daytona workspace." +description: "Run Sandbox Agent in a Daytona workspace." --- -Daytona Tier 3+ is required to access api.anthropic.com and api.openai.com. Tier 1/2 sandboxes have restricted network access that will cause agent failures. See [Daytona network limits](https://www.daytona.io/docs/en/network-limits/) for details. +Daytona Tier 3+ is required for access to common model provider endpoints. +See [Daytona network limits](https://www.daytona.io/docs/en/network-limits/). ## Prerequisites -- `DAYTONA_API_KEY` environment variable -- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` for the coding agents +- `DAYTONA_API_KEY` +- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` -## TypeScript Example +## TypeScript example ```typescript import { Daytona } from "@daytonaio/sdk"; -import { SandboxAgentClient } from "sandbox-agent"; +import { SandboxAgent } from "sandbox-agent"; const daytona = new Daytona(); -// Pass API keys to the sandbox const envVars: Record = {}; if (process.env.ANTHROPIC_API_KEY) envVars.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envVars.OPENAI_API_KEY = process.env.OPENAI_API_KEY; const sandbox = await daytona.create({ envVars }); -// Install sandbox-agent await sandbox.process.executeCommand( - "curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh" + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh" ); -// Start the server in the background await sandbox.process.executeCommand( "nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 >/tmp/sandbox-agent.log 2>&1 &" ); -// Wait for server to be ready await new Promise((r) => setTimeout(r, 2000)); -// Get the public URL const baseUrl = (await sandbox.getSignedPreviewUrl(3000, 4 * 60 * 60)).url; +const sdk = await SandboxAgent.connect({ baseUrl }); -// Connect and use the SDK -const client = new SandboxAgentClient({ baseUrl, agent: "mock" }); +const session = await sdk.createSession({ agent: "claude" }); +await session.prompt([{ type: "text", text: "Summarize this repository" }]); -await client.createSession("my-session", { - agent: "claude", - permissionMode: "default", -}); - -// Cleanup when done await sandbox.delete(); ``` -## Using Snapshots for Faster Startup - -For production, use snapshots with pre-installed binaries: +## Using snapshots for faster startup ```typescript import { Daytona, Image } from "@daytonaio/sdk"; @@ -65,7 +54,6 @@ import { Daytona, Image } from "@daytonaio/sdk"; const daytona = new Daytona(); const SNAPSHOT = "sandbox-agent-ready"; -// Create snapshot once (takes 2-3 minutes) const hasSnapshot = await daytona.snapshot.get(SNAPSHOT).then(() => true, () => false); if (!hasSnapshot) { @@ -73,18 +61,10 @@ if (!hasSnapshot) { name: SNAPSHOT, image: Image.base("ubuntu:22.04").runCommands( "apt-get update && apt-get install -y curl ca-certificates", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", "sandbox-agent install-agent claude", "sandbox-agent install-agent codex", ), }); } - -// Now sandboxes start instantly -const sandbox = await daytona.create({ - snapshot: SNAPSHOT, - envVars, -}); ``` - -See [Daytona Snapshots](https://daytona.io/docs/snapshots) for details. diff --git a/docs/deploy/docker.mdx b/docs/deploy/docker.mdx index a7520b7..28c9f77 100644 --- a/docs/deploy/docker.mdx +++ b/docs/deploy/docker.mdx @@ -1,15 +1,15 @@ --- title: "Docker" -description: "Build and run the daemon in a Docker container." +description: "Build and run Sandbox Agent in a Docker container." --- -Docker is not recommended for production. Standard Docker containers don't provide sufficient isolation for running untrusted code. Use a dedicated sandbox provider like E2B or Daytona for production workloads. +Docker is not recommended for production isolation of untrusted workloads. Use dedicated sandbox providers (E2B, Daytona, etc.) for stronger isolation. -## Quick Start +## Quick start -Run sandbox-agent in a container with agents pre-installed: +Run Sandbox Agent with agents pre-installed: ```bash docker run --rm -p 3000:3000 \ @@ -17,23 +17,21 @@ docker run --rm -p 3000:3000 \ -e OPENAI_API_KEY="$OPENAI_API_KEY" \ alpine:latest sh -c "\ apk add --no-cache curl ca-certificates libstdc++ libgcc bash && \ - curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh && \ + curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh && \ sandbox-agent install-agent claude && \ sandbox-agent install-agent codex && \ sandbox-agent server --no-token --host 0.0.0.0 --port 3000" ``` -Alpine is required because Claude Code is built for musl libc. Debian/Ubuntu images use glibc and won't work. +Alpine is required for some agent binaries that target musl libc. -Access the API at `http://localhost:3000`. - ## TypeScript with dockerode ```typescript import Docker from "dockerode"; -import { SandboxAgentClient } from "sandbox-agent"; +import { SandboxAgent } from "sandbox-agent"; const docker = new Docker(); const PORT = 3000; @@ -42,7 +40,7 @@ const container = await docker.createContainer({ Image: "alpine:latest", Cmd: ["sh", "-c", [ "apk add --no-cache curl ca-certificates libstdc++ libgcc bash", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", "sandbox-agent install-agent claude", "sandbox-agent install-agent codex", `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, @@ -60,24 +58,18 @@ const container = await docker.createContainer({ await container.start(); -// Wait for server and connect const baseUrl = `http://127.0.0.1:${PORT}`; -const client = new SandboxAgentClient({ baseUrl, agent: "mock" }); +const sdk = await SandboxAgent.connect({ baseUrl }); -// Use the client... -await client.createSession("my-session", { - agent: "claude", - permissionMode: "default", -}); +const session = await sdk.createSession({ agent: "claude" }); +await session.prompt([{ type: "text", text: "Summarize this repository." }]); ``` -## Building from Source - -To build a static binary for use in minimal containers: +## Building from source ```bash docker build -f docker/release/linux-x86_64.Dockerfile -t sandbox-agent-build . docker run --rm -v "$PWD/artifacts:/artifacts" sandbox-agent-build ``` -The binary will be at `./artifacts/sandbox-agent-x86_64-unknown-linux-musl`. +Binary output: `./artifacts/sandbox-agent-x86_64-unknown-linux-musl`. diff --git a/docs/deploy/e2b.mdx b/docs/deploy/e2b.mdx index 7d06164..e2d5922 100644 --- a/docs/deploy/e2b.mdx +++ b/docs/deploy/e2b.mdx @@ -1,79 +1,52 @@ --- title: "E2B" -description: "Deploy the daemon inside an E2B sandbox." +description: "Deploy Sandbox Agent inside an E2B sandbox." --- ## Prerequisites -- `E2B_API_KEY` environment variable -- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` for the coding agents +- `E2B_API_KEY` +- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` -## TypeScript Example +## TypeScript example ```typescript import { Sandbox } from "@e2b/code-interpreter"; -import { SandboxAgentClient } from "sandbox-agent"; +import { SandboxAgent } from "sandbox-agent"; -// Pass API keys to the sandbox const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; const sandbox = await Sandbox.create({ allowInternetAccess: true, envs }); -// Install sandbox-agent await sandbox.commands.run( - "curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh" + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh" ); -// Install agents before starting the server await sandbox.commands.run("sandbox-agent install-agent claude"); await sandbox.commands.run("sandbox-agent install-agent codex"); -// Start the server in the background await sandbox.commands.run( "sandbox-agent server --no-token --host 0.0.0.0 --port 3000", { background: true } ); -// Connect to the server const baseUrl = `https://${sandbox.getHost(3000)}`; -const client = new SandboxAgentClient({ baseUrl, agent: "mock" }); +const sdk = await SandboxAgent.connect({ baseUrl }); -// Wait for server to be ready -for (let i = 0; i < 30; i++) { - try { - await client.getHealth(); - break; - } catch { - await new Promise((r) => setTimeout(r, 1000)); - } -} - -// Create a session and start coding -await client.createSession("my-session", { - agent: "claude", - permissionMode: "default", +const session = await sdk.createSession({ agent: "claude" }); +const off = session.onEvent((event) => { + console.log(event.sender, event.payload); }); -await client.postMessage("my-session", { - message: "Summarize this repository", -}); +await session.prompt([{ type: "text", text: "Summarize this repository" }]); +off(); -for await (const event of client.streamEvents("my-session")) { - console.log(event.type, event.data); -} - -// Cleanup await sandbox.kill(); ``` -## Faster Cold Starts +## Faster cold starts -For faster startup, create a custom E2B template with sandbox-agent and agents pre-installed: - -1. Create a template with the install script baked in -2. Pre-install agents: `sandbox-agent install-agent claude codex` -3. Use the template ID when creating sandboxes - -See [E2B Custom Templates](https://e2b.dev/docs/sandbox-template) for details. +For faster startup, create a custom E2B template with Sandbox Agent and target agents pre-installed. +See [E2B Custom Templates](https://e2b.dev/docs/sandbox-template). diff --git a/docs/deploy/local.mdx b/docs/deploy/local.mdx index 5fafb50..8af9f51 100644 --- a/docs/deploy/local.mdx +++ b/docs/deploy/local.mdx @@ -1,52 +1,53 @@ --- title: "Local" -description: "Run the daemon locally for development." +description: "Run Sandbox Agent locally for development." --- -For local development, you can run the daemon directly on your machine. +For local development, run Sandbox Agent directly on your machine. ## With the CLI ```bash # Install -curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh +curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh # Run sandbox-agent server --no-token --host 127.0.0.1 --port 2468 ``` -Or with npm or Bun: +Or with npm/Bun: ```bash - npx sandbox-agent server --no-token --host 127.0.0.1 --port 2468 + npx @sandbox-agent/cli@0.2.x server --no-token --host 127.0.0.1 --port 2468 ``` ```bash - bunx sandbox-agent server --no-token --host 127.0.0.1 --port 2468 + bunx @sandbox-agent/cli@0.2.x server --no-token --host 127.0.0.1 --port 2468 ``` ## With the TypeScript SDK -The SDK can automatically spawn and manage the server as a subprocess: +The SDK can spawn and manage the server as a subprocess: ```typescript import { SandboxAgent } from "sandbox-agent"; -// Spawns sandbox-agent server as a subprocess -const client = await SandboxAgent.start(); +const sdk = await SandboxAgent.start(); -await client.createSession("my-session", { +const session = await sdk.createSession({ agent: "claude", - permissionMode: "default", }); -// When done -await client.dispose(); +await session.prompt([ + { type: "text", text: "Summarize this repository." }, +]); + +await sdk.dispose(); ``` -This installs the binary (if needed) and starts the server on a random available port. No manual setup required. +This starts the server on an available local port and connects automatically. diff --git a/docs/deploy/vercel.mdx b/docs/deploy/vercel.mdx index f32013a..4a840ee 100644 --- a/docs/deploy/vercel.mdx +++ b/docs/deploy/vercel.mdx @@ -1,47 +1,39 @@ --- title: "Vercel" -description: "Deploy the daemon inside a Vercel Sandbox." +description: "Deploy Sandbox Agent inside a Vercel Sandbox." --- ## Prerequisites -- `VERCEL_OIDC_TOKEN` or `VERCEL_ACCESS_TOKEN` environment variable -- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` for the coding agents +- `VERCEL_OIDC_TOKEN` or `VERCEL_ACCESS_TOKEN` +- `ANTHROPIC_API_KEY` or `OPENAI_API_KEY` -## TypeScript Example +## TypeScript example ```typescript import { Sandbox } from "@vercel/sandbox"; -import { SandboxAgentClient } from "sandbox-agent"; +import { SandboxAgent } from "sandbox-agent"; -// Pass API keys to the sandbox const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; if (process.env.OPENAI_API_KEY) envs.OPENAI_API_KEY = process.env.OPENAI_API_KEY; -// Create sandbox with port 3000 exposed const sandbox = await Sandbox.create({ runtime: "node24", ports: [3000], }); -// Helper to run commands const run = async (cmd: string, args: string[] = []) => { const result = await sandbox.runCommand({ cmd, args, env: envs }); if (result.exitCode !== 0) { throw new Error(`Command failed: ${cmd} ${args.join(" ")}`); } - return result; }; -// Install sandbox-agent -await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh"]); - -// Install agents before starting the server +await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh"]); await run("sandbox-agent", ["install-agent", "claude"]); await run("sandbox-agent", ["install-agent", "codex"]); -// Start the server in the background await sandbox.runCommand({ cmd: "sandbox-agent", args: ["server", "--no-token", "--host", "0.0.0.0", "--port", "3000"], @@ -49,43 +41,22 @@ await sandbox.runCommand({ detached: true, }); -// Connect to the server const baseUrl = sandbox.domain(3000); -const client = new SandboxAgentClient({ baseUrl, agent: "mock" }); +const sdk = await SandboxAgent.connect({ baseUrl }); -// Wait for server to be ready -for (let i = 0; i < 30; i++) { - try { - await client.getHealth(); - break; - } catch { - await new Promise((r) => setTimeout(r, 1000)); - } -} +const session = await sdk.createSession({ agent: "claude" }); -// Create a session and start coding -await client.createSession("my-session", { - agent: "claude", - permissionMode: "default", +const off = session.onEvent((event) => { + console.log(event.sender, event.payload); }); -await client.postMessage("my-session", { - message: "Summarize this repository", -}); +await session.prompt([{ type: "text", text: "Summarize this repository" }]); +off(); -for await (const event of client.streamEvents("my-session")) { - console.log(event.type, event.data); -} - -// Cleanup await sandbox.stop(); ``` ## Authentication -Vercel Sandboxes support two authentication methods: - -- **OIDC Token**: Set `VERCEL_OIDC_TOKEN` (recommended for CI/CD) -- **Access Token**: Set `VERCEL_ACCESS_TOKEN` (for local development, run `vercel env pull`) - -See [Vercel Sandbox docs](https://vercel.com/docs/functions/sandbox) for details. +Vercel Sandboxes support OIDC token auth (recommended) and access-token auth. +See [Vercel Sandbox docs](https://vercel.com/docs/functions/sandbox). diff --git a/docs/docs.json b/docs/docs.json index d6b62f1..e7c2fdf 100644 --- a/docs/docs.json +++ b/docs/docs.json @@ -50,8 +50,7 @@ "group": "Getting started", "pages": [ "quickstart", - "building-chat-ui", - "manage-sessions", + "sdk-overview", { "group": "Deploy", "icon": "server", @@ -68,11 +67,7 @@ ] }, { - "group": "SDKs", - "pages": ["sdks/typescript", "sdks/python"] - }, - { - "group": "Agent Features", + "group": "Agent", "pages": [ "agent-sessions", "attachments", @@ -82,19 +77,24 @@ ] }, { - "group": "Features", + "group": "System", "pages": ["file-system"] }, { - "group": "Advanced", - "pages": ["advanced/acp-http-client"] + "group": "Orchestration", + "pages": [ + "architecture", + "session-persistence", + "observability", + "multiplayer", + "security" + ] }, { "group": "Reference", "pages": [ "cli", "inspector", - "session-transcript-schema", "opencode-compatibility", { "group": "More", @@ -102,6 +102,7 @@ "credentials", "daemon", "cors", + "session-restoration", "telemetry", { "group": "AI", diff --git a/docs/file-system.mdx b/docs/file-system.mdx index 5bc57fd..a91fd6b 100644 --- a/docs/file-system.mdx +++ b/docs/file-system.mdx @@ -5,183 +5,130 @@ sidebarTitle: "File System" icon: "folder" --- -The filesystem API lets you list, read, write, move, and delete files inside the sandbox, plus upload batches of files via tar archives. -Control operations (`list`, `mkdir`, `move`, `stat`, `delete`) are ACP extensions on `/v2/rpc` and require an active ACP connection in the SDK. +The filesystem API lets you list, read, write, move, and delete files inside the sandbox, plus upload tar archives in batch. -Binary transfer is intentionally a separate HTTP API (not ACP extension methods): - -- `GET /v2/fs/file` -- `PUT /v2/fs/file` -- `POST /v2/fs/upload-batch` - -Reason: these are host/runtime capabilities implemented by Sandbox Agent for cross-agent-consistent behavior, and they may require streaming very large binary payloads that ACP JSON-RPC is not suited to transport efficiently. -This is intentionally separate from ACP native `fs/read_text_file` and `fs/write_text_file`. -ACP extension variants may exist in parallel for compatibility, but SDK defaults should use the HTTP endpoints above for binary transfer. - -## Path Resolution +## Path resolution - Absolute paths are used as-is. -- Relative paths use the session working directory when `sessionId` is provided. -- Without `sessionId`, relative paths resolve against the server home directory. -- Relative paths cannot contain `..` or absolute prefixes; requests that attempt to escape the root are rejected. +- Relative paths resolve from the server process working directory. +- Requests that attempt to escape allowed roots are rejected by the server. -The session working directory is the server process current working directory at the moment the session is created. - -## List Entries - -`listFsEntries()` uses ACP extension method `_sandboxagent/fs/list_entries`. +## List entries ```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; +import { SandboxAgent } from "sandbox-agent"; -const client = new SandboxAgentClient({ baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock" }); +const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", +}); -const entries = await client.listFsEntries({ +const entries = await sdk.listFsEntries({ path: "./workspace", - sessionId: "my-session", }); console.log(entries); ``` ```bash cURL -curl -X POST "http://127.0.0.1:2468/v2/rpc" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - -H "x-acp-connection-id: acp_conn_1" \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":1,"method":"_sandboxagent/fs/list_entries","params":{"path":"./workspace","sessionId":"my-session"}}' +curl -X GET "http://127.0.0.1:2468/v1/fs/entries?path=./workspace" ``` -## Read And Write Files +## Read and write files -`PUT /v2/fs/file` writes raw bytes. `GET /v2/fs/file` returns raw bytes. +`PUT /v1/fs/file` writes raw bytes. `GET /v1/fs/file` returns raw bytes. ```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; +import { SandboxAgent } from "sandbox-agent"; -const client = new SandboxAgentClient({ baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock" }); - -await client.writeFsFile({ path: "./notes.txt", sessionId: "my-session" }, "hello"); - -const bytes = await client.readFsFile({ - path: "./notes.txt", - sessionId: "my-session", +const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", }); +await sdk.writeFsFile({ path: "./notes.txt" }, "hello"); + +const bytes = await sdk.readFsFile({ path: "./notes.txt" }); const text = new TextDecoder().decode(bytes); + console.log(text); ``` ```bash cURL -curl -X PUT "http://127.0.0.1:2468/v2/fs/file?path=./notes.txt&sessionId=my-session" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ +curl -X PUT "http://127.0.0.1:2468/v1/fs/file?path=./notes.txt" \ --data-binary "hello" -curl -X GET "http://127.0.0.1:2468/v2/fs/file?path=./notes.txt&sessionId=my-session" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ +curl -X GET "http://127.0.0.1:2468/v1/fs/file?path=./notes.txt" \ --output ./notes.txt ``` -## Create Directories - -`mkdirFs()` uses ACP extension method `_sandboxagent/fs/mkdir`. +## Create directories ```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; +import { SandboxAgent } from "sandbox-agent"; -const client = new SandboxAgentClient({ baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock" }); - -await client.mkdirFs({ - path: "./data", - sessionId: "my-session", +const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", }); + +await sdk.mkdirFs({ path: "./data" }); ``` ```bash cURL -curl -X POST "http://127.0.0.1:2468/v2/rpc" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - -H "x-acp-connection-id: acp_conn_1" \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":2,"method":"_sandboxagent/fs/mkdir","params":{"path":"./data","sessionId":"my-session"}}' +curl -X POST "http://127.0.0.1:2468/v1/fs/mkdir?path=./data" ``` -## Move, Delete, And Stat - -`moveFs()`, `statFs()`, and `deleteFsEntry()` use ACP extension methods (`_sandboxagent/fs/move`, `_sandboxagent/fs/stat`, `_sandboxagent/fs/delete_entry`). +## Move, delete, and stat ```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; +import { SandboxAgent } from "sandbox-agent"; -const client = new SandboxAgentClient({ baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock" }); - -await client.moveFs( - { from: "./notes.txt", to: "./notes-old.txt", overwrite: true }, - { sessionId: "my-session" }, -); - -const stat = await client.statFs({ - path: "./notes-old.txt", - sessionId: "my-session", +const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", }); -await client.deleteFsEntry({ - path: "./notes-old.txt", - sessionId: "my-session", +await sdk.moveFs({ + from: "./notes.txt", + to: "./notes-old.txt", + overwrite: true, }); +const stat = await sdk.statFs({ path: "./notes-old.txt" }); +await sdk.deleteFsEntry({ path: "./notes-old.txt" }); + console.log(stat); ``` ```bash cURL -curl -X POST "http://127.0.0.1:2468/v2/rpc" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - -H "x-acp-connection-id: acp_conn_1" \ +curl -X POST "http://127.0.0.1:2468/v1/fs/move" \ -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":3,"method":"_sandboxagent/fs/move","params":{"from":"./notes.txt","to":"./notes-old.txt","overwrite":true,"sessionId":"my-session"}}' + -d '{"from":"./notes.txt","to":"./notes-old.txt","overwrite":true}' -curl -X POST "http://127.0.0.1:2468/v2/rpc" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - -H "x-acp-connection-id: acp_conn_1" \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":4,"method":"_sandboxagent/fs/stat","params":{"path":"./notes-old.txt","sessionId":"my-session"}}' +curl -X GET "http://127.0.0.1:2468/v1/fs/stat?path=./notes-old.txt" -curl -X POST "http://127.0.0.1:2468/v2/rpc" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - -H "x-acp-connection-id: acp_conn_1" \ - -H "Content-Type: application/json" \ - -d '{"jsonrpc":"2.0","id":5,"method":"_sandboxagent/fs/delete_entry","params":{"path":"./notes-old.txt","sessionId":"my-session"}}' +curl -X DELETE "http://127.0.0.1:2468/v1/fs/entry?path=./notes-old.txt" ``` -## Batch Upload (Tar) +## Batch upload (tar) -Batch upload accepts `application/x-tar` only and extracts into the destination directory. The response returns absolute paths for extracted files, capped at 1024 entries. +Batch upload accepts `application/x-tar` and extracts into the destination directory. ```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; +import { SandboxAgent } from "sandbox-agent"; import fs from "node:fs"; import path from "node:path"; import tar from "tar"; -const client = new SandboxAgentClient({ baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock" }); +const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", +}); const archivePath = path.join(process.cwd(), "skills.tar"); await tar.c({ @@ -190,9 +137,8 @@ await tar.c({ }, ["."]); const tarBuffer = await fs.promises.readFile(archivePath); -const result = await client.uploadFsBatch(tarBuffer, { +const result = await sdk.uploadFsBatch(tarBuffer, { path: "./skills", - sessionId: "my-session", }); console.log(result); @@ -201,8 +147,7 @@ console.log(result); ```bash cURL tar -cf skills.tar -C ./skills . -curl -X POST "http://127.0.0.1:2468/v2/fs/upload-batch?path=./skills&sessionId=my-session" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ +curl -X POST "http://127.0.0.1:2468/v1/fs/upload-batch?path=./skills" \ -H "Content-Type: application/x-tar" \ --data-binary @skills.tar ``` diff --git a/docs/manage-sessions.mdx b/docs/manage-sessions.mdx deleted file mode 100644 index 7d51538..0000000 --- a/docs/manage-sessions.mdx +++ /dev/null @@ -1,265 +0,0 @@ ---- -title: "Manage Sessions" -description: "Persist and replay agent transcripts across connections." -icon: "database" ---- - -Sandbox Agent stores sessions in memory only. When the server restarts or the sandbox is destroyed, all session data is lost. It's your responsibility to persist events to your own database. - -See the [Building a Chat UI](/building-chat-ui) guide for understanding session lifecycle events like `session.started` and `session.ended`. - -## Recommended approach - -1. Store events to your database as they arrive -2. On reconnect, get the last event's `sequence` and pass it as `offset` -3. The API returns events where `sequence > offset` - -This prevents duplicate writes and lets you recover from disconnects. - -## Receiving Events - -Two ways to receive events: SSE streaming (recommended) or polling. - -### Streaming - -Use SSE for real-time events with automatic reconnection support. - -```typescript -import { SandboxAgentClient } from "sandbox-agent"; - -const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - agent: "mock", -}); - -// Get offset from last stored event (0 returns all events) -const lastEvent = await db.getLastEvent("my-session"); -const offset = lastEvent?.sequence ?? 0; - -// Stream from where you left off -for await (const event of client.streamEvents("my-session", { offset })) { - await db.insertEvent("my-session", event); -} -``` - -### Polling - -If you can't use SSE streaming, poll the events endpoint: - -```typescript -const lastEvent = await db.getLastEvent("my-session"); -let offset = lastEvent?.sequence ?? 0; - -while (true) { - const { events } = await client.getEvents("my-session", { - offset, - limit: 100 - }); - - for (const event of events) { - await db.insertEvent("my-session", event); - offset = event.sequence; - } - - await sleep(1000); -} -``` - -## Database options - -Choose where to persist events based on your requirements. For most use cases, we recommend Rivet Actors. - -| | Durable | Real-time | Multiplayer | Scaling | Throughput | Complexity | -|---------|:-------:|:---------:|:-----------:|---------|------------|------------| -| Rivet Actors | ✓ | ✓ | ✓ | Auto-sharded, one actor per session | Millions of concurrent sessions | Zero infrastructure | -| PostgreSQL | ✓ | | | Manual sharding | Connection pool limited | Connection pools, migrations | -| Redis | | ✓ | | Redis Cluster | High, in-memory | Memory management, Sentinel for failover | - -### Rivet Actors - -For production workloads, [Rivet Actors](https://rivet.gg) provide a managed solution for: - -- **Persistent state**: Events survive crashes and restarts -- **Real-time streaming**: Built-in WebSocket support for clients -- **Horizontal scaling**: Run thousands of concurrent sessions -- **Observability**: Built-in logging and metrics - -#### Actor - -```typescript -import { actor } from "rivetkit"; -import { Daytona } from "@daytonaio/sdk"; -import { SandboxAgent, SandboxAgentClient, AgentEvent } from "sandbox-agent"; - -interface CodingSessionState { - sandboxId: string; - baseUrl: string; - sessionId: string; - events: AgentEvent[]; -} - -interface CodingSessionVars { - client: SandboxAgentClient; -} - -const daytona = new Daytona(); - -const codingSession = actor({ - createState: async (): Promise => { - const sandbox = await daytona.create({ - snapshot: "sandbox-agent-ready", - envVars: { - ANTHROPIC_API_KEY: process.env.ANTHROPIC_API_KEY, - OPENAI_API_KEY: process.env.OPENAI_API_KEY, - }, - autoStopInterval: 0, - }); - - await sandbox.process.executeCommand( - "nohup sandbox-agent server --no-token --host 0.0.0.0 --port 3000 &" - ); - - const baseUrl = (await sandbox.getSignedPreviewUrl(3000)).url; - const sessionId = crypto.randomUUID(); - - return { - sandboxId: sandbox.id, - baseUrl, - sessionId, - events: [], - }; - }, - - createVars: async (c): Promise => { - const client = new SandboxAgentClient({ - baseUrl: c.state.baseUrl, - agent: "mock", -}); - await client.createSession(c.state.sessionId, { agent: "claude" }); - return { client }; - }, - - onDestroy: async (c) => { - const sandbox = await daytona.get(c.state.sandboxId); - await sandbox.delete(); - }, - - run: async (c) => { - for await (const event of c.vars.client.streamEvents(c.state.sessionId)) { - c.state.events.push(event); - c.broadcast("agentEvent", event); - } - }, - - actions: { - postMessage: async (c, message: string) => { - await c.vars.client.postMessage(c.state.sessionId, message); - }, - - getTranscript: (c) => c.state.events, - }, -}); -``` - -#### Client - - - -```typescript TypeScript -import { createClient } from "rivetkit/client"; - -const client = createClient(); -const session = client.codingSession.getOrCreate(["my-session"]); - -const conn = session.connect(); -conn.on("agentEvent", (event) => { - console.log(event.type, event.data); -}); - -await conn.postMessage("Create a new React component for user profiles"); - -const transcript = await conn.getTranscript(); -``` - -```typescript React -import { createRivetKit } from "@rivetkit/react"; - -const { useActor } = createRivetKit(); - -function CodingSession() { - const [messages, setMessages] = useState([]); - const session = useActor({ name: "codingSession", key: ["my-session"] }); - - session.useEvent("agentEvent", (event) => { - setMessages((prev) => [...prev, event]); - }); - - const sendPrompt = async (prompt: string) => { - await session.connection?.postMessage(prompt); - }; - - return ( -
- {messages.map((msg, i) => ( -
{JSON.stringify(msg)}
- ))} - -
- ); -} -``` - -
- -### PostgreSQL - -```sql -CREATE TABLE agent_events ( - event_id TEXT PRIMARY KEY, - session_id TEXT NOT NULL, - native_session_id TEXT, - sequence INTEGER NOT NULL, - time TIMESTAMPTZ NOT NULL, - type TEXT NOT NULL, - source TEXT NOT NULL, - synthetic BOOLEAN NOT NULL DEFAULT FALSE, - data JSONB NOT NULL, - UNIQUE(session_id, sequence) -); - -CREATE INDEX idx_events_session ON agent_events(session_id, sequence); -``` - -### Redis - -```typescript -// Append event to list -await redis.rpush(`session:${sessionId}`, JSON.stringify(event)); - -// Get events from offset -const events = await redis.lrange(`session:${sessionId}`, offset, -1); -``` - -## Handling disconnects - -The SSE stream may disconnect due to network issues. Handle reconnection gracefully: - -```typescript -async function streamWithRetry(sessionId: string) { - while (true) { - try { - const lastEvent = await db.getLastEvent(sessionId); - const offset = lastEvent?.sequence ?? 0; - - for await (const event of client.streamEvents(sessionId, { offset })) { - await db.insertEvent(sessionId, event); - } - } catch (error) { - console.error("Stream disconnected, reconnecting...", error); - await sleep(1000); - } - } -} -``` diff --git a/docs/mcp-config.mdx b/docs/mcp-config.mdx index 0536868..71e8105 100644 --- a/docs/mcp-config.mdx +++ b/docs/mcp-config.mdx @@ -5,119 +5,80 @@ sidebarTitle: "MCP" icon: "plug" --- -MCP (Model Context Protocol) servers extend agents with tools. Sandbox Agent can auto-load MCP servers when a session starts by passing an `mcp` map in the create-session request. +MCP (Model Context Protocol) servers extend agents with tools and external context. -## Session Config +## Configuring MCP servers -The `mcp` field is a map of server name to config. Use `type: "local"` for stdio servers and `type: "remote"` for HTTP/SSE servers: +The HTTP config endpoints let you store/retrieve MCP server configs by directory + name. - - -```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; - -const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); - -await client.createSession("claude-mcp", { - agent: "claude", - mcp: { - filesystem: { - type: "local", - command: "my-mcp-server", - args: ["--root", "."], - }, - github: { - type: "remote", - url: "https://example.com/mcp", - headers: { - Authorization: "Bearer ${GITHUB_TOKEN}", - }, - }, +```ts +// Create MCP config +await sdk.setMcpConfig( + { + directory: "/workspace", + mcpName: "github", }, + { + type: "remote", + url: "https://example.com/mcp", + }, +); + +// Create a session using the configured MCP servers +const session = await sdk.createSession({ + agent: "claude", + sessionInit: { + cwd: "/workspace", + }, +}); + +await session.prompt([ + { type: "text", text: "Use available MCP servers to help with this task." }, +]); + +// List MCP configs +const config = await sdk.getMcpConfig({ + directory: "/workspace", + mcpName: "github", +}); + +console.log(config.type); + +// Delete MCP config +await sdk.deleteMcpConfig({ + directory: "/workspace", + mcpName: "github", }); ``` -```bash cURL -curl -X POST "http://127.0.0.1:2468/v1/sessions/claude-mcp" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "agent": "claude", - "mcp": { - "filesystem": { - "type": "local", - "command": "my-mcp-server", - "args": ["--root", "."] - }, - "github": { - "type": "remote", - "url": "https://example.com/mcp", - "headers": { - "Authorization": "Bearer ${GITHUB_TOKEN}" - } - } - } - }' -``` +## Config fields - - -## Config Fields - -### Local Server - -Stdio servers that run inside the sandbox. +### Local server | Field | Description | |---|---| | `type` | `local` | -| `command` | string or array (`["node", "server.js"]`) | -| `args` | array of string arguments | -| `env` | environment variables map | -| `enabled` | enable or disable the server | -| `timeoutMs` | tool timeout override | -| `cwd` | working directory for the MCP process | +| `command` | executable path | +| `args` | array of CLI args | +| `env` | environment variable map | +| `cwd` | working directory | +| `enabled` | enable/disable server | +| `timeoutMs` | timeout override | -```json -{ - "type": "local", - "command": ["node", "./mcp/server.js"], - "args": ["--root", "."], - "env": { "LOG_LEVEL": "debug" }, - "cwd": "/workspace" -} -``` - -### Remote Server - -HTTP/SSE servers accessed over the network. +### Remote server | Field | Description | |---|---| | `type` | `remote` | | `url` | MCP server URL | -| `headers` | static headers map | -| `bearerTokenEnvVar` | env var name to inject into `Authorization: Bearer ...` | -| `envHeaders` | map of header name to env var name | -| `oauth` | object with `clientId`, `clientSecret`, `scope`, or `false` to disable | -| `enabled` | enable or disable the server | -| `timeoutMs` | tool timeout override | | `transport` | `http` or `sse` | +| `headers` | static headers map | +| `bearerTokenEnvVar` | env var name to inject in auth header | +| `envHeaders` | header name to env var map | +| `oauth` | optional OAuth config object | +| `enabled` | enable/disable server | +| `timeoutMs` | timeout override | -```json -{ - "type": "remote", - "url": "https://example.com/mcp", - "headers": { "x-client": "sandbox-agent" }, - "bearerTokenEnvVar": "MCP_TOKEN", - "transport": "sse" -} -``` - -## Custom MCP Servers +## Custom MCP servers To bundle and upload your own MCP server into the sandbox, see [Custom Tools](/custom-tools). diff --git a/docs/multiplayer.mdx b/docs/multiplayer.mdx new file mode 100644 index 0000000..4f405ea --- /dev/null +++ b/docs/multiplayer.mdx @@ -0,0 +1,115 @@ +--- +title: "Multiplayer" +description: "Use Rivet Actors to coordinate shared sessions." +icon: "users" +--- + +For multiplayer orchestration, use [Rivet Actors](https://rivet.dev/docs/actors). + +Recommended model: + +- One actor per collaborative workspace/thread. +- The actor owns Sandbox Agent session lifecycle and persistence. +- Clients connect to the actor and receive realtime broadcasts. + +Use [actor keys](https://rivet.dev/docs/actors/keys) to map each workspace to one actor, [events](https://rivet.dev/docs/actors/events) for realtime updates, and [lifecycle hooks](https://rivet.dev/docs/actors/lifecycle) for cleanup. + +## Example + + + +```ts Actor (server) +import { actor, setup } from "rivetkit"; +import { SandboxAgent } from "sandbox-agent"; +import { RivetSessionPersistDriver, type RivetPersistState } from "@sandbox-agent/persist-rivet"; + +type WorkspaceState = RivetPersistState & { + sandboxId: string; + baseUrl: string; +}; + +export const workspace = actor({ + createState: async () => { + return { + sandboxId: "sbx_123", + baseUrl: "http://127.0.0.1:2468", + } satisfies Partial; + }, + + createVars: async (c) => { + const persist = new RivetSessionPersistDriver(c); + const sdk = await SandboxAgent.connect({ + baseUrl: c.state.baseUrl, + persist, + }); + + const session = await sdk.resumeOrCreateSession({ id: "default", agent: "codex" }); + + const unsubscribe = session.onEvent((event) => { + c.broadcast("session.event", event); + }); + + return { sdk, session, unsubscribe }; + }, + + actions: { + getSessionInfo: (c) => ({ + workspaceId: c.key[0], + sandboxId: c.state.sandboxId, + }), + + prompt: async (c, input: { userId: string; text: string }) => { + c.broadcast("chat.user", { + userId: input.userId, + text: input.text, + createdAt: Date.now(), + }); + + await c.vars.session.prompt([{ type: "text", text: input.text }]); + }, + }, + + onSleep: async (c) => { + c.vars.unsubscribe?.(); + await c.vars.sdk.dispose(); + }, +}); + +export const registry = setup({ + use: { workspace }, +}); +``` + +```ts Client (browser) +import { createClient } from "rivetkit/client"; +import type { registry } from "./actors"; + +const client = createClient({ + endpoint: process.env.NEXT_PUBLIC_RIVET_ENDPOINT!, +}); + +const workspaceId = "workspace-42"; +const room = client.workspace.getOrCreate([workspaceId]); +const conn = room.connect(); + +conn.on("chat.user", (event) => { + console.log("user message", event); +}); + +conn.on("session.event", (event) => { + console.log("sandbox event", event); +}); + +await conn.prompt({ + userId: "user-123", + text: "Propose a refactor plan for auth middleware.", +}); +``` + + + +## Notes + +- Keep sandbox calls actor-only. Browser clients should not call Sandbox Agent directly. +- Use `@sandbox-agent/persist-rivet` so session history persists in actor state. +- For client connection patterns, see [Rivet JavaScript client](https://rivet.dev/docs/clients/javascript). diff --git a/docs/observability.mdx b/docs/observability.mdx new file mode 100644 index 0000000..770fe8b --- /dev/null +++ b/docs/observability.mdx @@ -0,0 +1,64 @@ +--- +title: "Observability" +description: "Track session activity with OpenTelemetry." +icon: "terminal" +--- + +Use OpenTelemetry to instrument session traffic, then ship telemetry to your collector/backend. + +## Common collectors and backends + +- [OpenTelemetry Collector](https://opentelemetry.io/docs/collector/) +- [Jaeger](https://www.jaegertracing.io/) +- [Grafana Tempo](https://grafana.com/oss/tempo/) +- [Honeycomb](https://www.honeycomb.io/) +- [Datadog APM](https://docs.datadoghq.com/tracing/) + +## Example: trace a prompt round-trip + +Wrap `session.prompt()` in a span to measure the full round-trip, then log individual events as span events. + +Assumes your OTEL provider/exporter is already configured. + +```ts +import { trace } from "@opentelemetry/api"; +import { SandboxAgent } from "sandbox-agent"; + +const tracer = trace.getTracer("my-app/sandbox-agent"); + +const sdk = await SandboxAgent.connect({ + baseUrl: process.env.SANDBOX_URL!, +}); + +const session = await sdk.createSession({ agent: "mock" }); + +// Log each event as an OTEL span event on the active span +const unsubscribe = session.onEvent((event) => { + const activeSpan = trace.getActiveSpan(); + if (!activeSpan) return; + + activeSpan.addEvent("session.event", { + "sandbox.sender": event.sender, + "sandbox.event_index": event.eventIndex, + }); +}); + +// The span covers the full prompt round-trip +await tracer.startActiveSpan("sandbox_agent.prompt", async (span) => { + span.setAttribute("sandbox.session_id", session.id); + + try { + const result = await session.prompt([ + { type: "text", text: "Summarize this repository." }, + ]); + span.setAttribute("sandbox.stop_reason", result.stopReason); + } catch (error) { + span.recordException(error as Error); + throw error; + } finally { + span.end(); + } +}); + +unsubscribe(); +``` diff --git a/docs/openapi.json b/docs/openapi.json index 6fc7ae0..9d4c18b 100644 --- a/docs/openapi.json +++ b/docs/openapi.json @@ -18,173 +18,43 @@ } ], "paths": { - "/v2/fs/file": { + "/v1/acp": { "get": { "tags": [ - "v2" + "v1" ], - "operationId": "get_v2_fs_file", + "operationId": "get_v1_acp_servers", + "responses": { + "200": { + "description": "Active ACP server instances", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AcpServerListResponse" + } + } + } + } + } + } + }, + "/v1/acp/{server_id}": { + "get": { + "tags": [ + "v1" + ], + "operationId": "get_v1_acp", "parameters": [ { - "name": "path", - "in": "query", - "description": "File path", + "name": "server_id", + "in": "path", + "description": "Client-defined ACP server id", "required": true, "schema": { "type": "string" } - }, - { - "name": "session_id", - "in": "query", - "description": "Session id for relative path base", - "required": false, - "schema": { - "type": "string", - "nullable": true - } } ], - "responses": { - "200": { - "description": "File content" - } - } - }, - "put": { - "tags": [ - "v2" - ], - "operationId": "put_v2_fs_file", - "parameters": [ - { - "name": "path", - "in": "query", - "description": "File path", - "required": true, - "schema": { - "type": "string" - } - }, - { - "name": "session_id", - "in": "query", - "description": "Session id for relative path base", - "required": false, - "schema": { - "type": "string", - "nullable": true - } - } - ], - "requestBody": { - "description": "Raw file bytes", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Write result", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FsWriteResponse" - } - } - } - } - } - } - }, - "/v2/fs/upload-batch": { - "post": { - "tags": [ - "v2" - ], - "operationId": "post_v2_fs_upload_batch", - "parameters": [ - { - "name": "path", - "in": "query", - "description": "Destination path", - "required": false, - "schema": { - "type": "string", - "nullable": true - } - }, - { - "name": "session_id", - "in": "query", - "description": "Session id for relative path base", - "required": false, - "schema": { - "type": "string", - "nullable": true - } - } - ], - "requestBody": { - "description": "tar archive body", - "content": { - "text/plain": { - "schema": { - "type": "string" - } - } - }, - "required": true - }, - "responses": { - "200": { - "description": "Upload/extract result", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/FsUploadBatchResponse" - } - } - } - } - } - } - }, - "/v2/health": { - "get": { - "tags": [ - "v2" - ], - "summary": "v2 Health", - "description": "Returns server health for the v2 ACP surface.", - "operationId": "get_v2_health", - "responses": { - "200": { - "description": "Service health response", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/HealthResponse" - } - } - } - } - } - } - }, - "/v2/rpc": { - "get": { - "tags": [ - "v2" - ], - "summary": "ACP SSE", - "description": "Streams ACP JSON-RPC envelopes for an ACP client over SSE.", - "operationId": "get_v2_acp", "responses": { "200": { "description": "SSE stream of ACP envelopes" @@ -200,7 +70,7 @@ } }, "404": { - "description": "Unknown ACP client", + "description": "Unknown ACP server", "content": { "application/json": { "schema": { @@ -218,26 +88,35 @@ } } } - }, - "409": { - "description": "ACP client already has an active SSE stream", - "content": { - "application/json": { - "schema": { - "$ref": "#/components/schemas/ProblemDetails" - } - } - } } } }, "post": { "tags": [ - "v2" + "v1" + ], + "operationId": "post_v1_acp", + "parameters": [ + { + "name": "server_id", + "in": "path", + "description": "Client-defined ACP server id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "agent", + "in": "query", + "description": "Agent id required for first POST", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } ], - "summary": "ACP POST", - "description": "Sends ACP JSON-RPC envelopes to an ACP client and returns request responses.", - "operationId": "post_v2_acp", "requestBody": { "content": { "application/json": { @@ -273,7 +152,7 @@ } }, "404": { - "description": "Unknown ACP client", + "description": "Unknown ACP server", "content": { "application/json": { "schema": { @@ -292,6 +171,16 @@ } } }, + "409": { + "description": "ACP server bound to different agent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, "415": { "description": "Unsupported media type", "content": { @@ -316,14 +205,187 @@ }, "delete": { "tags": [ - "v2" + "v1" + ], + "operationId": "delete_v1_acp", + "parameters": [ + { + "name": "server_id", + "in": "path", + "description": "Client-defined ACP server id", + "required": true, + "schema": { + "type": "string" + } + } ], - "summary": "ACP Close", - "description": "Closes an ACP client and releases agent process resources.", - "operationId": "delete_v2_acp", "responses": { "204": { - "description": "ACP client closed" + "description": "ACP server closed" + } + } + } + }, + "/v1/agents": { + "get": { + "tags": [ + "v1" + ], + "operationId": "get_v1_agents", + "parameters": [ + { + "name": "config", + "in": "query", + "description": "When true, include version/path/configOptions (slower)", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } + }, + { + "name": "no_cache", + "in": "query", + "description": "When true, bypass version cache", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "List of v1 agents", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentListResponse" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/agents/{agent}": { + "get": { + "tags": [ + "v1" + ], + "operationId": "get_v1_agent", + "parameters": [ + { + "name": "agent", + "in": "path", + "description": "Agent id", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "config", + "in": "query", + "description": "When true, include version/path/configOptions (slower)", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } + }, + { + "name": "no_cache", + "in": "query", + "description": "When true, bypass version cache", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Agent info", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentInfo" + } + } + } + }, + "400": { + "description": "Unknown agent", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + }, + "401": { + "description": "Authentication required", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + } + }, + "/v1/agents/{agent}/install": { + "post": { + "tags": [ + "v1" + ], + "operationId": "post_v1_agent_install", + "parameters": [ + { + "name": "agent", + "in": "path", + "description": "Agent id", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentInstallRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Agent install result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/AgentInstallResponse" + } + } + } }, "400": { "description": "Invalid request", @@ -335,8 +397,8 @@ } } }, - "404": { - "description": "Unknown ACP client", + "500": { + "description": "Install failed", "content": { "application/json": { "schema": { @@ -347,6 +409,545 @@ } } } + }, + "/v1/config/mcp": { + "get": { + "tags": [ + "v1" + ], + "operationId": "get_v1_config_mcp", + "parameters": [ + { + "name": "directory", + "in": "query", + "description": "Target directory", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "mcpName", + "in": "query", + "description": "MCP entry name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "MCP entry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpServerConfig" + } + } + } + }, + "404": { + "description": "Entry not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "put": { + "tags": [ + "v1" + ], + "operationId": "put_v1_config_mcp", + "parameters": [ + { + "name": "directory", + "in": "query", + "description": "Target directory", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "mcpName", + "in": "query", + "description": "MCP entry name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/McpServerConfig" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Stored" + } + } + }, + "delete": { + "tags": [ + "v1" + ], + "operationId": "delete_v1_config_mcp", + "parameters": [ + { + "name": "directory", + "in": "query", + "description": "Target directory", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "mcpName", + "in": "query", + "description": "MCP entry name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + } + } + } + }, + "/v1/config/skills": { + "get": { + "tags": [ + "v1" + ], + "operationId": "get_v1_config_skills", + "parameters": [ + { + "name": "directory", + "in": "query", + "description": "Target directory", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "skillName", + "in": "query", + "description": "Skill entry name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Skills entry", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillsConfig" + } + } + } + }, + "404": { + "description": "Entry not found", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ProblemDetails" + } + } + } + } + } + }, + "put": { + "tags": [ + "v1" + ], + "operationId": "put_v1_config_skills", + "parameters": [ + { + "name": "directory", + "in": "query", + "description": "Target directory", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "skillName", + "in": "query", + "description": "Skill entry name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/SkillsConfig" + } + } + }, + "required": true + }, + "responses": { + "204": { + "description": "Stored" + } + } + }, + "delete": { + "tags": [ + "v1" + ], + "operationId": "delete_v1_config_skills", + "parameters": [ + { + "name": "directory", + "in": "query", + "description": "Target directory", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "skillName", + "in": "query", + "description": "Skill entry name", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "204": { + "description": "Deleted" + } + } + } + }, + "/v1/fs/entries": { + "get": { + "tags": [ + "v1" + ], + "operationId": "get_v1_fs_entries", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "Directory path", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Directory entries", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "$ref": "#/components/schemas/FsEntry" + } + } + } + } + } + } + } + }, + "/v1/fs/entry": { + "delete": { + "tags": [ + "v1" + ], + "operationId": "delete_v1_fs_entry", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "File or directory path", + "required": true, + "schema": { + "type": "string" + } + }, + { + "name": "recursive", + "in": "query", + "description": "Delete directory recursively", + "required": false, + "schema": { + "type": "boolean", + "nullable": true + } + } + ], + "responses": { + "200": { + "description": "Delete result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsActionResponse" + } + } + } + } + } + } + }, + "/v1/fs/file": { + "get": { + "tags": [ + "v1" + ], + "operationId": "get_v1_fs_file", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "File path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "File content" + } + } + }, + "put": { + "tags": [ + "v1" + ], + "operationId": "put_v1_fs_file", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "File path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "requestBody": { + "description": "Raw file bytes", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Write result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsWriteResponse" + } + } + } + } + } + } + }, + "/v1/fs/mkdir": { + "post": { + "tags": [ + "v1" + ], + "operationId": "post_v1_fs_mkdir", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "Directory path", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Directory created", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsActionResponse" + } + } + } + } + } + } + }, + "/v1/fs/move": { + "post": { + "tags": [ + "v1" + ], + "operationId": "post_v1_fs_move", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsMoveRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Move result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsMoveResponse" + } + } + } + } + } + } + }, + "/v1/fs/stat": { + "get": { + "tags": [ + "v1" + ], + "operationId": "get_v1_fs_stat", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "Path to stat", + "required": true, + "schema": { + "type": "string" + } + } + ], + "responses": { + "200": { + "description": "Path metadata", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsStat" + } + } + } + } + } + } + }, + "/v1/fs/upload-batch": { + "post": { + "tags": [ + "v1" + ], + "operationId": "post_v1_fs_upload_batch", + "parameters": [ + { + "name": "path", + "in": "query", + "description": "Destination path", + "required": false, + "schema": { + "type": "string", + "nullable": true + } + } + ], + "requestBody": { + "description": "tar archive body", + "content": { + "text/plain": { + "schema": { + "type": "string" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Upload/extract result", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/FsUploadBatchResponse" + } + } + } + } + } + } + }, + "/v1/health": { + "get": { + "tags": [ + "v1" + ], + "operationId": "get_v1_health", + "responses": { + "200": { + "description": "Service health response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HealthResponse" + } + } + } + } + } + } } }, "components": { @@ -378,6 +979,49 @@ } } }, + "AcpPostQuery": { + "type": "object", + "properties": { + "agent": { + "type": "string", + "nullable": true + } + } + }, + "AcpServerInfo": { + "type": "object", + "required": [ + "serverId", + "agent", + "createdAtMs" + ], + "properties": { + "agent": { + "type": "string" + }, + "createdAtMs": { + "type": "integer", + "format": "int64" + }, + "serverId": { + "type": "string" + } + } + }, + "AcpServerListResponse": { + "type": "object", + "required": [ + "servers" + ], + "properties": { + "servers": { + "type": "array", + "items": { + "$ref": "#/components/schemas/AcpServerInfo" + } + } + } + }, "AgentCapabilities": { "type": "object", "required": [ @@ -469,33 +1113,24 @@ "capabilities": { "$ref": "#/components/schemas/AgentCapabilities" }, - "credentialsAvailable": { - "type": "boolean" - }, - "defaultModel": { + "configError": { "type": "string", "nullable": true }, + "configOptions": { + "type": "array", + "items": {}, + "nullable": true + }, + "credentialsAvailable": { + "type": "boolean" + }, "id": { "type": "string" }, "installed": { "type": "boolean" }, - "models": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AgentModelInfo" - }, - "nullable": true - }, - "modes": { - "type": "array", - "items": { - "$ref": "#/components/schemas/AgentModeInfo" - }, - "nullable": true - }, "path": { "type": "string", "nullable": true @@ -586,51 +1221,6 @@ } } }, - "AgentModeInfo": { - "type": "object", - "required": [ - "id", - "name", - "description" - ], - "properties": { - "description": { - "type": "string" - }, - "id": { - "type": "string" - }, - "name": { - "type": "string" - } - } - }, - "AgentModelInfo": { - "type": "object", - "required": [ - "id" - ], - "properties": { - "defaultVariant": { - "type": "string", - "nullable": true - }, - "id": { - "type": "string" - }, - "name": { - "type": "string", - "nullable": true - }, - "variants": { - "type": "array", - "items": { - "type": "string" - }, - "nullable": true - } - } - }, "ErrorType": { "type": "string", "enum": [ @@ -674,10 +1264,6 @@ "recursive": { "type": "boolean", "nullable": true - }, - "sessionId": { - "type": "string", - "nullable": true } } }, @@ -687,10 +1273,6 @@ "path": { "type": "string", "nullable": true - }, - "sessionId": { - "type": "string", - "nullable": true } } }, @@ -772,19 +1354,6 @@ "properties": { "path": { "type": "string" - }, - "sessionId": { - "type": "string", - "nullable": true - } - } - }, - "FsSessionQuery": { - "type": "object", - "properties": { - "sessionId": { - "type": "string", - "nullable": true } } }, @@ -819,10 +1388,6 @@ "path": { "type": "string", "nullable": true - }, - "sessionId": { - "type": "string", - "nullable": true } } }, @@ -872,6 +1437,135 @@ } } }, + "McpConfigQuery": { + "type": "object", + "required": [ + "directory", + "mcpName" + ], + "properties": { + "directory": { + "type": "string" + }, + "mcpName": { + "type": "string" + } + } + }, + "McpServerConfig": { + "oneOf": [ + { + "type": "object", + "required": [ + "command", + "type" + ], + "properties": { + "args": { + "type": "array", + "items": { + "type": "string" + } + }, + "command": { + "$ref": "#/components/schemas/McpCommand" + }, + "cwd": { + "type": "string", + "nullable": true + }, + "enabled": { + "type": "boolean", + "nullable": true + }, + "env": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "timeoutMs": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "type": { + "type": "string", + "enum": [ + "local" + ] + } + } + }, + { + "type": "object", + "required": [ + "url", + "type" + ], + "properties": { + "bearerTokenEnvVar": { + "type": "string", + "nullable": true + }, + "enabled": { + "type": "boolean", + "nullable": true + }, + "envHeaders": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "headers": { + "type": "object", + "additionalProperties": { + "type": "string" + }, + "nullable": true + }, + "oauth": { + "allOf": [ + { + "$ref": "#/components/schemas/McpOAuthConfigOrDisabled" + } + ], + "nullable": true + }, + "timeoutMs": { + "type": "integer", + "format": "int64", + "nullable": true, + "minimum": 0 + }, + "transport": { + "allOf": [ + { + "$ref": "#/components/schemas/McpRemoteTransport" + } + ], + "nullable": true + }, + "type": { + "type": "string", + "enum": [ + "remote" + ] + }, + "url": { + "type": "string" + } + } + } + ], + "discriminator": { + "propertyName": "type" + } + }, "ProblemDetails": { "type": "object", "required": [ @@ -906,30 +1600,15 @@ "type": "string", "enum": [ "running", - "stopped", - "error" + "stopped" ] }, "ServerStatusInfo": { "type": "object", "required": [ - "status", - "restartCount" + "status" ], "properties": { - "baseUrl": { - "type": "string", - "nullable": true - }, - "lastError": { - "type": "string", - "nullable": true - }, - "restartCount": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, "status": { "$ref": "#/components/schemas/ServerStatus" }, @@ -941,139 +1620,61 @@ } } }, - "SessionInfo": { + "SkillSource": { "type": "object", "required": [ - "sessionId", - "agent", - "agentMode", - "permissionMode", - "ended", - "eventCount", - "createdAt", - "updatedAt" + "type", + "source" ], "properties": { - "agent": { - "type": "string" - }, - "agentMode": { - "type": "string" - }, - "createdAt": { - "type": "integer", - "format": "int64" - }, - "directory": { + "ref": { "type": "string", "nullable": true }, - "ended": { - "type": "boolean" - }, - "eventCount": { - "type": "integer", - "format": "int64", - "minimum": 0 - }, - "model": { - "type": "string", + "skills": { + "type": "array", + "items": { + "type": "string" + }, "nullable": true }, - "nativeSessionId": { - "type": "string", - "nullable": true - }, - "permissionMode": { + "source": { "type": "string" }, - "sessionId": { - "type": "string" - }, - "terminationInfo": { - "allOf": [ - { - "$ref": "#/components/schemas/TerminationInfo" - } - ], - "nullable": true - }, - "title": { + "subpath": { "type": "string", "nullable": true }, - "updatedAt": { - "type": "integer", - "format": "int64" + "type": { + "type": "string" } } }, - "SessionListResponse": { + "SkillsConfig": { "type": "object", "required": [ - "sessions" + "sources" ], "properties": { - "sessions": { + "sources": { "type": "array", "items": { - "$ref": "#/components/schemas/SessionInfo" + "$ref": "#/components/schemas/SkillSource" } } } }, - "StderrOutput": { + "SkillsConfigQuery": { "type": "object", "required": [ - "truncated" + "directory", + "skillName" ], "properties": { - "head": { - "type": "string", - "nullable": true - }, - "tail": { - "type": "string", - "nullable": true - }, - "totalLines": { - "type": "integer", - "nullable": true, - "minimum": 0 - }, - "truncated": { - "type": "boolean" - } - } - }, - "TerminationInfo": { - "type": "object", - "required": [ - "reason", - "terminatedBy" - ], - "properties": { - "exitCode": { - "type": "integer", - "format": "int32", - "nullable": true - }, - "message": { - "type": "string", - "nullable": true - }, - "reason": { + "directory": { "type": "string" }, - "stderr": { - "allOf": [ - { - "$ref": "#/components/schemas/StderrOutput" - } - ], - "nullable": true - }, - "terminatedBy": { + "skillName": { "type": "string" } } @@ -1082,8 +1683,8 @@ }, "tags": [ { - "name": "v2", - "description": "ACP-native v2 API" + "name": "v1", + "description": "ACP proxy v1 API" } ] } \ No newline at end of file diff --git a/docs/opencode-compatibility.mdx b/docs/opencode-compatibility.mdx index 7242639..ac766b4 100644 --- a/docs/opencode-compatibility.mdx +++ b/docs/opencode-compatibility.mdx @@ -1,26 +1,125 @@ --- title: "OpenCode Compatibility" -description: "Status of the OpenCode bridge during ACP v2 migration." +description: "Connect OpenCode clients, SDKs, and web UI to Sandbox Agent." --- -OpenCode compatibility is intentionally deferred during ACP core migration. + + **Experimental**: OpenCode SDK/UI compatibility may change. + -## Current status (v2 core phases) +Sandbox Agent exposes an OpenCode-compatible API at `/opencode`. -- `/opencode/*` routes are disabled. -- `sandbox-agent opencode` returns an explicit disabled error. -- This is expected while ACP runtime, SDK, and inspector migration is completed. +## Why use OpenCode clients with Sandbox Agent? -## Planned re-enable step +- OpenCode CLI (`opencode attach`) +- OpenCode web UI +- OpenCode TypeScript SDK (`@opencode-ai/sdk`) -OpenCode support is restored in a dedicated phase after ACP core is stable: +## Quick start -1. Reintroduce `/opencode/*` routing on top of ACP internals. -2. Add dedicated OpenCode ↔ ACP integration tests. -3. Re-enable OpenCode docs and operational guidance. +### OpenCode CLI / TUI -Track details in: +```bash +sandbox-agent opencode --port 2468 --no-token +``` -- `research/acp/spec.md` -- `research/acp/migration-steps.md` -- `research/acp/todo.md` +Or start server + attach manually: + +```bash +sandbox-agent server --no-token --host 127.0.0.1 --port 2468 +opencode attach http://localhost:2468/opencode +``` + +With authentication enabled: + +```bash +sandbox-agent server --token "$SANDBOX_TOKEN" --host 127.0.0.1 --port 2468 +opencode attach http://localhost:2468/opencode --password "$SANDBOX_TOKEN" +``` + +### OpenCode web UI + + + + ```bash + sandbox-agent server --no-token --host 127.0.0.1 --port 2468 --cors-allow-origin http://127.0.0.1:5173 + ``` + + + ```bash + git clone https://github.com/anomalyco/opencode + cd opencode/packages/app + export VITE_OPENCODE_SERVER_HOST=127.0.0.1 + export VITE_OPENCODE_SERVER_PORT=2468 + bun install + bun run dev -- --host 127.0.0.1 --port 5173 + ``` + + + Visit `http://127.0.0.1:5173/`. + + + +### OpenCode SDK + +```typescript +import { createOpencodeClient } from "@opencode-ai/sdk"; + +const client = createOpencodeClient({ + baseUrl: "http://localhost:2468/opencode", +}); + +const session = await client.session.create(); + +await client.session.promptAsync({ + path: { id: session.data.id }, + body: { + parts: [{ type: "text", text: "Hello, write a hello world script" }], + }, +}); + +const events = await client.event.subscribe({}); +for await (const event of events.stream) { + console.log(event); +} +``` + +## Notes + +- API base path: `/opencode` +- If server auth is enabled, pass bearer auth (or `--password` in OpenCode CLI) +- For browser UIs, configure CORS with `--cors-allow-origin` +- Provider selector currently exposes compatible providers (`mock`, `amp`, `claude`, `codex`) +- Provider/model metadata for compatibility endpoints is normalized and may differ from native OpenCode grouping +- Optional proxy: set `OPENCODE_COMPAT_PROXY_URL` to forward selected endpoints to native OpenCode + +## Endpoint coverage + + + +| Endpoint | Status | Notes | +|---|---|---| +| `GET /event` | ✓ | Session/message updates (SSE) | +| `GET /global/event` | ✓ | GlobalEvent-wrapped stream | +| `GET /session` | ✓ | Session list | +| `POST /session` | ✓ | Create session | +| `GET /session/{id}` | ✓ | Session details | +| `POST /session/{id}/message` | ✓ | Send message | +| `GET /session/{id}/message` | ✓ | Session messages | +| `GET /permission` | ✓ | Pending permissions | +| `POST /permission/{id}/reply` | ✓ | Permission reply | +| `GET /question` | ✓ | Pending questions | +| `POST /question/{id}/reply` | ✓ | Question reply | +| `GET /provider` | ✓ | Provider metadata | +| `GET /command` | ↔ | Proxied when `OPENCODE_COMPAT_PROXY_URL` is set; otherwise stub | +| `GET /config` | ↔ | Proxied when set; otherwise stub | +| `PATCH /config` | ↔ | Proxied when set; otherwise local compatibility behavior | +| `GET /global/config` | ↔ | Proxied when set; otherwise stub | +| `PATCH /global/config` | ↔ | Proxied when set; otherwise local compatibility behavior | +| `/tui/*` | ↔ | Proxied when set; otherwise local compatibility behavior | +| `GET /agent` | − | Agent list | +| *other endpoints* | − | Empty/stub responses | + +✓ Functional ↔ Proxied optional − Stubbed + + diff --git a/docs/quickstart.mdx b/docs/quickstart.mdx index 64cb467..37eacb9 100644 --- a/docs/quickstart.mdx +++ b/docs/quickstart.mdx @@ -68,14 +68,14 @@ icon: "rocket" - - - Use `sandbox-agent credentials extract-env --export` to extract your existing API keys (Anthropic, OpenAI, etc.) from your existing Claude Code or Codex config files on your machine. - - - If you want to test Sandbox Agent without API keys, use the `mock` agent to test the SDK without any credentials. It simulates agent responses for development and testing. - - + + + Use `sandbox-agent credentials extract-env --export` to extract your existing API keys (Anthropic, OpenAI, etc.) from local Claude Code or Codex config files. + + + Use the `mock` agent for SDK and integration testing without provider credentials. + + @@ -84,7 +84,7 @@ icon: "rocket" Install and run the binary directly. ```bash - curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh + curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh sandbox-agent server --no-token --host 0.0.0.0 --port 2468 ``` @@ -93,7 +93,7 @@ icon: "rocket" Run without installing globally. ```bash - npx @sandbox-agent/cli server --no-token --host 0.0.0.0 --port 2468 + npx @sandbox-agent/cli@0.2.x server --no-token --host 0.0.0.0 --port 2468 ``` @@ -101,7 +101,7 @@ icon: "rocket" Run without installing globally. ```bash - bunx @sandbox-agent/cli server --no-token --host 0.0.0.0 --port 2468 + bunx @sandbox-agent/cli@0.2.x server --no-token --host 0.0.0.0 --port 2468 ``` @@ -109,7 +109,7 @@ icon: "rocket" Install globally, then run. ```bash - npm install -g @sandbox-agent/cli + npm install -g @sandbox-agent/cli@0.2.x sandbox-agent server --no-token --host 0.0.0.0 --port 2468 ``` @@ -118,33 +118,32 @@ icon: "rocket" Install globally, then run. ```bash - bun add -g @sandbox-agent/cli + bun add -g @sandbox-agent/cli@0.2.x # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). bun pm -g trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 sandbox-agent server --no-token --host 0.0.0.0 --port 2468 ``` - - For local development, use `SandboxAgent.start()` to automatically spawn and manage the server as a subprocess. + For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess. ```bash - npm install sandbox-agent + npm install sandbox-agent@0.2.x ``` ```typescript import { SandboxAgent } from "sandbox-agent"; - const client = await SandboxAgent.start(); + const sdk = await SandboxAgent.start(); ``` - For local development, use `SandboxAgent.start()` to automatically spawn and manage the server as a subprocess. + For local development, use `SandboxAgent.start()` to spawn and manage the server as a subprocess. ```bash - bun add sandbox-agent + bun add sandbox-agent@0.2.x # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 ``` @@ -152,10 +151,8 @@ icon: "rocket" ```typescript import { SandboxAgent } from "sandbox-agent"; - const client = await SandboxAgent.start(); + const sdk = await SandboxAgent.start(); ``` - - This installs the binary and starts the server for you. No manual setup required. @@ -167,53 +164,51 @@ icon: "rocket" - Binding to `0.0.0.0` allows the server to accept connections from any network interface, which is required when running inside a sandbox where clients connect remotely. + Binding to `0.0.0.0` allows the server to accept connections from any network interface, which is required when running inside a sandbox where clients connect remotely. - - - Tokens are usually not required. Most sandbox providers (E2B, Daytona, etc.) already secure their networking at the infrastructure level, so the server endpoint is never publicly accessible. For local development, binding to `127.0.0.1` ensures only local connections are accepted. + + + Tokens are usually not required. Most sandbox providers (E2B, Daytona, etc.) already secure networking at the infrastructure layer. - If you need to expose the server on a public endpoint, use `--token "$SANDBOX_TOKEN"` to require authentication on all requests: + If you expose the server publicly, use `--token "$SANDBOX_TOKEN"` to require authentication: - ```bash - sandbox-agent server --token "$SANDBOX_TOKEN" --host 0.0.0.0 --port 2468 - ``` + ```bash + sandbox-agent server --token "$SANDBOX_TOKEN" --host 0.0.0.0 --port 2468 + ``` - Then pass the token when connecting: + Then pass the token when connecting: - - - ```typescript - import { SandboxAgentClient } from "sandbox-agent"; + + + ```typescript + import { SandboxAgent } from "sandbox-agent"; - const client = new SandboxAgentClient({ - baseUrl: "http://your-server:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); - ``` - + const sdk = await SandboxAgent.connect({ + baseUrl: "http://your-server:2468", + token: process.env.SANDBOX_TOKEN, + }); + ``` + - - ```bash - curl "http://your-server:2468/v1/sessions" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" - ``` - + + ```bash + curl "http://your-server:2468/v1/health" \ + -H "Authorization: Bearer $SANDBOX_TOKEN" + ``` + - - ```bash - sandbox-agent api sessions list \ - --endpoint http://your-server:2468 \ - --token "$SANDBOX_TOKEN" - ``` - - - - - If you're calling the server from a browser, see the [CORS configuration guide](/docs/cors). - - + + ```bash + sandbox-agent --token "$SANDBOX_TOKEN" api agents list \ + --endpoint http://your-server:2468 + ``` + + + + + If you're calling the server from a browser, see the [CORS configuration guide](/cors). + + @@ -226,124 +221,57 @@ icon: "rocket" sandbox-agent install-agent amp ``` - If agents are not installed up front, they will be lazily installed when creating a session. It's recommended to pre-install agents then take a snapshot of the sandbox for faster coldstarts. + If agents are not installed up front, they are lazily installed when creating a session. - - - ```typescript - import { SandboxAgentClient } from "sandbox-agent"; + ```typescript + import { SandboxAgent } from "sandbox-agent"; - const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - agent: "claude", - }); + const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + }); - await client.createSession("my-session", { - agent: "claude", - agentMode: "build", - permissionMode: "default", - }); - ``` - + const session = await sdk.createSession({ + agent: "claude", + sessionInit: { + cwd: "/", + mcpServers: [], + }, + }); - - ```bash - curl -X POST "http://127.0.0.1:2468/v1/sessions/my-session" \ - -H "Content-Type: application/json" \ - -d '{"agent":"claude","agentMode":"build","permissionMode":"default"}' - ``` - - - - ```bash - sandbox-agent api sessions create my-session \ - --agent claude \ - --endpoint http://127.0.0.1:2468 - ``` - - + console.log(session.id); + ``` - - - ```typescript - await client.postMessage("my-session", { - message: "Summarize the repository and suggest next steps.", - }); - ``` - + ```typescript + const result = await session.prompt([ + { type: "text", text: "Summarize the repository and suggest next steps." }, + ]); - - ```bash - curl -X POST "http://127.0.0.1:2468/v1/sessions/my-session/messages" \ - -H "Content-Type: application/json" \ - -d '{"message":"Summarize the repository and suggest next steps."}' - ``` - - - - ```bash - sandbox-agent api sessions send-message my-session \ - --message "Summarize the repository and suggest next steps." \ - --endpoint http://127.0.0.1:2468 - ``` - - + console.log(result.stopReason); + ``` - - - ```typescript - // Poll for events - const events = await client.getEvents("my-session", { offset: 0, limit: 50 }); + ```typescript + const off = session.onEvent((event) => { + console.log(event.sender, event.payload); + }); - // Or stream events - for await (const event of client.streamEvents("my-session", { offset: 0 })) { - console.log(event.type, event.data); - } - ``` - + const page = await sdk.getEvents({ + sessionId: session.id, + limit: 50, + }); - - ```bash - # Poll for events - curl "http://127.0.0.1:2468/v1/sessions/my-session/events?offset=0&limit=50" - - # Stream events via SSE - curl "http://127.0.0.1:2468/v1/sessions/my-session/events/sse?offset=0" - - # Single-turn stream (post message and get streamed response) - curl -N -X POST "http://127.0.0.1:2468/v1/sessions/my-session/messages/stream" \ - -H "Content-Type: application/json" \ - -d '{"message":"Hello"}' - ``` - - - - ```bash - # Poll for events - sandbox-agent api sessions events my-session \ - --endpoint http://127.0.0.1:2468 - - # Stream events via SSE - sandbox-agent api sessions events-sse my-session \ - --endpoint http://127.0.0.1:2468 - - # Single-turn stream - sandbox-agent api sessions send-message-stream my-session \ - --message "Hello" \ - --endpoint http://127.0.0.1:2468 - ``` - - + console.log(page.items.length); + off(); + ``` - Open the Inspector UI at `/ui/` on your server (e.g., `http://localhost:2468/ui/`) to inspect session state using a GUI. + Open the Inspector UI at `/ui/` on your server (for example, `http://localhost:2468/ui/`) to inspect sessions and events in a GUI. Sandbox Agent Inspector @@ -354,13 +282,13 @@ icon: "rocket" ## Next steps - - Learn how to build a chat interface for your agent. + + Configure in-memory, Rivet Actor state, IndexedDB, SQLite, and Postgres persistence. - - Persist and replay agent transcripts. + + Deploy your agent to E2B, Daytona, Docker, Vercel, or Cloudflare. - - Deploy your agent to E2B, Daytona, or Vercel Sandboxes. + + Use the latest TypeScript SDK API. diff --git a/docs/sdk-overview.mdx b/docs/sdk-overview.mdx new file mode 100644 index 0000000..8a973a2 --- /dev/null +++ b/docs/sdk-overview.mdx @@ -0,0 +1,174 @@ +--- +title: "SDK Overview" +description: "Use the TypeScript SDK to manage Sandbox Agent sessions and APIs." +icon: "compass" +--- + +The TypeScript SDK is centered on `sandbox-agent` and its `SandboxAgent` class. + +## Install + + + + ```bash + npm install sandbox-agent@0.2.x + ``` + + + ```bash + bun add sandbox-agent@0.2.x + # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). + bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 + ``` + + + +## Optional persistence drivers + +```bash +npm install @sandbox-agent/persist-indexeddb@0.2.x @sandbox-agent/persist-sqlite@0.2.x @sandbox-agent/persist-postgres@0.2.x +``` + +## Create a client + +```ts +import { SandboxAgent } from "sandbox-agent"; + +const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", +}); +``` + +With persistence: + +```ts +import { SandboxAgent } from "sandbox-agent"; +import { SQLiteSessionPersistDriver } from "@sandbox-agent/persist-sqlite"; + +const persist = new SQLiteSessionPersistDriver({ + filename: "./sessions.db", +}); + +const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + persist, +}); +``` + +Local autospawn (Node.js only): + +```ts +import { SandboxAgent } from "sandbox-agent"; + +const localSdk = await SandboxAgent.start(); + +await localSdk.dispose(); +``` + +## Session flow + +```ts +const session = await sdk.createSession({ + agent: "mock", + sessionInit: { + cwd: "/", + mcpServers: [], + }, +}); + +const prompt = await session.prompt([ + { type: "text", text: "Summarize this repository." }, +]); + +console.log(prompt.stopReason); +``` + +Load and destroy: + +```ts +const restored = await sdk.resumeSession(session.id); +await restored.prompt([{ type: "text", text: "Continue from previous context." }]); + +await sdk.destroySession(restored.id); +``` + +## Events + +Subscribe to live events: + +```ts +const unsubscribe = session.onEvent((event) => { + console.log(event.eventIndex, event.sender, event.payload); +}); + +await session.prompt([{ type: "text", text: "Give me a short summary." }]); +unsubscribe(); +``` + +Fetch persisted events: + +```ts +const page = await sdk.getEvents({ + sessionId: session.id, + limit: 100, +}); + +console.log(page.items.length); +``` + +## Control-plane and HTTP helpers + +```ts +const health = await sdk.getHealth(); +const agents = await sdk.listAgents(); +await sdk.installAgent("codex", { reinstall: true }); + +const entries = await sdk.listFsEntries({ path: "." }); +const writeResult = await sdk.writeFsFile({ path: "./hello.txt" }, "hello"); + +console.log(health.status, agents.agents.length, entries.length, writeResult.path); +``` + +## Error handling + +```ts +import { SandboxAgentError } from "sandbox-agent"; + +try { + await sdk.listAgents(); +} catch (error) { + if (error instanceof SandboxAgentError) { + console.error(error.status, error.problem); + } +} +``` + +## Inspector URL + +```ts +import { buildInspectorUrl } from "sandbox-agent"; + +const url = buildInspectorUrl({ + baseUrl: "https://your-sandbox-agent.example.com", + headers: { "X-Custom-Header": "value" }, +}); + +console.log(url); +``` + +Parameters: + +- `baseUrl` (required): Sandbox Agent server URL +- `token` (optional): Bearer token for authenticated servers +- `headers` (optional): Additional request headers + +## Types + +```ts +import type { + AgentInfo, + HealthResponse, + SessionEvent, + SessionRecord, +} from "sandbox-agent"; +``` diff --git a/docs/sdks/python.mdx b/docs/sdks/python.mdx deleted file mode 100644 index 80f667a..0000000 --- a/docs/sdks/python.mdx +++ /dev/null @@ -1,41 +0,0 @@ ---- -title: "Python" -description: "Python client for managing sessions and streaming events." -icon: "python" -tag: "Coming Soon" ---- - -The Python SDK is on our roadmap. It will provide a typed client for managing sessions and streaming events, similar to the TypeScript SDK. - -In the meantime, you can use the [HTTP API](/http-api) directly with any HTTP client like `requests` or `httpx`. - -```python -import httpx - -base_url = "http://127.0.0.1:2468" -headers = {"Authorization": f"Bearer {token}"} - -# Create a session -httpx.post( - f"{base_url}/v1/sessions/my-session", - headers=headers, - json={"agent": "claude", "permissionMode": "default"} -) - -# Send a message -httpx.post( - f"{base_url}/v1/sessions/my-session/messages", - headers=headers, - json={"message": "Hello from Python"} -) - -# Get events -response = httpx.get( - f"{base_url}/v1/sessions/my-session/events", - headers=headers, - params={"offset": 0, "limit": 50} -) -events = response.json()["events"] -``` - -Want the Python SDK sooner? [Open an issue](https://github.com/rivet-dev/sandbox-agent/issues) to let us know. diff --git a/docs/sdks/typescript.mdx b/docs/sdks/typescript.mdx deleted file mode 100644 index 9c74214..0000000 --- a/docs/sdks/typescript.mdx +++ /dev/null @@ -1,267 +0,0 @@ ---- -title: "TypeScript" -description: "Use the TypeScript SDK to manage ACP sessions and Sandbox Agent HTTP APIs." -icon: "js" ---- - -The TypeScript SDK is centered on `sandbox-agent` and its `SandboxAgentClient`, which provides a Sandbox-facing API for session flows, ACP extensions, and binary HTTP filesystem helpers. - -## Install - - - - ```bash - npm install sandbox-agent - ``` - - - ```bash - bun add sandbox-agent - # Allow Bun to run postinstall scripts for native binaries (required for SandboxAgent.start()). - bun pm trust @sandbox-agent/cli-linux-x64 @sandbox-agent/cli-darwin-arm64 @sandbox-agent/cli-darwin-x64 @sandbox-agent/cli-win32-x64 - ``` - - - -## Create a client - -```ts -import { SandboxAgentClient } from "sandbox-agent"; - -const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", -}); -``` - -`SandboxAgentClient` is the canonical API. By default it auto-connects (`autoConnect: true`), so provide `agent` in the constructor. Use the instance method `client.connect()` only when you explicitly set `autoConnect: false`. - -## Autospawn (Node only) - -If you run locally, the SDK can launch the server for you. - -```ts -import { SandboxAgent } from "sandbox-agent"; - -const client = await SandboxAgent.start({ - agent: "mock", -}); - -await client.dispose(); -``` - -Autospawn uses the local `sandbox-agent` binary. Install `@sandbox-agent/cli` (recommended) or set -`SANDBOX_AGENT_BIN` to a custom path. - -## Connect lifecycle - -Use manual mode when you want explicit ACP session lifecycle control. - -```ts -import { - AlreadyConnectedError, - NotConnectedError, - SandboxAgentClient, -} from "sandbox-agent"; - -const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - autoConnect: false, -}); - -await client.connect(); - -try { - await client.connect(); -} catch (error) { - if (error instanceof AlreadyConnectedError) { - console.error("already connected"); - } -} - -await client.disconnect(); - -try { - await client.prompt({ sessionId: "s", prompt: [{ type: "text", text: "hi" }] }); -} catch (error) { - if (error instanceof NotConnectedError) { - console.error("connect first"); - } -} -``` - -## Session flow - -```ts -const session = await client.newSession({ - cwd: "/", - mcpServers: [], - metadata: { - agent: "mock", - title: "Demo Session", - variant: "high", - permissionMode: "ask", - }, -}); - -const result = await client.prompt({ - sessionId: session.sessionId, - prompt: [{ type: "text", text: "Summarize this repository." }], -}); - -console.log(result.stopReason); -``` - -Load, cancel, and runtime settings use ACP-aligned method names: - -```ts -await client.loadSession({ sessionId: session.sessionId, cwd: "/", mcpServers: [] }); -await client.cancel({ sessionId: session.sessionId }); -await client.setSessionMode({ sessionId: session.sessionId, modeId: "default" }); -await client.setSessionConfigOption({ - sessionId: session.sessionId, - configId: "config-id-from-session", - value: "config-value-id", -}); -``` - -## Extension helpers - -Sandbox extensions are exposed as first-class methods: - -```ts -const models = await client.listModels({ sessionId: session.sessionId }); -console.log(models.currentModelId, models.availableModels.length); - -await client.setMetadata(session.sessionId, { - title: "Renamed Session", - model: "mock", - permissionMode: "ask", -}); - -await client.detachSession(session.sessionId); -await client.terminateSession(session.sessionId); -``` - -## Event handling - -Use `onEvent` to consume converted SDK events. - -```ts -import { SandboxAgentClient, type AgentEvent } from "sandbox-agent"; - -const events: AgentEvent[] = []; - -const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - onEvent: (event) => { - events.push(event); - - if (event.type === "sessionEnded") { - console.log("ended", event.notification.params.sessionId ?? event.notification.params.session_id); - } - - if (event.type === "agentUnparsed") { - console.warn("unparsed", event.notification.params); - } - }, -}); -``` - -You can also handle raw session update notifications directly: - -```ts -const client = new SandboxAgentClient({ - baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - onSessionUpdate: (notification) => { - console.log(notification.update.sessionUpdate); - }, -}); -``` - -## Control + HTTP helpers - -Agent/session and non-binary filesystem control helpers use ACP extension methods over `/v2/rpc`: - -```ts -const health = await client.getHealth(); -const agents = await client.listAgents(); -await client.installAgent("codex", { reinstall: true }); - -const sessions = await client.listSessions(); -const sessionInfo = await client.getSession(sessions.sessions[0].session_id); -``` - -These methods require an active ACP connection and throw `NotConnectedError` when disconnected. - -Binary filesystem transfer intentionally remains HTTP: - -- `readFsFile` -> `GET /v2/fs/file` -- `writeFsFile` -> `PUT /v2/fs/file` -- `uploadFsBatch` -> `POST /v2/fs/upload-batch` - -Reason: these are Sandbox Agent host/runtime filesystem operations (not agent-specific ACP behavior), intentionally separate from ACP native `fs/read_text_file` / `fs/write_text_file`, and they may require streaming very large binary payloads that ACP JSON-RPC is not suited to transport efficiently. - -ACP extension variants can exist in parallel for compatibility, but `SandboxAgentClient` should prefer the HTTP endpoints above by default. - -## Error handling - -All HTTP errors throw `SandboxAgentError`: - -```ts -import { SandboxAgentError } from "sandbox-agent"; - -try { - await client.listAgents(); -} catch (error) { - if (error instanceof SandboxAgentError) { - console.error(error.status, error.problem); - } -} -``` - -## Inspector URL - -Build a URL to open the sandbox-agent Inspector UI with pre-filled connection settings: - -```ts -import { buildInspectorUrl } from "sandbox-agent"; - -const url = buildInspectorUrl({ - baseUrl: "https://your-sandbox-agent.example.com", - token: "optional-bearer-token", - headers: { "X-Custom-Header": "value" }, -}); - -console.log(url); -// https://your-sandbox-agent.example.com/ui/?token=...&headers=... -``` - -Parameters: -- `baseUrl` (required): The sandbox-agent server URL -- `token` (optional): Bearer token for authentication -- `headers` (optional): Extra headers to pass to the server (JSON-encoded in the URL) - -## Types - -The SDK exports typed events and responses for the Sandbox layer: - -```ts -import type { - AgentEvent, - AgentInfo, - HealthResponse, - SessionInfo, - SessionListResponse, - SessionTerminateResponse, -} from "sandbox-agent"; -``` - -For low-level protocol transport details, see [ACP HTTP Client](/advanced/acp-http-client). diff --git a/docs/security.mdx b/docs/security.mdx new file mode 100644 index 0000000..ec00f49 --- /dev/null +++ b/docs/security.mdx @@ -0,0 +1,191 @@ +--- +title: "Security" +description: "Backend-first auth and access control patterns." +icon: "shield" +--- + +As covered in [Architecture](/architecture), run the Sandbox Agent client on your backend, not in the browser. + +This keeps sandbox credentials private and gives you one place for authz, rate limiting, and audit logging. + +## Auth model + +Implement auth however it fits your stack (sessions, JWT, API keys, etc.), but enforce it before any sandbox-bound request. + +Minimum checks: + +- Authenticate the caller. +- Authorize access to the target workspace/sandbox/session. +- Apply request rate limits and request logging. + +## Examples + +### Rivet + + + +```ts Actor (server) +import { UserError, actor } from "rivetkit"; +import { SandboxAgent } from "sandbox-agent"; + +type ConnParams = { + accessToken: string; +}; + +type WorkspaceClaims = { + sub: string; + workspaceId: string; + role: "owner" | "member" | "viewer"; +}; + +async function verifyWorkspaceToken( + token: string, + workspaceId: string, +): Promise { + // Validate JWT/session token here, then enforce workspace scope. + // Return null when invalid/expired/not a member. + if (!token) return null; + return { sub: "user_123", workspaceId, role: "member" }; +} + +export const workspace = actor({ + state: { + events: [] as Array<{ userId: string; prompt: string; createdAt: number }>, + }, + + onBeforeConnect: async (c, params: ConnParams) => { + const claims = await verifyWorkspaceToken(params.accessToken, c.key[0]); + if (!claims) { + throw new UserError("Forbidden", { code: "forbidden" }); + } + }, + + createConnState: async (c, params: ConnParams) => { + const claims = await verifyWorkspaceToken(params.accessToken, c.key[0]); + if (!claims) { + throw new UserError("Forbidden", { code: "forbidden" }); + } + + return { + userId: claims.sub, + role: claims.role, + workspaceId: claims.workspaceId, + }; + }, + + actions: { + submitPrompt: async (c, prompt: string) => { + if (!c.conn) { + throw new UserError("Connection required", { code: "connection_required" }); + } + + if (c.conn.state.role === "viewer") { + throw new UserError("Insufficient permissions", { code: "forbidden" }); + } + + // Connect to Sandbox Agent from the actor (server-side only). + // Sandbox credentials never reach the client. + const sdk = await SandboxAgent.connect({ + baseUrl: process.env.SANDBOX_URL!, + token: process.env.SANDBOX_TOKEN, + }); + + const session = await sdk.createSession({ + agent: "claude", + sessionInit: { cwd: "/workspace" }, + }); + + session.onEvent((event) => { + c.broadcast("session.event", { + userId: c.conn!.state.userId, + eventIndex: event.eventIndex, + sender: event.sender, + payload: event.payload, + }); + }); + + const result = await session.prompt([ + { type: "text", text: prompt }, + ]); + + c.state.events.push({ + userId: c.conn.state.userId, + prompt, + createdAt: Date.now(), + }); + + return { stopReason: result.stopReason }; + }, + }, +}); +``` + +```ts Client (browser) +import { createClient } from "rivetkit/client"; +import type { registry } from "./actors"; + +const client = createClient({ + endpoint: process.env.NEXT_PUBLIC_RIVET_ENDPOINT!, +}); + +const handle = client.workspace.getOrCreate(["ws_123"], { + params: { accessToken: userJwt }, +}); + +const conn = handle.connect(); + +conn.on("session.event", (event) => { + console.log(event.sender, event.payload); +}); + +const result = await conn.submitPrompt("Plan a refactor for auth middleware."); +console.log(result.stopReason); +``` + + + +Use [onBeforeConnect](https://rivet.dev/docs/actors/authentication), [connection params](https://rivet.dev/docs/actors/connections), and [actor keys](https://rivet.dev/docs/actors/keys) together so each actor enforces auth per workspace. + +### Hono + +```ts +import { Hono } from "hono"; +import { bearerAuth } from "hono/bearer-auth"; + +const app = new Hono(); + +app.use("/sandbox/*", bearerAuth({ token: process.env.APP_API_TOKEN! })); + +app.all("/sandbox/*", async (c) => { + const incoming = new URL(c.req.url); + const upstreamUrl = new URL(process.env.SANDBOX_URL!); + upstreamUrl.pathname = incoming.pathname.replace(/^\/sandbox/, "/v1"); + upstreamUrl.search = incoming.search; + + const headers = new Headers(); + headers.set("authorization", `Bearer ${process.env.SANDBOX_TOKEN ?? ""}`); + + const accept = c.req.header("accept"); + if (accept) headers.set("accept", accept); + + const contentType = c.req.header("content-type"); + if (contentType) headers.set("content-type", contentType); + + const body = + c.req.method === "POST" || c.req.method === "PUT" || c.req.method === "PATCH" + ? await c.req.text() + : undefined; + + const upstream = await fetch(upstreamUrl, { + method: c.req.method, + headers, + body, + }); + + return new Response(upstream.body, { + status: upstream.status, + headers: upstream.headers, + }); +}); +``` + diff --git a/docs/session-persistence.mdx b/docs/session-persistence.mdx new file mode 100644 index 0000000..43bbcef --- /dev/null +++ b/docs/session-persistence.mdx @@ -0,0 +1,183 @@ +--- +title: "Persisting Sessions" +description: "Choose and configure session persistence for the TypeScript SDK." +icon: "database" +--- + +The TypeScript SDK uses a `SessionPersistDriver` to store session records and event history. +If you do not provide one, the SDK uses in-memory storage. +With persistence enabled, sessions can be restored after runtime/session loss. See [Session Restoration](/session-restoration). + +Each driver stores: + +- `SessionRecord` (`id`, `agent`, `agentSessionId`, `lastConnectionId`, `createdAt`, optional `destroyedAt`, optional `sessionInit`) +- `SessionEvent` (`id`, `eventIndex`, `sessionId`, `connectionId`, `sender`, `payload`, `createdAt`) + +## Persistence drivers + +### In-memory + +Best for local dev and ephemeral workloads. + +```ts +import { InMemorySessionPersistDriver, SandboxAgent } from "sandbox-agent"; + +const persist = new InMemorySessionPersistDriver({ + maxSessions: 1024, + maxEventsPerSession: 500, +}); + +const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + persist, +}); +``` + +### Rivet + +Recommended for sandbox orchestration with actor state. + +```bash +npm install @sandbox-agent/persist-rivet@0.1.x +``` + +```ts +import { actor } from "rivetkit"; +import { SandboxAgent } from "sandbox-agent"; +import { RivetSessionPersistDriver, type RivetPersistState } from "@sandbox-agent/persist-rivet"; + +type PersistedState = RivetPersistState & { + sandboxId: string; + baseUrl: string; +}; + +export default actor({ + createState: async () => { + return { + sandboxId: "sbx_123", + baseUrl: "http://127.0.0.1:2468", + } satisfies Partial; + }, + createVars: async (c) => { + const persist = new RivetSessionPersistDriver(c); + const sdk = await SandboxAgent.connect({ + baseUrl: c.state.baseUrl, + persist, + }); + + const session = await sdk.resumeOrCreateSession({ id: "default", agent: "codex" }); + + const unsubscribe = session.onEvent((event) => { + c.broadcast("session.event", event); + }); + + return { sdk, session, unsubscribe }; + }, + actions: { + sendMessage: async (c, message: string) => { + await c.vars.session.prompt([{ type: "text", text: message }]); + }, + }, + onSleep: async (c) => { + c.vars.unsubscribe?.(); + await c.vars.sdk.dispose(); + }, +}); +``` + +### IndexedDB + +Best for browser apps that should survive reloads. + +```bash +npm install @sandbox-agent/persist-indexeddb@0.2.x +``` + +```ts +import { SandboxAgent } from "sandbox-agent"; +import { IndexedDbSessionPersistDriver } from "@sandbox-agent/persist-indexeddb"; + +const persist = new IndexedDbSessionPersistDriver({ + databaseName: "sandbox-agent-session-store", +}); + +const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + persist, +}); +``` + +### SQLite + +Best for local/server Node apps that need durable storage without a DB server. + +```bash +npm install @sandbox-agent/persist-sqlite@0.2.x +``` + +```ts +import { SandboxAgent } from "sandbox-agent"; +import { SQLiteSessionPersistDriver } from "@sandbox-agent/persist-sqlite"; + +const persist = new SQLiteSessionPersistDriver({ + filename: "./sandbox-agent.db", +}); + +const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + persist, +}); +``` + +### Postgres + +Use when you already run Postgres and want shared relational storage. + +```bash +npm install @sandbox-agent/persist-postgres@0.2.x +``` + +```ts +import { SandboxAgent } from "sandbox-agent"; +import { PostgresSessionPersistDriver } from "@sandbox-agent/persist-postgres"; + +const persist = new PostgresSessionPersistDriver({ + connectionString: process.env.DATABASE_URL, + schema: "public", +}); + +const sdk = await SandboxAgent.connect({ + baseUrl: "http://127.0.0.1:2468", + persist, +}); +``` + +### Custom driver + +Implement `SessionPersistDriver` for custom backends. + +```ts +import type { SessionPersistDriver } from "sandbox-agent"; + +class MyDriver implements SessionPersistDriver { + async getSession(id) { return null; } + async listSessions(request) { return { items: [] }; } + async updateSession(session) {} + async listEvents(request) { return { items: [] }; } + async insertEvent(event) {} +} +``` + +## Replay controls + +`SandboxAgent.connect(...)` supports: + +- `replayMaxEvents` (default `50`) +- `replayMaxChars` (default `12000`) + +These cap replay size when restoring sessions. + +## Related docs + +- [SDK Overview](/sdk-overview) +- [Session Restoration](/session-restoration) diff --git a/docs/session-restoration.mdx b/docs/session-restoration.mdx new file mode 100644 index 0000000..9766633 --- /dev/null +++ b/docs/session-restoration.mdx @@ -0,0 +1,33 @@ +--- +title: "Session Restoration" +description: "How the TypeScript SDK restores sessions after connection/runtime loss." +--- + +Sandbox Agent automatically restores stale sessions when live session state is no longer available. + +This is driven by the configured `SessionPersistDriver` (`inMemory`, IndexedDB, SQLite, Postgres, or custom). + +## How Auto-Restore Works + +When you call `session.prompt(...)` (or `resumeSession(...)`) and the saved session points to a stale connection, the SDK: + +1. Recreates a fresh session for the same local session id. +2. Rebinds the local session to the new runtime session id. +3. Replays recent persisted events into the next prompt as context. + +This happens automatically; you do not need to manually rebuild the session. + +## Replay Limits + +Replay payload size is capped by: + +- `replayMaxEvents` (default `50`) +- `replayMaxChars` (default `12000`) + +These controls limit prompt growth during restore while preserving recent context. + +## Related Docs + +- [SDK Overview](/sdk-overview) +- [Persisting Sessions](/session-persistence) +- [Agent Sessions](/agent-sessions) diff --git a/docs/session-transcript-schema.mdx b/docs/session-transcript-schema.mdx deleted file mode 100644 index c9c004a..0000000 --- a/docs/session-transcript-schema.mdx +++ /dev/null @@ -1,388 +0,0 @@ ---- -title: "Session Transcript Schema" -description: "Universal event schema for session transcripts across all agents." ---- - -Each coding agent outputs events in its own native format. The sandbox-agent converts these into a universal event schema, giving you a consistent session transcript regardless of which agent you use. - -The schema is defined in [OpenAPI format](https://github.com/rivet-dev/sandbox-agent/blob/main/docs/openapi.json). See the [HTTP API Reference](/api-reference) for endpoint documentation. - -## Coverage Matrix - -This table shows which agent feature coverage appears in the universal event stream. All agents retain their full native feature coverage—this only reflects what's normalized into the schema. - -| Feature | Claude | Codex | OpenCode | Amp | Pi (RPC) | -|--------------------|:------:|:-----:|:------------:|:------------:|:------------:| -| Stability | Stable | Stable| Experimental | Experimental | Experimental | -| Text Messages | ✓ | ✓ | ✓ | ✓ | ✓ | -| Tool Calls | ✓ | ✓ | ✓ | ✓ | ✓ | -| Tool Results | ✓ | ✓ | ✓ | ✓ | ✓ | -| Questions (HITL) | ✓ | | ✓ | | | -| Permissions (HITL) | ✓ | ✓ | ✓ | - | | -| Images | - | ✓ | ✓ | - | ✓ | -| File Attachments | - | ✓ | ✓ | - | | -| Session Lifecycle | - | ✓ | ✓ | - | | -| Error Events | - | ✓ | ✓ | ✓ | ✓ | -| Reasoning/Thinking | - | ✓ | - | - | ✓ | -| Command Execution | - | ✓ | - | - | | -| File Changes | - | ✓ | - | - | | -| MCP Tools | ✓ | ✓ | ✓ | ✓ | | -| Streaming Deltas | ✓ | ✓ | ✓ | - | ✓ | -| Variants | | ✓ | ✓ | ✓ | ✓ | - -Agents: [Claude Code](https://docs.anthropic.com/en/docs/agents-and-tools/claude-code/overview) · [Codex](https://github.com/openai/codex) · [OpenCode](https://github.com/opencode-ai/opencode) · [Amp](https://ampcode.com) · [Pi](https://buildwithpi.ai/pi-cli) - -- ✓ = Appears in session events -- \- = Agent supports natively, schema conversion coming soon -- (blank) = Not supported by agent -- Pi runtime model is router-managed per-session RPC (`pi --mode rpc`); it does not use generic subprocess streaming. - - - - Basic message exchange between user and assistant. - - - Visibility into tool invocations (file reads, command execution, etc.) and their results. When not natively supported, tool activity is embedded in message content. - - - Interactive questions the agent asks the user. Emits `question.requested` and `question.resolved` events. - - - Permission requests for sensitive operations. Emits `permission.requested` and `permission.resolved` events. - - - Support for image attachments in messages. - - - Support for file attachments in messages. - - - Native `session.started` and `session.ended` events. When not supported, the daemon emits synthetic lifecycle events. - - - Structured error events for runtime failures. - - - Extended thinking or reasoning content with visibility controls. - - - Detailed command execution events with stdout/stderr. - - - Structured file modification events with diffs. - - - Model Context Protocol tool support. - - - Native streaming of content deltas. When not supported, the daemon emits a single synthetic delta before `item.completed`. - - - Model variants such as reasoning effort or depth. Agents may expose different variant sets per model. - - - -Want support for another agent? [Open an issue](https://github.com/rivet-dev/sandbox-agent/issues/new) to request it. - -## UniversalEvent - -Every event from the API is wrapped in a `UniversalEvent` envelope. - -| Field | Type | Description | -|-------|------|-------------| -| `event_id` | string | Unique identifier for this event | -| `sequence` | integer | Monotonic sequence number within the session (starts at 1) | -| `time` | string | RFC3339 timestamp | -| `session_id` | string | Daemon-generated session identifier | -| `native_session_id` | string? | Provider-native session/thread identifier (e.g., Codex `threadId`, OpenCode `sessionID`) | -| `source` | string | Event origin: `agent` (native) or `daemon` (synthetic) | -| `synthetic` | boolean | Whether this event was generated by the daemon to fill gaps | -| `type` | string | Event type (see [Event Types](#event-types)) | -| `data` | object | Event-specific payload | -| `raw` | any? | Original provider payload (only when `include_raw=true`) | - -```json -{ - "event_id": "evt_abc123", - "sequence": 1, - "time": "2025-01-28T12:00:00Z", - "session_id": "my-session", - "native_session_id": "thread_xyz", - "source": "agent", - "synthetic": false, - "type": "item.completed", - "data": { ... } -} -``` - -## Event Types - -### Session Lifecycle - -| Type | Description | Data | -|------|-------------|------| -| `session.started` | Session has started | `{ metadata?: any }` | -| `session.ended` | Session has ended | `{ reason, terminated_by, message?, exit_code? }` | - -### Turn Lifecycle - -| Type | Description | Data | -|------|-------------|------| -| `turn.started` | Turn has started | `{ phase: "started", turn_id?, metadata? }` | -| `turn.ended` | Turn has ended | `{ phase: "ended", turn_id?, metadata? }` | - -**SessionEndedData** - -| Field | Type | Values | -|-------|------|--------| -| `reason` | string | `completed`, `error`, `terminated` | -| `terminated_by` | string | `agent`, `daemon` | -| `message` | string? | Error message (only present when reason is `error`) | -| `exit_code` | int? | Process exit code (only present when reason is `error`) | -| `stderr` | StderrOutput? | Structured stderr output (only present when reason is `error`) | - -**StderrOutput** - -| Field | Type | Description | -|-------|------|-------------| -| `head` | string? | First 20 lines of stderr (if truncated) or full stderr (if not truncated) | -| `tail` | string? | Last 50 lines of stderr (only present if truncated) | -| `truncated` | boolean | Whether the output was truncated | -| `total_lines` | int? | Total number of lines in stderr | - -### Item Lifecycle - -| Type | Description | Data | -|------|-------------|------| -| `item.started` | Item creation | `{ item }` | -| `item.delta` | Streaming content delta | `{ item_id, native_item_id?, delta }` | -| `item.completed` | Item finalized | `{ item }` | - -Items follow a consistent lifecycle: `item.started` → `item.delta` (0 or more) → `item.completed`. - -### HITL (Human-in-the-Loop) - -| Type | Description | Data | -|------|-------------|------| -| `permission.requested` | Permission request pending | `{ permission_id, action, status, metadata? }` | -| `permission.resolved` | Permission decision recorded | `{ permission_id, action, status, metadata? }` | -| `question.requested` | Question pending user input | `{ question_id, prompt, options, status }` | -| `question.resolved` | Question answered or rejected | `{ question_id, prompt, options, status, response? }` | - -**PermissionEventData** - -| Field | Type | Description | -|-------|------|-------------| -| `permission_id` | string | Identifier for the permission request | -| `action` | string | What the agent wants to do | -| `status` | string | `requested`, `accept`, `accept_for_session`, `reject` | -| `metadata` | any? | Additional context | - -**QuestionEventData** - -| Field | Type | Description | -|-------|------|-------------| -| `question_id` | string | Identifier for the question | -| `prompt` | string | Question text | -| `options` | string[] | Available answer options | -| `status` | string | `requested`, `answered`, `rejected` | -| `response` | string? | Selected answer (when resolved) | - -### Errors - -| Type | Description | Data | -|------|-------------|------| -| `error` | Runtime error | `{ message, code?, details? }` | -| `agent.unparsed` | Parse failure | `{ error, location, raw_hash? }` | - -The `agent.unparsed` event indicates the daemon failed to parse an agent payload. This should be treated as a bug. - -## UniversalItem - -Items represent discrete units of content within a session. - -| Field | Type | Description | -|-------|------|-------------| -| `item_id` | string | Daemon-generated identifier | -| `native_item_id` | string? | Provider-native item/message identifier | -| `parent_id` | string? | Parent item ID (e.g., tool call/result parented to a message) | -| `kind` | string | Item category (see below) | -| `role` | string? | Actor role for message items | -| `status` | string | Lifecycle status | -| `content` | ContentPart[] | Ordered list of content parts | - -### ItemKind - -| Value | Description | -|-------|-------------| -| `message` | User or assistant message | -| `tool_call` | Tool invocation | -| `tool_result` | Tool execution result | -| `system` | System message | -| `status` | Status update | -| `unknown` | Unrecognized item type | - -### ItemRole - -| Value | Description | -|-------|-------------| -| `user` | User message | -| `assistant` | Assistant response | -| `system` | System prompt | -| `tool` | Tool-related message | - -### ItemStatus - -| Value | Description | -|-------|-------------| -| `in_progress` | Item is streaming or pending | -| `completed` | Item is finalized | -| `failed` | Item execution failed | - -## Content Parts - -The `content` array contains typed parts that make up an item's payload. - -### text - -Plain text content. - -```json -{ "type": "text", "text": "Hello, world!" } -``` - -### json - -Structured JSON content. - -```json -{ "type": "json", "json": { "key": "value" } } -``` - -### tool_call - -Tool invocation. - -| Field | Type | Description | -|-------|------|-------------| -| `name` | string | Tool name | -| `arguments` | string | JSON-encoded arguments | -| `call_id` | string | Unique call identifier | - -```json -{ - "type": "tool_call", - "name": "read_file", - "arguments": "{\"path\": \"/src/main.ts\"}", - "call_id": "call_abc123" -} -``` - -### tool_result - -Tool execution result. - -| Field | Type | Description | -|-------|------|-------------| -| `call_id` | string | Matching call identifier | -| `output` | string | Tool output | - -```json -{ - "type": "tool_result", - "call_id": "call_abc123", - "output": "File contents here..." -} -``` - -### file_ref - -File reference with optional diff. - -| Field | Type | Description | -|-------|------|-------------| -| `path` | string | File path | -| `action` | string | `read`, `write`, `patch` | -| `diff` | string? | Unified diff (for patches) | - -```json -{ - "type": "file_ref", - "path": "/src/main.ts", - "action": "write", - "diff": "@@ -1,3 +1,4 @@\n+import { foo } from 'bar';" -} -``` - -### image - -Image reference. - -| Field | Type | Description | -|-------|------|-------------| -| `path` | string | Image file path | -| `mime` | string? | MIME type | - -```json -{ "type": "image", "path": "/tmp/screenshot.png", "mime": "image/png" } -``` - -### reasoning - -Model reasoning/thinking content. - -| Field | Type | Description | -|-------|------|-------------| -| `text` | string | Reasoning text | -| `visibility` | string | `public` or `private` | - -```json -{ "type": "reasoning", "text": "Let me think about this...", "visibility": "public" } -``` - -### status - -Status indicator. - -| Field | Type | Description | -|-------|------|-------------| -| `label` | string | Status label | -| `detail` | string? | Additional detail | - -```json -{ "type": "status", "label": "Running tests", "detail": "3 of 10 passed" } -``` - -## Source & Synthetics - -### EventSource - -The `source` field indicates who emitted the event: - -| Value | Description | -|-------|-------------| -| `agent` | Native event from the agent | -| `daemon` | Synthetic event generated by the daemon | - -### Synthetic Events - -The daemon emits synthetic events (`synthetic: true`, `source: "daemon"`) to provide a consistent event stream across all agents. Common synthetics: - -| Synthetic | When | -|-----------|------| -| `session.started` | Agent doesn't emit explicit session start | -| `session.ended` | Agent doesn't emit explicit session end | -| `turn.started` | Agent doesn't emit explicit turn start | -| `turn.ended` | Agent doesn't emit explicit turn end | -| `item.started` | Agent doesn't emit item start events | -| `item.delta` | Agent doesn't stream deltas natively | -| `question.*` | Claude Code plan mode (from ExitPlanMode tool) | - -### Raw Payloads - -Pass `include_raw=true` to event endpoints to receive the original agent payload in the `raw` field. Useful for debugging or accessing agent-specific data not in the universal schema. - -```typescript -const events = await client.getEvents("my-session", { includeRaw: true }); -// events[0].raw contains the original agent payload -``` diff --git a/docs/skills-config.mdx b/docs/skills-config.mdx index ae87142..c85bc2c 100644 --- a/docs/skills-config.mdx +++ b/docs/skills-config.mdx @@ -1,88 +1,81 @@ --- title: "Skills" -description: "Auto-load skills into agent sessions." +description: "Configure skill sources for agent sessions." sidebarTitle: "Skills" icon: "sparkles" --- -Skills are local instruction bundles stored in `SKILL.md` files. Sandbox Agent can fetch, discover, and link skill directories into agent-specific skill paths at session start using the `skills.sources` field. The format is fully compatible with [skills.sh](https://skills.sh). +Skills are local instruction bundles stored in `SKILL.md` files. -## Session Config +## Configuring skills -Pass `skills.sources` when creating a session to load skills from GitHub repos, local paths, or git URLs. +Use `setSkillsConfig` / `getSkillsConfig` / `deleteSkillsConfig` to manage skill source config by directory + skill name. - +```ts +import { SandboxAgent } from "sandbox-agent"; -```ts TypeScript -import { SandboxAgentClient } from "sandbox-agent"; - -const client = new SandboxAgentClient({ +const sdk = await SandboxAgent.connect({ baseUrl: "http://127.0.0.1:2468", - token: process.env.SANDBOX_TOKEN, - agent: "mock", - }); +}); -await client.createSession("claude-skills", { - agent: "claude", - skills: { +// Add a skill +await sdk.setSkillsConfig( + { + directory: "/workspace", + skillName: "default", + }, + { sources: [ { type: "github", source: "rivet-dev/skills", skills: ["sandbox-agent"] }, { type: "local", source: "/workspace/my-custom-skill" }, ], }, +); + +// Create a session using the configured skills +const session = await sdk.createSession({ + agent: "claude", + sessionInit: { + cwd: "/workspace", + }, }); + +await session.prompt([ + { type: "text", text: "Use available skills to help with this task." }, +]); + +// List skills +const config = await sdk.getSkillsConfig({ + directory: "/workspace", + skillName: "default", +}); + +console.log(config.sources.length); + +// Delete skill +await sdk.deleteSkillsConfig({ + directory: "/workspace", + skillName: "default", +}); + ``` -```bash cURL -curl -X POST "http://127.0.0.1:2468/v1/sessions/claude-skills" \ - -H "Authorization: Bearer $SANDBOX_TOKEN" \ - -H "Content-Type: application/json" \ - -d '{ - "agent": "claude", - "skills": { - "sources": [ - { "type": "github", "source": "rivet-dev/skills", "skills": ["sandbox-agent"] }, - { "type": "local", "source": "/workspace/my-custom-skill" } - ] - } - }' -``` +## Skill sources - - -Each skill directory must contain `SKILL.md`. See [Skill authoring best practices](https://platform.claude.com/docs/en/agents-and-tools/agent-skills/best-practices) for tips on writing effective skills. - -## Skill Sources - -Each entry in `skills.sources` describes where to find skills. Three source types are supported: +Each `skills.sources` entry describes where to find skills. | Type | `source` value | Example | |------|---------------|---------| | `github` | `owner/repo` | `"rivet-dev/skills"` | -| `local` | Filesystem path | `"/workspace/my-skill"` | -| `git` | Git clone URL | `"https://git.example.com/skills.git"` | +| `local` | filesystem path | `"/workspace/my-skill"` | +| `git` | git clone URL | `"https://git.example.com/skills.git"` | -### Optional fields +Optional fields: -- **`skills`** — Array of skill directory names to include. When omitted, all discovered skills are installed. -- **`ref`** — Branch, tag, or commit to check out (default: HEAD). Applies to `github` and `git` types. -- **`subpath`** — Subdirectory within the repo to search for skills. +- `skills`: subset of skill directory names to include +- `ref`: branch/tag/commit (for `github` and `git`) +- `subpath`: subdirectory within repo to scan -## Custom Skills +## Custom skills To write, upload, and configure your own skills inside the sandbox, see [Custom Tools](/custom-tools). - -## Advanced - -### Discovery logic - -After resolving a source to a local directory (cloning if needed), Sandbox Agent discovers skills by: -1. Checking if the directory itself contains `SKILL.md`. -2. Scanning `skills/` subdirectory for child directories containing `SKILL.md`. -3. Scanning immediate children of the directory for `SKILL.md`. - -Discovered skills are symlinked into project-local skill roots (`.claude/skills/`, `.agents/skills/`, `.opencode/skill/`). - -### Caching - -GitHub sources are downloaded as zip archives and git sources are cloned to `~/.sandbox-agent/skills-cache/` and updated on subsequent session creations. GitHub sources do not require `git` to be installed. diff --git a/examples/CLAUDE.md b/examples/CLAUDE.md index 048312f..cdb5515 100644 --- a/examples/CLAUDE.md +++ b/examples/CLAUDE.md @@ -6,16 +6,16 @@ - Do not bind mount host files or host directories into Docker example containers. - If an example needs tools, skills, or MCP servers, install them inside the container during setup. -## Testing Examples (ACP v2) +## Testing Examples (ACP v1) -Examples should be validated against v2 endpoints: +Examples should be validated against v1 endpoints: 1. Start the example: `SANDBOX_AGENT_DEV=1 pnpm start` -2. Create an ACP client by POSTing `initialize` to `/v2/rpc` with `x-acp-agent: mock` (or another installed agent). -3. Capture `x-acp-connection-id` from the response headers. -4. Open SSE stream: `GET /v2/rpc` with `x-acp-connection-id`. -5. Send `session/new` then `session/prompt` via `POST /v2/rpc` with the same connection id. -6. Close connection via `DELETE /v2/rpc` with `x-acp-connection-id`. +2. Pick a server id, for example `example-smoke`. +3. Create ACP transport by POSTing `initialize` to `/v1/acp/example-smoke?agent=mock` (or another installed agent). +4. Open SSE stream: `GET /v1/acp/example-smoke`. +5. Send `session/new` then `session/prompt` via `POST /v1/acp/example-smoke`. +6. Close connection via `DELETE /v1/acp/example-smoke`. v1 reminder: diff --git a/examples/cloudflare/Dockerfile b/examples/cloudflare/Dockerfile index 88b96f2..17ddb78 100644 --- a/examples/cloudflare/Dockerfile +++ b/examples/cloudflare/Dockerfile @@ -1,7 +1,7 @@ FROM cloudflare/sandbox:0.7.0 # Install sandbox-agent -RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh +RUN curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh # Pre-install agents RUN sandbox-agent install-agent claude && \ diff --git a/examples/daytona/src/daytona-with-snapshot.ts b/examples/daytona/src/daytona-with-snapshot.ts index e196065..df28971 100644 --- a/examples/daytona/src/daytona-with-snapshot.ts +++ b/examples/daytona/src/daytona-with-snapshot.ts @@ -1,6 +1,6 @@ import { Daytona, Image } from "@daytonaio/sdk"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; const daytona = new Daytona(); @@ -13,7 +13,7 @@ if (process.env.OPENAI_API_KEY) // Build a custom image with sandbox-agent pre-installed (slower first run, faster subsequent runs) const image = Image.base("ubuntu:22.04").runCommands( "apt-get update && apt-get install -y curl ca-certificates", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", ); console.log("Creating Daytona sandbox (first run builds the base image and may take a few minutes, subsequent runs are fast)..."); @@ -29,8 +29,8 @@ console.log("Waiting for server..."); await waitForHealth({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl }); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { agent: detectAgent() }); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root" } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(" Press Ctrl+C to stop."); diff --git a/examples/daytona/src/index.ts b/examples/daytona/src/index.ts index 9fbd2f4..7c98b8d 100644 --- a/examples/daytona/src/index.ts +++ b/examples/daytona/src/index.ts @@ -1,6 +1,6 @@ import { Daytona } from "@daytonaio/sdk"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; const daytona = new Daytona(); @@ -17,7 +17,7 @@ const sandbox = await daytona.create({ envVars, autoStopInterval: 0 }); // Install sandbox-agent and start server console.log("Installing sandbox-agent..."); await sandbox.process.executeCommand( - "curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", ); await sandbox.process.executeCommand( @@ -30,8 +30,8 @@ console.log("Waiting for server..."); await waitForHealth({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl }); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { agent: detectAgent() }); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root" } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(" Press Ctrl+C to stop."); diff --git a/examples/docker/src/index.ts b/examples/docker/src/index.ts index 1ae51e7..f7620d1 100644 --- a/examples/docker/src/index.ts +++ b/examples/docker/src/index.ts @@ -1,6 +1,6 @@ import Docker from "dockerode"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; const IMAGE = "alpine:latest"; const PORT = 3000; @@ -25,7 +25,7 @@ const container = await docker.createContainer({ Image: IMAGE, Cmd: ["sh", "-c", [ "apk add --no-cache curl ca-certificates libstdc++ libgcc bash", - "curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh", + "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh", "sandbox-agent install-agent claude", "sandbox-agent install-agent codex", `sandbox-agent server --no-token --host 0.0.0.0 --port ${PORT}`, @@ -46,8 +46,8 @@ const baseUrl = `http://127.0.0.1:${PORT}`; await waitForHealth({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl }); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { agent: detectAgent() }); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root" } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(" Press Ctrl+C to stop."); diff --git a/examples/e2b/src/index.ts b/examples/e2b/src/index.ts index d82141d..d636975 100644 --- a/examples/e2b/src/index.ts +++ b/examples/e2b/src/index.ts @@ -1,6 +1,6 @@ import { Sandbox } from "@e2b/code-interpreter"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -16,7 +16,7 @@ const run = async (cmd: string) => { }; console.log("Installing sandbox-agent..."); -await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh"); +await run("curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh"); console.log("Installing agents..."); await run("sandbox-agent install-agent claude"); @@ -31,8 +31,8 @@ console.log("Waiting for server..."); await waitForHealth({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl }); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { agent: detectAgent() }); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root" } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(" Press Ctrl+C to stop."); diff --git a/examples/file-system/src/index.ts b/examples/file-system/src/index.ts index 2e2c8f9..7b57a62 100644 --- a/examples/file-system/src/index.ts +++ b/examples/file-system/src/index.ts @@ -1,5 +1,5 @@ import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; import * as tar from "tar"; import fs from "node:fs"; @@ -47,8 +47,8 @@ const readmeText = new TextDecoder().decode(readmeBytes); console.log(` README.md content: ${readmeText.trim()}`); console.log("Creating session..."); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { agent: detectAgent() }); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/opt/my-project" } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(' Try: "read the README in /opt/my-project"'); console.log(" Press Ctrl+C to stop."); diff --git a/examples/mcp-custom-tool/src/index.ts b/examples/mcp-custom-tool/src/index.ts index 0c0bc33..fc06b78 100644 --- a/examples/mcp-custom-tool/src/index.ts +++ b/examples/mcp-custom-tool/src/index.ts @@ -1,5 +1,5 @@ import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; import fs from "node:fs"; import path from "node:path"; @@ -31,16 +31,19 @@ console.log(` Written: ${written.path} (${written.bytesWritten} bytes)`); // Create a session with the uploaded MCP server as a local command. console.log("Creating session with custom MCP tool..."); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { +const session = await client.createSession({ agent: detectAgent(), - mcp: { - customTools: { - type: "local", - command: ["node", "/opt/mcp/custom-tools/mcp-server.cjs"], - }, + sessionInit: { + cwd: "/root", + mcpServers: [{ + name: "customTools", + command: "node", + args: ["/opt/mcp/custom-tools/mcp-server.cjs"], + env: [], + }], }, }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(' Try: "generate a random number between 1 and 100"'); console.log(" Press Ctrl+C to stop."); diff --git a/examples/mcp/src/index.ts b/examples/mcp/src/index.ts index 84be8df..5187643 100644 --- a/examples/mcp/src/index.ts +++ b/examples/mcp/src/index.ts @@ -1,5 +1,5 @@ import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; console.log("Starting sandbox..."); @@ -12,17 +12,19 @@ const { baseUrl, cleanup } = await startDockerSandbox({ console.log("Creating session with everything MCP server..."); const client = await SandboxAgent.connect({ baseUrl }); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { +const session = await client.createSession({ agent: detectAgent(), - mcp: { - everything: { - type: "local", - command: ["mcp-server-everything"], - timeoutMs: 10000, - }, + sessionInit: { + cwd: "/root", + mcpServers: [{ + name: "everything", + command: "mcp-server-everything", + args: [], + env: [], + }], }, }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(' Try: "generate a random number between 1 and 100"'); console.log(" Press Ctrl+C to stop."); diff --git a/examples/mock-acp-agent/README.md b/examples/mock-acp-agent/README.md new file mode 100644 index 0000000..46fc583 --- /dev/null +++ b/examples/mock-acp-agent/README.md @@ -0,0 +1,9 @@ +# @sandbox-agent/mock-acp-agent + +Minimal newline-delimited ACP JSON-RPC mock agent. + +Behavior: +- Echoes every inbound message as `mock/echo` notification. +- For requests (`method` + `id`), returns `result.echoed` payload. +- For `mock/ask_client`, emits an agent-initiated `mock/request` before response. +- For responses from client (`id` without `method`), emits `mock/client_response` notification. diff --git a/examples/mock-acp-agent/package.json b/examples/mock-acp-agent/package.json new file mode 100644 index 0000000..124901f --- /dev/null +++ b/examples/mock-acp-agent/package.json @@ -0,0 +1,24 @@ +{ + "name": "@sandbox-agent/mock-acp-agent", + "version": "0.1.0", + "private": false, + "type": "module", + "description": "Mock ACP agent for adapter integration testing", + "license": "Apache-2.0", + "main": "./dist/index.js", + "exports": { + ".": "./dist/index.js" + }, + "files": [ + "dist" + ], + "scripts": { + "build": "tsc -p tsconfig.build.json", + "typecheck": "tsc --noEmit", + "start": "node ./dist/index.js" + }, + "devDependencies": { + "@types/node": "latest", + "typescript": "latest" + } +} diff --git a/examples/mock-acp-agent/src/index.ts b/examples/mock-acp-agent/src/index.ts new file mode 100644 index 0000000..bde006c --- /dev/null +++ b/examples/mock-acp-agent/src/index.ts @@ -0,0 +1,100 @@ +import { createInterface } from "node:readline"; + +interface JsonRpcRequest { + jsonrpc?: unknown; + id?: unknown; + method?: unknown; + params?: unknown; + result?: unknown; + error?: unknown; +} + +let outboundRequestSeq = 0; + +function writeMessage(payload: unknown): void { + process.stdout.write(`${JSON.stringify(payload)}\n`); +} + +function echoNotification(message: unknown): void { + writeMessage({ + jsonrpc: "2.0", + method: "mock/echo", + params: { + message, + }, + }); +} + +function handleMessage(raw: string): void { + if (!raw.trim()) { + return; + } + + let msg: JsonRpcRequest; + try { + msg = JSON.parse(raw) as JsonRpcRequest; + } catch (error) { + writeMessage({ + jsonrpc: "2.0", + method: "mock/parse_error", + params: { + error: error instanceof Error ? error.message : String(error), + raw, + }, + }); + return; + } + + echoNotification(msg); + + const hasMethod = typeof msg.method === "string"; + const hasId = msg.id !== undefined; + + if (hasMethod && hasId) { + if (msg.method === "mock/ask_client") { + outboundRequestSeq += 1; + writeMessage({ + jsonrpc: "2.0", + id: `agent-req-${outboundRequestSeq}`, + method: "mock/request", + params: { + prompt: "please respond", + }, + }); + } + + writeMessage({ + jsonrpc: "2.0", + id: msg.id, + result: { + echoed: msg, + }, + }); + return; + } + + if (!hasMethod && hasId) { + writeMessage({ + jsonrpc: "2.0", + method: "mock/client_response", + params: { + id: msg.id, + result: msg.result ?? null, + error: msg.error ?? null, + }, + }); + } +} + +const rl = createInterface({ + input: process.stdin, + crlfDelay: Infinity, +}); + +rl.on("line", (line) => { + handleMessage(line); +}); + +rl.on("close", () => { + process.exit(0); +}); diff --git a/examples/mock-acp-agent/tsconfig.build.json b/examples/mock-acp-agent/tsconfig.build.json new file mode 100644 index 0000000..8ca8089 --- /dev/null +++ b/examples/mock-acp-agent/tsconfig.build.json @@ -0,0 +1,11 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "allowImportingTsExtensions": false, + "noEmit": false, + "declaration": true, + "outDir": "./dist", + "rootDir": "./src" + }, + "include": ["src/**/*"] +} diff --git a/examples/mock-acp-agent/tsconfig.json b/examples/mock-acp-agent/tsconfig.json new file mode 100644 index 0000000..8f7a8cc --- /dev/null +++ b/examples/mock-acp-agent/tsconfig.json @@ -0,0 +1,17 @@ +{ + "compilerOptions": { + "target": "ES2022", + "lib": ["ES2022"], + "module": "ES2022", + "moduleResolution": "Bundler", + "allowImportingTsExtensions": true, + "noEmit": true, + "esModuleInterop": true, + "strict": true, + "skipLibCheck": true, + "resolveJsonModule": true, + "types": ["node"] + }, + "include": ["src/**/*"], + "exclude": ["node_modules"] +} diff --git a/examples/shared/Dockerfile.dev b/examples/shared/Dockerfile.dev index 87ba956..cac363d 100644 --- a/examples/shared/Dockerfile.dev +++ b/examples/shared/Dockerfile.dev @@ -6,9 +6,11 @@ WORKDIR /build COPY package.json pnpm-lock.yaml pnpm-workspace.yaml ./ # Copy packages needed for the inspector build chain: -# inspector -> sandbox-agent SDK -> cli-shared +# inspector -> sandbox-agent SDK -> acp-http-client, cli-shared, persist-indexeddb COPY sdks/typescript/ sdks/typescript/ +COPY sdks/acp-http-client/ sdks/acp-http-client/ COPY sdks/cli-shared/ sdks/cli-shared/ +COPY sdks/persist-indexeddb/ sdks/persist-indexeddb/ COPY frontend/packages/inspector/ frontend/packages/inspector/ COPY docs/openapi.json docs/ @@ -16,6 +18,7 @@ COPY docs/openapi.json docs/ # but not needed for the inspector build (avoids install errors). RUN set -e; for dir in \ sdks/cli sdks/gigacode \ + sdks/persist-postgres sdks/persist-sqlite sdks/persist-rivet \ resources/agent-schemas resources/vercel-ai-sdk-schemas \ scripts/release scripts/sandbox-testing \ examples/shared examples/docker examples/e2b examples/vercel \ @@ -44,6 +47,7 @@ COPY Cargo.toml Cargo.lock ./ COPY server/ ./server/ COPY gigacode/ ./gigacode/ COPY resources/agent-schemas/artifacts/ ./resources/agent-schemas/artifacts/ +COPY scripts/agent-configs/ ./scripts/agent-configs/ COPY --from=frontend /build/frontend/packages/inspector/dist/ ./frontend/packages/inspector/dist/ RUN --mount=type=cache,target=/usr/local/cargo/registry \ --mount=type=cache,target=/usr/local/cargo/git \ diff --git a/examples/skills-custom-tool/src/index.ts b/examples/skills-custom-tool/src/index.ts index c53498b..02e528a 100644 --- a/examples/skills-custom-tool/src/index.ts +++ b/examples/skills-custom-tool/src/index.ts @@ -1,5 +1,5 @@ import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; import fs from "node:fs"; import path from "node:path"; @@ -36,15 +36,17 @@ const skillResult = await client.writeFsFile( ); console.log(` Skill: ${skillResult.path} (${skillResult.bytesWritten} bytes)`); -// Create a session with the uploaded skill as a local source. +// Configure the uploaded skill. +console.log("Configuring custom skill..."); +await client.setSkillsConfig( + { directory: "/", skillName: "random-number" }, + { sources: [{ type: "local", source: "/opt/skills/random-number" }] }, +); + +// Create a session. console.log("Creating session with custom skill..."); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { - agent: detectAgent(), - skills: { - sources: [{ type: "local", source: "/opt/skills/random-number" }], - }, -}); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root" } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(' Try: "generate a random number between 1 and 100"'); console.log(" Press Ctrl+C to stop."); diff --git a/examples/skills/src/index.ts b/examples/skills/src/index.ts index 2e1990e..7dfd113 100644 --- a/examples/skills/src/index.ts +++ b/examples/skills/src/index.ts @@ -1,5 +1,5 @@ import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl } from "@sandbox-agent/example-shared"; import { startDockerSandbox } from "@sandbox-agent/example-shared/docker"; console.log("Starting sandbox..."); @@ -7,17 +7,16 @@ const { baseUrl, cleanup } = await startDockerSandbox({ port: 3001, }); -console.log("Creating session with skill source..."); +console.log("Configuring skill source..."); const client = await SandboxAgent.connect({ baseUrl }); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { - agent: detectAgent(), - skills: { - sources: [ - { type: "github", source: "rivet-dev/skills", skills: ["sandbox-agent"] }, - ], - }, -}); +await client.setSkillsConfig( + { directory: "/", skillName: "rivet-dev-skills" }, + { sources: [{ type: "github", source: "rivet-dev/skills", skills: ["sandbox-agent"] }] }, +); + +console.log("Creating session..."); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root" } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(' Try: "How do I start sandbox-agent?"'); console.log(" Press Ctrl+C to stop."); diff --git a/examples/vercel/src/index.ts b/examples/vercel/src/index.ts index 93093ae..d0c9528 100644 --- a/examples/vercel/src/index.ts +++ b/examples/vercel/src/index.ts @@ -1,6 +1,6 @@ import { Sandbox } from "@vercel/sandbox"; import { SandboxAgent } from "sandbox-agent"; -import { detectAgent, buildInspectorUrl, generateSessionId, waitForHealth } from "@sandbox-agent/example-shared"; +import { detectAgent, buildInspectorUrl, waitForHealth } from "@sandbox-agent/example-shared"; const envs: Record = {}; if (process.env.ANTHROPIC_API_KEY) envs.ANTHROPIC_API_KEY = process.env.ANTHROPIC_API_KEY; @@ -22,7 +22,7 @@ const run = async (cmd: string, args: string[] = []) => { }; console.log("Installing sandbox-agent..."); -await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/latest/install.sh | sh"]); +await run("sh", ["-c", "curl -fsSL https://releases.rivet.dev/sandbox-agent/0.2.x/install.sh | sh"]); console.log("Installing agents..."); await run("sandbox-agent", ["install-agent", "claude"]); @@ -42,8 +42,8 @@ console.log("Waiting for server..."); await waitForHealth({ baseUrl }); const client = await SandboxAgent.connect({ baseUrl }); -const sessionId = generateSessionId(); -await client.createSession(sessionId, { agent: detectAgent() }); +const session = await client.createSession({ agent: detectAgent(), sessionInit: { cwd: "/root" } }); +const sessionId = session.id; console.log(` UI: ${buildInspectorUrl({ baseUrl, sessionId })}`); console.log(" Press Ctrl+C to stop."); diff --git a/frontend/CLAUDE.md b/frontend/CLAUDE.md index 4ae817d..3a37268 100644 --- a/frontend/CLAUDE.md +++ b/frontend/CLAUDE.md @@ -3,7 +3,7 @@ ## Inspector Architecture - Inspector source is `frontend/packages/inspector/`. -- `/ui/` must use ACP over HTTP (`/v2/rpc`) for session/prompt traffic. +- `/ui/` must use ACP over HTTP (`/v1/rpc`) for session/prompt traffic. - Primary flow: - `initialize` - `session/new` diff --git a/frontend/packages/inspector/index.html b/frontend/packages/inspector/index.html index 72d6460..b7b284d 100644 --- a/frontend/packages/inspector/index.html +++ b/frontend/packages/inspector/index.html @@ -110,6 +110,25 @@ color: var(--muted); } + .header-link { + display: inline-flex; + align-items: center; + gap: 5px; + padding: 5px 10px; + border: 1px solid var(--border-2); + border-radius: var(--radius-sm); + font-size: 11px; + font-weight: 600; + color: var(--muted); + text-decoration: none; + transition: color var(--transition), border-color var(--transition); + } + + .header-link:hover { + color: var(--accent); + border-color: var(--accent); + } + .status-indicator.disconnected { display: flex; align-items: center; @@ -404,7 +423,7 @@ flex-direction: column; border-right: 1px solid var(--border); background: var(--surface-2); - overflow: visible; + min-height: 0; } .sidebar-header { @@ -555,6 +574,20 @@ min-width: 0; } + .setup-custom-back { + flex-shrink: 0; + background: none; + border: none; + color: var(--accent); + font-size: 10px; + cursor: pointer; + padding: 2px 4px; + } + + .setup-custom-back:hover { + text-decoration: underline; + } + .session-create-section { overflow: hidden; } @@ -1055,6 +1088,23 @@ color: var(--danger); } + .session-persistence-note { + padding: 8px 10px 10px; + border-top: 1px solid var(--border); + font-size: 10px; + line-height: 1.45; + color: var(--muted); + } + + .session-persistence-note a { + color: var(--accent); + text-decoration: none; + } + + .session-persistence-note a:hover { + text-decoration: underline; + } + /* Chat Panel */ .chat-panel { display: flex; @@ -1322,6 +1372,64 @@ margin-top: 8px; } + .toast-stack { + position: fixed; + right: 16px; + bottom: 16px; + z-index: 1000; + display: flex; + flex-direction: column; + gap: 8px; + width: min(420px, calc(100vw - 24px)); + pointer-events: none; + } + + .toast { + pointer-events: auto; + display: flex; + align-items: flex-start; + gap: 10px; + padding: 10px 12px; + border-radius: var(--radius-sm); + border: 1px solid rgba(255, 59, 48, 0.35); + background: rgba(28, 8, 8, 0.95); + box-shadow: 0 10px 25px rgba(0, 0, 0, 0.35); + } + + .toast-content { + min-width: 0; + } + + .toast-title { + font-size: 10px; + text-transform: uppercase; + letter-spacing: 0.4px; + color: var(--danger); + margin-bottom: 4px; + } + + .toast-message { + color: var(--text-secondary); + font-size: 12px; + line-height: 1.4; + word-break: break-word; + } + + .toast-close { + background: transparent; + border: none; + color: var(--muted); + font-size: 12px; + line-height: 1; + cursor: pointer; + padding: 2px; + margin-top: 1px; + } + + .toast-close:hover { + color: var(--text); + } + .cursor { display: inline-block; width: 2px; @@ -1932,84 +2040,82 @@ letter-spacing: 0.3px; } - .event-type.session, - .event-type.session-started, - .event-type.session-ended { + /* ACP event categories: connection */ + .event-type.connection, + .event-type.session { color: var(--success); } - - .event-type.item, - .event-type.item-started, - .event-type.item-completed { - color: var(--accent); - } - - .event-type.item-delta { - color: var(--cyan); - } - - .event-type.error, - .event-type.agent-unparsed { - color: var(--danger); - } - - .event-type.question, - .event-type.question-requested, - .event-type.question-resolved { - color: var(--warning); - } - - .event-type.permission, - .event-type.permission-requested, - .event-type.permission-resolved { - color: var(--purple); - } - - .event-icon.session, - .event-icon.session-started, - .event-icon.session-ended { + .event-icon.connection, + .event-icon.session { color: var(--success); border-color: rgba(48, 209, 88, 0.35); background: rgba(48, 209, 88, 0.12); } - .event-icon.item, - .event-icon.item-started, - .event-icon.item-completed { + /* ACP event categories: prompt / tool */ + .event-type.prompt, + .event-type.tool { + color: var(--accent); + } + .event-icon.prompt, + .event-icon.tool { color: var(--accent); border-color: rgba(255, 79, 0, 0.35); background: rgba(255, 79, 0, 0.12); } - .event-icon.item-delta { + /* ACP event categories: update / terminal (streaming, realtime) */ + .event-type.update, + .event-type.terminal { + color: var(--cyan); + } + .event-icon.update, + .event-icon.terminal { color: var(--cyan); border-color: rgba(100, 210, 255, 0.35); background: rgba(100, 210, 255, 0.12); } - .event-icon.error, - .event-icon.agent-unparsed { + /* ACP event categories: cancel */ + .event-type.cancel { + color: var(--danger); + } + .event-icon.cancel { color: var(--danger); border-color: rgba(255, 59, 48, 0.35); background: rgba(255, 59, 48, 0.12); } - .event-icon.question, - .event-icon.question-requested, - .event-icon.question-resolved { + /* ACP event categories: filesystem */ + .event-type.filesystem { + color: var(--warning); + } + .event-icon.filesystem { color: var(--warning); border-color: rgba(255, 159, 10, 0.35); background: rgba(255, 159, 10, 0.12); } - .event-icon.permission, - .event-icon.permission-requested, - .event-icon.permission-resolved { + /* ACP event categories: config / permission */ + .event-type.config, + .event-type.permission { + color: var(--purple); + } + .event-icon.config, + .event-icon.permission { color: var(--purple); border-color: rgba(191, 90, 242, 0.35); background: rgba(191, 90, 242, 0.12); } + /* ACP event categories: response (fallback) */ + .event-type.response { + color: var(--muted); + } + .event-icon.response { + color: var(--muted); + } + .event-time { font-size: 10px; color: var(--muted); @@ -2247,6 +2353,13 @@ .header-title { display: none; } + + .toast-stack { + left: 12px; + right: 12px; + bottom: 12px; + width: auto; + } } diff --git a/frontend/packages/inspector/package.json b/frontend/packages/inspector/package.json index 2c1c85b..119b8ce 100644 --- a/frontend/packages/inspector/package.json +++ b/frontend/packages/inspector/package.json @@ -6,19 +6,23 @@ "type": "module", "scripts": { "dev": "vite", - "build": "pnpm --filter sandbox-agent build && vite build", + "build": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && vite build", "preview": "vite preview", - "typecheck": "tsc --noEmit" + "typecheck": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && tsc --noEmit", + "test": "SKIP_OPENAPI_GEN=1 pnpm --filter @sandbox-agent/persist-indexeddb build && vitest run" }, "devDependencies": { "sandbox-agent": "workspace:*", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@vitejs/plugin-react": "^4.3.1", + "fake-indexeddb": "^6.2.4", "typescript": "^5.7.3", - "vite": "^5.4.7" + "vite": "^5.4.7", + "vitest": "^3.0.0" }, "dependencies": { + "@sandbox-agent/persist-indexeddb": "workspace:*", "lucide-react": "^0.469.0", "react": "^18.3.1", "react-dom": "^18.3.1" diff --git a/frontend/packages/inspector/src/components/ConnectScreen.tsx b/frontend/packages/inspector/src/components/ConnectScreen.tsx index cb69264..1337b4b 100644 --- a/frontend/packages/inspector/src/components/ConnectScreen.tsx +++ b/frontend/packages/inspector/src/components/ConnectScreen.tsx @@ -1,4 +1,4 @@ -import { AlertTriangle, Zap } from "lucide-react"; +import { AlertTriangle, BookOpen, Zap } from "lucide-react"; import { isHttpsToHttpConnection, isLocalNetworkTarget } from "../lib/permissions"; const logoUrl = `${import.meta.env.BASE_URL}logos/sandboxagent.svg`; @@ -11,7 +11,9 @@ const ConnectScreen = ({ onEndpointChange, onTokenChange, onConnect, - reportUrl + reportUrl, + docsUrl, + discordUrl, }: { endpoint: string; token: string; @@ -21,6 +23,8 @@ const ConnectScreen = ({ onTokenChange: (value: string) => void; onConnect: () => void; reportUrl?: string; + docsUrl?: string; + discordUrl?: string; }) => { return (
@@ -28,11 +32,26 @@ const ConnectScreen = ({
Sandbox Agent
- {reportUrl && ( + {(docsUrl || discordUrl || reportUrl) && (
- - Report Bug - + {docsUrl && ( + + + Docs + + )} + {discordUrl && ( + + + Discord + + )} + {reportUrl && ( + + + Issues + + )}
)} diff --git a/frontend/packages/inspector/src/components/SessionCreateMenu.tsx b/frontend/packages/inspector/src/components/SessionCreateMenu.tsx index 96c390f..b20b0af 100644 --- a/frontend/packages/inspector/src/components/SessionCreateMenu.tsx +++ b/frontend/packages/inspector/src/components/SessionCreateMenu.tsx @@ -1,15 +1,17 @@ -import { ArrowLeft, ArrowRight, ChevronDown, ChevronRight, Pencil, Plus, X } from "lucide-react"; -import { useEffect, useRef, useState } from "react"; -import type { McpServerEntry } from "../App"; -import type { AgentInfo, AgentModelInfo, AgentModeInfo, SkillSource } from "../types/legacyApi"; +import { ArrowLeft, ArrowRight } from "lucide-react"; +import { useEffect, useState } from "react"; +import type { AgentInfo } from "sandbox-agent"; + +type AgentModeInfo = { id: string; name: string; description: string }; +type AgentModelInfo = { id: string; name?: string }; export type SessionConfig = { - model: string; agentMode: string; - permissionMode: string; - variant: string; + model: string; }; +const CUSTOM_MODEL_VALUE = "__custom__"; + const agentLabels: Record = { claude: "Claude Code", codex: "Codex", @@ -17,59 +19,6 @@ const agentLabels: Record = { amp: "Amp" }; -const validateServerJson = (json: string): string | null => { - const trimmed = json.trim(); - if (!trimmed) return "Config is required"; - try { - const parsed = JSON.parse(trimmed); - if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) { - return "Must be a JSON object"; - } - if (!parsed.type) return 'Missing "type" field'; - if (parsed.type !== "local" && parsed.type !== "remote") { - return 'Type must be "local" or "remote"'; - } - if (parsed.type === "local" && !parsed.command) return 'Local server requires "command"'; - if (parsed.type === "remote" && !parsed.url) return 'Remote server requires "url"'; - return null; - } catch { - return "Invalid JSON"; - } -}; - -const getServerType = (configJson: string): string | null => { - try { - const parsed = JSON.parse(configJson); - return parsed?.type ?? null; - } catch { - return null; - } -}; - -const getServerSummary = (configJson: string): string => { - try { - const parsed = JSON.parse(configJson); - if (parsed?.type === "local") { - const cmd = Array.isArray(parsed.command) ? parsed.command.join(" ") : parsed.command; - return cmd ?? "local"; - } - if (parsed?.type === "remote") { - return parsed.url ?? "remote"; - } - return parsed?.type ?? ""; - } catch { - return ""; - } -}; - -const skillSourceSummary = (source: SkillSource): string => { - let summary = source.source; - if (source.skills && source.skills.length > 0) { - summary += ` [${source.skills.join(", ")}]`; - } - return summary; -}; - const SessionCreateMenu = ({ agents, agentsLoading, @@ -77,17 +26,8 @@ const SessionCreateMenu = ({ modesByAgent, modelsByAgent, defaultModelByAgent, - modesLoadingByAgent, - modelsLoadingByAgent, - modesErrorByAgent, - modelsErrorByAgent, - mcpServers, - onMcpServersChange, - mcpConfigError, - skillSources, - onSkillSourcesChange, - onSelectAgent, onCreateSession, + onSelectAgent, open, onClose }: { @@ -97,60 +37,18 @@ const SessionCreateMenu = ({ modesByAgent: Record; modelsByAgent: Record; defaultModelByAgent: Record; - modesLoadingByAgent: Record; - modelsLoadingByAgent: Record; - modesErrorByAgent: Record; - modelsErrorByAgent: Record; - mcpServers: McpServerEntry[]; - onMcpServersChange: (servers: McpServerEntry[]) => void; - mcpConfigError: string | null; - skillSources: SkillSource[]; - onSkillSourcesChange: (sources: SkillSource[]) => void; - onSelectAgent: (agentId: string) => void; onCreateSession: (agentId: string, config: SessionConfig) => void; + onSelectAgent: (agentId: string) => Promise; open: boolean; onClose: () => void; }) => { - const [phase, setPhase] = useState<"agent" | "config">("agent"); + const [phase, setPhase] = useState<"agent" | "config" | "loading-config">("agent"); const [selectedAgent, setSelectedAgent] = useState(""); const [agentMode, setAgentMode] = useState(""); - const [permissionMode, setPermissionMode] = useState("default"); - const [model, setModel] = useState(""); - const [variant, setVariant] = useState(""); - - const [mcpExpanded, setMcpExpanded] = useState(false); - const [skillsExpanded, setSkillsExpanded] = useState(false); - - // Skill add/edit state - const [addingSkill, setAddingSkill] = useState(false); - const [editingSkillIndex, setEditingSkillIndex] = useState(null); - const [skillType, setSkillType] = useState<"github" | "local" | "git">("github"); - const [skillSource, setSkillSource] = useState(""); - const [skillFilter, setSkillFilter] = useState(""); - const [skillRef, setSkillRef] = useState(""); - const [skillSubpath, setSkillSubpath] = useState(""); - const [skillLocalError, setSkillLocalError] = useState(null); - const skillSourceRef = useRef(null); - - // MCP add/edit state - const [addingMcp, setAddingMcp] = useState(false); - const [editingMcpIndex, setEditingMcpIndex] = useState(null); - const [mcpName, setMcpName] = useState(""); - const [mcpJson, setMcpJson] = useState(""); - const [mcpLocalError, setMcpLocalError] = useState(null); - const mcpNameRef = useRef(null); - const mcpJsonRef = useRef(null); - - const cancelSkillEdit = () => { - setAddingSkill(false); - setEditingSkillIndex(null); - setSkillType("github"); - setSkillSource(""); - setSkillFilter(""); - setSkillRef(""); - setSkillSubpath(""); - setSkillLocalError(null); - }; + const [selectedModel, setSelectedModel] = useState(""); + const [customModel, setCustomModel] = useState(""); + const [isCustomModel, setIsCustomModel] = useState(false); + const [configLoadDone, setConfigLoadDone] = useState(false); // Reset state when menu closes useEffect(() => { @@ -158,20 +56,21 @@ const SessionCreateMenu = ({ setPhase("agent"); setSelectedAgent(""); setAgentMode(""); - setPermissionMode("default"); - setModel(""); - setVariant(""); - setMcpExpanded(false); - setSkillsExpanded(false); - cancelSkillEdit(); - setAddingMcp(false); - setEditingMcpIndex(null); - setMcpName(""); - setMcpJson(""); - setMcpLocalError(null); + setSelectedModel(""); + setCustomModel(""); + setIsCustomModel(false); + setConfigLoadDone(false); } }, [open]); + // Transition to config phase after load completes — deferred via useEffect + // so parent props (modelsByAgent) have settled before we render the config form + useEffect(() => { + if (phase === "loading-config" && configLoadDone) { + setPhase("config"); + } + }, [phase, configLoadDone]); + // Auto-select first mode when modes load for selected agent useEffect(() => { if (!selectedAgent) return; @@ -181,174 +80,60 @@ const SessionCreateMenu = ({ } }, [modesByAgent, selectedAgent, agentMode]); - // Focus skill source input when adding + // Auto-select default model when agent is selected useEffect(() => { - if ((addingSkill || editingSkillIndex !== null) && skillSourceRef.current) { - skillSourceRef.current.focus(); + if (!selectedAgent) return; + if (selectedModel) return; + const defaultModel = defaultModelByAgent[selectedAgent]; + if (defaultModel) { + setSelectedModel(defaultModel); + } else { + const models = modelsByAgent[selectedAgent]; + if (models && models.length > 0) { + setSelectedModel(models[0].id); + } } - }, [addingSkill, editingSkillIndex]); - - // Focus MCP name input when adding - useEffect(() => { - if (addingMcp && mcpNameRef.current) { - mcpNameRef.current.focus(); - } - }, [addingMcp]); - - // Focus MCP json textarea when editing - useEffect(() => { - if (editingMcpIndex !== null && mcpJsonRef.current) { - mcpJsonRef.current.focus(); - } - }, [editingMcpIndex]); + }, [modelsByAgent, defaultModelByAgent, selectedAgent, selectedModel]); if (!open) return null; const handleAgentClick = (agentId: string) => { setSelectedAgent(agentId); - setPhase("config"); - onSelectAgent(agentId); + setPhase("loading-config"); + setConfigLoadDone(false); + onSelectAgent(agentId).finally(() => { + setConfigLoadDone(true); + }); }; const handleBack = () => { setPhase("agent"); setSelectedAgent(""); setAgentMode(""); - setPermissionMode("default"); - setModel(""); - setVariant(""); + setSelectedModel(""); + setCustomModel(""); + setIsCustomModel(false); + setConfigLoadDone(false); }; + const handleModelSelectChange = (value: string) => { + if (value === CUSTOM_MODEL_VALUE) { + setIsCustomModel(true); + setSelectedModel(""); + } else { + setIsCustomModel(false); + setCustomModel(""); + setSelectedModel(value); + } + }; + + const resolvedModel = isCustomModel ? customModel : selectedModel; + const handleCreate = () => { - if (mcpConfigError) return; - onCreateSession(selectedAgent, { model, agentMode, permissionMode, variant }); + onCreateSession(selectedAgent, { agentMode, model: resolvedModel }); onClose(); }; - // Skill source helpers - const startAddSkill = () => { - setAddingSkill(true); - setEditingSkillIndex(null); - setSkillType("github"); - setSkillSource("rivet-dev/skills"); - setSkillFilter("sandbox-agent"); - setSkillRef(""); - setSkillSubpath(""); - setSkillLocalError(null); - }; - - const startEditSkill = (index: number) => { - const entry = skillSources[index]; - setEditingSkillIndex(index); - setAddingSkill(false); - setSkillType(entry.type as "github" | "local" | "git"); - setSkillSource(entry.source); - setSkillFilter(entry.skills?.join(", ") ?? ""); - setSkillRef(entry.ref ?? ""); - setSkillSubpath(entry.subpath ?? ""); - setSkillLocalError(null); - }; - - const commitSkill = () => { - const src = skillSource.trim(); - if (!src) { - setSkillLocalError("Source is required"); - return; - } - const entry: SkillSource = { - type: skillType, - source: src, - }; - const filterList = skillFilter.trim() - ? skillFilter.split(",").map((s) => s.trim()).filter(Boolean) - : undefined; - if (filterList && filterList.length > 0) entry.skills = filterList; - if (skillRef.trim()) entry.ref = skillRef.trim(); - if (skillSubpath.trim()) entry.subpath = skillSubpath.trim(); - - if (editingSkillIndex !== null) { - const updated = [...skillSources]; - updated[editingSkillIndex] = entry; - onSkillSourcesChange(updated); - } else { - onSkillSourcesChange([...skillSources, entry]); - } - cancelSkillEdit(); - }; - - const removeSkill = (index: number) => { - onSkillSourcesChange(skillSources.filter((_, i) => i !== index)); - if (editingSkillIndex === index) { - cancelSkillEdit(); - } - }; - - const isEditingSkill = addingSkill || editingSkillIndex !== null; - - const startAddMcp = () => { - setAddingMcp(true); - setEditingMcpIndex(null); - setMcpName("everything"); - setMcpJson('{\n "type": "local",\n "command": "npx",\n "args": ["@modelcontextprotocol/server-everything"]\n}'); - setMcpLocalError(null); - }; - - const startEditMcp = (index: number) => { - const entry = mcpServers[index]; - setEditingMcpIndex(index); - setAddingMcp(false); - setMcpName(entry.name); - setMcpJson(entry.configJson); - setMcpLocalError(entry.error); - }; - - const cancelMcpEdit = () => { - setAddingMcp(false); - setEditingMcpIndex(null); - setMcpName(""); - setMcpJson(""); - setMcpLocalError(null); - }; - - const commitMcp = () => { - const name = mcpName.trim(); - if (!name) { - setMcpLocalError("Server name is required"); - return; - } - const error = validateServerJson(mcpJson); - if (error) { - setMcpLocalError(error); - return; - } - // Check for duplicate names (except when editing the same entry) - const duplicate = mcpServers.findIndex((e) => e.name === name); - if (duplicate !== -1 && duplicate !== editingMcpIndex) { - setMcpLocalError(`Server "${name}" already exists`); - return; - } - - const entry: McpServerEntry = { name, configJson: mcpJson.trim(), error: null }; - - if (editingMcpIndex !== null) { - const updated = [...mcpServers]; - updated[editingMcpIndex] = entry; - onMcpServersChange(updated); - } else { - onMcpServersChange([...mcpServers, entry]); - } - cancelMcpEdit(); - }; - - const removeMcp = (index: number) => { - onMcpServersChange(mcpServers.filter((_, i) => i !== index)); - if (editingMcpIndex === index) { - cancelMcpEdit(); - } - }; - - const isEditingMcp = addingMcp || editingMcpIndex !== null; - if (phase === "agent") { return (
@@ -378,30 +163,25 @@ const SessionCreateMenu = ({ ); } + const agentLabel = agentLabels[selectedAgent] ?? selectedAgent; + + if (phase === "loading-config") { + return ( +
+
+ + {agentLabel} +
+
Loading config...
+
+ ); + } + // Phase 2: config form const activeModes = modesByAgent[selectedAgent] ?? []; - const modesLoading = modesLoadingByAgent[selectedAgent] ?? false; - const modesError = modesErrorByAgent[selectedAgent] ?? null; - const modelOptions = modelsByAgent[selectedAgent] ?? []; - const modelsLoading = modelsLoadingByAgent[selectedAgent] ?? false; - const modelsError = modelsErrorByAgent[selectedAgent] ?? null; - const defaultModel = defaultModelByAgent[selectedAgent] ?? ""; - const selectedModelId = model || defaultModel; - const selectedModelObj = modelOptions.find((entry) => entry.id === selectedModelId); - const variantOptions = selectedModelObj?.variants ?? []; - const showModelSelect = modelsLoading || Boolean(modelsError) || modelOptions.length > 0; - const hasModelOptions = modelOptions.length > 0; - const modelCustom = - model && hasModelOptions && !modelOptions.some((entry) => entry.id === model); - const supportsVariants = - modelsLoading || - Boolean(modelsError) || - modelOptions.some((entry) => (entry.variants?.length ?? 0) > 0); - const showVariantSelect = - supportsVariants && (modelsLoading || Boolean(modelsError) || variantOptions.length > 0); - const hasVariantOptions = variantOptions.length > 0; - const variantCustom = variant && hasVariantOptions && !variantOptions.includes(variant); - const agentLabel = agentLabels[selectedAgent] ?? selectedAgent; + const activeModels = modelsByAgent[selectedAgent] ?? []; return (
@@ -415,330 +195,69 @@ const SessionCreateMenu = ({
Model - {showModelSelect ? ( - - ) : ( + {isCustomModel ? ( setModel(e.target.value)} - placeholder="Model" - title="Model" + type="text" + value={customModel} + onChange={(e) => setCustomModel(e.target.value)} + placeholder="Enter model name..." + autoFocus /> - )} -
- -
- Mode - handleModelSelectChange(e.target.value)} + title="Model" + > + {activeModels.map((m) => ( - )) - ) : ( - - )} - + ))} + + + )} + {isCustomModel && ( + + )}
- -
- Permission - -
- - {supportsVariants && ( + {activeModes.length > 0 && (
- Variant - {showVariantSelect ? ( - - ) : ( - setVariant(e.target.value)} - placeholder="Variant" - title="Variant" - /> - )} + Mode +
)} - - {/* MCP Servers - collapsible */} -
- - {mcpExpanded && ( -
- {mcpServers.length > 0 && !isEditingMcp && ( -
- {mcpServers.map((entry, index) => ( -
-
- {entry.name} - {getServerType(entry.configJson) && ( - {getServerType(entry.configJson)} - )} - {getServerSummary(entry.configJson)} -
-
- - -
-
- ))} -
- )} - {isEditingMcp ? ( -
- { setMcpName(e.target.value); setMcpLocalError(null); }} - placeholder="server-name" - disabled={editingMcpIndex !== null} - /> -