npm.io
0.11.0 • Published yesterdayCLI

@seldonframe/reelier

Licence
AGPL-3.0-only
Version
0.11.0
Deps
1
Size
453 kB
Vulns
0
Weekly
0
DeprecatedThis package is deprecated
Reelier

Reelier

Agents make claims. Reelier writes receipts. Record what your agent does once, replay it deterministically, and get a receipt every run — the exact steps it ran, each one asserted, byte-identical, 0 tokens.

npm version CI license tests

A receipt is the record of what your agent actually did — every step asserted, byte-identical on replay, 0 tokens. For developers whose agents do the same work over and over.

https://www.reelier.com/reelier-explainer.mp4

Install + first receipt

npm i -g @seldonframe/reelier && reelier init

First receipt in 60 seconds. reelier init records a real workflow, compiles it, replays it, and closes with a receipt — the actual shape (see src/init.ts's formatReceipt):

$ reelier init --yes

  1. Fetch @seldonframe/reelier's versioned npm registry metadata
  2. Fetch the package homepage, using the URL bound from the registry response
Compiled skills/reelier-init-demo-1.skill.md — 2 steps, 2 asserts, 1 bind

Open questions:
  - ...(the compiler's honest gaps, printed verbatim — never papered over)

Your receipt:
  skill:        reelier-init-demo-1
  steps:        2 total, 2 passed, 0 unchecked, 0 failed
  replay time:  44ms [measured]
  LLM tokens:   0 [measured]

  An agent doing a comparable task re-reasons every run (~2.8s, ~18k
  tokens on our benchmark); your replay: 44ms, 0 tokens.

Next steps:
  reelier run skills/reelier-init-demo-1.skill.md      # replay again
  reelier push skills/reelier-init-demo-1.skill.md     # sync receipts (opt-in)

reelier init also detects a Claude Code project MCP config (.mcp.json) or user config (~/.claude.json) and prints the entry to add for recording against your own MCP server(s). Without one, it runs the zero-setup demo above. Pass --yes to skip every prompt and run non-interactively (no config is ever written under --yes) — useful in CI.

The measured proof

From a real, live head-to-head benchmark (agent vs. Reelier, same task, same data) — see examples/benchmark and docs/strategy/reelier-launch/benchmark-results.md for the full raw tables, methodology, and how to reproduce it yourself:

  • 1,000/1,000 replays byte-identical (N=1000 tail-variance test)
  • 0 tokens per replay (verified from the run record, not assumed)
  • ~50x cheaper ($0.000000/replay vs. $0.019068/run averaged over the agent arm)
  • ~59x faster (48ms vs. 2,842ms average latency)
  • a real drift self-healed for ~$0.001, once, then free every replay after

Latency varies by network. Level-0 replay re-executes the skill's tool calls (e.g. the HTTP request), so absolute wall-clock time depends on your connection — these figures are from our benchmark environment; on a slower link a replay may take 150–500ms. What does not vary: 0 LLM tokens, the same steps every run, and the receipt — those are guaranteed by construction. The agent arm's latency is reasoning-bound, not network-bound.

How it works

  1. Recordreelier mcp --wrap "<your mcp server>" sits in front of your agent's real MCP tools and captures a lossless trace of what it actually did.
  2. Compilereelier compile trace.jsonl turns that trace into a SKILL.md deterministically, zero LLM calls — a recipe with an assertion on every step, and the compiler's honest gaps printed as Open questions rather than guessed at.
  3. Replayreelier run skill.md runs it again at Level 0: no LLM, milliseconds, byte-identical.
  4. Self-heal — when the world drifts underneath a skill (--max-level 1|2), an LLM patches the one broken step once and writes its own changelog entry, so the fix never has to happen twice.

Works with any model (BYOK)

LLM-agnostic

Level-0 replay (the default) never calls a model — 0 tokens, by construction, not convention. Escalation is opt-in and speaks through one narrow BYOK call surface (--llm-base-url / REELIER_LLM_BASE_URL + --llm-model): a native Anthropic Messages API adapter for api.anthropic.com, and an OpenAI-compatible chat-completions adapter for everything else — OpenRouter, Ollama, Gemini's OpenAI-compat endpoint, Groq, vLLM, LM Studio, Kimi (Moonshot), or any other host that speaks the same wire format. Better models don't obsolete Reelier — point --llm-base-url at a stronger one and every skill's next self-heal gets smarter for free, without recompiling anything by hand.

Why it's not brittle RPA

  • Replays tool calls, not pixels — nothing to break on a UI redesign.
  • Every step carries its own assertion — a broken step fails loudly, never silently passes.
  • AGPL-3.0, BYOK, local-first: the engine can never be taken closed, and your skills, traces, and run records are your data.
  • Independently corroborated — arXiv 2605.14237 found 93.3–99.98% token reduction for the same record-and-replay pattern.

The formats are specified in SPEC.md — a normative, RFC-style reference (trace/SKILL.md/run-record/proxy/runner) for anyone emitting or consuming these formats without reading this package's source.


Status: v0 spike + recorder + compiler + escalation ladder (L1/L2) + init

The Level-0 deterministic runner and file formats are the spike. On top of that there's a recorder (a lossless MCP proxy that captures a live agent session as a trace), a compiler (reelier compile, turning a trace into a runner-ready SKILL.md deterministically — see "Compile" below), an escalation ladder (--max-level 1|2, see "Escalation ladder" below) — the first LLM code in this repo, strictly opt-in — reelier init, the guided record → compile → replay → receipt loop above, and reelier from-session / reelier scan / reelier install (see "Record from your agent's history" below) — compiling skills straight out of an agent's own session history, no recorder proxy required first. There is still:

  • No Level 3 — full agentic recovery when the recorded trace no longer applies at all is not implemented; a diverged destructive step, or an L2 failure, just stops the run with a message telling you to fix the skill by hand.
  • Zero LLM calls at the default --max-level 0 — by construction, not by convention (see below).

Record

Point Reelier at the real MCP server(s) your agent already uses. It spawns them, re-exposes their tools 1:1 (pure passthrough — the live path is never modified), and adds three control tools your agent can call to capture a lossless trace of what it did.

Install it in front of an existing MCP server (Claude Code example):

claude mcp add reelier -- npx reelier mcp --wrap "npx -y @your/mcp-server"

Wrap more than one downstream server by repeating --wrap. Then tell your agent: "record yourself doing this." It will:

  1. Call reelier_start_recording {name} — opens .reelier/traces/<name>-<n>.jsonl (n auto-increments so an existing trace is never overwritten) and returns the path.
  2. Call reelier_note {text} before each logical step to narrate intent ("I'm about to pull this week's bookings"). No-op with a friendly message if you're not currently recording.
  3. Work normally — every wrapped tool call and its result is appended to the trace, in order, while recording is on. Calls made while not recording pass through unlogged.
  4. Call reelier_stop_recording {} — returns the path and how many calls were captured.

Then inspect it:

reelier trace .reelier/traces/<name>-1.jsonl

and compile it into a SKILL.md (see "Compile" below for what it derives and what it leaves as open questions for you to review):

reelier compile .reelier/traces/<name>-1.jsonl

Once you have a skill, replay it against the same downstream(s):

reelier run skills/my-skill.skill.md --wrap "npx -y @your/mcp-server"

Record from your agent's history

You don't have to set anything up before recording — if you use Claude Code (or a compatible agent that writes standard Anthropic-format session transcripts), the recording already happened. Every session is a JSONL file at ~/.claude/projects/<project>/<uuid>.jsonl. Compile one directly:

reelier from-session ~/.claude/projects/my-app/2f8e....jsonl

This is the honest version of "turn what I just did into a skill": it walks the transcript, pairs each tool call with its result, and keeps only the calls Reelier can actually replay deterministically — its own builtins (http.get/http.post) and MCP server tool calls (mcp__<server>__<tool>). Everything else your agent did in that session — Bash, Read, Edit, Write, Grep, Task, WebFetch, ... — gets reported as skipped, with why, never silently dropped and never faked into a step. If a session has no replayable calls at all, from-session says so plainly and writes nothing:

no deterministically-replayable tool calls found in this session — Reelier
replays API/MCP tool workflows, not file edits or shell commands.

The replayable calls run through the exact same compiler as reelier compile (see "Compile" below) — same dataflow-recovered binds, same honest "Open questions" list.

To find which of your past sessions are even worth compiling:

reelier scan

walks ~/.claude/projects (or --dir <path>), summarizes every transcript it finds, and asks which ones to turn into skills:

Found 1364 session(s) in your agent history · 150 contain replayable workflows.

Which should Reelier turn into a skill you can replay forever?

  [1] my-app · 2026-07-19 12:50 · 88 replayable call(s) — posthog, neon, seldonframe
  [2] my-app · 2026-07-17 21:58 · 51 replayable call(s) — claude-in-chrome
  ...

Select sessions to compile (comma-separated numbers, "all", or Enter for none):

Pass --yes to compile every session with at least one replayable call, non-interactively — sessions with zero replayable calls are never offered as an option. Compiled skills land in .reelier/skills-from-scan/ by default (--out-dir to change it).

Making future recording one phrase

reelier init already tells you the exact command to front an existing MCP server with the recorder proxy. reelier install does it for you:

reelier install          # wraps every local MCP server in .mcp.json (or ~/.claude.json)
reelier install --dry-run  # show the exact rewrite without writing anything

It backs up your current config first (.mcp.json.backup-<timestamp>), then rewrites each configured local server in place to be fronted by reelier mcp --wrap "<its original command>" — same server name, so nothing else that references it breaks. Running it again is a no-op (already-wrapped servers are detected and left alone); remote/URL-based servers are skipped, never mis-wrapped. Restart your agent, then just work normally — no new setup step before recording:

Restart your agent, then work normally. When you want to save a workflow,
tell your agent: "record this" ... do the work ... "done"
Then compile it: reelier from-session <the .jsonl transcript your agent just wrote>

To revert:

reelier uninstall   # restores the config from install's backup
The trace format

One JSON object per line, written in order — order in the file IS the association between a call and its result. Every record carries a file-global monotonic seq; call/result pairs additionally share a call-index i.

{"t":"meta","seq":0,"name":"...","startedAt":"<ISO>","wrapped":["<downstream server names>"]}
{"t":"note","seq":1,"ts":"<ISO>","text":"..."}
{"t":"call","seq":2,"i":0,"ts":"<ISO>","tool":"...","args":{...}}
{"t":"result","seq":3,"i":0,"ok":true,"ms":12,"body":{...}}

Control-tool calls (reelier_start_recording/reelier_note/ reelier_stop_recording) are never themselves written as call/result entries.

Redaction (trace-write time only, never on the live path)

redact() (src/redact.ts) deep-walks args/body before they're written to the trace:

  • REELIER_REDACT — comma-separated env var names. Any occurrence of those vars' values (length ≥ 6) inside a string is replaced with «redacted:NAME».
  • Always on, no config needed: sk-...-shaped tokens and Bearer ... headers are replaced with «redacted».
  • Always on: a 32+-char hex string sitting in a field literally named like /token|secret|key|password|authorization/i is replaced with «redacted».

This is deliberately conservative — it will miss secrets that don't match these shapes (e.g. base64-encoded keys, secrets embedded mid-string in an unnamed field, non-hex tokens under 32 chars). It will not corrupt a trace with false-positive redactions of ordinary data, which was the higher priority for a first pass. Treat trace files as sensitive until you've verified redaction covers what you're wrapping.

Tool name identity

A downstream tool is exposed under its original name. If two --wrapd downstreams both have a tool of the same name, the later one (by --wrap order, 0-indexed) is exposed as <downstreamIndex>_<name> and a warning is logged to stderr. The exposed name is what gets recorded, and it's what reelier run --wrap looks up on replay — both sides build the same collision table from the same --wrap order, so a name recorded by reelier mcp always resolves to the same tool on replay.

MCP result → Observation mapping (for reelier run --wrap)

Runner steps assert against an Observation ({status, headers, body} — see src/assert.ts). MCP tool results don't have that shape natively, so src/mcp-tool.ts adapts:

  • status: 200 if the call succeeded, 500 if the MCP result set isError: true.
  • headers: always {} — MCP has no header concept.
  • body: every text-type content block's .text, concatenated with "\n". When a tool returns a single JSON-shaped text block (the common case — e.g. {"result": 9}), body is that JSON text, so json.<dotpath> asserts/binds parse it directly through the existing JSON.parse(obs.body) path. If a tool returns multiple text blocks, only body contains/body match asserts are reliable; json.* will fail to parse the concatenation — this is a documented limitation, not silently papered over.

Builtin http.* tools keep working unchanged when --wrap is also passed — the MCP-backed tools are merged alongside them.

Compile

reelier compile .reelier/traces/my-trace-1.jsonl [-o my-skill.skill.md] [--force]

Turns a recorded trace into a runner-ready SKILL.md, deterministically — zero LLM calls. Default output is <trace-meta-name>.skill.md in the current directory; reelier compile refuses to overwrite an existing file without --force (review-before-save — it never silently clobbers a skill you've hand-edited). On success it prints the output path, step/assert/bind counts, an effect-class summary, and the full Open questions list — that printout is the review step for now.

What it derives, per call in the trace (one call = one step, in call order):

  • Intent/title from the nearest preceding reelier_note (consecutive notes join with "; "). No note → title Call <tool> and an auto-generated intent, plus an open question asking you to narrate it.
  • Dataflow recovery — the core of the compiler. For every scalar arg value in a call (strings ≥ 4 chars, numbers whose decimal form is ≥ 4 chars), it searches prior results' parsed JSON bodies for an exact match (most recent prior result wins). A hit becomes a bind + a json.<path> is set assert on the source step, and the value is replaced with {{name}} in the consuming step — so replay always uses the fresh value produced at run time, never the one baked into the trace. The same source path is bound once and reused by every later consumer. A match found inside an array element uses an index-based path (e.g. json.items.0.id) and gets an open question flagging that as drift-prone.
  • Success asserts — a step whose recorded result was ok gets assert: status == 200. A step whose result was missing or not-ok gets no assertions at all and an open question instead.
  • Effect classes from a verb heuristic on the tool name (read/idempotent write/destructive). An unrecognized verb is conservatively downgraded to destructive (never silently guessed safe) with an open question asking you to review it once.

What it deliberately leaves as open questions, rather than papering over:

  • steps with no narration, no result, or a failed result during recording
  • effect classes it couldn't confidently classify
  • dataflow binds that had to use a drift-prone array index
  • literal values repeated across 2+ calls that were never derived from a prior result — candidates for promotion to an {{input}} variable (a later, LLM-assisted step; this compiler never invents input variables itself)
  • a trailing reelier_note with no call after it (a warning, not a crash)
  • (0.3.0+) date literals — any arg string shaped like an ISO date (YYYY-MM-DD, optionally with a time suffix) gets an open question proposing the equivalent computed var, with the actual offset worked out from the trace's own recording date: "2026-07-11" recorded on 2026-07-18 becomes a suggestion to replace it with {{today-7d}} if that's what it means — the compiler never substitutes it for you, since only you know whether a recorded date was meant to be relative to run time or is genuinely fixed. See "Computed date template vars" below.

This is the honest-gaps principle: a step the compiler can't derive a meaningful assertion for gets emitted assertion-less rather than a fabricated check that would silently pass. The runner already reports such a step as unchecked, never passed — the compiler's job is only to be honest about why.

The compiled SKILL.md also seeds a ## Changelog section (one line: - <date> — compiled from <tracefile> (<N> calls, <M> steps)). That's the write-back convention later escalation-ladder levels (L1+) will append to when they patch a step — the changelog is the skill's own audit trail.

The five atoms

Every step in a skill is five atoms:

Atom What it is
intent A natural-language sentence describing what the step is for
action A tool name + a JSON args template ({{var}} holes allowed)
assert Predicates over the observation the tool returned
bind Extractions from the observation that feed later steps
effect read | idempotent-write | destructive

The SKILL.md format

SKILL.md-standard-compatible frontmatter, then human-editable step blocks:

---
name: sf-post-deploy-smoke
description: Post-deploy smoke sweep of seldonframe.com core routes
---

# SF post-deploy smoke sweep

Inputs: (none for this skill; document `{{name}}` input variables here when a skill has them)

## Steps

### Step 1 — Homepage is up and branded
- intent: Confirm the marketing homepage serves and carries the brand sentinel
- action: http.get {"url": "https://www.seldonframe.com/"}
- assert: status == 200
- assert: body contains "SeldonFrame"
- effect: read

Steps must be numbered sequentially from 1. A malformed skill (bad frontmatter, a missing required field, an unrecognized assert/bind expression, an out-of-order step number) is rejected with an error naming the exact step and line — Reelier never silently skips a broken step.

Assert mini-language
  • status == <int> / status != <int>
  • body contains "<text>" / body not contains "<text>"
  • json.<dotpath> is array / is set
  • json.<dotpath> == <json-scalar> / != / > / <
  • json.<dotpath> length > <int> (arrays and strings)
Bind mini-language
  • <name> = json.<dotpath>
  • <name> = body match /<regex>/ (first capture group; no match is a divergence)

today and today±Nd (e.g. today-7d) are reserved — see below — and can't be used as a bind name; parseSkill rejects it immediately with a clear error rather than letting it silently shadow the computed var.

Computed date template vars (0.3.0+)

Alongside ordinary {{name}} holes filled from binds/vars, three forms are resolved deterministically at fill time instead, never looked up in a skill's own bindings:

  • {{today}} — today's date, YYYY-MM-DD, UTC.
  • {{today-Nd}} — the date N days before today (N an integer, 1-365).
  • {{today+Nd}} — the date N days after today.
- action: list_bookings {"from": "{{today-7d}}", "to": "{{today}}"}

That's it — no times, no other formats, no locales; a heavier need is a future, separately-versioned form, not a silent extension of this one. A run resolves all of these against a single clock snapshot taken once at the start of the run, so a long-running run never straddles a UTC midnight boundary and sees a different {{today}} partway through.

Builtin tools (v0)

http.get {url} and http.post {url, headers?, body?}, backed by Node's native fetch with a 15s timeout. The registry is a plain map so MCP-backed tools can be registered alongside these later without touching the runner.

A step whose effect is destructive is refused unless --yes is passed — Reelier prints the filled action instead of executing it.

CLI usage

# The guided 60-second first run: detect config -> record (demo or real) ->
# compile -> replay -> receipt. See "Install + first receipt" above.
reelier init [--yes]

# Print every step's filled action without executing anything.
reelier run skills/my-skill.skill.md --dry-run

# Run for real. Exit 0 if every step passed or was unchecked, 1 on any failure.
reelier run skills/my-skill.skill.md

# Pass input variables.
reelier run skills/my-skill.skill.md --var name=acme

# Allow destructive steps to actually execute.
reelier run skills/my-skill.skill.md --yes

# Replay against one or more live MCP downstreams (repeatable --wrap).
reelier run skills/my-skill.skill.md --wrap "npx -y @your/mcp-server"

# Opt into the escalation ladder on divergence (default --max-level 0 —
# see "Escalation ladder" below). BYOK flags only matter when a step
# actually diverges; they're never touched at --max-level 0.
reelier run skills/my-skill.skill.md --max-level 1 \
  [--llm-base-url https://api.anthropic.com] [--llm-api-key sk-...] \
  [--llm-model claude-haiku-4-5-20251001] [--llm-l2-model claude-sonnet-5]

# Summarize a skill's run-record history (per-level step counts,
# escalation attempts vs heals, and total LLM tokens across all runs).
reelier bench skills/my-skill.skill.md

# Start the recording proxy: re-exposes each --wrap'd downstream's tools
# 1:1 plus 3 reelier_* control tools, on stdio.
reelier mcp --wrap "npx -y @your/mcp-server" [--wrap "..."] [--trace-dir <dir>]

# Pretty-print a trace file.
reelier trace .reelier/traces/my-trace-1.jsonl

# Compile a trace into a runner-ready SKILL.md (zero LLM calls). Default
# output is <trace-meta-name>.skill.md; refuses to overwrite without --force.
reelier compile .reelier/traces/my-trace-1.jsonl [-o my-skill.skill.md] [--force]

# Sync new run records (and, on first push, the skill file) to a Reelier
# Cloud instance — see "Push" below. Fully opt-in: does nothing unless both
# REELIER_CLOUD_URL and REELIER_CLOUD_KEY are set.
reelier push skills/my-skill.skill.md [--all] [--dry-run] [--with-skill]

# Compile a skill straight from an agent session transcript your agent
# already wrote — see "Record from your agent's history" above. Only the
# deterministically-replayable calls (builtins + mcp__server__tool) become
# steps; everything else is reported skipped, never fabricated.
reelier from-session ~/.claude/projects/my-app/<uuid>.jsonl [--out my-skill.skill.md] [--name my-skill] [--force]

# Discover every replayable workflow across your agent's session history and
# pick which to compile.
reelier scan [--dir ~/.claude/projects] [--yes] [--out-dir .reelier/skills-from-scan]

# Front every configured local MCP server with the reelier recorder proxy,
# in place, so recording is one phrase away next time — see "Making future
# recording one phrase" above.
reelier install [--agent claude] [--dry-run]

# Restore the config reelier install backed up.
reelier uninstall [--agent claude]

Every run appends one JSON line to .reelier/runs/<skill-name>.jsonl. A step with zero assertions is recorded as "unchecked", never "passed" — an honest-success rule: Reelier will not report a step as having verified anything it didn't actually check.

Run-record shape (0.2.0+)
{
  "skill": "my-skill",
  "startedAt": "...", "finishedAt": "...",
  // The boolean summary: true iff zero steps failed. Unchanged semantics —
  // an unchecked step still counts as "not failed".
  "passed": true,
  "steps": [
    {
      "n": 1, "title": "...",
      // 0 = ran deterministically or never healed; 1/2 = HEALED at that level.
      "level": 0,
      "outcome": "passed", // "passed" | "unchecked" | "failed" | "skipped"
      "ms": 123,
      "failures": [],
      "llm": { "inputTokens": 0, "outputTokens": 0 }, // absent if escalation never ran for this step
      // Highest escalation level TRIED for this step, present whenever
      // escalation ran at all (success OR failure) — absent if it never
      // ran. Distinct from `level`: a step can have escalationAttempted: 2
      // and level: 0 (it burned L1+L2 tokens and still never healed).
      "escalationAttempted": 1
    }
  ],
  "totals": {
    "steps": 1,
    // "passed" counts ONLY steps whose outcome is exactly "passed" — never
    // "unchecked". This is the totals-honesty fix (0.2.0): before it,
    // totals.passed silently counted "passed" OR "unchecked" together.
    "passed": 1,
    "unchecked": 0,
    "skipped": 0,
    "failed": 0,
    "ms": 123,
    "llmInputTokens": 0,
    "llmOutputTokens": 0
  }
}

Records written before 0.2.0 have no totals.unchecked, no totals.skipped, and their totals.passed used the old (dishonest) "passed OR unchecked" rollup — but their per-step outcome values were always recorded correctly. reelier bench reads a mixed history gracefully: for any record lacking totals.unchecked, it derives the honest passed/unchecked/skipped/failed split from that record's own steps[].outcome instead of trusting the legacy totals rollup. Old records stay fully readable; nothing needs to be migrated or re-run.

Escalation ladder

When a step diverges (an assertion fails, a bind can't find its path), Level 0 just stops — that's still the default and it's the only thing that runs unless you opt in. --max-level 1|2 lets an LLM attempt a heal before the run gives up. The whole ladder is built around one idea: a heal that isn't written back to the skill file is worthless, because the same drift would just escalate again on the very next run. So every successful heal is persisted immediately (see "Write-back" below) — the anti-RPA-rot property: once healed, a skill needs the LLM again only if the world drifts again.

The four levels
  • L0 (default, always on) — deterministic replay, zero LLM calls, fails closed on divergence. This is the only level that ever runs unless you pass --max-level.
  • L1 — re-evaluates the step's already-captured observation with an LLM-patched assert/bind set. Nothing is re-executed — L1 is zero side effects by construction, because it never calls a tool again, only re-reads the same result with fresh eyes (e.g. a JSON field moved from json.id to json.data.id). L1 may patch asserts and binds only — never args.
  • L2 — tried only if L1 didn't hold and --max-level 2. The LLM may propose patched args in addition to asserts/binds, and the harness re-executes the step exactly once against the new args — but only when the step's effect is read or idempotent-write. A diverged destructive step is never handed to L2 at all: it fails immediately with a message telling you to fix the skill by hand. That refusal is Level 3 in this version — full agentic recovery for a destructive divergence isn't implemented.
  • L3 (not implemented) — full agentic recovery when the recorded trace no longer applies at all. Today that's a human editing the skill.
Safety rules
  • L0 is the default and the LLM is never constructed or called at --max-level 0 — not "configured to no-op", actually never touched. BYOK spend is opt-in, full stop.
  • L1 never re-executes. It only re-reads the observation the step's tool call already produced. There is no way for an L1 heal to cause a second side effect.
  • L2 never re-runs a destructive step. The harness checks step.effect itself before ever asking the LLM for an L2 patch — an LLM proposing "just re-run the delete" is structurally impossible to reach for a destructive step in this version.
  • The LLM only ever emits a narrow JSON patch; the harness applies it. Every patched assert/bind line is validated against the exact same grammar the skill parser uses (src/assert.ts) before it's allowed near a real observation or the skill file — an unparseable line is downgraded to a real failure, never silently dropped or half-applied. The model never writes the skill file directly.
  • Write-back is mandatory on a successful heal, not best-effort. See below.
BYOK config — "almost any LLM"

Resolved as flags > env vars > defaults:

Flag Env var Default
Base URL --llm-base-url REELIER_LLM_BASE_URL https://api.anthropic.com
API key --llm-api-key REELIER_LLM_API_KEY (falls back to ANTHROPIC_API_KEY only when the base URL is api.anthropic.com) (none — required only when a step actually escalates)
L1 model --llm-model REELIER_LLM_MODEL claude-haiku-4-5-20251001
L2 model --llm-l2-model REELIER_LLM_L2_MODEL claude-sonnet-5

Two wire adapters, chosen by the base URL's host: the native Anthropic Messages API for api.anthropic.com, and OpenAI-compatible chat-completions for every other host — which is the "almost any LLM" story: point --llm-base-url at OpenRouter, a local Ollama, Gemini's OpenAI-compat endpoint, Groq, vLLM, Kimi (Moonshot's api.moonshot.ai/v1), or anything else that speaks the chat-completions shape, and it works unchanged. The API key is only required — and only checked — the first time a step actually escalates; a --max-level 1 run whose skill never diverges never needs a key.

Observations are sent to your configured LLM during escalation. The prompt includes the step's text, the failure messages, and a bounded summary of the tool's response (status, body truncated to ~2000 chars, a truncated sample of json.<path> leaves) — never the full unredacted body, but still real data from your workflow. This is BYOK: it goes to whichever endpoint you've pointed --llm-base-url at, and nowhere else.

Token accounting — honest, no cost math

Every escalation attempt's usage (input/output tokens) is summed into that step's run record, even when the escalation fails — a step that tried L1 and L2 and still diverged still spent real tokens, and the run record says so (escalationAttempted records the highest level tried regardless of outcome). reelier bench prints total LLM tokens across a skill's run history, a per-level step count (L0=x L1=y L2=z, the level that HEALED each step), and a separate escalation line — attempts vs. heals per level — so a step that burned L1 tokens and still failed is visible, not silently absorbed into the "L0" bucket. No dollar figures are fabricated anywhere — token counts only; pricing varies by provider and model, and this codebase doesn't guess at it.

Write-back and the changelog convention

A successful heal (L1 or L2) is applied to the in-memory skill and immediately serialized back to the .skill.md file (src/writeback.ts) — the patched assert/bind lines (and, for an L2 heal, the patched args), plus one new line under a ## Changelog section (created if the skill doesn't have one yet):

- 2026-07-17 — L1 heal, step 1 (create a note): upstream API update wrapped create_note's fields under a 'note' object (v1 -> v2)

serializeSkill (also src/writeback.ts) is the faithful inverse of parseSkill: non-step content (the title, the Inputs: line, ## Open questions, ## Changelog, any hand-written prose) is preserved verbatim from the parse, and step blocks are re-rendered canonically — stable under repeated serialize→parse→serialize even where it isn't byte-identical to hand-formatted input.

A write-back failure (disk full, permission denied) is never allowed to crash the run — the heal already worked for this run; only persistence failed, and that prints a loud stderr warning instead of throwing, because silently losing a heal means the same drift escalates again next time, defeating the entire point of the ladder.

(0.3.0+) Atomic write-back. The file is never edited in place. It's written in full to a temp file next to it (<skill>.skill.md.tmp-<random>) and then renamed over the target, so a crash or kill mid-write leaves the old, complete file behind — a torn/partial skill file is unrepresentable. On a target that can't be renamed over directly (EEXIST/EPERM), the fallback unlinks the target and retries the rename once. The temp file is always cleaned up, success or failure. This guarantees safety for a single writer; it is not inter-process locking — two reelier processes racing a write-back against the same skill file is out of scope here and deferred to cloud execution.

Push

reelier push <skill.md> syncs a skill's local run records — and, on the first push, the skill file itself — to a hosted Reelier Cloud instance, so your receipts (pass rates, escalation history, LLM token spend) accrue in one place instead of living only in .reelier/runs/*.jsonl on your machine. Fully opt-in and fully inert without config: nothing is ever pushed, no network call is ever made, unless both env vars below are set.

reelier push skills/my-skill.skill.md
Config — two required env vars
Env var Meaning
REELIER_CLOUD_URL Base URL of your Reelier Cloud instance.
REELIER_CLOUD_KEY Your API key.

Either missing gives a clear, actionable error naming the missing var — the key's value is never printed, logged, or included in any error message.

What gets uploaded, and when
  • The skill file (skillMd, the raw .skill.md source) uploads automatically the first time that skill is pushed from this working directory. Subsequent pushes skip it — pass --with-skill to force a re-upload (e.g. after hand-editing the skill).
  • Run records — every line of .reelier/runs/<skill-name>.jsonl that hasn't been pushed successfully yet, in order, one POST per record.
Cursor semantics

A cursor file, .reelier/push-state.json ({[skillName]: {pushed: N, skillUploaded: bool, rejected: [...]}}), tracks how many records (from the start of the run-record file) the cursor has advanced past for each skill. A plain reelier push only sends records after that cursor — running it repeatedly is cheap and never re-sends what already made it to the cloud.

  • --all ignores/resets the cursor for this run and reconsiders every record in the file from the start.
  • --dry-run prints what would push (record count, cursor range) and touches nothing — no network call, no state file read for writing, no state file write.

Two different outcomes advance the cursor differently — this is the important part:

  • A 400 or 413 response is treated as the cloud's permanent verdict on that exact record (it will never fit, or it's malformed). These print a loud warning, get logged to a rejected: [{index, reason, at}, ...] audit array in push-state.json for that skill (never pruned automatically), and — critically — the cursor advances past them and the batch keeps going. One bad record from months ago never blocks everything pushed since.
  • A 401 (auth failure) or a network/other error is treated as transient — the key or the network might work on the next attempt. These stop the batch immediately and the cursor is left at the last consumed record, so the very next reelier push retries from exactly there.
  • --all re-reports rather than deduplicating: a previously-rejected record included again via --all that gets rejected again adds another entry to rejected rather than replacing the old one — it's a timestamped history, not overwritten state, and it never wedges: a record's own rejection, however many times it recurs, never blocks the records after it.

If push-state.json itself is ever corrupt (unparseable JSON — e.g. from an interrupted write on an older version, or manual tampering), reelier push doesn't hard-fail: it warns loudly, renames the corrupt file aside to push-state.json.corrupt-<timestamp>, and starts fresh (worst case: some records get re-pushed, which --all-shaped behavior always recovers from cleanly). Writes to push-state.json are atomic (write-temp-then-rename, the same primitive skill write-back uses).

Per-record output

Each record's outcome prints as it happens — pushed (with the cloud's returned id when available), permanently rejected (with the field errors the cloud returned, or the 413 message — cursor still advances), an auth failure, or a generic error (both of the latter two stop the batch) — followed by a one-line summary (skill-upload status, pushed/rejected counts, and the cursor's before/after position). The process exits non-zero only when the batch stopped early on a transient outcome — not merely because some records were permanently rejected.

Privacy note

Run records can contain observed values from your actual tool calls — status codes, response bodies (or samples of them), bind values extracted from real observations. Pushing a run record uploads that data to whatever REELIER_CLOUD_URL points at. This is why push is opt-in and explicit (never triggered by reelier run, never on by default): review what a skill's run records actually contain — the same review that applies to trace files (see "Redaction" above) applies here, since redaction happens at trace-write time, not at push time. Treat a run-record file as potentially sensitive before pushing it anywhere.

Use Reelier inside your coding agent

Everything above assumes you're typing reelier ... at a terminal. reelier serve instead exposes Reelier's own commands as MCP tools, so a running coding agent — Claude Code, Cursor, Windsurf, Codex, anything that speaks MCP — can call Reelier mid-session without shelling out:

reelier serve

This is a different role from reelier mcp. reelier mcp --wrap "<server>" is the recorder: it fronts your own MCP server(s) so their calls get captured into a trace. reelier serve takes no --wrap at all — it fronts Reelier itself, exposing four tools:

Tool Wraps What it does
reelier_scan scan.ts Discover replayable workflows in an agent's session history (default ~/.claude/projects).
reelier_from_session session.ts Compile a SKILL.md from a session transcript.
reelier_replay runner.ts Run a skill file at Level 0 (zero LLM calls) and return the real run record.
reelier_push push.ts Push a skill's run records (and, on first push, the skill) to Reelier Cloud.

Same honesty rules as the CLI throughout: a scan/compile over a session with nothing replayable returns an explicit empty/skip result, never a fabricated skill; a replay returns whatever the runner actually measured; a push missing REELIER_CLOUD_URL/REELIER_CLOUD_KEY reports skipped-no-key, not a silent success.

Wiring it up

Add to your agent's MCP config (e.g. .mcp.json for Claude Code):

{
  "mcpServers": {
    "reelier": {
      "command": "npx",
      "args": ["-y", "@seldonframe/reelier", "serve"]
    }
  }
}
Teaching the agent when to reach for it

Having the tools available doesn't mean an agent knows when to use them. integrations/claude-code/reelier/ ships a SKILL.md you drop into ~/.claude/skills/ (or your project's .claude/skills/) that teaches the reflex: after finishing a repeatable, tool-call-driven task, offer to freeze it into a replayable skill; before redoing deterministic work, check whether a skill already covers it. integrations/cursor/ and integrations/windsurf/ have thinner rules-file variants of the same guidance. See integrations/README.md for install steps per agent.

Run it in CI

A GitHub Action (action.yml at the repo root) wraps reelier run (and, optionally, reelier push) as a composite step, so any repo — yours or anyone forking a skill you published — can replay a skill on a schedule or a PR and get the receipt as a real GitHub check, not just a local log. This is the same "honest-success" contract as everywhere else in Reelier: the job's pass/fail comes straight from the replay's own exit code (§4.2's RunRecord.passed), and the job summary is built by reading the actual .reelier/runs/<skill-name>.jsonl record that run produced — never a guess, never a rollup that hides an unchecked step as a pass (§4.3).

- uses: seldonframe/reelier@v1
  with:
    skill: examples/benchmark/npm-info.skill.md
    max-level: "0" # default — 0 tokens, pure deterministic replay
    cloud-key: ${{ secrets.REELIER_CLOUD_KEY }} # optional — omit for local-only

Copy .github/workflows/reelier-replay.example.yml into your own .github/workflows/ (drop the .example) for a ready-to-edit daily schedule + manual-dispatch workflow.

Inputs
Input Required Default Meaning
skill yes Path to the .skill.md to replay. One path per action call — for multiple skills, add one step (or matrix entry) per skill.
max-level no "0" Forwarded to reelier run --max-level. 0 never calls an LLM. 1/2 allow self-healing escalation and need LLM credentials in the job's environment (see "Works with any model" above) — the action does not supply those for you.
cloud-url no https://www.reelier.com Base URL for reelier push. Only used when cloud-key is set.
cloud-key no Reelier Cloud API key. When set and the replay passed, reelier push <skill> runs after the replay so the receipt lands in your hosted ledger. Unset (or a failed replay) means no push and no network call — pass this as ${{ secrets.<NAME> }}, never inline.
vars no Newline-separated name=value pairs, forwarded as --var name=value for each line.
What the job summary contains

Written by .github/scripts/gha-summary.mjs from inside action.yml, straight to $GITHUB_STEP_SUMMARY:

  • The skill name and overall passed/failed verdict, exactly as recorded.
  • A totals table: steps / passed / unchecked / skipped / failed / duration, copied from RunRecord.totals — with a note that unchecked (ran, zero assertions) is never presented as a pass, matching the CLI's own rule.
  • LLM token totals, but only when escalation actually ran (> 0 tokens) — never a fabricated 0 line implying escalation was considered and skipped.
  • A collapsible per-step table (outcome, healed level, duration).
  • Whether the receipt was pushed to Reelier Cloud, skipped (no cloud-key), or not attempted because the replay itself failed.
  • If the run never got far enough to write a run record at all (a bad skill path, a parse error), the summary says exactly that — no invented numbers — and points at the raw CLI log above it.
The proof-surface idea

A scheduled replay that keeps passing, visible as a green check (or a status badge you add yourself, pointing at the workflow) on a public repo, is a dated, falsifiable claim: "this deterministic tool-call sequence still works, as of this morning, with zero LLM involved." That's the pitch this action exists to make visible in the one place developers already look — their own repo's checks — rather than only in a local .reelier/runs/ file no one outside the author ever sees.

Note: this action's CLI invocation and the .jsonl parsing it depends on were verified locally (reelier run examples/benchmark/npm-info.skill.md against the built CLI, plus a standalone run of gha-summary.mjs against the resulting run record) and the YAML was schema-checked, but the composite action itself has not yet been exercised by a real GitHub Actions runner — that verification is pending the first live workflow run after this is merged and tagged.

Licensing

The AGPL-3.0 license in this repository covers the Reelier harness only — the parser, runner, and CLI in src/. Your traces, your SKILL.md files, and your run records are your data. They are not covered by, and not affected by, this license.

Keywords