██████╗ █████╗ ██████╗ ██╗ ██╗██╗ ███████╗
██╔══██╗██╔══██╗██╔══██╗██║ ██║██║ ██╔════╝
██████╔╝███████║██║ ██║██║ ██║██║ █████╗
██╔══██╗██╔══██║██║ ██║██║ ██║██║ ██╔══╝
██║ ██║██║ ██║██████╔╝╚██████╔╝███████╗██║
╚═╝ ╚═╝╚═╝ ╚═╝╚═════╝ ╚═════╝ ╚══════╝╚═╝
· the wolf that plans ·
A local-first agent loop that turns a task into a reviewable diff.
Point it at a repo. Describe a task. Watch the wolf plan, work, and hand you back a diff.
Radulf (RAH-doolf) — from Old Norse Ráðúlfr:
ráð("counsel, plan") +úlfr("wolf"). The wolf that plans. It's also the root of the name "Ralph" — a nod to the Ralph Wiggum loop beating at its core.
You describe a coding task against a local Git repo. Radulf's agents — a planner, a loop, and an evaluator — carry it through a Ralph Wiggum loop inside an isolated git worktree, retrying and revising until the acceptance criteria hold. What comes back to you is a reviewable diff. You approve; it merges.
describe ──▶ 🧭 plan ──▶ 🔁 loop ──▶ 🔎 evaluate ──▶ 📋 diff ──▶ ✅ merge
you └──────── the wolf ────────┘ ▲ you you
│ revise
└────────────────┘
Auto Mode pulls the next queued task the moment a slot frees, so the pipeline keeps moving without you. Improvement Runs go further: Radulf proposes its own work, drives it end to end on auto-approve, and stacks every approved change on one branch until the time budget runs out.
Radulf's own repo is a valid target — so you can point it at itself and let it improve its own code.
Status: working application under active development. Card lifecycle orchestration, review/merge flows, multi-provider agents, analytics, benchmarks, optional authentication, and runtime-history cleanup are all implemented. The full guides live in
docs/and are browsable in-app under the Docs tab;specs/is the dated record of what was decided and when.
- The big idea
- Quick start · clone, install, log in, run
- Requirements
- How it works
- Improvement Runs · let it improve a repo on a time budget
- Configuring agents
- Authentication (optional)
- Make targets
- Project layout
- Contributing
- License
Every agent role runs through one harness: pi
in SDK mode, in-process — no subprocess, no claude/codex/opencode CLIs to install.
Each role can use any supported provider; the provider only decides auth.
flowchart LR
subgraph roles["Agent roles"]
P["🧭 Planner"]
L["🔁 Loop"]
E["🔎 Evaluator"]
end
subgraph harness["One harness · pi SDK (in-process)"]
H(("pi"))
end
subgraph providers["Providers · differ only in auth"]
A["Anthropic / Claude<br/>subscription · default"]
C["ChatGPT / Codex<br/>subscription"]
G["GitHub Copilot<br/>subscription"]
O["OpenRouter<br/>API key · remote"]
M["oMLX<br/>local · Apple Silicon"]
end
P --> H
L --> H
E --> H
H --> A
H --> C
H --> G
H --> O
H --> M
Next.js (App Router) + TypeScript + SQLite (Drizzle), single user on localhost by
default. Loops work in per-card git worktrees so your main checkout is never touched.
See specs/13-single-pi-sdk-harness.md for the harness design.
# 1 · Clone
git clone https://github.com/lhansen-dev/radulf.git
cd radulf
# 2 · Install
make install
# 3 · Log in a provider (opens pi — type /login, then Ctrl+C)
make login
# 4 · Run
make devThen open http://localhost:3000 🎉
Note
main is the stable branch and always sits at the latest release, so the clone
above needs no extra flags. To try unreleased work, clone the integration
branch instead — git clone -b beta https://github.com/lhansen-dev/radulf.git
— or pin an exact release with -b v1.0.0.
The SQLite database and every runtime directory (./data, plus the agent-writable
./worktrees, ./plans and ./runtmp beside it) are created automatically on first
run and are all gitignored — no manual migration step needed.
Tip
First time here? Once the dev server is up, open the in-app Docs tab
(desktop rail / mobile bottom nav, or /docs). Its
Start-here group walks you through the idea, getting set up, and how the pipeline
actually works — the same guides that live in docs/.
Radulf needs at least one provider. For the subscription providers, this is a one-time interactive login through pi's own UI, pointed at Radulf's agent dir:
make login # opens pi → type /login → pick Claude, ChatGPT, or Copilot/login is a command you type inside pi, not a shell command. Quit pi when it
reports success (Ctrl+C); the credential lands in data/pi-agent/auth.json, which is
where Radulf looks. make login is what points pi at that directory — running pi
straight from your shell would write to ~/.pi/agent/ instead, and Radulf would still
see no provider.
OpenRouter and oMLX need no login — set them in the app's Settings page (an OpenRouter API key, or the oMLX base URL). See Requirements for the full matrix.
| What | Detail |
|---|---|
| 🖥️ OS | macOS (Apple Silicon or Intel). Linux (incl. WSL2) is best-effort / future — the sandbox has a Linux implementation but is not release-verified there. Native Windows and WSL1 are not supported. |
| 🟢 Node | 22 or newer |
| 📦 pi SDK | @earendil-works/pi-coding-agent — a pinned dependency. No global install, no CLIs. |
| 🔒 Sandbox runtime | @anthropic-ai/sandbox-runtime — a pinned dependency (spec 14). Kernel-enforced containment for agent bash: Seatbelt (sandbox-exec) on macOS, bubblewrap + a seccomp filter on Linux. On Ubuntu 24.04+, the default AppArmor policy blocks bubblewrap's unprivileged user namespace — Radulf's startup preflight detects this (kernel.apparmor_restrict_unprivileged_userns=1) and logs the exact remediation (grant bwrap the userns capability via an AppArmor profile, or set the sysctl to 0). The sandbox is on by default (sandboxEnabled in Settings); turning it off is a deliberate, logged escape hatch — see spec 14's Failure semantics. How it's implemented: SANDBOXING.md. |
| 🔑 A provider | At least one of the five below |
Provider matrix — pick one or mix per role:
| Provider | Auth | Where | Notes |
|---|---|---|---|
| 🟣 Claude (default) | make login → /login |
Claude Pro/Max subscription | Third-party harness usage billed per token as extra usage |
| 🟢 ChatGPT (Codex) | make login → /login |
ChatGPT Plus/Pro subscription | — |
| ⚫ GitHub Copilot | make login → /login |
GitHub Copilot subscription | — |
| 🔵 OpenRouter | API key | remote | Bring your own model. Set in Settings — no login |
| 🟠 oMLX | base URL | local, Apple Silicon | Optional. Set base URL in Settings — no login |
make loginis a one-time interactive step for the subscription providers. It opens pi against the Radulf agent dir (data/pi-agent/);/loginis typed inside pi.
Disk limits (spec 14)
Nothing in the container/cgroup world bounds disk use the way memory.max
bounds RAM, and legacy UNIX quotas are per-UID — since Radulf runs as the
same user as the agent, a quota would throttle the server alongside the
agent it's supposed to contain. The default on every platform is a
polling watchdog + ballast file: the orchestrator samples worktree size
and volume free space every few seconds and fails a run past a threshold
with real headroom; a pre-allocated few-GB ballast file is deleted under
disk pressure to keep the host usable while it recovers. This needs no
operator setup and is what a fresh install gets — reactive, not preventive
(an agent can write several GB between samples on fast NVMe).
Two hardened, opt-in alternatives for operators who want a real ceiling
(macOS only — Linux gets an actual hard wall from the cgroup v2 memory.max
already in force):
-
APFS volume with a quota — a genuine, kernel-enforced ceiling. Writes past it fail with
ENOSPCinstead of the watchdog's best-effort catch. One-time setup, requires disk ownership:# Find your container's disk identifier (usually disk3s1's container, e.g. disk3): diskutil list # Add a volume with a 20 GB quota, mounted where RADULF_WORKTREES_DIR points: diskutil apfs addVolume disk3 APFS RadulfWorktrees -quota 20g
Point
RADULF_WORKTREES_DIRat the new volume's mount point. Space stays shared from the container's pool — the quota caps allocation, it doesn't reserve a fixed partition, so it costs no disk until a run actually fills it. Worktrees living on a different volume fromdata/is fine:git worktreeuses a pointer file, not hardlinks, so it works across volumes (unlikegit clone --local). -
Sparse disk image (
hdiutil, APFS sparsebundle) — created and destroyed per run, which also handles cleanup automatically. Two costs: a sparsebundle doesn't reclaim freed space withouthdiutil compact, and detaching it fails while any process still holds a file open inside it — which is exactly why process-group reaping runs before every integrity check (spec 14 Phase 1f/1g), not after.
Every run row stamps diskLimitMechanism so "what actually bounded this run"
is answerable from the run detail rather than assumed from the platform:
cgroup on Linux; on macOS, apfs-quota is auto-detected when the run's
worktree lives on a quota volume (diskutil reports the volume's TotalSize
below the shared container size — verified live against a real quota volume),
otherwise watchdog. sparse-image stays a reserved value for the per-run
sparsebundle option, which is not auto-detected.
A card starts safely in Backlog. Move it to the ordered Todo queue when it's ready — Auto Mode (on by default) then picks it up. The pipeline runs, and the card advances through the lifecycle on its own.
stateDiagram-v2
direction LR
[*] --> Backlog
Backlog --> Todo: you release it
Todo --> InProgress: Auto Mode picks it up
state InProgress {
direction LR
Plan: 🧭 Plan
Loop: 🔁 Loop
Eval: 🔎 Evaluate
Plan --> Loop
Loop --> Eval
Eval --> Loop: revise (feedback = next task)
}
InProgress --> InReview: evaluator approves
InProgress --> NeedsAttention: agent hit an issue
NeedsAttention --> Todo: you fix + requeue
InReview --> Done: you approve → merge
Done --> [*]
The pipeline, step by step:
- 🧭 Plan — a frontier agent turns the card into a plan (
PLAN.md,PROMPT.md, acceptance criteria). - 🔁 Loop — a loop agent implements one task at a time against a git worktree, running its targeted check each iteration. The repo is the memory between fresh-context runs.
- 🔎 Evaluate — the sole whole-card verifier. It independently inspects the loop's
diff and runs every acceptance criterion, then either sends concrete feedback back to
the loop (
revise→ becomes the loop's next task) or clears the change (approve). A bounded revision limit escalates to you if it stalls. - 📋 Review — the evaluator-cleared diff waits in In Review with diff + transcript. Approve to merge into Done; reject with feedback to send it back.
States at a glance:
| State | Meaning |
|---|---|
Backlog |
Parked. Never auto-scheduled. |
Todo |
Ordered execution queue. Auto Mode pulls from here. |
In Progress |
Planning / execution / evaluation pipeline is active. |
In Review |
Evaluator-cleared diff is ready for your approval. |
Needs Attention |
An agent hit an issue that needs manual intervention. |
Done |
Approved and merged. |
Note
One ticket at a time. A single card runs the planner, loop, or evaluator at any moment; the next card starts only when that slot frees. There's no parallelism setting — local models own the machine's unified memory, so the queue is always serial.
While a task loops you can pause it (the current iteration finishes, then the card
waits in a paused state) and continue later — optionally after editing its per-card
plannerModel, loopModel, and evaluatorModel overrides.
These lifecycle states are a model the orchestrator advances, not lanes you drag things between. The UI is a mobile-first, attention-ordered Work feed: what needs you, what's running now, what's queued next.
Don't want to write the cards yourself? Details ▸ Start improvement run hands a repo a time budget and lets Radulf fill it. Pick a base branch, optionally a Focus ("improve test coverage"), and how long it may work.
The run then loops until the budget is spent: propose one improvement → make
a card for it → drive it through the same plan/loop/evaluate pipeline with
auto-approve → merge the approved change into a single feature branch
ralph/improve-<ts>. Each new proposal is made against that branch's tip, so it
builds on everything landed so far.
propose one ─▶ card (auto-approve) ─▶ plan ▶ loop ▶ evaluate ─▶ merge
▲ │
└────────────── until the budget runs out ─────────────────┘
- ⏱️ The clock is a soft gate — checked between tasks, so a task in flight always finishes. Stop works the same way.
- 🧱 The branch is the deliverable — never auto-deleted. Review the accumulated diff afterwards, then merge it or bin it.
- 🛟 It gives up sensibly — a rejected card is left in Needs Attention for you, and three failures in a row end the run instead of burning the budget.
- 💾 It survives a restart — run state is persisted and drivers re-attach on boot.
Warning
A run spends real tokens on every cycle — a planner pass plus a full pipeline per card. Start with ~30 minutes on a repo you're happy to throw a branch away from.
📖 Full reference: docs/IMPROVEMENT_RUNS.md · design
rationale: specs/06-self-improvement.md
Open the Settings page in the app to choose which provider and model each role uses:
| Role | Job |
|---|---|
| 🧭 Planner | Determines the execution plan for a card. |
| 🔁 Loop | Implements one task at a time and runs its targeted check against the git repo. |
| 🔎 Evaluator | The sole whole-card verifier — runs every acceptance criterion, inspects the diff, and returns approve or concrete revise feedback. On approve it also writes the card summary and refreshes any docs the change made stale. |
There is no separate summarizer role; the evaluator writes the summary.
Each role has a provider picker (anthropic, chatgpt, copilot, omlx, or openrouter), a model picker, and a reasoning-level picker (pi's thinking level — default Medium — applied across every provider).
- The oMLX base URL defaults to
http://127.0.0.1:8000. - Set an OpenRouter API key in Settings to use OpenRouter models.
- Every provider runs through the one pi coding agent
harness (SDK mode); the provider only determines auth — anthropic, chatgpt, and copilot
use the subscription credentials from
make login, openrouter your API key, and omlx your local server.
Important
If you pick the oMLX provider, ensure oMLX is running with at least one
tool-capable model and reachable at the configured base URL (default
http://127.0.0.1:8000) before starting a loop.
When deploying Radulf beyond localhost, protect every page and API route behind a
single-password login. Set RADULF_AUTH_PASSWORD_HASH to a bcrypt hash of your password.
When this variable is unset, auth is disabled and the app runs in default no-auth mode
(appropriate for localhost development).
# Generate a bcrypt hash of your chosen password
node -e 'console.log(require("bcryptjs").hashSync(process.argv[1], 12))' -- '<password>'Set the result as RADULF_AUTH_PASSWORD_HASH.
How auth works under the hood (click to expand)
sequenceDiagram
participant U as Browser
participant P as Next.js proxy (edge)
participant N as Node runtime
U->>P: GET /any-page
P-->>U: 302 → /login (no valid cookie)
U->>N: POST /login (password)
N->>N: bcrypt check + read data/auth-secret
N-->>U: Set signed session cookie (30 days)
U->>P: GET /any-page (with cookie)
P->>P: HMAC-SHA256 verify (Web Crypto)
P-->>U: 200 ✅
- Logging in: Visit any page → redirected to
/login. On success you get a signed session cookie valid for 30 days. To clear it,POST /api/auth/logout(redirects back to/login). - Rate limiting: With
RADULF_TRUSTED_PROXY_IP_HEADERset, each client address gets 5 failed attempts per minute, then429 Too Many Requests. Without it every direct request shares one budget, so a hard cutoff would be an operator lockout — that mode escalates delay instead (2s per recent failure, capped at 15s) and never refuses the correct password. See Authentication. - Revoking sessions: Cookies are HMAC-signed with the secret in
data/auth-secret; there's no server-side session store. The kill switch for a leaked cookie is rotating the secret — deletedata/auth-secretand restart; a fresh secret invalidates every outstanding cookie at once. - Cross-origin requests: Mutating requests are accepted only from localhost origins and,
when set, the hostname in
RADULF_ALLOWED_ORIGIN(e.g.RADULF_ALLOWED_ORIGIN=radulf.example.com). Set it when deploying behind a public hostname. This check runs whether or not auth is enabled — the no-auth default is exactly when a page you merely visit must not be able to drive your local instance. Clients that send noOriginat all (curl, scripts) are unaffected. - Provider credentials: API keys are write-only over HTTP.
GET /api/settingsreturns••••••••for any key that is set, and sending that marker back leaves the stored value alone, so the settings form round-trips without the key ever reaching the browser. - Under the hood: An HMAC-SHA256 session secret is generated on first boot and stored in
data/auth-secret(gitignored). The Next.js request proxy uses Web Crypto to verify the signed cookie on every request; the bcrypt check and file I/O happen only in Node runtime modules, so the proxy stays edge-compatible.
All tasks are driven through the Makefile — run make for the
full list. It is the single source of truth: CI and the release workflow call
these same targets.
| Command | What it does |
|---|---|
make install |
📥 Install dependencies from the lockfile |
make login |
🔑 Open pi against Radulf's agent dir to log in a subscription provider (type /login) |
make dev |
🔥 Start the Next.js dev server with hot reload |
make build |
📦 Build the application for production |
make start |
🚀 Start the production server (run build first) |
make lint |
🧹 Run ESLint across the codebase |
make test |
🧪 Run unit, component, route, and lifecycle integration tests |
make typecheck |
🔍 Type-check without emitting files |
make check |
✅ Run tests, lint, type-checking, and a production build (the CI gate) |
make db-generate |
🗂️ Generate a migration from schema changes |
make db-migrate |
⬆️ Apply pending migrations |
make db-studio |
🔎 Open Drizzle Studio |
make clean |
🧽 Remove build output and caches |
make release VERSION=1.0.0 |
🏷️ Bump version, tag, and push — triggers the release workflow |
radulf/
├── src/
│ ├── app/ Next.js App Router routes and UI components
│ ├── server/ Orchestrator, runners, and provider integrations
│ └── db/ Drizzle schema definitions
├── drizzle/ Generated SQL migration files
├── docs/ The guides, browsable in-app under the Docs tab
├── specs/ Dated design decision log → see docs/DESIGN_HISTORY.md
├── benchmarks/ Repeatable loop-performance fixtures and runners
├── data/ Gitignored runtime state (SQLite DB, transcripts, provider auth)
└── worktrees/ Gitignored per-card git worktrees — beside data/, never inside it
docs/ is what you read to understand Radulf; specs/ is the record of what was
decided and when. Specs are never rewritten to match the present — a later spec
supersedes an earlier one, and
docs/DESIGN_HISTORY.md tracks which.
| Directory | Contents |
|---|---|
src/app |
Next.js App Router routes and UI components |
src/server |
Orchestrator, runners, and provider integrations |
src/db |
Drizzle schema definitions |
drizzle |
Generated SQL migration files |
docs |
The guides served by the in-app Docs tab |
specs |
Dated design decision log (see docs/DESIGN_HISTORY.md) |
benchmarks |
Loop-performance benchmark fixtures and runners (see benchmarks/snake-tui/README.md) |
data |
Gitignored runtime state (SQLite DB, transcripts, provider auth). Denied to agents wholesale by the sandbox |
worktrees, plans |
Gitignored agent-writable dirs, siblings of data/ so that deny needs no carve-out |
Contributions are welcome! 🐺 See CONTRIBUTING.md for local setup,
the make check gate CI enforces, and PR guidelines. Please report security issues
privately per SECURITY.md rather than in a public issue.
Radulf is released under the MIT License.
Built to plan its own future. Point it at itself and watch. 🐺
from Old Norse Ráðúlfr — the wolf that plans