mirror of
https://github.com/harivansh-afk/sandbox-agent.git
synced 2026-04-18 13:04:05 +00:00
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>
This commit is contained in:
parent
1720b6d0ba
commit
e8ffd78ac0
27 changed files with 812 additions and 563 deletions
|
|
@ -96,14 +96,6 @@ const session = await sdk.createSession({
|
|||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// Claude permission modes are exposed as a first-class helper.
|
||||
const claude = await sdk.createSession({
|
||||
agent: "claude",
|
||||
permissionMode: "default",
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
// After creation
|
||||
await session.setModel("gpt-5.2-codex");
|
||||
|
|
@ -111,10 +103,6 @@ await session.setMode("full-access");
|
|||
await session.setThoughtLevel("medium");
|
||||
```
|
||||
|
||||
```ts
|
||||
await claude.setPermissionMode("acceptEdits");
|
||||
```
|
||||
|
||||
Query available modes:
|
||||
|
||||
```ts
|
||||
|
|
@ -139,26 +127,41 @@ await session.setConfigOption("some-agent-option", "value");
|
|||
|
||||
## Handle permission requests
|
||||
|
||||
For agents that use ACP `session/request_permission` (for example Claude in `default` mode), register a permission listener and reply with `once`, `always`, or `reject`:
|
||||
For agents that request tool-use permissions, register a permission listener and reply with `once`, `always`, or `reject`:
|
||||
|
||||
```ts
|
||||
const claude = await sdk.createSession({
|
||||
const session = await sdk.createSession({
|
||||
agent: "claude",
|
||||
permissionMode: "default",
|
||||
mode: "default",
|
||||
});
|
||||
|
||||
claude.onPermissionRequest((request) => {
|
||||
session.onPermissionRequest((request) => {
|
||||
console.log(request.toolCall.title, request.availableReplies);
|
||||
void claude.replyPermission(request.id, "once");
|
||||
void session.respondPermission(request.id, "once");
|
||||
});
|
||||
|
||||
await claude.prompt([
|
||||
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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
@ -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 .
|
||||
|
|
|
|||
|
|
@ -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
|
||||
{
|
||||
|
|
|
|||
|
|
@ -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(...)`.
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -138,16 +138,16 @@ const options = await session.getConfigOptions();
|
|||
const modes = await session.getModes();
|
||||
```
|
||||
|
||||
Permission modes use the same surface:
|
||||
Handle permission requests from agents that ask before executing tools:
|
||||
|
||||
```ts
|
||||
const claude = await sdk.createSession({
|
||||
agent: "claude",
|
||||
permissionMode: "default",
|
||||
mode: "default",
|
||||
});
|
||||
|
||||
claude.onPermissionRequest((request) => {
|
||||
void claude.replyPermission(request.id, "once");
|
||||
void claude.respondPermission(request.id, "once");
|
||||
});
|
||||
```
|
||||
|
||||
|
|
@ -222,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()`
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue