@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-pi

npm · 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.ts JSDoc, 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_ASKPASS helper 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 is inconclusive, never safe.

  • Caller input is validated before any exec (M-1): repoUrl must match https://github.com/<owner>/<repo> (same gate the REST route applies), a non-empty GITHUB_TOKEN must have a GitHub token shape (ghp_/gho_/ghu_/ghs_/github_pat_ + ≥20 more characters), and every allowedHosts entry 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_KEY or OPENROUTER_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 --model fuzzy-matches, so a bare id could silently pick another model. Catalog ids in DEFAULT_MODEL_MAP are mapped; anything else must be passed as provider/id (e.g. openrouter/qwen/qwen3-coder) or added via createProvider({ modelMap }) — an unmapped bare id is refused. Pi's thinking suffix works too (anthropic/claude-opus-5-5:high). The run is marked failed if Pi reports that a different model answered.

  • Pi's exit code does not mean success. In --mode json a failed or aborted model call still exits 0, so exitStatus comes from the event stream: the last assistant stopReason (error → failed, aborted → cancelled), a retry sequence that gave up (failed), and the stream ending before agent_settled (failed, or timeout at the deadline).

  • Project-local Pi files are ignored by default (--no-approve): a repo's .pi/settings.json, .pi/mcp.json, .pi/extensions and .pi/SYSTEM.md do not load. createProvider({ approveProjectFiles: true }) loads them — repo-supplied extensions are executable code, so enable it only for repositories you trust. AGENTS.md / CLAUDE.md always load.

  • No background network calls: the CLI runs with --offline, PI_SKIP_VERSION_CHECK=1 and PI_TELEMETRY=0, so the egress allowlist is just the model provider's API host, GitHub and the npm registry — never add pi.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 — no grep/find/ls (the model searches with bash). Pass createProvider({ tools: ['read', 'bash', 'edit', 'write', 'grep', 'find', 'ls'] }) to add them. Pi has no sub-agents and no plan mode.

  • usage sums 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'.