Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added .context/notes.md
Empty file.
Empty file added .context/todos.md
Empty file.
35 changes: 34 additions & 1 deletion docs/agent-sessions.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -96,13 +96,25 @@ 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");
await session.setMode("full-access");
await session.setThoughtLevel("medium");
```

```ts
await claude.setPermissionMode("acceptEdits");
```

Query available modes:

```ts
Expand All @@ -125,9 +137,30 @@ for (const opt of options) {
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`:

```ts
const claude = await sdk.createSession({
agent: "claude",
permissionMode: "default",
});

claude.onPermissionRequest((request) => {
console.log(request.toolCall.title, request.availableReplies);
void claude.replyPermission(request.id, "once");
});

await claude.prompt([
{ type: "text", text: "Create ./permission-example.txt with the text hello." },
]);
```

See `examples/claude-permissions/src/index.ts` for a complete Claude example with interactive approve/reject handling.

## Destroy a session

```ts
await sdk.destroySession(session.id);
```

13 changes: 13 additions & 0 deletions docs/sdk-overview.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -138,6 +138,19 @@ const options = await session.getConfigOptions();
const modes = await session.getModes();
```

Claude permission modes use the same surface:

```ts
const claude = await sdk.createSession({
agent: "claude",
permissionMode: "default",
});

claude.onPermissionRequest((request) => {
void claude.replyPermission(request.id, "once");
});
```

See [Agent Sessions](/agent-sessions) for full details on config options and error handling.

## Events
Expand Down
17 changes: 17 additions & 0 deletions examples/claude-permissions/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
{
"name": "@sandbox-agent/example-claude-permissions",
"private": true,
"type": "module",
"scripts": {
"start": "tsx src/index.ts",
"typecheck": "tsc --noEmit"
},
"dependencies": {
"sandbox-agent": "workspace:*"
},
"devDependencies": {
"@types/node": "latest",
"tsx": "latest",
"typescript": "latest"
}
}
158 changes: 158 additions & 0 deletions examples/claude-permissions/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import { createInterface } from "node:readline/promises";
import { stdin as input, stdout as output } from "node:process";
import {
SandboxAgent,
type PermissionReply,
type SessionPermissionRequest,
} from "sandbox-agent";

const permissionMode = process.env.CLAUDE_PERMISSION_MODE?.trim() || "default";
const autoReply = parsePermissionReply(process.env.CLAUDE_PERMISSION_REPLY);
const promptText =
process.env.CLAUDE_PERMISSION_PROMPT?.trim() ||
"Create ./permission-example.txt with the text 'hello from Claude permissions example'.";

const sdk = await SandboxAgent.start({
spawn: {
enabled: true,
log: "inherit",
},
});

try {
await sdk.installAgent("claude");

const agents = await sdk.listAgents({ config: true });
const claude = agents.agents.find((agent) => agent.id === "claude");
const configOptions = Array.isArray(claude?.configOptions)
? (claude.configOptions as Array<{ category?: string; options?: unknown[] }>)
: [];
const modeOption = configOptions.find((option) => option.category === "mode");
const availableModes = extractOptionValues(modeOption);

console.log(`Claude permission mode: ${permissionMode}`);
if (availableModes.length > 0) {
console.log(`Available Claude modes: ${availableModes.join(", ")}`);
}
console.log(`Working directory: ${process.cwd()}`);
console.log(`Prompt: ${promptText}`);
if (autoReply) {
console.log(`Automatic permission reply: ${autoReply}`);
} else {
console.log("Interactive permission replies enabled.");
}

const session = await sdk.createSession({
agent: "claude",
permissionMode,
sessionInit: {
cwd: process.cwd(),
mcpServers: [],
},
});

const rl = autoReply
? null
: createInterface({
input,
output,
});

session.onPermissionRequest((request: SessionPermissionRequest) => {
void handlePermissionRequest(session, request, autoReply, rl);
});

const response = await session.prompt([{ type: "text", text: promptText }]);
console.log(`Prompt finished with stopReason=${response.stopReason}`);

await rl?.close();
} finally {
await sdk.dispose();
}

async function handlePermissionRequest(
session: {
replyPermission(permissionId: string, reply: PermissionReply): Promise<void>;
},
request: SessionPermissionRequest,
auto: PermissionReply | null,
rl: ReturnType<typeof createInterface> | null,
): Promise<void> {
const reply = auto ?? (await promptForReply(request, rl));
console.log(`Permission ${reply}: ${request.toolCall.title ?? request.toolCall.toolCallId}`);
await session.replyPermission(request.id, reply);
}

async function promptForReply(
request: SessionPermissionRequest,
rl: ReturnType<typeof createInterface> | null,
): Promise<PermissionReply> {
if (!rl) {
return "reject";
}

const title = request.toolCall.title ?? request.toolCall.toolCallId;
const available = request.availableReplies;
console.log("");
console.log(`Permission request: ${title}`);
console.log(`Available replies: ${available.join(", ")}`);
const answer = (await rl.question("Reply [once|always|reject]: ")).trim().toLowerCase();
const parsed = parsePermissionReply(answer);
if (parsed && available.includes(parsed)) {
return parsed;
}

console.log("Invalid reply, defaulting to reject.");
return "reject";
}

function extractOptionValues(option: { options?: unknown[] } | undefined): string[] {
if (!option?.options) {
return [];
}

const values: string[] = [];
for (const entry of option.options) {
if (!entry || typeof entry !== "object") {
continue;
}
const value = "value" in entry && typeof entry.value === "string" ? entry.value : null;
if (value) {
values.push(value);
continue;
}
if (!("options" in entry) || !Array.isArray(entry.options)) {
continue;
}
for (const nested of entry.options) {
if (!nested || typeof nested !== "object") {
continue;
}
const nestedValue =
"value" in nested && typeof nested.value === "string" ? nested.value : null;
if (nestedValue) {
values.push(nestedValue);
}
}
}

return [...new Set(values)];
}

function parsePermissionReply(value: string | undefined): PermissionReply | null {
if (!value) {
return null;
}

switch (value.trim().toLowerCase()) {
case "once":
return "once";
case "always":
return "always";
case "reject":
case "deny":
return "reject";
default:
return null;
}
}
14 changes: 14 additions & 0 deletions examples/claude-permissions/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2022",
"lib": ["ES2022"],
"module": "ESNext",
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"noEmit": true,
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true
},
"include": ["src/**/*"]
}
Loading
Loading