Deterministic, AI-driven development flows.

Orca allows you to programmatically define software development workflows where AI agents perform the coding. If you want AI-generated code to always be reviewed by another agent, don't try to coerce the agents; just express that requirement in code. Don't waste tokens on formatting, committing, or creating PRs - all of this can be handled by an ordinary script.

Orca comes with an orca cli, which can be used interactively by humans, or

headlessly by humans and agents alike. A number of built-in flows, implementing

e.g. a plan-implement-review loop, allow you to start using Orca right away.

Orca flow scripts are written in Scala, and can be run with a single command

through scala-cli, which is installed by the

orca installer. No other dependencies are needed - everything is automatically

bootstrapped. Scala 3 looks like Python, but with types - so you get quick

feedback if your flow script has any problems.

Orca's development flows are resumable, so that if work is interrupted mid-flow for any reason, it can be continued from the last commit.

You can use Orca to orchestrate development in any language and ecosystem.

Orca assumes that it has configured, logged-in access to Claude, Codex,

OpenCode, or Pi (depending which backend you use), as well as gh and git.

Install with one command, which installs scala-cli (via its official

installer) if you don't have it already, and writes the orca executable to

~/.local/bin/orca:

curl -fsSL https://raw.githubusercontent.com/VirtusLab/orca/master/install.sh | bashSee Orca Shell for the details and the full command-line

reference, or just run orca / orca help.

Interactively: install the CLI, run orca, pick a flow (implement.sc

comes first in the list) and enter your task. Non-interactively, use orca run <flow> "<task>". See Orca Shell for installation and the full

command-line reference.

[!WARNING] Orca is designed to work in a sandboxed environment! Coding agent tool usage is auto-approved by default (

tools = ToolSet.Full,autoApprove = AutoApprove.All): write-capable turns let the agent edit files and run shell commands without prompting. This can be changed by changing the flow's options in code. Alternatively, use a VPS or local sandbox such as Sandcat, Docker Sandboxes, or any other.

Driven by an agent (headless): a coding agent or harness invokes the CLI non-interactively to implement a task, e.g. from CI or as a sub-task of another agent:

orca run implement.sc "add a rate limiter to /login"Useful flags: --skip-branch (continue on the current branch instead of

creating one), --keep-changes (leave uncommitted files in place instead of

stashing them) and --worktree (run in a git worktree of this repository

instead of the current checkout).

In every mode, which agent (and model) handles the planning, coding, and review

roles comes from settings.properties — written for you by the shell's

first-run wizard or orca config, hand-editable too; see Settings.

Agents can load skills/using-orca to know when

and how to delegate here — installable as a Claude Code plugin, a Pi package, or

by symlinking into any harness's skills directory; see its

README for specifics.

As a script: run a flow directly with scala-cli, no install required — see

An example flow.

scala-cli run implement.sc -- "add a rate limiter to /login"Save this as implement.sc and run it with your task:

//> using scala 3.9.0

//> using dep "org.virtuslab::orca:0.1.7"

//> using jvm 21

import orca.{*, given}

// Roles (planning / coding / review) come from settings.properties —

// per-project `.orca/settings.properties`, else ~/.config/orca/settings.properties,

// else claude for everything. Bodies can still name a concrete harness

// (`claude`, `codex.mini`, …) where a flow wants one — details under "Coding

// agent tools".

flow(OrcaArgs(args)):

// `stage` is the committing, resumable unit of work. The plan is produced in

// one agentic turn and recorded in the stage log; a re-run with the same

// prompt skips this stage and reads the stored Plan back.

val plan = stage("Plan"):

Plan.autonomous.from(userPrompt, planningAgent).value

// One stage per task: each stage commits its work + a progress-log entry as

// one commit. Completed stages are skipped on resume — re-running the same

// prompt picks up from the first incomplete task. Each task gets its own

// session, keyed by that task and seeded with the plan's brief (which primes

// it on first use, and is replayed if the backend session is lost on resume).

for (task, n) <- plan.tasks.zipWithIndex do

stage(s"Task: ${task.title}"):

val session = codingAgent.session(

"implementer",

detail = s"task ${n + 1}: ${task.title}",

seed = plan.brief

)

session.run(task.description)

reviewThenFix(

coderSession = session,

reviewers = allReviewers(reviewAgent),

// One review round, one fix turn. Reviewers are picked by a picker LLM

// on reviewAgent.cheap (see "Review utilities"); format and lint

// default to the project's stack settings

// (`.orca/settings.properties`, auto-discovered on first run) — see

// "Settings" below. The whole task goes in: reviewers are shown its

// title and description, plus the run's prompt, each labelled.

task = task

)

// Each task's single pass took the fixer's word for its own fixes; this loop

// over everything the run changed is what checks them.

val openFindings = stage("Final review"):

reviewAndFixLoop(

coderSession = session,

reviewers = allReviewers(reviewAgent),

task = Task(Title("The whole planned change"), plan.brief),

diff = ReviewDiff.WholeRun,

maxIterations = 5

)

// Best effort: opens a PR when the checkout is on a GitHub `gh` can reach,

// and says why in one line when it isn't. What the loop left open is listed

// in the PR body.

openPrIfGitHub(

summarisingAgent = codingAgent.cheap,

openFindings = openFindings

)scala-cli run implement.sc -- "Add a rate-limiter to the /login endpoint"Each flow starts by creating a feature branch, named by a short

cheap-model-generated label derived from the prompt (slugged; pass branchNaming = ... to override). On success the flow opens a PR when the repository is on a

