Overview
AstralisHarness documentation

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.

v0.1.0Node >=22.18.0Pi SDK 1.5.2MITno network needed to read this page
Step kinds8
CLI commands8
Journal events25
Error classes11
Templates4

How it fits together#

In plain words

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.

How a workflow reaches a model A workflow program, written in TypeScript, calls typed steps. Every step that needs a model goes through the gateway, the one door, to the models. Steps and the gateway write to the journal. A tool policy and sandbox sit beside the steps that use tools. Workflow program your TypeScript loops, ifs, functions Typed steps infer, agent, tool, fanout, verify, approve, memory Gateway the one door only works in a step Models local servers hosted providers Journal every step, request, response and tool call, one JSON line each Tool policy and sandbox deny by default
The one door

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 agent step, 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 bash tool from buildTools exists only inside bubblewrap, and there is no unsandboxed switch. (A tool you write yourself and name bash is 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.
Start here

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.

About the command name

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
in your projectsh
npm install https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgz
npx astralis --help
pnpm
in your projectsh
pnpm add https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgz
pnpm exec astralis --help
yarn
in your projectsh
yarn add https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgz
yarn astralis --help
bun
in your projectsh
bun add https://github.com/astralisone/astralis-harness/releases/download/v0.1.0/astralis-harness-0.1.0.tgz
bunx astralis --help

Bun installs the package; the CLI itself is a Node program (#!/usr/bin/env node) and needs Node on the machine.

From a checkout
in the repositorysh
pnpm install
pnpm build
node dist/cli/main.js --help

Use 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#

Create, enter, installsh
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.tgz
astralis templatesoutput
coding-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.
astralis init pipeline my-projectoutput
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:

my-project/astralis.config.json (as created, not runnable until you edit it)json
{
	"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.

  1. Change baseUrl if your server is not Ollama on its default port.
  2. Replace both occurrences of EXAMPLE-replace-with-a-model-you-have-pulled with a model your server really has, for example qwen2.5-coder:7b after ollama pull qwen2.5-coder:7b. The first is defaultModel (local/<id>), the second is the model's id.

3. Check the setup#

Terminalsh
npx astralis doctor
Example output (endpoint, Node version and bubblewrap stubbed for the docs build): astralis doctoroutput
[ 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:

Example output (endpoint, Node version and bubblewrap stubbed for the docs build): astralis doctor, with the server stopped and a key variable unsetoutput
[ 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#

Terminalsh
npx astralis run workflow.ts --input '{"text":"The export button crashes when the list is empty."}'
astralis run workflow.ts --input '{...}' (stdout, then stderr)output
{
  "category": "bug",
  "summary": "The export button crashes when the list is empty."
}
journal: .astralis/runs/<run-id>/journal.jsonl

The 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#

Terminalsh
npx astralis journal .astralis/runs/<run-id>/journal.jsonl
npx astralis report  .astralis/runs/<run-id>/journal.jsonl -o run.html
astralis journal <journal-file>output
   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              pipeline

report 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.

You are done when

You have a journal.jsonl with a run_start and a run_end, your JSON on stdout, and a report you can open. Next: Concepts explains what you just ran, and Templates has three more starting points.

Understand

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#

In plain words

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.

The precise rule

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.

docs/site/examples/concepts.tsts
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.

StepOne line
ctx.inferOne model call whose answer is forced into a schema.
ctx.agentA bounded loop where the model chooses among tools you allowed, until it submits a result.
ctx.toolRuns exactly one named tool with arguments you give. No model.
ctx.fanoutThe same worker over a list, with a concurrency limit and fixed step ids.
ctx.verifyA model tries to refute a claim; a failed verdict throws.
ctx.approveStops for a person's yes or no.
ctx.memoryRead, ctx.memoryWriteValidated 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#

In plain words

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.

The precise rule

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.

docs/site/examples/concepts.tsts
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.

What the door is not

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#

In plain words

"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.

The precise rule

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.

astralis diff <run-a> <run-b>output
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.

Keep your own code repeatable too

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#

In plain words

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.

The precise rule

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.

the first lines of journal.jsonloutput
{"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.

Build

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...UseModel call?
Get one typed answer from the modelinferone
Let the model explore with tools, then answeragentup to maxTurns
Run one tool with known argumentstoolnone
Do the same job for every item in a listfanoutwhatever the workers do
Have a model check a claim against evidenceverifyone
Ask a person before going onapprovenone
Keep notes between runsmemoryRead / memoryWritenone
One rule for every step: failures arrive as StepError

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

infer#

In plain words

Ask the model one question and get back a filled-in form. The model cannot use tools and cannot decide what happens next.

The precise rule

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.

FieldTypeWhat it does
namestringrequiredStable name for this step; appears in the journal and in errors.
promptstringrequired
outputSrequired
systemstringoptional
modelstringoptional
temperaturenumberoptional
maxTokensnumberoptional
reasoning"high" | "low" | "max" | "medium" | "minimal" | "xhigh"optional
signalAbortSignaloptional

How it fails

Cause classWhen
GatewayErrorThe 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.
SchemaValidationErrorThe 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.

docs/site/examples/step-infer.tsts
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

agent#

In plain words

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.

The precise rule

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.

FieldTypeWhat it does
namestringrequiredStable name for this step; appears in the journal and in errors.
promptstringrequired
outputSrequired
systemstringoptional
modelstringoptional
toolsHarnessTool[]requiredTools 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"optionalWhat 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.
maxTurnsnumberrequiredHard cap on model turns (assistant messages). Reaching it without a result fails the step.
reasoning"high" | "low" | "max" | "medium" | "minimal" | "xhigh"optional
signalAbortSignaloptional

How it fails

Cause classWhen
PolicyErrorA tool is not policy-wrapped; the model called a tool outside the allowlist; a policy refused a call (tool_blocked is journaled).
AstralisErrormaxTurns 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.
SchemaValidationErrorThe model stopped without submitting; submitted twice or alongside other tool calls (none of them run); malformed JSON; the result does not fit output.
GatewayErrorA 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.

docs/site/examples/step-agent.tsts
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

tool#

In plain words

"Run this tool with these arguments." No model decides anything; it is a function call that is policy-checked and journaled.

The precise rule

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.

FieldTypeWhat it does
namestringrequiredName of the tool to run; it must be one of tools.
argsRecord<string, unknown>requiredMust match the tool's parameter schema exactly.
toolsreadonly HarnessTool[]requiredThe tools this step may use, already behind the policy gate (see buildTools).
signalAbortSignaloptional

How it fails

Cause classWhen
ConfigErrorThe named tool is not in tools (the available names are in details.available).
PolicyErrorThe tool is not policy-wrapped, or the policy refused these arguments.
SchemaValidationErrorThe arguments do not match the tool's schema.
AstralisErrorThe 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.

docs/site/examples/step-tool.tsts
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

fanout#

In plain words

Do the same job for each thing in a list, a few at a time, and collect the answers in the original order.

The precise rule

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.

FieldTypeWhat it does
namestringrequiredStable name for this step; appears in the journal and in errors.
itemsreadonly T[]required
worker(item: T, index: number) => Promise<R>requiredRuns inside its own "worker" step, so infer/agent/tool steps it starts nest under it.
concurrencynumberrequiredMaximum workers running at once. Required: the program decides parallelism.

How it fails

Cause classWhen
AstralisErrorconcurrency is not a positive integer.
FanoutErrorOne 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, ...).

docs/site/examples/step-fanout.tsts
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

verify#

In plain words

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.

The precise rule

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.

FieldTypeWhat it does
namestringrequiredStable name for this step; appears in the journal and in errors.
claimstringrequiredWhat is asserted to be true.
evidencestringrequiredThe material to check the claim against.
instructionsstringoptionalExtra checking guidance appended to the prompt.
modelstringoptionalPass a stronger model than the one that produced the work.
reasoning"high" | "low" | "max" | "medium" | "minimal" | "xhigh"optional

How it fails

Cause classWhen
VerificationFailedErrorThe verdict was fail. details.findings and details.claim are set.
GatewayError, SchemaValidationErrorAs for infer.

Journal

The inference events, then verification with name, verdict and findings.

docs/site/examples/step-verify.tsts
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

approve#

In plain words

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 precise rule

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.

FieldTypeWhat it does
namestringrequiredStable name for this step; appears in the journal and in errors.
questionstringrequired
detailsstringoptional

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 classWhen
ApprovalRejectedErrorThe answer was no, or no approver was available. error.note has the reason.
ApprovalUnavailableErrorThe 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.

docs/site/examples/step-approve.tsts
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 / memoryWrite

memoryRead and memoryWrite#

In plain words

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.

The precise rule

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

FieldTypeWhat it does
namestringrequired
storeMemoryStorerequired
keystringrequired
schemaSrequired

memoryWrite

FieldTypeWhat it does
namestringrequired
storeMemoryStorerequired
keystringrequired
valueStatic<S>required
schemaSrequired

How it fails

Cause classWhen
ConfigErrorInvalid key; the store directory or key file is a symlink; the file is not valid JSON; the value is not JSON-serializable.
SchemaValidationErrorThe 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.

docs/site/examples/step-memory.tsts
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 };
	},
});
Build

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#

In plain words

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.

The precise rule

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.

FieldTypeWhat it does
cwdstringrequiredRoot 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).
builtinreadonly BuiltinToolName[]optionalWhich of Pi's built-in tools the step gets.
mcp{ connection: McpConnection; allow: readonly string[]; }optionalMCP tools by their mcp__<server>__<tool> names, from a live connection.
policyRuleBasedToolPolicyrequiredEvery 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).
sandboxSandboxConfigoptionalRequired 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#

