Orchy runs agent flows. You declare the steps and the rules. Orchy runs the steps, checks the rules, and records what each step did.
A flow can call a command, ask an agent to use a model, or wait for a person. Each step declares what it needs and what value it must return. Orchy checks the order and the value. You can also set rules for tools, changed files, cycles, and cost.
The start guide begins with one command step. It needs no model or account. The guide then adds an agent step, order, a contract, and a gate. Each part leaves a flow you can run.
npm install -g @krimvp/orchyOrchy needs Node 22.18 or later. The package name has a scope; the command is
orchy.
| If you want to... | Go to... |
|---|---|
| Run your first flow | Start with Orchy |
| Learn a field or a feature | Run a flow |
| Read complete flows | Examples |
| Learn the design | Plan |
The rest of this page lists Orchy's rules, commands, and records.
A prompt asks. Orchy enforces.
- Tools — a step calls only the tools it declares. This is not a sandbox.
A step that declares
bashcan still change any file. - Contract — the value of a step must match its JSON Schema. A step that
answers something else fails. The rule works both ways: a step declares
takes, and the values that reach it must match that schema as well. - Order — a step starts only after every step it needs passes.
- Limit — a cycle stops at its declared limit. A flow cannot run forever.
A flow declares a
budgetin dollars, and the run stops before the next wave when it reaches it — the check falls between waves, because no one knows what a step will cost before it runs. Every attempt counts, including the ones a cycle threw away. A budget needs a cost that a harness reports: a run whose spend Orchy cannot measure stops and says so. - Provenance — Orchy records what each step changed in the workspace. A
step that declares
changes: nothing,changes: { paths: [docs] }, orchanges: { except: [src] }fails when anything else moved. The record says what the step did to each path: added, changed, deleted, renamed, restored, or moved. This catches whatbashdoes behind rule 1. A promise bounds what a step may change: it does not require the step to change anything, and the record covers the workspace, so a step that writes outside it moved nothing that Orchy can see.
validate() refuses a promise that no workspace can check, a tool that the
harness of the flow does not supply, a model name that the harness cannot read,
a value that a step takes and nothing supplies, and a field that the kind of a
step cannot act on. A rule never looks enforced when it is not, and a field that
no step reads never passes in silence.
- A step runs an agent, a component of your own — a TypeScript module, or a command in any language — or waits for a person.
- The values of a run reach every step. A flow declares what it takes, a
prompt reads
{{ issue }}, and a component takes the same value as an argument. - A harness and a model for the flow, and for any step that wants another. Code with one model, review with three others.
- A fanout runs one step once for each member, so a panel of reviewers is one block of YAML. A member holds a value, so one prompt serves a list. A fanout over a list that an earlier step computes waits for that value.
- A condition on a step, so a step runs only when an earlier value says so.
A match holds a value, or one of five operators:
is,not,empty,lt,gt. - A retry is a cycle to the step itself, on the word
failed. A fanout takes one too, and each member retries its own work. - A flow inside a flow reuses a whole flow as one step, and carries a cycle of its own.
- A wave runs every step whose needs have passed at the same time, eight at
once unless the flow says otherwise. A wave that can change the workspace runs
one step at a time, because a snapshot of the workspace reads what every step
wrote. A wave where every step promises
nothinghas no writer, so it runs whole. - A gate stops the run and waits for a person. The run persists, the
process ends, and
orchy resumecontinues it. So a flow works in CI. A gate carries a cycle, so a person rejects the work and sends the run back.
Eight flows in examples. A test checks every one of them, and CI runs the tests on every push, so a broken example fails the build.
| Flow | What it shows |
|---|---|
| code-review | a cycle, written twice: as flow.ts and as flow.yaml |
| research | the web, three readers at once, a budget in dollars, and a check that reads the sources again |
| triage | a gate that overrides the agent, and no workspace at all |
| docs-audit | one promise on the flow, which the workspace checks on every step |
| release-notes | a deterministic step reads git, so no model spends tokens on it |
| decision | three fixed stances argue, then a person chooses |
| dependency-audit | one prompt over a list, a value every member supplies, a retry, and a step a condition rules out |
| grilling | a gate that asks a person a round of questions, over and over |
Twelve more flows live in flows — work rather than teaching:
bugfix, qa, standup, pr-describe, test-writer, typo-hunt,
api-docs, security-sweep, refactor-gate, planning-poker,
translate-readme, and ship. The same test checks each one, and
docs/usability.md reports what happened when every one
of them ran against a real model.
Orchy drives a harness, and does not replace one.
| Harness | How | Model |
|---|---|---|
pi |
the Pi SDK | provider/model, such as ollama/glm-5.2 |
claude |
the claude command |
a model name, such as claude-opus-4-5 |
droid |
the droid command of Factory |
the id droid reads, such as custom:glm-5.2-[Ollama-Cloud]-0 |
A tool has one name in Orchy and another in each harness.
| Orchy | Pi | Claude | Droid |
|---|---|---|---|
read write edit bash grep |
the same names | Read Write Edit Bash Grep |
Read Create Edit Execute Grep |
find ls |
find ls |
Glob |
Glob LS |
web |
none, and Pi says so | WebSearch WebFetch |
WebSearch FetchUrl |
orchy |
none | mcp__orchy |
none |
A harness refuses a tool it cannot supply. It never drops one in silence.
Adding a harness costs one adapter with two methods. The runner, the flow data, and the five invariants took no edit when the second one arrived, and none when the third did.
orchy check flow.yaml # say what is wrong, and what it takes
orchy run flow.yaml # or flow.ts
orchy run flow.yaml --with '{"issue":412}' # the values the flow takes
orchy run flow.yaml --harness claude # for a flow that names none
orchy runs # the runs of this directory, and why each ended
orchy resume <run id> '{"approved":true}' # answer a gate
orchy resume <run id> # continue a run that ended
orchy resume <run id> --from <step> # go back to a step, and run again
orchy memory list ticket-412 # what runs recorded, and who
orchy daemon # a queue, an API, and a page
orchy mcp # the same engine, for an agentA resume with no value continues a run that ended: a failed run goes back to
the step that failed, and a stopped run continues where it stood. --from
names the step to go back to, and every step after it runs again. The steps
that passed keep their work, and the record of a step that runs again goes to
history first, so its cost still counts.
--with takes one JSON object. Orchy checks it against what the flow takes
before the first step spends a token. It refuses a value that breaks the schema,
and a name the flow does not take: a value that reaches no step and no prompt is
a mistake that would otherwise run to the end in silence.
The command prints the events to the error stream and the run state to the
output stream. It ends with 0 when the run finishes, 1 when the run fails, 2
when the command or the flow it was given is wrong, and 3 when the run waits for
a person. --events writes one JSON event for each line to the output stream
instead, and prints no state there, so a parent process reads the events alone.
That is how the daemon reads a run.
orchy check <flow file> reads a flow, and every flow it holds, and says what
is wrong with it. It runs nothing and spends nothing.
orchy memory keys | list <scope> | add <scope> <text> | forget <scope> [id]
reads and writes what runs record. A scope is the key a flow declares. forget
takes the id of one entry, and drops the whole scope without one. See
What a run remembers.
orchy mcp serves the Model Context Protocol on stdin and stdout, so a coding
agent writes flows, hears every problem from validate(), runs them, and
answers a gate. Register it with claude mcp add orchy -- orchy mcp. See
docs/running.md.
A prompt path and a module path are relative to the flow file. The working
directory is where the steps act, so run the command from the directory you want
them to work in.
npm run ui:build # once, and after a change to the UI
orchy daemon # http://127.0.0.1:4000The daemon holds a queue, starts each run as a child process, and serves a page. On the page you make a flow or register a flow file, start a run, watch each step as it runs, read what a step says while it works, answer a gate in a form built from its contract, read the value, the cost, and the changed files of every step, open the trajectory of every run of every step, and edit a flow as a drawing.
One flow runs many times. Every run of a flow reads together on its own page, newest first, and a run starts from there without leaving it — so a person starts one run on issue 41 and the next on issue 42, and watches both. Four runs work at once and the rest wait in the queue. Each run keeps the values it took, so every list tells one run of a flow from another.
A flow also runs by itself. A schedule fires it on a pace, at most every 15 minutes, and a hook starts it from a POST whose body is the values the flow takes. Both start a run through the same checked door as the button, so each refuses the same broken flow the same way. The tab title says whose move it is, and an opt-in notification says when a run waits for you or ends.
- A run in a child process. A run that hangs or dies takes nothing with it. Four runs start at the same time. See ADR 0008.
- The disk stays the run. A SQLite index answers a list and holds the events. Losing it costs the events and no run. See ADR 0009.
- The editor writes your file. It reads a flow, draws it, and writes the
same YAML back. It refuses to write a flow that
validate()rejects, and it reads a flow in TypeScript without writing one. See ADR 0010. - A step reports by reading its own record. An adapter reads the file that its harness already writes, so the live report and the trajectory come from one parser, and no harness is started a different way. See ADR 0011.
The daemon listens on 127.0.0.1 only, and it refuses a page that is not its
own. A request with a foreign Origin reaches nothing, so a page a person
visits starts no flow here. A request with a Host that this daemon does not
answer to reaches nothing, which closes DNS rebinding. The daemon answers to
127.0.0.1, to [::1], and to localhost, on its own port, and the answer
names the address to use. See ADR
0020.
This is not a user and a password. The daemon has neither. It starts an agent
that can hold bash, so anyone who reaches the port from a program still runs
code on the machine. The rule above bounds a browser, because a browser writes
those two headers itself. The daemon is not ready for a shared host.
The steps of every run act in the directory where the daemon starts, so start it where you want the work to happen. A flow file outside that directory is refused, and the message names the directory.
.orchy/
├── index.db the daemon builds this from the runs, and rebuilds it
└── runs/<run id>/
├── state.json the flow, the prompt and the value of every step,
│ the value the flow returns, and the cycle counts
└── trajectory.json one ATIF v1.7 trajectory, a child for each agent step
orchy runs lists them, newest first.
state.json holds value, which is what the flow produced: the value of the
step it ends with. It holds the prompt of every agent step as well, filled in
with the values of the run, so a reader knows what the step was really asked.
The trajectory holds the tool calls, the reasoning, the tokens, and the cost of every step, including the runs a cycle threw away. ATIF is a standard format, so a tool that already reads it turns the file into OpenTelemetry spans. Orchy ships no exporter.
One field of that file is not the standard yet. ATIF v1.7 names the totals
total_prompt_tokens, total_completion_tokens, total_cached_tokens, and
total_cost_usd, and Orchy writes the per-step names there instead. Every other
part of the file is ATIF, and a reader of final_metrics reads the short names
until this closes. See docs/sweep/observability.md.
jq .final_metrics .orchy/runs/<run id>/trajectory.jsonA flow that says nothing remembers nothing. One that wants to says where:
name: bugfix
takes:
type: object
required: [issue]
properties:
issue: { type: string }
memory:
scope: "ticket/{{ issue }}" # none | flow | root | a key of your own
most: 20 # how many entries seed a promptscope is the key of one store. none is the default and remembers nothing.
flow is one store for every run of this flow, and root is the one store of
the whole root — available, but you have to ask for it by name. Everything else is a key
of your own, and it reads the values of the run the way a prompt does. So each
ticket gets a store, and a follow-up flow that writes the same scope reads what
the first run left.
What the scope holds seeds the prompt of every step, beside the values of the run. A step that must judge the work and nothing else declines it:
- id: review
kind: agent
memory: noneFour things write an entry, and they write the same entry:
- a step that holds the
orchytool, throughrememberat the door of its run; - a
orchy:rememberstep, which is bookkeeping the flow decides and not a choice the model makes on the fly; orchy memory add, for a person, and for a harness that holds noorchytool — acommand, claude, or droid step reads$ORCHY_MEMORY_KEYfor the same reason, and the entry names the step;- a hand, in the file.
A step reads past the seed with recall_memory, which takes a query and no key:
the key comes from the state of the run, so a step cannot reach the store of
another ticket or another flow through the door. There is nothing to ask for.
The door is not a sandbox, though: a step that holds bash reaches every store
of the root through orchy memory.
Every entry names the run and the step that wrote it, so a wrong one is found and dropped:
orchy memory list ticket-proj-14
orchy memory forget ticket-proj-14 a41f9c02The store is a line of JSON for each entry, under .orchy/memory, one file for
each key. It is not the run: losing it loses no run. See ADR
0029.
TypeScript API ─┐
YAML file ──────┼──▶ Flow (data) ──▶ expand ──▶ validate ──▶ waves ──▶ record
Graphical editor┘ │
▼
orchy daemon ──▶ a queue, an API, a page
A flow is data, not code. So the API, a file, and the graphical editor all produce the same flow.
The daemon sits above the runner and adds no rule about a flow. It starts
orchy run as a child process for each run, and indexes what that child writes.
Its own rule is its door, and nothing else.
A fanout and a flow step are expansions. They become plain steps before the run, so the runner knows neither and an editor draws one flat graph. A fanout over a list that a step computes is the one exception: that list arrives with a value, so the run expands it and writes the steps it made to disk.
A run is a state machine on disk. It writes its state after every step, so a gate and a crash recover the same way.
Orchy needs Node 22.18 or later.
npm install -g @krimvp/orchy
orchy run flow.yamlThe bare name orchy on npm belongs to another package, so the scope carries
this one. The command it installs is still orchy.
From this repository instead. Node strips the types, so a clone needs no build to run:
git clone https://github.com/krimvp/orchy && cd orchy && npm install
node src/cli.ts run flow.yamlThe published package holds JavaScript, because Node strips the types of a file
it runs but refuses to strip the types of one under node_modules. So npm run build compiles src to dist before a publish, and a clone still runs the
TypeScript as it stands.
A run needs ajv, yaml, and the Pi SDK. Add @sinclair/typebox only to
write a flow in TypeScript. A flow in YAML holds plain JSON Schema and needs
nothing.
Orchy holds no credential and no model catalogue. Each harness reads its own, so a flow runs only where its harness can reach the model it names.
Pi reads ~/.pi/agent/auth.json for a key, and ~/.pi/agent/models.json
for a provider it does not ship. A local model, an Ollama server, or any
OpenAI-compatible endpoint goes in that file:
{
"providers": {
"ollama": {
"baseUrl": "https://ollama.com/v1",
"api": "openai-completions",
"apiKey": "$OLLAMA_API_KEY",
"models": [{ "id": "glm-5.2" }]
}
}
}That name is the one a flow writes: model: ollama/glm-5.2. Run
npx pi --list-models to see what Pi reaches, and npx pi auth check --provider <name> to see whether it can reach it.
A provider declared this way carries no price, so it reports no cost, and a flow
that holds a step on it declares no budget. Orchy stops a run whose spend it
cannot measure. See docs/running.md.
Claude Code reads the account that claude is logged in to. A flow writes a
plain name: model: opus.
Droid reads ~/.factory/config.json for a custom model, and a custom model
needs no Factory account. Install the command with
curl -fsSL https://app.factory.ai/cli | sh. Ollama Cloud, a local Ollama
server, or any OpenAI-compatible endpoint goes in that file:
{
"custom_models": [
{
"model_display_name": "glm-5.2 [Ollama Cloud]",
"model": "glm-5.2",
"base_url": "https://ollama.com/v1/",
"api_key": "<your key>",
"provider": "generic-chat-completion-api",
"max_tokens": 32000
}
]
}A flow writes the id droid reads: model: "custom:glm-5.2-[Ollama-Cloud]-0" —
custom:, the display name, and the index of the entry. The bare model value
works too, when no model of the Factory catalogue wears the same name. Droid
writes no dollars into its record, so a droid step reports no cost, and a flow
that holds one declares no budget.
A flow that names no model takes the default of its harness, and the default of Pi is a model that most machines cannot reach. The default of droid is a model of the Factory catalogue, which needs a Factory account. So name one.
The page needs a build, and its packages live under ui/ and reach no run.
npm run ui:build
node src/cli.ts daemonThe daemon keeps its index with node:sqlite, which the standard library holds.
Node calls it experimental and prints a warning when it starts.
npm test # every test, with node --test
npm run check # the compiler
npm run build # compile src to dist, as a publish doesCI runs both, and the page build, on every push to main and on every pull request. See .github/workflows/ci.yml.
A release goes out from GitHub. Move the lines under ## [Unreleased] in
CHANGELOG.md to a version heading, then publish a GitHub
Release whose tag is vX.Y.Z. Actions reads the version from the tag, builds,
and publishes to npm with provenance. See
.github/workflows/README.md.
A flow in TypeScript builds the same data as a file:
import { agent, flow, run } from "./src/index.ts";
import { Type } from "@sinclair/typebox";
const state = await run(
flow("code-and-review", {
steps: [
agent({
id: "code",
prompt: "prompts/code.md",
tools: ["edit"],
returns: Type.Object({ summary: Type.String() }),
}),
],
}),
);agent() is generic over its contract, so TypeScript catches a cycle.when
key that the step never returns.
Early, and honest about it.
Run against a real model: all three harnesses, a cycle that carries its
reason back, a gate and orchy resume, an escalation to a person, a panel of
three models at once, a flow inside a flow, and a workspace that proves what
changed.
Covered by a run of the whole flow, with a stand-in model: the values a run takes and the value a flow returns, a name in a prompt, a step that declares what reaches it, a member that holds a value, a fanout over a list that a step computes, each of the five operators, a gate that sends the run back, a step that retries itself, a step that a condition rules out, a wave that holds a promise, two failures in one wave, a budget that stops a run, and a memory that one run writes and the next one reads. See dependency-audit.
Driven in a browser: the daemon, the queue, the live events, the notes a
step reports while it works, the gate form, the step view, the trajectory view,
and the editor. A test drives each one through the API, and a flow of
deterministic steps stands in for a model. A test also proves that the daemon
passes the values of a run to the child, that a schedule fires a run by itself
and holds its pace, that a hook starts a run and a wrong token starts nothing,
that a failed run resumes from the step that failed and keeps the work that
passed, and that it refuses a foreign Origin, a foreign Host, and a flow
file outside its root.
Checked, and never run: the none workspace, and the docs-audit,
release-notes, decision, and dependency-audit examples. A test reads every
example and holds it to validate(). It starts no run of one.
Not built: a memory that expires or that ranks what it answers with, an OpenTelemetry exporter, a sandbox, a timeout for a step, a workspace for one step, a workspace for each run, and any user or password on the daemon. Invariant 1 names the sandbox gap rather than hiding it, and ADR 0018 records the probes that closed the question. A note arrives when the harness writes a line, so a step reports by the turn and not by the word. Two runs in one working directory disturb each other, and the code names that limit.
The API can still change, and the name on npm is @krimvp/orchy, because
the bare name belongs to another package.
- docs/running.md — choose a harness and a model, answer a gate, pace the work.
- docs/plan.md — the design and the milestones.
- docs/shape.md — what the flow data holds, and where it is weak.
- docs/usability.md — one usability run over the whole product, and what it found.
- docs/sweep.md — fifteen agents wrote 401 flows against the product for an hour, and what the 167 findings closed.
- docs/adr — every decision that is hard to reverse, and why.
- CONTEXT.md — the words this project uses.
- AGENTS.md — how to work in this repository.
- .github/workflows/README.md — how a release reaches npm.
MIT. The text is in LICENSE.