puku-agent-sdk
v3.1.4
Published
TypeScript SDK for building agents on top of puku-cli. Spawns the puku-cli binary as a subprocess and streams NDJSON over stdio.
Maintainers
Readme
puku-agent-sdk
A TypeScript SDK that spawns the puku-cli binary as a subprocess and streams typed messages over NDJSON over stdio. Designed as a programmatic interface for building agents on top of puku-cli.
- Status: v3.1.1
- Node: ≥ 20
- Runtime: requires
puku-clion$PATH(or passoptions.pathToPukuCliExecutable) - License: MIT
Table of contents
- Installation
- Authentication
- Quickstart
- API reference
- Tools (in-process)
- MCP servers
- Hooks
- Permissions
- Sandbox
- Sessions
- Session management helpers
- Session store customization
- Streaming input & WarmQuery
- Control protocol
- Structured output
- Settings
- Capabilities
- Startup (process-wide state)
- Lifecycle helpers
- Compatibility & manifest
- Options reference
- Common recipes
- Environment variables
- Errors
- Troubleshooting
- License
Installation
# with bun
bun add puku-agent-sdk
# with npm
npm install puku-agent-sdk
# with pnpm
pnpm add puku-agent-sdkYou also need the puku-cli binary on $PATH:
# Install globally via npm
npm install -g @puku/puku-cli
# Or via bun
bun add -g @puku/puku-cli
# verify
puku-cli --versionIf you can't put puku-cli on $PATH, pass options.pathToPukuCliExecutable:
import { query } from 'puku-agent-sdk';
for await (const msg of query({
prompt: 'Hello',
options: { pathToPukuCliExecutable: '/usr/local/bin/puku-cli' },
})) {
console.log(msg);
}Node version
node --version # must be >= v20.0.0Authentication
The SDK does not speak to the LLM directly. It spawns puku-cli and forwards auth via environment variables. The CLI exchanges those for the upstream provider's credentials.
puku-cli reads PUKU_AI_API_KEY (init apiKeySource). Set that name — there is no PUKU_API_KEY fallback. The SDK merges options.env over process.env before spawning the subprocess.
| Source | API key env var | Gateway URL env var |
|---|---|---|
| CLI native (recommended for CI/VM) | PUKU_AI_API_KEY | PUKU_BASE_URL |
Set them in your shell before invoking the SDK:
# CI / VM (no OAuth login available)
export PUKU_AI_API_KEY=pk_<your-key>
export PUKU_BASE_URL=""
node ./your-agent.mjsimport { query } from 'puku-agent-sdk';
for await (const msg of query({
prompt: 'hello',
// env is inherited automatically; the SDK merges options.env on top of process.env
})) {
// ...
}The SDK never logs API keys — argv redaction is enforced at build time. API keys travel in process.env, not in argv.
Mint a key
Sign in to https://puku.sh and create a key from the admin UI. Keys are 32 bytes of entropy → 43 chars of base64url after the prefix.
Per-query overrides
To scope auth per-call (e.g. when the same Node process serves multiple tenants), pass options.env:
for await (const msg of query({
prompt: 'hello',
options: {
env: {
PUKU_AI_API_KEY: process.env.PUKU_AI_API_KEY!,
PUKU_BASE_URL: '',
},
},
})) {
// ...
}Quickstart
import { query } from 'puku-agent-sdk';
const result = query({
prompt: 'What is 2 + 2?',
options: { model: 'puku-default' },
});
for await (const msg of result) {
if (msg.type === 'assistant') {
for (const block of msg.message.content) {
if (block.type === 'text') {
console.log('assistant:', block.text);
}
}
} else if (msg.type === 'result') {
console.log('done — turns:', msg.num_turns, 'cost: $', msg.total_cost_usd);
}
}The result of query({…}) is an AsyncIterable<SDKMessage>. One-shot callers can for await directly. Long-lived sessions get back a WarmQuery with the live control surface — see Streaming input & WarmQuery.
Multi-turn
import { query } from 'puku-agent-sdk';
const warm = query({
prompt: 'Hi!',
options: { sessionId: crypto.randomUUID() },
});
// consumer loop
const consumer = (async () => {
for await (const msg of warm.messages()) {
if (msg.type === 'assistant') console.log('A:', msg.message.content);
}
})();
// follow-up later
setTimeout(() => warm.send({ type: 'user', message: { role: 'user', content: 'now do X' } }), 500);
await warm.close();
await consumer.catch(() => undefined);API reference
The package exports everything from a single root entry point: puku-agent-sdk.
import {
// Core
query,
WarmQuery,
Transport,
ControlProtocol,
// Validation + errors
validateOptions,
OptionsValidationError,
AbortError,
JsonSchemaValidationError,
UnsupportedCapabilityError,
CompatError,
HARNESS_SCHEMA,
SDK_VERSION,
checkCompatibility,
readSdkManifest,
// Lifecycle
startup,
shutdown,
getStartedState,
getAccountInfo,
getModelInfo,
// Sessions
listSessions,
getSessionInfo,
getSessionMessages,
listSubagents,
getSubagentMessages,
forkSession,
deleteSession,
renameSession,
tagSession,
importSessionToStore,
LocalFsSessionStore,
InMemorySessionStore,
// MCP
createSdkMcpServer,
startMcpBridge,
// Output
materializeJsonSchemaResult,
materializeStructuredResult,
// Hooks
HookRegistry,
DEFAULT_HOOK_TIMEOUT_MS,
// Capabilities
parseInitCapabilities,
hasCapability,
assertCapabilitySupported,
// Structured output
type JsonSchemaOutputFormat,
type Options,
type SDKMessage,
type WarmQuery,
type HookEvent,
type HookCallbackMatcher,
} from 'puku-agent-sdk';Message types
Every iteration yields an SDKMessage discriminated by type:
| type | Subtypes / fields | When |
|---|---|---|
| 'system' | subtype: 'init' \| … | Once at start (capabilities + session info) and for notifications |
| 'user' | message: { role: 'user', content } | Echo of the user prompt you sent |
| 'assistant' | message: { role: 'assistant', content: ContentBlock[] } | Model turn (text, tool_use, thinking) |
| 'result' | subtype: 'success' \| 'error_max_turns' \| … | Terminal envelope with usage + cost |
| 'stream_event' | event: { type: 'content_block_delta' \| … } | Streaming delta (when includePartialMessages: true) |
ContentBlock is a discriminated union over text | tool_use | tool_result | thinking | image. Narrow with block.type.
Tools (in-process)
This is the supported way to give the agent custom tools. Do not use options.tools / toolChoice / toolConfig / toolAliases — those are 🟡 typed-only and ignored at runtime.
Use createSdkMcpServer to expose in-process tools to the agent as if they were MCP servers. The SDK starts a local stdio bridge for the duration of the session.
import { z } from 'zod';
import { createSdkMcpServer, query, tool } from 'puku-agent-sdk';
// Pass a Zod *raw shape* ({ field: z.xxx() }), not z.object({...}).
// createSdkMcpServer currently rejects a wrapped ZodObject at registration.
const lookup = tool(
'lookup',
'Look up a customer by id',
{ id: z.string() },
async ({ id }) => ({ content: [{ type: 'text', text: `customer ${id}` }] }),
);
const server = createSdkMcpServer({ name: 'workspace', tools: [lookup] });
for await (const msg of query({
prompt: 'Look up customer 42',
options: {
mcpServers: { workspace: server },
allowedTools: ['mcp__workspace__lookup'],
},
})) {
// ...
}The SDK starts a temporary stdio bridge inside the subprocess's stdio, wires every mcp__workspace__* tool call through the bridge, and tears the bridge down when the session ends.
MCP servers
stdio / sse / http
options: {
mcpServers: {
github: { type: 'stdio', command: 'mcp-server-github', args: ['--readonly'] },
remote: { type: 'sse', url: 'https://mcp.example.com/sse' },
custom: { type: 'http', url: 'https://mcp.example.com/mcp', headers: { Authorization: 'Bearer …' } },
},
}SDK-defined (in-process)
See Tools (in-process) above.
Per-server tool policy
options: {
mcpServers: {
workspace: {
type: 'sdk',
name: 'workspace',
tools: { allow: ['lookup', 'search'], deny: ['delete_*'] },
},
},
}Hooks
Hooks let you intercept every lifecycle event of the agent loop. The SDK supports 33 hook events, discriminated by hook_event_name on the input payload.
Registering callbacks
import { query, type HookCallbackMatcher } from 'puku-agent-sdk';
const hooks: Record<string, HookCallbackMatcher[]> = {
PreToolUse: [
{ matcher: 'Bash|Write', hooks: [async (input) => {
console.log('about to run', input.tool_name);
return { continue: true };
}] },
],
SessionStart: [{ hooks: [async () => ({ continue: true, hookSpecificOutput: { hookEventName: 'SessionStart', additionalContext: 'today is ' + new Date().toISOString() } })] }],
PostToolUseFailure: [{ hooks: [async (input) => ({ continue: true, suppressOutput: false })] }],
};
for await (const msg of query({
prompt: '…',
options: {
hooks,
includeHookEvents: true,
},
})) { /* … */ }Supported events (33)
| Group | Events |
|---|---|
| Tool lifecycle | PreToolUse, PostToolUse, PostToolUseFailure, PostToolBatch |
| User prompt | UserPromptSubmit, UserPromptExpansion |
| Session | SessionStart, SessionEnd |
| Turn termination | Stop, StopFailure |
| Subagent | SubagentStart, SubagentStop |
| Compaction | PreCompact, PostCompact |
| Model switch | PreModelSwitch, PostModelSwitch |
| Permission flow | PermissionRequest, PermissionDenied |
| Setup / notify | Setup, Notification |
| Team / task | TeammateIdle, TaskCreated, TaskCompleted |
| MCP elicitation | Elicitation, ElicitationResult |
| Misc lifecycle | ConfigChange, WorktreeCreate, WorktreeRemove, InstructionsLoaded, CwdChanged, FileChanged, DirectoryAdded, MessageDisplay |
Hook outputs
A hook returns AsyncHookJSONOutput (or void to defer):
interface HookJSONOutput {
continue: boolean;
stopReason?: string;
suppressOutput?: boolean;
decision?: 'approve' | 'block' | undefined;
reason?: string;
hookSpecificOutput?: HookSpecificOutput;
// Permission hook-specific:
behavior?: 'allow' | 'deny' | 'ask';
updatedPermissions?: PermissionUpdate[];
}Per-event HookSpecificOutput types are exported — see the *HookSpecificOutput types in the hooks barrel.
Process-wide registry
For app-wide hooks that survive across many query() calls, register with startup() and getStartedState().hooks. Every query() automatically picks up the global registry.
Permissions
The SDK exposes six permission modes, mapped 1:1 to the CLI's --permission-mode flag:
| Mode | CLI flag | Behavior |
|---|---|---|
| 'default' | default | Prompt when a tool is not pre-approved |
| 'acceptEdits' | acceptEdits | Auto-approve edit tools; prompt for others |
| 'plan' | plan | Plan mode; tool use is constrained to planning |
| 'bypassPermissions' | bypassPermissions | Auto-approve everything — requires allowDangerouslySkipPermissions: true |
| 'dontAsk' | dontAsk | Never prompt; deny if not pre-approved |
| 'auto' | auto | Model classifier approves or denies |
Per-tool handler
canUseTool is the SDK's escape hatch for fine-grained permission logic. It's fail-closed — return { behavior: 'deny', reason: '…' } to block.
options: {
permissionMode: 'default',
canUseTool: async (toolName, input, { signal, suggestions }) => {
if (toolName === 'Bash' && String(input.command ?? '').startsWith('rm -rf')) {
return { behavior: 'deny', reason: 'no recursive rm' };
}
return { behavior: 'allow', updatedPermissions: suggestions };
},
}Allow / deny rules
options: {
allowedTools: ['Read', 'Edit', 'mcp__workspace__search'],
disallowedTools: ['Bash'],
}--allowed-tools / --disallowed-tools are forwarded to the CLI verbatim.
MCP permission override
For long-lived sessions, tighten MCP permissions at runtime via warm.setMcpPermissionModeOverride(server, mode). Tighten-only: 'default' | 'auto' | null — widening back to 'allowEdits' is rejected.
Sandbox
The SDK accepts a sandbox config object and materializes it to a tmp file (mode 0o600) before launching.
options: {
sandbox: {
enabled: true,
network: { allowedDomains: ['api.github.com'], deniedDomains: ['*.example.com'] },
filesystem: { allowWrite: ['./build'], denyRead: ['~/.ssh'] },
ignoreViolations: { '*': ['Bash(rm:*)'] },
},
}Or use the granular shortcuts (sandboxFiles, sandboxNetwork) which are merged into the same --sandbox-config JSON.
Sessions
Resume / fork / continue
// Resume an existing session.
options: { resume: '00000000-0000-4000-8000-000000000001' }
// Resume the most recent session in the cwd.
options: { continue: true }
// Resume + fork into a new session id (history preserved).
options: { resume: '<id>', forkSession: true, sessionId: '<new-id>' }
// Resume from a specific message.
options: { resume: '<id>', resumeSessionAt: '<message-uuid>' }On-disk layout
LocalFsSessionStore (the default) writes per-project directories under:
~/.local/share/puku-cli/projects/<encoded-cwd>/<sessionId>.jsonl
~/.local/share/puku-cli/projects/<encoded-cwd>/<sessionId>-summary.json<encoded-cwd> is the absolute cwd, percent-encoded. Subagent transcripts are nested under a sibling <sessionId>/<agent-id>.jsonl tree.
The SDK returns SessionInfo[] with metadata — firstPrompt, lastPrompt, mtime, tags, etc.
Tagging / renaming
tagSession takes a single tag (or null to clear). Call once per tag:
import { tagSession, renameSession } from 'puku-agent-sdk';
await tagSession('00000000-...', 'production');
await tagSession('00000000-...', 'q3-rollout');
await renameSession('00000000-...', 'customer-onboarding-v1');Subagents
A single agent can spawn subagents. Use listSubagents + getSubagentMessages to inspect them:
const subagents = await listSubagents('00000000-...');
for (const sub of subagents) {
const messages = await getSubagentMessages('00000000-...', sub.subagentId);
console.log(`subagent ${sub.agentType}: ${messages.length} messages`);
}File checkpointing
Set enableFileCheckpointing: true to enable WarmQuery.rewindFiles():
const warm = query({
prompt: 'edit /tmp/x.txt',
options: { sessionId, enableFileCheckpointing: true },
}) as WarmQuery;
// … agent runs, makes edits …
const result = await warm.rewindFiles(checkpointUuid);
if (result.canRewind) {
console.log('rolled back to', checkpointUuid);
}Requires the rewind_files capability on the CLI's init message.
Persistence controls
| Field | Default | Effect |
|---|---|---|
| persistSession | true | Skip persistence when false |
| loadTimeoutMs | undefined | Override the CLI's session-load timeout |
| sessionStoreFlush | 'batched' | 'eager' flushes per message |
| sessionStore | LocalFsSessionStore | Swap for InMemorySessionStore in tests |
Session management helpers
In addition to the per-query resume / fork / continue knobs, the SDK exposes 10 standalone session helpers for managing transcripts independent of any in-flight agent. Use these for history browsers, "replay this past run" UIs, audit pipelines, or programmatic cleanup.
import {
listSessions,
getSessionInfo,
getSessionMessages,
listSubagents,
getSubagentMessages,
forkSession,
deleteSession,
renameSession,
tagSession,
importSessionToStore,
} from 'puku-agent-sdk';Listing sessions
const infos = await listSessions({ dir: process.cwd(), limit: 20 });
for (const info of infos) {
console.log(`${info.sessionId} ${info.summary}`);
console.log(` ${info.firstPrompt ?? '(no prompt)'}`);
console.log(` tag: ${info.tag ?? '(none)'} branch: ${info.gitBranch ?? '(none)'}`);
console.log(` created: ${new Date(info.createdAt ?? 0).toISOString()}`);
}PukuSessionInfo carries: sessionId, summary, lastModified, fileSize, customTitle, firstPrompt, gitBranch, cwd, tag, createdAt.
Pagination via offset + limit. includeWorktrees: true (default) follows sibling worktrees; includeProgrammatic: true (default) includes SDK-driven sessions.
Reading messages
const messages = await getSessionMessages('00000000-0000-4000-8000-000000000001', {
dir: process.cwd(),
includeSystemMessages: true, // include compact / informational / etc.
limit: 50,
});
for (const m of messages) {
console.log(m.type, m.uuid, m.parent_tool_use_id);
}SessionMessage shape: { type: 'user' | 'assistant' | 'system', uuid, session_id, message, parent_tool_use_id, parent_agent_id }. The parent_agent_id lets you trace subagent trees.
Programmatic forking
const result = await forkSession(
'00000000-0000-4000-8000-000000000001',
{ upToMessageId: 'uuid-of-cut-point', title: 'branch-experiment-1' },
);
console.log('forked session id:', result.sessionId);
// then resume the fork:
for await (const msg of query({
prompt: 'continue',
options: { resume: result.sessionId },
})) { /* ... */ }Tagging, renaming, deleting
await tagSession(sessionId, 'production'); // set
await tagSession(sessionId, null as unknown as string); // clear (see note)
await renameSession(sessionId, 'incident-2026-08-10');
await deleteSession(sessionId, { dir: process.cwd() });To clear a tag, pass an empty string or omit the field — the CLI's on-disk store treats the absence as "no tag".
Subagent introspection
const subs = await listSubagents(sessionId, { dir: process.cwd() });
for (const sub of subs) {
console.log(`subagent ${sub.agentType ?? sub.agent_id}`);
const history = await getSubagentMessages(sessionId, sub.subagent_id, { dir: process.cwd() });
console.log(` ${history.length} messages`);
}Importing on-disk sessions to a SessionStore
When you've configured a custom SessionStore (e.g. S3-backed archival), backfill it from the local filesystem:
await importSessionToStore('00000000-0000-4000-8000-000000000001', {
dir: process.cwd(),
includeSubagents: true,
batchSize: 500,
});All session helpers accept a sessionStore option (where applicable) so they read/write against a custom store instead of the local filesystem.
Session store customization
sessionStore lets you mirror every session transcript to a backend of your choice — Postgres, Redis, S3, an internal audit log — while the CLI continues writing to local disk for durability. The SDK never deletes from your store unless store.delete? is implemented.
Built-in stores
The SDK ships with two ready-to-use adapters:
import {
LocalFsSessionStore,
InMemorySessionStore,
} from 'puku-agent-sdk';
// Default — mirrors to disk under `~/.local/share/puku-cli/projects/…`.
const fs = new LocalFsSessionStore();
// Tests — keeps everything in memory.
const mem = new InMemorySessionStore();
for await (const msg of query({
prompt: '…',
options: { sessionStore: mem },
})) { /* … */ }Implementing a custom store
Implement the SessionStore interface. All methods are required except those marked optional.
import type { SessionStore, SessionKey, SessionStoreEntry } from 'puku-agent-sdk';
class PostgresSessionStore implements SessionStore {
constructor(private readonly pool: import('pg').Pool) {}
async append(key: SessionKey, entries: SessionStoreEntry[]): Promise<void> {
// entries arrive at ~100ms cadence; use `entry.uuid` as an idempotency
// key to absorb retries and replay-from-import calls.
await this.pool.query(
`INSERT INTO transcript_entries (project_key, session_id, subpath, uuid, payload)
VALUES ($1, $2, $3, $4, $5)
ON CONFLICT (uuid) DO NOTHING`,
entries.map((e) => [key.projectKey, key.sessionId, key.subpath ?? null, e.uuid ?? null, e]),
);
}
async load(key: SessionKey): Promise<SessionStoreEntry[] | null> {
const { rows } = await this.pool.query(
`SELECT payload FROM transcript_entries
WHERE project_key = $1 AND session_id = $2 AND subpath IS NOT DISTINCT FROM $3
ORDER BY ordinal ASC`,
[key.projectKey, key.sessionId, key.subpath ?? null],
);
return rows.length ? rows.map((r) => r.payload) : null;
}
async listSessions(projectKey: string) {
const { rows } = await this.pool.query(
`SELECT session_id, MAX(modified_at) AS mtime
FROM transcript_entries WHERE project_key = $1
GROUP BY session_id ORDER BY mtime DESC`,
[projectKey],
);
return rows.map((r) => ({ sessionId: r.session_id, mtime: Number(r.mtime) }));
}
async delete(key: SessionKey): Promise<void> {
await this.pool.query(
`DELETE FROM transcript_entries WHERE project_key = $1 AND session_id = $2`,
[key.projectKey, key.sessionId],
);
}
async listSubkeys(key: { projectKey: string; sessionId: string }): Promise<string[]> {
const { rows } = await this.pool.query(
`SELECT DISTINCT subpath FROM transcript_entries
WHERE project_key = $1 AND session_id = $2 AND subpath IS NOT NULL`,
[key.projectKey, key.sessionId],
);
return rows.map((r) => r.subpath);
}
}
for await (const msg of query({
prompt: '…',
options: { sessionStore: new PostgresSessionStore(pool) },
})) { /* … */ }Reference: SessionStore interface
interface SessionStore {
append(key: SessionKey, entries: SessionStoreEntry[]): Promise<void>;
load(key: SessionKey): Promise<SessionStoreEntry[] | null>;
listSessions?(projectKey: string): Promise<Array<{ sessionId: string; mtime: number }>>;
listSessionSummaries?(projectKey: string): Promise<SessionSummaryEntry[]>;
delete?(key: SessionKey): Promise<void>;
listSubkeys?(key: { projectKey: string; sessionId: string }): Promise<string[]>;
}
interface SessionKey {
projectKey: string; // default: percent-encoded cwd
sessionId: string;
subpath?: string; // undefined for main transcript; set for subagents
}SessionSummaryEntry (mtime, opaque data) is the incrementally-maintained summary the SDK folds inside your append() — the helper foldSessionSummary and summaryToSessionInfo are exported for this purpose.
Streaming input & WarmQuery
For multi-turn agents, pass an AsyncIterable<QueryUserMessage> as the prompt. The SDK forwards each message as it becomes available — pipe user input from stdin, a websocket, or a queue; inject messages mid-flight; stream tool results back into the conversation.
Basic multi-turn
import { query, type QueryUserMessage } from 'puku-agent-sdk';
async function* prompts() {
yield { type: 'user', message: { role: 'user', content: 'hi, my name is Ada' } } satisfies QueryUserMessage;
yield { type: 'user', message: { role: 'user', content: 'what is my name?' } } satisfies QueryUserMessage;
}
for await (const msg of query({ prompt: prompts() })) {
if (msg.type === 'assistant') {
console.log('assistant:', msg.message.content);
}
}Mid-flight input via WarmQuery
For long-lived sessions, use sessionId (or resume / continue) to get a WarmQuery. Then send additional messages with .send():
import { query, type WarmQuery } from 'puku-agent-sdk';
const warm = query({
prompt: 'hi',
options: { sessionId: crypto.randomUUID() },
}) as WarmQuery;
// Consumer loop runs in the background.
const consumer = (async () => {
for await (const msg of warm.messages()) {
if (msg.type === 'assistant') console.log('assistant:', msg.message.content);
}
})();
// Wait a beat for the agent to settle, then send a follow-up.
await new Promise((r) => setTimeout(r, 500));
warm.send({ type: 'user', message: { role: 'user', content: 'now do X' } });
await warm.close();
await consumer.catch(() => undefined);Control methods on WarmQuery
| Method | Capability | What it does |
|---|---|---|
| interrupt() | interrupt | Sends a control_request with subtype interrupt |
| setModel(model?) | set_model | Switches the model mid-session |
| setPermissionMode(mode) | set_permission_mode | Toggles permission modes |
| setMcpPermissionModeOverride(server, mode) | set_mcp_permission_mode_override | Per-MCP tighten-only override (default/auto/null); requires CLI capability |
| setMcpServers(servers) | mcp_set_servers | Adds or replaces MCP servers dynamically |
| rewindFiles(uuid) | rewind_files | Rolls back to a previous file-checkpoint |
| initializationResult() | — | Reads the parsed init capabilities |
| effectiveSettings() | — | Reads the merged ResolvedSettings |
| messages() | — | The async iterator of SDKMessage |
| send(message) | — | Pushes an additional user message |
| close() | — | Tears the subprocess down |
Every control method is gated behind a capability declared on the CLI's init system message. Calling warm.interrupt() against a CLI that didn't advertise interrupt throws UnsupportedCapabilityError.
Control protocol
Underneath WarmQuery, all control traffic flows over a bidirectional request/response protocol riding on the same NDJSON stdio stream. Most users never need to touch it — WarmQuery is the ergonomic wrapper — but the lower-level ControlProtocol class is exported for advanced integrations and for routing inbound control requests from the CLI.
Wire format
// SDK → CLI (outbound request)
{ "type": "control_request", "request_id": "uuid", "request": { "subtype": "interrupt" } }
// SDK → CLI (cancel)
{ "type": "control_cancel_request", "request_id": "uuid" }
// CLI → SDK (response to an outbound request)
{ "type": "control_response", "request_id": "uuid", "response": { "subtype": "success" } }
// CLI → SDK (inbound request — e.g. permission dialog)
{ "type": "control_request", "request_id": "uuid", "request": { "subtype": "permission_request", "tool_name": "Bash", "tool_input": {...} } }Outbound subtypes (SDK → CLI)
| subtype | Capability | Purpose |
|---|---|---|
| interrupt | interrupt | Abort the current turn. |
| set_model | set_model | Change the active model mid-session. |
| set_permission_mode | set_permission_mode | Change the permission mode. |
| mcp_set_servers | mcp_set_servers | Replace the MCP server set. |
| rewind_files | rewind_files | Roll back to a checkpoint. |
Inbound subtypes (CLI → SDK)
| subtype | Handler |
|---|---|
| permission_request | canUseTool callback on Options |
| elicitation_request | Options.onElicitation |
| user_dialog_request | Options.onUserDialog |
| hook_callback | Fires registered Options.hooks |
Sending a raw control request
If you need to send a control envelope the SDK doesn't expose (or want to drive a custom capability-gated flow), use ControlProtocol directly:
import {
ControlProtocol,
Transport,
newRequestId,
routeControlEnvelope,
} from 'puku-agent-sdk';
import { spawn } from 'node:child_process';
// Lower-level example — most users should use WarmQuery instead.
const transport = new Transport({
binaryPath: 'puku-cli',
args: ['--print', '--output-format', 'stream-json', '--input-format', 'stream-json', '--verbose'],
});
const control = new ControlProtocol(transport);
await transport.start();
// Send a raw interrupt with a custom timeout.
const reply = await control.request('interrupt', undefined, {
requestId: newRequestId(),
timeoutMs: 5_000,
});
console.log('interrupt reply:', reply);The WarmQuery ergonomics (warm.interrupt(), warm.setModel(), etc.) are thin wrappers over control.request() — see [runtime/query.ts] for the exact mapping.
Routing inbound envelopes
If you want to drive the iterator yourself (e.g. embed the SDK in a custom stream consumer), use routeControlEnvelope to dispatch inbound control_request / control_response envelopes to the right place:
for await (const msg of transport.messages()) {
routeControlEnvelope(msg, control); // dispatches to pending requests / requestHandlers
// …your own consumption logic…
}Structured output
Pass a JSON Schema and the SDK ensures every result message carries a typed structured_output field. Validation failures throw JsonSchemaValidationError with the full error list.
Basic
import { query, type JsonSchemaOutputFormat } from 'puku-agent-sdk';
const personSchema: JsonSchemaOutputFormat = {
type: 'json_schema',
schema: {
type: 'object',
properties: {
name: { type: 'string', minLength: 1 },
age: { type: 'integer', minimum: 0, maximum: 150 },
},
required: ['name'],
additionalProperties: false,
},
};
for await (const msg of query({
prompt: 'Extract: "Ada Lovelace, 36 years old"',
options: { outputFormat: personSchema },
})) {
if (msg.type === 'result' && msg.subtype === 'success') {
console.log(msg.structured_output);
// { name: 'Ada Lovelace', age: 36 }
console.log(msg.structured_output_valid); // true
}
}The SDK forwards --json-schema <schema> --output-format json to the CLI, parses the assistant's reply, and validates against the supplied schema subset.
Validation failures
If the assistant's reply doesn't match the schema, the SDK throws JsonSchemaValidationError:
try {
for await (const msg of query({ prompt, options: { outputFormat: personSchema } })) {
/* ... */
}
} catch (err) {
if (err instanceof JsonSchemaValidationError) {
console.error('assistant produced invalid JSON:', err.rawText);
console.error('errors:', err.errors);
} else {
throw err;
}
}The error carries:
errors— the list of validation issues, each withinstancePath,keyword, andmessage.rawText— the original assistant text so you can log or retry.
Supported schema keywords
| Keyword | Notes |
|---|---|
| type | string, number, integer, boolean, null, array, object |
| required | Array of required property names |
| properties | Map of property name → schema |
| additionalProperties | false rejects unknowns |
| items | Single schema OR tuple of schemas (validated by index) |
| minItems, maxItems | Length bounds |
| anyOf, allOf | Lightweight branch validation |
| enum | Strict deep-equality match |
| const | Single-value match |
| pattern | ECMA-262 regex |
| minLength, maxLength | String length bounds |
| minimum, maximum | Number bounds |
For richer validation, write the schema with Zod and convert to JSON Schema — or pipe the assistant's raw output through your own validator.
Soft-failure (no throw)
If you'd rather receive an invalid value than throw, use the lower-level materializeJsonSchemaResult directly:
import { materializeJsonSchemaResult } from 'puku-agent-sdk';
const { value, valid, errors } = materializeJsonSchemaResult(
assistantText,
personSchema,
{ validateSchema: false }, // do NOT throw on validation failure
);
if (!valid) {
console.warn('assistant produced invalid output:', errors);
}Settings
Tiers
Precedence, low → high:
~/.puku/settings.json(user)<cwd>/.puku/settings.json(project)<cwd>/.puku/settings.local.json(local)- Inline overrides from
Options['settings']etc. Options['managedSettings']— highest precedence
Higher tiers win on a per-top-level-key basis; arrays are concatenated.
Inline settings
options: {
settings: {
model: 'puku-default',
theme: 'dark',
},
}The SDK materializes the inline object to a tmp file and forwards --settings <path>. The file is mode 0o600 and deleted when the subprocess exits.
Or pass a path directly:
options: {
settings: '/etc/puku/team-settings.json',
}Tier selection
Limit which tiers the CLI reads:
options: {
settingSources: ['user', 'project'],
// No 'local' — ignores .puku/settings.local.json
}Valid values: 'user' | 'project' | 'local' | 'managed'.
Managed settings
Org-policy settings that outrank every filesystem tier:
options: {
managedSettings: {
permissions: { deny: ['Bash(rm -rf:*)'] },
model: 'puku-fast',
},
}Skip the cascade
options: { dangerouslySkipSettings: true }Emits --skip-settings. No filesystem resolution, no inline overrides. WarmQuery.effectiveSettings() returns null so callers can detect the opt-out.
Reading the resolved cascade
import { query, type WarmQuery } from 'puku-agent-sdk';
const warm = query({ prompt: 'hi', options: { sessionId } }) as WarmQuery;
const resolved = await warm.effectiveSettings();
if (resolved) {
console.log('effective model:', resolved.effective.model);
console.log('effective model came from:', resolved.provenance.model?.source);
// → 'managed' | 'user' | 'project' | 'local'
}ResolvedSettings carries effective (the merged object), provenance (per-field source), and sources (the full tier list).
Process-wide settings
For app-wide defaults, use startup():
import { startup, shutdown } from 'puku-agent-sdk';
const handle = await startup({
resolveSettingsOptions: { settingSources: ['user', 'project'] },
});
console.log('app-wide model:', handle.settings.model);
// Later:
shutdown();Capabilities
The CLI advertises what it supports in an init system message at session start. The SDK parses that into a PukuCliCapabilities object and uses it to gate every control method on WarmQuery.
Reading the declaration
import { query, type WarmQuery } from 'puku-agent-sdk';
const warm = query({
prompt: 'hi',
options: { sessionId: crypto.randomUUID() },
}) as WarmQuery;
// Drain messages() in parallel so the init envelope gets parsed.
const consumer = (async () => {
for await (const _ of warm.messages()) { /* drain */ }
})();
await new Promise((r) => setTimeout(r, 50));
const caps = warm.initializationResult();
console.log(caps.cliVersion); // e.g. '1.8.45'
console.log(caps.protocolVersion); // e.g. '2025-01-01'
console.log(caps.features); // { interrupt: true, set_model: true, … }
await warm.close();
await consumer.catch(() => undefined);Gating
Calling warm.interrupt() against a CLI that didn't advertise interrupt throws UnsupportedCapabilityError:
try {
await warm.interrupt();
} catch (err) {
if (err instanceof UnsupportedCapabilityError) {
console.error(`CLI ${err.capabilities.cliVersion} doesn't support ${err.feature}`);
}
}Feature list
| Capability | Required for |
|---|---|
| interrupt | warm.interrupt() |
| set_model | warm.setModel() |
| set_permission_mode | warm.setPermissionMode() |
| mcp_set_servers | warm.setMcpServers() |
| rewind_files | warm.rewindFiles() |
| telemetry | internal telemetry path |
| plugin_install | dynamic plugin loading |
| structured_output | outputFormat: { type: 'json_schema' } |
| partial_messages | includePartialMessages: true |
| hook_lifecycle | hook lifecycle envelopes |
Missing keys fail closed — hasCapability(caps, 'rewind_files') returns false when the CLI didn't declare rewind_files, even if it actually supports it. This is intentional: the SDK would rather throw than call a method whose envelope the CLI rejects.
Manually asserting
import { assertCapabilitySupported, hasCapability } from 'puku-agent-sdk';
if (hasCapability(caps, 'structured_output')) {
// safe to set outputFormat: { type: 'json_schema' }
}
assertCapabilitySupported(caps, 'mcp_set_servers');
// throws UnsupportedCapabilityError when missingStartup (process-wide state)
startup() initializes a process-wide singleton that caches resolved settings and owns a global HookRegistry. Most users don't need it — query() resolves settings per-call — but it's the right primitive when you want:
- App-wide defaults that survive across many
query()calls (e.g. a long-running daemon). - A shared registry of hook callbacks that every session picks up automatically.
- Pre-resolved settings to skip the on-disk cascade.
import { startup, shutdown, getStartedState } from 'puku-agent-sdk';
const handle = await startup({
resolveSettingsOptions: { settingSources: ['user', 'project'] },
hooks: {
PreToolUse: [{ matcher: 'Bash', hooks: [auditLogger] }],
},
});
console.log('app-wide model:', handle.settings.model);
console.log('registered hooks:', handle.hooks);
// Each query() now picks up the global hook registry.
for await (const msg of query({ prompt: '…' })) { /* ... */ }
// Later, in shutdown handlers:
handle.close();
shutdown();API surface
function startup(options?: StartupOptions): Promise<StartedHandle>;
function shutdown(): void;
function getStartedState(): StartedHandle | undefined;
interface StartedHandle {
settings: Settings;
hooks: HookRegistry;
readonly active: boolean;
close(): void;
}startup() is idempotent — calling it more than once without an intervening close() returns the same handle. shutdown() releases the singleton globally so a fresh startup() re-initializes state.
Lifecycle helpers
Two best-effort runtime helpers that read env vars and return typed metadata. They're useful in startup code, init listeners, or anywhere you need a default before a live session is open.
import { getAccountInfo, getModelInfo } from 'puku-agent-sdk';
const acct = getAccountInfo();
console.log(acct.email, acct.organizationName, acct.billingType, acct.apiKeySource);
// Model metadata — looks up a built-in catalogue.
const model = getModelInfo('puku-default');
console.log(model?.displayName, model?.supportsEffort, model?.supportedEffortLevels);
if (model?.supportsAdaptiveThinking) {
// safe to set options.thinking = { type: 'adaptive' }
}For authoritative values, wait for a live session and read warm.initializationResult() / inspect the system init envelope.
AccountInfo shape
interface AccountInfo {
email?: string;
organizationName?: string;
organizationType?: string;
billingType?: string;
creditBalance?: number;
subscriptionType?: string;
tokenSource?: string;
apiKeySource?: string;
apiProvider?: 'firstParty' | 'bedrock' | 'vertex' | 'foundry' | 'mantle' | 'gateway';
}ModelInfo shape
interface ModelInfo {
value: string;
resolvedModel?: string; // e.g. 'opus' → 'opus-4.8'
displayName: string;
description: string;
supportsEffort?: boolean;
supportedEffortLevels?: EffortLevel[];
supportsAdaptiveThinking?: boolean;
supportsFastMode?: boolean;
supportsAutoMode?: boolean;
}EffortLevel and FastModeState
type EffortLevel = 'low' | 'medium' | 'high' | 'xhigh' | 'max';
type FastModeState = 'off' | 'cooldown' | 'on';
type ApiKeySource = 'user' | 'project' | 'org' | 'temporary' | 'oauth';Pass options.effort to select reasoning depth; inspect result.fast_mode_state to see if the loop ran in fast mode.
Compatibility & manifest
The SDK and the binary have version-coupled surfaces. checkCompatibility() runs before the subprocess is spawned and throws CompatError when the runtime SDK is incompatible with the bundled manifest.
import {
checkCompatibility,
HARNESS_SCHEMA,
SDK_VERSION,
readSdkManifest,
} from 'puku-agent-sdk';
// 1. What version of the SDK is running?
console.log('SDK version:', SDK_VERSION);
console.log('Harness schema:', HARNESS_SCHEMA);
// 2. Run the compatibility check explicitly.
try {
await checkCompatibility();
console.log('SDK + bundled manifest compatible');
} catch (err) {
console.error('Incompatible:', err);
}
// 3. Read the bundled manifest directly.
const manifest = readSdkManifest();
console.log(manifest.version, manifest.commit, manifest.buildDate);
console.log('tested wrapper versions:', manifest.sdkCompat.testedWrapperVersions);
console.log('supported platforms:', manifest.platforms);interface SdkManifest {
version: string;
commit: string;
buildDate: string;
platforms: string[]; // e.g. ['darwin-arm64', 'linux-x64', 'win32-x64']
sdkCompat: {
testedWrapperVersions: string[];
harnessSchema: number; // must equal HARNESS_SCHEMA
};
}
interface PlatformBinary {
os: 'darwin' | 'linux' | 'win32';
arch: 'arm64' | 'x64';
asset: string;
sha256: string;
}checkCompatibility() is called automatically inside query() — you only need to invoke it directly when running an explicit pre-flight, or when you want to gate version-skew behavior in your own code.
A CompatError looks like:
import { CompatError } from 'puku-agent-sdk';
try { /* … */ } catch (err) {
if (err instanceof CompatError) {
console.error(`harness mismatch: expected ${HARNESS_SCHEMA}, got ${err.observedHarnessSchema}`);
}
}Options reference
Options is the shape you pass to query({ options }). Status values:
- ✅ fully wired — the SDK reads the field and forwards it to the CLI (or honors it in-process).
- ⚠️ partial — read but with caveats — read the Notes column before using.
- 🟡 typed only — TypeScript accepts the field, but the runtime ignores it (no CLI flag / initialize key). Do not build features on these — prefer ✅ fields and the dedicated Tools / Permissions / Hooks / Sessions sections.
- 🔵 escape hatch — forwarded verbatim to
puku-cli(e.g.extraArgs).
Prefer ✅ fields. For custom tools, sessions, and permissions, use the dedicated sections above — not 🟡 stubs like tools / skills / toolChoice.
Top-level
| Field | Type | Status | Notes |
|---|---|---|---|
| model | string | ✅ | --model |
| fallbackModel | string | ✅ | --fallback-model |
| systemPrompt | string \| string[] \| { type:'custom',text } \| { type:'preset',preset,append? } | ✅ | --system-prompt / --append-system-prompt |
| appendSystemPrompt | string | ✅ | --append-system-prompt |
| permissionMode | 'default' \| 'acceptEdits' \| 'plan' \| 'bypassPermissions' \| 'dontAsk' \| 'auto' | ✅ | --permission-mode |
| allowDangerouslySkipPermissions | boolean | ✅ | required when permissionMode === 'bypassPermissions' |
| maxTurns | number | ✅ | --max-turns |
| maxBudgetUsd | number | ✅ | --max-budget-usd |
| maxThinkingTokens | number | ✅ | --max-thinking-tokens |
| effort | 'low' \| 'medium' \| 'high' \| 'xhigh' \| 'max' | ✅ | --effort |
| cwd | string | ✅ | passed to spawn |
| env | Record<string, string \| undefined> | ✅ | merged over process.env |
| pathToPukuCliExecutable | string | ✅ | overrides binary resolution |
| pukuCliVersion | string | ✅ | checkCompatibility() gate |
| signal | AbortSignal | ✅ | SIGTERM + SIGKILL escalation |
| abortController | AbortController | ✅ | alias for signal |
| stderr | (data: string) => void | ✅ | captures subprocess stderr |
| debug | boolean | ✅ | --debug |
| debugFile | string | ✅ | --debug-file |
| verbose | boolean | 🟡 | typed only — --verbose is hard-coded by the SDK for the stream-json protocol regardless of this value |
| extraArgs | Record<string, string \| null> | 🔵 | appended to argv; SDK-known flags win on collision |
| betas | SdkBeta[] | ✅ | --betas CSV |
Tools & permissions
| Field | Type | Status | Notes |
|---|---|---|---|
| allowedTools | string[] | ✅ | --allowed-tools CSV |
| disallowedTools | string[] | ✅ | --disallowed-tools CSV |
| canUseTool | CanUseTool | ✅ | Per-tool permission handler (fail-closed) |
| onElicitation | OnElicitation | ✅ | MCP elicitation handler |
| onUserDialog | OnUserDialog | ✅ | CLI dialog handler |
| supportedDialogKinds | string[] | 🟡 | typed only |
| tools | ToolDefinition[] \| string[] \| { preset } | 🟡 | typed only — use mcpServers with type:'sdk' for custom tools |
| toolConfig | ToolConfig | 🟡 | typed only |
| toolAliases | Record<string, string> | 🟡 | typed only |
| toolChoice | ToolChoice | 🟡 | typed only |
MCP
| Field | Type | Status | Notes |
|---|---|---|---|
| mcpServers | Record<string, McpServerConfig> | ✅ | stdio / sse / http / sdk |
| strictMcpConfig | boolean | ✅ | --strict-mcp-config |
| skipMcpDiscovery | boolean | 🟡 | typed only |
Sessions
| Field | Type | Status | Notes |
|---|---|---|---|
| sessionId | string | ✅ | UUID-validated |
| resume | string | ✅ | --resume |
| continue | boolean | ✅ | --continue (precedence over resume) |
| forkSession | boolean | ✅ | --fork-session |
| resumeSessionAt | string | ✅ | --resume-session-at (requires resume or continue) |
| resumeDropsTurn | string | 🟡 | warns and is not forwarded; pass via extraArgs if your binary supports --resume-drops-turn |
| persistSession | boolean | 🟡 | typed only |
| sessionStore | SessionStoreRef | ✅ | LocalFsSessionStore / InMemorySessionStore |
| sessionStoreFlush | 'batched' \| 'eager' | 🟡 | typed only |
| loadTimeoutMs | number | 🟡 | typed only |
| enableFileCheckpointing | boolean | 🟡 | typed only |
Settings / sandbox / plugins
| Field | Type | Status | Notes |
|---|---|---|---|
| settings | string \| OptionsSettings | ✅ | path or inline object |
| settingSources | SettingSource[] | ✅ | --setting-source CSV |
| managedSettings | OptionsSettings | ✅ | --managed-settings tmp file |
| dangerouslySkipSettings | boolean | ✅ | --skip-settings |
| sandbox | SandboxSettings | ✅ | merged into --sandbox-config |
| sandboxFiles | SandboxFilesConfig | ✅ | merged into --sandbox-config.filesystem |
| sandboxNetwork | SandboxNetworkConfig | ✅ | merged into --sandbox-config.network |
| plugins | SdkPluginConfig[] | ✅ | repeated --plugin-dir |
| pluginDelivery | 'argv' \| 'initialize' | ✅ | default argv; initialize falls back to argv (no --await-initialize yet) |
| perTaskStopAffordance | boolean | 🟡 | typed; initialize field not yet supported (warns) |
Streaming
| Field | Type | Status | Notes |
|---|---|---|---|
| includePartialMessages | boolean | ✅ | --include-partial-messages |
| includeHookEvents | boolean | ✅ | --include-hook-events |
| outputFormat | OutputFormat | ⚠️ | Prefer structured output helpers / --json-schema via the structured-output APIs. Overriding --output-format fights the SDK's hard-coded stream-json transport and can make puku-cli exit non-zero — avoid setting this unless you know you need it |
| preferManyToolsInASingleTurn | boolean | 🟡 | typed only |
Hooks
| Field | Type | Status | Notes |
|---|---|---|---|
| hooks | Partial<Record<HookEvent, HookCallbackMatcher[]>> | ✅ | 33 events supported |
Subagents
| Field | Type | Status | Notes |
|---|---|---|---|
| agents | Record<string, AgentDefinition> | ✅ | --agents <json> |
| agent | string | ✅ | --agent |
| permissionPromptToolName | string | ✅ | --permission-prompt-tool (defaults to 'stdio' when a canUseTool handler or PermissionRequest hook is registered) |
| permissionPrompts | 'host' \| 'none' | ✅ | SDK-side (none skips host prompt routing; CLI flag not yet supported) |
| planModeInstructions | string | 🟡 | typed only |
Misc
| Field | Type | Status | Notes |
|---|---|---|---|
| additionalDirectories | string[] | ✅ | repeated --add-dir |
| logLevel | 'debug' \| 'info' \| 'warn' \| 'error' | ⚠️ | only 'debug' forwards as --debug; other values are typed-only |
| skills | string[] \| 'all' | 🟡 | typed only |
| user | string | 🟡 | typed only |
| title / customTitle | string | 🟡 | typed only |
| executable | 'bun' \| 'deno' \| 'node' | 🟡 | typed only |
| executableArgs | string[] | 🟡 | typed only |
| spawnPukuCliProcess | () => unknown | ✅ | replaces child_process.spawn entirely (consumed by transport.ts); not a CLI flag |
| forwardSubagentText | boolean | 🟡 | typed only |
| promptSuggestions | boolean | 🟡 | typed only |
| agentProgressSummaries | boolean | 🟡 | typed only |
| onError | (err: Error) => void | ✅ | receives runtime warnings (e.g. typed-only fields that were set but not forwarded); not emitted as a CLI flag |
| onSessionLog | (...args) => void | 🟡 | typed only |
| telemetry | (...args) => void | 🟡 | typed only |
| scratchpad | boolean | 🟡 | typed only |
| fastMode | boolean | 🟡 | typed only |
| thinking | ThinkingConfig | 🟡 | typed only |
Common recipes
End-to-end examples for Options that puku-cli actually honors. Every example uses query({ prompt, options }). Skip 🟡 typed-only fields (TypeScript accepts them, but the runtime ignores them).
System prompts (4 shapes)
// 1. Plain string — replaces the default system prompt.
options: { systemPrompt: 'You only answer in haiku.' }
// 2. Array — multiple system-prompt blocks.
options: { systemPrompt: ['You are Ada.', 'Reply tersely.'] }
// 3. Preset — use a built-in preset + append custom instructions.
options: {
systemPrompt: {
type: 'preset',
preset: 'puku-default',
append: 'Always cite your sources.',
excludeDynamicSections: false,
},
}
// 4. Custom — full override as a single block.
options: { systemPrompt: { type: 'custom', text: '<SYSTEM>…</SYSTEM>' } }
// Append to whatever the CLI defaults to without replacing it:
options: { appendSystemPrompt: 'Prefer TypeScript over JavaScript.' }Thinking & effort
// Reasoning depth — forwarded as --effort.
options: { effort: 'high' } // 'low' | 'medium' | 'high' | 'xhigh' | 'max'
// Token budget for extended thinking (CLI flag).
options: { maxThinkingTokens: 4_000 }options.thinking, fastMode, and scratchpad are 🟡 typed-only — setting them does nothing.
Custom tools (use mcpServers, not options.tools)
options.tools / toolChoice / toolConfig / toolAliases are 🟡 typed-only. Register tools via MCP instead:
import { z } from 'zod';
import { createSdkMcpServer, query, tool } from 'puku-agent-sdk';
// Pass a Zod *raw shape* ({ field: z.xxx() }), not z.object({...}).
// createSdkMcpServer currently rejects a wrapped ZodObject at registration.
const lookup = tool(
'lookup',
'Look up a customer by id',
{ id: z.string() },
async ({ id }) => ({ content: [{ type: 'text', text: `customer ${id}` }] }),
);
const server = createSdkMcpServer({ name: 'workspace', tools: [lookup] });
for await (const msg of query({
prompt: 'Look up customer 42',
options: {
mcpServers: { workspace: server },
allowedTools: ['mcp__workspace__lookup'],
},
})) { /* … */ }Permissions that actually gate tools
// Auto-approve edits; prompt for everything else.
options: { permissionMode: 'acceptEdits' }
// Hard allow / deny lists (CLI flags).
options: {
allowedTools: ['Read', 'Edit', 'mcp__workspace__lookup'],
disallowedTools: ['Bash'],
}
// Fail-closed per-tool handler (SDK-side).
options: {
permissionMode: 'default',
canUseTool: async (toolName, input) => {
if (toolName === 'Bash' && String(input.command ?? '').includes('rm -rf')) {
return { behavior: 'deny', reason: 'no recursive rm' };
}
return { behavior: 'allow' };
},
}
// bypassPermissions requires an explicit confirm flag.
options: {
permissionMode: 'bypassPermissions',
allowDangerouslySkipPermissions: true,
}PreToolUse: block writes to .env
import type { HookCallback, PreToolUseHookInput } from 'puku-agent-sdk';
const protectEnv: HookCallback = async (input) => {
const pre = input as PreToolUseHookInput;
const filePath = String((pre.tool_input as { file_path?: string } | undefined)?.file_path ?? '');
if (filePath.split('/').pop() === '.env') {
return {
hookSpecificOutput: {
hookEventName: pre.hook_event_name,
permissionDecision: 'deny',
permissionDecisionReason: 'Cannot modify .env files',
},
};
}
return {};
};
options: {
hooks: { PreToolUse: [{ matcher: 'Write|Edit', hooks: [protectEnv] }] },
includeHookEvents: true,
}Resume a session
for await (const msg of query({
prompt: 'continue from where we left off',
options: {
resume: '00000000-0000-4000-8000-000000000001',
},
})) { /* … */ }Forking a session inline
When you resume + fork, you resume into a brand-new session id while keeping the conversation history. The original session is untouched.
for await (const msg of query({
prompt: 'try a different approach',
options: {
resume: '00000000-0000-4000-8000-000000000001',
forkSession: true,
sessionId: '00000000-0000-4000-8000-000000000002',
},
})) { /* … */ }For programmatic forks outside of a running query, see forkSession() in Session management helpers.
Capturing stderr, debug, log level
// Stream CLI stderr into your own logger.
for await (const msg of query({
prompt: '…',
options: {
stderr: (chunk) => process.stderr.write(`[puku-cli] ${chunk}`),
},
})) { /* … */ }
// Enable debug logging inside the CLI.
options: { debug: true }
// Or write the CLI's debug log to a specific file.
options: { debug: true, debugFile: '/tmp/puku-debug.log' }
// Prefer `debug` / `debugFile` — `logLevel` only forwards when set to `'debug'`
// (same as `--debug`); other values are typed-only and ignored.
options: { logLevel: 'debug' }Additional directories & plugins
// Expose extra directories to the agent — repeated `--add-dir` flags.
options: { additionalDirectories: ['/srv/data', '/tmp/scratch'] }
// Load a local plugin (skills/hooks/agents/commands) via --plugin-dir.
options: {
plugins: [
{ type: 'local', path: '/etc/puku/team-plugin' },
{ type: 'local', path: './.puku/plugins/extras', skipMcpDiscovery: true },
],
}Subagent definitions
Define named subagents that the main loop can dispatch to. Each subagent gets its own tool set, system prompt, model, and (optionally) MCP servers.
options: {
agents: {
'security-reviewer': {
description: 'Reviews code for security issues',
prompt: 'You are a security reviewer. Audit the supplied code carefully.',
tools: ['Read', 'Grep', 'Bash'],
model: 'opus',
},
'doc-writer': {
description: 'Writes and updates documentation',
prompt: 'You are a technical writer. Be concise.',
tools: ['Read', 'Write', 'Edit'],
model: 'inherit', // inherit from parent
skills: ['api-docs'],
},
},
// Select the main agent (defaults to the loop's default).
agent: 'security-reviewer',
}AgentDefinition shape:
interface AgentDefinition {
description: string;
tools?: string[];
disallowedTools?: string[];
prompt: string;
model?: string; // 'inherit' to use parent model
mcpServers?: AgentMcpServerSpec[]; // per-agent MCP server references
skills?: string[];
criticalSystemReminder?: string; // experimental
}Beta features
options: {
betas: ['context-1m-2025-08-07'], // 1M-token context window
}Extra CLI args (escape hatch)
For CLI flags the SDK doesn't model yet, append them via extraArgs. null means a boolean flag.
options: {
extraArgs: {
'some-new-flag': 'value',
'boolean-flag': null,
},
}Override the spawn (escape hatch)
Replace child_process.spawn entirely — useful for VM / container / remote execution:
import type { ChildProcess } from 'node:child_process';
options: {
spawnPukuCliProcess: (command, args, options) => {
// Forward to your own launcher; return something spawn-shaped.
return myCustomLauncher(command, args, options) as unknown as ChildProcess;
},
}Environment variables
The SDK inherits process.env by default. Override or add entries with options.env:
for await (const m of query({
prompt: '...',
options: {
env: {
PUKU_AI_API_KEY: 'pk_...',
PUKU_BASE_URL: 'https://agent.sdk.puku.sh',
PUKU_LOG: 'debug',
},
},
})) { /* ... */ }The SDK forwards your merged env to the puku-cli subprocess. Variables the CLI itself reads:
| Variable | Purpose |
|---|---|
| PUKU_AI_API_KEY | Primary API key (apiKeySource = 'user'). |
| PUKU_AUTH_TOKEN | OAuth bearer (alternative to API key). |
| PUKU_BASE_URL | Gateway / SDK base URL override. |
| PUKU_LOG | CLI log level — debug, info, warn, error. |
The SDK also reads a small number of variables at build / runtime:
| Variable | Purpose |
|---|---|
| PUKU_SDK_SKIP_COMPAT | If set to '1', skips checkCompatibility() (debug / sandboxed CI). |
| PUKU_SDK_TELEMETRY | If set to 'off', disables telemetry flush even when initialized. |
API keys are argv-redacted at build time — they never appear in subprocess argv.
Errors
The SDK throws typed errors for spawn failures, transport errors, and protocol violations. Wrap queries in try/catch:
import {
query,
AbortError,
OptionsValidationError,
JsonSchemaValidationError,
UnsupportedCapabilityError,
CompatError,
} from 'puku-agent-sdk';
try {
for await (const m of query({ prompt: '...' })) { /* ... */ }
} catch (err) {
if (err instanceof OptionsValidationError) {
console.error('bad options:', err.issues);
} else if (err instanceof JsonSchemaValidationError) {
console.error('invalid structured output:', err.errors);
} else if (err instanceof UnsupportedCapabilityError) {
console.error(`CLI ${err.capabilities.cliVersion} lacks ${err.feature}`);
} else if (err instanceof CompatError) {
console.error('SDK / CLI version mismatch');
} else if (err instanceof AbortError) {
console.error('aborted:', err.reason);
} else {
throw err;
}
}A result message with subtype !== 'success' is not a thrown error — it's a normal termination with subtype like 'error_max_turns', 'error_during_execution', etc. Check it on the loop end.
Error class reference
| Error | Thrown by | Notes |
|---|---|---|
| AbortError | Transport.close(), query cancellation | Carries optional reason from AbortSignal. |
| OptionsValidationError | validateOptions(), query() at start | Carries issues: OptionsIssue[]. |
| JsonSchemaValidationError | materializeJsonSchemaResult(), materializeStructuredResult() | Carries errors[] (per-issue breakdown) and rawText. |
| UnsupportedCapabilityError | Capability-gated WarmQuery methods | Carries feature name and capabilities: PukuCliCapabilities. |
| CompatError | checkCompatibility() | Thrown when HARNESS_SCHEMA mismatches the bundled manifest. |
validateOptions({...}) is also exported explicitly so you can dry-run option validation without spawning the subprocess:
import { validateOptions, OptionsValidationError } from 'puku-agent-sdk';
try {
validateOptions({
permissionMode: 'bypassPermissions', // missing allowDangerouslySkipPermissions
});
} catch (err) {
if (err instanceof OptionsValidationError) {
for (const issue of err.issues) {
console.error(`${issue.path}: ${issue.message}`);
}
}
}OptionsIssue carries path (dotted path to the offending field), message, and code.
Troubleshooting
puku-cli: command not found
The binary isn't on $PATH. Either install it or point the SDK at it:
options: { pathToPukuCliExecutable: '/full/path/to/puku-cli' }Verify the path is executable:
ls -l /usr/local/bin/puku-cli # must have +x
/usr/local/bin/puku-cli --versionEACCES on puku-cli
chmod +x $(which puku-cli)401 Unauthorized / API key rejected
Three causes in order of frequency:
The CLI doesn't see your key. The CLI reads
PUKU_AI_API_KEY; the SDK forwards it via the inherited env. Make sure the variable is exported in the shell that launches the SDK.export PUKU_AI_API_KEY=pk_<your-key>The key is set in a child shell but not the spawning shell. The SDK passes
process.envto the subprocess by default — verify the env var is exported in the shell that runsnode, not in a subshell.Key revoked. Mint a new key from the admin UI.
UnsupportedCapabilityError: puku-cli does not advertise the "X" capability
The CLI you spawned is older than the SDK expected. Either:
- Update the CLI binary to a version that advertises the capability.
- Stop calling the gated method (
warm.interrupt(),setMcpServers(), …).
See Capabilities.
JsonSchemaValidationError: structured output did not match the supplied JSON schema
The assistant produced output that didn't match the schema you passed. The error carries errors (per-issue breakdown):
import { JsonSchemaValidationError, materializeJsonSchemaResult } from 'puku-agent-sdk';
try {
const out = materializeJsonSchemaResult(assistantText, format, { rawText: assistantText });
} catch (err) {
if (err instanceof JsonSchemaValidationError) {
for (const e of err.errors) {
console.error(` ${e.instancePath || '<root>'} ${e.keyword}: ${e.message}`);
}
}
}Common causes:
additionalProperties: falsebut the model added an extra field. Relax the schema or strengthen the prompt.required: ['x']but the model omittedx. Makexrequired only if the prompt guarantees it.enummismatch. The model invented a value outside your enum — strengthen the prompt with the allowed values.
query() hangs forever
The CLI is silent. Enable debug logging:
options: {
debug: true,
stderr: (data) => process.stderr.write(data),
}Or set PUKU_LOG=debug in the environment. Stream stalls have three common causes: signal aborted, canUseTool returning a never-resolving promise (add a timeout), or the CLI hanging (send SIGTERM, the SDK escalates to SIGKILL after 5 s).
Files leaked in /tmp
The SDK materialises MCP configs, inline settings, and sandbox configs to os.tmpdir(). They're cleaned up in the finally block of every query() invocation. If a process crashes hard, leftover directories are prefixed puku-mcp-, puku-settings-, puku-managed-settings-, puku-sandbox-config- and are safe to delete manually.
The subprocess output looks like NDJSON but isn't parsed
The SDK buffers lines and parses each as JSON. Lines that fail to parse are silently skipped — your downstream code never sees them. To debug, capture the raw stream:
options: { stderr: (line) => process.stderr.write(`[puku-cli] ${line}\n`) }If you see Send exactly one of Authorization or X-Api-Key, both PUKU_AI_API_KEY is present in the subprocess env. Strip PUKU_AI_API_KEY from the inherited env before spawning.
options.tools / toolChoice / skills seem ignored
They are. Those fields are 🟡 typed-only — TypeScript accepts them, but the runtime does not forward them. Use mcpServers + createSdkMcpServer / tool() for custom tools, and allowedTools / disallowedTools / permissionMode / hooks for policy.
License
MIT — see LICENSE.
