@outerlayer/cli
v0.1.1
Published
OuterLayer CLI: init/doctor (hooks + daemon onboarding), watch, scan, and eval (validate/mine/qualify/report — execution-verified evals from your own PRs)
Readme
outerlayer
Capture what your coding agents actually did, and sync it to your OuterLayer cloud workspace.
OuterLayer captures the sessions your coding agents already write to disk (Claude Code, Codex CLI, Cursor) and syncs them to your OuterLayer cloud workspace, where you and your team can see what your agents did across every repo. Capture itself is local: it reads the session files your agents already write to disk. Uploading is a separate, launch-gated step — what reaches the network, and when, is below.
Launch a session naming the work it's for, then sync:
npx @outerlayer/cli init # install the capture hooks
npx @outerlayer/cli work add --issue 42 # create the work item; prints its number
OUTERLAYER_WORK=7 claude # launch a session naming that number
npx @outerlayer/cli sync --dry-run # see exactly what would leave your machine
npx @outerlayer/cli sync # upload sessions launched that wayPrivacy, stated plainly
A session uploads only when you launch it with OUTERLAYER_WORK naming
the work item it's for. Create the item first with outerlayer work add,
which prints its factory-scoped number, then set that number before
starting your agent — OUTERLAYER_WORK=7 claude. The session-start hook
then records the launch and the session uploads whole, from its first turn,
until it ends. A session launched without the variable never leaves the machine,
in any tool, however sync is invoked. There is no way to turn upload on
mid-session — the decision is made once, at launch.
Only Claude Code has a session-start hook today, so only Claude Code sessions can be launched as work. Codex CLI and Cursor sessions are captured locally and never upload.
outerlayer sync is the complete upload, and it sends only launched
sessions. It runs when you run it. The hooks also fire outerlayer sync
--quiet in the background after each agent turn and when a session ends, at
most once every five minutes, as soon as outerlayer login has saved
credentials. So a launched session uploads while it is still running, not
only once it is over. Set "autoSync": false in ~/.outerlayer/config.json
to leave every automatic upload — the background sync and the daemon's
streaming below — to your own command.
outerlayer daemon uploads a launched session as it grows, turn by turn,
while it runs, and nothing else. The same launch gate as sync applies: a
session started without OUTERLAYER_WORK is never streamed, in whole or in
part, and neither is a session the repo filter excludes. It sends only the
turns since the last one it already sent, and it stops sending the moment
the session ends. "autoSync": false stops it too, and it is read on every
send, so turning it off takes effect without restarting the daemon.
outerlayer sync still runs on top of this and remains the complete,
authoritative upload — the daemon exists so a session's page can follow
along before that sync ever runs.
These commands also talk to your workspace. None of them sends session content:
outerlayer work add,remove,status,list,link-session,pr,claim,renew,releaseread and write your Floor. The session-start hook spawnsouterlayer work link-sessionwhen you launch a session withOUTERLAYER_WORK, naming the item and the session id — the item itself must already exist, created ahead of time withouterlayer work add.outerlayer work prsends the session id and the pull request number and repository — never the session's own content.outerlayer work claimrecords a lease for this host before it starts working on an item, so two hosts never build the same piece of work at once;renewextends it andreleasemarks it done. A runner key needsgit_connection.readto list and read items,work_item_claim.insertto claim, andwork_item_claim.updateto renew or release — the two claim permissions are not granted to a dashboard role by default, since a claim is a host's lease, not a person's.outerlayer runner startlists, claims, renews and releases work items the same waywork claim/renew/releasedo, on the schedule its config sets — it sends nothing about the job it runs beyond that; the agent it starts is a separate process with its own credentials, from therunnerblock, never the copy-out daemon's.runner checkmakes one list call to confirm the key works and sends nothing else;runner init,runner statusandrunner logsmake no network call at all.outerlayer emit artifactuploads the file you name — a screenshot, a recording, a report, a log — along with its caption. With no recorded session to attach it to, it uploads immediately, anchored to a pull request or to the git checkout.outerlayer emit <name>andouterlayer emit commit-creditsend one check's outcome, and one commit's attribution, for a work item. Run from inside a session, they send only when that session carries a launch record — the session's own content may not leave by a second route. Run from CI or a plain shell, where there is no session, they send as they always have.outerlayer emit findingandouterlayer emit findings <file>send one finding, or a whole batch of them, for a work item — the same anchoring asemit <name>(--item, or the recorded session's own item), except a session may record findings on the item it was launched for.outerlayer mcp serveis the stdio MCP server your editor spawns. It forwards every JSON-RPC message the editor sends to the gateway and returns the reply.
The session-start hook reaches the network in two more cases:
- In a repository your factory governs, it asks the control plane which
context repository and ref this checkout is pinned to (
GET /v1/context/source, with your API key), thengit fetches that ref. It sends the repository name; it degrades to the last known commit when the control plane cannot be reached. - When your repository's git hooks are missing, it runs that
repository's own
preparescript (yarn prepare/npm run prepare). What that script does is your repository's business; installers commonly download packages.
The daemon's one-shot sweep (outerlayer daemon --once) makes no network
calls — the copy-out mirror it performs is the same one the long-running
daemon runs continuously, and neither the sweep nor an ineligible session
in the long-running daemon opens a connection. The session-start hook makes
none either, in a repository your factory does not govern. All three are
checked rather than promised: a test runs each of them with fetch and
net.Socket.prototype.connect replaced by stubs that throw, and asserts
nothing tried to connect. Those two stubs cover every outbound path this
process opens itself — anything built on node:http, node:https or
node:tls opens its socket through connect, and fetch opens its own
below that method. A child process opens its sockets in its own address
space, past both stubs, so the same test records every program each run
starts and asserts none of them fetches over the network.
Failures the hook cannot show you — a refused OUTERLAYER_WORK value, a
work add that could not reach the Floor — are appended to
~/.outerlayer/spool/hook-errors.log, and the next session start says so.
The addition retries a gateway it cannot reach a few times, with backoff,
before it gives up.
- The tier is applied before anything leaves. The default tier is
full: message text, thinking, images, and tool input/output all ship.--tier redactedstrips that content client-side, keeping only structure (repos, branches, file paths, tool names, error signatures) and metrics;--tier metricsstrips identifiers too (the server additionally clamps to your org's configured ceiling — sending more than it allows stores less). --dry-runshows exactly what would leave — per-session rows, image bytes, and the precise field classes stripped at the chosen tier. Add--jsonto inspect the literal request payloads. Zero network calls.
Commands
| Command | What it does |
|---|---|
| outerlayer sync | Upload sessions launched with OUTERLAYER_WORK to your OuterLayer cloud workspace (incremental — only what's new since the last sync). Tier-gated client-side (--tier metrics\|redacted\|full, default full); --dry-run prints exactly what would leave the machine; --all re-sends everything (idempotent server-side). Credentials come from outerlayer login, OUTERLAYER_* env vars, or --url/--app-id. |
| outerlayer login [--url] [--app-id] | Save the cloud URL, app id, and API key to ~/.outerlayer/config.json once. The key is read from stdin (echo "$KEY" \| outerlayer login …) or a prompt with echo off; it is never a flag. --no-input refuses to prompt. |
| outerlayer init | Install the capture hooks and the status-line segment. --json for scripts. Run through npx, it copies the CLI to ~/.outerlayer/cli and points the hooks at that copy, so they survive npm clearing its cache; npx @outerlayer/cli@latest init upgrades it. From any other install, the hooks run that install. Claude Code deletes transcripts after ~30 days; run outerlayer daemon separately to mirror them first — init does not start it for you. |
| outerlayer daemon | Run the copy-out daemon in the foreground (--once for a single sweep, which uploads nothing). Once cloud credentials exist, it also streams a launched session's new turns as they land — see Privacy, above. outerlayer watch is the former name and still works, with a warning. |
| outerlayer doctor | Check the installation: hooks, status-line freshness, and sync health. --json prints the checks and a summary for scripts. |
| outerlayer context emit [--check] | Compile .outerlayer/ into each configured target tool's native files (targets come from .outerlayer/config.json). --check computes outputs and diffs against disk without writing (CI mode). Bare outerlayer emit with no name still compiles, with a deprecation warning. |
| outerlayer import ruler | Port a .ruler/ tree (Ruler) into the equivalent .outerlayer/ tree — mostly a rename; never overwrites an existing .outerlayer/. |
| outerlayer hooks wrap / outerlayer hooks unwrap | Auto-wrap (or undo wrapping) PreToolUse/PostToolUse hooks for execution evidence — one spawn per firing. |
| outerlayer emit artifact <file> --caption <text> [--for <criterion-id>] [--pr <n>] | Upload a proof artifact — screenshot, recording, report, or log — with its caption. Inside a recorded session it spools locally and ships on the next sync; otherwise it uploads immediately, anchored to a pull request or the current checkout. --replaces retires artifacts an earlier run uploaded. |
| outerlayer emit <name> --result <pass\|fail> [--link <url>] [--body <text>\|--body-file <path>] --item <number> | Record one named check's outcome on a work item. A check that ran carries the run URL as its proof (--link); a judgment you are making yourself carries one sentence (--body, or --body-file to read it from a file — - reads standard input). A fail needs at least one of the two; a pass needs neither. --item names the work item by the number printed when the item was created — always required, in or out of a recorded session; from inside a session it can only name an item that session was NOT launched for (a session cannot record a check on its own item). Prints the recorded check's id. Who recorded it comes from your API key, never from what you send. |
| outerlayer emit artifact-review --result <pass\|fail> --artifact <id> [--body <text>\|--body-file <path>] | Record a person's own pass or fail on one artifact — evidence already emitted, bound to a criterion. --artifact names it and replaces --item; the gateway resolves the work item from the artifact's own pull request. --link is refused. A fail reads its sentence from --body/--body-file, or from standard input when neither is given. Refused from inside a recorded session — an artifact verdict is a person's act, the same rule the gateway enforces. Prints the recorded verdict's id. |
| outerlayer emit commit-credit --pr <n> … | Send one commit's attribution for a pull request. |
| outerlayer emit finding --id <id> --subject <change\|context> --title <text> --file <path> --kind <behavior\|hygiene\|proof> --verdict <confirmed\|refuted\|unverified> --source <implementer\|reviewer\|refuter\|gate> --where <label> [--item <number>] … | Record one finding — what was found wrong about the change (--subject change) or about a rule it ran on (--subject context, which then needs --rule-path, --rule-quote and --rule-relation together — all three are required, not just --rule-path). Validated against the same contract the gateway checks before anything is sent. --item names the work item; without it, inside a recorded session, the item that session was launched for is used automatically — unlike emit <name>, a session may record findings on its own item. Re-emitting the same --id on the item replaces that finding. |
| outerlayer emit findings <file> [--item <number>] | Record a whole batch at once, read from a FindingBatch JSON file (- for standard input) — the same shape and validation as emit finding, one record per subject/title/file/kind/verdict/source/where. Anchored the same way: --item, else a recorded session's own item. |
| outerlayer mcp install [--transport stdio\|http] [--url] [--name] [--command] | Write (or update) an mcpServers entry in .mcp.json for the OuterLayer gateway. Default stdio: the client spawns outerlayer mcp serve, which reads the API key from ~/.outerlayer/config.json (or OUTERLAYER_API_KEY) each time it connects, so a reconnect picks up a newly saved or rotated key. --transport http writes a direct POST /v1/mcp entry referencing ${OUTERLAYER_API_KEY}, resolved by the client from the environment it was launched with. Never writes an API key. Pass --url/--app-id for self-host. |
| outerlayer mcp serve [--url] [--app-id] | Stdio MCP server bridging stdin/stdout JSON-RPC to the gateway's POST /v1/mcp. What the stdio .mcp.json entry runs; exits 1 with a clear message when no API key is configured. |
| outerlayer work add --issue <n>\|--pr <n> [--repo] [--note] | Records that a source — a recorded session, a CI run, or the API key's bound member — is working on an issue or pull request. The only way an item becomes visible on the Floor. |
| outerlayer work remove --issue <n>\|--pr <n> --reason <text> [--repo] | Withdraws the caller's own addition, recording the reason. Never deletes anything; the item leaves the Floor only once no live addition remains on it. Removing again is a no-op that still succeeds. |
| outerlayer work status --issue <n>\|--pr <n> [--repo] | Shows one item's stage, section, gate ledger, linked pull requests and sessions, and its additions. |
| outerlayer work list [--stage] [--section] [--repo] [--unclaimed] [--startable] [--needs amend] | Lists live work items for the current factory, filterable by stage, section, claim state and whether an open fail is waiting for an answer. |
| outerlayer work pr <n> [--repo] [--session-id] | Run inside a session on a work item: declares that pull request <n> in the checkout's repository belongs to that item. Idempotent — declaring the same pull request twice is a no-op. Refuses when the session is on no item, or the repository is not connected. |
| outerlayer work claim --item <n>\|--issue <n> --kind implement\|amend [--repo] [--host] [--seconds] | Records a lease for this host (default: its own hostname), so two hosts never build the same item at once. A live lease already held by a different host is refused; claiming again under the same host extends it. Lease length defaults to 900 seconds, the server's own cap. |
| outerlayer work renew --item <n>\|--issue <n> [--repo] [--host] [--seconds] | Extends this host's own live lease. Refused if the lease has expired, or if a different host holds it. |
| outerlayer work release --item <n>\|--issue <n> [--repo] [--host] [--outcome] | Marks this host's lease released, optionally recording how the attempt ended. Releasing an already-released lease is a no-op that returns the recorded release time and outcome. |
| outerlayer runner init [--config <path>] | Writes a runner block with defaults and three example hooks, keeping every key the config already had. Refuses rather than overwriting an existing block. |
| outerlayer runner check [--config <path>] | Validates the config, the hooks and the key, prints the settings the runner would use, and exits non-zero on a problem. Takes no work and writes no pid file. |
| outerlayer runner start [--config <path>] | Runs the loop: claim work, run it, sync, clean up, report, release with an outcome. Repeat. Reads the runner block from the config file (default ~/.outerlayer/config.json). Refuses to start on a bad config, an unrunnable hook, or a refused key. |
| outerlayer runner stop [--config <path>] [--now] | Takes no more work and waits for running jobs to finish, naming each every five seconds. --now ends them immediately with outcome stopped. Ctrl-C on a foreground runner drains; a second one stops now. |
| outerlayer runner status [--config <path>] [--recent] [--json] | A header naming the runner's own state, then a row per running job — item, queue, stage, elapsed, started, lease, job directory. --recent adds finished jobs, newest first, capped at 20, with their outcomes. |
| outerlayer runner logs <item> [--config <path>] [-f] | Prints the newest attempt's log for one item, following it with -f. |
The work commands need an API key that carries Ingest traces
(trace.write) to add, remove, and declare a pull request (pr), and Read
the Work page (git_connection.read) to look an item up — which remove and
status both do before they act. Tick both when you mint the key, in
Settings → API keys. Claiming a lease needs Claim work items
(work_item_claim.insert); renewing or releasing one needs Renew or
release work item claims (work_item_claim.update). Withdrawing an
addition somebody else made additionally needs Withdraw others' work from
the Work page (git_connection.update).
Recording your own pass or fail on a check
outerlayer emit also records a judgment you make yourself, under a check
name a validator declares, on the work item — not any one pull request, so
it holds across every pull request the item has. Say what is wrong, in one
sentence:
outerlayer emit code-review --result fail --item 412 \
--body "The button should be the destructive red, not grey."The sentence can come from a file, or from standard input with -:
echo "The button should be the destructive red, not grey." \
| outerlayer emit code-review --result fail --item 412 --body-file -Record a pass once the work is right:
outerlayer emit code-review --result pass --item 412--item is always required, in or out of a recorded session. A session
records a check only on an item it was NOT launched for, by naming it with
--item; a check on the item the session itself was launched for needs a
machine key (an API key run outside the recorded session) or CI.
Three things to know. Who recorded a check is decided by the API key you
used, never by anything in the request. A key bound to a membership records
the check against that person, and once a fail is recorded, only a person's
key can record over it. A failing check needs a run link or a sentence,
because a fail with neither is something nobody can act on. And a check only
holds a gate once a validator in .outerlayer/validators/ declares its emit
name; without one the check is recorded and read back, and blocks nothing.
A review of the work itself is different: it is recorded from the Review
tab on the work item page, not the CLI, and holds every pull request of the
item until a person records a pass — no validator declaration needed.
outerlayer emit refuses work-review outright; recording it anywhere but
the work item page is not supported. The gateway refuses a review, and an
artifact verdict, on an item that is closed or shipped, has no evaluation
yet, or whose evaluation is still waiting on a session link, with the code
item_not_reviewable. A recorded pass carries the head commit of every
pull request the item had at the time; once one of them moves, that pass
stops counting and a fresh review is needed. A repository can require a
current pass before the evidence check completes by setting the base
branch's review policy key to required (default optional, alongside
merge_gate) — the check then stays in progress, not failed, until a
current pass exists.
outerlayer emit artifact-review sits between the two: a person's own pass
or fail, like a review, but on one piece of evidence rather than the whole
item. A fail here holds the item the same way a review's fail does, and
counts the work as bad on Quality once, however many artifacts carry one —
it also lists the item on a queue the API exposes, naming the artifact and
why it failed, so a host with a session linked to the item can answer it
with a replacement, or the fail's own recorder can pass over it directly.
The item's own pass stays available only once every required artifact has
a pass and no fail is still open.
Every command accepts --no-color, and color is off on its own when stdout is not a terminal, when NO_COLOR is set, or when TERM is dumb. FORCE_COLOR=1 turns it on for a pipe. Commands that print anything accept --json.
Status line
init also adds an ambient Claude Code status-line segment showing what your
session and your agents are costing today:
⬢ OL $0.87 session · $23.40 today across 3 agents · 12 unsyncedThe session figure comes straight from Claude Code's own cost field, so it
always matches what Claude Code itself would show. The cross-agent total and
unsynced count come from ~/.outerlayer/statusline.json, a small file the
watch daemon keeps fresh — the status line itself never parses transcripts,
so it stays well under Claude Code's refresh budget. On a day with only one
active agent, the scope adapts: across N agents becomes across N sessions
if you ran several sessions with it, or drops entirely to a bare $X today
for a single session — "across 1 agent" never appears.
If a statusLine command is already configured, init wraps it rather
than replacing it: your existing command's output is printed first, the
OuterLayer segment appends after. A hang or failure in the wrapped command
never blanks the line — it times out and OuterLayer's segment prints alone.
outerlayer init --remove restores the original command exactly.
Without outerlayer watch running, the line degrades gracefully to the
session figure alone plus a dim outerlayer doctor hint — run outerlayer
doctor to see why (usually: the daemon isn't running, or hasn't refreshed
recently).
Opt out of the segment with outerlayer init --no-statusline.
Supported agents
| Agent | Source | Status |
|---|---|---|
| Claude Code | ~/.claude/projects (+ raw mirror) | full: turns, tool I/O, thinking, images, subagents, cost |
| Codex CLI | ~/.codex/sessions | captured locally only — turns, tool I/O, edits (apply_patch), errors, usage |
| Cursor | ~/.cursor/chats | captured locally only — turns, thinking, tool I/O, edits, errors, no cost (Cursor stores no token usage) |
Upload needs a session-start hook to record the launch, and only Claude Code has one, so only Claude Code sessions can be launched as work today.
Sessions from every agent land in one canonical schema
(@outerlayer/session-schema), so sync and everything downstream treat them
identically. Adding an agent is one source adapter.
How capture works
Your agents already write complete transcripts to disk — OuterLayer treats those as the source of truth rather than wrapping or proxying the agent:
initadds a <50ms hook that notes each session event and installs the status-line segment.outerlayer daemonmirrors transcripts before the agent deletes them — so history survives even for agents with retention windows. It's a separate, long-running process you start yourself;initdoesn't start it for you.syncparses whatever is on disk and ships the sessions you launched withOUTERLAYER_WORK, incrementally, only what's new since the last run.
No API keys, no model calls, no interception. If you uninstall OuterLayer, your agents never notice.
Requirements
Node 22+. macOS and Linux; Windows untested (issues welcome). Capturing
Cursor sessions additionally needs Node 22.5+ (it reads Cursor's SQLite
chat store via the node:sqlite builtin); on older Node, Cursor is not
captured at all.
