@molecule/api-agent-runtime-pi
Provider bond · agent-run · API (Node) · v1.0.3 · Apache-2.0
Pi coding-agent runtime — unattended runs on any Pi-supported model provider in ephemeral cloud sandboxes; artifacts-only return, per-run credentials, deny-by-default egress
npm install @molecule/api-agent-runtime-pinpm · Source on GitHub · Implements @molecule/api-agent-run
How it works
@molecule/api-agent-runtime-pi is a provider bond on the API (Node) side: it implements the agent-run core interface (@molecule/api-agent-run) with a concrete vendor or library behind it.
Your code calls the core; you wire this provider once at startup. Swapping vendors later is one line in that wiring, not a rewrite.
import { setProvider, requireProvider } from '@molecule/api-agent-run'
import { provider as pi } from '@molecule/api-agent-runtime-pi'
setProvider(pi)
const artifact = await requireProvider().run(
{
repoUrl: 'https://github.com/acme/widgets',
instructions: 'Fix the failing test in src/math.test.ts.',
model: 'deepseek-v4-pro', // catalog id → deepseek/deepseek-v4-pro
timeoutMs: 600_000,
},
{
env: {
GITHUB_TOKEN: fineGrainedToken, // ONE repo, contents:read/write, TTL ≈ timeout
DEEPSEEK_API_KEY: platformKey, // the key of the model's provider
},
onLog: (line) => process.stdout.write(line + '\n'),
},
)
if (artifact.patch) {
// HOST-side apply: scan, review, then git apply — never trust the sandbox.
}Works with: @molecule/api-agent-run, @molecule/api-ai-tools, @molecule/api-code-sandbox
Reference
Pi coding-agent runtime for molecule.dev.
Runs Pi (@earendil-works/pi-coding-agent, MIT) unattended inside an
EPHEMERAL cloud sandbox that is created for the run and destroyed before the
artifact returns. Pi is provider-neutral, so one runtime runs any catalog
model whose provider Pi supports — Anthropic, OpenAI, Google, xAI, DeepSeek,
Moonshot, MiniMax, OpenRouter — on that provider's own key and price.
Auto-generated, AI-first package reference for the molecule.dev ecosystem. It is written to be read by coding agents as much as by people, and is generated from this package's source — edit
src/index.tsJSDoc, not this file.
Quick Start
import { setProvider, requireProvider } from '@molecule/api-agent-run'
import { provider as pi } from '@molecule/api-agent-runtime-pi'
setProvider(pi)
const artifact = await requireProvider().run(
{
repoUrl: 'https://github.com/acme/widgets',
instructions: 'Fix the failing test in src/math.test.ts.',
model: 'deepseek-v4-pro', // catalog id → deepseek/deepseek-v4-pro
timeoutMs: 600_000,
},
{
env: {
GITHUB_TOKEN: fineGrainedToken, // ONE repo, contents:read/write, TTL ≈ timeout
DEEPSEEK_API_KEY: platformKey, // the key of the model's provider
},
onLog: (line) => process.stdout.write(line + '\n'),
},
)
if (artifact.patch) {
// HOST-side apply: scan, review, then git apply — never trust the sandbox.
}
Type
provider
Installation
npm install @molecule/api-agent-runtime-pi @molecule/api-agent-run @molecule/api-ai-tools @molecule/api-code-sandbox
API
Interfaces
PiRunSummary
What one pi --mode json invocation reported, condensed.
interface PiRunSummary {
/** Whether the stream opened with Pi's session header. */
sawSessionHeader: boolean
/** Whether `agent_settled` arrived — Pi's "no more automatic work" signal. */
settled: boolean
/** `stopReason` of the last completed assistant message (`stop`, `toolUse`, `length`, `error`, `aborted`, …). */
lastStopReason?: string
/** `errorMessage` of the last completed assistant message, when it failed. */
lastErrorMessage?: string
/** Prose of the last completed assistant message. */
lastText: string
/** Every distinct `provider/model` that answered, in order of first appearance. */
answeredBy: string[]
/** The final error of an automatic retry sequence that gave up. */
retryFailure?: string
/** Token usage summed over every assistant `message_end` and every successful compaction. */
usage?: AgentRunUsage
/** One short line per tool call and notable event, for the run's logs. */
lines: string[]
/** Records that were not JSON (Pi reserves stdout for JSONL, so these are anomalies). */
unparsed: number
}
ResolvedPiModel
A model resolved to what the Pi CLI needs.
interface ResolvedPiModel {
/** Pi provider name, e.g. `anthropic`. */
provider: string
/** Pi model id within the provider, e.g. `claude-sonnet-5-5`. */
model: string
/** The fully qualified `provider/id` passed to `--model`. */
qualified: string
/** How the sandbox reaches the provider. */
access: PiProviderAccess
}
PiProviderAccess
How the sandbox reaches one Pi model provider: the environment variable Pi reads its key from, and the API host the egress allowlist must open.
interface PiProviderAccess {
/** Environment variable Pi reads the provider's key from, e.g. `ANTHROPIC_API_KEY`. */
keyEnv: string
/** API hostname Pi calls for this provider, e.g. `api.anthropic.com`. */
host: string
}
PiRuntimeConfig
Options for createProvider.
interface PiRuntimeConfig extends AgentRunConfig {
/**
* npm package spec for the Pi CLI, installed at run start from the
* egress-allowed npm registry. Always an EXACT version. Default
* `@earendil-works/pi-coding-agent@1.0.0`.
*/
cliPackage?: string
/**
* Model used when a spec names none. A catalog id (`claude-sonnet-5-5`) found
* in the model map, or a fully qualified Pi `provider/id`. Default
* `claude-sonnet-5-5`.
*/
defaultModel?: string
/**
* Extra entries for the catalog-id → Pi `provider/id` map, merged over
* `DEFAULT_MODEL_MAP`.
*/
modelMap?: Record<string, string>
/**
* Extra Pi providers (or overrides), merged over `PI_PROVIDERS`. A run
* on a provider found in neither is refused before any sandbox is created.
*/
providers?: Record<string, PiProviderAccess>
/**
* Trust the repository's project-local Pi files (`.pi/settings.json`,
* `.pi/mcp.json`, `.pi/extensions`, `.pi/SYSTEM.md`, project skills) for the
* run — `--approve` instead of the default `--no-approve`. Repo-supplied
* extensions are executable code; enable only for repositories you trust.
* Default false.
*/
approveProjectFiles?: boolean
/**
* Tool selection passed as `--tools`. Default: Pi's own default
* (`read`, `bash`, `edit`, `write`).
*/
tools?: string[]
/**
* Extra seconds granted to the ENVIRONMENT beyond the task budget — the
* clone + CLI install happen before the agent starts, and the artifact
* collection happens after. Default 240000 (4 minutes).
*/
setupSlackMs?: number
}
Classes
PiAgentRuntime
Pi coding-agent runtime backed by an ephemeral cloud sandbox.
class PiAgentRuntime implements AgentRuntimeProvider {
readonly name = 'pi'
private readonly defaults: {
timeoutMs: number
cliPackage: string
defaultModel: string
setupSlackMs: number
approveProjectFiles: boolean
tools: string[] | undefined
modelMap: Record<string, string>
providers: Record<string, PiProviderAccess>
}
/**
* Create the runtime.
*
* @param config - CLI package, default model, model map, providers, budgets.
*/
constructor(config: PiRuntimeConfig = {}) {
this.defaults = {
timeoutMs: config.timeoutMs ?? 600_000,
cliPackage: config.cliPackage ?? DEFAULT_PI_CLI_PACKAGE,
defaultModel: config.defaultModel ?? 'claude-sonnet-5-5',
setupSlackMs: config.setupSlackMs ?? 240_000,
approveProjectFiles: config.approveProjectFiles ?? false,
tools: config.tools,
modelMap: { ...DEFAULT_MODEL_MAP, ...config.modelMap },
providers: { ...PI_PROVIDERS, ...config.providers },
}
}
/**
* Execute one unattended agent run in a fresh ephemeral sandbox.
*
* @param spec - The task.
* @param opts - Per-run credentials (used ONLY by the exec calls that need
* them), log sink, cancellation signal.
* @returns Artifacts: patch, redacted logs, usage, sandbox id, compute time.
*/
async run(spec: AgentRunSpec, opts: AgentRunOptions): Promise<AgentRunArtifact> {
const provider = requireSandboxProvider()
const totalBudgetMs = spec.timeoutMs ?? this.defaults.timeoutMs
const sandboxBudgetMs = totalBudgetMs + this.defaults.setupSlackMs
const started = Date.now()
const logs: string[] = []
const log = (line: string): void => {
logs.push(line)
try {
opts.onLog?.(line)
} catch (_error) {
// Intentional noop: a throwing log sink must not fail the run.
}
}
const abortCheck = (): void => {
if (opts.signal?.aborted) throw new CANCELLED()
}
let sandboxId: string | undefined
// Any exit from here destroys the sandbox FIRST — credentials die with it.
try {
abortCheck()
// Caller input is validated BEFORE any sandbox or exec exists (M-1):
// the REST route applies this gate, but the bond is a published package
// any consumer can call directly, and every one of these values would
// otherwise reach a shell command inside the sandbox.
if (!GITHUB_REPO_URL_RE.test(spec.repoUrl)) {
throw new Error(
`repoUrl must be an https GitHub repo URL (https://github.com/owner/repo), got: ${JSON.stringify(spec.repoUrl.slice(0, 100))}`,
)
}
if (opts.env.GITHUB_TOKEN && !GITHUB_TOKEN_RE.test(opts.env.GITHUB_TOKEN)) {
throw new Error(
'GITHUB_TOKEN does not look like a GitHub token (expected ghp_/gho_/ghu_/ghs_/github_pat_ followed by at least 20 more characters) — refusing the run rather than placing a malformed credential into the sandbox.',
)
}
// Resolve the model and its credential BEFORE a sandbox exists: an
// unmappable model or a missing key fails the run for free.
const resolved = resolvePiModel(
spec.model || this.defaults.defaultModel,
this.defaults.modelMap,
this.defaults.providers,
)
const modelKey = opts.env[resolved.access.keyEnv]
if (!modelKey) {
throw new Error(
`${resolved.access.keyEnv} is missing from opts.env — Pi needs it to call ${resolved.provider} (${resolved.qualified}).`,
)
}
// The provider's API host first: it is the host the egress probe dials.
const allowOut = [
...new Set([resolved.access.host, ...RUNTIME_ALLOWED_HOSTS, ...(spec.allowedHosts ?? [])]),
]
assertProbeSafeHosts(allowOut)
const handle: NetworkCapableSandbox = await provider.create({
// NOT a project id: no env-materialization, no scaffold, no vault —
// the sandbox boots from the bare template and stays bare.
projectId: `agent-run-${Date.now().toString(36)}`,
env: {},
labels: { 'molecule.agent-run': '1' },
})
sandboxId = handle.id
// Egress policy + OBSERVED proof of it, before any credential exists
// here. A provider that cannot enforce a per-run policy is refused
// outright (M-3): an unimplemented capability is `inconclusive`, never
// safe, and this run is about to hold live credentials.
if (typeof handle.applyNetwork !== 'function') {
throw new Error('sandbox provider cannot enforce per-run egress; refusing agent run')
}
await handle.applyNetwork(allowOut)
await assertEgress(handle, allowOut)
abortCheck()
// Clone with the credential carried by an in-sandbox GIT_ASKPASS helper
// (M-1, the house pattern from audit H-3 2026-09-17): git answers its
// credential prompt by executing the helper, so the token never rides
// the clone URL or the git argv. The helper is staged at an unguessable
// 0700 path (base64-delivered, so the token is not plaintext in the
// staging argv either), used for the clone + branch fetch, and removed
// before the agent starts. Logs are redacted before leaving the bond.
const token = opts.env.GITHUB_TOKEN ?? ''
const askpass = token ? buildAskpassHelper(token) : null
const gitEnvPrefix = askpass
? `GIT_ASKPASS=${shellQuote(askpass.path)} GIT_TERMINAL_PROMPT=0 `
: ''
if (askpass) {
const staged = await handle.exec(askpass.stageCommand, { timeout: 5_000 })
if (staged.exitCode !== 0) {
throw new Error('failed to stage the git credential helper inside the sandbox')
}
}
try {
const clone = await handle.exec(
`${gitEnvPrefix}git clone --depth 1 ${shellQuote(spec.repoUrl)} ${REPO_DIR}`,
{ timeout: Math.min(180_000, sandboxBudgetMs) },
)
if (clone.exitCode !== 0) {
throw new Error(
`clone failed: ${redact(clone.stderr || clone.stdout, opts.env).slice(0, 300)}`,
)
}
if (spec.baseBranch) {
const co = await handle.exec(
`cd ${REPO_DIR} && ${gitEnvPrefix}git fetch --depth 1 origin ${shellQuote(spec.baseBranch)} && git checkout ${shellQuote(spec.baseBranch)}`,
{ timeout: 120_000 },
)
if (co.exitCode !== 0) {
throw new Error(
`checkout ${spec.baseBranch} failed: ${redact(co.stderr, opts.env).slice(0, 200)}`,
)
}
}
} finally {
if (askpass) {
await handle
.exec(askpass.cleanupCommand, { timeout: 5_000 })
.catch((_error: unknown) => undefined)
}
}
abortCheck()
// Pi needs Node >= 22.19 — say so plainly instead of a cryptic CLI crash.
const nodeCheck = await handle.exec(
`node -e "const [a,b]=process.versions.node.split('.').map(Number);process.exit(a>22||(a===22&&b>=19)?0:1)"`,
{ timeout: 15_000 },
)
if (nodeCheck.exitCode !== 0) {
throw new Error('pi CLI needs Node.js >= 22.19 in the sandbox image.')
}
// Install the agent CLI at run start, from the egress-allowed npm
// registry. Pi needs no lifecycle scripts, so none run.
const install = await handle.exec(
`npm install -g --ignore-scripts ${shellQuote(this.defaults.cliPackage)}`,
{ timeout: Math.min(300_000, sandboxBudgetMs) },
)
if (install.exitCode !== 0) {
throw new Error(`pi CLI install failed: ${redactSecrets(install.stderr).slice(0, 300)}`)
}
abortCheck()
// The task travels by FILE + stdin, never argv, never the environment.
await handle.writeFile(PROMPT_PATH, spec.instructions)
const deadlineMs = Math.max(30_000, totalBudgetMs - (Date.now() - started))
const agentStarted = Date.now()
const agent = await handle.exec(
// … truncated — the full declaration is in node_modules/@molecule/api-agent-runtime-pi/dist/provider.d.ts
Functions
splitJsonlRecords(text)
Split a JSONL stream into records exactly as Pi's framing requires: on LF only, stripping one optional preceding CR. Blank records are dropped.
function splitJsonlRecords(text: string): string[]
text— Raw stdout.
Returns: The record strings.
summarizePiEvents(stdout)
Condense a pi --mode json stdout into what the runtime reports.
function summarizePiEvents(stdout: string): PiRunSummary
stdout— The raw JSONL stream.
Returns: The run summary.
resolvePiModel(id, modelMap, providers)
Resolve a catalog id or a fully qualified Pi id to the provider, model and credential the run needs.
function resolvePiModel(
id: string,
modelMap: Readonly<Record<string, string>>,
providers: Readonly<Record<string, PiProviderAccess>>,
): ResolvedPiModel
id— Catalog id (claude-sonnet-5-5) or Pi id (openrouter/qwen/qwen3-coder).modelMap— Catalog-id map (defaults merged with config).providers— Provider access table (defaults merged with config).
Returns: The resolved model.
createProvider(config)
Create a Pi agent runtime.
function createProvider(config?: PiRuntimeConfig): AgentRuntimeProvider
config— CLI package, default model, model map, providers, budgets.
Returns: An AgentRuntimeProvider running the Pi CLI in ephemeral sandboxes.
Constants
PI_PROVIDERS
Pi providers the runtime knows how to credential and allowlist. Keys are
Pi's provider names (the part before / in a model id).
const PI_PROVIDERS: Readonly<Record<string, PiProviderAccess>>
DEFAULT_MODEL_MAP
molecule catalog model id → Pi provider/id, for every catalog model Pi
1.0.0 serves on the provider's own pay-as-you-go API. Catalog models absent
here (e.g. Qwen, GLM — Pi reaches those only through coding/token plans)
must be passed as a fully qualified Pi id or added via modelMap.
const DEFAULT_MODEL_MAP: Readonly<Record<string, string>>
RUNTIME_ALLOWED_HOSTS
Hosts the runtime's own tooling needs, always allowed on top of the spec's
and the model provider's API host. pi.dev is deliberately absent: the CLI
runs with --offline, PI_SKIP_VERSION_CHECK=1 and PI_TELEMETRY=0.
const RUNTIME_ALLOWED_HOSTS: readonly [
'github.com',
'api.github.com',
'codeload.github.com',
'objects.githubusercontent.com',
'registry.npmjs.org',
]
DEFAULT_PI_CLI_PACKAGE
The exact Pi CLI version the runtime installs by default.
const DEFAULT_PI_CLI_PACKAGE: '@earendil-works/pi-coding-agent@1.0.0'
provider
The provider implementation (wire with the agent-run core's setProvider).
const provider: AgentRuntimeProvider
Core Interface
Implements @molecule/api-agent-run interface.
Bond Wiring
Setup function to register this provider with the core interface:
import { setProvider } from '@molecule/api-agent-run'
import { provider } from '@molecule/api-agent-runtime-pi'
export function setupAgentRuntimePi(): void {
setProvider(provider)
}
Injection Notes
Requirements
Peer dependencies:
@molecule/api-agent-run>=1.0.0@molecule/api-ai-tools>=1.0.0@molecule/api-code-sandbox>=1.0.0
Runtime Dependencies
-
@molecule/api-agent-run -
@molecule/api-ai-tools -
@molecule/api-code-sandbox -
Isolation contract (same as the Claude Code runtime): the sandbox is created through the code-sandbox bond's MINIMAL path; credentials ride ONLY the exec calls that need them — the clone authenticates through an in-sandbox
GIT_ASKPASShelper staged at an unguessable path and removed after (the GitHub token never appears in a clone URL or git argv), the model provider's key the agent; egress is deny-by-default where the sandbox supports network policy and is PROBED before any credential is injected; the sandbox is destroyed on every exit path. Never run Pi on the API host: it has no permission prompts and no sandbox of its own. -
A sandbox provider without per-run egress enforcement is refused (M-3): if the bonded provider has no
applyNetwork, the run fails before any credential is injected — an unimplemented capability isinconclusive, never safe. -
Caller input is validated before any exec (M-1):
repoUrlmust matchhttps://github.com/<owner>/<repo>(same gate the REST route applies), a non-emptyGITHUB_TOKENmust have a GitHub token shape (ghp_/gho_/ghu_/ghs_/github_pat_+ ≥20 more characters), and everyallowedHostsentry must be a bare hostname. Anything else refuses the run before a sandbox exists. -
Pass the model provider's key under Pi's env var name —
ANTHROPIC_API_KEY,OPENAI_API_KEY,GEMINI_API_KEY,XAI_API_KEY,DEEPSEEK_API_KEY,MOONSHOT_API_KEY,MINIMAX_API_KEYorOPENROUTER_API_KEY. A missing key fails the run before any sandbox is created. Only that one key enters the agent's environment. -
Models are resolved to a fully qualified Pi
provider/id. Pi's--modelfuzzy-matches, so a bare id could silently pick another model. Catalog ids inDEFAULT_MODEL_MAPare mapped; anything else must be passed asprovider/id(e.g.openrouter/qwen/qwen3-coder) or added viacreateProvider({ modelMap })— an unmapped bare id is refused. Pi's thinking suffix works too (anthropic/claude-opus-5-5:high). The run is markedfailedif Pi reports that a different model answered. -
Pi's exit code does not mean success. In
--mode jsona failed or aborted model call still exits 0, soexitStatuscomes from the event stream: the last assistantstopReason(error→failed,aborted→cancelled), a retry sequence that gave up (failed), and the stream ending beforeagent_settled(failed, ortimeoutat the deadline). -
Project-local Pi files are ignored by default (
--no-approve): a repo's.pi/settings.json,.pi/mcp.json,.pi/extensionsand.pi/SYSTEM.mddo not load.createProvider({ approveProjectFiles: true })loads them — repo-supplied extensions are executable code, so enable it only for repositories you trust.AGENTS.md/CLAUDE.mdalways load. -
No background network calls: the CLI runs with
--offline,PI_SKIP_VERSION_CHECK=1andPI_TELEMETRY=0, so the egress allowlist is just the model provider's API host, GitHub and the npm registry — never addpi.dev. -
The CLI is installed at run start, pinned EXACTLY (
@earendil-works/pi-coding-agent@1.0.0, with--ignore-scripts); its transitive dependencies are not pinned by npm. The sandbox image needs Node.js ≥ 22.19 — the run fails with that message otherwise. -
Default tools are
read,bash,edit,write— nogrep/find/ls(the model searches withbash). PasscreateProvider({ tools: ['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls'] })to add them. Pi has no sub-agents and no plan mode. -
usagesums every assistant response plus context-compaction summaries (Pi compacts long runs automatically, and those calls are billed too). -
Cancellation is cooperative: a signalled abort destroys the sandbox at the next phase boundary and the artifact reports
exitStatus: 'cancelled'.