KindUse it forHow it decides
pathFile 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.
commandbashThe whole string must equal one of commands or match one of patterns, and may contain only conservative characters.
patternAny other string argumentThe whole value must match at least one regular expression.
unrestrictedTools 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

FieldTypeWhat it does
kind"path"required
argstringrequired
rootstringrequiredAbsolute directory. Must be the tool's working directory when used with buildTools.
allowreadonly string[]requiredGlobs relative to root. * stays inside one directory, ** crosses directories.
defaultstringoptionalWhat 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

FieldTypeWhat it does
kind"command"required
argstringrequired
commandsreadonly string[]optionalExact, whole-string matches.
patternsreadonly RegExp[]optionalRegular expressions that must match the entire command.
extraCharsstringoptionalExtra permitted characters, never any of ; & | < > $ ( ) { } \ ` or a newline. Opt in sparingly.

PatternRule

FieldTypeWhat it does
kind"pattern"required
argstringrequired
patternsreadonly RegExp[]required

Safe and unsafe, side by side#

DO · exact commands and fully matched patterns

The rule lists what may run. Anything else, and anything with a shell operator, quote, $, backtick, newline or glob character, is refused.

docs/site/examples/policy-rules.tsts
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))?/],
			},
		],
	},
});
DON'T · a pattern that admits arbitrary arguments

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.

docs/site/examples/policy-rules.tsts
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

In plain words

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.

The precise rule

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):

the removed form, and what the policy says todaytext
// 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"' }
what a command that does match looks liketext
safeBash.check("bash", { command: "git diff --stat" })   // { allowed: true }
safeBash.check("bash", { command: "npm test; rm -rf ." })  // refused: ";" is not a permitted character
DO · confine writes to where they belong
docs/site/examples/policy-rules.tsts
export 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: "." }],
	},
});
DON'T · write rooted at the workspace with **

Valid, and sometimes right, but it lets the model overwrite anything in the workspace, including your config and the workflow itself.

docs/site/examples/policy-rules.tsts
export const writesEverywhere = createToolPolicy({
	allow: { write: [{ kind: "path", arg: "path", root: workspace, allow: ["**"] }] },
});
DO · say unrestricted out loud

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.

docs/site/examples/policy-rules.tsts
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" }],
	},
});
DON'T · an empty rule list

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.

docs/site/examples/policy-rules.tsts
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#

RefusedWhy
Absolute paths outside the root, and .. that climbs outResolved against the root, then compared.
A symlink inside the root that leads out; a new file below a symlinked or dangling parentThe deepest existing ancestor is resolved to its real path first.
~, a leading @, file: URLs, non-ASCII spaces, an empty path, a NULPi's file tools expand these specially, so the tool would open a different file than the one checked.
A path that matches none of allowGlobs are relative to the root: * stays in one directory, ** crosses directories.
Which tools are pinned to the check, and which are not

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#

ToolArgumentsWhat buildTools requires
readpath, offset?, limit?a path rule on path
editpath, edits[] (oldText, newText)a path rule on path
writepath, contenta path rule on path
greppattern, path?, glob?, ignoreCase?, literal?, context?, limit?a path rule on path; add default: "." because path is optional
findpattern, path?, limit?a path rule on path, with default: "."
lspath?, limit?a path rule on path, with default: "."
bashcommand, 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#

In plain words

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.

The precise rule

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.

FieldTypeWhat it does
workspacestringrequiredMounted 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.
networkbooleanoptionalOff 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.
envAllowreadonly string[]optionalEnvironment variables copied in. Nothing else is inherited. Default: PATH, LANG, LC_ALL, TERM.
envReadonly<Record<string, string>>optionalExtra values set inside, after the allowlist (for example a token meant for the sandbox).
bwrapPathstringoptionalPath of the bwrap binary. Default: /usr/bin/bwrap.
The sandbox doesThe sandbox does not
Make everything but the workspace read-onlyHide 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 /tmpProtect the workspace itself: a command can delete or overwrite anything in it
Cut the network by defaultLimit 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 abortLimit 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 listCover 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.

docs/site/examples/tools-mcp.tsts
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 call
policy.tsts

The 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, edit and ls it is re-verified at open time, for grep and find it 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: true can reach everything your host can reach, including loopback services.
  • Mods in astralis tui are not subject to any of this; see interactive mode.
Build

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#

In plain words

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:

astralis.config.example.jsonjson
{
	"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.

KeyTypeDefaultMeaning
defaultModelstring
min length 1
optionalnone (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.
builtinProvidersbooleanrequiredrequiredRegister Pi's catalog of frontier providers next to your own. Their API keys come from the environment variables Pi reads (table below).
providersobject (name to object)requiredrequiredYour own OpenAI-compatible endpoints, by provider id. May be empty when builtinProviders is true.
providers.<id>objectrequiredn/aOne 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>.baseUrlstring
min length 1
requiredrequiredBase 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>.apiKeyEnvstring
min length 1
optionalabsent: the endpoint is keylessName 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>.modelsarray of object
at least 1 item
requiredrequiredThe models the server offers that you want to use.
providers.<id>.models[]objectrequiredn/aOne model.
providers.<id>.models[].idstring
min length 1
requiredrequiredThe model id the server expects in the request, and the second half of provider/model-id.
providers.<id>.models[].namestring
min length 1
optionalthe idDisplay name.
providers.<id>.models[].contextWindowinteger
minimum 1
requiredrequiredContext size in tokens. Informs Pi; it is not enforced by the harness.
providers.<id>.models[].maxTokensinteger
minimum 1
requiredrequiredMaximum output tokens Pi will request.
providers.<id>.models[].reasoningbooleanoptionalfalseThe model produces reasoning output.
providers.<id>.models[].supportsDeveloperRolebooleanoptionalfalseSend the system prompt with the developer role. Many OpenAI-compatible servers reject it, so it is off.
providers.<id>.models[].supportsReasoningEffortbooleanoptionalfalseSend reasoning_effort when a step sets reasoning. Many servers reject it, so it is off.
providers.<id>.models[].samplingParamsobject (name to any value)optionalnoneExtra fields merged into every request body for this model (top_p, top_k, min_p, ...). The values are part of the request hash.
tuiobjectoptionalall defaults belowSettings for astralis tui. Every field is optional.
tui.maxTurnsinteger
minimum 1
optional20Model turns allowed per chat prompt before the harness aborts it.
tui.toolsarray of stringoptionalread, ls, grep, findPi 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.modsarray of stringoptionalnone (plus anything in .astralis/mods/)Extra mod files (TypeScript Pi extensions), resolved against the project directory. Mods run with full process permissions.
tui.themestring
min length 1
optionalPi's own defaultPi theme name.
tui.loadContextFilesbooleanoptionalfalseLoad AGENTS.md / CLAUDE.md style context files into the chat prompt.
What a configured model can and cannot say

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 download
astralis.config.jsonjson

The 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
Start it and pull a modelsh
ollama serve
ollama pull qwen2.5-coder:7b
astralis.config.jsonjson
{
	"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.cpp server
Start it (flags vary by build)sh
llama-server -m ./model.gguf --port 8080 --jinja -c 32768
astralis.config.jsonjson
{
	"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
Start itsh
vllm serve Qwen/Qwen2.5-7B-Instruct --enable-auto-tool-choice --tool-call-parser hermes
astralis.config.jsonjson
{
	"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.

LiteLLM proxy
astralis.config.jsonjson
{
	"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.

Hosted, OpenAI-compatible
astralis.config.jsonjson
{
	"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 }
			]
		}
	}
}
Keys come from the environment, never the filesh
export HOSTED_API_KEY=...
astralis doctor

Replace 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#

In plain words

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.

astralis.config.jsonjson
{
	"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:

astralis.config.jsonjson
{
	"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 }
			]
		}
	}
}
docs/site/examples/models.tsts
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-lingANT_LING_API_KEY
qwen-token-planQWEN_TOKEN_PLAN_API_KEY
qwen-token-plan-cnQWEN_TOKEN_PLAN_CN_API_KEY
qwen-token-plan-individualQWEN_TOKEN_PLAN_API_KEY
qwen-token-plan-individual-cnQWEN_TOKEN_PLAN_CN_API_KEY
openaiOPENAI_API_KEY
azureAZURE_OPENAI_API_KEY
nvidiaNVIDIA_API_KEY
deepseekDEEPSEEK_API_KEY
googleGEMINI_API_KEY
google-vertexGOOGLE_CLOUD_API_KEY
groqGROQ_API_KEY
cerebrasCEREBRAS_API_KEY
xaiXAI_API_KEY
typesafeTYPESAFE_API_KEY
radiusRADIUS_API_KEY
openrouterOPENROUTER_API_KEY
vercel-ai-gatewayAI_GATEWAY_API_KEY
zaiZAI_API_KEY
zai-coding-cnZAI_CODING_CN_API_KEY
mistralMISTRAL_API_KEY
minimaxMINIMAX_API_KEY
minimax-cnMINIMAX_CN_API_KEY
moonshotaiMOONSHOT_API_KEY
moonshotai-cnMOONSHOT_API_KEY
huggingfaceHF_TOKEN
fireworksFIREWORKS_API_KEY
togetherTOGETHER_API_KEY
basetenBASETEN_API_KEY
opencodeOPENCODE_API_KEY
opencode-goOPENCODE_API_KEY
kimi-codingKIMI_API_KEY
metaMETA_API_KEY
cloudflare-workers-aiCLOUDFLARE_API_KEY
cloudflare-ai-gatewayCLOUDFLARE_API_KEY
xiaomiXIAOMI_API_KEY
xiaomi-token-plan-cnXIAOMI_TOKEN_PLAN_CN_API_KEY
xiaomi-token-plan-amsXIAOMI_TOKEN_PLAN_AMS_API_KEY
xiaomi-token-plan-sgpXIAOMI_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.

astralis doctor does not check built-in providers

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#

In plain words

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.

The precise rule

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.

SymptomLikely causeWhat to do
SchemaValidationError: "was required to call submit_result exactly once but called it 0 times", with prose in details.rawThe 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 pathsThe 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_effortThe server rejects fields the harness would send.Leave supportsDeveloperRole and supportsReasoningEffort off (the default).
If the model really cannot call tools

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.

Build

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#

In plain words

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.

The precise rule

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#

PatternShapeWhen it fits
Map, then reducefanout of infer, then one infer mergesMany documents, one overview. The map-reduce template is exactly this.
Map, then verifyfanout of cheap workers, then one verify with a stronger modelCheap breadth, expensive judgement once.
Agent per itemfanout of agentEach item needs exploring with tools (one module, one ticket).
PipelineSequential steps, each reading the previous typed resultThe next question depends on the last answer. This is plain code, no fanout needed.
Partial resultsA worker that catches its own failure and returns a markerOne 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.

docs/site/examples/subagents-agents.tsts
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 };
	},
});
Choosing concurrency

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#

In plain words

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.

The precise rule

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.

docs/site/examples/subagents.tsts
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#

StepModel callsWhat dominates the time and the billLevers
infer1Prompt size plus output sizeShort prompts, maxTokens, a smaller model
verify1Evidence size, and a stronger model's priceVerify the claims that matter; pass only the relevant evidence
agentUp to maxTurnsEvery turn re-sends a growing transcript, tool output includedSmall maxTurns, narrow tools, read with limit, a policy that keeps output small
fanoutSum of the workersItems times the worker's cost, divided by effective parallelismA cheap worker model, a sensible concurrency
tool, approve, memory0The tool, or the personUse 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.

Build

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#

In plain words

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.

The precise rule

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.

templates/research-with-memory/workflow.tsts
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 };
	},
});
templates/research-with-memory/skills/research-method/SKILL.mdtext
---
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.
Get the hash to pinsh
sha256sum skills/research-method/SKILL.md
Where the skill shows up in the journal

renderSkills 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#

In plain words

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.

The precise rule

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.

Hooks need your own runner

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

docs/site/examples/hooks.tsts
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

docs/site/examples/hooks.tsts
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

docs/site/examples/hooks.tsts
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#

In plain words

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.

The precise rule

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

docs/site/examples/memory-patterns.tsts
// 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

docs/site/examples/memory-patterns.tsts
// 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 version field 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 .gitignore line 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_read and memory_write is over the JSON text of the value, so the same data with keys in a different order hashes differently.
Use

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#

In plain words

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.

The precise rule
  • Tool allowlist. Default read, ls, grep, find. Set tui.tools to 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.
  • bash and powershell refused at startup. The error says to run shell commands from a workflow agent step, 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 agent step 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/extensions cannot load (the project is untrusted), and skills, prompt templates and AGENTS.md or CLAUDE.md files are not loaded unless tui.loadContextFiles is on.
  • What tui.tools may 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 a ConfigError, 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_violation or tool_set_violation, and the process exits 1. Tools Pi or a mod activated beyond the allowlist at startup are switched off and journaled as tool_set_trimmed.
What the chat journal does not contain

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#

CommandWhat 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.
/stepsLists the steps of the last /run with status and time, and its journal path.
/journalShows 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.

KeysAction
EscCancel or abort the current response
Ctrl CFirst press clears the editor, second press exits
Ctrl DExit when the editor is empty
Ctrl LOpen the model selector
Ctrl OCollapse or expand tool output
Ctrl TCollapse or expand thinking blocks
Shift TabCycle the thinking level
Alt EnterQueue a follow-up message
Ctrl GEdit 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 code that runs as you

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 the tui.mods list. Pi's own global and project extension directories are never consulted, and any other extension found is dropped with a notice.
  • --no-mods skips all of them. A path in tui.mods that 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#

In plain words

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.

examples/mods/hello-mod.tsts
/**
 * 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);
		},
	});
}
Install itsh
mkdir -p .astralis/mods
cp examples/mods/hello-mod.ts .astralis/mods/
astralis tui

Or 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:

  1. Default-export a function that takes the extension API (import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"). Use import type: workflow and mod files are run by stripping types.
  2. Register commands with pi.registerCommand(name, { description, handler }). The handler gets the argument string and a context whose ui can notify, setStatus and setWidget.
  3. Listen to events with pi.on("turn_end", ...) and friends. Keep per-session state inside the exported function: Pi runs it again for /new, /resume and /fork.
  4. Clean up what you show: setWidget(key, undefined) removes a widget, as /hello-clear does.

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.

docs/site/examples/counter-mod.tsts
/**
 * 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.

Use

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.

Before you run any template

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-project

Plan, edit with a bounded agent behind a strict tool policy and sandbox, then verify with a stronger model.

Files createdtree
my-project/
  workflow.ts
  astralis.config.json
  package.json
  README.md
Create itsh
astralis init coding-task my-project
cd my-project
astralis doctor
workflow.ts
templates/coding-task/workflow.tsts
import { 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 };
	},
});
astralis.config.json
templates/coding-task/astralis.config.jsonjson
{
	"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
				}
			]
		}
	}
}
package.json
templates/coding-task/package.jsonjson
{
	"name": "coding-task-project",
	"private": true,
	"type": "module"
}
README.md
templates/coding-task/README.mdtext
# 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-project

Fan out infer workers over a list with a concurrency cap, then merge the results.

Files createdtree
my-project/
  workflow.ts
  astralis.config.json
  package.json
  README.md
Create itsh
astralis init map-reduce my-project
cd my-project
astralis doctor
workflow.ts
templates/map-reduce/workflow.tsts
import { 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 };
	},
});
astralis.config.json
templates/map-reduce/astralis.config.jsonjson
{
	"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
				}
			]
		}
	}
}
package.json
templates/map-reduce/package.jsonjson
{
	"name": "map-reduce-project",
	"private": true,
	"type": "module"
}
README.md
templates/map-reduce/README.mdtext
# 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-project

Classify an input, branch in code on the typed result, then summarise (infer plus plain code).

Files createdtree
my-project/
  workflow.ts
  astralis.config.json
  package.json
  README.md
Create itsh
astralis init pipeline my-project
cd my-project
astralis doctor
workflow.ts
templates/pipeline/workflow.tsts
import { 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 };
	},
});
astralis.config.json
templates/pipeline/astralis.config.jsonjson
{
	"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
				}
			]
		}
	}
}
package.json
templates/pipeline/package.jsonjson
{
	"name": "pipeline-project",
	"private": true,
	"type": "module"
}
README.md
templates/pipeline/README.mdtext
# 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-project

Read memory, infer with a pinned skill, write memory back.

Files createdtree
my-project/
  workflow.ts
  astralis.config.json
  package.json
  skills/
    research-method/
      SKILL.md
  README.md
Create itsh
astralis init research-with-memory my-project
cd my-project
astralis doctor
workflow.ts
templates/research-with-memory/workflow.tsts
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 };
	},
});
astralis.config.json
templates/research-with-memory/astralis.config.jsonjson
{
	"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
				}
			]
		}
	}
}
package.json
templates/research-with-memory/package.jsonjson
{
	"name": "research-with-memory-project",
	"private": true,
	"type": "module"
}
skills/research-method/SKILL.md
templates/research-with-memory/skills/research-method/SKILL.mdtext
---
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.
README.md
templates/research-with-memory/README.mdtext
# 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?"}'`
Use

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.