GitHub gh can reach, and hands you back the branch you started on — the work

is on the PR. Otherwise it says so in one line and leaves you on the feature

branch, ready to test or open a PR by hand — see The flow

lifecycle for the full success/failure/resume behavior.

If the flow is interrupted — user intervention or an intermittent error — just run the same command again: it resumes from the last committed set of changes, so only a small amount of work is repeated. Orca borrows ideas from durable computing: which stages have completed, and with what results, is tracked in a progress file committed alongside the modified code, making commits the unit of atomicity — the progress log can't drift from the changes in the repository. When the flow is done, the progress log is removed from the branch in one last commit, which is pushed too if the flow had already pushed the branch.

There are two runnable examples under

examples/runnable/:

- 01-simple (in-memory plan + review, autonomous planner),

- 02-interactive (same shape as 01, but the

planner can ask clarifying questions via ask_user).

More flow scripts — issue-pr.sc, issue-pr-bugfix.sc,

implement-enhanced.sc, review.sc — live in flows/; run them

against your own git repo.

For convenient editing of Orca flow scripts, with code-completion, you can try the Metals VSCode extension.

The following are available inside a flow(...) { ... }.

The five coding agents — claude, codex, opencode, pi, gemini — share

one call surface. Durable: session(name, detail, seed): FlowSession →

.run(prompt) / .resultAs[O].run(input). One-shot: run(prompt),

resultAs[O].{autonomous,interactive}.run(input). Ephemeral multi-turn:

chat(): Chat → .run(prompt) / .resultAs[O]...run(input). Common tuning:

withModel, withCheapModel, withConfig, withSystemPrompt, withName,

withReadOnly, withNetworkOnly, withSelfManagedGit. The table lists each

backend's model accessors and backend-specific extras:

The runtime owns git: every write-capable agent turn is told not to commit,

push, or switch branches — it edits the working tree, and the flow

commits/branches/pushes via git.*. Opt out per-tool with

claude.withSelfManagedGit.

For the LLM interfaces, resultAs[O] defines the shape of the structured

output. The O type needs a JsonData[O] (provided by derives JsonData on a

case class) for schema generation and deserialization. Additionally, you might

define an Announce[O] so that a friendly summary is printed in the event log,

instead of a raw json.

A minimal Pi-backed flow looks the same; Pi reads your normal Pi configuration:

flow(OrcaArgs(args)):

stage("Run"):

val session =

pi.session("run", detail = "the whole prompt", seed = userPrompt)

session.run(userPrompt)There are two ways to drive a model in a flow:

- The role agents — planningAgent/codingAgent/reviewAgent. Backend-agnostic: each is resolved from settings (see Settings), defaulting to claude. UseplanningAgentforPlan.*calls,codingAgentfor the implementer's durable session, andreviewAgentforallReviewers(...)and the review machinery's defaults. Edit settings and the whole flow follows; you never name a backend in the body.

- A specific agent + model — claude.opus,codex.mini,opencode.openaiLuna. Use a concrete accessor when you want a particular backend or tier regardless of settings — sayclaude.opusfor a step that must have the strongest model even where the coding role is a cheaper backend. None of the shipped flows do this; they all follow the roles. The tier accessors (.opus/.sonnet/…) live on the concrete agents, not on the role accessors — socodingAgent.opuswon't compile; that's the cue to name the backend. Pin any other model withwithModel(Model("…")). Don't mix the two for one session (aSessionIdis backend-typed).

Two axes constrain an agent. Capability (AgentConfig.tools: ToolSet) is

which tools exist at all:

// ReadOnly — reads only, no shell, no edits (reviewers, plan review, brief).

val reviewer = claude.withReadOnly

// NetworkOnly — reads plus read-only network (web, and on claude a host-served

// GitHub issue/PR read), for planners that must read an issue/PR. How strongly

// each backend blocks edits varies — see the enforcement matrix in AGENTS.md.

val planner = claude.withNetworkOnly

// Full (the default) — write-capable.Prompting (autoApprove) is which of the available tools auto-approve

without a y/n prompt — only meaningful for interactive turns, and consulted only

on Full:

// Restrict auto-approval to a named tool set (honoured by claude).

val limited = claude.withConfig(

AgentConfig(autoApprove = AutoApprove.Only(Set("Read", "Edit", "Grep")))

)AutoApprove.Only fits interactive flows, where a human answers anything

outside the set; an autonomous turn has no one to approve, so an out-of-set call

blocks. Only claude enforces the set per tool — codex and gemini have no

per-tool granularity, so there Only widens to full auto-approve. For an

unattended run the practical boundary is a sandbox:

Sandcat, Docker

Sandboxes, or any other.

Top-level, available via import orca.*:

Any tool or agent flow(...) builds by default can be replaced by a named

argument. Plain tools take the value directly (git = Some(myGit), interaction = Some(myInteraction) — your own orca.backend.Interaction implementation,

e.g. for Slack; not exported from orca.*, so import it by its full path).

Agents take a factory that receives the run's AgentWiring (event sink,

interaction, workDir, prompts), so a custom agent lands on the same dispatcher

as the defaults:

// Start from a per-backend factory and tune it:

flow(OrcaArgs(args), claude = Some(w => ClaudeAgents.default(w).opus))

// …or wrap a prebuilt agent:

flow(OrcaArgs(args), claude = Some(_ => myAgent))Factories exist for all five backends: ClaudeAgents.default(w),

