@yae-tools/narukami-shrine
v0.5.14
Published
CLI for orchestrating AI agents in isolated sandbox environments
Readme
Narukami Shrine
What Is Narukami Shrine?
A TypeScript library for orchestrating AI coding agents in isolated sandboxes:
- You invoke agents with a single
narukami.run(). - Narukami Shrine handles sandboxing the agent with a configurable branch strategy.
- The commits made on the branches get merged back.
Narukami Shrine is provider-agnostic — it ships with built-in providers for Docker, Podman, and Vercel, and you can create your own. Great for parallelizing multiple AFK agents, creating review pipelines, or even just orchestrating your own agents.
Prerequisites
- Git
- A sandbox provider — Narukami Shrine needs an isolated environment to run agents in. Built-in options:
- Docker Desktop — most common for local development
- Podman — rootless alternative to Docker
- Vercel — cloud-based Firecracker microVMs via
@vercel/sandbox - Or create your own using
createBindMountSandboxProviderorcreateIsolatedSandboxProvider
Quick start
- Install the package:
npm install --save-dev @yae-tools/narukami-shrine- Run
narukami init. This scaffolds a.narukamidirectory with all the files needed.
npx narukami init- Run
codex loginon the host to use your ChatGPT subscription. The default Codex scaffold mounts~/.codexinto the sandbox so the Codex CLI can reuse that login. Then copy.narukami/.env.exampleif your selected issue provider needs any local env values.
cp .narukami/.env.example .narukami/.env- Run the
.narukami/main.ts(ormain.mts) file withnpx tsx
npx tsx .narukami/main.ts// 3. Run the agent via the JS API
import { run, codex } from "@yae-tools/narukami-shrine";
import { docker } from "@yae-tools/narukami-shrine/sandboxes/docker";
await run({
agent: codex("gpt-5.5", { effort: "low" }),
sandbox: docker({
mounts: [{ hostPath: "~/.codex", sandboxPath: "~/.codex" }],
}), // or podman(), vercel(), or your own provider
promptFile: ".narukami/prompt.md",
});Differences From Upstream
Narukami Shrine is still recognizably Sandcastle at its core: it creates an isolated worktree or sandbox, runs an AI coding agent, collects commits, and merges or preserves the result according to the branch strategy. This fork changes the defaults and the operational edges to fit Codex subscription workflows, Linear-tracked task queues, and larger pnpm monorepos.
Beyond the Narukami branding, Codex-first defaults, and Linear-first issue provider, the major differences are:
- Package-manager-aware scaffolding — generated Dockerfiles and setup hooks understand
pnpm, install a pinned pnpm version when needed, use non-interactive install flags, and allow longer setup hooks for large workspaces. - No host
node_modulescopy by default — templates avoid copying host dependencies into sandbox worktrees, which prevents native binary mismatches such as macOSesbuildpackages being reused inside Linux containers. - Hardened Docker/Podman runtime env — default sandbox env writes Git, Corepack, pnpm, and XDG cache/config files under
/tmp, avoiding common permission failures from mounted home directories and container users. - Better sandbox failure output — lifecycle hook failures include useful stdout/stderr context, making dependency, quality-gate, and native-binary failures easier to diagnose.
- More reliable worktree handling — worktree paths are canonicalized before reuse checks, which helps on macOS path aliases such as
/varversus/private/var. - Issue provider terminology — the init flow and internal registry use issue-provider language instead of backlog-manager language, so Linear, GitHub Issues, Beads, and future trackers fit the same abstraction.
- Narukami runtime layout — generated projects use
.narukami/for config, logs, worktrees, env examples, and prompts, withnarukami:<repo>image names andnarukami/...branch naming. - CLI surface cleanup — the package exposes
narukamiandnarukami-shrinecommands only; the oldsandcastlecompatibility alias is intentionally not included. - Build dependency correctness — optional sandbox integrations such as Daytona have the type/runtime dependencies needed for this package to build cleanly from source.
- Expanded tests and docs — tests and documentation cover the new defaults, issue-provider setup, Docker env behavior, pnpm scaffold behavior, worktree canonicalization, and Narukami naming.
Sandbox Providers
Narukami Shrine uses a SandboxProvider to create isolated environments. The sandbox option on run() and createSandbox() accepts any provider. A no-sandbox option is also available for interactive() and wt.interactive(). Built-in providers:
| Provider | Import path | Type | Accepted by |
| ---------- | ------------------------------------------------- | ---------- | --------------------------------------------- |
| Docker | @yae-tools/narukami-shrine/sandboxes/docker | Bind-mount | run(), createSandbox(), interactive() |
| Podman | @yae-tools/narukami-shrine/sandboxes/podman | Bind-mount | run(), createSandbox(), interactive() |
| Vercel | @yae-tools/narukami-shrine/sandboxes/vercel | Isolated | run(), createSandbox(), interactive() |
| No-sandbox | @yae-tools/narukami-shrine/sandboxes/no-sandbox | None | interactive(), wt.interactive() (default) |
Worktree methods (wt.run(), wt.interactive(), wt.createSandbox()) accept the same providers as their top-level counterparts. wt.interactive() defaults to noSandbox() when no sandbox is specified.
import { docker } from "@yae-tools/narukami-shrine/sandboxes/docker";
import { podman } from "@yae-tools/narukami-shrine/sandboxes/podman";
import { vercel } from "@yae-tools/narukami-shrine/sandboxes/vercel";
import { noSandbox } from "@yae-tools/narukami-shrine/sandboxes/no-sandbox";
// Docker, Podman, and Vercel are interchangeable in run() and createSandbox():
await run({
agent: codex("gpt-5.5", { effort: "low" }),
sandbox: docker({
mounts: [{ hostPath: "~/.codex", sandboxPath: "~/.codex" }],
}),
prompt: "...",
});
// No-sandbox runs the agent directly on the host — interactive() only:
await interactive({
agent: codex("gpt-5.5", { effort: "low" }),
sandbox: noSandbox(),
prompt: "...", // optional — omit to launch the TUI with no initial prompt
cwd: "/path/to/other-repo", // optional — defaults to process.cwd()
});You can also create your own provider using createBindMountSandboxProvider or createIsolatedSandboxProvider.
API
Narukami Shrine exports a programmatic run() function for use in scripts, CI pipelines, or custom tooling. The examples below use docker(), but any SandboxProvider works in its place.
import { run, codex } from "@yae-tools/narukami-shrine";
import { docker } from "@yae-tools/narukami-shrine/sandboxes/docker";
const result = await run({
agent: codex("gpt-5.5", { effort: "low" }),
sandbox: docker({
mounts: [{ hostPath: "~/.codex", sandboxPath: "~/.codex" }],
}),
promptFile: ".narukami/prompt.md",
});
console.log(result.iterations.length); // number of iterations executed
console.log(result.iterations); // per-iteration results with optional sessionId
console.log(result.commits); // array of { sha } for commits created
console.log(result.branch); // target branch nameAll options
import { run, codex } from "@yae-tools/narukami-shrine";
import { docker } from "@yae-tools/narukami-shrine/sandboxes/docker";
const result = await run({
// Agent provider — required. Codex is the default focus.
// Optional second arg for provider-specific options like effort level.
agent: codex("gpt-5.5", { effort: "low" }),
// Sandbox provider — required. Any SandboxProvider works (docker, podman, vercel, or custom).
// Provider-specific config (like imageName, mounts) lives inside the provider factory call.
sandbox: docker({
imageName: "narukami:local",
// Optional: override the UID/GID used for --user flag (defaults to host UID/GID).
// Must match the UID baked into the image. Pre-flight check catches mismatches.
// containerUid: 1000,
// containerGid: 1000,
// Optional: mount host directories into the sandbox (e.g. package manager caches)
// hostPath supports absolute, tilde-expanded (~), and relative paths (resolved from cwd).
// sandboxPath supports absolute and relative paths (resolved from the sandbox repo directory).
mounts: [
{ hostPath: "~/.codex", sandboxPath: "~/.codex" },
{ hostPath: "~/.npm", sandboxPath: "/home/agent/.npm", readonly: true },
{ hostPath: "data", sandboxPath: "data" }, // mounts <cwd>/data → <sandbox-repo>/data
],
// Optional: SELinux volume label — "z" (default, shared), "Z" (private), or false (none).
// No-op on non-SELinux systems (Docker Desktop on macOS/Windows, Linux without SELinux).
selinuxLabel: "z",
// Optional: provider-level env vars merged at launch time
env: { DOCKER_SPECIFIC: "value" },
// Optional: attach container to Docker network(s) — string or string[]
network: "my-network",
}),
// Host repo directory — replaces process.cwd() as the anchor for
// .narukami/ artifacts (worktrees, logs, env, patches) and git operations.
// Relative paths resolve against process.cwd(). Defaults to process.cwd().
cwd: "../other-repo",
// Branch strategy — controls how the agent's changes relate to branches.
// Defaults to { type: "head" } for bind-mount and { type: "merge-to-head" } for isolated providers.
branchStrategy: { type: "branch", branch: "agent/fix-42" },
// Prompt source — provide one of these, not both.
// Note: promptFile resolves against process.cwd(), NOT cwd.
promptFile: ".narukami/prompt.md", // path to a prompt file
// prompt: "Fix issue #42 in this repo", // OR an inline prompt string
// Values substituted for {{KEY}} placeholders in the prompt.
promptArgs: {
ISSUE_NUMBER: "42",
},
// Maximum number of agent iterations to run before stopping. Default: 1
maxIterations: 5,
// Display name for this run, shown as a prefix in log output.
name: "fix-issue-42",
// Lifecycle hooks grouped by where they run: host or sandbox.
hooks: {
host: {
onWorktreeReady: [{ command: "cp .env.example .env" }],
onSandboxReady: [{ command: "echo setup done" }],
},
sandbox: {
onSandboxReady: [{ command: "npm install" }],
},
},
// Host-relative file paths to copy into the sandbox before the container starts.
// Not supported with branchStrategy: { type: "head" }.
copyToWorktree: [".env"],
// Override default timeouts for built-in lifecycle steps.
// Unset keys keep their defaults.
timeouts: {
copyToWorktreeMs: 120_000, // default: 60_000
},
// How to record progress. Default: write to a file under .narukami/logs/
logging: {
type: "file",
path: ".narukami/logs/my-run.log",
// Optional: forward the agent's output stream to your own observability system.
// Fires for each text chunk and tool call the agent produces. Errors thrown
// by the callback are swallowed so a broken forwarder cannot kill the run.
onAgentStreamEvent: (event) => {
// event is { type: "text" | "toolCall", iteration, timestamp, ... }
myLogger.info(event);
},
},
// logging: { type: "stdout" }, // OR render an interactive UI in the terminal
// String (or array of strings) the agent emits to end the iteration loop early.
// Default: "<promise>COMPLETE</promise>"
completionSignal: "<promise>COMPLETE</promise>",
// Idle timeout in seconds — resets whenever the agent produces output. Default: 600 (10 minutes)
idleTimeoutSeconds: 600,
// Structured output — extract a typed payload from the agent's stdout.
// Requires maxIterations === 1 and the tag must appear in the prompt.
// output: Output.object({ tag: "result", schema: z.object({ answer: z.number() }) }),
// output: Output.string({ tag: "summary" }),
});
console.log(result.iterations.length); // number of iterations executed
console.log(result.completionSignal); // matched signal string, or undefined if none fired
console.log(result.commits); // array of { sha } for commits created
console.log(result.branch); // target branch namecreateSandbox() — reusable sandbox
Use createSandbox() when you need to run multiple agents (or multiple rounds of the same agent) inside a single sandbox. It creates the sandbox once, and you call sandbox.run() as many times as you need. This avoids repeated container startup costs and keeps all runs on the same branch.
Use run() instead when you only need a single one-shot invocation — it handles sandbox lifecycle automatically.
Basic single-run usage
import { createSandbox, codex } from "@yae-tools/narukami-shrine";
import { docker } from "@yae-tools/narukami-shrine/sandboxes/docker";
await using sandbox = await createSandbox({
branch: "agent/fix-42",
sandbox: docker(),
});
const result = await sandbox.run({
agent: codex("gpt-5.5", { effort: "low" }),
prompt: "Fix issue #42 in this repo.",
});
console.log(result.commits); // [{ sha: "abc123" }]Multi-run implement-then-review
import { createSandbox, codex } from "@yae-tools/narukami-shrine";
import { docker } from "@yae-tools/narukami-shrine/sandboxes/docker";
await using sandbox = await createSandbox({
branch: "agent/fix-42",
sandbox: docker(),
hooks: { sandbox: { onSandboxReady: [{ command: "npm install" }] } },
});
// Step 1: implement
const implResult = await sandbox.run({
agent: codex("gpt-5.5", { effort: "low" }),
promptFile: ".narukami/implement.md",
maxIterations: 5,
});
// Step 2: review on the same branch, same container
const reviewResult = await sandbox.run({
agent: codex("gpt-5.5", { effort: "low" }),
prompt: "Review the changes and fix any issues.",
});Commits from all run() calls accumulate on the same branch. The sandbox container stays alive between runs, so installed dependencies and build artifacts persist.
Automatic cleanup with await using
await using calls sandbox.close() automatically when the block exits. If the sandbox has uncommitted changes, the worktree is preserved on disk; if clean, both container and worktree are removed.
Manual close() with CloseResult
const sandbox = await createSandbox({
branch: "agent/fix-42",
sandbox: docker(),
});
// ... run agents ...
const closeResult = await sandbox.close();
if (closeResult.preservedWorktreePath) {
console.log(`Worktree preserved at ${closeResult.preservedWorktreePath}`);
}CreateSandboxOptions
| Option | Type | Default | Description |
| ---------------- | --------------- | --------------- | -------------------------------------------------------------------- |
| branch | string | — | Required. Explicit branch for the sandbox |
| sandbox | SandboxProvider | — | Required. Sandbox provider (e.g. docker(), podman()) |
| cwd | string | process.cwd() | Host repo directory — relative paths resolve against process.cwd() |
| hooks | SandboxHooks | — | Lifecycle hooks (host.*, sandbox.*) — run once at creation time |
| copyToWorktree | string[] | — | Host-relative file paths to copy into the sandbox at creation time |
| timeouts | Timeouts | — | Override default timeouts (e.g. { copyToWorktreeMs: 120_000 }) |
Sandbox
| Property / Method | Type | Description |
| ----------------------- | ------------------------------------------------------------------ | -------------------------------------------- |
| branch | string | The branch the sandbox is on |
| worktreePath | string | Host path to the worktree |
| run(options) | (SandboxRunOptions) => Promise<SandboxRunResult> | Invoke an agent inside the existing sandbox |
| interactive(options) | (SandboxInteractiveOptions) => Promise<SandboxInteractiveResult> | Launch an interactive session in the sandbox |
| close() | () => Promise<CloseResult> | Tear down the container and sandbox |
| [Symbol.asyncDispose] | () => Promise<void> | Auto teardown via await using |
SandboxRunOptions
| Option | Type | Default | Description |
| -------------------- | ------------------ | ----------------------------- | ------------------------------------------------------------------------- |
| agent | AgentProvider | — | Required. Agent provider (e.g. codex("gpt-5.5", { effort: "low" })) |
| prompt | string | — | Inline prompt (mutually exclusive with promptFile) |
| promptFile | string | — | Path to prompt file (mutually exclusive with prompt) |
| promptArgs | PromptArgs | — | Key-value map for {{KEY}} placeholder substitution |
| maxIterations | number | 1 | Maximum iterations to run |
| completionSignal | string | string[] | <promise>COMPLETE</promise> | String(s) the agent emits to stop the iteration loop early |
| idleTimeoutSeconds | number | 600 | Idle timeout in seconds — resets on each agent output event |
| name | string | — | Display name for the run |
| logging | object | file (auto-generated) | { type: 'file', path } or { type: 'stdout' } |
| signal | AbortSignal | — | Cancels the run when aborted; handle stays usable afterward |
SandboxRunResult
| Field | Type | Description |
| ------------------ | ------------------- | ------------------------------------------------------------------ |
| iterations | IterationResult[] | Per-iteration results (use .length for the count) |
| completionSignal | string? | The matched completion signal string, or undefined if none fired |
| stdout | string | Combined agent output from all iterations |
| commits | { sha }[] | Commits created during the run |
| logFilePath | string? | Path to the log file (only when logging to a file) |
CloseResult
| Field | Type | Description |
| ----------------------- | ------- | ------------------------------------------------------------------------ |
| preservedWorktreePath | string? | Host path to the preserved worktree, set when it had uncommitted changes |
createWorktree() — independent worktree lifecycle
Use createWorktree() when you need a worktree (git worktree) as an independent, first-class concept — separate from any sandbox. This is useful when you want to run an interactive session first and then hand the same worktree to a sandboxed AFK agent.
Only branch and merge-to-head strategies are accepted; head is a compile-time type error since it means no worktree.
Pass cwd to target a repo other than process.cwd(). Relative paths resolve against process.cwd(); absolute paths pass through. A CwdError is thrown if the path does not exist or is not a directory.
import { createWorktree } from "@yae-tools/narukami-shrine";
await using wt = await createWorktree({
branchStrategy: { type: "branch", branch: "agent/fix-42" },
copyToWorktree: ["node_modules"],
cwd: "/path/to/other-repo", // optional — defaults to process.cwd()
});
console.log(wt.worktreePath); // host path to the worktree
console.log(wt.branch); // "agent/fix-42"
// Run an interactive session in the worktree (defaults to noSandbox)
await wt.interactive({
agent: codex("gpt-5.5", { effort: "low" }),
prompt: "Explore the codebase and understand the bug.",
});
// Run an AFK agent in the worktree (sandbox is required)
const result = await wt.run({
agent: codex("gpt-5.5", { effort: "low" }),
sandbox: docker({ imageName: "narukami:myrepo" }),
prompt: "Fix issue #42.",
maxIterations: 3,
});
console.log(result.commits); // commits made during the run
// Create a long-lived sandbox from the worktree
import { docker } from "@yae-tools/narukami-shrine/sandboxes/docker";
await using sandbox = await wt.createSandbox({
sandbox: docker(),
hooks: { sandbox: { onSandboxReady: [{ command: "npm install" }] } },
});
// sandbox.close() tears down the container only — the worktree stays
await sandbox.close();
// wt.close() cleans up the worktreewt.close() checks for uncommitted changes: if the worktree is dirty, it's preserved on disk; if clean, it's removed. await using calls close() automatically. The worktree persists after run(), interactive(), and createSandbox() complete, so you can hand it to another agent or inspect it.
Split ownership: When a sandbox is created via wt.createSandbox(), sandbox.close() tears down the container only — the worktree remains. wt.close() is responsible for worktree cleanup. This differs from the top-level createSandbox(), where sandbox.close() owns both container and worktree.
CreateWorktreeOptions
| Option | Type | Default | Description |
| ---------------- | ---------------------- | ------- | ------------------------------------------------------------------------- |
| branchStrategy | WorktreeBranchStrategy | — | Required. { type: "branch", branch } or { type: "merge-to-head" } |
| copyToWorktree | string[] | — | Host-relative file paths to copy into the worktree at creation time |
| timeouts | Timeouts | — | Override default timeouts (e.g. { copyToWorktreeMs: 120_000 }) |
Worktree
| Property / Method | Type | Description |
| ------------------------ | --------------------------------------------------------------------- | --------------------------------------------------- |
| branch | string | The branch the worktree is on |
| worktreePath | string | Host path to the worktree |
| run(options) | (options: WorktreeRunOptions) => Promise<WorktreeRunResult> | Run an AFK agent in the worktree (sandbox required) |
| interactive(options) | (options: WorktreeInteractiveOptions) => Promise<InteractiveResult> | Run an interactive agent session in the worktree |
| createSandbox(options) | (options: WorktreeCreateSandboxOptions) => Promise<Sandbox> | Create a long-lived sandbox backed by this worktree |
| close() | () => Promise<CloseResult> | Clean up the worktree (preserves if dirty) |
| [Symbol.asyncDispose] | () => Promise<void> | Auto cleanup via await using |
WorktreeInteractiveOptions
| Option | Type | Default | Description |
| ------------ | ---------------------- | ------------- | ------------------------------------------------------------------------------------------------- |
| agent | AgentProvider | — | Required. Agent provider |
| sandbox | AnySandboxProvider | noSandbox() | Sandbox provider (defaults to no sandbox) |
| prompt | string | — | Inline prompt (mutually exclusive with promptFile) |
| promptFile | string | — | Path to prompt file |
| name | string | — | Optional session name |
| hooks | SandboxHooks | — | Lifecycle hooks (host.*, sandbox.*) |
| promptArgs | PromptArgs | — | Key-value map for {{KEY}} placeholder substitution |
| env | Record<string, string> | — | Environment variables to inject into the sandbox |
| signal | AbortSignal | — | Cancel the session when aborted. The worktree is preserved on disk. Rejects with signal.reason. |
WorktreeRunOptions
| Option | Type | Default | Description |
| -------------------- | ---------------------- | ------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| agent | AgentProvider | — | Required. Agent provider |
| sandbox | SandboxProvider | — | Required. Sandbox provider (AFK agents must be sandboxed) |
| prompt | string | — | Inline prompt (mutually exclusive with promptFile) |
| promptFile | string | — | Path to prompt file |
| maxIterations | number | 1 | Maximum iterations to run |
| completionSignal | string | string[] | — | Substring(s) to stop the iteration loop early |
| idleTimeoutSeconds | number | 600 | Idle timeout in seconds |
| name | string | — | Optional run name |
| logging | LoggingOption | file | Logging mode |
| hooks | SandboxHooks | — | Lifecycle hooks (host.*, sandbox.*) |
| promptArgs | PromptArgs | — | Key-value map for {{KEY}} placeholder substitution |
| env | Record<string, string> | — | Environment variables to inject into the sandbox |
| resumeSession | string | — | Resume a prior provider-supported session by ID. Incompatible with maxIterations > 1; Claude requires a host session file, Codex uses native codex exec resume. |
| signal | AbortSignal | — | Cancel the run when aborted. Kills the in-flight agent subprocess; the worktree is preserved on disk. Rejects with signal.reason. |
WorktreeRunResult
| Property | Type | Description |
| ------------------ | ------------------- | ------------------------------------------------------ |
| iterations | IterationResult[] | Per-iteration results (use .length for the count) |
| completionSignal | string | The matched completion signal, or undefined |
| stdout | string | Combined stdout output from all agent iterations |
| commits | { sha: string }[] | List of commits made by the agent during the run |
| branch | string | The branch name the agent worked on |
| logFilePath | string | Path to the log file, if logging was drained to a file |
WorktreeCreateSandboxOptions
| Option | Type | Default | Description |
| ---------------- | --------------- | ------- | ------------------------------------------------------------------- |
| sandbox | SandboxProvider | — | Required. Sandbox provider (e.g. docker()) |
| hooks | SandboxHooks | — | Lifecycle hooks (host.*, sandbox.*) |
| copyToWorktree | string[] | — | Host-relative file paths to copy into the worktree at creation time |
| timeouts | Timeouts | — | Override default timeouts (e.g. { copyToWorktreeMs: 120_000 }) |
How it works
Narukami Shrine uses a branch strategy configured on the sandbox provider to control how the agent's changes relate to branches. There are three strategies:
- Head (
{ type: "head" }) — The agent writes directly to the host working directory. No worktree, no branch indirection. This is the default for bind-mount providers likedocker(). - Merge-to-head (
{ type: "merge-to-head" }) — Narukami Shrine creates a temporary branch in a git worktree. The agent works on the temp branch, and changes are merged back to HEAD when done. The temp branch is cleaned up after merge. - Branch (
{ type: "branch", branch: "foo" }) — Commits land on an explicitly named branch in a git worktree.
For bind-mount providers (like Docker), the worktree directory is bind-mounted into the container — the agent writes directly to the host filesystem through the mount, so no sync is needed.
From your point of view, you just configure branchStrategy: { type: 'branch', branch: 'foo' } on run(), and get a commit on branch foo once it's complete. All 100% local.
Prompts
Narukami Shrine uses a flexible prompt system. You write the prompt, and the engine executes it — no opinions about workflow, task management, or context sources are imposed.
Prompt resolution
Most runs must provide exactly one of:
prompt: "inline string"— pass an inline prompt directly viaRunOptionspromptFile: "./path/to/prompt.md"— point to a specific file viaRunOptions
prompt and promptFile are mutually exclusive — providing both is an error. If neither is provided, run() throws an error asking you to supply one, unless the selected agent provider has its own default prompt. For example, codexReview() uses Codex CLI's built-in review preset and does not require a prompt.
Inline prompts (prompt: "...") are passed to the agent literally. No {{KEY}} substitution, no !`command` expansion, no built-in {{SOURCE_BRANCH}} / {{TARGET_BRANCH}} injection. If you need values interpolated into an inline prompt, build the string in JavaScript (`Work on ${branch}…`). Passing promptArgs alongside an inline prompt is an error — switch to promptFile to use substitution.
The substitution and expansion features below apply only to prompts sourced from promptFile.
Convention:
narukami initscaffolds.narukami/prompt.mdand all templates explicitly reference it viapromptFile: ".narukami/prompt.md". This is a convention, not an automatic fallback — Narukami Shrine does not read.narukami/prompt.mdunless you pass it aspromptFile.
Dynamic context with !`command`
Use !`command` expressions in your prompt to pull in dynamic context. Each expression is replaced with the command's stdout before the prompt is sent to the agent. All expressions in a prompt run in parallel for faster expansion.
Commands run inside the sandbox after sandbox.onSandboxReady hooks complete, so they see the same repo state the agent sees (including installed dependencies).
# Open issues
!`gh issue list --state open --label Narukami Shrine --json number,title,body,comments,labels --limit 20`
# Recent commits
!`git log --oneline -10`If any command exits with a non-zero code, the run fails immediately with an error.
Prompt arguments with {{KEY}}
Use {{KEY}} placeholders in your prompt to inject values from the promptArgs option. This is useful for reusing the same prompt file across multiple runs with different parameters.
import { run } from "@yae-tools/narukami-shrine";
await run({
promptFile: "./my-prompt.md",
promptArgs: { ISSUE_NUMBER: 42, PRIORITY: "high" },
});In the prompt file:
Work on issue #{{ISSUE_NUMBER}} (priority: {{PRIORITY}}).Prompt argument substitution runs on the host before shell expression expansion, so {{KEY}} placeholders inside !`command` expressions are replaced first:
!`gh issue view {{ISSUE_NUMBER}} --json body -q .body`A {{KEY}} placeholder with no matching prompt argument is an error. Unused prompt arguments produce a warning.
!`command` expansion only runs on shell blocks written in the prompt file itself. Any !`…` pattern that appears inside an argument value is treated as inert text — it won't be executed against the host shell. This makes it safe to pass user-authored content (issue titles, PR descriptions, docs excerpts) through promptArgs.
Built-in prompt arguments
Narukami Shrine automatically injects two built-in prompt arguments into every prompt:
| Placeholder | Value |
| ------------------- | ----------------------------------------------------------------- |
| {{SOURCE_BRANCH}} | The branch the agent works on (determined by the branch strategy) |
| {{TARGET_BRANCH}} | The host's active branch at run() time |
Use them in your prompt without passing them via promptArgs:
You are working on {{SOURCE_BRANCH}}. When diffing, compare against {{TARGET_BRANCH}}.Passing SOURCE_BRANCH or TARGET_BRANCH in promptArgs is an error — built-in prompt arguments cannot be overridden.
Early termination with <promise>COMPLETE</promise>
When the agent outputs <promise>COMPLETE</promise>, the orchestrator stops the iteration loop early. This is a convention you document in your prompt for the agent to follow — the engine never injects it.
This is useful for task-based workflows where the agent should stop once it has finished, rather than running all remaining iterations.
You can override the default signal by passing completionSignal to run(). It accepts a single string or an array of strings:
await run({
// ...
completionSignal: "DONE",
});
// Or pass multiple signals — the loop stops on the first match:
await run({
// ...
completionSignal: ["TASK_COMPLETE", "TASK_ABORTED"],
});Tell the agent to output your chosen string(s) in the prompt, and the orchestrator will stop when it detects any of them. The matched signal is returned as result.completionSignal.
Structured output
Use Output.object() to extract a typed, schema-validated JSON payload from the agent's stdout. The agent emits its answer inside an XML tag you specify, and Narukami parses, validates, and returns it on result.output. See ADR 0010 for design rationale.
import { run, Output, claudeCode } from "@yae-tools/narukami-shrine";
import { docker } from "@yae-tools/narukami-shrine/sandboxes/docker";
import { z } from "zod";
const result = await run({
agent: claudeCode("claude-opus-4-6"),
sandbox: docker(),
prompt: `Analyze the code, and output the result as JSON inside <result> tags.
The result must match this schema:
{ summary: string; score: string }
`,
output: Output.object({
tag: "result",
schema: z.object({ summary: z.string(), score: z.number() }),
}),
});
console.log(result.output.summary); // typed as string
console.log(result.output.score); // typed as numberOutput.string({ tag }) extracts the tag contents as a plain string (trimmed, no JSON parsing). Both helpers require maxIterations to be 1 (the default). The resolved prompt must contain the configured opening tag literal.
Templates
narukami init prompts you to choose a sandbox provider (Docker or Podman), an issue provider (Linear, GitHub Issues, or Beads), and a template, which scaffolds a ready-to-use prompt and main.mts suited to a specific workflow. Linear requires the Linear MCP tool to be installed and available to the agent. If your project's package.json has "type": "module", the file will be named main.ts instead. Five templates are available:
| Template | Description |
| ------------------------------ | ------------------------------------------------------------------------- |
| blank | Bare scaffold — write your own prompt and orchestration |
| simple-loop | Picks issues one by one and closes them |
| sequential-reviewer | Implements issues one by one, with a code review step after each |
| parallel-planner | Plans parallelizable issues, executes on separate branches, then merges |
| parallel-planner-with-review | Plans parallelizable issues, executes with per-branch review, then merges |
Select a template during narukami init when prompted, or re-run init in a fresh repo to try a different one.
Review self-healing
The review templates (sequential-reviewer and parallel-planner-with-review) run a bounded review → repair → review loop. If a reviewer reports findings but does not commit fixes, Narukami starts a repairer agent with the latest reviewer output plus prior findings from the same repair loop. The repaired branch is reviewed again, up to MAX_REPAIR_ATTEMPTS times. If findings remain after the cap, the run fails and points you at the reviewer/repairer logs.
This behavior is template-level orchestration, not limited to Codex built-in review. It works with both review backends:
- Codex built-in review: Codex-agent scaffolds can use
codexReview(), which runs Codex CLI's review preset and does not require a prompt. - Prompt-based review: non-Codex agents, or Codex scaffolds configured for prompt review, use
.narukami/review-prompt.md; their findings are parsed the same way and can trigger the same repair loop.
OpenAI's Codex docs describe review feedback as guidance Codex can act on, and the Codex CLI docs show multi-stage review/fix automation with codex exec and codex exec resume. Narukami wraps that idea into the scaffolded loop so AFK runs can self-heal without requiring a human to copy review findings into a second command. When the repair agent is codex(), the first repair attempt resumes the implementation thread if Codex emitted a thread_id; later repair attempts resume the latest repair thread, preserving first-party Codex context across the fix loop.
CLI commands
narukami init
Scaffolds the .narukami/ config directory and builds the container image. This is the first command you run in a new repo. You choose a sandbox provider (Docker or Podman) during init — selecting Podman writes a Containerfile instead of Dockerfile and uses narukami podman build-image for the build step. Init also asks whether to install optional sandbox tools such as SonarScanner CLI.
| Option | Required | Default | Description |
| ------------------ | -------- | -------------------------- | ------------------------------------------------------------------------ |
| --image-name | No | narukami:<repo-dir-name> | Docker image name |
| --agent | No | Interactive prompt | Agent to use (codex, claude-code, pi, opencode) |
| --model | No | Agent's default model | Model to use (e.g. gpt-5.5). Defaults to agent's default |
| --template | No | Interactive prompt | Template to scaffold (e.g. blank, simple-loop) |
| --tools | No | Interactive prompt | Comma-separated optional sandbox tools to install (e.g. sonar-scanner) |
| --sonar-host-url | No | — | Prefill SONAR_HOST_URL in .narukami/.env.example when using Sonar |
Creates the following files:
.narukami/
├── Dockerfile # Sandbox environment (customize as needed)
├── prompt.md # Agent instructions
├── .env.example # Token placeholders
└── .gitignore # Ignores .env, logs/Errors if .narukami/ already exists to prevent overwriting customizations.
Optional tools are marked in generated files so they can be removed later. For example, to remove SonarScanner CLI from .narukami/Dockerfile or .narukami/Containerfile, clean up the matching .env.example block, and rebuild the detected image:
npx narukami tools remove --tool sonar-scanner --rebuildIf your SonarQube server runs on the host, Docker Desktop usually exposes it to containers as http://host.docker.internal:9000; hosted SonarQube or SonarCloud URLs can be used instead.
narukami docker build-image
Rebuilds the Docker image from an existing .narukami/ directory. Use this after modifying the Dockerfile.
| Option | Required | Default | Description |
| -------------- | -------- | -------------------------- | --------------------------------------------------------------------------------- |
| --image-name | No | narukami:<repo-dir-name> | Docker image name |
| --dockerfile | No | — | Path to a custom Dockerfile (build context will be the current working directory) |
narukami docker remove-image
Removes the Docker image.
| Option | Required | Default | Description |
| -------------- | -------- | -------------------------- | ----------------- |
| --image-name | No | narukami:<repo-dir-name> | Docker image name |
narukami podman build-image
Builds the Podman image from an existing .narukami/ directory. Use this after modifying the Containerfile.
| Option | Required | Default | Description |
| ----------------- | -------- | -------------------------- | ------------------------------------------------------------------------------------ |
| --image-name | No | narukami:<repo-dir-name> | Podman image name |
| --containerfile | No | — | Path to a custom Containerfile (build context will be the current working directory) |
narukami podman remove-image
Removes the Podman image.
| Option | Required | Default | Description |
| -------------- | -------- | -------------------------- | ----------------- |
| --image-name | No | narukami:<repo-dir-name> | Podman image name |
narukami tools remove
Removes a marked optional sandbox tool block from .narukami/Dockerfile or .narukami/Containerfile and from .narukami/.env.example. Use this if an optional tool is not needed or if its install step is incompatible with your local image build.
| Option | Required | Default | Description |
| -------------- | -------- | -------------------------- | ------------------------------------------------------ |
| --tool | Yes | — | Optional tool to remove, such as sonar-scanner |
| --rebuild | No | false | Rebuild the detected Docker/Podman image after cleanup |
| --image-name | No | narukami:<repo-dir-name> | Image name to rebuild when --rebuild is passed |
RunOptions
| Option | Type | Default | Description |
| -------------------- | ------------------ | ----------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| agent | AgentProvider | — | Required. Agent provider (e.g. codex("gpt-5.5", { effort: "low" }), claudeCode("claude-opus-4-6"), pi("claude-sonnet-4-6"), opencode("opencode/big-pickle")) |
| sandbox | SandboxProvider | — | Required. Sandbox provider (e.g. docker(), podman(), docker({ imageName: "narukami:local" })) |
| cwd | string | process.cwd() | Host repo directory — anchor for .narukami/ artifacts and git operations. Relative paths resolve against process.cwd(). |
| prompt | string | — | Inline prompt (mutually exclusive with promptFile) |
| promptFile | string | — | Path to prompt file (mutually exclusive with prompt). Resolves against process.cwd(), not cwd. |
| maxIterations | number | 1 | Maximum iterations to run |
| hooks | SandboxHooks | — | Lifecycle hooks (host.*, sandbox.*) |
| name | string | — | Display name for the run, shown as a prefix in log output |
| promptArgs | PromptArgs | — | Key-value map for {{KEY}} placeholder substitution |
| branchStrategy | BranchStrategy | per-provider default | Branch strategy: { type: 'head' }, { type: 'merge-to-head' }, or { type: 'branch', branch: '…' } |
| copyToWorktree | string[] | — | Host-relative file paths to copy into the sandbox before start (not supported with branchStrategy: { type: 'head' }) |
| logging | object | file (auto-generated) | { type: 'file', path } or { type: 'stdout' } |
| completionSignal | string | string[] | <promise>COMPLETE</promise> | String or array of strings the agent emits to stop the iteration loop early |
| idleTimeoutSeconds | number | 600 | Idle timeout in seconds — resets on each agent output event |
| resumeSession | string | — | Resume a prior provider-supported session by ID. Incompatible with maxIterations > 1; Claude requires a host session file, Codex uses native codex exec resume. |
| signal | AbortSignal | — | Cancel the run when aborted. Kills the in-flight agent subprocess and cancels lifecycle hooks; the worktree is preserved on disk. Rejects with signal.reason. |
| timeouts | Timeouts | — | Override default timeouts for built-in lifecycle steps. Currently supports { copyToWorktreeMs?: number } (default: 60 000). |
| output | OutputDefinition | — | Structured output definition (Output.object(…) or Output.string(…)). Requires maxIterations === 1. See Structured output. |
RunResult
| Field | Type | Description |
| ------------------ | ------------------- | ------------------------------------------------------------------ |
| iterations | IterationResult[] | Per-iteration results (use .length for the count) |
| completionSignal | string? | The matched completion signal string, or undefined if none fired |
| stdout | string | Agent output |
| commits | { sha }[] | Commits created during the run |
| branch | string | Target branch name |
| logFilePath | string? | Path to the log file (only when logging to a file) |
| output | T? | Typed structured output (only present when output option is set) |
IterationResult
| Field | Type | Description |
| ----------------- | ----------------- | ----------------------------------------------------------------------------------------------------------------------------------- |
| sessionId | string? | Agent session ID from the stream, or undefined when the provider does not emit one |
| sessionFilePath | string? | Absolute host path to the captured session JSONL, or undefined when capture is off |
| usage | IterationUsage? | Token usage snapshot from the last assistant message, or undefined when capture is off or provider does not support usage parsing |
IterationUsage
| Field | Type | Description |
| -------------------------- | ------ | ------------------------------------------ |
| inputTokens | number | Input tokens consumed |
| cacheCreationInputTokens | number | Tokens used to create prompt cache entries |
| cacheReadInputTokens | number | Tokens read from prompt cache |
| outputTokens | number | Output tokens generated |
Session capture
After each Claude Code iteration, Narukami Shrine automatically captures the agent's session JSONL from the sandbox to the host at ~/.claude/projects/<encoded-path>/sessions/<session-id>.jsonl. The cwd fields inside each JSONL entry are rewritten to match the host repo root, so claude --resume works natively.
Session capture is enabled by default for claudeCode() and can be opted out via captureSessions: false. Non-Claude agent providers never attempt capture. Capture failure fails the run.
Session resume
Pass resumeSession to run() to continue a prior provider-supported conversation inside a new sandbox. Claude Code uses file-backed session transfer; Codex uses the CLI's native codex exec resume <SESSION_ID> mode.
const result = await run({
agent: claudeCode("claude-opus-4-6"),
sandbox: docker(),
prompt: "Continue where you left off",
resumeSession: "abc-123-def",
});Before the sandbox starts, Narukami Shrine validates Claude Code session files and transfers them into the sandbox with cwd fields rewritten to match the sandbox-side path. The Claude Code agent receives --resume <id> on its print command for iteration 1.
For Codex, Narukami parses thread.started JSONL events from codex exec --json and stores the emitted thread_id on each iteration result. Passing that ID as resumeSession calls codex exec resume ... <SESSION_ID> directly; no host-side JSONL copy is required.
Constraints:
resumeSessionis incompatible withmaxIterations > 1(throws before sandbox creation).- Claude Code session files must exist at
~/.claude/projects/<encoded-path>/sessions/<id>.jsonl(throws before sandbox creation). - Only iteration 1 receives the resume flag; subsequent iterations (if any) start fresh.
- Providers without a resume strategy ignore
resumeSession.
ClaudeCodeOptions
The claudeCode() factory accepts an optional second argument for provider-specific options:
agent: claudeCode("claude-opus-4-6", { effort: "high" });| Option | Type | Default | Description |
| ----------------- | -------------------------------------------- | ------- | --------------------------------------------------------- |
| effort | "low" | "medium" | "high" | "max" | — | Claude Code reasoning effort level (max is Opus only) |
| env | Record<string, string> | {} | Environment variables injected by this agent provider |
| captureSessions | boolean | true | Capture agent session JSONL to host for claude --resume |
CodexOptions
The codex() factory accepts an optional second argument for provider-specific options:
agent: codex("gpt-5.5", { effort: "low" });| Option | Type | Default | Description |
| -------- | ---------------------------------------------- | ------- | --------------------------------------------------------- |
| effort | "low" | "medium" | "high" | "xhigh" | — | Codex reasoning effort level via model_reasoning_effort |
| env | Record<string, string> | {} | Environment variables injected by this agent provider |
CodexReviewOptions
Use codexReview() when a review step should use Codex CLI's built-in review preset instead of a general agent prompt:
agent: codexReview("gpt-5.5", { effort: "low", base: "main" });No prompt or promptFile is required. If you provide one, it is passed to Codex as custom review instructions.
During narukami init, review templates ask Codex-agent users whether to use this built-in review backend or the scaffolded .narukami/review-prompt.md. Non-Codex agents use the prompt-based reviewer by default. Either way, review-prompt.md is still generated so teams can switch later or layer custom review instructions onto Codex review.
In the scaffolded review templates, codexReview() only performs the review pass. If it reports findings without producing a fix commit, Narukami invokes a separate repair agent and then runs review again. The same repair loop is used for prompt-based reviewers. If the repair agent is codex(), Narukami uses codex exec resume with the implementation or prior repair thread ID when one is available.
| Option | Type | Default | Description |
| ------------- | ---------------------------------------------- | ------- | --------------------------------------------------------- |
| effort | "low" | "medium" | "high" | "xhigh" | — | Codex reasoning effort level via model_reasoning_effort |
| base | string | — | Review changes against this base branch or SHA |
| uncommitted | boolean | false | Review staged, unstaged, and untracked changes |
| commit | string | — | Review the changes introduced by this commit |
| title | string | — | Optional title for Codex's review summary |
| env | Record<string, string> | {} | Environment variables injected by this agent provider |
Provider env
Both agent providers and sandbox providers accept an optional env: Record<string, string> in their options. These environment variables are merged with the .narukami/.env resolver output at launch time:
await run({
agent: codex("gpt-5.5", { effort: "low" }),
sandbox: docker({
mounts: [{ hostPath: "~/.codex", sandboxPath: "~/.codex" }],
env: { DOCKER_SPECIFIC_VAR: "value" },
}),
prompt: "Fix issue #42",
});Merge rules:
- Provider env (agent + sandbox) overrides
.narukami/.envresolver output for shared keys - Agent provider env and sandbox provider env must not overlap — if they share any key,
run()throws an error - When
envis not provided, it defaults to{}
Environment variables are also resolved automatically from .narukami/.env and process.env — no need to pass them to the API. The required variables depend on the agent provider (see narukami init output for details).
Custom Sandbox Providers
Narukami Shrine ships with built-in providers for Docker, Podman, and Vercel, but you can create your own. A sandbox provider tells Narukami Shrine how to execute commands in an isolated environment. There are two kinds:
- Bind-mount — the sandbox can mount a host directory. Narukami Shrine creates a worktree on the host and the provider mounts it in. No file sync needed. Use this for Docker, Podman, or any local container runtime.
- Isolated — the sandbox has its own filesystem (e.g. a cloud VM). The provider handles syncing code in and out via
copyInandcopyFileOut. Use this when the sandbox cannot access the host filesystem.
The sandbox handle contract
Both provider types return a sandbox handle from their create() function. The handle exposes:
| Method | Required | Description |
| -------------- | ---------- | ---------------------------------------------------------------------------- |
| exec | Both | Run a command, optionally streaming stdout line-by-line via options.onLine |
| close | Both | Tear down the sandbox |
| copyFileIn | Bind-mount | Copy a single file from the host into the sandbox |
| copyFileOut | Both | Copy a single file from the sandbox to the host |
| copyIn | Isolated | Copy a file or directory from the host into the sandbox |
| worktreePath | Both | Absolute path to the repo directory inside the sandbox |
ExecResult
Every exec call returns an ExecResult:
interface ExecResult {
readonly stdout: string;
readonly stderr: string;
readonly exitCode: number;
}Bind-mount provider example
A minimal bind-mount provider that shells out to local processes (no container):
import {
createBindMountSandboxProvider,
type BindMountCreateOptions,
type BindMountSandboxHandle,
type ExecResult,
} from "@yae-tools/narukami-shrine";
import { execFile, spawn } from "node:child_process";
import { copyFile as fsCopyFile, mkdir as fsMkdir } from "node:fs/promises";
import { dirname } from "node:path";
import { createInterface } from "node:readline";
const localProcess = () =>
createBindMountSandboxProvider({
name: "local-process",
create: async (
options: BindMountCreateOptions,
): Promise<BindMountSandboxHandle> => {
const worktreePath = options.worktreePath;
return {
worktreePath,
exec: (
command: string,
opts?: { onLine?: (line: string) => void; cwd?: string },
): Promise<ExecResult> => {
if (opts?.onLine) {
const onLine = opts.onLine;
return new Promise((resolve, reject) => {
const proc = spawn("sh", ["-c", command], {
cwd: opts?.cwd ?? worktreePath,
stdio: ["ignore", "pipe", "pipe"],
});
const stdoutChunks: string[] = [];
const stderrChunks: string[] = [];
const rl = createInterface({ input: proc.stdout! });
rl.on("line", (line) => {
stdoutChunks.push(line);
onLine(line); // forward each line to Narukami Shrine
});
proc.stderr!.on("data", (chunk: Buffer) => {
stderrChunks.push(chunk.toString());
});
proc.on("error", (err) => reject(err));
proc.on("close", (code