docs/site/examples/cookbook-retry.tsts
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.

docs/site/examples/cookbook-model-per-step.tsts
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.

docs/site/examples/cookbook-escalate.tsts
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.

docs/site/examples/cookbook-approve-write.tsts
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.

docs/site/examples/cookbook-spend-cap.tsts
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.

docs/site/examples/cookbook-two-models.tsts
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.

docs/site/examples/cookbook-resume.tsts
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.

docs/site/examples/cookbook-extract-folder.tsts
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.

docs/site/examples/cookbook-guard-hook.tsts
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.

docs/site/examples/cookbook-offline-test.tsts
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"]);
});
Run it with Node's built-in test runnersh
node --test cookbook-offline-test.ts

Token usage per model from a journal#

A journal is plain JSON lines, so you can analyse it with no harness code at all.

docs/site/examples/cookbook-usage-report.tsts
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.

docs/site/examples/cookbook-policy-test.tsts
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" }),
];
Use

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: [] throws ConfigError. 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, find and ls may be called without path. A path rule with no default: "." then refuses the call ("argument path must be a string"). A glob like src/** does not match src itself, 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.
  • agent fails on the first tool error unless you set onToolError: "report", which lets the model see the error and carry on (and journals agent_lenient_mode). A policy refusal fails the step either way.
  • buildTools runs when the file is imported if you call it at the top of the workflow file, as the templates do. A missing bwrap or a bad policy fails before the run starts, which is good, and also means process.cwd() is evaluated at import time.
  • The path rule's root must equal the tool set's cwd, resolved to real paths. buildTools checks 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 and astralis diff. Make them specific (extract-invoice-0042, not step), and give retry attempts different names.
  • Add additionalProperties: false to 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 diff reports a difference you caused.
  • Files are TypeScript run by type stripping: use import type for types, keep the .ts extension on relative imports, and avoid enums and other syntax that needs real compilation.
  • A headless astralis run rejects every approve step (stdin is not a terminal). That is deliberate. In CI, expect the failure or do not use approve.

Models#

  • Keep maxTurns small 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 maxTokens for reasoning models. Thinking and answer share the budget, and running out is a GatewayError ("hit its output limit").
  • Typed steps need tool calling. If a model answers in prose you get SchemaValidationError with the prose in details.raw. See the local-models table.
  • defaultModel is not checked when the file is read. A typo shows up at the first step. astralis doctor catches it.

Journals#

  • Journals contain everything the model saw and returned, including prompts, file contents and tool output. Treat .astralis/runs and 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-dir for 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 report and diff already do.
  • A tool that reports an error leaves a tool_call with no tool_result. The result is in the step_error details.

Interactive mode#

  • Chat mode refuses bash. Put shell work in a workflow agent step, where it is sandboxed, and start it with /run.
  • /run has no approver, so a workflow with approve steps will be rejected there. Run it with astralis run in a terminal.
  • Read mods before installing them. Nothing contains them.
  1. astralis doctor passes, including the sandbox check.
  2. Every tool has a rule you can explain in one sentence, and no ** on a write path unless you meant it.
  3. maxTurns is set low, and the agent's output schema is strict.
  4. There is an approve step before anything irreversible, and you run it from a terminal.
  5. The workspace is a copy or a clean git checkout, so a bad run is one git checkout away from undone.
  6. You know where the journal will be written.
Use

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#

In plain words

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.

astralis run, with a model answer that breaks the schema (stderr)output
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.jsonl
  1. error: the whole message, never shortened.
  2. failing step: id, kind and name. Step 1 is the first thing the program did.
  3. details: for a schema failure, raw is exactly what the model sent, and violations lists each mismatch (a union of literals reports once per option).
  4. caused by: the original error class. The error you catch in code is a StepError; this line is its .cause.
  5. journal: where to look next.

Reading a journal#

astralis journal <journal-file>output
   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.

Slicing a journal with jq (not part of the harness)sh
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.jsonl

astralis 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#

In plain words

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.

astralis diff <run-a> <run-b>output
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 kindMeaning
different_stepAt some position the runs started different steps (kind, name or id). The paths split here.
different_requestSame step, but the model, or its request hash, differs (or one run made fewer calls). The model's input changed.
different_responseSame request, different response. This is the model, not your program.
different_outcomeTool 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 idWhat it checksFails or warns whenFix
nodeThe 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.
configThat 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>.keyThat 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>.endpointGET <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-modelThat 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.
sandboxRuns 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-dirThat 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.

ClassWhat it meansCommon causesWhat to do
AstralisError
extends 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.
NoActiveStepError
extends 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.
GatewayError
extends 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.
SchemaValidationError
extends 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).
ConfigError
extends 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.
PolicyError
extends 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.
StepError
extends 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.
FanoutError
extends 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.
VerificationFailedError
extends 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).
ApprovalRejectedError
extends 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.
ApprovalUnavailableError
extends 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.
Not every failure is an AstralisError

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.

Reference

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.

astralis --helpgenerated
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 command

astralis 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.

astralis run --helpgenerated
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 command
Examplessh
astralis run workflow.ts --input '{"text":"hello"}'
astralis run workflow.ts --input-file input.json --model local/qwen --runs-dir /tmp/runs

Exit 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.

astralis tui --helpgenerated
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 command
Examplessh
astralis tui
astralis tui --model anthropic/claude-sonnet-5-5 --no-mods

Exit 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.

astralis journal --helpgenerated
Usage: astralis journal [options] <journal-file>

Print a one-line-per-event view of a run journal.

Options:
  -h, --help  display help for command
Examplessh
astralis journal .astralis/runs/<run-id>/journal.jsonl

Exit 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.

astralis doctor --helpgenerated
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 command
Examplessh
astralis doctor
astralis doctor --offline --config ./astralis.config.json

Exit codes: 0 when no check failed (warnings allowed); 1 otherwise.

astralis templates#

Lists the template names and one-line descriptions that astralis init accepts.

astralis templates --helpgenerated
Usage: astralis templates [options]

List the starter projects that `astralis init` can create.

Options:
  -h, --help  display help for command
Examplessh
astralis templates

Exit codes: 0.

astralis init#

Copies the template's files (everything except template.json). Never overwrites, and checks all files before writing any.

astralis init --helpgenerated
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 command
Examplessh
astralis 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.

astralis report --helpgenerated
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 command
Examplessh
astralis report .astralis/runs/<run-id>/journal.jsonl -o run.html

Exit 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.

astralis diff --helpgenerated
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 command
Examplessh
astralis diff a/journal.jsonl b/journal.jsonl
astralis diff a/journal.jsonl b/journal.jsonl --json

Exit codes: 0 identical; 1 the runs differ; 2 the comparison itself failed (unreadable journal, or journals hashed with different hashVersion).

Reference

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 typeWritten byWhendata fields
run_start
run level (no stepId)
runWorkflow; the TUI sessionBefore the workflow's input is validated.
  • workflow: workflow name (astralis-tui for a chat session)
  • input: the raw input as given
  • cwd, allowedTools, maxTurns: TUI sessions only
run_end
run level (no stepId)
runWorkflow; the TUI sessionThe workflow returned and its output matched the output schema.
  • workflow: workflow name
  • output: the validated output
  • chats: TUI sessions: number of chat prompts
run_error
run level (no stepId)
runWorkflowThe input was invalid, the workflow threw, or its output did not match.
  • workflow: workflow name
  • error: the error message
step_start
has a stepId
every stepA step begins. Its id is allocated at call time.
  • kind: infer, agent, tool, fanout, worker, verify, approve or memory
  • name: the step's name
step_end
has a stepId
every stepThe step finished successfully. Nothing more is written for that id after this.
  • kind: step kind
  • name: step name
step_error
has a stepId
every stepThe step failed, including a failure of a hook that ran inside it.
  • kind, name: as in step_start
  • error: message of the StepError
  • details: the original error's details
  • failedStepId: the deepest step that failed; differs from stepId when a child failed
inference_request
has a stepId
the gatewayBefore every model call, with the exact context sent.
  • model: provider/model-id
  • api: the Pi API the call uses
  • requestHash: sha256 of model, resolved model config, system prompt, messages, tools and options
  • hashVersion: version of the hashing scheme; astralis diff refuses journals of another version
  • context: system prompt, messages and tool definitions
  • options: request options without credentials or header values
inference_response
has a stepId
the gatewayThe model call returned (including a stop reason of error).
  • model, requestHash: match the request
  • stopReason: stop, length, toolUse, error or aborted
  • usage: token counts and cost as the provider reported them
  • content: the response blocks
  • errorMessage: provider message, when there is one
inference_error
has a stepId
the gatewayThe provider call threw instead of returning.
  • model, requestHash: match the request
  • error: the provider's message
tool_call
has a stepId
tool step; agent step; TUI chatA tool is about to run (after the policy allowed it, for chat).
  • tool (tool step) or name (agent, chat): the tool's name; the field name differs by writer
  • args: the arguments
  • toolCallId: agent and chat only
tool_result
has a stepId
tool step; agent step; TUI chatA tool returned.
  • tool or name: as in tool_call
  • content, details: what the tool returned (chat: content is text truncated to 4000 characters)
  • structuredContent: tool step only, when present
  • isError: agent and chat
  • durationMs: chat only
tool_blocked
has a stepId
withPolicy; the TUIA call was refused by the policy and did not run.
  • tool: tool name
  • args: the refused arguments
  • reason: why the policy refused
verification
has a stepId
verify stepThe verifier answered, before a failing verdict throws.
  • name: step name
  • verdict: pass or fail
  • findings: array of strings
memory_read
has a stepId
memoryRead stepA key was read.
  • key: memory key
  • found: whether the key existed
  • sha256: hash of the stored value (only when found)
memory_write
has a stepId
memoryWrite stepAfter a value was validated and written.
  • key: memory key
  • sha256: hash of the value
skill_attached
has a stepId
renderSkillsA skill was rendered into a prompt while a step was active. Not written when renderSkills runs in workflow code outside a step.
  • name: skill name
  • hash: sha256 of the SKILL.md file
approval
has a stepId
approve stepAn approver answered, before a no throws.
  • name, question, details: what was asked
  • approved: boolean
  • note: the reason, when given
  • approver: the approver's id (terminal, deny-all, or your own)
agent_lenient_mode
has a stepId
agent stepAn agent step started with onToolError: "report".
  • onToolError: always report
  • tools: the tool names the step was given
prompt
has a stepId
TUI chatA chat prompt opened its bounded agent step.
  • text: the prompt
turn_end
has a stepId
TUI chatA chat model turn completed.
  • turn: turn number within the prompt
  • stopReason: the model's stop reason
  • toolResults: how many tool results the turn produced
turn_cap
has a stepId
TUI chatA prompt reached tui.maxTurns while still calling tools; it is aborted.
  • maxTurns: the cap
tool_source_violation
stepId optional
TUIAn allowlisted tool name is provided by a mod instead of Pi's built-ins. Chat refuses to continue.
  • violations: array of { tool, source }
tool_set_trimmed
run level (no stepId)
TUIAt 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.
  • removed: the tool names switched off
  • allowedTools: the allowlist
tool_set_violation
stepId optional
TUIA 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.
  • extra: the tools that were active
  • where: where the check caught it
  • allowedTools: the allowlist
workflow_run
run level (no stepId)
TUI /runA /run workflow finished, in the session's own journal.
  • workflow, runId, journalPath: which run and where its journal is
  • status: done or failed
  • error: message when failed
Field names differ by writer

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.

Reference

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#

PathWhat it isWho writes it
astralis.config.jsonProviders, models and chat settings. Read from the current directory, or --config.You (astralis init creates one)
.astralis/runs/<run-id>/journal.jsonlThe 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/*.tsMods loaded by astralis tui.You
Your memory directoryWhatever you pass to new MemoryStore(dir). The templates use .astralis/memory.Your workflow, through memoryWrite
Your skills directoriesThe directories you pass to loadSkill. There is no default location.You
templates/The starter projects inside the package.The package

Environment#

  • Every apiKeyEnv named 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_ALL and TERM from your environment, plus HOME set to the workspace, unless envAllow and env say otherwise.
  • MCP stdio servers inherit nothing; they get only the env in 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-harnessKind (approximate)
AfterStepHandlertype or class
AgentSpectype or class
AgentStreamFntype or class
AgentStreamOptionstype or class
ApprovalDecisiontype or class
ApprovalRejectedErrorerror class
ApprovalRequesttype or class
ApprovalUnavailableErrorerror class
ApproveSpectype or class
Approvertype or class
ArgRuletype or class
AstralisErrorerror class
BeforeStepHandlertype or class
BuiltinToolNametype or class
CallOptionstype or class
CommandRuletype or class
ConfigErrorerror class
DEFAULT_CONFIG_FILEtype or class
FanoutErrorerror class
FanoutFailuretype or class
FanoutSpectype or class
FileJournaltype or class
Gatewaytype or class
GatewayErrorerror class
HASH_VERSIONtype or class
HarnessConfigtype or class
HarnessTooltype or class
HookRegistrytype or class
InferSpectype or class
Journaltype or class
JournalEventtype or class
LoadedSkilltype or class
McpConnectOptionstype or class
McpConnectiontype or class
McpServerstype or class
MemoryJournaltype or class
MemoryReadSpectype or class
MemoryStoretype or class
MemoryWriteSpectype or class
NoActiveStepErrorerror class
PathRuletype or class
PatternRuletype or class
PolicyDecisiontype or class
PolicyErrorerror class
RESULT_TOOLtype or class
RuleBasedToolPolicytype or class
Runtype or class
RunOptionstype or class
RunResulttype or class
SandboxConfigtype or class
SchemaValidationErrorerror class
Statictype or class
StepErrorerror class
StepErrorHandlertype or class
StepInfotype or class
StepScopetype or class
StructuredRequesttype or class
TSchematype or class
TerminalApprovertype or class
ToolPolicytype or class
ToolPolicySpectype or class
ToolSetSpectype or class
ToolSpectype or class
ToolStepResulttype or class
Typetype or class
UnrestrictedRuletype or class
VerificationFailedErrorerror class
VerifySpectype or class
Workflowtype or class
WorkflowContexttype or class
assertBwrapAvailablefunction or value
buildToolsfunction or value
connectMcpfunction or value
createGatewayFromConfigfunction or value
createToolPolicyfunction or value
defineWorkflowfunction or value
denyAllApproverfunction or value
describeErrorerror class
hashValuefunction or value
loadConfigfunction or value
loadSkillfunction or value
parseConfigfunction or value
renderSkillsfunction or value
resolveWorkspacefunction or value
runWorkflowfunction or value
stepSequencefunction or value
terminalApproverfunction or value
validatefunction or value
withPolicyfunction or value
Reference

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 an id and an ask method. 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 bash tool (from buildTools) 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 memoryRead and memoryWrite steps.
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 createToolPolicy that 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 bash tool (from buildTools) 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 a run function.
Workspace
The directory a sandboxed command can write to, and the root that file-path rules confine file tools to.
Reference

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 aboutWhat the harness doesWhat remains your job
The model decides what runs nextControl 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 aboutThe 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 notDeny-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 notCommand 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 approvedapprove stops the run, and a missing approver means no.Put it before anything you cannot undo.
Silent changes to instructionsSkills can be pinned by hash.Pin them, and review hash changes.
Not knowing what happenedAn 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 modeMods 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/bwrap and unprivileged user namespaces, the file tools need /proc; without them bash does not run and astralis doctor fails.
  • Credential scrubbing is exact-match only. The gateway replaces the values of your apiKeyEnv variables (and, with builtinProviders, any environment value whose name ends in API_KEY, TOKEN or SECRET) 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, verify or agent. 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 /run workflows are fully journaled.
  • TypeScript is run by Node's type stripping. Use a Node release where that is enabled by default; astralis doctor only checks the minimum version in engines.
  • The request hash is not a signature. It shows two requests were equal, not who made them.

Planned, not built#

These three do not exist yet

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).

AstralisHarness v0.1.0 documentation. This page is one self-contained file: no network, no external fonts, scripts or images.

Code samples are built from the files that ship in the repository and are typechecked against the real API; the CLI, journal, config and error references are generated from the code.