npm package discovery and stats viewer.

Discover Tips

  • General search

    [free text search, go nuts!]

  • Package details

    pkg:[package-name]

  • User packages

    @[username]

Sponsor

Optimize Toolset

I’ve always been into building performant and accessible sites, but lately I’ve been taking it extremely seriously. So much so that I’ve been building a tool to help me optimize and monitor the sites that I build to make sure that I’m making an attempt to offer the best experience to those who visit them. If you’re into performant, accessible and SEO friendly sites, you might like it too! You can check it out at Optimize Toolset.

About

Hi, 👋, I’m Ryan Hefner  and I built this site for me, and you! The goal of this site was to provide an easy way for me to check the stats on my npm packages, both for prioritizing issues and updates, and to give me a little kick in the pants to keep up on stuff.

As I was building it, I realized that I was actually using the tool to build the tool, and figured I might as well put this out there and hopefully others will find it to be a fast and useful way to search and browse npm packages as I have.

If you’re interested in other things I’m working on, follow me on Twitter or check out the open source projects I’ve been publishing on GitHub.

I am also working on a Twitter bot for this site to tweet the most popular, newest, random packages from npm. Please follow that account now and it will start sending out packages soon–ish.

Open Software & Tools

This site wouldn’t be possible without the immense generosity and tireless efforts from the people who make contributions to the world and share their work via open source initiatives. Thank you 🙏

© 2026 – Pkg Stats / Ryan Hefner

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.

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-cli on $PATH (or pass options.pathToPukuCliExecutable)
  • License: MIT

Table of contents

  1. Installation
  2. Authentication
  3. Quickstart
  4. API reference
  5. Tools (in-process)
  6. MCP servers
  7. Hooks
  8. Permissions
  9. Sandbox
  10. Sessions
  11. Session management helpers
  12. Session store customization
  13. Streaming input & WarmQuery
  14. Control protocol
  15. Structured output
  16. Settings
  17. Capabilities
  18. Startup (process-wide state)
  19. Lifecycle helpers
  20. Compatibility & manifest
  21. Options reference
  22. Common recipes
  23. Environment variables
  24. Errors
  25. Troubleshooting
  26. License

Installation

# with bun
bun add puku-agent-sdk

# with npm
npm install puku-agent-sdk

# with pnpm
pnpm add puku-agent-sdk

You 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 --version

If 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.0

Authentication

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.mjs
import { 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 with instancePath, keyword, and message.
  • 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:

  1. ~/.puku/settings.json (user)
  2. <cwd>/.puku/settings.json (project)
  3. <cwd>/.puku/settings.local.json (local)
  4. Inline overrides from Options['settings'] etc.
  5. 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 missing

Startup (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 --version

EACCES on puku-cli

chmod +x $(which puku-cli)

401 Unauthorized / API key rejected

Three causes in order of frequency:

  1. 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>
  2. The key is set in a child shell but not the spawning shell. The SDK passes process.env to the subprocess by default — verify the env var is exported in the shell that runs node, not in a subshell.

  3. 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: false but the model added an extra field. Relax the schema or strengthen the prompt.
  • required: ['x'] but the model omitted x. Make x required only if the prompt guarantees it.
  • enum mismatch. 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.