CodexAgents.default(w), GeminiAgents.default(w), PiAgents.default(w), and

OpencodeAgents.default(w, launcher) — opencode's factory is applied where the

run's Ox scope exists (it pins a shared opencode serve to the scope), so its

slot is typed AgentWiring => Ox ?=> OpencodeAgent.

Every side-effecting call — git mutations (commit/push/discardUncommitted/…),

fs.write, gh writes, every agent.*.run — must happen inside a stage

body, and the compiler enforces it: a mutation outside a stage doesn't

compile. Pure reads (git.uncommittedDiff, git.changedFiles, gh.readIssue,

gh.availability, fs.read),

display, and fail run anywhere; agent.session(name, detail, seed) runs

inside or outside a stage — it records a session, not a side effect. Where to

place effects is covered by the Authoring rules.

Each flow(...) run is bound to exactly one feature branch and one progress log

(.orca/progress-<hash>.json, where <hash> is derived from the prompt):

- Start: stash a dirty working tree with a warning (recover with git stash pop); create + checkout the feature branch; write and commit the progress log header. The three flags below reach a flow as oneOrcaArgs.target(RunTarget), which has no case for a combination orca refuses.--skip-branch(RunTarget.CurrentBranch) binds the run to the CURRENT branch instead of creating one — for continuing work already planned on a branch — refusing on a protected branch or detached HEAD. On a FRESH--skip-branchrun a dirty tree is tolerated, not stashed: uncommitted or untracked files (e.g. plan files left by a planning harness) stay in place for the flow, and get swept into the first stage's commit.--keep-changes(Uncommitted.Keepon either branch case) does the same on a FRESH run in either branch mode — in normal mode the files survive branch creation and reach the new branch in that first stage commit. With neither flag, a dirty tree on a fresh run is put to the user: stash (the default), keep, or abort; with no terminal to ask, it stashes. A run that already has a progress log — a resume, or one too broken to read — always stashes and ignores--keep-changes, so an interrupted stage's partial work can't leak into the stage that re-runs.--worktree(RunTarget.Worktree) runs the whole flow in.orca/worktrees/<hash>of this repository — a second checkout, keyed on the same prompt hash as the progress log, created on the first run and reused by every later one for that task. It isolates the run: two tasks can run at once without sharing a checkout or a branch. Uncommitted work does NOT come along — a worktree is made from a commit — so--worktreeis refused with--skip-branchand with--keep-changes:RunTarget.Worktreecarries neither a branch mode nor anUncommitted, so the pair is refused while argv is parsed and has no representation after that. The first run in a worktree pays a cold build (no build outputs, no dependencies, none of the untracked local config a project may need), an editor or indexer that ignores.gitignorewill see the second checkout, and orca never removes it. The run starts on anorca-worktree-<hash>branch orca also never deletes, so full cleanup isgit worktree remove .orca/worktrees/<hash>andgit branch -d orca-worktree-<hash>; a re-run of the task refuses rather than moving that branch if it has gained commits since. Sharp edge: kept files are unprotected until that first stage commit — a failure before it runs the teardown'sgit reset --hardand destroys kept modifications to tracked files (kept untracked files survive).

- Resume: a re-run with the same prompt finds the progress log and resumes from the first incomplete stage. It says once which branch it bound, how many stages are already recorded, and that the interrupted stage's uncommitted work was not carried over; a re-seeded agent session is told the same. A corrupt or truncated progress log is detected at startup — orca warns and starts fresh (previous stages re-run) rather than silently mis-resuming.

- Success teardown: remove the progress-log file in a final commit, and push

it when the remote branch still carries the log (i.e. the flow pushed). A

throwaway feature branch (no substantive changes vs the starting branch) is

deleted and HEAD returns to the starting branch. Otherwise the feature branch

is kept, and where HEAD lands follows the run: a run that created a branch and

opened a PR hands you back the branch you started on (the work is on the

PR). Every other run leaves you where you were — on the feature branch when no

PR was opened or under --skip-branch, and untouched under--worktree, where the work is in the separate checkout the summary names. The run then closes by naming the branch you are left on, the PR it opened if it opened one, how many files changed since the commit it started from, and thegit diffthat shows them.

- Failure teardown: discard the failed stage's uncommitted partial edits —

git reset --hardfor tracked files, plusgit clean -fdfor the files it newly created; stay on the feature branch so a re-run resumes in place. Gitignored paths and.orca/are never removed. Whether the clean runs at all is decided once, at setup, for the whole run: a FRESH run that kept a dirty tree instead of stashing it (--skip-branch,--keep-changes, or the interactive keep answer) leaves orca unable to tell those files apart from the run's own — no untracked file is deleted, in any stage, including ones the failed stage created.

Two files, both plain key = value lines, parsed once per run before setup:

- {workDir}/.orca/settings.properties— committed, hand-editable project settings: the stack commands (- format/- lint/- test) and, per role, which agent to use.

- $XDG_CONFIG_HOME/orca/settings.properties, defaulting to- ~/.config/orca/settings.properties(also on macOS) — a per-user default, agent keys only. An absent global file is simply skipped.

Precedence, code always winning over files:

- Roles: flow(planningAgent = ...)(andcodingAgent/reviewAgent) programmatic override > project file > global file > built-in default (claude, no model pin).

- Stack commands: reviewAndFixLoop(formatCommands = Use(...)/Off)>flow(stackSettings = Some(...))> project file > auto-discovery (which writes the file).

An unreadable or malformed file — project or global — aborts the run before any tree mutation; the global file may contain ONLY agent keys, so a stack key there is also an error.

