Guides
Prompts
On this page
Sandcastle 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
You 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.
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.
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 Sandcastle --json number,title,body,comments,labels --limit 100`# 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 "@ai-hero/sandcastle";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
Sandcastle 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.
Hanging processes after the completion signal
The agent process is expected to exit shortly after emitting the completion signal. When a child it spawned — a gh/git subprocess, a long-lived MCP server, etc. — inherits the agent's stdout pipe and keeps it open, the parent process can linger long past its logical end. Sandcastle would otherwise wait for the full idleTimeoutSeconds and fail with AgentIdleTimeoutError, throwing away the commits the agent already made.
Instead, once the completion signal is observed in the output buffer, Sandcastle swaps in a short completion timeout (default 60 s). When it expires, the run resolves successfully with a warning that the process was hanging; result.commits and result.completionSignal are populated as if the process had exited cleanly. The timer resets on every subsequent output line, so trailing data emitted after the signal — token-usage events, terminal result events, a structured-output <tag> — is still captured.
A clean process exit always wins the race, so healthy runs gain zero added latency. The completion timeout only matters when the process hangs.
Tune the window with completionTimeoutSeconds:
await run({// ...completionTimeoutSeconds: 30, // shorter grace window});
This is independent of idleTimeoutSeconds. They cover different phases: idleTimeoutSeconds runs before any signal is seen (genuinely stuck agent → fail); completionTimeoutSeconds runs after the signal is seen (hanging process → succeed with warning). See ADR 0019.
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 Sandcastle parses, validates, and returns it on result.output. The schema can be any Standard Schema validator — the examples below use Zod, but Valibot, ArkType, and others work identically. See ADR 0010 for design rationale.
import { run, Output, claudeCode } from "@ai-hero/sandcastle";import { docker } from "@ai-hero/sandcastle/sandboxes/docker";import { z } from "zod";const result = await run({agent: claudeCode("claude-opus-4-8"),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 stringconsole.log(result.output.score); // typed as number
Output.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.
When extraction or validation fails, run() throws a StructuredOutputError. Alongside tag, rawMatched, cause, commits, branch, and preservedWorktreePath, the error carries the sessionId (and sessionFilePath, when the session was captured) of the run that produced the bad output.
Pass maxRetries to have Sandcastle handle the retry loop for you. Each retry resumes the same agent session and feeds back a token-efficient description of the error, so the agent can re-emit a corrected tag without redoing the work. Retries require an agent provider that supports session resumption (claudeCode, codex, pi) — calling run() with maxRetries > 0 against a non-resumable provider (cursor, opencode, copilot) throws immediately.
const result = await run({agent: claudeCode("claude-opus-4-8"),sandbox: docker(),prompt: "Analyze the code and emit JSON inside <result> tags.",output: Output.object({tag: "result",schema: z.object({ summary: z.string(), score: z.number() }),maxRetries: 2, // 2 retries on top of the initial attempt}),});
If you need to drive the retry loop manually — for example, to customise the feedback prompt or rotate models on each attempt — leave maxRetries at its default of 0 and resume the failed session yourself:
import { run, Output, StructuredOutputError } from "@ai-hero/sandcastle";try {return await run({ ...opts, output });} catch (e) {if (e instanceof StructuredOutputError && e.sessionId) {return await run({...opts,output,resumeSession: e.sessionId,prompt: `Your previous output failed: ${e.message}. Re-emit it inside <${e.tag}> tags.`,});}throw e;}
Templates
sandcastle init prompts you to choose a sandbox provider (Docker or Podman), an issue tracker (GitHub Issues, Beads, or Custom), and a template, which scaffolds a ready-to-use prompt and main.mts suited to a specific workflow. If your project's package.json has "type": "module", the file will be named main.ts instead. Choosing Custom scaffolds the project in a deliberately broken-until-configured state plus a .sandcastle/SETUP_ISSUE_TRACKER.md prompt you feed to your coding agent, which wires up your own tracker by editing the scaffolded files in place. 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 sandcastle init when prompted, or re-run init in a fresh repo to try a different one.