The model fills in the form. Your code runs the program.
AstralisHarness is a TypeScript library and command line tool, built on the Pi SDK, for running language-model workflows as ordinary programs. Control flow, step order and tool access live in your code, and every model call, tool call and decision lands in a journal you can read, diff and turn into a report.
How it fits together#
Think of a workflow as a normal program with a few special lines. On those lines you hand the model a form (a typed schema) and it fills it in. The next line of the program is yours: an if, a loop, a function call. The model never gets to decide "what should happen next". It only fills the blank you gave it.
The gateway is the only code in the harness that calls a model, and it refuses to run unless the caller is inside a workflow step. A model call outside a step throws NoActiveStepError before anything is sent. That is what keeps "what did the model do?" answerable from the journal alone.
Who it is for#
Developers adding LLM steps to real programs
You already write TypeScript. You want one step that classifies, one that extracts, one that checks, wired together with the control flow you would write anyway.
People running local or self-hosted models
Any OpenAI-compatible server (Ollama, llama.cpp, vLLM, LiteLLM) works, and Pi's frontier providers are one config switch away. The harness keeps small models on a short leash.
Anyone who has to explain a run afterwards
Every run writes a journal. You can print it, render it as a page, and compare two runs step by step to see where they diverged.
What it deliberately does not do#
- It never lets the model choose what runs next. The one exception is the
agentstep, where the model picks tools, inside an allowlist and a turn cap you set. - It never retries or falls back silently. A failure throws a typed error with the details attached. If you want a retry, you write the loop (see the cookbook).
- It never runs a shell outside the sandbox. the built-in
bashtool frombuildToolsexists only inside bubblewrap, and there is no unsandboxed switch. (A tool you write yourself and namebashis not covered by this.) - It does not resume, replay or budget for you yet. Those three are planned, not built (see the end of Limits and security model).
- It is not a security boundary against code you install. Mods and your own workflow code run with full permissions.
- It does not speak every model API. Your own providers are OpenAI-compatible chat endpoints; anything else comes through Pi's built-in providers.
Quickstart#
From nothing to a journaled run in five steps. You need Node.js and, for a first run, a model server you can reach. Ollama is the quickest.
The examples write astralis. If it is not on your PATH, run it as npx astralis, pnpm exec astralis, or node dist/cli/main.js from a checkout. Workflow files are TypeScript that Node runs directly by stripping types, so use a Node release where that is on by default.
1. Install#
The package is not published to the npm registry yet; it is installed from a tarball at https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgz. Node 22.18 or later is required, because workflows are TypeScript files run through Node's built-in type stripping. In code you still import it as astralis-harness.
npm install https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgz
npx astralis --helppnpm add https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgz
pnpm exec astralis --helpyarn add https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgz
yarn astralis --helpbun add https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgz
bunx astralis --helpBun installs the package; the CLI itself is a Node program (#!/usr/bin/env node) and needs Node on the machine.
pnpm install
pnpm build
node dist/cli/main.js --helpUse this if you have the source and the package is not on your registry. pnpm link --global in the checkout puts astralis on your path.
About the npx prefix. A project-local install does not put a bare astralis command on your PATH, so the commands in this quickstart are written as npx astralis ... (use pnpm exec astralis or bunx astralis with those package managers). To type plain astralis, as the rest of this manual does, install it globally once: npm install -g https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgz.
Success looks like the help text of the CLI. If it prints and lists run, doctor, init and the rest, you are done. The full help of every command is in the CLI reference.
2. Create a project from a template#
npx astralis templates
npx astralis init pipeline my-project
cd my-project
npm install https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgzcoding-task Plan, edit with a bounded agent behind a strict tool policy and sandbox, then verify with a stronger model.
map-reduce Fan out infer workers over a list with a concurrency cap, then merge the results.
pipeline Classify an input, branch in code on the typed result, then summarise (infer plus plain code).
research-with-memory Read memory, infer with a pinned skill, write memory back.created my-project/astralis.config.json
created my-project/package.json
created my-project/README.md
created my-project/workflow.ts
next: edit astralis.config.json, then run `astralis doctor`init never overwrites: if any target file already exists it lists every conflict and writes nothing. The workflow imports astralis-harness by name, which is why the last command installs it into the new project.
Then point the config at your server. The template ships with placeholder model ids that are meant to be replaced:
{
"defaultModel": "local/EXAMPLE-replace-with-a-model-you-have-pulled",
"builtinProviders": true,
"providers": {
"local": {
"baseUrl": "http://127.0.0.1:11434/v1",
"models": [
{
"id": "EXAMPLE-replace-with-a-model-you-have-pulled",
"name": "Example local model (replace the id)",
"contextWindow": 32768,
"maxTokens": 8192
}
]
}
}
}This config is not runnable as shown. Its model id is a placeholder: replace it with a model id that your endpoint actually serves before you run anything.
- Change
baseUrlif your server is not Ollama on its default port. - Replace both occurrences of
EXAMPLE-replace-with-a-model-you-have-pulledwith a model your server really has, for exampleqwen2.5-coder:7bafterollama pull qwen2.5-coder:7b. The first isdefaultModel(local/<id>), the second is the model'sid.
3. Check the setup#
npx astralis doctor[ ok ] Node.js version: Node 26.13.1 satisfies >=22.18.0.
[ ok ] Config file: ~/my-project/astralis.config.json is valid.
[ ok ] Provider "local" API key: No apiKeyEnv configured; the endpoint is treated as keyless.
[ ok ] Provider "local" endpoint: Reachable (HTTP 200). Server offers: qwen2.5-coder:7b. All configured models are offered.
[ ok ] Default model: local/qwen2.5-coder:7b is a configured model.
[ ok ] Sandbox (bubblewrap): /usr/bin/bwrap ran a command in the sandbox.
[ ok ] Runs directory: ~/my-project/.astralis/runs does not exist yet; it can be created under ~/my-project.
All checks passed.Every line is a check; every failure carries a fix: line. Warnings do not fail the run. When something is wrong it looks like this:
[ ok ] Node.js version: Node 26.13.1 satisfies >=22.18.0.
[ ok ] Config file: ~/my-project/astralis.config.json is valid.
[FAIL] Provider "local" API key: LOCAL_API_KEY is not set.
fix: Export LOCAL_API_KEY in the environment that runs astralis.
[FAIL] Provider "local" endpoint: GET http://127.0.0.1:11434/v1/models failed: fetch failed (connect ECONNREFUSED 127.0.0.1:11434)
fix: Start the server at http://127.0.0.1:11434/v1, or correct baseUrl in the config.
[ ok ] Default model: local/qwen2.5-coder:7b is a configured model.
[ ok ] Sandbox (bubblewrap): /usr/bin/bwrap ran a command in the sandbox.
[ ok ] Runs directory: ~/my-project/.astralis/runs does not exist yet; it can be created under ~/my-project.
2 checks failed.The exit code is 0 when nothing failed and 1 otherwise, so you can use it in scripts. --offline skips the endpoint check. Every check id is explained in the doctor table.
4. Run the workflow#
npx astralis run workflow.ts --input '{"text":"The export button crashes when the list is empty."}'{
"category": "bug",
"summary": "The export button crashes when the list is empty."
}
journal: .astralis/runs/<run-id>/journal.jsonlThe result is printed as JSON on stdout and the journal path on stderr, so you can pipe the output and still see where the journal went. Output above comes from Pi's scripted provider answering two steps (a real run shows whatever your model said). If the model's answer does not fit the schema the run stops with the offending value attached; see Debugging.
5. Look at what happened#
npx astralis journal .astralis/runs/<run-id>/journal.jsonl
npx astralis report .astralis/runs/<run-id>/journal.jsonl -o run.html 1 - run_start pipeline
2 1 step_start infer classify
3 1 inference_request faux/faux-1 108a8e1b31b0
4 1 inference_response faux/faux-1 stop=toolUse
5 1 step_end infer classify
6 2 step_start infer summarise
7 2 inference_request faux/faux-1 8b38c9d1ce93
8 2 inference_response faux/faux-1 stop=toolUse
9 2 step_end infer summarise
10 - run_end pipelinereport writes one self-contained HTML page (no network needed) with the step tree, every prompt and response, tool calls, token totals and failures, and prints the path it wrote. Open it in a browser.
Concepts#
Four ideas carry the whole harness: workflows made of typed steps, one gateway to the model, a precise meaning of "deterministic", and the journal. Each one starts in plain words, then gives the exact rule.
Workflows and typed steps#
A workflow is a function in a TypeScript file. When it needs the model, it does not "chat". It calls a step such as ctx.infer, hands over a prompt and a schema (a form with typed blanks), and gets back a value that is guaranteed to fit the form or the run stops. Everything between those calls is your normal code.
A workflow is defineWorkflow({ name, input, output, run }). input and output are TypeBox schemas, checked strictly: no coercion, no defaults, and a mismatch throws SchemaValidationError with the offending value. run(ctx, input) receives a WorkflowContext whose methods are the only way to do model work. Each call is a step with a kind, a name you choose, and an id assigned in call order (1, 2, 2.1). The default export of the file must be the workflow.
const Triage = Type.Object(
{ urgency: Type.Union([Type.Literal("low"), Type.Literal("high")]), reason: Type.String() },
{ additionalProperties: false },
);
export const triage = defineWorkflow({
name: "triage",
input: Type.Object({ ticket: Type.String() }),
output: Type.Object({ reply: Type.String() }),
async run(ctx, input) {
// Step 1: the model fills in a typed form. That is all it can do.
const verdict = await ctx.infer({
name: "classify",
prompt: `How urgent is this ticket?\n\n${input.ticket}`,
output: Triage,
});
// What happens next is ordinary code reading a typed value. The model does not choose.
if (verdict.urgency === "high") return { reply: "Escalated to the on-call engineer." };
const draft = await ctx.infer({
name: "draft-reply",
prompt: `Write a short, friendly reply.\n\n${input.ticket}`,
output: Type.Object({ text: Type.String() }),
});
return { reply: draft.text };
},
});The model's answer verdict is typed from Triage, so verdict.urgency is "low" | "high" in the editor. The if is yours.
| Step | One line |
|---|---|
ctx.infer | One model call whose answer is forced into a schema. |
ctx.agent | A bounded loop where the model chooses among tools you allowed, until it submits a result. |
ctx.tool | Runs exactly one named tool with arguments you give. No model. |
ctx.fanout | The same worker over a list, with a concurrency limit and fixed step ids. |
ctx.verify | A model tries to refute a claim; a failed verdict throws. |
ctx.approve | Stops for a person's yes or no. |
ctx.memoryRead, ctx.memoryWrite | Validated JSON values that survive between runs. |
The full field list of each is in the step reference.
The gateway, and why inference only happens inside a step#
Picture one door in a wall, and the only way to reach a model is through it. A guard at the door asks "which step are you part of?" and turns away anyone without an answer. Because every trip goes through that door, the journal can list every model call, and nothing can sneak one in from the side.
Gateway is the only code in the harness that calls a model. complete, structured and streamFn look up the active step (using Node's AsyncLocalStorage); with none they throw NoActiveStepError. A step that has ended does not count, nor does one whose parent has ended, so a timer or closure kept past its step cannot call the model. Each call journals an inference_request before it goes out and an inference_response (or inference_error) after. Nothing is retried: maxRetries is 0, and temperature defaults to 0 unless a step sets it.
export async function callOutsideAStep(): Promise<void> {
const gateway = await createGatewayFromConfig(loadConfig());
// Throws NoActiveStepError: this is plain program code, not a step.
await gateway.structured({ model: "local/my-model", prompt: "hello", output: Type.Object({ ok: Type.Boolean() }) });
}Typed output works by making the model call one tool, submit_result, whose parameters are your schema. Providers that support constrained sampling enforce the schema while generating; either way the arguments are validated strictly afterwards. If the model answers in plain text instead, or calls the tool twice, you get a SchemaValidationError.
This guarantees what the harness's own steps do. It is not a sandbox: any code in your process can import the Pi packages and call a model directly, and mods in astralis tui can too. See Limits and security model.
Determinism: what is guaranteed and what is not#
"Deterministic" here does not mean the model says the same thing twice. It means the program around the model is repeatable: same steps, same order, same names and ids. If the model gives the same answers, you get the same run. If it does not, the harness can show you the exact step where the two runs split.
Guaranteed: control flow (it is your code); step kinds, names and ids (allocated synchronously from the parent step's counter at call time, so parallel steps started in a fixed order get fixed ids, whichever finishes first); the journal's format; strict schema validation; and the request hash, a sha256 over the model reference and resolved model configuration, system prompt, messages, tool definitions and every request option, excluding timestamps and provider-assigned ids. Equal hashes mean equal requests. Not guaranteed: the model's sampled output (even at temperature 0, hosted providers can vary), latency, token usage, the order of events from steps that run at the same time, the ts and run id fields, and anything your own code reads from the clock, the network or the filesystem.
same path, different results: 1 difference(s)
first divergence at step 2: Step 2, model call 0: same request, different response.
[different_response] step 2: Step 2, model call 0: same request, different response.That is astralis diff on two real runs of the same workflow where the model worded step 2 differently: same path, same request, different response. A hash mismatch ("the requests differ") tells you the input to the model changed, usually because an earlier answer changed.
Sort anything you list from disk before you fan out over it, and avoid putting the time or a random value into a prompt. Both make the next run's request hash differ for reasons the harness cannot fix.
The journal#
The journal is a flight recorder. While a run goes, the harness writes one line per thing that happens: a step started, a request went out, a tool ran, a step failed. When something looks wrong afterwards you read the recording instead of guessing.
One append-only JSON-lines file per run, .astralis/runs/<run-id>/journal.jsonl by default, written synchronously so line order equals call order. Every line has seq, ts, runId, type, an optional stepId and a data object. After a step's step_end or step_error nothing more may be recorded for it: a late write throws rather than falsify "this step is over". You can pass your own object that implements append(event) as the journal, and tests use MemoryJournal.
{"seq":1,"ts":"2026-01-01T00:00:00.000Z","runId":"<run-id>","type":"run_start","data":{"workflow":"pipeline","input":{"text":"The export button crashes when the list is empty."}}}
{"seq":2,"ts":"2026-01-01T00:00:00.000Z","runId":"<run-id>","type":"step_start","stepId":"1","data":{"kind":"infer","name":"classify"}}All event types and their fields are in the journal event reference; the commands that read journals are in Debugging.
Step reference#
Eight calls on ctx make up everything a workflow can do. Each is a step: journaled, named, numbered. Start with the chooser if you are not sure which one you need.
Which step do I need?
answer a few questions| If you want to... | Use | Model call? |
|---|---|---|
| Get one typed answer from the model | infer | one |
| Let the model explore with tools, then answer | agent | up to maxTurns |
| Run one tool with known arguments | tool | none |
| Do the same job for every item in a list | fanout | whatever the workers do |
| Have a model check a claim against evidence | verify | one |
| Ask a person before going on | approve | none |
| Keep notes between runs | memoryRead / memoryWrite | none |
Whatever goes wrong inside a step, the call rejects with a StepError that carries stepId, stepKind and stepName, and puts the real error in .cause. The tables below name that cause. Test it with error instanceof StepError && error.cause instanceof PolicyError.
infer#
Ask the model one question and get back a filled-in form. The model cannot use tools and cannot decide what happens next.
One call through the gateway with the schema as a forced result tool. The answer is validated strictly. Model: the step's model, else the run's default model, else the step fails with a plain Error ("names no model and the run has no default model"). Temperature is 0 unless you set it.
| Field | Type | What it does | |
|---|---|---|---|
name | string | required | Stable name for this step; appears in the journal and in errors. |
prompt | string | required | |
output | S | required | |
system | string | optional | |
model | string | optional | |
temperature | number | optional | |
maxTokens | number | optional | |
reasoning | "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | optional | |
signal | AbortSignal | optional |
How it fails
| Cause class | When |
|---|---|
GatewayError | The server is unreachable or errors; unknown model; the stop reason was error, aborted or length (output limit hit before the result finished); the model's API cannot be forced to call a tool. |
SchemaValidationError | The answer does not fit output; the model did not call the result tool exactly once; malformed JSON arguments. details.raw has the value. |
Error (plain) | No model named anywhere. |
Journal
step_start, inference_request, inference_response (or inference_error), then step_end or step_error.
import { defineWorkflow, Type } from "astralis-harness";
const Sentiment = Type.Object(
{
label: Type.Union([Type.Literal("positive"), Type.Literal("negative"), Type.Literal("neutral")]),
confidence: Type.Number({ minimum: 0, maximum: 1 }),
},
{ additionalProperties: false },
);
export default defineWorkflow({
name: "sentiment",
input: Type.Object({ review: Type.String({ minLength: 1 }) }),
output: Sentiment,
async run(ctx, input) {
return ctx.infer({
name: "classify-review",
system: "You label customer reviews.",
prompt: input.review,
output: Sentiment,
maxTokens: 200,
});
},
});agent#
Give the model a toolbox and a goal, let it work for a limited number of turns, and require a typed answer at the end. This is the one step where the model chooses its own moves, so it is the one you fence in hardest.
Every tool must have come through buildTools or withPolicy (otherwise PolicyError before any model call). Tool names must be unique and may not be submit_result, which is the reserved result tool. The model must finish by calling submit_result as the only tool call in its message; the arguments are validated against output. Tools run one at a time. maxTurns is a hard cap on model turns. Nothing is retried and the model is never told to try again.
onToolError decides what a failing tool does. "fail" (default): the first tool call that throws, returns an error result or has arguments that do not match its schema ends the step. "report": the model sees the error and may continue, and the step journals agent_lenient_mode so a reader can tell. A policy refusal fails the step in both modes. A bash command that exits non-zero is not a tool error: Pi returns its output and exit code as a normal result.
| Field | Type | What it does | |
|---|---|---|---|
name | string | required | Stable name for this step; appears in the journal and in errors. |
prompt | string | required | |
output | S | required | |
system | string | optional | |
model | string | optional | |
tools | HarnessTool[] | required | Tools the model may call. Every one must have been wrapped by withPolicy; an unwrapped tool is refused with a PolicyError before any model call. submit_result is reserved and names must be unique. |
onToolError | "fail" | "report" | optional | What a failing tool does to the step. "fail" (default): the first tool execution that throws, returns an error result or is given arguments that do not match its schema ends the loop and fails the step, with the tool name, arguments and error attached. "report": the model is shown the error and may continue; the step journals an agent_lenient_mode event so a reader can see it ran this way. A policy refusal (PolicyError from a policy-wrapped tool) fails the step in both modes. A bash command that exits non-zero is not a tool error in Pi (it returns its output and exit code), so it is not affected by this setting. |
maxTurns | number | required | Hard cap on model turns (assistant messages). Reaching it without a result fails the step. |
reasoning | "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | optional | |
signal | AbortSignal | optional |
How it fails
| Cause class | When |
|---|---|
PolicyError | A tool is not policy-wrapped; the model called a tool outside the allowlist; a policy refused a call (tool_blocked is journaled). |
AstralisError | maxTurns is not a positive integer; duplicate or reserved tool name; a tool failed in "fail" mode (name, arguments and error attached); the turn cap was reached ("hit its turn cap"); the step was aborted. |
SchemaValidationError | The model stopped without submitting; submitted twice or alongside other tool calls (none of them run); malformed JSON; the result does not fit output. |
GatewayError | A model call ended with error or aborted, or the provider failed. |
Journal
inference_request and inference_response for every turn, tool_call and tool_result for every tool, tool_blocked for refusals, agent_lenient_mode when lenient.
import { resolve } from "node:path";
import { buildTools, createToolPolicy, defineWorkflow, Type } from "astralis-harness";
const workspace = resolve(process.cwd());
// A read-only toolbox: three of Pi's tools, every path confined to the workspace.
const policy = createToolPolicy({
allow: {
read: [{ kind: "path", arg: "path", root: workspace, allow: ["**"] }],
grep: [{ kind: "path", arg: "path", root: workspace, allow: ["**"], default: "." }],
ls: [{ kind: "path", arg: "path", root: workspace, allow: ["**"], default: "." }],
},
});
const tools = buildTools({ cwd: workspace, builtin: ["read", "grep", "ls"], policy });
const Answer = Type.Object({ file: Type.String(), explanation: Type.String() }, { additionalProperties: false });
export default defineWorkflow({
name: "where-is-it-defined",
input: Type.Object({ symbol: Type.String({ minLength: 1 }) }),
output: Answer,
async run(ctx, input) {
return ctx.agent({
name: "find-definition",
system: "Find where the symbol is defined. Explore with the tools, then submit your answer.",
prompt: `Where is ${input.symbol} defined?`,
output: Answer,
tools,
// Hard cap: reaching it without a submitted result fails the step.
maxTurns: 8,
});
},
});tool#
"Run this tool with these arguments." No model decides anything; it is a function call that is policy-checked and journaled.
name must be one of tools, and that tool must be policy-wrapped. args are validated against the tool's parameter schema exactly, then the policy checks them, then the tool runs. A result flagged isError throws. The returned object has content, details and, when the tool provides it, structuredContent.
| Field | Type | What it does | |
|---|---|---|---|
name | string | required | Name of the tool to run; it must be one of tools. |
args | Record<string, unknown> | required | Must match the tool's parameter schema exactly. |
tools | readonly HarnessTool[] | required | The tools this step may use, already behind the policy gate (see buildTools). |
signal | AbortSignal | optional |
How it fails
| Cause class | When |
|---|---|
ConfigError | The named tool is not in tools (the available names are in details.available). |
PolicyError | The tool is not policy-wrapped, or the policy refused these arguments. |
SchemaValidationError | The arguments do not match the tool's schema. |
AstralisError | The tool reported an error ("reported an error: ..."), with the result in details. |
Journal
tool_call with tool and args, then tool_result with content and details. A tool that reports an error writes tool_call but no tool_result; the result is in the step_error details.
import { resolve } from "node:path";
import { buildTools, createToolPolicy, defineWorkflow, Type } from "astralis-harness";
const workspace = resolve(process.cwd());
const policy = createToolPolicy({ allow: { read: [{ kind: "path", arg: "path", root: workspace, allow: ["docs/**", "README.md"] }] } });
const tools = buildTools({ cwd: workspace, builtin: ["read"], policy });
export default defineWorkflow({
name: "read-then-summarise",
input: Type.Object({ file: Type.String({ minLength: 1 }) }),
output: Type.Object({ summary: Type.String() }),
async run(ctx, input) {
// No model decides this call: the program names the tool and its arguments.
const result = await ctx.tool({ name: "read", args: { path: input.file }, tools });
const text = result.content.map((c) => (c.type === "text" ? c.text : "")).join("");
return ctx.infer({
name: "summarise",
prompt: `Summarise this file in two sentences.\n\n${text}`,
output: Type.Object({ summary: Type.String() }),
});
},
});fanout#
Do the same job for each thing in a list, a few at a time, and collect the answers in the original order.
concurrency is required and must be a positive integer: the program decides the parallelism. Each item runs worker(item, index) in its own step of kind worker named <name>[<index>]. All worker steps are allocated, in index order, before any body runs, so step ids do not depend on which worker finishes first. Anything a worker starts (ctx.infer and the rest) nests under its worker. Results come back in item order.
If a worker fails, workers that have not started are skipped (they are not reported as failures), workers already running finish, and the step throws one FanoutError that lists every failed index.
| Field | Type | What it does | |
|---|---|---|---|
name | string | required | Stable name for this step; appears in the journal and in errors. |
items | readonly T[] | required | |
worker | (item: T, index: number) => Promise<R> | required | Runs inside its own "worker" step, so infer/agent/tool steps it starts nest under it. |
concurrency | number | required | Maximum workers running at once. Required: the program decides parallelism. |
How it fails
| Cause class | When |
|---|---|
AstralisError | concurrency is not a positive integer. |
FanoutError | One or more workers failed. details.failures and details.failedIndexes list them; .cause is the first failure by index. |
Journal
A fanout step containing one worker step per item, each with its own children, all with fixed ids (3.1, 3.2, ...).
import { defineWorkflow, Type } from "astralis-harness";
const Digest = Type.Object({ title: Type.String(), points: Type.Array(Type.String(), { maxItems: 3 }) }, { additionalProperties: false });
export default defineWorkflow({
name: "digest-all",
input: Type.Object({ documents: Type.Array(Type.String(), { minItems: 1 }) }),
output: Type.Object({ digests: Type.Array(Digest) }),
async run(ctx, input) {
const digests = await ctx.fanout({
name: "digest-each",
items: input.documents,
// At most two model calls in flight. Results come back in item order.
concurrency: 2,
worker: (document, index) => ctx.infer({ name: `digest-${index}`, prompt: `Digest this document.\n\n${document}`, output: Digest }),
});
return { digests };
},
});verify#
A skeptical second opinion. A model is told to try to prove your claim wrong using only the evidence you hand it. If it cannot, the run goes on; if it can, the run stops.
One structured call with a fixed adversarial system prompt. The verdict is pass or fail plus findings. The verification event is written before a failing verdict throws, so a failed check still shows its reasons. On pass the call returns the findings (what was checked and any caveats). Pass a stronger model than the one that did the work; nothing enforces it, but a model checking itself proves little.
| Field | Type | What it does | |
|---|---|---|---|
name | string | required | Stable name for this step; appears in the journal and in errors. |
claim | string | required | What is asserted to be true. |
evidence | string | required | The material to check the claim against. |
instructions | string | optional | Extra checking guidance appended to the prompt. |
model | string | optional | Pass a stronger model than the one that produced the work. |
reasoning | "high" | "low" | "max" | "medium" | "minimal" | "xhigh" | optional |
How it fails
| Cause class | When |
|---|---|
VerificationFailedError | The verdict was fail. details.findings and details.claim are set. |
GatewayError, SchemaValidationError | As for infer. |
Journal
The inference events, then verification with name, verdict and findings.
import { defineWorkflow, Type } from "astralis-harness";
export default defineWorkflow({
name: "summarise-and-check",
input: Type.Object({ source: Type.String({ minLength: 1 }) }),
output: Type.Object({ summary: Type.String(), checked: Type.Array(Type.String()) }),
async run(ctx, input) {
const draft = await ctx.infer({
name: "summarise",
prompt: `Summarise in three sentences.\n\n${input.source}`,
output: Type.Object({ summary: Type.String() }),
});
// Returns the verifier's notes on a pass. On a fail it throws, and nothing after this line runs.
const checked = await ctx.verify({
name: "check-summary",
model: "local/stronger-model",
claim: `This is an accurate summary of the source: ${draft.summary}`,
evidence: input.source,
instructions: "Flag any statement that the source does not support.",
});
return { summary: draft.summary, checked };
},
});approve#
Pauses the workflow and asks a person yes or no. If nobody can be asked, the answer is no. A run can never approve itself.
The step asks the run's Approver and journals the decision as an approval event (with the approver's id) before acting on it. A "no" throws ApprovalRejectedError with the note. With no approver the built-in deny-all approver answers no. The CLI wires a terminal approver (prompts on stderr, reads stdin) only when stdin is a TTY, so a piped or scheduled astralis run rejects every approval. /run inside astralis tui has no approver either. The terminal approver accepts y, yes, n or no, optionally followed by a note, and asks again on anything else.
| Field | Type | What it does | |
|---|---|---|---|
name | string | required | Stable name for this step; appears in the journal and in errors. |
question | string | required | |
details | string | optional |
Your own approver is an object with an id and an ask(request) that resolves to { approved, note? }; pass it as approver to runWorkflow.
How it fails
| Cause class | When |
|---|---|
ApprovalRejectedError | The answer was no, or no approver was available. error.note has the reason. |
ApprovalUnavailableError | The terminal approver's input ended before an answer. No approval event is written; it is neither yes nor no. |
Journal
approval with name, question, details, approved, note and approver.
import { defineWorkflow, Type } from "astralis-harness";
export default defineWorkflow({
name: "reply-with-approval",
input: Type.Object({ ticket: Type.String({ minLength: 1 }) }),
output: Type.Object({ reply: Type.String(), approvedBy: Type.String() }),
async run(ctx, input) {
const draft = await ctx.infer({
name: "draft",
prompt: `Write a reply to this ticket.\n\n${input.ticket}`,
output: Type.Object({ text: Type.String() }),
});
// A "no", or no approver at all, throws here, so the line after it only ever runs after a yes.
const decision = await ctx.approve({
name: "approve-reply",
question: "Send this reply to the customer?",
details: draft.text,
});
return { reply: draft.text, approvedBy: decision.note ?? "approved without a note" };
},
});memoryRead and memoryWrite#
A notebook that survives between runs. Each note has a short name (a key) and holds JSON that must fit a schema, so a later run can trust what it reads.
A MemoryStore is a directory you name; each key is one file <key>.json. Keys are 1 to 128 characters of A-Z a-z 0-9 . _ -, starting with a letter or digit. memoryRead returns undefined when the key does not exist and throws when the stored value fails the schema; the read is journaled before it is validated. memoryWrite validates, then writes atomically (temporary file, then rename), and refuses symlinked files or directories. There is no locking: two runs doing read-then-write on one key can lose an update.
memoryRead
| Field | Type | What it does | |
|---|---|---|---|
name | string | required | |
store | MemoryStore | required | |
key | string | required | |
schema | S | required |
memoryWrite
| Field | Type | What it does | |
|---|---|---|---|
name | string | required | |
store | MemoryStore | required | |
key | string | required | |
value | Static<S> | required | |
schema | S | required |
How it fails
| Cause class | When |
|---|---|
ConfigError | Invalid key; the store directory or key file is a symlink; the file is not valid JSON; the value is not JSON-serializable. |
SchemaValidationError | The stored (read) or given (write) value does not fit schema. Nothing is written when validation fails. |
Journal
memory_read with key, found and sha256; memory_write with key and sha256.
import { defineWorkflow, MemoryStore, Type } from "astralis-harness";
const store = new MemoryStore(".astralis/memory");
const Counter = Type.Object({ runs: Type.Integer({ minimum: 0 }) }, { additionalProperties: false });
export default defineWorkflow({
name: "count-runs",
input: Type.Object({}),
output: Type.Object({ runs: Type.Integer() }),
async run(ctx) {
// undefined means the key has never been written. A stored value that fails the schema throws.
const before = await ctx.memoryRead({ name: "read-counter", store, key: "counter", schema: Counter });
const runs = (before?.runs ?? 0) + 1;
await ctx.memoryWrite({ name: "write-counter", store, key: "counter", schema: Counter, value: { runs } });
return { runs };
},
});Tools, policy and sandbox#
When a model is allowed to touch files or run commands, three layers stand between it and your machine: a policy that says what each call may look like, a sandbox that contains the shell, and your own review of what you allow. This section says exactly what each layer does and does not do.
Deny by default#
Nothing is allowed because nobody forbade it. A tool must be named in a policy before it can run at all, and every named tool must say what its arguments may look like. A tool with no rules is a configuration error, not an open door.
createToolPolicy({ allow }) maps tool names to a non-empty list of rules; all rules of a tool must pass. A tool not listed is refused. buildTools creates the tools for one step and wraps each in the policy; withPolicy wraps any tool you have. ctx.tool and ctx.agent refuse a tool that did not pass through one of them. A refused call writes tool_blocked to the journal and throws PolicyError; the tool never runs.
| Field | Type | What it does | |
|---|---|---|---|
cwd | string | required | Root of the file tools and starting directory of bash. Canonicalised with realpath once, and that one path is what every tool, the sandbox and the policy's path rules use; the rules' root must already be that exact path (a symlinked cwd would make the policy and Pi resolve ../x differently). |
builtin | readonly BuiltinToolName[] | optional | Which of Pi's built-in tools the step gets. |
mcp | { connection: McpConnection; allow: readonly string[]; } | optional | MCP tools by their mcp__<server>__<tool> names, from a live connection. |
policy | RuleBasedToolPolicy | required | Every tool is wrapped by it. A tool it does not allow cannot run. Pi's file tools need a path rule on their path argument rooted at cwd, bash needs a command rule on command, and every MCP tool needs an entry ({ kind: "unrestricted" } is accepted for MCP tools only). |
sandbox | SandboxConfig | optional | Required when bash is selected. Bash never runs outside it. |
buildTools also refuses, before any tool exists, a policy that leaves a tool unconstrained: Pi's file tools need a path rule on path rooted at the tool's working directory, bash needs a command rule on command and a sandbox, and every MCP tool on the allowlist needs an entry.
The four rule kinds#
| Kind | Use it for | How it decides |
|---|---|---|
path | File tools (read, edit, write, grep, find, ls) | The argument must resolve inside root and match one of the allow globs. Symlinks are followed, so a link that leads out of the root is refused. |
command | bash | The whole string must equal one of commands or match one of patterns, and may contain only conservative characters. |
pattern | Any other string argument | The whole value must match at least one regular expression. |
unrestricted | Tools with no argument worth checking (MCP tools only) | Always allows. Must be the tool's only rule, and buildTools refuses it for Pi's file tools and bash. |
Every rule checks one named argument. A missing or non-string argument fails the rule: an unchecked value is never let through.
PathRule
| Field | Type | What it does | |
|---|---|---|---|
kind | "path" | required | |
arg | string | required | |
root | string | required | Absolute directory. Must be the tool's working directory when used with buildTools. |
allow | readonly string[] | required | Globs relative to root. * stays inside one directory, ** crosses directories. |
default | string | optional | What the tool uses when the argument is absent (for example "." for a search tool that defaults to its working directory). It is checked exactly like a supplied value. Without it, a missing argument fails. |
CommandRule
| Field | Type | What it does | |
|---|---|---|---|
kind | "command" | required | |
arg | string | required | |
commands | readonly string[] | optional | Exact, whole-string matches. |
patterns | readonly RegExp[] | optional | Regular expressions that must match the entire command. |
extraChars | string | optional | Extra permitted characters, never any of ; & | < > $ ( ) { } \ ` or a newline. Opt in sparingly. |
PatternRule
| Field | Type | What it does | |
|---|---|---|---|
kind | "pattern" | required | |
arg | string | required | |
patterns | readonly RegExp[] | required |
Safe and unsafe, side by side#
The rule lists what may run. Anything else, and anything with a shell operator, quote, $, backtick, newline or glob character, is refused.
export const safeBash = createToolPolicy({
allow: {
bash: [
{
kind: "command",
arg: "command",
// Whole-command matches only. Nothing else is accepted, so there is nothing to smuggle in.
commands: ["npm test", "git status"],
patterns: [/git (diff|log)( --(stat|oneline))?/],
},
],
},
});This compiles and the character filter still applies, but it accepts find . -delete, git push and countless others. No list of allowed commands is safe if the allowed programs can run other programs.
export const tooBroad = createToolPolicy({
allow: {
// Compiles, and the character filter still applies, but `find . -delete` and `git push` pass.
// A pattern that admits arbitrary arguments to a program is not a safe rule.
bash: [{ kind: "command", arg: "command", patterns: [/[a-z]+( [a-z./=-]+)*/] }],
},
});Why prefix matching was removed
An early version let you say "allow anything that starts with git". That sounds harmless until you remember that git can run a program named in its own options.
Allowing the prefix git accepted git -c alias.x=!touch x x, because git runs an alias that starts with ! as a shell command. A rule that admits arbitrary arguments to a program is unsafe, so the prefix form is gone: commands match as whole strings only. Today that same command is refused twice over (the ! is outside the permitted characters, and it matches no listed command):
// removed: matched the start of the command
{ kind: "command", arg: "command", prefix: "git" }
safeBash.check("bash", { command: "git -c alias.x=!touch x x" })
// { allowed: false, reason: 'bash: command contains characters outside the permitted set: "git -c alias.x=!touch x x"' }safeBash.check("bash", { command: "git diff --stat" }) // { allowed: true }
safeBash.check("bash", { command: "npm test; rm -rf ." }) // refused: ";" is not a permitted characterexport const files = createToolPolicy({
allow: {
// Readable: the whole workspace. Writable: only src and test. Symlinks are resolved first.
read: [{ kind: "path", arg: "path", root: workspace, allow: ["**"] }],
write: [{ kind: "path", arg: "path", root: workspace, allow: ["src/**", "test/**"] }],
// `path` is optional for ls, so the rule says what the tool uses when it is omitted.
ls: [{ kind: "path", arg: "path", root: workspace, allow: ["**"], default: "." }],
},
});Valid, and sometimes right, but it lets the model overwrite anything in the workspace, including your config and the workflow itself.
export const writesEverywhere = createToolPolicy({
allow: { write: [{ kind: "path", arg: "path", root: workspace, allow: ["**"] }] },
});For a tool whose arguments do not need checking (a read-only MCP tool), write it down. Or constrain a string with a pattern rule.
export const mcp = createToolPolicy({
allow: {
// A non-built-in tool: constrain a string argument with a whole-value regex, or say "unrestricted".
mcp__tickets__search: [{ kind: "pattern", arg: "query", patterns: [/[A-Za-z0-9 ]{1,80}/] }],
mcp__tickets__list: [{ kind: "unrestricted" }],
},
});An empty list used to mean "allow anything". It is now a ConfigError, and unrestricted on a file tool or bash is refused by buildTools.
export function emptyRuleListIsRefused(): void {
try {
createToolPolicy({ allow: { read: [] } });
} catch (error) {
// ConfigError: "Tool policy for "read" has no rules. An empty list no longer allows everything..."
if (!(error instanceof ConfigError)) throw error;
}
}What path rules refuse, and what they cannot#
| Refused | Why |
|---|---|
Absolute paths outside the root, and .. that climbs out | Resolved against the root, then compared. |
| A symlink inside the root that leads out; a new file below a symlinked or dangling parent | The deepest existing ancestor is resolved to its real path first. |
~, a leading @, file: URLs, non-ASCII spaces, an empty path, a NUL | Pi's file tools expand these specially, so the tool would open a different file than the one checked. |
A path that matches none of allow | Globs are relative to the root: * stays in one directory, ** crosses directories. |
The policy check runs before the call. For read, write, edit and ls, buildTools also installs file operations that open the final path component without following symlinks, ask the kernel where the open file really lives (/proc/self/fd, so Linux only) and re-check that against the same rule before any data moves; a symlink swapped in after the check is refused. A residual remains for creating a new file or directory: if a parent is swapped in that instant, one empty file or directory can land outside the root; it is detected, removed and reported, and no data is written. grep and find hand the path to ripgrep and fd, which cannot be pinned this way, so a swap between check and use is not defended against there. None of this is the sandbox: file tools run in the host process.
Pi's built-in tools and what each needs#
| Tool | Arguments | What buildTools requires |
|---|---|---|
read | path, offset?, limit? | a path rule on path |
edit | path, edits[] (oldText, newText) | a path rule on path |
write | path, content | a path rule on path |
grep | pattern, path?, glob?, ignoreCase?, literal?, context?, limit? | a path rule on path; add default: "." because path is optional |
find | pattern, path?, limit? | a path rule on path, with default: "." |
ls | path?, limit? | a path rule on path, with default: "." |
bash | command, timeout? | a command rule on command, and a sandbox |
The path rule's root must be the same directory as the tool set's cwd, because Pi resolves relative paths against the working directory while the rule checks against its root. buildTools canonicalises cwd with realpath once and every rule's root must be that exact real path, so a symlinked working directory has to be written as its real path.
The bubblewrap sandbox#
Bubblewrap puts a command in a small, temporary room: it can read the system, write only into your workspace, cannot see your home directory, and cannot reach the network unless you opened that door. If the room cannot be built, the command does not run at all.
Every call of the built-in bash tool that buildTools creates is wrapped like this (a tool of your own that merely happens to be named bash, even one wrapped with withPolicy, is your code and is not sandboxed), in this order (later mounts overlay earlier ones): all namespaces unshared (user, ipc, pid, net, uts, cgroup; a missing user namespace is a failure, not a downgrade), --die-with-parent, a new session (blocks terminal-injection escapes), all capabilities dropped and nested user namespaces disabled, the whole filesystem mounted read-only, fresh /dev and /proc, empty tmpfs over /tmp and /run, and over every home directory (yours and /home, /root, /mnt, /media, /var/tmp, hiding ~/.ssh, ~/.aws and tokens), then the workspace bound read-write and used as the working directory. The environment is rebuilt from an allowlist (default PATH, LANG, LC_ALL, TERM) and HOME is set to the workspace. The network is off unless network: true. A command whose working directory is outside the workspace is refused. If /usr/bin/bwrap is not executable there is no fallback: buildTools throws ConfigError.
The workspace itself is validated first and refused (ConfigError) if it must be absolute and real, or is /, your home directory or an ancestor of it, a system directory or inside one, /tmp, /var, /run, /home or /root themselves, an ancestor of the node binary, or inside the harness's own installation. When the harness is installed in the workspace's node_modules, that directory is mounted read-only inside the sandbox so a command cannot rewrite the code that enforces the policy; package installs must happen outside the sandbox.
| Field | Type | What it does | |
|---|---|---|---|
workspace | string | required | Mounted read-write; the only place a command can write. Commands start here. Must be an absolute path to an existing directory; it is canonicalised with realpath and refused (ConfigError) when it is /, the home directory itself, an ancestor of one, a system directory (/usr, /etc, /dev, /proc, ...) or a place inside one, /tmp, /var, /run, /home or /root themselves, or an ancestor of the node binary. A workspace holding the harness in its node_modules is allowed, and that node_modules directory is mounted read-only inside the sandbox so a command cannot rewrite the code that enforces the policy (so package installs must happen outside the sandbox). A workspace holding the harness outside node_modules is refused. |
network | boolean | optional | Off unless set. With it on the command shares the HOST network namespace, which exposes: every service listening on the host loopback (local databases, a local model server, ...), link-local addresses such as a cloud metadata endpoint, the host's abstract unix sockets (X11, dbus and the like; filesystem sockets stay hidden because /run and /tmp are private), and the ability to listen on host ports. Leave it off unless the command must reach the network. |
envAllow | readonly string[] | optional | Environment variables copied in. Nothing else is inherited. Default: PATH, LANG, LC_ALL, TERM. |
env | Readonly<Record<string, string>> | optional | Extra values set inside, after the allowlist (for example a token meant for the sandbox). |
bwrapPath | string | optional | Path of the bwrap binary. Default: /usr/bin/bwrap. |
| The sandbox does | The sandbox does not |
|---|---|
| Make everything but the workspace read-only | Hide the rest of the filesystem: /etc, /usr and other world-readable files are visible |
Hide your home directory, sockets in /run and the host's /tmp | Protect the workspace itself: a command can delete or overwrite anything in it |
| Cut the network by default | Limit a network you turned on: network: true shares the host network namespace, which exposes services on the host loopback (local databases, a local model server), link-local addresses such as a cloud metadata endpoint, and abstract unix sockets |
| Kill the whole process tree on timeout or abort | Limit CPU, memory or disk, or filter system calls: the harness sets no resource limits and passes no seccomp filter (it does drop all capabilities) |
| Pass only the environment you list | Cover the file tools or MCP tools: those run in the host process and are confined by the policy (MCP servers get only the environment their config gives them) |
Bubblewrap needs unprivileged user namespaces. astralis doctor runs a real command through the same code path and tells you if they are unavailable.
MCP tools#
MCP (Model Context Protocol) servers can supply tools. You connect explicitly, with the server definitions you pass in; the harness never reads Pi's own config files. Every tool you allow needs a rule or { kind: "unrestricted" }. OAuth servers are refused. Close the connection when the run ends.
import { buildTools, connectMcp, createToolPolicy } from "astralis-harness";
export async function ticketTools() {
// Servers get only the environment the config gives them; nothing is inherited from this process.
const connection = await connectMcp({ tickets: { command: "node", args: ["tickets-server.js"] } });
// Tool names are mcp__<server>__<tool>. This lists what the server offers, for building the allowlist.
console.error(connection.toolNames());
const policy = createToolPolicy({
allow: {
mcp__tickets__search: [{ kind: "pattern", arg: "query", patterns: [/[A-Za-z0-9 ]{1,80}/] }],
},
});
const tools = buildTools({ cwd: process.cwd(), mcp: { connection, allow: ["mcp__tickets__search"] }, policy });
// Pass `tools` to ctx.agent or ctx.tool, and close the connection when the run is over.
return { tools, close: () => connection.close() };
}Rule builder#
This builder needs JavaScript. The generated shape is the createToolPolicy call in the examples above.
Policy rule builder
assembles a createToolPolicy and buildTools callThe builder runs in your browser and writes code; nothing is validated by the harness until you run it. Its output is typechecked against the real API by the docs tests.
The limits, stated plainly#
- The policy narrows what a model may attempt. The sandbox is the boundary for shell commands. Neither protects you from code you wrote or installed yourself.
- File tools run in the host process. The path rule is their fence: for
read,write,editandlsit is re-verified at open time, forgrepandfindit is only a check before the call. Neither is an operating-system barrier. - Command rules are defence in depth. A command that is allowed can still do what that command does; choose exact commands and keep the sandbox on.
- A sandbox with
network: truecan reach everything your host can reach, including loopback services. - Mods in
astralis tuiare not subject to any of this; see interactive mode.
Models and providers#
A model is always named provider/model-id. Your own servers are OpenAI-compatible endpoints you list in astralis.config.json; Pi's frontier providers come in with one switch.
The config file#
One JSON file says where your models live. The harness reads it strictly: a typo in a key is an error, not something silently ignored. astralis doctor checks it, the servers and the keys before you spend a run finding out.
The file is astralis.config.json in the directory you run from, or whatever --config names. The repository ships an example:
{
"defaultModel": "local/EXAMPLE-replace-with-a-model-you-have-pulled",
"builtinProviders": true,
"providers": {
"local": {
"baseUrl": "http://127.0.0.1:11434/v1",
"models": [
{
"id": "EXAMPLE-replace-with-a-model-you-have-pulled",
"name": "Example local model (Ollama)",
"contextWindow": 32768,
"maxTokens": 8192
}
]
}
},
"tui": {
"maxTurns": 20,
"tools": ["read", "ls", "grep", "find"],
"mods": [],
"loadContextFiles": false
}
}The model in a reference such as local/qwen2.5 is split at the first slash, so model ids that contain slashes work (vllm/Qwen/Qwen2.5-7B-Instruct), and a provider id may not contain one.
Configuration reference#
Every key the schema accepts. Unknown keys are rejected, and so are values that do not match the type.
| Key | Type | Default | Meaning | |
|---|---|---|---|---|
defaultModel | string min length 1 | optional | none (steps then need an explicit model) | provider/model-id used by any step that names no model, and by the CLI when --model is absent. The provider and model are not checked when the file is read; astralis doctor checks them. |
builtinProviders | boolean | required | required | Register Pi's catalog of frontier providers next to your own. Their API keys come from the environment variables Pi reads (table below). |
providers | object (name to object) | required | required | Your own OpenAI-compatible endpoints, by provider id. May be empty when builtinProviders is true. |
providers.<id> | object | required | n/a | One endpoint. The id becomes the first half of model references (id/model-id) and must not contain /. A configured provider with the same id as a built-in one replaces it. |
providers.<id>.baseUrl | string min length 1 | required | required | Base URL of the OpenAI-compatible API, including the version path (for example http://127.0.0.1:11434/v1). Requests go to the chat completions endpoint under it; astralis doctor calls GET <baseUrl>/models. |
providers.<id>.apiKeyEnv | string min length 1 | optional | absent: the endpoint is keyless | Name of the environment variable that holds the key. The value is never read from the config file. If the variable is not set when the gateway is created, you get a ConfigError naming it. |
providers.<id>.models | array of object at least 1 item | required | required | The models the server offers that you want to use. |
providers.<id>.models[] | object | required | n/a | One model. |
providers.<id>.models[].id | string min length 1 | required | required | The model id the server expects in the request, and the second half of provider/model-id. |
providers.<id>.models[].name | string min length 1 | optional | the id | Display name. |
providers.<id>.models[].contextWindow | integer minimum 1 | required | required | Context size in tokens. Informs Pi; it is not enforced by the harness. |
providers.<id>.models[].maxTokens | integer minimum 1 | required | required | Maximum output tokens Pi will request. |
providers.<id>.models[].reasoning | boolean | optional | false | The model produces reasoning output. |
providers.<id>.models[].supportsDeveloperRole | boolean | optional | false | Send the system prompt with the developer role. Many OpenAI-compatible servers reject it, so it is off. |
providers.<id>.models[].supportsReasoningEffort | boolean | optional | false | Send reasoning_effort when a step sets reasoning. Many servers reject it, so it is off. |
providers.<id>.models[].samplingParams | object (name to any value) | optional | none | Extra fields merged into every request body for this model (top_p, top_k, min_p, ...). The values are part of the request hash. |
tui | object | optional | all defaults below | Settings for astralis tui. Every field is optional. |
tui.maxTurns | integer minimum 1 | optional | 20 | Model turns allowed per chat prompt before the harness aborts it. |
tui.tools | array of string | optional | read, ls, grep, find | Pi tool names the chat model may use. Others do not exist for the model and are blocked if called. bash and powershell are refused at startup. |
tui.mods | array of string | optional | none (plus anything in .astralis/mods/) | Extra mod files (TypeScript Pi extensions), resolved against the project directory. Mods run with full process permissions. |
tui.theme | string min length 1 | optional | Pi's own default | Pi theme name. |
tui.loadContextFiles | boolean | optional | false | Load AGENTS.md / CLAUDE.md style context files into the chat prompt. |
Providers you configure speak the OpenAI chat completions protocol and are text-only. There is no key for custom headers, and no per-model tool-calling flag: typed steps need the server to honour a forced tool call (see below). Reaching anything else goes through builtinProviders.
Config builder#
This builder needs JavaScript. The recipes below show complete files.
astralis.config.json builder
form to file, with copy and downloadThe builder applies the same checks the schema does (whole numbers of at least 1, no / in provider ids, at least one model per provider). The docs tests run its output through the real config parser.
Recipes for local and hosted endpoints#
Your choice of backend is remembered in this browser. Every file below is validated by the real config parser when the docs are built. Server flags change between releases, so treat the commands as a starting point and check your server's own documentation; the config is what the harness needs.
ollama serve
ollama pull qwen2.5-coder:7b{
"defaultModel": "ollama/qwen2.5-coder:7b",
"builtinProviders": false,
"providers": {
"ollama": {
"baseUrl": "http://127.0.0.1:11434/v1",
"models": [
{ "id": "qwen2.5-coder:7b", "contextWindow": 32768, "maxTokens": 8192 }
]
}
}
}Ollama listens on port 11434 and serves the OpenAI-compatible API under /v1. No key. The model must support tool calling, because typed steps depend on it; check the model's page. contextWindow here is what you say it is: Ollama's own context default can be smaller than the number you write, so set the server's context to match.
llama-server -m ./model.gguf --port 8080 --jinja -c 32768{
"defaultModel": "llamacpp/my-model",
"builtinProviders": false,
"providers": {
"llamacpp": {
"baseUrl": "http://127.0.0.1:8080/v1",
"models": [
{ "id": "my-model", "contextWindow": 32768, "maxTokens": 4096, "samplingParams": { "top_k": 40, "min_p": 0.05 } }
]
}
}
}Use the host and port you started it on. Tool calling generally needs the chat template enabled (--jinja in common builds). samplingParams is merged into every request body, which is how you pass top_k or min_p to a server that accepts them. The id is whatever the server reports at /v1/models.
vllm serve Qwen/Qwen2.5-7B-Instruct --enable-auto-tool-choice --tool-call-parser hermes{
"defaultModel": "vllm/Qwen/Qwen2.5-7B-Instruct",
"builtinProviders": false,
"providers": {
"vllm": {
"baseUrl": "http://127.0.0.1:8000/v1",
"apiKeyEnv": "VLLM_API_KEY",
"models": [
{ "id": "Qwen/Qwen2.5-7B-Instruct", "contextWindow": 32768, "maxTokens": 8192 }
]
}
}
}vLLM needs tool-choice support switched on and a parser that matches the model family. The model id contains a slash; that is fine. Add --api-key on the server and export the same value as VLLM_API_KEY, or drop apiKeyEnv for a keyless server.
{
"defaultModel": "litellm/smart",
"builtinProviders": false,
"providers": {
"litellm": {
"baseUrl": "http://127.0.0.1:4000/v1",
"apiKeyEnv": "LITELLM_API_KEY",
"models": [
{ "id": "fast", "contextWindow": 32768, "maxTokens": 4096 },
{ "id": "smart", "contextWindow": 128000, "maxTokens": 8192 }
]
}
}
}Run the LiteLLM proxy with the aliases you want (fast and smart above are names from its config) and list each alias as a model here. It is a convenient way to put one stable name in front of several back ends, or to reach providers Pi does not know. If the proxy is in front of an Anthropic model, an older proxy may need the request quirk Pi documents as requiresToolsWithToolHistory, which configured models cannot set; upgrade the proxy.
{
"defaultModel": "hosted/model-name",
"builtinProviders": false,
"providers": {
"hosted": {
"baseUrl": "https://api.example.com/v1",
"apiKeyEnv": "HOSTED_API_KEY",
"models": [
{ "id": "model-name", "contextWindow": 128000, "maxTokens": 8192, "reasoning": true, "supportsReasoningEffort": true }
]
}
}
}export HOSTED_API_KEY=...
astralis doctorReplace the URL and ids with your provider's. reasoning and supportsReasoningEffort are off by default because many compatible servers reject the extra field; turn them on only for a model that accepts them. If apiKeyEnv is set and the variable is empty, creating the gateway fails with a ConfigError that names the variable.
Frontier providers through builtinProviders#
Set one switch and Pi's catalog of hosted providers appears next to your own. You still name models as provider/model-id; the keys are read from the environment.
{
"defaultModel": "anthropic/claude-sonnet-5-5",
"builtinProviders": true,
"providers": {}
}Mix local and hosted by keeping both in the same file, and choose per step with the model field:
{
"defaultModel": "ollama/qwen2.5-coder:7b",
"builtinProviders": true,
"providers": {
"ollama": {
"baseUrl": "http://127.0.0.1:11434/v1",
"models": [
{ "id": "qwen2.5-coder:7b", "contextWindow": 32768, "maxTokens": 8192 }
]
}
}
}const Plan = Type.Object({ steps: Type.Array(Type.String()) }, { additionalProperties: false });
export const plan = defineWorkflow({
name: "plan-and-write",
input: Type.Object({ goal: Type.String() }),
output: Type.Object({ text: Type.String() }),
async run(ctx, input) {
// A small local model for the cheap structured step...
const steps = await ctx.infer({ name: "plan", model: "ollama/qwen2.5-coder:7b", prompt: `Plan: ${input.goal}`, output: Plan });
// ...and a hosted frontier model, through builtinProviders, for the part that needs it.
return ctx.infer({
name: "write",
model: "anthropic/claude-sonnet-5-5",
prompt: `Write it up.\n\n${steps.steps.join("\n")}`,
output: Type.Object({ text: Type.String() }),
});
},
});Pi resolves keys from these environment variables. The table is read from the installed Pi package at build time, so it matches the version this harness pins.
Provider id (first half of provider/model-id) | Environment variable |
|---|---|
ant-ling | ANT_LING_API_KEY |
qwen-token-plan | QWEN_TOKEN_PLAN_API_KEY |
qwen-token-plan-cn | QWEN_TOKEN_PLAN_CN_API_KEY |
qwen-token-plan-individual | QWEN_TOKEN_PLAN_API_KEY |
qwen-token-plan-individual-cn | QWEN_TOKEN_PLAN_CN_API_KEY |
openai | OPENAI_API_KEY |
azure | AZURE_OPENAI_API_KEY |
nvidia | NVIDIA_API_KEY |
deepseek | DEEPSEEK_API_KEY |
google | GEMINI_API_KEY |
google-vertex | GOOGLE_CLOUD_API_KEY |
groq | GROQ_API_KEY |
cerebras | CEREBRAS_API_KEY |
xai | XAI_API_KEY |
typesafe | TYPESAFE_API_KEY |
radius | RADIUS_API_KEY |
openrouter | OPENROUTER_API_KEY |
vercel-ai-gateway | AI_GATEWAY_API_KEY |
zai | ZAI_API_KEY |
zai-coding-cn | ZAI_CODING_CN_API_KEY |
mistral | MISTRAL_API_KEY |
minimax | MINIMAX_API_KEY |
minimax-cn | MINIMAX_CN_API_KEY |
moonshotai | MOONSHOT_API_KEY |
moonshotai-cn | MOONSHOT_API_KEY |
huggingface | HF_TOKEN |
fireworks | FIREWORKS_API_KEY |
together | TOGETHER_API_KEY |
baseten | BASETEN_API_KEY |
opencode | OPENCODE_API_KEY |
opencode-go | OPENCODE_API_KEY |
kimi-coding | KIMI_API_KEY |
meta | META_API_KEY |
cloudflare-workers-ai | CLOUDFLARE_API_KEY |
cloudflare-ai-gateway | CLOUDFLARE_API_KEY |
xiaomi | XIAOMI_API_KEY |
xiaomi-token-plan-cn | XIAOMI_TOKEN_PLAN_CN_API_KEY |
xiaomi-token-plan-ams | XIAOMI_TOKEN_PLAN_AMS_API_KEY |
xiaomi-token-plan-sgp | XIAOMI_TOKEN_PLAN_SGP_API_KEY |
Anthropic also accepts ANTHROPIC_OAUTH_TOKEN and bearer-style ANTHROPIC_AUTH_TOKEN. Google Vertex works with an API key or with Application Default Credentials plus GOOGLE_CLOUD_PROJECT and GOOGLE_CLOUD_LOCATION. Amazon Bedrock uses the usual AWS credential sources (AWS_PROFILE, access key pairs, AWS_BEARER_TOKEN_BEDROCK, container or web-identity variables). GitHub Copilot uses COPILOT_GITHUB_TOKEN. Pi's own provider guide describes each in detail.
Doctor checks the providers you configured. For a built-in provider with no key you find out at run time, as a GatewayError such as Provider is not configured: anthropic. Set the variable before the first run.
Structured output with local models#
When a step asks for a typed answer, the harness does not hope the model writes valid JSON. It gives the model one tool, submit_result, shaped like your schema, and forces the model to call it. A model that can do tool calls can fill any form you design. A model or server that cannot do tool calls cannot do typed steps at all.
For OpenAI-compatible endpoints the request carries tool_choice naming submit_result, and the tool declares constrained sampling with strict: "prefer", so servers that can constrain generation to the schema will, and others fall back to normal tool calling. The arguments are then validated strictly against your schema. There is no JSON-mode or text-parsing fallback on purpose: a fallback would hide the failure this design exists to show.
| Symptom | Likely cause | What to do |
|---|---|---|
SchemaValidationError: "was required to call submit_result exactly once but called it 0 times", with prose in details.raw | The model or server ignores forced tool choice, or the model has no tool support. | Use a model that supports tools; enable tool calling on the server (chat template, parser flags); try another server for the same weights. |
SchemaValidationError on /field paths | The model called the tool but filled it wrongly; small models stumble on large or deeply nested schemas. | Flatten the schema, replace long unions with a few literals, split into two infer steps, or use a stronger model for that step. |
| "malformed JSON for submit_result" | The model emitted broken JSON inside the call, often truncated. | Raise maxTokens; shorten the output. |
GatewayError: "hit its output limit" | Stop reason length. Reasoning models spend the same budget on thinking. | Raise maxTokens on the step and on the model, or lower reasoning. |
HTTP errors mentioning developer or reasoning_effort | The server rejects fields the harness would send. | Leave supportsDeveloperRole and supportsReasoningEffort off (the default). |
The harness will not coax JSON out of it. Either serve that model through something that provides tool calling, or use a tool-capable model for every infer, verify and agent step. Work that needs no model belongs in plain code or a tool step.
Subagents and verification#
More than one model call in a workflow is normal. This section covers the patterns that matter: fanning work out, giving each piece its own bounded agent, and having a stronger model check the result.
There is no subagent object, only steps#
A "subagent" here is not a separate program. It is one more step: an agent step started from inside a fanout worker, with its own tools, its own turn cap, and its own numbered place in the journal. You get parallel helpers by writing the loop yourself, so you always know how many there are and what each may do.
Steps nest. Anything a fanout worker starts nests under that worker (3.2.1 is the first step started by the second worker of step 3). Step ids are fixed by item order, not by which worker finishes first, so two runs over the same list line up in astralis diff.
Fanout patterns#
| Pattern | Shape | When it fits |
|---|---|---|
| Map, then reduce | fanout of infer, then one infer merges | Many documents, one overview. The map-reduce template is exactly this. |
| Map, then verify | fanout of cheap workers, then one verify with a stronger model | Cheap breadth, expensive judgement once. |
| Agent per item | fanout of agent | Each item needs exploring with tools (one module, one ticket). |
| Pipeline | Sequential steps, each reading the previous typed result | The next question depends on the last answer. This is plain code, no fanout needed. |
| Partial results | A worker that catches its own failure and returns a marker | One bad item must not sink the batch. Explicit, and still in the journal. |
Fanout fails fast and loudly by default: the first failing worker stops workers that have not started, and the step throws one FanoutError listing every failure. That is usually what you want. When it is not, catch inside the worker, as below, so the choice is visible in your code.
import { resolve } from "node:path";
import { buildTools, createToolPolicy, defineWorkflow, StepError, Type } from "astralis-harness";
const workspace = resolve(process.cwd());
const policy = createToolPolicy({
allow: {
read: [{ kind: "path", arg: "path", root: workspace, allow: ["src/**"] }],
grep: [{ kind: "path", arg: "path", root: workspace, allow: ["src", "src/**"], default: "src" }],
},
});
// One toolbox is fine to share: each agent step gets its own loop, journal events and turn count.
const tools = buildTools({ cwd: workspace, builtin: ["read", "grep"], policy });
const Report = Type.Object({ module: Type.String(), risks: Type.Array(Type.String()), ok: Type.Boolean() }, { additionalProperties: false });
export default defineWorkflow({
name: "inspect-modules",
input: Type.Object({ modules: Type.Array(Type.String({ pattern: "^[a-z][a-z0-9-]*$" }), { minItems: 1 }) }),
output: Type.Object({ reports: Type.Array(Report) }),
async run(ctx, input) {
// A "subagent" is just an agent step inside a fanout worker, with its own tools and turn cap.
const reports = await ctx.fanout({
name: "inspect-each",
items: input.modules,
concurrency: 2,
worker: async (module) => {
try {
return await ctx.agent({
name: `inspect-${module}`,
prompt: `Inspect src/${module}. List real risks you can point to in the code.`,
output: Report,
tools,
maxTurns: 6,
});
} catch (error) {
// Explicit partial results: this one worker is allowed to fail without failing the batch.
// The failed step is still in the journal as a step_error; nothing is hidden.
if (error instanceof StepError) return { module, risks: [error.message], ok: false };
throw error;
}
},
});
return { reports };
},
});It is a promise about how many calls you will have in flight, not a speed-up. A single local GPU often serves requests one at a time, so a high number mostly queues. A hosted provider may rate-limit you. Start low and look at the durations in astralis report.
Using a stronger model as the verifier#
Having a model check its own work is a weak test: it tends to agree with itself. Have a different, usually stronger, model play skeptic, hand it the claim and the evidence, and let a "fail" stop the run.
verify is one structured call with a fixed adversarial system prompt. It sees only the claim and evidence you pass (and optional instructions), so build the evidence deliberately: read the changed files with a tool step and pass their contents, as the coding-task template does, rather than passing the writer's own summary. Set model to the checker; reasoning raises the effort on models that support it.
import { defineWorkflow, Type } from "astralis-harness";
const Finding = Type.Object({ file: Type.String(), issues: Type.Array(Type.String()) }, { additionalProperties: false });
// A fixed number of workers in parallel, then one stronger model checks the merged result.
export default defineWorkflow({
name: "review-files",
input: Type.Object({ files: Type.Array(Type.Object({ path: Type.String(), text: Type.String() }), { minItems: 1 }) }),
output: Type.Object({ findings: Type.Array(Finding), verifierNotes: Type.Array(Type.String()) }),
async run(ctx, input) {
const findings = await ctx.fanout({
name: "review-each",
items: input.files,
concurrency: 4,
// The workers use a small, fast model. Each is its own journaled step.
worker: (file) =>
ctx.infer({
name: `review-${file.path}`,
model: "local/small",
prompt: `List concrete problems in this file, or none.\n\n${file.text}`,
output: Finding,
}),
});
const verifierNotes = await ctx.verify({
name: "check-review",
// The checker is a different, stronger model than the one that produced the work.
model: "local/strong",
claim: "Every reported issue is real, and no file with a serious problem was reported clean.",
evidence: JSON.stringify({ findings, files: input.files }),
});
return { findings, verifierNotes };
},
});To react to a failed verdict instead of stopping, catch it where you can see it: StepError whose cause is a VerificationFailedError. The cookbook's escalation recipe retries with a stronger writer.
Cost and latency#
| Step | Model calls | What dominates the time and the bill | Levers |
|---|---|---|---|
infer | 1 | Prompt size plus output size | Short prompts, maxTokens, a smaller model |
verify | 1 | Evidence size, and a stronger model's price | Verify the claims that matter; pass only the relevant evidence |
agent | Up to maxTurns | Every turn re-sends a growing transcript, tool output included | Small maxTurns, narrow tools, read with limit, a policy that keeps output small |
fanout | Sum of the workers | Items times the worker's cost, divided by effective parallelism | A cheap worker model, a sensible concurrency |
tool, approve, memory | 0 | The tool, or the person | Use them to replace model calls wherever the answer is known |
Every model response's token usage is in the journal, and astralis report totals it by model. For a hard limit in code, see the token cap recipe; the harness has no built-in budget yet.
Skills, hooks and memory#
Three small tools for shaping a run without giving the model more control: reusable instructions you can pin, tripwires at step boundaries, and notes that outlive a run.
Skills, pinned by hash#
A skill is a file of instructions you want to reuse, such as "how we write release notes". Pinning it by hash is like a lockfile for a prompt: if anyone (a teammate, an editor, a script) changes the file, the run stops instead of quietly behaving differently.
loadSkill(name, searchDirs, expectedHash?) looks for a SKILL.md with that name in the directories you list, in order. There is no implicit global discovery. A name that appears twice in one directory is an error, a skill whose real path leaves its directory through a symlink is refused, and an invalid skill file throws ConfigError. The hash is the sha256 of the whole file, front matter included; if you pass one and it does not match, the error says what was expected and what was found. renderSkills([skill]) returns the text to append to a system prompt: the full body of each skill, because the model does not choose whether to read it.
import { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { defineWorkflow, loadSkill, MemoryStore, renderSkills, Type } from "astralis-harness";
const here = dirname(fileURLToPath(import.meta.url));
// Pinned: if skills/research-method/SKILL.md changes, the run fails instead of silently behaving differently.
// After editing the skill on purpose, get the new hash with `sha256sum skills/<name>/SKILL.md` (or read the "found" value in the mismatch error) and update it here.
const SKILL_SHA256 = "d6a9f1ffdbca823fa1ea0b2a39d8581951e202bcf20d5c421911852d1b7d1af7";
// Notes persist between runs in this directory, one JSON file per key.
const store = new MemoryStore(join(here, ".astralis", "memory"));
const Notes = Type.Object({ notes: Type.Array(Type.String()) }, { additionalProperties: false });
const Answer = Type.Object({ answer: Type.String(), newNote: Type.String() }, { additionalProperties: false });
export default defineWorkflow({
name: "research-with-memory",
input: Type.Object({ question: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
output: Type.Object({ answer: Type.String(), notesKept: Type.Integer() }, { additionalProperties: false }),
async run(ctx, input) {
const memory = await ctx.memoryRead({ name: "read-notes", store, key: "notes", schema: Notes });
const notes = memory?.notes ?? [];
const skill = loadSkill("research-method", [join(here, "skills")], SKILL_SHA256);
const result = await ctx.infer({
name: "answer",
system: renderSkills([skill]),
prompt: `Earlier notes:\n${notes.length === 0 ? "(none)" : notes.map((n) => `- ${n}`).join("\n")}\n\nQuestion: ${input.question}`,
output: Answer,
});
const updated = [...notes, result.newNote];
await ctx.memoryWrite({ name: "write-notes", store, key: "notes", schema: Notes, value: { notes: updated } });
return { answer: result.answer, notesKept: updated.length };
},
});---
name: research-method
description: How to answer a question using earlier notes and record one new note.
---
# Research method
1. Check the earlier notes first. If they answer the question, use them and say so.
2. Separate what you know from what you are guessing. Mark guesses as guesses.
3. Answer in a short paragraph.
4. Write one new note: a single self-contained sentence a later run can rely on without this question.sha256sum skills/research-method/SKILL.mdrenderSkills writes a skill_attached event only when it runs inside a step. In the usual form, system: renderSkills([skill]) inside ctx.infer({ ... }), the call is evaluated before the step starts, so no such event is written. The skill text is still recorded: it is part of the system prompt in inference_request, and the pinned hash is in your code. Run renderSkills inside a fanout worker if you want the event.
Hooks#
A hook is a tripwire at the edge of a step. It can look at what is happening (and write it down, time it, count it) and it can say "stop" by throwing. It cannot change what the step receives or returns.
Register handlers on a HookRegistry and pass it as hooks to runWorkflow. beforeStep runs before the step body, afterStep after it succeeds, onStepError after it fails. All run inside the step, in registration order, for every step kind including worker and fanout. A handler that throws fails that step, and the journal records it (step_error). Handlers get frozen copies: afterStep receives a deep-frozen clone of the result (a result that cannot be cloned fails the step with ConfigError), and onStepError receives a frozen snapshot of the error (name, message, stack, details, cause), never the live object. Every onStepError handler runs even if an earlier one threw; if any threw, the step fails with an error whose cause is the original failure and whose details carry the handlers' failures.
astralis run does not register hooks. Hooks are for programs that call runWorkflow themselves (see the guard recipe in the cookbook for a complete runner). Inside astralis tui, /run uses hooks internally for its progress widget.
Timing
const started = new Map<string, number>();
export const timing = new HookRegistry()
.beforeStep(({ stepId }) => {
started.set(stepId, performance.now());
})
.afterStep(({ stepId, kind, name }) => {
const ms = Math.round(performance.now() - (started.get(stepId) ?? performance.now()));
console.error(`step ${stepId} ${kind} ${name}: ${ms} ms`);
});Logging failures
export const logging = new HookRegistry().onStepError(({ stepId, kind, name, error }) => {
// `error` is a frozen snapshot (name, message, stack, details, cause), never the live error object.
appendFileSync("failures.jsonl", `${JSON.stringify({ stepId, kind, name, error })}\n`);
});A guard that fails a step
export const readOnly = new HookRegistry().beforeStep(({ kind, name, stepId }) => {
// Throwing from a hook fails the step it ran in, and the journal records why.
if (kind === "memory" && name.startsWith("write-")) {
throw new AstralisError(`Step ${stepId} (${name}) is a memory write, and this run is read-only.`, { stepId, name });
}
});A guard that matches step names is why names are worth choosing well. The cookbook has the same idea as a complete runner that blocks steps named in a list.
Memory patterns#
Memory is a folder of small JSON files, one per key, that your workflow reads and writes through steps. It is how a later run knows what an earlier run learned or finished, without a database.
A MemoryStore takes an explicit directory, creates it, and resolves it to its real path; every read and write checks that it still is that directory and refuses if it was swapped for a symlink. Reads and writes are steps (memoryRead, memoryWrite), so they are journaled with a hash of the value, and both directions validate against a schema. Keys are letters, digits, ., _ and -, starting with a letter or digit. Writes are atomic per file. There is no locking and no history: it is last write wins.
Cache by input
// Cache by input: the key is a hash, so it always satisfies the key rules (letters, digits, . _ -).
const key = `answer-${createHash("sha256").update(input.question).digest("hex").slice(0, 32)}`;
const hit = await ctx.memoryRead({ name: "read-cache", store, key, schema: Cached });
if (hit !== undefined) return { answer: hit.answer, cached: true };
const fresh = await ctx.infer({ name: "answer", prompt: input.question, output: Cached });
await ctx.memoryWrite({ name: "write-cache", store, key, schema: Cached, value: fresh });Append to a log
// A log is read-modify-write. There is no locking, so run one writer at a time.
const log = (await ctx.memoryRead({ name: "read-log", store, key: "log", schema: Log })) ?? { entries: [] };
await ctx.memoryWrite({ name: "write-log", store, key: "log", schema: Log, value: { entries: [...log.entries, input.question] } });- Version your schemas. A stored value that no longer matches throws on read. Add a
versionfield when a shape may change, and write the migration in code. - Keep secrets out. Memory files are plain JSON on disk, and the model sees whatever you put in a prompt.
- Decide whether memory is committed. The templates keep it under
.astralis/, next to the journals, so a.gitignoreline is usually right. - Resuming a failed run is not built in. The cookbook shows how to make a rerun skip finished items by writing each result to memory as it completes.
- The hash in
memory_readandmemory_writeis over the JSON text of the value, so the same data with keys in a different order hashes differently.
Interactive mode: astralis tui#
Pi's chat interface, run inside the harness: the model can look around your project, you can run workflows from the chat, and every prompt is journaled. Chat is deliberately read-only by default.
What chat mode enforces#
You get a normal chat with a model, but fenced: it can read and search your project and nothing else unless you widen the list. It cannot run shell commands at all, because in chat there would be no sandbox around them. Each prompt is capped at a number of turns, and what happens is written to a journal.
- Tool allowlist. Default
read,ls,grep,find. Settui.toolsto change it. Tools not listed do not exist for the model, and a call to one is blocked as well. - File tools confined to the project directory by a path rule, symlinks resolved.
bashandpowershellrefused at startup. The error says to run shell commands from a workflowagentstep, where they are sandboxed.- Turn cap per prompt (
tui.maxTurns, default 20). A prompt still calling tools at the cap is aborted; a final answer on the last allowed turn is fine. - One
agentstep per prompt in the session journal (chat:1,chat:2, ...), with the prompt, allowed and blocked tool calls, tool results and turn ends. - Isolation. Pi's state lives in
.astralis/agent, never~/.pi. A project's.pi/extensionscannot load (the project is untrusted), and skills, prompt templates and AGENTS.md or CLAUDE.md files are not loaded unlesstui.loadContextFilesis on. - What
tui.toolsmay contain. Pi's file tools (confined to the project) and tools registered by a mod you listed. Pi's own extension tools (tool_search, codemode, MCP tools) are refused at startup with aConfigError, because they can change the active tool set or run code. - The active tool set is checked, not assumed. If a mod registers a tool under an allowlisted name, or a tool outside the allowlist becomes active, chat resets the set, fails the prompt, journals
tool_source_violationortool_set_violation, and the process exits 1. Tools Pi or a mod activated beyond the allowlist at startup are switched off and journaled astool_set_trimmed.
Chat model calls go through Pi's own session, not the gateway, so the journal has prompt, tool_call, tool_result, turn_end and tool_blocked events but no inference_request or inference_response. Workflows you start with /run do go through the gateway and get their own journal.
Slash commands#
| Command | What it does |
|---|---|
/run <workflow-file> [json-input] | Runs a workflow file. The first word is the path (so no spaces in it), the rest is JSON. A progress widget lists the steps; the result or the full error appears when it ends. One workflow at a time. The default model is the config's defaultModel, else the model selected in chat. There is no approver, so approve steps are rejected. |
/steps | Lists the steps of the last /run with status and time, and its journal path. |
/journal | Shows this session's journal path and its last events. |
| Pi's own commands | /model, /new, /resume, /fork, /tree, /session, /export and others are Pi's. Type / to see what is loaded. Some leave your machine (Pi documents /share as uploading the session) and /login stores credentials under .astralis/agent; the harness does not remove them. |
Keyboard shortcuts#
These are Pi's defaults in the editor; /hotkeys shows the active set and Pi's keybinding file can change them.
| Keys | Action |
|---|---|
| Esc | Cancel or abort the current response |
| Ctrl C | First press clears the editor, second press exits |
| Ctrl D | Exit when the editor is empty |
| Ctrl L | Open the model selector |
| Ctrl O | Collapse or expand tool output |
| Ctrl T | Collapse or expand thinking blocks |
| Shift Tab | Cycle the thinking level |
| Alt Enter | Queue a follow-up message |
| Ctrl G | Edit the prompt in your external editor |
| / | In the editor, start a slash command |
Configuring it#
All keys live under tui in astralis.config.json and are listed in the configuration reference. --model picks the chat model, --runs-dir moves the journals, and --no-mods loads no mods.
Mods: trust, stated bluntly#
A mod is an ordinary TypeScript file loaded into the astralis process with full permissions. It can read and write any file, run commands, use the network, read the stored credentials in .astralis/agent/auth.json, call models directly without the gateway or the journal, and replace or fake the interface, including the status line that claims the session is safe. The tool allowlist, policy and turn cap constrain the chat model, not mods. Nothing a mod does is checked or recorded by the harness. Only install mods you have read.
- Startup prints every mod it is about to load, with its path and where it was found, before loading it (a mod's top-level code runs as soon as it is imported).
- Mods are loaded from exactly two places:
.astralis/mods/*.ts(also.js,.mjs) and thetui.modslist. Pi's own global and project extension directories are never consulted, and any other extension found is dropped with a notice. --no-modsskips all of them. A path intui.modsthat does not exist is an error before the UI starts.- If you must run code you do not trust, use an operating-system boundary such as a container or a virtual machine. That is the only thing that contains a mod.
Writing a mod#
A mod is one file that exports one function. Pi calls it with an object (pi) you use to add slash commands and to listen for events. The one below adds /hello, which puts a greeting in a box under the editor.
/**
* Example Astralis mod: a `/hello [name]` slash command that shows a greeting in a
* widget under the editor, plus a `/hello-clear` command that removes it.
*
* Install (the mod is loaded only from these two places, never from Pi's own
* global or project extension directories):
*
* mkdir -p .astralis/mods
* cp examples/mods/hello-mod.ts .astralis/mods/
*
* or list it in astralis.config.json: { "tui": { "mods": ["examples/mods/hello-mod.ts"] } }
*
* Then run `astralis tui`. Startup prints every mod it loads.
*
* TRUST: a mod is ordinary code in the astralis process, with full permissions. The
* harness's tool allowlist and turn cap apply to the chat model, not to a mod: this
* file could just as well write files, run commands or call a model directly, and
* nothing would stop or journal it. Read a mod before you install it.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const WIDGET_KEY = "hello-mod";
export default function helloMod(pi: ExtensionAPI): void {
pi.registerCommand("hello", {
description: "Greet someone in a widget: /hello [name]",
handler: async (args, ctx) => {
const name = args.trim() === "" ? "world" : args.trim();
ctx.ui.setWidget(WIDGET_KEY, [`Hello, ${name}! (from examples/mods/hello-mod.ts)`], { placement: "belowEditor" });
ctx.ui.notify(`hello-mod: greeted ${name}`, "info");
},
});
pi.registerCommand("hello-clear", {
description: "Remove the hello widget",
handler: async (_args, ctx) => {
ctx.ui.setWidget(WIDGET_KEY, undefined);
},
});
}mkdir -p .astralis/mods
cp examples/mods/hello-mod.ts .astralis/mods/
astralis tuiOr leave it where it is and list it in the config: { "tui": { "mods": ["examples/mods/hello-mod.ts"] } }. Then type /hello Ada. The pattern is always the same:
- Default-export a function that takes the extension API (
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"). Useimport type: workflow and mod files are run by stripping types. - Register commands with
pi.registerCommand(name, { description, handler }). The handler gets the argument string and a context whoseuicannotify,setStatusandsetWidget. - Listen to events with
pi.on("turn_end", ...)and friends. Keep per-session state inside the exported function: Pi runs it again for/new,/resumeand/fork. - Clean up what you show:
setWidget(key, undefined)removes a widget, as/hello-cleardoes.
A second mod, building on the first, counts completed turns and shows the count in the status line. Note the guard: Pi starts one more request after an abort, and it must not be counted.
/**
* A second mod, building on hello-mod: it counts completed chat turns and shows the count in the status
* line. Like every mod it is ordinary code in the astralis process; read mods before you install them.
*/
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
const STATUS_KEY = "counter-mod";
export default function counterMod(pi: ExtensionAPI): void {
let turns = 0;
pi.on("session_start", async (_event, ctx) => {
turns = 0;
ctx.ui.setStatus(STATUS_KEY, "counter-mod: 0 turns");
});
pi.on("turn_end", async (event, ctx) => {
// Pi starts one more request after an abort; it ends "aborted" and is not a real turn.
if (event.outcome !== "completed") return;
turns += 1;
ctx.ui.setStatus(STATUS_KEY, `counter-mod: ${turns} turns`);
});
pi.registerCommand("turns", {
description: "Show how many turns this session has taken",
handler: async (_args, ctx) => {
ctx.ui.notify(`${turns} completed turn(s) so far`, "info");
},
});
}Both files are typechecked against the installed Pi API when these docs are built. The extension API itself is Pi's; its own documentation in the Pi package covers every event and UI method.
Templates gallery#
Four starter projects ship with the package. Each is a complete, runnable project: astralis init <template> my-project copies its files, then astralis doctor tells you what to fix. The files below are the real ones, read from the repository when this page was built.
Edit astralis.config.json: set baseUrl and replace the placeholder model ids (they begin with EXAMPLE-). Install the package in the new directory (npm install https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgz), because the workflow imports it by name. init never overwrites files and checks every target before writing any.
coding-task#
coding-task
astralis init coding-task my-projectPlan, edit with a bounded agent behind a strict tool policy and sandbox, then verify with a stronger model.
my-project/
workflow.ts
astralis.config.json
package.json
README.mdastralis init coding-task my-project
cd my-project
astralis doctorimport { realpathSync } from "node:fs";
import { buildTools, createToolPolicy, defineWorkflow, Type } from "astralis-harness";
// The directory you run `astralis` from is the workspace. Everything below is confined to it.
const workspace = realpathSync(process.cwd());
// Replace with a stronger model from your config. Verifying with the model that did the work proves little.
const VERIFIER_MODEL = "local/EXAMPLE-stronger-model";
const Plan = Type.Object(
{ steps: Type.Array(Type.String(), { minItems: 1 }), files: Type.Array(Type.String()) },
{ additionalProperties: false },
);
const Outcome = Type.Object(
{ summary: Type.String(), changedFiles: Type.Array(Type.String()) },
{ additionalProperties: false },
);
// Deny by default. A tool that is not listed cannot run; a listed tool runs only when its arguments pass.
const policy = createToolPolicy({
allow: {
read: [{ kind: "path", arg: "path", root: workspace, allow: ["**"] }],
ls: [{ kind: "path", arg: "path", root: workspace, allow: ["**"], default: "." }],
// Edits are confined to src/ and test/ inside the workspace.
edit: [{ kind: "path", arg: "path", root: workspace, allow: ["src/**", "test/**"] }],
write: [{ kind: "path", arg: "path", root: workspace, allow: ["src/**", "test/**"] }],
// Exact commands or fully matched patterns, and no shell operators or quotes, so "npm test; rm -rf ." is refused.
// This narrows what the model may attempt; the sandbox below is the actual boundary.
bash: [
{
kind: "command",
arg: "command",
commands: ["npm test"],
patterns: [/git (status|diff)( --stat)?/, /ls( -la?)?( [a-z/.]+)?/],
},
],
},
});
// bash runs inside bubblewrap: only the workspace is writable and there is no network.
const tools = buildTools({
cwd: workspace,
builtin: ["read", "ls", "edit", "write", "bash"],
policy,
sandbox: { workspace },
});
export default defineWorkflow({
name: "coding-task",
input: Type.Object({ task: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
output: Type.Object({ summary: Type.String(), changedFiles: Type.Array(Type.String()), findings: Type.Array(Type.String()) }, { additionalProperties: false }),
async run(ctx, input) {
const plan = await ctx.infer({
name: "plan",
system: "You plan small code changes. Name the files you expect to touch.",
prompt: `Plan this change as short steps.\n\nTask: ${input.task}`,
output: Plan,
});
// The one step where the model chooses its own moves, capped at 12 turns and limited to the tools above.
const outcome = await ctx.agent({
name: "implement",
system: "You make the planned change with the tools given, run the tests, then submit your result.",
prompt: `Task: ${input.task}\n\nPlan:\n${plan.steps.map((s, i) => `${i + 1}. ${s}`).join("\n")}`,
output: Outcome,
tools,
maxTurns: 12,
});
// Read the changed files back with no model involved, so the verifier judges real contents.
const contents: string[] = [];
for (const file of outcome.changedFiles) {
const read = await ctx.tool({ name: "read", args: { path: file }, tools });
const text = read.content.map((c) => (c.type === "text" ? c.text : "")).join("");
contents.push(`--- ${file}\n${text}`);
}
// A failed verdict throws and stops the run.
const findings = await ctx.verify({
name: "verify-change",
model: VERIFIER_MODEL,
claim: `The task "${input.task}" was completed correctly. Reported summary: ${outcome.summary}`,
evidence: contents.join("\n\n"),
});
return { summary: outcome.summary, changedFiles: outcome.changedFiles, findings };
},
});{
"defaultModel": "local/EXAMPLE-replace-with-a-model-you-have-pulled",
"builtinProviders": true,
"providers": {
"local": {
"baseUrl": "http://127.0.0.1:11434/v1",
"models": [
{
"id": "EXAMPLE-replace-with-a-model-you-have-pulled",
"name": "Example local model (replace the id)",
"contextWindow": 32768,
"maxTokens": 8192
},
{
"id": "EXAMPLE-stronger-model",
"name": "Example stronger model for verification (replace the id)",
"contextWindow": 32768,
"maxTokens": 8192
}
]
}
}
}{
"name": "coding-task-project",
"private": true,
"type": "module"
}# coding-task
Plan with `infer`, make the change with one bounded `agent` step, read the changed files back with `tool`,
then check the result with `verify` using a second, stronger model.
Safety is in the code, not the prompt:
- `createToolPolicy` denies every tool not listed. `edit` and `write` only reach `src/**` and `test/**` inside the
workspace (symlinks are resolved first). `bash` only accepts `npm test`, `git status`/`git diff` (optionally `--stat`)
and `ls`, matched over the whole command, and any shell operator or quote is refused. This narrows what the
model may try; the sandbox is the real boundary.
- `bash` runs inside bubblewrap: only the workspace is writable, `~` is hidden, and there is no network. There is no
unsandboxed fallback, so `npx astralis doctor` should show the sandbox check as ok before you run this.
- The agent stops after 12 turns.
Run it from the root of the project you want changed (the current directory is the workspace):
1. `npm install https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgz` here, or install it where your project resolves it.
2. Edit `astralis.config.json`: set `baseUrl`, and replace both example model ids. Put the stronger model's
id into `VERIFIER_MODEL` in `workflow.ts`.
3. `npx astralis doctor`
4. `npx astralis run workflow.ts --input '{"task":"Add a unit test for the parseDate function"}'`
Note: the agent's tools resolve paths against the workspace; relative paths such as `src/date.ts` work best.map-reduce#
map-reduce
astralis init map-reduce my-projectFan out infer workers over a list with a concurrency cap, then merge the results.
my-project/
workflow.ts
astralis.config.json
package.json
README.mdastralis init map-reduce my-project
cd my-project
astralis doctorimport { defineWorkflow, Type } from "astralis-harness";
const Digest = Type.Object(
{
title: Type.String(),
keyPoints: Type.Array(Type.String(), { maxItems: 5 }),
},
{ additionalProperties: false },
);
const Merged = Type.Object({ overview: Type.String() }, { additionalProperties: false });
export default defineWorkflow({
name: "map-reduce",
input: Type.Object({ documents: Type.Array(Type.String({ minLength: 1 }), { minItems: 1 }) }, { additionalProperties: false }),
output: Type.Object({ count: Type.Integer(), overview: Type.String() }, { additionalProperties: false }),
async run(ctx, input) {
// At most two workers at once. Step ids follow item order, so two runs journal the same ids.
const digests = await ctx.fanout({
name: "digest-each",
items: input.documents,
concurrency: 2,
worker: (document, index) =>
ctx.infer({
name: `digest-${index}`,
prompt: `Give the document a short title and list its key points.\n\n${document}`,
output: Digest,
}),
});
const merged = await ctx.infer({
name: "merge",
prompt: `Write one overview that covers all of these digests.\n\n${JSON.stringify(digests, null, 2)}`,
output: Merged,
});
return { count: digests.length, overview: merged.overview };
},
});{
"defaultModel": "local/EXAMPLE-replace-with-a-model-you-have-pulled",
"builtinProviders": true,
"providers": {
"local": {
"baseUrl": "http://127.0.0.1:11434/v1",
"models": [
{
"id": "EXAMPLE-replace-with-a-model-you-have-pulled",
"name": "Example local model (replace the id)",
"contextWindow": 32768,
"maxTokens": 8192
}
]
}
}
}{
"name": "map-reduce-project",
"private": true,
"type": "module"
}# map-reduce
`fanout` runs one `infer` worker per document, at most two at a time, then a final `infer` merges the digests.
Results come back in input order and step ids are fixed by that order, so runs are comparable.
If any worker fails, the run fails and reports every failed index.
1. Install the harness in this directory: `npm install https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgz`.
2. Edit `astralis.config.json`: point `baseUrl` at your server and replace the example model id.
3. Check the setup: `npx astralis doctor`.
4. Run it:
```sh
npx astralis run workflow.ts --input '{"documents":["First document text...","Second document text...","Third document text..."]}'
```pipeline#
pipeline
astralis init pipeline my-projectClassify an input, branch in code on the typed result, then summarise (infer plus plain code).
my-project/
workflow.ts
astralis.config.json
package.json
README.mdastralis init pipeline my-project
cd my-project
astralis doctorimport { defineWorkflow, Type } from "astralis-harness";
const Classification = Type.Object(
{
category: Type.Union([Type.Literal("bug"), Type.Literal("feature"), Type.Literal("question")]),
reason: Type.String(),
},
{ additionalProperties: false },
);
const Summary = Type.Object({ summary: Type.String() }, { additionalProperties: false });
export default defineWorkflow({
name: "pipeline",
input: Type.Object({ text: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
output: Type.Object({ category: Type.String(), summary: Type.String() }, { additionalProperties: false }),
async run(ctx, input) {
const classification = await ctx.infer({
name: "classify",
system: "You triage incoming messages for a software project.",
prompt: `Classify this message as a bug report, a feature request or a question.\n\n${input.text}`,
output: Classification,
});
// The model does not choose what happens next. This code does, from the typed result.
const instruction =
classification.category === "bug"
? "Summarise the problem and what would be needed to reproduce it, in two sentences."
: classification.category === "feature"
? "Summarise the requested capability and the need behind it, in two sentences."
: "Summarise the question in one sentence.";
const result = await ctx.infer({
name: "summarise",
prompt: `${instruction}\n\n${input.text}`,
output: Summary,
});
return { category: classification.category, summary: result.summary };
},
});{
"defaultModel": "local/EXAMPLE-replace-with-a-model-you-have-pulled",
"builtinProviders": true,
"providers": {
"local": {
"baseUrl": "http://127.0.0.1:11434/v1",
"models": [
{
"id": "EXAMPLE-replace-with-a-model-you-have-pulled",
"name": "Example local model (replace the id)",
"contextWindow": 32768,
"maxTokens": 8192
}
]
}
}
}{
"name": "pipeline-project",
"private": true,
"type": "module"
}# pipeline
Classify a message, branch in code on the typed category, then summarise with a prompt chosen by that branch.
Two `infer` steps and ordinary TypeScript between them.
1. Install the harness in this directory: `npm install https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgz`.
2. Edit `astralis.config.json`: point `baseUrl` at your OpenAI-compatible server and replace the example model id.
3. Check the setup: `npx astralis doctor`.
4. Run it:
```sh
npx astralis run workflow.ts --input '{"text":"The export button crashes when the list is empty."}'
```
The journal of the run is printed on stderr (`.astralis/runs/<id>/journal.jsonl`).research-with-memory#
research-with-memory
astralis init research-with-memory my-projectRead memory, infer with a pinned skill, write memory back.
my-project/
workflow.ts
astralis.config.json
package.json
skills/
research-method/
SKILL.md
README.mdastralis init research-with-memory my-project
cd my-project
astralis doctorimport { dirname, join } from "node:path";
import { fileURLToPath } from "node:url";
import { defineWorkflow, loadSkill, MemoryStore, renderSkills, Type } from "astralis-harness";
const here = dirname(fileURLToPath(import.meta.url));
// Pinned: if skills/research-method/SKILL.md changes, the run fails instead of silently behaving differently.
// After editing the skill on purpose, get the new hash with `sha256sum skills/<name>/SKILL.md` (or read the "found" value in the mismatch error) and update it here.
const SKILL_SHA256 = "d6a9f1ffdbca823fa1ea0b2a39d8581951e202bcf20d5c421911852d1b7d1af7";
// Notes persist between runs in this directory, one JSON file per key.
const store = new MemoryStore(join(here, ".astralis", "memory"));
const Notes = Type.Object({ notes: Type.Array(Type.String()) }, { additionalProperties: false });
const Answer = Type.Object({ answer: Type.String(), newNote: Type.String() }, { additionalProperties: false });
export default defineWorkflow({
name: "research-with-memory",
input: Type.Object({ question: Type.String({ minLength: 1 }) }, { additionalProperties: false }),
output: Type.Object({ answer: Type.String(), notesKept: Type.Integer() }, { additionalProperties: false }),
async run(ctx, input) {
const memory = await ctx.memoryRead({ name: "read-notes", store, key: "notes", schema: Notes });
const notes = memory?.notes ?? [];
const skill = loadSkill("research-method", [join(here, "skills")], SKILL_SHA256);
const result = await ctx.infer({
name: "answer",
system: renderSkills([skill]),
prompt: `Earlier notes:\n${notes.length === 0 ? "(none)" : notes.map((n) => `- ${n}`).join("\n")}\n\nQuestion: ${input.question}`,
output: Answer,
});
const updated = [...notes, result.newNote];
await ctx.memoryWrite({ name: "write-notes", store, key: "notes", schema: Notes, value: { notes: updated } });
return { answer: result.answer, notesKept: updated.length };
},
});{
"defaultModel": "local/EXAMPLE-replace-with-a-model-you-have-pulled",
"builtinProviders": true,
"providers": {
"local": {
"baseUrl": "http://127.0.0.1:11434/v1",
"models": [
{
"id": "EXAMPLE-replace-with-a-model-you-have-pulled",
"name": "Example local model (replace the id)",
"contextWindow": 32768,
"maxTokens": 8192
}
]
}
}
}{
"name": "research-with-memory-project",
"private": true,
"type": "module"
}---
name: research-method
description: How to answer a question using earlier notes and record one new note.
---
# Research method
1. Check the earlier notes first. If they answer the question, use them and say so.
2. Separate what you know from what you are guessing. Mark guesses as guesses.
3. Answer in a short paragraph.
4. Write one new note: a single self-contained sentence a later run can rely on without this question.# research-with-memory
`memoryRead` loads notes saved by earlier runs, one `infer` answers using them and a pinned skill, and
`memoryWrite` saves a new note. Run it twice to see the second answer use the first run's note.
- Memory: JSON files under `.astralis/memory/` next to `workflow.ts`.
- Skill: `skills/research-method/SKILL.md`, attached in full to the system prompt and pinned by its sha256 in
`workflow.ts`. Editing the skill without updating the hash fails the run on purpose.
1. `npm install https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgz` in this directory.
2. Edit `astralis.config.json`: set `baseUrl` and replace the example model id.
3. `npx astralis doctor`
4. `npx astralis run workflow.ts --input '{"question":"What does a journal record in this harness?"}'`Cookbook#
Twelve short recipes for things you will actually want. Every one is a real file typechecked against the harness's API when these docs are built.
Retry a step, with a cap#
The harness never retries for you, so a retry is a loop you can read. Give each attempt its own name so the journal shows every try, and retry only the failure you expect.
import { defineWorkflow, SchemaValidationError, StepError, Type } from "astralis-harness";
const Answer = Type.Object({ label: Type.Union([Type.Literal("yes"), Type.Literal("no")]) }, { additionalProperties: false });
// The harness never retries for you. If you want a retry, it is a loop you can read, with a cap.
const MAX_ATTEMPTS = 3;
export default defineWorkflow({
name: "retry-with-cap",
input: Type.Object({ question: Type.String({ minLength: 1 }) }),
output: Type.Object({ label: Type.String(), attempts: Type.Integer() }),
async run(ctx, input) {
let lastFailure: unknown;
for (let attempt = 1; attempt <= MAX_ATTEMPTS; attempt++) {
try {
// A different name per attempt, so the journal shows every try as its own step.
const result = await ctx.infer({ name: `answer-attempt-${attempt}`, prompt: input.question, output: Answer });
return { label: result.label, attempts: attempt };
} catch (error) {
// Retry only the failure you expect. Anything else (a network error, a policy refusal) propagates.
if (error instanceof StepError && error.cause instanceof SchemaValidationError) {
lastFailure = error;
continue;
}
throw error;
}
}
throw lastFailure;
},
});Choose a model per step#
Every step takes a model. Use a small one for routing and a strong one only where quality shows.
import { defineWorkflow, Type } from "astralis-harness";
const CHEAP = "ollama/qwen2.5-coder:7b";
const STRONG = "anthropic/claude-sonnet-5-5";
export default defineWorkflow({
name: "model-per-step",
input: Type.Object({ text: Type.String({ minLength: 1 }) }),
output: Type.Object({ category: Type.String(), answer: Type.String() }),
async run(ctx, input) {
// Routing is easy: a small local model is enough.
const route = await ctx.infer({
name: "route",
model: CHEAP,
prompt: `Is this a billing question or a technical question?\n\n${input.text}`,
output: Type.Object({ category: Type.Union([Type.Literal("billing"), Type.Literal("technical")]) }),
});
// Writing the answer is the hard part, so only this step pays for the stronger model.
const answer = await ctx.infer({
name: "answer",
model: STRONG,
prompt: `Answer this ${route.category} question.\n\n${input.text}`,
output: Type.Object({ text: Type.String() }),
});
return { category: route.category, answer: answer.text };
},
});Escalate to a stronger model when verify fails#
A failed verdict is an ordinary outcome you can branch on: catch the StepError, check its cause, and try the next writer. Anything else still propagates.
import { AstralisError, defineWorkflow, StepError, Type, VerificationFailedError } from "astralis-harness";
// Cheapest first. The verifier is always the strongest model, never the one that wrote the draft.
const WRITERS = ["ollama/qwen2.5-coder:7b", "ollama/qwen2.5-coder:32b", "anthropic/claude-sonnet-5-5"] as const;
const VERIFIER = "anthropic/claude-sonnet-5-5";
export default defineWorkflow({
name: "escalate-on-verify-fail",
input: Type.Object({ source: Type.String({ minLength: 1 }) }),
output: Type.Object({ summary: Type.String(), writtenBy: Type.String() }),
async run(ctx, input) {
for (const [i, model] of WRITERS.entries()) {
const draft = await ctx.infer({
name: `summarise-${i}`,
model,
prompt: `Summarise in three sentences.\n\n${input.source}`,
output: Type.Object({ summary: Type.String() }),
});
try {
await ctx.verify({
name: `verify-${i}`,
model: VERIFIER,
claim: `This is an accurate summary: ${draft.summary}`,
evidence: input.source,
});
return { summary: draft.summary, writtenBy: model };
} catch (error) {
// A failed verdict is an expected outcome here, so only that is handled. Anything else propagates.
if (!(error instanceof StepError && error.cause instanceof VerificationFailedError)) throw error;
}
}
throw new AstralisError("No model produced a summary the verifier accepted.", { tried: [...WRITERS] });
},
});Require approval before a write#
Two layers: the policy limits where the write can go at all, and the approval puts a person in front of the content. Run it from a terminal; headless it is rejected, which is the point.
import { resolve } from "node:path";
import { buildTools, createToolPolicy, defineWorkflow, Type } from "astralis-harness";
const workspace = resolve(process.cwd());
// The policy is the hard limit (only notes/ is writable); the approval is the human check inside it.
const policy = createToolPolicy({ allow: { write: [{ kind: "path", arg: "path", root: workspace, allow: ["notes/**"] }] } });
const tools = buildTools({ cwd: workspace, builtin: ["write"], policy });
export default defineWorkflow({
name: "approve-before-write",
input: Type.Object({ topic: Type.String({ minLength: 1 }), file: Type.String({ pattern: "^notes/[A-Za-z0-9._-]+\\.md$" }) }),
output: Type.Object({ written: Type.String() }),
async run(ctx, input) {
const draft = await ctx.infer({
name: "draft-note",
prompt: `Write a short note about ${input.topic}.`,
output: Type.Object({ markdown: Type.String() }),
});
// Rejected, or nobody to ask: this throws and the write below never happens.
await ctx.approve({ name: "approve-write", question: `Create ${input.file}?`, details: draft.markdown });
await ctx.tool({ name: "write", args: { path: input.file, content: draft.markdown }, tools });
return { written: input.file };
},
});Cap spend by counting tokens from the journal#
There is no built-in budget. A journal is any object with append, so wrap the real one, add up inference_response usage, and let an afterStep hook throw. A running step is not interrupted: the cap bites when the step ends.
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import { AstralisError, createGatewayFromConfig, FileJournal, HookRegistry, type Journal, type JournalEvent, loadConfig, runWorkflow, type Workflow } from "astralis-harness";
/** Passes every event to the real journal and keeps a running total of the tokens the provider reported. */
class TokenCountingJournal implements Journal {
totalTokens = 0;
readonly inner: Journal;
constructor(inner: Journal) {
this.inner = inner;
}
append(event: JournalEvent): void {
this.inner.append(event);
if (event.type === "inference_response") {
const usage = event.data.usage as { totalTokens?: unknown } | undefined;
if (typeof usage?.totalTokens === "number") this.totalTokens += usage.totalTokens;
}
}
}
/** Runs a workflow and stops it at the first step boundary after `maxTokens` have been used. */
export async function runWithTokenCap(workflow: Workflow, input: unknown, maxTokens: number): Promise<unknown> {
const config = loadConfig();
const runId = randomUUID();
const journal = new TokenCountingJournal(new FileJournal(join(".astralis", "runs", runId, "journal.jsonl")));
const hooks = new HookRegistry().afterStep(({ stepId }) => {
// A step that is already running is not interrupted: the cap bites when the step ends.
if (journal.totalTokens > maxTokens) {
throw new AstralisError(`Token cap of ${maxTokens} exceeded after step ${stepId}: ${journal.totalTokens} used.`, { maxTokens, used: journal.totalTokens });
}
});
const gateway = await createGatewayFromConfig(config);
const result = await runWorkflow(workflow, input, {
gateway,
journal,
hooks,
runId,
...(config.defaultModel === undefined ? {} : { defaultModel: config.defaultModel }),
});
return result.output;
}Run the same workflow against two models and compare#
Run once per model into separate journals, then diff or report them. The request hashes differ by design, since the model is part of what was sent; the diff shows where the paths split and which responses differ.
import { randomUUID } from "node:crypto";
import { join } from "node:path";
import { createGatewayFromConfig, FileJournal, loadConfig, runWorkflow, type Workflow } from "astralis-harness";
/** Runs one workflow against two models and prints the command that compares their journals. */
export async function runOnBoth(workflow: Workflow, input: unknown, models: readonly [string, string]): Promise<void> {
const gateway = await createGatewayFromConfig(loadConfig());
const journals: string[] = [];
for (const model of models) {
const runId = randomUUID();
const path = join(".astralis", "runs", runId, "journal.jsonl");
await runWorkflow(workflow, input, { gateway, defaultModel: model, journal: new FileJournal(path), runId });
journals.push(path);
}
// The requests differ by design (the model is part of the hash). The diff shows where the paths split.
console.log(`astralis diff ${journals.join(" ")}`);
console.log(`astralis report ${journals[0]} -o a.html && astralis report ${journals[1]} -o b.html`);
}Resume by remembering finished work#
Resuming a failed run is not built in. Save each item's result as soon as it exists, and a rerun skips what is already done.
import { defineWorkflow, MemoryStore, Type } from "astralis-harness";
const store = new MemoryStore(".astralis/memory/batch");
const Result = Type.Object({ summary: Type.String() }, { additionalProperties: false });
// There is no built-in resume. This makes a rerun skip the work a failed run already finished.
export default defineWorkflow({
name: "resumable-batch",
input: Type.Object({
// Memory keys allow letters, digits, . _ - and must start with a letter or digit.
items: Type.Array(Type.Object({ id: Type.String({ pattern: "^[A-Za-z0-9][A-Za-z0-9._-]{0,100}$" }), text: Type.String() })),
}),
output: Type.Object({ summaries: Type.Array(Type.String()), reused: Type.Integer() }),
async run(ctx, input) {
const summaries: string[] = [];
let reused = 0;
for (const item of input.items) {
const key = `done-${item.id}`;
const saved = await ctx.memoryRead({ name: `check-${item.id}`, store, key, schema: Result });
if (saved !== undefined) {
summaries.push(saved.summary);
reused += 1;
continue;
}
const fresh = await ctx.infer({ name: `summarise-${item.id}`, prompt: `Summarise.\n\n${item.text}`, output: Result });
// Written as soon as the item is done, so a crash on the next item loses nothing.
await ctx.memoryWrite({ name: `save-${item.id}`, store, key, schema: Result, value: fresh });
summaries.push(fresh.summary);
}
return { summaries, reused };
},
});Structured extraction over a folder#
Listing a folder is plain code. Sort it, so step ids do not depend on the filesystem, then fan out one infer per file.
import { readdirSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { defineWorkflow, Type } from "astralis-harness";
const Invoice = Type.Object(
{ vendor: Type.String(), total: Type.Number(), currency: Type.String({ minLength: 3, maxLength: 3 }) },
{ additionalProperties: false },
);
export default defineWorkflow({
name: "extract-invoices",
input: Type.Object({ dir: Type.String({ minLength: 1 }) }),
output: Type.Object({ invoices: Type.Array(Type.Object({ file: Type.String(), vendor: Type.String(), total: Type.Number(), currency: Type.String() })) }),
async run(ctx, input) {
// Listing a folder is ordinary code. Sort it, or the step ids depend on the filesystem's order.
const files = readdirSync(input.dir).filter((f) => f.endsWith(".txt")).sort();
const invoices = await ctx.fanout({
name: "extract-each",
items: files,
concurrency: 3,
worker: async (file) => {
const fields = await ctx.infer({
name: `extract-${file}`,
prompt: `Extract the vendor, total and currency code.\n\n${readFileSync(join(input.dir, file), "utf8")}`,
output: Invoice,
});
return { file, ...fields };
},
});
return { invoices };
},
});A guard hook that blocks a step by name#
A beforeStep hook runs inside the step, so a throw fails that step and the journal says why. This is also a complete runner: load the config, build the gateway, run with hooks.
import { createGatewayFromConfig, HookRegistry, loadConfig, PolicyError, runWorkflow, type Workflow } from "astralis-harness";
// Step names are your handle on a run, so they are also something a guard can match.
const BLOCKED = new Set(["publish", "send-email", "delete-branch"]);
export const guard = new HookRegistry().beforeStep(({ name, kind, stepId }) => {
if (BLOCKED.has(name)) {
throw new PolicyError(`Step ${stepId} (${kind} "${name}") is blocked by the run's guard hook.`, { stepId, name, kind });
}
});
export async function runGuarded(workflow: Workflow, input: unknown): Promise<unknown> {
const config = loadConfig();
const result = await runWorkflow(workflow, input, {
gateway: await createGatewayFromConfig(config),
hooks: guard,
...(config.defaultModel === undefined ? {} : { defaultModel: config.defaultModel }),
});
return result.output;
}Test a workflow offline with Pi's scripted provider#
No network, no model, no cost. Pi's scripted provider replays the answers you give it, so a test can assert both the result and the path through the program. Add @earendil-works/pi-ai to your dev dependencies at the version the harness pins.
import assert from "node:assert/strict";
import { test } from "node:test";
import { createModels, fauxAssistantMessage, fauxProvider, fauxToolCall } from "@earendil-works/pi-ai";
import { defineWorkflow, Gateway, MemoryJournal, RESULT_TOOL, runWorkflow, stepSequence, Type } from "astralis-harness";
const workflow = defineWorkflow({
name: "label-then-branch",
input: Type.Object({ text: Type.String() }),
output: Type.Object({ route: Type.String() }),
async run(ctx, input) {
const label = await ctx.infer({
name: "label",
prompt: input.text,
output: Type.Object({ kind: Type.Union([Type.Literal("bug"), Type.Literal("question")]) }),
});
return { route: label.kind === "bug" ? "engineering" : "support" };
},
});
test("a bug report is routed to engineering, with no network and no model", async () => {
// Pi's scripted provider replays the answers you give it. The gateway recognises it by this api name.
const faux = fauxProvider({ api: "faux" });
const models = createModels();
models.setProvider(faux.provider);
const model = faux.getModel();
faux.setResponses([fauxAssistantMessage(fauxToolCall(RESULT_TOOL, { kind: "bug" }), { stopReason: "toolUse" })]);
const journal = new MemoryJournal();
const result = await runWorkflow(workflow, { text: "It crashes" }, { gateway: new Gateway(models), defaultModel: `${model.provider}/${model.id}`, journal });
assert.deepEqual(result.output, { route: "engineering" });
// The path through the program is part of the contract too.
assert.deepEqual(stepSequence(journal.events), ["1 infer label"]);
});node --test cookbook-offline-test.tsToken usage per model from a journal#
A journal is plain JSON lines, so you can analyse it with no harness code at all.
import { readFileSync } from "node:fs";
interface Line {
type: string;
stepId?: string;
data: { model?: string; usage?: { totalTokens?: number } };
}
/** Total tokens per model from a journal file. The journal is plain JSON lines, so no harness import is needed. */
export function tokensByModel(journalPath: string): Map<string, number> {
const totals = new Map<string, number>();
for (const text of readFileSync(journalPath, "utf8").split("\n")) {
if (text.trim() === "") continue;
const event = JSON.parse(text) as Line;
if (event.type !== "inference_response") continue;
const model = event.data.model ?? "unknown";
totals.set(model, (totals.get(model) ?? 0) + (event.data.usage?.totalTokens ?? 0));
}
return totals;
}Unit-test your tool policy#
check answers "would this call be allowed?" without running anything, so rules can have tests like any other code.
import { createToolPolicy } from "astralis-harness";
// Ask a policy what it would say, without running anything: handy in a unit test for your rules.
const policy = createToolPolicy({ allow: { bash: [{ kind: "command", arg: "command", commands: ["npm test"] }] } });
export const results = [
policy.check("bash", { command: "npm test" }),
policy.check("bash", { command: "npm test && curl example.com" }),
policy.check("write", { path: "x", content: "y" }),
];Tips, tricks and pitfalls#
Things that are true because of how the code actually behaves, collected so you do not have to find them the slow way.
Policy and tools#
- An empty rule list is an error.
read: []throwsConfigError. If a tool truly needs no checks, write[{ kind: "unrestricted" }](allowed for MCP tools, refused for file tools and bash). - Optional path arguments need a
default.grep,findandlsmay be called withoutpath. A path rule with nodefault: "."then refuses the call ("argument path must be a string"). A glob likesrc/**does not matchsrcitself, so list"src"too if that is your default. - A non-zero bash exit is not a tool error. Pi returns the output and exit code as a normal result, so
onToolError: "fail"does not stop an agent whose tests failed. The model sees the failure; if you need the step to fail, check the result in code. agentfails on the first tool error unless you setonToolError: "report", which lets the model see the error and carry on (and journalsagent_lenient_mode). A policy refusal fails the step either way.buildToolsruns when the file is imported if you call it at the top of the workflow file, as the templates do. A missingbwrapor a bad policy fails before the run starts, which is good, and also meansprocess.cwd()is evaluated at import time.- The path rule's
rootmust equal the tool set'scwd, resolved to real paths.buildToolschecks this for you. - Test your policy with
policy.check(tool, args)instead of waiting for a model to trip it.
Steps and workflows#
- Step names are your debugging handle. They appear in
astralis journal, error messages, the report, guard hooks andastralis diff. Make them specific (extract-invoice-0042, notstep), and give retry attempts different names. - Add
additionalProperties: falseto output schemas you pass along. Without it an object with extra fields still validates. - Nothing is coerced.
--input '{"count":"3"}'fails a schema that wants a number, and so does a model answering"3"for an integer. - Hooks receive frozen copies and cannot rewrite inputs or results. Throwing is how a hook says no.
- Sort before you fan out. A list read from disk in filesystem order makes step ids differ between machines.
- One failed worker ends the fanout and stops workers that have not started. Catch inside the worker if partial results are acceptable, and say so in the code.
- Keep the wall clock and randomness out of prompts. They change the request hash, so
astralis diffreports a difference you caused. - Files are TypeScript run by type stripping: use
import typefor types, keep the.tsextension on relative imports, and avoid enums and other syntax that needs real compilation. - A headless
astralis runrejects everyapprovestep (stdin is not a terminal). That is deliberate. In CI, expect the failure or do not useapprove.
Models#
- Keep
maxTurnssmall with local models. Each turn re-sends the whole transcript, small models loop, and the cap is the only thing that ends a loop. 6 to 10 is plenty for most tasks. - Verify with a different model. A model that wrote the answer is a weak judge of it.
- Raise
maxTokensfor reasoning models. Thinking and answer share the budget, and running out is aGatewayError("hit its output limit"). - Typed steps need tool calling. If a model answers in prose you get
SchemaValidationErrorwith the prose indetails.raw. See the local-models table. defaultModelis not checked when the file is read. A typo shows up at the first step.astralis doctorcatches it.
Journals#
- Journals contain everything the model saw and returned, including prompts, file contents and tool output. Treat
.astralis/runsand any report as sensitive, and keep it out of version control. Header values are never written, and exact credential values from your environment are replaced with[redacted secret], but a secret you put in a prompt, or that a tool prints in another form, is kept. - Nothing cleans the runs directory. Use
--runs-dirfor experiments and delete what you do not need. - Events from steps that run at once interleave in the file. Read by
stepId, not by line order, when a fanout is involved.astralis reportanddiffalready do. - A tool that reports an error leaves a
tool_callwith notool_result. The result is in thestep_errordetails.
Interactive mode#
- Chat mode refuses
bash. Put shell work in a workflowagentstep, where it is sandboxed, and start it with/run. /runhas no approver, so a workflow withapprovesteps will be rejected there. Run it withastralis runin a terminal.- Read mods before installing them. Nothing contains them.
astralis doctorpasses, including the sandbox check.- Every tool has a rule you can explain in one sentence, and no
**on a write path unless you meant it. maxTurnsis set low, and the agent's output schema is strict.- There is an
approvestep before anything irreversible, and you run it from a terminal. - The workspace is a copy or a clean git checkout, so a bad run is one
git checkoutaway from undone. - You know where the journal will be written.
Debugging and troubleshooting#
Every run leaves a journal, and four commands read it. When something goes wrong you rarely need more than the journal and the error class.
Reading a failure#
When a run fails, the first line is the full message, then which step failed, then the details the failing code attached, then the chain of causes. Read top to bottom: the message tells you what, the step tells you where, the details tell you with which values.
error: Step 1 (infer "classify") failed: Output of model faux/faux-1 does not match its schema: /category: must be equal to constant; /category: must be equal to constant; /category: must be equal to constant; /category: must match a schema in anyOf
failing step: 1 (infer "classify")
details: {
"what": "Output of model faux/faux-1",
"violations": [
"/category: must be equal to constant",
"/category: must be equal to constant",
"/category: must be equal to constant",
"/category: must match a schema in anyOf"
],
"raw": {
"category": "urgent",
"reason": "It sounds important."
}
}
caused by: SchemaValidationError: Output of model faux/faux-1 does not match its schema: /category: must be equal to constant; /category: must be equal to constant; /category: must be equal to constant; /category: must match a schema in anyOf
journal: .astralis/runs/<run-id>/journal.jsonlerror:the whole message, never shortened.failing step:id, kind and name. Step 1 is the first thing the program did.details:for a schema failure,rawis exactly what the model sent, andviolationslists each mismatch (a union of literals reports once per option).caused by:the original error class. The error you catch in code is aStepError; this line is its.cause.journal:where to look next.
Reading a journal#
1 - run_start pipeline
2 1 step_start infer classify
3 1 inference_request faux/faux-1 108a8e1b31b0
4 1 inference_response faux/faux-1 stop=toolUse
5 1 step_error Step 1 (infer "classify") failed: Output of model faux/faux-1 does not match its schema: /category:…
6 - run_error Step 1 (infer "classify") failed: Output of model faux/faux-1 does not match its schema: /category:…Columns: event number, step id (- for run-level events), event type, a short summary. The failed run above stops at step_error, then run_error. The full data of every event is in the file; the all-event reference is in the journal events table.
jq -c 'select(.type=="step_error")' journal.jsonl
jq -c 'select(.stepId=="2")' journal.jsonl
jq -s '[.[] | select(.type=="inference_response") | .data.usage.totalTokens] | add' journal.jsonlastralis report#
Writes one self-contained HTML page: run status, duration and token totals by model, then the step tree. Failed steps open by default; each step expands to its prompts, responses, tool calls and policy refusals. A filter box and a "failed steps only" switch cut it down. It makes no network requests, so you can attach it to a ticket. It contains everything in the journal, secrets in prompts included.
astralis diff#
Did two runs do the same thing? diff lines the steps up by position and then checks, step by step, whether the model got the same request and gave the same answer.
same path, different results: 1 difference(s)
first divergence at step 2: Step 2, model call 0: same request, different response.
[different_response] step 2: Step 2, model call 0: same request, different response.| Difference kind | Meaning |
|---|---|
different_step | At some position the runs started different steps (kind, name or id). The paths split here. |
different_request | Same step, but the model, or its request hash, differs (or one run made fewer calls). The model's input changed. |
different_response | Same request, different response. This is the model, not your program. |
different_outcome | Tool calls or results differ, or one step failed where the other succeeded. |
Exit code 0 means identical, 1 means different, 2 means the comparison could not be made. A journal written with a different request hash version is refused with a ConfigError (re-run to get a comparable one). The workflow's own input, output and timing are not compared.
astralis doctor, check by check#
| Check id | What it checks | Fails or warns when | Fix |
|---|---|---|---|
node | The running Node.js against the engines field of the package. | Node is older than the required minimum (>=22.18.0). | Install a newer Node. Workflow files are TypeScript loaded by Node's own type stripping, so use a release where that is enabled by default. |
config | That astralis.config.json (or --config) exists, is JSON and passes the strict schema. | File missing, unreadable, not valid JSON, an unknown key, a wrong type, or a provider id containing /. | Fix what the message names, copy astralis.config.example.json, or run astralis init <template> for a working one. |
provider.<id>.key | That the variable named by the provider's apiKeyEnv is set. Only whether it is set is reported, never the value. | apiKeyEnv is configured and the variable is empty or unset. | export the variable in the shell that runs astralis. Keyless providers (no apiKeyEnv) always pass. |
provider.<id>.endpoint | GET <baseUrl>/models with a 5 second timeout; that the response is an OpenAI model list and offers every model you configured. | Connection refused or timed out, HTTP error (401/403 mean a bad key), or a configured model id is not in the server's list. A body that is not a model list is a warning. | Start the server, correct baseUrl, or pull or load the missing model. With --offline this check is skipped and reported as a warning. |
default-model | That defaultModel is provider/model-id and names a configured model, or a model in Pi's built-in catalog when builtinProviders is true. | Not of the form provider/model-id, the provider is not configured and built-ins are off, the model is not offered, or it is not in the catalog. No defaultModel at all is a warning. | Fix the spelling, add the provider, or set builtinProviders to true. |
sandbox | Runs echo inside the real bubblewrap sandbox, the code path the built-in bash tool from buildTools uses. | bwrap is not executable at /usr/bin/bwrap, or it cannot create a user namespace. | Install bubblewrap. If it is installed, allow unprivileged user namespaces (kernel.unprivileged_userns_clone=1, and not blocked by AppArmor or SELinux). A failure here fails the whole doctor run even if you never use bash. |
runs-dir | That the journal directory (.astralis/runs, or --runs-dir) exists and is writable, or can be created under an existing parent. The check itself creates nothing. | The directory or its nearest existing parent is not writable. | Fix the permissions or pass --runs-dir somewhere writable. |
Error reference#
Every error class the package exports. Catch AstralisError for any of them; details carries the values that matter. Remember the step rule: an error from a step arrives wrapped in a StepError, and the class below is its .cause.
| Class | What it means | Common causes | What to do |
|---|---|---|---|
AstralisErrorextends Error | Base class of every harness error. Carries a details object with the values needed to act on the failure. Also thrown directly for failures that have no more specific class. | An agent step reached its turn cap; a tool reported an error in a tool step or (default mode) an agent step; maxTurns or fanout concurrency is not a positive integer; submit_result is used as a tool name; duplicate tool names; an event is recorded for a step that already ended; an onStepError hook threw. | Read error.message first, then error.details. Catch this class when you want any harness failure and nothing else. |
NoActiveStepErrorextends AstralisError | A model call was attempted outside a workflow step. This is the "one door" rule failing loudly. | Calling gateway.structured, gateway.complete or gateway.streamFn directly from workflow code, from a timer, or from a closure kept after its step ended. | Move the call into ctx.infer or ctx.agent. If you need a model call in your own code, wrap it in a step. |
GatewayErrorextends AstralisError | The model call itself failed, or the request could not be made. | The server is unreachable or answered an error; the model reference is not provider/model-id or names an unknown model or provider; the stop reason was error or aborted; the model hit its output limit (length) before finishing a result; typed output is not supported for the model's API; a stream function was used outside the step that created it. | Run astralis doctor. Check details.model. For a length stop raise maxTokens or shorten the prompt. The provider's own message is in error.message and error.cause. |
SchemaValidationErrorextends AstralisError | A value did not match its schema. details.raw holds the offending value and details.violations every mismatch. | The model's answer does not fit the output schema (or it did not call the result tool exactly once, or sent malformed JSON); the workflow's input or output is wrong; tool arguments do not match the tool's schema; a stored memory value no longer matches its schema; an agent step ended without submitting a result. | Open the journal and read the inference_response for the step. Loosen the schema, improve the prompt, use a stronger model, or retry in code with an explicit cap (see the cookbook). |
ConfigErrorextends AstralisError | Something you configured is wrong. It is never a model or network failure. | Config file missing, not JSON, or failing the strict schema; a provider's apiKeyEnv variable is not set; a tool step names a tool that is not in its list; a tool policy has an empty rule list or a malformed rule; buildTools is given a policy that does not constrain a file tool or bash; bubblewrap is missing; a skill is not found, ambiguous or does not match its pinned hash; a memory key or directory is invalid or a symlink; a workflow file cannot be imported or has no default defineWorkflow export; astralis init would overwrite a file; an MCP server is misconfigured; a journal line is not valid. | The message names the file, key or variable. Fix it and run again. astralis doctor catches most of these before a run. |
PolicyErrorextends AstralisError | A tool call was refused, or a tool was used that is not behind the policy. | The arguments failed a rule (path outside the root, command not on the allowlist); the model called a tool outside the step's allowlist; a tool was passed to ctx.agent or ctx.tool without going through buildTools or withPolicy; a sandboxed command's working directory is outside the workspace; a chat prompt hit the turn cap in astralis tui. | Read details.reason and details.args. Widen the rule only if the call was legitimate; a refusal that shows a model trying something odd is the policy working. A tool_blocked event is in the journal. |
StepErrorextends AstralisError | A step failed. It wraps the original error as cause and records stepId, stepKind and stepName. | Any failure inside any step. Every ctx.* call rejects with a StepError, and a failing child is wrapped again by each enclosing step (a worker, then its fanout). | Look at error.cause (and its cause) for the real class: error.cause instanceof PolicyError. The step id tells you where to look in the journal. |
FanoutErrorextends AstralisError | One or more workers of a fanout failed. details.failures lists every failed index in item order. | A worker's infer, agent, tool or any other step threw. The first worker to fail stops workers that have not started; workers already running finish. | The message lists every failed index with its message. error.cause is the first failure by index. Fix that item or handle it before the fanout. |
VerificationFailedErrorextends AstralisError | A verify step's verdict was fail. details.findings holds the verifier's reasons and details.claim the claim. | The verifier could not support the claim from the evidence, or the evidence was insufficient. | Treat it as a normal outcome, not a crash: catch the StepError, read the findings, and decide (escalate to a stronger model, ask a person, fail). |
ApprovalRejectedErrorextends AstralisError | An approve step was answered no, or there was nobody to ask. error.note holds the reason given. | A person typed n; the run had no approver (a non-interactive astralis run, or any run started with /run inside astralis tui, where no approver is wired in), so the built-in deny-all approver answered. | Run from a terminal to be asked, or pass an approver to runWorkflow. The approval event in the journal records who answered. |
ApprovalUnavailableErrorextends AstralisError | An approver could not get an answer at all. This is not a yes or a no, and no approval event is journaled. | The terminal approver's input stream ended (stdin closed or piped input ran out) before an answer was read. | Keep stdin open, or use a different approver. |
A step with no model named anywhere throws a plain Error ("names no model and the run has no default model"), which the step wraps in a StepError. Provider and network failures surface as GatewayError with the original as .cause.
Common messages#
A GatewayError. The message lists the models the provider does have. Check the spelling, that the provider is under providers in your config (or that builtinProviders is true), and that the model id is exactly what the server reports.
A built-in provider has no key. Export its environment variable (see the table in Models) in the shell that runs astralis. Doctor does not check built-in providers.
A ConfigError at startup: the config names apiKeyEnv: "Y" and it is not set. The harness checks early so a run does not fail halfway.
A ConfigError naming the path or the key. The schema is strict, so a misspelled key is rejected rather than ignored. Compare with astralis.config.example.json.
Install bubblewrap (the built-in bash tool needs it) and make sure unprivileged user namespaces are allowed. Run astralis doctor. There is no unsandboxed fallback by design.
A PolicyError; details.reason says which rule refused and details.args what was sent. The journal has a tool_blocked event. Decide whether the call was legitimate: if yes, widen that rule narrowly; if no, the policy did its job.
The model used all maxTurns turns without calling submit_result. Read its turns in the journal to see what it was doing. Usually the prompt is vague, the tools do not give it what it needs, or the model is too small. Raising the cap is rarely the fix.
Code kept a reference to a step (a timer, an un-awaited promise, a stored closure) and used it after the step finished. The harness refuses rather than let a late event contradict step_end. Await your work inside the step.
There was nobody to ask: stdin is not a terminal, or the run came from /run. Run astralis run from a terminal, or pass your own approver to runWorkflow.
A ConfigError naming the file. The module must export default defineWorkflow({...}). An import error inside the file (a missing package, a type-only import written as a value import, syntax that needs compiling) is the cause shown.
CLI reference#
Generated from the real command-line program when this page was built: the option lists below are the program's own help output, so they cannot drift from the code.
Usage: astralis [options] [command]
Run deterministic LLM workflows.
Options:
-h, --help display help for command
Commands:
run [options] <workflow-file> Run a workflow file (a TypeScript module whose default export is made with defineWorkflow).
tui [options] Interactive chat (Pi's TUI) under the harness: tool allowlist, turn cap, journaled prompts, /run for workflows.
journal <journal-file> Print a one-line-per-event view of a run journal.
doctor [options] Check the config, model endpoints, API key variables and the sandbox, with a fix for each problem.
templates List the starter projects that `astralis init` can create.
init <template> [dir] Create a starter project from a template. Never overwrites an existing file.
report [options] <journal-file> Write a self-contained HTML report of a run from its journal.
diff [options] <journal-a> <journal-b> Compare two runs: did they take the same path, and where did they first diverge?
help [command] display help for commandastralis run#
The workflow file must default-export a defineWorkflow() result. If a person is at the terminal (stdin is a TTY) ctx.approve asks there; otherwise every approval is rejected. Hooks and tools are code in your workflow file, not CLI options.
Usage: astralis run [options] <workflow-file>
Run a workflow file (a TypeScript module whose default export is made with
defineWorkflow).
Options:
--input <json> workflow input as JSON
--input-file <path> read the workflow input JSON from a file
--config <path> config file (default: astralis.config.json)
--model <provider/id> default model for steps that name none
--runs-dir <dir> where journals are written (default: .astralis/runs)
-h, --help display help for commandastralis run workflow.ts --input '{"text":"hello"}'
astralis run workflow.ts --input-file input.json --model local/qwen --runs-dir /tmp/runsExit codes: 0 on success (output JSON on stdout, journal path on stderr); 1 on any failure (message, failing step and details on stderr).
astralis tui#
Needs a config. Shell access is refused in chat mode. Mods run with full permissions.
Usage: astralis tui [options]
Interactive chat (Pi's TUI) under the harness: tool allowlist, turn cap,
journaled prompts, /run for workflows.
Options:
--config <path> config file (default: astralis.config.json)
--model <provider/id> chat model (default: defaultModel from the config)
--runs-dir <dir> where journals are written (default: .astralis/runs)
--no-mods do not load any mods
-h, --help display help for commandastralis tui
astralis tui --model anthropic/claude-sonnet-5-5 --no-modsExit codes: 0 after a normal quit; 1 if startup failed or an allowlisted tool turned out to be provided by a mod.
astralis journal#
One line per event: sequence number, step id, event type and a short summary. For the full data open the file or use astralis report.
Usage: astralis journal [options] <journal-file>
Print a one-line-per-event view of a run journal.
Options:
-h, --help display help for commandastralis journal .astralis/runs/<run-id>/journal.jsonlExit codes: 0, or 1 if the file cannot be read or a line is not JSON.
astralis doctor#
Every failing check prints a fix: line. See the doctor check table in the troubleshooting section.
Usage: astralis doctor [options]
Check the config, model endpoints, API key variables and the sandbox, with a fix
for each problem.
Options:
--config <path> config file (default: astralis.config.json)
--runs-dir <dir> where journals are written (default: .astralis/runs)
--offline skip checks that contact model endpoints
-h, --help display help for commandastralis doctor
astralis doctor --offline --config ./astralis.config.jsonExit codes: 0 when no check failed (warnings allowed); 1 otherwise.
astralis templates#
Lists the template names and one-line descriptions that astralis init accepts.
Usage: astralis templates [options]
List the starter projects that `astralis init` can create.
Options:
-h, --help display help for commandastralis templatesExit codes: 0.
astralis init#
Copies the template's files (everything except template.json). Never overwrites, and checks all files before writing any.
Usage: astralis init [options] <template> [dir]
Create a starter project from a template. Never overwrites an existing file.
Arguments:
template
dir target directory (default: ".")
Options:
-h, --help display help for commandastralis init pipeline my-project
astralis init coding-task .Exit codes: 0 on success; 1 if the template is unknown or any target file already exists (nothing is written in that case).
astralis report#
Writes one self-contained HTML page (no network needed) with the step tree, every model call and tool call, token totals and failures. Prints the output path.
Usage: astralis report [options] <journal-file>
Write a self-contained HTML report of a run from its journal.
Options:
-o, --out <file> output file (default: run-<runId>.html)
-h, --help display help for commandastralis report .astralis/runs/<run-id>/journal.jsonl -o run.htmlExit codes: 0, or 1 if the journal cannot be read.
astralis diff#
Compares step by step: same steps, same request hashes, same responses. It does not compare workflow input, output or timing.
Usage: astralis diff [options] <journal-a> <journal-b>
Compare two runs: did they take the same path, and where did they first diverge?
Options:
--json print the full comparison as JSON
-h, --help display help for commandastralis diff a/journal.jsonl b/journal.jsonl
astralis diff a/journal.jsonl b/journal.jsonl --jsonExit codes: 0 identical; 1 the runs differ; 2 the comparison itself failed (unreadable journal, or journals hashed with different hashVersion).
Journal event reference#
Every event type the harness writes, who writes it, and the fields of its data. Filter by typing a type or a field name.
Each journal line is a JSON object: seq (1, 2, 3 within the run), ts (ISO time), runId, type, an optional stepId and data. Step-scoped events carry the step's id; run-level events do not. After step_end or step_error for a step, nothing else is written for it.
| Event type | Written by | When | data fields |
|---|---|---|---|
run_startrun level (no stepId) | runWorkflow; the TUI session | Before the workflow's input is validated. |
|
run_endrun level (no stepId) | runWorkflow; the TUI session | The workflow returned and its output matched the output schema. |
|
run_errorrun level (no stepId) | runWorkflow | The input was invalid, the workflow threw, or its output did not match. |
|
step_starthas a stepId | every step | A step begins. Its id is allocated at call time. |
|
step_endhas a stepId | every step | The step finished successfully. Nothing more is written for that id after this. |
|
step_errorhas a stepId | every step | The step failed, including a failure of a hook that ran inside it. |
|
inference_requesthas a stepId | the gateway | Before every model call, with the exact context sent. |
|
inference_responsehas a stepId | the gateway | The model call returned (including a stop reason of error). |
|
inference_errorhas a stepId | the gateway | The provider call threw instead of returning. |
|
tool_callhas a stepId | tool step; agent step; TUI chat | A tool is about to run (after the policy allowed it, for chat). |
|
tool_resulthas a stepId | tool step; agent step; TUI chat | A tool returned. |
|
tool_blockedhas a stepId | withPolicy; the TUI | A call was refused by the policy and did not run. |
|
verificationhas a stepId | verify step | The verifier answered, before a failing verdict throws. |
|
memory_readhas a stepId | memoryRead step | A key was read. |
|
memory_writehas a stepId | memoryWrite step | After a value was validated and written. |
|
skill_attachedhas a stepId | renderSkills | A skill was rendered into a prompt while a step was active. Not written when renderSkills runs in workflow code outside a step. |
|
approvalhas a stepId | approve step | An approver answered, before a no throws. |
|
agent_lenient_modehas a stepId | agent step | An agent step started with onToolError: "report". |
|
prompthas a stepId | TUI chat | A chat prompt opened its bounded agent step. |
|
turn_endhas a stepId | TUI chat | A chat model turn completed. |
|
turn_caphas a stepId | TUI chat | A prompt reached tui.maxTurns while still calling tools; it is aborted. |
|
tool_source_violationstepId optional | TUI | An allowlisted tool name is provided by a mod instead of Pi's built-ins. Chat refuses to continue. |
|
tool_set_trimmedrun level (no stepId) | TUI | At session start, tools that Pi or a mod had activated beyond the allowlist were switched off. Expected when mods register tools; recorded so the journal shows it. |
|
tool_set_violationstepId optional | TUI | A tool outside the allowlist was active when it should not have been (checked at session start and per prompt). The active set is reset, the prompt fails and the process will exit non-zero. |
|
workflow_runrun level (no stepId) | TUI /run | A /run workflow finished, in the session's own journal. |
|
tool_call and tool_result name the tool tool when a tool step wrote them and name when an agent step or the chat mode did. The report and astralis diff accept both; code you write against journals must too.
Files, environment and API index#
What the harness reads and writes on disk, which environment it consults, and every name the package exports.
Files and directories#
| Path | What it is | Who writes it |
|---|---|---|
astralis.config.json | Providers, models and chat settings. Read from the current directory, or --config. | You (astralis init creates one) |
.astralis/runs/<run-id>/journal.jsonl | The journal of one run. Moved by --runs-dir or the runsDir and journal options. | The harness, on every run and chat session |
.astralis/agent/ | Pi's state for astralis tui: sessions, auth.json, settings. Never ~/.pi. | Pi, under the harness |
.astralis/mods/*.ts | Mods loaded by astralis tui. | You |
| Your memory directory | Whatever you pass to new MemoryStore(dir). The templates use .astralis/memory. | Your workflow, through memoryWrite |
| Your skills directories | The directories you pass to loadSkill. There is no default location. | You |
templates/ | The starter projects inside the package. | The package |
Environment#
- Every
apiKeyEnvnamed in your config, read when the gateway is created. - The provider variables Pi reads for
builtinProviders(table in Models). - Sandboxed commands see only
PATH,LANG,LC_ALLandTERMfrom your environment, plusHOMEset to the workspace, unlessenvAllowandenvsay otherwise. - MCP stdio servers inherit nothing; they get only the
envin their config.
API index#
Everything exported from astralis-harness, listed from the package's own entry point. The kind column is a guess from the name; follow the links in each section for the shapes.
Export of astralis-harness | Kind (approximate) |
|---|---|
AfterStepHandler | type or class |
AgentSpec | type or class |
AgentStreamFn | type or class |
AgentStreamOptions | type or class |
ApprovalDecision | type or class |
ApprovalRejectedError | error class |
ApprovalRequest | type or class |
ApprovalUnavailableError | error class |
ApproveSpec | type or class |
Approver | type or class |
ArgRule | type or class |
AstralisError | error class |
BeforeStepHandler | type or class |
BuiltinToolName | type or class |
CallOptions | type or class |
CommandRule | type or class |
ConfigError | error class |
DEFAULT_CONFIG_FILE | type or class |
FanoutError | error class |
FanoutFailure | type or class |
FanoutSpec | type or class |
FileJournal | type or class |
Gateway | type or class |
GatewayError | error class |
HASH_VERSION | type or class |
HarnessConfig | type or class |
HarnessTool | type or class |
HookRegistry | type or class |
InferSpec | type or class |
Journal | type or class |
JournalEvent | type or class |
LoadedSkill | type or class |
McpConnectOptions | type or class |
McpConnection | type or class |
McpServers | type or class |
MemoryJournal | type or class |
MemoryReadSpec | type or class |
MemoryStore | type or class |
MemoryWriteSpec | type or class |
NoActiveStepError | error class |
PathRule | type or class |
PatternRule | type or class |
PolicyDecision | type or class |
PolicyError | error class |
RESULT_TOOL | type or class |
RuleBasedToolPolicy | type or class |
Run | type or class |
RunOptions | type or class |
RunResult | type or class |
SandboxConfig | type or class |
SchemaValidationError | error class |
Static | type or class |
StepError | error class |
StepErrorHandler | type or class |
StepInfo | type or class |
StepScope | type or class |
StructuredRequest | type or class |
TSchema | type or class |
TerminalApprover | type or class |
ToolPolicy | type or class |
ToolPolicySpec | type or class |
ToolSetSpec | type or class |
ToolSpec | type or class |
ToolStepResult | type or class |
Type | type or class |
UnrestrictedRule | type or class |
VerificationFailedError | error class |
VerifySpec | type or class |
Workflow | type or class |
WorkflowContext | type or class |
assertBwrapAvailable | function or value |
buildTools | function or value |
connectMcp | function or value |
createGatewayFromConfig | function or value |
createToolPolicy | function or value |
defineWorkflow | function or value |
denyAllApprover | function or value |
describeError | error class |
hashValue | function or value |
loadConfig | function or value |
loadSkill | function or value |
parseConfig | function or value |
renderSkills | function or value |
resolveWorkspace | function or value |
runWorkflow | function or value |
stepSequence | function or value |
terminalApprover | function or value |
validate | function or value |
withPolicy | function or value |
Glossary#
The words this documentation uses, in plain language.
- Agent step
- The one step kind where the model picks its own moves: which allowed tool to call next. Bounded by a turn cap and a tool allowlist.
- Approver
- Whatever answers
ctx.approve: a terminal prompt, a dialog, your own object with anidand anaskmethod. With none, every approval is rejected. - Bubblewrap (bwrap)
- A Linux tool that runs a command in a restricted namespace. The harness runs every call of the built-in
bashtool (frombuildTools) inside it; a tool you supply yourself is not sandboxed. - Deny by default
- A tool, path or command that no rule allows is refused. Nothing is allowed because nobody forbade it.
- Fanout
- A step that runs a worker function over a list with a concurrency limit. Each worker is its own step with a predictable id.
- Gateway
- The single object in the harness that calls a model. It refuses to do so outside a step, journals every request and response, and never retries.
- Hook
- A function you register to observe step boundaries (before, after, on error). Hooks cannot change inputs or results; they can throw to fail the step.
- Journal
- The append-only JSONL file of everything that happened in a run, one event per line.
.astralis/runs/<run-id>/journal.jsonl. - MCP
- Model Context Protocol. A way to expose tools from another process. The harness can connect to MCP servers and hand their tools to steps behind a policy.
- Memory
- A directory of JSON files, one per key, that persists between runs. Read and written through
memoryReadandmemoryWritesteps. - Mod
- A TypeScript Pi extension loaded by
astralis tui. It is ordinary code in the harness process with full permissions. - Policy
- A set of rules created with
createToolPolicythat says which tools may run and what their arguments may look like. - Provider
- A named endpoint that serves models. Yours are OpenAI-compatible; Pi's built-in ones come with
builtinProviders. - Request hash
- A sha256 over everything that determines a model request. Two runs with the same hash sent the same request.
- Run
- One execution of a workflow. It has an id, a journal, and a tree of steps.
- Sandbox
- The bubblewrap configuration the built-in
bashtool (frombuildTools) runs under: only the workspace is writable, no network unless you turn it on, your home directory is hidden. - Skill
- A SKILL.md file of instructions, found in a directory you name, optionally pinned by its sha256 and attached to a prompt in full.
- Step
- One journaled unit of work with a kind, a name and an id. Steps nest; ids look like
2.1.3. - Step id
- Dot-separated counters assigned in call order (
1,2,2.1). Stable across runs of the same code. - Structured output
- A model answer forced into a schema by making the model call a single result tool whose parameters are the schema.
- Template
- A starter project copied by
astralis init: a workflow, a config and sometimes a skill. - Verify
- A step in which a model tries to refute a claim against evidence. A failing verdict stops the run.
- Workflow
- A TypeScript program made with
defineWorkflow: a name, an input schema, an output schema and arunfunction. - Workspace
- The directory a sandboxed command can write to, and the root that file-path rules confine file tools to.
Limits and security model#
What the harness protects you from, what it leaves to you, and what is not built yet. Read this before you rely on it for anything that can cause damage.
The model in one table#
| What you worry about | What the harness does | What remains your job |
|---|---|---|
| The model decides what runs next | Control flow is your code. Only agent lets the model pick tools, inside an allowlist and a turn cap. | Keep agent steps small and their tools narrow. |
| A model call you did not know about | The gateway refuses to call a model outside a step and journals every call. | Code in your own process can still import the Pi packages and call a model. The gateway is a rule for the harness's steps, not a sandbox. |
| The model touches files it should not | Deny-by-default policy, path rules with symlink resolution, and a refusal of unconstrained file tools. | The file tools run in your process. For read, write, edit and ls the opened file is re-checked against the rule (Linux, /proc); for grep and find a symlink swapped in between check and use is not defended against. |
| The model runs a command it should not | Command rules narrow what may be attempted; every command of the built-in bash tool runs inside bubblewrap with no network by default (a caller-supplied tool named bash is not sandboxed by the harness). | Allowed commands still do what they do inside the workspace. There is no CPU, memory or disk limit and no syscall filter. With network: true it shares the host network, loopback services included. |
| A change nobody approved | approve stops the run, and a missing approver means no. | Put it before anything you cannot undo. |
| Silent changes to instructions | Skills can be pinned by hash. | Pin them, and review hash changes. |
| Not knowing what happened | An append-only journal, a report, and a diff. | The journal is a plain file anyone with write access can edit, so it is a record, not evidence. It also contains your prompts and tool output. |
| Third-party code in chat mode | Mods are listed at startup, can be disabled, and a mod that takes over an allowlisted tool name stops the session. | Mods are full-permission code in the process. Read them; use a container or VM if you must run code you do not trust. |
Limits worth knowing#
- Linux only for the shell and the confined file tools. The sandbox needs
/usr/bin/bwrapand unprivileged user namespaces, the file tools need/proc; without thembashdoes not run andastralis doctorfails. - Credential scrubbing is exact-match only. The gateway replaces the values of your
apiKeyEnvvariables (and, withbuiltinProviders, any environment value whose name ends inAPI_KEY,TOKENorSECRET) with[redacted secret]in journals and CLI errors. Values shorter than 8 characters, encoded forms such as base64, and secrets you put in a prompt yourself are not recognised. - Your own providers are OpenAI chat endpoints, text only. No custom headers, no image input, no per-model tool-calling flag. Everything else comes through Pi's built-in providers.
- Typed output depends on tool calling. A model or server without it cannot run
infer,verifyoragent. There is no JSON-mode fallback. - No retries, no fallbacks, no budget. Every one is something you write, which is why each is visible.
- Memory has no locking and no history. Last write wins.
- MCP servers using OAuth are not supported. Their tools are not sandboxed; they run wherever the server runs, under the policy you wrote.
- Determinism covers the program, not the model. See Concepts.
- Chat journals lack model requests. Chat goes through Pi's session; only
/runworkflows are fully journaled. - TypeScript is run by Node's type stripping. Use a Node release where that is enabled by default;
astralis doctoronly checks the minimum version inengines. - The request hash is not a signature. It shows two requests were equal, not who made them.
Planned, not built#
They are listed so you can plan around their absence, not because they work.
- Replay of a run from its journal.
- Resume of a failed run where it stopped. Today, make work restartable yourself with memory (see the cookbook).
- A built-in token budget cap. Today, count tokens from the journal in a hook (see the cookbook).