Stack commands. Keys format, lint, and test. Each value is one shell

command, run via bash -c in the flow's working directory; everything after the

first = is command text (lint = FOO=bar cargo check works). Repeating a key

appends — the task's commands run in file order, so a multi-stack repo lists one

line per stack half. A key's value may also be the literal off, which

explicitly disables that task; a missing key has the same runtime effect (the

gate is skipped) but, unlike off, does not count as "configured" — see

Auto-discovery below. # lines are comments; commenting out a line is the same

as deleting it. A typical discovered project file:

# orca settings — edit freely, commit with the project.

# format/lint/test: one shell command per key; `off` disables the gate. Delete the stack lines (or the whole file) to re-run auto-discovery.

# planningAgent/codingAgent/reviewAgent (harness[:model]): override the global settings file; a flow's own code overrides both.

# Cargo.toml; via rustfmt

format = cargo fmt

# Cargo.toml

lint = cargo check --tests

# no test config found

test = offAgent keys. planningAgent, codingAgent, and reviewAgent, valid in both

files, single-valued (a repeated agent key is an error). Value grammar:

harness[:model], split at the first : so a model id containing : survives;

harness is one of claude, codex, opencode, pi, gemini (an

unrecognised name is an error naming the valid set). The model part is passed

verbatim to the harness's withModel — orca does not normalise or validate

model ids, except that claude's bare haiku alias is sent as

claude-haiku-4-5, so a claude:haiku pin cannot land on a pricier tier when

the CLI resolves the alias. For example:

planningAgent = claude:opus

codingAgent = codex:gpt-5-mini

reviewAgent = opencode:anthropic/claude-haiku-4-5Agent keys are read even when flow(stackSettings = Some(...)) overrides the

stack commands — that override governs the stack portion only, and a malformed

project or global file still aborts the run either way. setup announces the

resolved roles and where each came from:

agents: planning=claude:claude-opus-5[1m] (default), coding=codex:gpt-5-mini (project), review=opencode:<harness default> (global)

<harness default> marks a role where nothing pins a model, so the harness

picks one itself.

Auto-discovery. Discovery runs when the project file is absent or has no stack line; discovered entries are appended below any existing content, so agent lines are never touched. Delete the stack lines (or the whole file) to re-run it. Discovery spends one cheap-model, read-only agent call inspecting the repo, then writes the file and announces every guess in the event log:

no .orca/settings.properties — discovering how to format, lint & test this project

format = cargo fmt # Cargo.toml; via rustfmt

lint = cargo check --tests # Cargo.toml

warning: stack settings: no test command — gate disabled

written to .orca/settings.properties — review and edit as needed.

Runs with an existing, stack-complete file — the steady state, including CI — make no model call.

Reviewer prompts. Reviewers come from three tiers, read once per run before setup like the settings files:

