Atomic can help you use the SDK. Ask it to build an integration for your use case.
SDK
Use the SDK to embed Atomic in an application, build a custom interface, or integrate agent capabilities into an automated workflow. Example use cases:- Build a custom UI (web, desktop, mobile)
- Integrate agent capabilities into existing applications
- Create automated pipelines with agent reasoning
- Build custom tools that spawn sub-agents
- Test agent behavior programmatically
On this page and its reference
This page covers the SDK quick start, its core concepts, and one complete example. Options, resource loaders, return types, run modes, and exports live in the SDK API reference. For a custom host that runs background work, see Owner-bound task supervisor, Supervised command SDK, and Task transcript references. For the standalone SQLite facade and adapter conformance suite, see SQLite storage API. Not sure the SDK is the right integration mode? Compare it with RPC and JSON mode on Programmatic use. To serve an agent over HTTP, see Embedding Atomic in a web server, which walks through a Next.js App Router route that streams session output to a browser.Quick Start
ModelRuntime is the canonical asynchronous provider runtime when an integration wants provider-owned credentials, dynamic catalogs, and native providers in one object:
ModelRuntime.create() accepts custom authPath, modelsPath, credential storage, and runtime auth overrides, plus the model-catalog options allowModelNetwork, modelRefreshTimeoutMs, modelsStorePath, and modelsStore (see Model catalog persistence and refresh). ModelRegistry and AuthStorage remain available as Atomic’s synchronous compatibility facades. Use readStoredCredential(provider, authPath?) for a lightweight read of one stored provider credential.
Use await resolveRestoredModelReference(provider, modelId, modelRuntime) to resolve a saved model without selecting a settings default. It returns the authenticated exact model, reconstructs eligible custom or dynamic IDs, or returns undefined when restoration is unavailable. Import it from @bastani/atomic and check the result before creating a session when restoration must not fall back to another model.
Extensions supplied directly to SDK sessions can use the exported InlineExtension type. Extension APIs and event types include native registerProvider(Provider), registerEntryRenderer, entry_appended, before_provider_headers, and agent_settled.
The package root also exports buildContextEntries, sessionEntryToContextMessages, and CompactionEntry for converting durable session branches into model context. The equivalent active-session operation is sessionManager.buildContextEntries().
Installation
Install@bastani/atomic as a project dependency with npm, pnpm, or Bun:
With npm:
--ignore-scripts to the install command.
The SDK is included in the main package. No separate SDK package is needed.
Migrating an existing Node host
Use the installed package’s ESM exports, not checkout paths or extension-loader aliases:"type": "module" in package.json and module: "NodeNext", moduleResolution: "NodeNext", and strict: true in tsconfig.json. Declaration checking works with skipLibCheck: false; disabling it is not required. No separate workflows install, ambient module declarations, source aliases, CLI process, or terminal is needed.
When upgrading an existing host:
- Remove manual registration of Atomic’s shipped builtin extensions. They and their resources are included by default. Use
builtinsto disable packages; usetools/excludedToolsto control tool access. An empty tool allowlist also excludes Intercom. - Supply all five
HostInputmethods (confirm,select,input,editor,questionnaire) throughextensionBindings.humanInput. Preserve question text and option values, honor each request’s abort signal, and return literaltrueonly for explicit approval. No adapter is not consent: ordinary dialogs reject withHumanInputUnavailable, while required durable workflow gates remain pending. Do not translate cancellation into approval. - Treat a headless workflow launch as acceptance, not completion. Inspect its run ID/status. After reconnecting, resume that run and bind the new adapter with
session.bindExtensions({ humanInput }); do not start a second run to answer an outstanding question. Restore journal access before retrying a storage failure. - Always
await session.dispose()infinally, including on failure. Disposal is asynchronous and can reject withShutdownFailedafter attempting all cleanup; do not hide that rejection or callprocess.exit()to force a successful shutdown. Each session owns its own work, even when services are shared.
extensionBindings.onDiagnostic; a missing provider or extractor is an error, not an empty successful result.
If creation reports BuiltinUnavailable, reinstall the complete package and its dependencies rather than copying only dist/index.js into your application.
Migrating from the experimental client
The@bastani/atomic/client entrypoint is no longer available because upstream Pi stopped publishing its client and protocol packages. Use createAgentSession() from @bastani/atomic in-process, or RPC mode with --mode rpc or @bastani/atomic/rpc-entry. Pin the previous release if you still need the experimental client. The CLI, RPC mode, extensions and workflows are unaffected.
Experimental Harness factory
For the current supported integration, start with createAgentSession() and the SDK API reference.Core Concepts
createAgentSession()
The main factory function for a singleAgentSession.
createAgentSession() includes Atomic’s shipped workflows, subagents, MCP, web access and Intercom, plus their bundled resources. It uses normal user, project and configured-package discovery when no resourceLoader is supplied. A custom loader supplies your resources; the factory adds shipped builtins without changing the loader’s options. Services still need their existing configuration and credentials.
Use builtins: { "web-access": false, intercom: false } to disable specific shipped packages and their resources. Omitted keys remain enabled, including with builtins: {}. Tool selection is separate: tools: [] and noTools: "all" expose no tools, including Intercom; noTools: "all" also overrides a nonempty allowlist. excludedTools wins over selection. Suppression survives reload. See tool precedence for defaultTools and noTools: "builtin".
Creation finishes extension startup before returning. Pass extensionBindings when startup hooks need your host bindings. Later session.bindExtensions(...) updates those bindings without replaying session_start; reload starts a new extension generation. Missing shipped assets reject with an error whose code is BuiltinUnavailable and whose message names the package. Reinstall the package rather than continuing with a partially available session.
Opening or binding a session never recovers work an earlier process left unfinished. That work includes restoring background subagents, replaying interrupted deliveries, and showing undelivered background results. Call await session.startPendingRecovery("resume") once you are ready to show its output. It runs once per session and resolves with a report: { replayed, dropped, completions, notices }.
Session storage
createAgentSession() persists sessions in ~/.atomic/workflows/journal.sqlite by default, sharing the file with workflow runs under separate session_ and wf_ table prefixes. Pass sessionStore to choose storage explicitly:
{ kind: "jsonl", dir }, { kind: "memory" }, { kind: "database", database, prefix? }, and { kind: "adapter", module, options?, secrets?, prefix? }. database must implement the async SqliteDatabase facade. Install your own driver and expose an adapter module for a remote database; Atomic bundles only its local node:sqlite adapter. See Storage adapters.
sessionStore also accepts a SessionStore instance. A supplied sessionManager takes precedence and remains caller-owned. Otherwise, explicit SDK storage wins over the process-level variables (ATOMIC_SESSIONS_STORAGE or the session-directory variable, then ATOMIC_STORAGE), sessions.storage, shared storage, and the persistent default. Memory is never a default or an opening-failure fallback. The sessionDir factory option has been removed; use a JSONL sessionStore selection instead. See Session storage settings for scope and path rules, and Selecting storage per process to choose storage for the processes a host starts, such as atomic --mode rpc children, without editing settings.
Atomic closes stores it opens from a selection when the session is disposed, after pending writes finish. Supplied SessionStore instances and stores belonging to supplied managers remain caller-owned. With { kind: "database", database }, Atomic closes its store but leaves your database open. Always await session.dispose() and close caller-owned stores when no session still uses them.
Resume by opening the ID in the chosen store, then pass that manager to the factory:
SessionManager mutation before reading dependent state. Writes resolve after commit; a failure rejects with a typed error without publishing the failed entry. There is no flush() step. Extension pi.appendEntry, pi.setSessionName, pi.setLabel, and pi.setThinkingLevel also return promises. ExtensionContext.sessionSignal aborts when reload or shutdown starts, before admitted work is drained. Use it to stop waiting on long-running command resources, but keep awaiting any session write already started. See SessionManager API and Storage errors.
Human input without a terminal
PassextensionBindings.humanInput to answer extension dialogs and ask_user_question in a Node host. HostInput requires all five methods: confirm, select, input, editor and questionnaire. QuestionParams and QuestionnaireResult are exported from @bastani/atomic; questionnaire answers retain their question indices, answer kinds, selections, previews and notes.
Each callback receives a HostInputOptions argument with a runtime-generated requestId, the originating sessionId, and an AbortSignal. Stop presenting the question when the signal aborts.
Return an actual boolean from confirm, a supplied choice or undefined from select, and a string or undefined from text dialogs. Atomic preserves empty strings, whitespace and choice order. Malformed replies reject with InvalidHostInput; false, cancellation and rejected callbacks never approve an action.
Binding and withdrawing an input adapter
humanInput has different omission rules at creation and during rebinding:
- At creation, omission means no input unless
uiContextsupplies a dialog bridge. Explicit callbacks override that bridge. - On later binding, omission keeps the current adapter.
nullwithdraws it and cancels pending ordinary questions.
ctx.hasHumanInput before asking questions and ctx.hasUI before rendering. Existing ctx.ui.confirm/select/input/editor calls use the host adapter; dialog timeouts cancel their requests. Without an adapter, ordinary dialogs reject with HumanInputUnavailable. The ask_user_question tool retains its compatible { answers: [], cancelled: true, error: "no_ui" } details. ui.custom still requires a presentation host and is not part of HostInput.
Durable workflow approvals
Workflow input uses the same callbacks for stage questionnaires and nested workflows. Requests includeworkflowRunId and workflowStageId; combine them with requestId to associate a question with its run. Author semantic ctx.ui calls rather than terminal-specific branches so the definition works across hosts.
A durable approval stays pending when input is unavailable, cancelled, or invalid. Withdrawing the adapter neither approves the request nor discards the run. Bind a new adapter to present a live pending request again. It receives a fresh request ID; late answers to the withdrawn request cannot authorize work.
To continue a saved run in another session:
- Keep its definition and durable storage available.
- Bind the new host.
- Use
session.workflows.resume(runId)(Workflow run control),/workflow resume <run-id>, or the workflow tool’sresumeaction.
true confirms a primitive approval. Choosing to stay on a questionnaire’s stage does not advance it. Missing input never bypasses approval or an exhausted budget; obtain approval before explicitly resuming with a raised budget.
Diagnostics and provider state
onDiagnostic receives session-attributed operational diagnostics. Existing errors and tool results remain available without a callback. Third-party extensions can still write directly to the console; the callback does not intercept their output.
Rebinding host callbacks preserves pending MCP OAuth ownership. Authorization state and PKCE verifiers stay in memory for that SDK session; restart authorization after disposing it. Durable MCP tokens and client registrations still use server-name credential files. Separate sessions are not separate credential stores.
Web provider configuration caches are session-local. Configuration discovery and environment keys are unchanged; create a new session to pick up changed cached settings. GitHub extraction returns the owned clone path for use with file tools, so callers need not predict its directory.
Stored session summaries
session.sessionSummary returns the current branch’s latest SessionSummaryEntry, or undefined when none is available. Read summary for the generated text and summarizedThroughId for the last message it describes. SessionManager.list() and SessionManager.listAll() expose those same fields on each SessionInfo. Stored summaries remain readable when sessionSummary.enabled is false; that setting controls generation only.
Workflow journal selection
Standalone workflowrun() accepts RunOpts.journal to choose a local SQLite file, a caller-owned connection, process-local memory, or a supplied RunJournal:
{ database, prefix? } for an async SqliteDatabase facade; Atomic never closes the caller-owned database. To use a driver selected by module specifier, pass { adapter, options?, secrets?, prefix? }. Atomic bundles no remote drivers. A supplied RunJournal is used as-is. Memory is an explicit opt-in for tests or ephemeral runs and cannot recover runs after process exit. See Storage adapters.
Custom journals implement the full RunJournal interface, including deleteOwnedRun(lease, expectSeq?) for atomic lease-fenced deletion with an optional sequence check. A stale lease rejects with StaleLeaseError without deleting the successor’s data. Open a facade-backed journal with await SqlRunJournal.open(database, { prefix }), or use await openSqlRunJournal(pathOrDatabase?) for the node adapter. Validate custom journals with registerRunJournalConformance; see Choosing the durable backend.
To choose the journal for the model-facing workflow tool of one SDK session, pass the same selection to createAgentSession. Each session in a process can use its own journal without setting ATOMIC_WORKFLOW_JOURNAL_PATH:
journal option, the session’s own workflows.journal setting applies, including settings passed through SettingsManager.inMemory({ workflows: { journal: { path: "./runs.sqlite" } } }). A memory: true journal selected this way belongs to that session alone. Disposing a session closes the journals Atomic opened for it once no other session is using them, so runs kept in a memory journal end with the session. Atomic never closes a database or journal you supply.
Without a per-run or per-session option, ATOMIC_WORKFLOWS_JOURNAL or a non-empty ATOMIC_WORKFLOW_JOURNAL_PATH wins, then ATOMIC_STORAGE, then workflows.journal, then shared top-level storage. Setting both journal variables is a conflict. Nearer-scope settings replace whole selections. Relative settings paths resolve against Atomic home. The default journal is ~/.atomic/workflows/journal.sqlite; an empty shared selection uses ~/.atomic/storage.sqlite. Keep the original journal and working directory to resume saved runs. Invalid configuration and storage failures reject rather than switching to memory. See Workflow storage settings and Selecting storage per process.
Workflow run control
session.workflows controls this session’s runs without slash commands or the model-facing workflow tool. A fresh session can inspect a durable root by its full UUID within the same working directory and journal. Inspection stays read-only and does not add the run to listRuns().
listRuns() and getRun() expose label, identity, summary, and summarizedThrough (Unix milliseconds). The identity prefers the label, then the objective/prompt input, then the run ID. Use listRuns({ includeDurable: true }) to include stored resumable and completed top-level runs launched from the current project directory alongside this session’s live runs. Stored runs must have a recorded session owner, matching the SDK’s run-detail access rules. The default remains this session’s runs only. Saved summaries can lag current progress and remain readable with summary generation disabled.
listRuns() returns the same per-run data as workflow status: id, workflow name, status, active stages, and unanswered prompts (awaitingInput). Run IDs accept the full UUID or a unique 8-character prefix. getRun(), getStages(), pause() and quit() resolve a prefix against the runs this session holds and reject it at once when none matches, so pass the full UUID for a run recorded by an earlier process. pause, quit and resume resolve with the tool’s acknowledged outcome: action, runId (--all for a batch), a status of ok, running, paused, partial, noop or cancelled, and a human-readable message. A noop means the request was understood and found nothing to change, such as pausing a run that already ended. A partial outcome means part of the request took effect. For pause({ all: true }) and quit({ all: true }), partial means this call stopped at least one run and others are still active: failedRuns lists each run that is still active with its runId, a reason (for example pause_failed or no_active_stages) and a message, so check outcome.status === "partial" before treating a batch stop as complete. If active runs could not be stopped and this call stopped none, the call rejects with WorkflowRunControlError (WORKFLOW_RUN_CONTROL_FAILED) whose failedRuns carries the same list, even when other runs were already paused. Runs that are already paused or already ended are not failures, and a batch with no active run left to stop resolves as a noop. Call getRun() before retrying.
After an owning process crashes, create a new authorized SDK session with the original cwd and workflow journal. Keep the workflow definition available, then call getRun(runId) with the saved full UUID. Once the journal lease has expired (about two minutes without renewal), status reports crashed. resume(runId) claims the run under a new fenced lease, keeps its UUID, and replays completed checkpoints. This works with SessionManager.inMemory() and does not require reopening a session file. Concurrent recovery attempts admit only one executor; inspect again if another caller won the claim.
A run with a live foreign lease remains read-only, even while a long-running tool has not refreshed progress. pause() and quit() require the owning session. Missing ownership metadata, a different working directory, or unavailable journal storage prevents adoption. Use the full UUID to recover a foreign run.
Disposing an owning session with await session.dispose() stops its in-flight runs at their last checkpoint and records their release in the journal. Another authorized SDK session with the same cwd and journal can then call resume(runId). The adopting session becomes the owner, and completed checkpoints replay without rerunning their callbacks.
Workflow resume is project-scoped. SDK and workflow-tool resume calls for a run from a different cwd return an actionable error without changing it. To continue in a different project, use /workflow resume <id> interactively and confirm the fork into the current directory. The fork receives a new run ID and copies saved progress; the original run is left untouched. To keep the original ID, create the successor session with the original cwd.
/new, /fork, /resume) also releases the previous session’s paused runs for adoption. Reloading does not release runs. A paused run whose owning session has not released it stays protected; finished runs are not resumable.
Journal fencing protects ownership and durable checkpoints, not arbitrary external effects. An interrupted ctx.tool callback can run again if its result was not checkpointed. Make external effects idempotent, for example by using a stable run/node operation key with an API that deduplicates requests.
If storage fails, preserve the journal and run ID, restore access, inspect the retained run, and resume explicitly. A failed pause leaves the local scheduler paused; resume retries the journal transition before releasing it. A failed resume keeps the scheduler paused and surfaces the error.
Pausing a run doesn’t park a stage that is waiting on a prompt, including an ask_user_question delivered through HostInput.questionnaire. If no other work is active, pause(runId) and pause({ all: true }) mark the run paused without cancelling its question. Questions not yet shown wait until the run resumes. A later pause counts it as already paused. If your host then answers an already-open prompt, the stage can continue its turn before you call resume. To keep a paused run fully stopped, hold prompt answers until you resume it.
Requests that cannot be carried out reject with a subclass of WorkflowRunControlError. Branch on code or use instanceof:
Each error carries the
runId when one applies. A refused batch also carries failedRuns.
A host with a stop button can pause its session’s runs and resume the same runs later:
pause holds the live run and keeps it resumable in this process; quit retires the executor at a durability boundary so a later process can resume it. After quit, or when resuming a run from a previous process, resume follows the durable resume path: keep the workflow definition and durable storage available. See pausing, quitting, and resuming for what each action guarantees.
Observing workflow runs
To follow runs instead of pollinglistRuns(), call session.workflows.observe(observer). It covers runs that the agent launches with the workflow tool, and needs no extension. The first frame is a snapshot. After it come changed and removed frames, delivered in order. Each callback finishes before the next one starts. Call dispose() on the returned subscription to stop. The frames and cursors follow the workflow activity contract.
Each root summary carries a graph with the nodes of that root’s workflow, including the nodes of nested child workflows. Nodes are stage nodes and ctx.tool nodes. Each one has an id, kind, name, status, parentIds, runId, nodeId (its run-local id), depth, and executionOrder when it is known. Tool nodes add their ordinal. A stage that is waiting on a prompt adds pendingPrompt with the prompt’s id, kind, message, choices, and createdAt. A prompt that a run raises outside any stage appears in graph.runPrompts with the same fields and the runId that owns it. A node status change produces a changed frame. Frames are plain JSON-serializable objects.
availability: "unavailable", and a ready snapshot follows. The subscription survives session.reload(). After a reload, the observer receives a new snapshot with a new cursor epoch, so replace everything you knew. When the session is disposed, each observer receives a final snapshot with availability: "unavailable" before dispose() resolves; after that, observe() throws WorkflowRunControlUnavailableError. This stream reports root activity and graph state. It does not carry the events inside each stage.
Workflow and subagent children
Children inherit the invoking session’s model/auth runtime, settings, agent directory, human-input callbacks, and diagnostic sink. Choosing a child model or fallback does not switch to global credentials. Workflow stages also inherit extensions supplied to the parent’sDefaultResourceLoader through additionalExtensionPaths and extensionFactories. Factories run again for each stage. Keep parent-only setup in a session callback such as session_start, and return early from that callback when ctx.subagentPolicy is set. This skips the setup, not factory construction or handler registration. Do not rely on pi.subagentPolicy during factory construction: stage resources can load before the session policy is attached.
The child working directory resolves in this order:
- Explicit child
cwd. - A supplied child
sessionManager.getCwd(). - The invoking session’s directory.
undefined child options retain inherited configuration, including individual builtin flags and host bindings. Use humanInput: null to withdraw input explicitly; empty arrays and other explicit values keep their normal meanings, subject to the parent’s capability ceiling.
Child tool selections can narrow the parent’s selection, not expand it. Disabled builtin packages, excluded tools, empty allowlists and noTools: "all" remain suppressed in children and fallback attempts. Enable a needed capability on the parent before launching a child. Workflow stages and subagents still exclude recursive workflow tooling; subagents retain the single-level delegation limit.
Callbacks may be shared, but request and diagnostic sessionId values identify the originating child. Workflow questions also carry their run/stage identity. Group overrides retain the normal Intercom authorization rules. Disabling or excluding Intercom does not create a substitute supervisor grant. Reopening conversation history does not restore child authority.
An explicit child extensionBindings.humanInput overrides the inherited host, including for durable stage questionnaires. Setting it to null leaves those questions pending; rebinding the parent cannot answer on that child’s behalf. Rebind the child to an authorized adapter to continue. Without a child override, pending stage questions follow parent host withdrawal and reattachment.
You may reuse the same adapter object when explicitly rebinding a child, even after the parent switches hosts. That child selection survives reload. An empty binding object does not select a new host or restore inheritance.
The inherited isFallbackModelAllowed predicate also applies when a workflow replaces its stage session to try a fallback. A rejected candidate is not executed. This predicate restricts fallback choices, not an explicitly selected primary model.
AgentSession
The session manages agent lifecycle, message history, model state, compaction, and event streaming.await session.dispose() in finally. Disposal immediately refuses new work, cancels and drains owned operations, settles questions, shuts down extensions and releases session leases. Repeated calls await the same outcome.
A ShutdownFailed error contains component failures in errors; cleanup still attempts the remaining components. Stores opened by the SDK, including stores created from a sessionStore selection, are released after session work and shutdown handlers finish. A failed session initialization releases its acquired store too. Caller-supplied SessionStore instances, session managers, databases, and model runtimes retain their caller-managed lifetimes. Close a supplied store or database only after its other users are done. Disposing one session does not close another session’s store or a shared journal. Use abort() to cancel current work without destroying the session.
Captured extension APIs also refuse new execution, mutations and registrations as soon as close or reload begins. Already-admitted pi.exec() calls remain part of the awaited drain; provide signal or timeout when invoking subprocesses that might not finish on their own. abort() alone does not retire the API. A rejected transactional reload restores the surviving generation’s action admission.
Custom messages queued with persistWhenStreaming: true survive a successful reload and receive their model turn through the replacement extensions. An explicitly paused queue remains paused; call resumeQueuedMessages() before sending the next prompt. A rejected transactional reload retains the notices for the next prompt on the surviving generation.
Finishing admitted work
Disposal and reload wait for admitted callbacks, resource refreshes, and subprocesses. Ensure callbacks can settle independently of disposal, including work started by cleanup handlers. Givepi.exec() calls a timeout or cancellation signal, and await them in the handler so you can inspect failures. Never make cleanup wait for disposal itself.
Register cleanup before acquiring resources or starting asynchronous callbacks. Call the unsubscribe returned by pi.events.on() or a workflow publisher’s dispose() when you no longer need it; both are idempotent. Generation cleanup releases remaining handles and reports failures.
Reload failures and resource ownership
Strict transactional reload failure leaves the original generation usable. Ordinary reload failure does not restore the retired generation. Both attempt cleanup of newly acquired resources and report cleanup failures throughShutdownFailed without hiding the original error.
Prefer acquiring extension resources in session_start. If a factory acquires them earlier, register session_shutdown immediately, even if extensionsOverride may later omit that factory. Capture cleanup handles when acquiring resources rather than rereading loader getters during cleanup.
Transactional reload dispatches candidate session_start before commit. Use publishExtensionContextEffect(ctx, effect) to publish shared lifecycle state only after commit and retiring-generation cleanup; rollback discards the effect. Outside a transactional candidate, it runs immediately. Awaiting the helper queues the effect, not its eventual execution, so startup must not depend on the effect’s result.
For synchronous identity handoff, use publishExtensionContextEffect(ctx, effect, "commit"). This phase runs after the transaction commits and before predecessor authority is revoked or any retirement work is awaited. It must not return a promise or start asynchronous work; asynchronous callbacks are rejected and reported. The default "start" phase still runs after retirement, when reporter and task-host startup is safe.
Register generation-owned observer disposal with registerExtensionContextRetirementEffect(ctx, dispose) during session_start. Disposal is synchronous, runs before authority revocation and before rollback invalidation, and is independent of session_shutdown handler order. The returned function unregisters the effect. Keep awaited resource cleanup in session_shutdown; observer disposal must not quit or checkpoint preserved workflows.
ctx.ui.
Shared loaders, event buses, settings, and SessionManager instances do not share live task ownership. Closing one session leaves another session’s commands and borrowed discovery resources alone.
Session replacement and deferred cleanup
await runtime.dispose() refuses new replacements and waits for pending factories, startup, and cleanup. It also waits up to three seconds, counted from the start of disposal, for an OAuth token exchange still in flight, so a token the provider already rotated is saved before your host exits. No successor is published after closure. Host callbacks must settle independently of disposal. Call runtime.dispose({ fromSignal: true }) when the host is shutting down because of SIGTERM or SIGHUP, so session_shutdown handlers receive fromSignal: true and can tell it from an explicit quit.
An extension command may await ctx.newSession(), ctx.fork(), ctx.switchSession(), or session.reload(). Its old generation remains owned until the command continuation finishes, and final disposal waits for that cleanup. Do not await disposal inside the continuation it must drain. Concurrent replacements are supported, and deferred cleanup failures remain visible.
After replacement or self-reload, use the new generation’s APIs. Captured old pi and ctx actions cannot mutate the successor or acquire its resources. Old shutdown handlers may release captured resources, but cannot authorize successor work.
For replacement initiated outside the retiring session’s work, outgoing shutdown finishes before successor creation. If it fails, replacement rejects without creating a successor. Handle creation, reload, replacement, and disposal failures rather than continuing as though cleanup succeeded.
Settings failures and operation IDs
Settings writes made through session APIs (for example,setThinkingLevel(level, { persist: true })) are attributed to that session. Unrecovered write failures make disposal reject with ShutdownFailed, even though the normal SettingsManager.flush() resolves and exposes errors through drainErrors(). Disposal does not consume that caller-owned error channel or attribute another borrower’s writes to an idle sibling. Correct the storage fault and persist again before closing to recover.
Caller-supplied executeBash() IDs remain correlation IDs, not unique operation IDs. abortBash(id) cancels every active call with that exact ID; disposal cancels every owned call, including duplicates.
Compaction and tree navigation
compact() removes selected older transcript lines without asking the model to rewrite retained text. It appends a durable compaction entry with details.strategy: "verbatim-lines" and respects the configured recent-message count. See Compaction for controls and Session format for persisted fields.
Pass compactionModel to choose the compaction model for one call without changing settings. It accepts the same exact provider/model IDs as the compactionModel setting: a chat model, a registered classifier, or morph/morph-compactor. An unknown ID rejects before the active run is interrupted. A call made while another manual compaction is running joins that run and uses its model.
session.navigateTree() rejects during streaming, compaction, or branch summarization rather than queueing the navigation. The active branch stays unchanged. Wait for the operation to finish before retrying.
Session replacement APIs such as new-session, resume, fork, and import live on AgentSessionRuntime, not on AgentSession.
createAgentSessionRuntime() and AgentSessionRuntime
Use the runtime API when you need to replace the active session and rebuild cwd-bound runtime state. This is the same layer used by the built-in interactive, print, and RPC modes.createAgentSessionRuntime() takes a runtime factory plus the initial cwd/session target. The factory closes over process-global fixed inputs, recreates cwd-bound services for the effective cwd, resolves session options against those services, and returns a full runtime result.
AgentSessionRuntime owns replacement of the active runtime across:
newSession()switchSession()fork()- clone flows via
fork(entryId, { position: "at" }) importFromJsonl()
runtime.sessionchanges after those operations- event subscriptions are attached to a specific
AgentSession, so re-subscribe after replacement - if you use extensions, call
runtime.session.bindExtensions(...)again for the new session - after a
switchSession(), callruntime.session.startPendingRecovery("resume")to recover interrupted work - creation returns diagnostics on
runtime.diagnostics - if runtime creation or replacement fails, the method throws and the caller decides how to handle it
Prompting and Message Queueing
PromptOptions controls prompt expansion, queueing behavior while streaming, and prompt preflight notifications:
preflightResult is called once per prompt() invocation:
truewhen the prompt was accepted, queued, or handled immediately. The second argument says which:"handled"if an extension command or input handler consumed it,"queued"if it was queued during a run, or"started"if it started a runfalsewhen prompt preflight rejected before acceptance
prompt() resolves. prompt() still resolves only after the full accepted run finishes, including retries. Failures after acceptance are reported through the normal event and message stream, not through preflightResult(false).
The prompt() method handles prompt templates, extension commands, and message sending:
- Extension commands (e.g.,
/mycommand): Execute immediately, even during streaming. They manage their own LLM interaction viapi.sendMessage(). - File-based prompt templates (from
.mdfiles): Expanded to their content before sending or queueing. - During streaming without
streamingBehavior: Throws an error. Usesteer()orfollowUp()directly, or specify the option. preflightResult(true): Means the prompt was accepted, queued, or handled immediately.preflightResult(false): Means preflight rejected before acceptance.
steer() and followUp() expand file-based prompt templates but error on extension commands (extension commands cannot be queued). They return "queued" if the input was queued (including after an extension transformed it), or "handled" if an extension consumed it.
pauseQueuedMessages() synchronously holds existing raw steering/follow-up entries before an abort boundary. Later context-bearing arrivals also stay queued without starting a provider turn. These include trigger-turn custom messages, batches, interrupts, sendUserMessage(), and ordinary prompt() calls.
The hold preserves content blocks, optional data, duplicate identities, raw text, message types, and order within each queue kind. Non-trigger custom messages remain history-only and do not start a turn.
resumeQueuedMessages() releases the hold exactly once but does not start or continue a model turn. Its promise resolves to true only when it released raw held steering/follow-up work, or false when none existed. The caller must use its explicit resume action, such as interactive chat submission or workflow resume, to drive execution.
clearQueue() clears the paused flag when it explicitly removes the final unowned held item. If a protected or interrupt-owned item remains, the gate stays paused.
Agent and AgentState
TheAgent class (from @earendil-works/pi-agent-core) handles the core LLM interaction. Access it via session.agent.
session.sessionManager as the canonical finalized context. Assigning session.agent.state.messages does not replace persisted context and may be overwritten at the next request boundary. Restore externally stored history when constructing the session instead:
session.navigateTree(entryId) to move its active branch. Use session.sessionManager.appendMessage(...) plus session.refreshContext() only when intentionally appending externally managed entries. A caller-supplied prepareNextTurnWithContext replacement context is still honored for exactly the request it prepared.
Events
Subscribe to events to receive streaming output and lifecycle notifications.message_start, not a snapshot. The provider keeps appending to that object, so appending deltas yourself would duplicate text.
If you subscribe part-way through a turn, seed your object from session.agent.state.streamingMessage, if present, to include the deltas you missed.
Collecting assistant output
createTranscript() does that bookkeeping for you. It turns session events into the assistant’s output as ordered, JSON-serializable parts:
parts() returns a fresh copy of the parts of every assistant message seen so far, including one still streaming:
{ type: "text", text }{ type: "thinking", thinking }. Redacted thinking is left out.{ type: "toolCall", id, name, arguments, result? }.resultappears once the tool reports progress or finishes. It holdsisErrorandisPartial, which staystrueuntil the tool finishes. If the message is aborted or fails, tool calls without a final result get an error result carrying the error message.
parentToolCallId) are ignored. By default, result.content holds the result’s text content. To get a tool’s structured details instead, pass toolResult:
Options Reference
Moved to SDK API reference.Directories
Moved to SDK API reference.Model
Moved to SDK API reference.Model catalog persistence and refresh
Moved to SDK API reference.API Keys and OAuth
Moved to SDK API reference.System Prompt
Moved to SDK API reference.Tools
Moved to SDK API reference.Bash tool behavior
Moved to SDK API reference.Waiting for existing shell tasks
Moved to SDK API reference.PowerShell tool behavior
Moved to SDK API reference.Tools with Custom cwd
Moved to SDK API reference.Custom Tools
Moved to SDK API reference.Structured output final results
Moved to SDK API reference.Extensions
Moved to SDK API reference.Skills
Moved to SDK API reference.Context Files
Moved to SDK API reference.Slash Commands
Moved to SDK API reference.Session Management
Moved to SDK API reference.Settings Management
Moved to SDK API reference.ResourceLoader
Moved to SDK API reference.Return Value
Moved to SDK API reference.Complete Example
Run Modes
Moved to SDK API reference.InteractiveMode
Moved to SDK API reference.runPrintMode
Moved to SDK API reference.runRpcMode
Moved to SDK API reference.RPC Mode Alternative
For subprocess-based integration without building with the SDK, use the CLI directly:- You want type safety
- You’re in the same Node.js process
- You need direct access to agent state
- You want to customize tools/extensions programmatically
- You’re integrating from another language
- You want process isolation
- You’re building a language-agnostic client