Add ACP permission mode support to the SDK (#224)

* chore: recover hamburg workspace state

* chore: drop workspace context files

* refactor: generalize permissions example

* refactor: parse permissions example flags

* docs: clarify why fs and terminal stay native

* feat: add interactive permission prompt UI to Inspector

Add permission request handling to the Inspector UI so users can
Allow, Always Allow, or Reject tool calls that require permissions
instead of having them auto-cancelled. Wires up SDK
onPermissionRequest/respondPermission through App → ChatPanel →
ChatMessages with proper toolCallId-to-pendingId mapping.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

* fix: prevent permission reply from silently escalating "once" to "always"

Remove allow_always from the fallback chain when the user replies "once",
aligning with the ACP spec which says "map by option kind first" with no
fallback for allow_once. Also fix Inspector to use rawSend, revert
hydration guard to accept empty configOptions, and handle respondPermission
errors by rejecting the pending promise.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Nathan Flurry 2026-03-10 21:52:43 -07:00 committed by GitHub
parent 5d65013aa5
commit 76586f409f
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
35 changed files with 1786 additions and 472 deletions

View file

@ -125,9 +125,45 @@ for (const opt of options) {
await session.setConfigOption("some-agent-option", "value");
```
## Handle permission requests
For agents that request tool-use permissions, register a permission listener and reply with `once`, `always`, or `reject`:
```ts
const session = await sdk.createSession({
agent: "claude",
mode: "default",
});
session.onPermissionRequest((request) => {
console.log(request.toolCall.title, request.availableReplies);
void session.respondPermission(request.id, "once");
});
await session.prompt([
{ type: "text", text: "Create ./permission-example.txt with the text hello." },
]);
```
### Auto-approving permissions
To auto-approve all permission requests, respond with `"once"` or `"always"` in your listener:
```ts
session.onPermissionRequest((request) => {
void session.respondPermission(request.id, "always");
});
```
See `examples/permissions/src/index.ts` for a complete permissions example that works with Claude and Codex.
<Info>
Some agents like Claude allow configuring permission behavior through modes (e.g. `bypassPermissions`, `acceptEdits`). We recommend leaving the mode as `default` and handling permission decisions explicitly in `onPermissionRequest` instead.
</Info>
## Destroy a session
```ts
await sdk.destroySession(session.id);
```

View file

@ -58,4 +58,4 @@ Use the filesystem API to upload files, then include file references in prompt c
- 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.
- Support for non-text resources depends on each agent's prompt capabilities.

View file

@ -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<string, ItemState>();
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 <Markdown>{part.text}</Markdown>;
case "tool_call":
return <ToolCall name={part.name} args={part.arguments} />;
case "tool_result":
return <ToolResult output={part.output} />;
case "file_ref":
return <FileChange path={part.path} action={part.action} diff={part.diff} />;
case "reasoning":
return <Reasoning>{part.text}</Reasoning>;
case "status":
return <Status label={part.label} detail={part.detail} />;
case "image":
return <Image src={part.path} />;
}
}
```
---
## Handling Permissions
When `permission.requested` arrives, show an approval UI:
```ts
const pendingPermissions = new Map<string, PermissionEventData>();
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 (
<div>
<p>Allow: {data.action}</p>
<button onClick={() => replyPermission(data.permission_id, "once")}>
Allow Once
</button>
<button onClick={() => replyPermission(data.permission_id, "always")}>
Always Allow
</button>
<button onClick={() => replyPermission(data.permission_id, "reject")}>
Reject
</button>
</div>
);
}
```
---
## Handling Questions
When `question.requested` arrives, show a selection UI:
```ts
const pendingQuestions = new Map<string, QuestionEventData>();
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<string[]>([]);
return (
<div>
<p>{data.prompt}</p>
{data.options.map((option) => (
<label key={option}>
<input
type="checkbox"
checked={selected.includes(option)}
onChange={(e) => {
if (e.target.checked) {
setSelected([...selected, option]);
} else {
setSelected(selected.filter((s) => s !== option));
}
}}
/>
{option}
</label>
))}
<button onClick={() => answerQuestion(data.question_id, [selected])}>
Submit
</button>
<button onClick={() => rejectQuestion(data.question_id)}>
Reject
</button>
</div>
);
}
```
---
## 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 <text>` | 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.

View file

@ -181,7 +181,7 @@ sandbox-agent api agents list
#### api agents report
Emit a JSON report of available models, modes, and thought levels for every agent. Calls `GET /v1/agents?config=true` and groups each agent's config options by category.
Emit a JSON report of available models, modes, and thought levels for every agent, grouped by category.
```bash
sandbox-agent api agents report --endpoint http://127.0.0.1:2468 | jq .

View file

@ -61,7 +61,7 @@ When prompting, Sandbox Agent does not pre-validate provider credentials. Agent-
### API
`GET /v1/agents` includes `credentialsAvailable` per agent.
`sdk.listAgents()` includes `credentialsAvailable` per agent.
```json
{

View file

@ -115,8 +115,8 @@ This keeps all Sandbox Agent calls inside the Cloudflare sandbox routing path an
## Troubleshooting streaming updates
If you only receive:
- outbound `session/prompt`
- final `{ stopReason: "end_turn" }`
- the outbound prompt request
- the final `{ stopReason: "end_turn" }` response
then the streamed update channel dropped. In Cloudflare sandbox paths, this is typically caused by forwarding `AbortSignal` from SDK fetch init into `containerFetch(...)`.

View file

@ -34,6 +34,7 @@ console.log(url);
- Event JSON inspector
- Prompt testing
- Request/response debugging
- Interactive permission prompts (approve, always-allow, or reject tool-use requests)
- Process management (create, stop, kill, delete, view logs)
- Interactive PTY terminal for tty processes
- One-shot command execution

View file

@ -6,8 +6,6 @@ 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
@ -18,11 +16,11 @@ This prevents duplicate writes and lets you recover from disconnects.
## Receiving Events
Two ways to receive events: SSE streaming (recommended) or polling.
Two ways to receive events: streaming (recommended) or polling.
### Streaming
Use SSE for real-time events with automatic reconnection support.
Use streaming for real-time events with automatic reconnection support.
```typescript
import { SandboxAgentClient } from "sandbox-agent";
@ -44,7 +42,7 @@ for await (const event of client.streamEvents("my-session", { offset })) {
### Polling
If you can't use SSE streaming, poll the events endpoint:
If you can't use streaming, poll the events endpoint:
```typescript
const lastEvent = await db.getLastEvent("my-session");
@ -244,7 +242,7 @@ const events = await redis.lrange(`session:${sessionId}`, offset, -1);
## Handling disconnects
The SSE stream may disconnect due to network issues. Handle reconnection gracefully:
The event stream may disconnect due to network issues. Handle reconnection gracefully:
```typescript
async function streamWithRetry(sessionId: string) {

View file

@ -9,7 +9,7 @@ The process API supports:
- **One-shot execution** — run a command to completion and capture stdout, stderr, and exit code
- **Managed processes** — spawn, list, stop, kill, and delete long-lived processes
- **Log streaming** — fetch buffered logs or follow live output via SSE
- **Log streaming** — fetch buffered logs or follow live output
- **Terminals** — full PTY support with bidirectional WebSocket I/O
- **Configurable limits** — control concurrency, timeouts, and buffer sizes per runtime
@ -155,7 +155,7 @@ curl "http://127.0.0.1:2468/v1/processes/proc_1/logs?tail=50&stream=combined"
```
</CodeGroup>
### Follow logs via SSE
### Follow logs
Stream log entries in real time. The subscription replays buffered entries first, then streams new output as it arrives.

View file

@ -138,6 +138,19 @@ const options = await session.getConfigOptions();
const modes = await session.getModes();
```
Handle permission requests from agents that ask before executing tools:
```ts
const claude = await sdk.createSession({
agent: "claude",
mode: "default",
});
claude.onPermissionRequest((request) => {
void claude.respondPermission(request.id, "once");
});
```
See [Agent Sessions](/agent-sessions) for full details on config options and error handling.
## Events
@ -209,6 +222,6 @@ Parameters:
- `baseUrl` (required unless `fetch` is provided): Sandbox Agent server URL
- `token` (optional): Bearer token for authenticated servers
- `headers` (optional): Additional request headers
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and ACP calls
- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and ACP session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait
- `fetch` (optional): Custom fetch implementation used by SDK HTTP and session calls
- `waitForHealth` (optional, defaults to enabled): waits for `/v1/health` before HTTP helpers and session setup proceed; pass `false` to disable or `{ timeoutMs }` to bound the wait
- `signal` (optional): aborts the startup `/v1/health` wait used by `connect()`