- {workDir}/.orca/reviewers/*.md— committed project reviewers.

- $XDG_CONFIG_HOME/orca/reviewers/*.md, defaulting to- ~/.config/orca/reviewers/— your own, across every project.

- The eight reviewers orca ships with.

A reviewer's identity is its filename stem — .orca/reviewers/orca.md is the

reviewer orca — compared case-insensitively. A file whose stem matches a

lower tier replaces it, keeping its position in the roster; anything else is

appended, sorted by name. Project beats global beats built-in, so

.orca/reviewers/scala-fp.md retunes the shipped scala-fp for this project

without changing how many reviewers run. A reviewer that adds a new name joins

both allReviewers and minimalReviewers; one that shadows a shipped reviewer

runs wherever that shipped reviewer runs, so shadowing scala-fp leaves

minimalReviewers — correctness, clarity, tests — alone. The picker narrows

per task as usual.

Each file is frontmatter plus a body, the same shape the shipped ones use:

---

description: Checks the project's own layering rules.

files: \.scala$

---

## Scope

Review only the layering of the changed files...description: is required and must be a single line — the reviewer-picker

decides from it. The value is the rest of that line, so a YAML block scalar

(>, |, >-, |-) or a wrapped continuation leaves a description of > or

half a sentence: nothing aborts, and the picker never selects that reviewer.

files: is optional: a regex matched against each changed path, so the

reviewer is only offered when the change touches a file it applies to. The body

is the reviewer's system prompt. A name: key, if present, is ignored.

README.md and any _-prefixed name sit in the directory as documents. Every

other .md must parse as a reviewer: a missing or unterminated frontmatter

block, a missing description:, an empty body, an invalid files: regex, or

two files claiming one name abort the run before any tree mutation, naming every

bad file at once — a reviewer silently dropped from the roster would read as a

clean review. A symlinked prompt aborts too, but only in .orca/reviewers/:

that directory is committed and orca runs against repos it did not write, while

the global tier is your own config home and is read through links like

settings.properties beside it. When a tier contributes

anything, the run says so:

discovered reviewers: orca (project); scala-fp (project, shadows built-in)

Discovery internals and the .orca/ directory

Every discovered command cites the file that evidences it, and two checks run

before the file is written: the command's executable must be on PATH, and the

cited evidence file must exist. A command failing either is demoted to a live

key = off line with the rejected command and reason as an informative comment

above (# just check: just: not found on PATH / lint = off), never run

silently. A discovery failure (backend unavailable, invalid output) aborts the

run rather than writing a "gates off" file.

.orca/ is committed by default: settings and the progress log ride the branch,

while scratch lives under .orca/cache/, which writes its own .gitignore. If

your .gitignore covers all of .orca/, every run warns to remove that line so

settings can be committed — the cache stays ignored on its own.

Within a flow body the resolved stack settings are available as

summon[FlowContext].stackSettings — a StackSettings(format, lint, test: List[String]). The test commands are not consumed by reviewAndFixLoop (the

lint gate stays deliberately cheap); they're there for a flow's own verification

stages.

Three rungs, by how long the conversation must live — the handle you hold tells you which one you're on:

The rule: name + seed ⇒ durable; anonymous ⇒ gone on crash. Structured

output mirrors it (agent.resultAs[O].{autonomous,interactive}.run(input),

chat.resultAs[O]..., session.resultAs[O].run(input)), and interactive

exists only on the ephemeral rungs — a live human steering a turn can't be

replayed from a seed, so durable interactive sessions don't exist by

construction.

- Durable — agent.session(name, detail, seed). A get-or-create keyed by(name, detail), returning aFlowSessionhandle that survives crash/resume: the same key resumes the same session (with a warning if this call's seed differs, rather than silently resuming the wrong one).nameis the role, and whatorca continue <name>matches.detailis free text saying which session under that role this is — the task it serves, or what a one-per-run session covers ("the whole planned change"). Both are required, and both show in the run manifest and inorca continue, which lists a session asname (detail)so per-task sessions sharing a role are told apart. Minting one key twice in a run is an error, not silent sharing: give the second call a detail of its own. A changed detail is a different session, so a re-plan that rewords a task gives that task a fresh session primed from the seed rather than resuming the old wording's conversation. Mint it where it is used: inside the stage that drives it, or above the stages when several share it. A handle cannot be minted in one stage and driven by a later one —FlowSessionhas noJsonData, so it can't leave a stage as that stage's result. Minting and running both happen on the flow thread.

- Ephemeral — agent.chat(). AChathandle continuing one conversation across.runcalls within this run only — no seeding, no persistence. Runs need only the sharedInStagecapability, so chats work inside aPar.mapUnorderedfork: parallel reviewers each holding a multi-turn conversation is the canonical use.agent.chat(session.id)adopts a durable session's conversation as an ephemeral chat — the escape hatch for follow-ups from a fork (turns are not persisted; one live continuation at a time).

val session =

agent.session("implementer", detail = task.title, seed = plan.brief)

session.run(task.description)

val chats = Par.mapUnordered(4)(reviewers): r =>

val c = r.chat()

c.run(s"review the diff: $diff")

c // keep the conversation for a later re-review turnThe seed is the essential context to rebuild the agent — typically the plan

brief, or the issue body when there is no brief. A fresh session is primed

with it on first use; if the backend lost the conversation on resume, the

session is re-seeded (with a warning: history is gone, only the seed plus a

preamble naming completed stages are rebuilt), while a live session just

continues with its full history.

How long a session should live. A backend conversation is re-sent whole on every API call it makes, so what a session costs grows with everything it has already done. Scope one to a unit of work — a task, a review stage — not to the run: the shipped flows mint a session per task and another for the final review, and each new one is primed from its seed and the completed-stage preamble.

agent.cheap returns the backend's cheap/fast variant (claude → haiku, codex →

mini, gemini → flash, opencode → anthropicHaiku, others → self) — used by the

runtime for branch naming and default commit messages.

Backend swaps across runs. If a settings edit changes a role's agent

between runs (e.g. codingAgent = codex becomes codingAgent = claude), a

session recorded under the old backend isn't resumed against the new one —

orca mints a fresh session from the seed and warns.

Mutations outside a stage body are compile errors (see Side effects happen inside stages). The rules below are the structural conventions you choose to follow as a flow author.

-

Reads outside, mutations inside. Only side-effecting work goes in a stage. Pure reads ( git.uncommittedDiff,gh.readIssue,fs.read,gh.waitForBuild) run outside stages — staging them wastes commits and checkpoints.agent.session(name, detail, seed)is neither — it records a session — so put it where the session is used (see Sessions).

-

Push lives in a later stage than the edit that produced it. A stage commits only on completion: a git.push()in the same stage as the edit would push nothing (the edit isn't committed yet). The push must be in a separate, later stage:stage("Write failing test"): session.run("Write the failing test …") // commits on completion val pr = stage("Push + open PR"): // LATER stage — the test commit exists now git.push().orThrow gh.createPr(title = …, body = …).orThrow

-

One commit per stage. Each stage produces exactly one commit (code changes + the progress-log entry). Don't call git.commitinside a stage body — the runtime commits for you when the stage completes.

-

Idempotent external effects, each in its own stage. Put each PR-open, comment-post, or push in a dedicated stage so it's checkpointed. gh.createPris idempotent by branch (an open PR is reused, not duplicated) andgh.upsertComment(target, marker, body)edits a prior comment carryingmarkerin place — so if a crash re-opens the stage on resume, the re-run reuses the PR/comment instead of duplicating it. UseorcaCommentMarker(userPrompt, purpose)so the marker is unique to this run.

-

Name stages descriptively. The stage name appears in the event log, the commit message (when no override is provided), and the progress preamble on resume. A name like "Push + open PR"lets a reader (and the resuming agent) understand the checkpoint without reading code.

Orca gates side effects behind three capability tokens. You normally never

construct one — stage(...) bodies provide them, and a missing token is a

compile error with a message telling you where the call belongs:

(FlowContext — reads and event emission — is deliberately not a capability:

it is thread-safe and forks receive it freely.)

The runtime always guards this at run time — a fork that calls

stage(...)/session(...) fails immediately, a second flow(...) in the same

working tree is refused, an agent used after its flow ended throws — so you get

the safety without any setup.

Compile-time checking (Scala's experimental capture checking)

The shared/exclusive split is capture checking vocabulary. Beyond the always-on runtime guards, enforcement moves to compile time in two more places:

-

Inside the library: orca's own parallel code (the reviewer fan-out) is compiled under capture + separation checking, so a change that captured a WorkspaceWriteinto that fan-out would not compile (pinned by a compile-time test suite).

-

Opt-in, in your script: add the two language imports to have the compiler check your code too — today that enforces, e.g., that a custom ReviewerSelector's per-round function stays pure:import language.experimental.captureChecking import language.experimental.separationChecking Full fork-boundary checking in scripts arrives when Ox itself adopts capture checking; until then the runtime guard covers that case.

The imports cost nothing when omitted — scripts without them compile and run identically (see ADR 0018 §6).

Available via import orca.plan.*:

The planning entry points form a mode × operation grid. The two axes are

orthogonal — every combination is valid. Mode is picked at the call site

(Plan.autonomous.* vs Plan.interactive.*), mirroring how Agent itself

splits autonomous / interactive:

Every cell returns Sessioned[B, <result>] — the result paired with the

(ephemeral) Chat that produced it. Continue that conversation in-run

(chat.run(task); continuations have write access), or .value it and start a

fresh, durable implementer session via agent.session("implementer", detail = task, seed = plan.brief) — the chat does not survive a crash/resume, so every

shipped example takes .value. Destructure when you want both: val Sessioned(chat, plan) = Plan.autonomous.from(...).

From a Sessioned[B, Plan], an optional .reviewed(agent) step refines the

plan before implementing — the planner critiques its own draft, producing an

improved Plan. Chain it: Plan.autonomous.from(...).reviewed(claude).value.

assessThenPlan returns a Verdict: Verdict.Proceed(plan) to implement, or

Verdict.Rejection(kind, body) — a follow-up question, critique, or rebuff the

caller surfaces back to the reporter. triage returns a Triage sum type the

caller pattern-matches (NotABug / Untestable / Testable).

Review utilities, available via import orca.review.*:

reviewAndFixLoop's stack-dependent parameters are three-state

(orca.Configured), so omission means "from the project's stack

settings" while "explicitly off" stays expressible:

enum Configured[+A]:

case FromSettings // resolve from the run's stack settings (the default)

case Off // explicitly disabled for this call

case Use(value: A) // explicit value; settings ignoredFromSettings resolves formatCommands to stackSettings.format and builds

the lint gate as Lint(stackSettings.lint, reviewAgent.cheap) — commands plus

the summariser agent bundled in one value (Lint(commands: List[String], agent)). An empty list resolves to no gate at all: FromSettings over empty

settings behaves exactly like Off. A script that omits lint gets a lint gate

whenever the target project's settings define one; for format-only, pass lint = Configured.Off.

The change set reviewers are shown — and that the selector picks from — is

everything the enclosing stage has produced since it began, so it is the same

whether or not the coding agent committed its own work along the way. It is

re-sampled each round and sent to every reviewer that runs, resumed ones

included, so each round's reviewers see the fixes made before it. Pass

diff = ReviewDiff.Pinned(...) to pin it instead: reviewers are then not told a

base commit, the selector's changed-file list is scraped from the diff text, and

every later round finds the same text, so a resumed reviewer is told there is no

new change set.

diff = ReviewDiff.WholeRun widens it to everything the run has changed since

it started — since the commit HEAD pointed at when the run bound its branch,

recorded in the progress log — so a stage placed after the per-task work

reviews the whole branch, earlier stages' commits included. Reviewers are told

the change set spans every stage. A run whose log records no usable commit (a

log from before orca recorded one, or one whose commit no longer sits behind

HEAD after a rebase) has no base: the call says so in a step and returns without

reviewing.

That is the final-review half of the shape every task-based built-in flow uses

— reviewThenFix per task, then this once:

stage("Final review"):

reviewAndFixLoop(

coderSession = session,

reviewers = allReviewers(reviewAgent),

task = Task(Title("The whole planned change"), plan.brief),

diff = ReviewDiff.WholeRun,

maxIterations = 5

)The cap is above the library default of 3 because nothing reviews again after

this loop. Each flow hands what it returns to its PR step

(openPrIfGitHub/openPrFromBranch), which lists every finding still open in

the PR body.

A change set past 128 KiB is cut down before it is sent: the reviewer gets as many whole files as fit, then a list naming every other changed file with its line counts, and reads those files itself. Without that, the largest change sets make a request no model can accept. A pinned diff is sent as given.

reviewAndFixLoop's reviewerSelection defaults to ReviewerSelector.default,

which narrows twice: a picker LLM on reviewAgent's cheap tier chooses from the

supplied list for round one, seeing each reviewer's description and the changed

file paths; every later round then re-runs only the reviewers that reported an

issue in the previous one. A reviewer that stays quiet stops costing a turn per

round — the trade-off is that it won't see the fixes made after it stopped. If

narrowing would leave no reviewer at all (everyone quiet, while a lint finding

keeps the loop going), the round's full selection runs again and a step says so.

A reviewer declaring a files: pattern in its frontmatter (of the shipped set,

only scala-fp) is offered to the picker only when a changed file matches it —

unless nothing is known about the change set, in which case it stays eligible.

The selector reads each reviewer's name, description and pattern off its

Reviewer, so your own reviewers are described and gated the same way.

To swap or extend the reviewer set for one project, drop .md files in

.orca/reviewers/ — no code changes (see Settings). To do it from

the flow, compose your own List[Reviewer] from reviewerCatalog.all (the

run's resolved set), ReviewerPrompts (the shipped entries alone), and/or your

own Reviewer(name, description, systemPrompt), then turn it into

ReviewerAgents with buildReviewers(base, list).

PR utilities, available via import orca.pr.*:

Every domain helper that bundles an LLM brief takes its prompt as a

default-valued instructions: String; the default lives on a sibling

XxxPrompts object. Override it, or compose with the default to extend it:

import orca.plan.{Plan, PlanPrompts}

Plan.interactive.from(

userPrompt,

claude,

instructions = PlanPrompts.Planning + "\n\nPrioritise observability tasks first."

)Where the defaults live

- orca.plan.PlanPrompts—- Planning,- AssessThenPlan,- Triage,- Review

- orca.pr.PrPrompts—- Summarise

- orca.review.ReviewLoopPrompts—- Fix,- SelectReviewers,- SummariseLint

- orca.review.ReviewerPrompts— per-reviewer system prompts (compose your own list to swap or extend- allReviewers/- minimalReviewers)

The lower-level per-call wrappers (autonomous/interactive/retry) are a separate

layer — replace the whole set via flow(prompts = ...). See ADR

0010 for the full convention.

Common types you'll see in flow scripts. Most derives JsonData, making them

valid stage results (the stage log can record and replay them) and usable as

structured LLM output via claude.resultAs[T]. Exceptions: Sessioned and

Verdict do not derive JsonData — they are intermediate values, not stage

results.

The types, in detail (click to expand)

- orca.plan.Plan(epicId, description, tasks, brief)— the task list the agent generates in one round-trip.- epicIdis a kebab-case identifier for the plan itself (heads its markdown render) — NOT the git branch name; the flow derives and announces its own branch separately (see- BranchNamingStrategy).- descriptionis the planner's epic summary;- briefis a concise codebase briefing always included (feed it to- agent.session("implementer", detail = task, seed = plan.brief), which threads it as the seed).- taskPrompt(task)prepends the brief to a task's description.

- orca.plan.Task(title, description)—- titleis the human-readable label shown in the event log.

- orca.plan.Sessioned(chat, value)— every- Plan.{autonomous, interactive}.*operation returns one: the result paired with the (ephemeral)- Chatthat produced it, so the caller can continue that conversation in-run or- .valueit and start fresh.

- orca.plan.Verdict[A]—- Verdict.Proceed(value)or- Verdict.Rejection(kind, body)(kind ∈ Question / Critique / Rebuff). Returned by- assessThenPlanas- Verdict[Plan].

- orca.plan.Triage— sum type returned by- triage:- NotABug,- Untestable, or- Testable— each carrying exactly the fields its branch needs.

- orca.plan.BugReportMatch— the agent's decision on whether a CI failure matches the original report.

- orca.FlowSession[B]— durable, resumable session handle returned by- agent.session(name, detail, seed). Bundles the agent with its- SessionId; call- .run(prompt)or- .resultAs[O].run(input)on it to drive the agent, with automatic seed/preamble replay (when the backend conversation isn't live) and resume-wire-id persistence.- agent.chat(session.id)adopts its conversation as an ephemeral- Chat(the fork-side escape hatch).

- orca.agents.Chat[B]— ephemeral multi-turn conversation handle from- agent.chat(): tool-using and workspace-editing like any agent turn ("chat" names its lifetime, not its powers), in-run only, fork-safe. Also carried by- Sessionedfor planning-conversation continuations.

- orca.agents.SessionId[B]— typed session id, parameterised by backend, exposed via- FlowSession.id. Carries the backend identity at the type level, so you cannot accidentally pass a Claude session to Codex.

- orca.Title— opaque- Stringalias for short labels (- Task.title,- ReviewIssue.title);- Title("…")to construct,- .valueto read.

- orca.tools.PrHandle(host, owner, repo, number)— handle to an open pull request, returned by- gh.createPr.- hostis- github.comor a GitHub Enterprise hostname, and every- ghcall taking the handle is routed to it.- derives JsonDataso a stage can record it: a push-and-open-PR stage is the checkpoint before a CI wait.- IssueHandlecarries no host, so the issue flows read their issue from gh's default host (- GH_HOST, else the host gh is logged in to).

- orca.tools.GitHubAvailability— what- gh.availabilityanswers with.- Available(host, owner, repo): the repository gh resolves, on github.com or a GitHub Enterprise host.- Unavailable(why): no PR can be opened;- whyis a- GitHubUnavailable—- NoRemote(no- origin),- NoHost(remote)(- originhas no host, a local path),- NotGitHub(host)(gh has no login for that host, so a GHES host needs- gh auth login --hostname <host>),- Unreachable(host, reason)(the host is GitHub, but gh could not answer for it —- reasonis gh's own words), or- GitUnusable(reason)(git itself could not be run, so nothing is known about the checkout).- why.explanationrenders that as one line to put a flow's own next action after.

- orca.pr.PrSummary(title, body)— what- summarisePrreturns. The two fields feed- gh.createPr(title = …, body = …)directly.

- orca.review.ReviewIssue/- ReviewResult— what reviewer agents return. Issues carry a- title(shown), a long- description(sent to the fixer), and an optional- location.

- orca.review.FixOutcome(fixed, ignored)— what the fix step returns: the titles of issues actually fixed in code, plus titles + reasons for issues set aside (environmental, out of scope, false positive). The loop re-evaluates iff- fixedis non-empty.

- orca.review.IgnoredIssues— accumulated- IgnoredIssue(title, reason)entries surfaced by- reviewAndFixLooponce it halts.

- orca.StackSettings(format, lint, test)— the resolved per-project tooling commands (each field a- List[String], run via- bash -c; empty = task disabled). Resolved once per run — see Settings — and read back via- summon[FlowContext].stackSettings; pass- flow(stackSettings = Some(...))to pin it.

- orca.Configured[A]— three-state default for- reviewAndFixLoop's stack-dependent parameters:- FromSettings(the default — resolve from the run's stack settings),- Off(explicitly disabled for this call), or- Use(value)(explicit value; settings ignored).

- orca.review.Lint(commands, agent)— the lint gate bundle- reviewAndFixLoopruns alongside the reviewers: the shell commands plus the (cheap) agent that summarises their output into a- ReviewResult.

While Orca runs the terminal output is split into two zones: an event log that grows top-to-bottom as stages and tools fire, and a status line pinned to the bottom, showing the active stage breadcrumb with a spinner. Nested stages are indented.

Glyph legend

Colours and animation auto-disable when stderr isn't a terminal. Set

NO_COLOR=1 or ORCA_NO_ANIMATION=1 (suppresses the spinner) to force them

off.

Each CLI manages its own auth; Orca stores no secrets. Before running a flow,

log in to the backend you use — claude, codex, opencode, or pi — and to

gh (for the GitHub helpers), each per its own instructions.

OpenCode with a local Ollama model

- Launcher (zero config): flow(OrcaArgs(args), opencode = Some(w => OpencodeAgents.default(w, OpencodeLauncher.ollama("qwen3-coder")))). Orca starts the server viaollama launch opencode, which injects Ollama's provider config and pins that one model — use bareopencode, nowithModel. Needs theollamaCLI and the model pulled.

- Manual config: declare an ollamaprovider in~/.config/opencode/opencode.json(baseURLhttp://localhost:11434/v1, your models,num_ctxraised for tool use), thenopencode.withModel("ollama", "qwen3-coder"). Supports several models and per-turn switching.

Orca is published to Maven Central — scala-cli fetches the artifacts on first

run:

scala-cli run implement.sc -- "your task here"For a guided start, install Orca Shell instead: its first-run wizard configures the role agents and models for you.

Orca Shell is an interactive terminal front-end for the same flow scripts: a

first-run wizard picks a harness and model for each of the

planning/coding/review roles — writing the same global settings.properties

described under Settings — then a menu lets you discover flows

(project, global, and built-in), run one, view or edit its source, create a new

flow (or fork an existing one) with the configured role agents' help, or

continue a session left by a previous run. It launches flows the same way

scala-cli run does — direct scala-cli run flow.sc -- "task" keeps working

unchanged.

Every action in the interactive menu also has a scriptable subcommand — orca

with no arguments starts the interactive shell; orca <command> ... runs one

action non-interactively and exits.

create, fork, edit, continue's resume, and config --edit each need a

real terminal and error cleanly if run without one; run, view, list,

config (without --edit), and clear-stack --yes work fine piped or in CI.

Examples:

orca run implement.sc "add a rate limiter to /login"

echo "add a rate limiter" | orca run implement.sc

orca list --json | jq -r '.[].name'

orca create "add a token-bucket limiter" --name rate-limit.sc

orca continue # resume the last session

orca continue --list

orca config --coding-agent codex

orca config --review-agent claude:sonnet

orca view implement.scRun orca --help for the full command list, or orca <command> --help for a

command's own flags. Exit codes: 0 success, 1 action failure, 2 usage error —

orca run propagates the flow's own exit code, which makes it CI-friendly.

Install it with:

curl -fsSL https://raw.githubusercontent.com/VirtusLab/orca/master/install.sh | bashThe script does exactly two things:

- If scala-cliisn't on yourPATH, it downloads and runs scala-cli's official installer (which places scala-cli in its own versioned location and updates your shell profile; scala-cli then manages its own JVM).

- It writes the orcaexecutable to~/.local/bin/orca— a short launcher script that runs the latest releasedorca-shellviascala-cli. Nothing else is downloaded at install time; the artifacts are fetched on the firstorcarun, and the launcher never needs a version bump.

Add ~/.local/bin to your PATH if the installer says it isn't there yet, then

run orca.

To avoid installing anything, or to pin a version (e.g. in CI), run the shell

directly instead. The pinned form works from the first release that includes the

shell; the version below always tracks the latest release. --workspace keeps

scala-cli's own build metadata out of the current directory (it lands under the

given directory instead):

scala-cli run --workspace "${XDG_CACHE_HOME:-$HOME/.cache}/orca/shell/workspace" --jvm 21 --quiet --verbose --dep "org.virtuslab::orca-shell:0.1.7" --main-class orca.shell.Main- adr/— architecture decision records. ADR 0018 describes the current stage-bound runtime; the ADR index covers module layout, backends, the flow DSL, and reviewers.

- CONTRIBUTING.md— building, testing, and running a locally modified orca.

- AGENTS.md— internals, architecture, and coding conventions; the same file AI assistants pick up.

Apache 2.0 — see LICENSE.

Copyright (C) 2026 VirtusLab https://virtuslab.com.