warpline
v0.7.0
Published
Deterministic plugin runtime with LLM-judgment dispatch and side-effect approval gates
Readme
Warpline
A deterministic plugin runtime where the LLM is a step you dispatch, not a capability the code carries around.
Warpline runs the recurring work that keeps a business going, on a schedule. Outbound sequencing, market research pulls, content pipelines, competitor monitoring, lead qualification, report generation.
A plugin that declares a side effect (sending email, creating issues, writing
to a database, calling external APIs, modifying files) doesn't run without
explicit human session approval. That includes at autonomous, because the
autonomy level is dispatch autonomy. It decides whether the scheduler can
start a plugin without asking. It never decides whether that plugin can act on
the world without asking.
Blanket approval exists and it's deliberate. warpline approve --all writes a
wildcard grant. What it covers, what it prints before writing anything, when it
expires, and why a run can't widen its own scope:
Can the gate be bypassed?.
Published docs: warplinehq.github.io/warpline.
warpline plan — preview only; nothing was executed.
Session grant: none — plugins with side effects would be SKIPPED this run
Plugins: /tmp/warpline-demo/plugins
Due (10):
announce-fanout (level 0)
llm_handoff: may hand judgment to the LLM ([needs-llm])
(no declared side effects)
anomaly-watch (level 0)
(no declared side effects)
cadence-replies (level 0)
(no declared side effects)
candidate-propose (level 0)
(no declared side effects)
derived-summary (level 0)
(no declared side effects)
draft-writer (level 0)
llm_handoff: may hand judgment to the LLM ([needs-llm])
(no declared side effects)
metrics-rollup (level 0)
(no declared side effects)
cadence-plan (level 1)
(no declared side effects)
daily-digest (level 1)
(no declared side effects)
feed-triage (level 1)
llm_handoff: may hand judgment to the LLM ([needs-llm])
(no declared side effects)
Not due (11):
change-watch — unapproved: side effects require session approval
external_api: ⚠ unapproved — would be SKIPPED this run
feed-monitor — unapproved: side effects require session approval
external_api: ⚠ unapproved — would be SKIPPED this run
github-poll — unapproved: side effects require session approval
external_api: ⚠ unapproved — would be SKIPPED this run
graph-sync — unapproved: side effects require session approval
external_api: ⚠ unapproved — would be SKIPPED this run
writes_db: ⚠ unapproved — would be SKIPPED this run
ledger-runner — unapproved: side effects require session approval
external_api: ⚠ unapproved — would be SKIPPED this run
modifies_file: ⚠ unapproved — would be SKIPPED this run
link-enrich — unapproved: side effects require session approval
external_api: ⚠ unapproved — would be SKIPPED this run
note-intake — schedule 'manual': requires profile 'manual'
search-console — unapproved: side effects require session approval
external_api: ⚠ unapproved — would be SKIPPED this run
anomaly-issue — unapproved: side effects require session approval
creates_issue: ⚠ unapproved — would be SKIPPED this run
external_api: ⚠ unapproved — would be SKIPPED this run
candidate-promote — unapproved: no content approval on file for 'candidate-promote'
modifies_file: ⚠ unapproved — would be SKIPPED this run
cadence-send — unapproved: no content approval on file for 'cadence-send'
sends_email: ⚠ unapproved — would be SKIPPED this runQuickstart
npm i warpline # or: bun add warpline
npx warpline --helpRuns on Node 22.18+ or 23.6+ (engines.node excludes 23.0–23.5), or Bun ≥ 1.4.2.
Node alone is enough. You don't need Bun to use warpline. v0.1 supports POSIX
systems (macOS, Linux). Windows is untested and unclaimed.
Every file warpline reads or writes lives under one home directory: the
WARPLINE_HOME env var, else the nearest ancestor .warpline/ directory,
else <cwd>/.warpline. The one exception is the plugin root: <home>/plugins
unless WARPLINE_PLUGINS_DIR names another directory.
What you get
- Plugin runtime — Zod-validated manifests; per-attempt timeouts; bounded
retries with exponential backoff + jitter;
AbortSignalthreaded into your I/O; per-run artifacts (runs/<id>.json+ captured log with attempt delimiters). - Side-effect approval gate — a closed enum of side-effect types declared
per plugin; declared effects gate execution behind
warpline approve-style session approval, regardless of autonomy level. - Engine — TTL freshness (skip work that is still fresh), dependency
topological ordering, quiet hours, review gate, and idle-based degradation
tiers (
normal → degraded → extended → suspended). - Event board — append-only
events.jsonl+ acknowledgements; tasks with ack / defer / complete states and severity-FIFO ordering. - The
[needs-llm]contract — plugins emit judgment work as a typed handoff; a Claude Code companion skill picks it up. Deterministic work costs nothing to run; judgment work uses your existing Claude subscription. See docs/needs-llm-contract.md. Measured against an agent doing the same day's work, warpline used 20–27% less wall-clock and 25–37% fewer output tokens, on a public scenario and again at private scale. Cache reads were not lower in the public run. The method was committed before the first result: bench/README.md.
Writing a plugin
A plugin is a directory under <home>/plugins/<name>/ with two files:
my-plugin/
manifest.ts # export const manifest = PluginManifestSchema.parse({...})
handler.ts # export const handler: CapabilityHandlerFn = async (manifest, args, signal, capabilities) => SkillResultThe manifest declares what the plugin is allowed to do (side effects,
schedule, TTL, timeout, retries, minimum degradation tier). The handler does
the work and returns a structured SkillResult. Invalid manifests are a
hard-stop at load, so a misconfigured plugin never silently runs. <name> is
lowercase letters, digits and hyphens, starting with a letter, at most 64
characters, and manifest.name equals it
(the rule).
Worked examples in examples/plugins/:
| Example | Demonstrates |
| --- | --- |
| anomaly-watch | Compare against a prior observation — reads the record it wrote last run, reports what newly breached and what cleared, and returns the breached set as an Output |
| github-poll | external_api side effect gating an autonomous plugin; writes one snapshot of what it polled and reports the delta on the next run |
| feed-monitor | Deterministic fetch/parse that reports new entries — the producer half of the feed chain; the judgment handoff is feed-triage's, not this one's |
| feed-triage | The on_run consumer half — per-entry judgment handed off via [needs-llm] through skillHandoff, the payload written under the home; no declared side effects |
| metrics-rollup | daily schedule with retained state — append-only rows, a retention window, weekly rollups; writes only under the home |
| anomaly-issue | dependencies ordering after anomaly-watch, supervised autonomy, and a creates_issue side effect through the gate — irreversible, so the result says how to undo it |
| daily-digest | Aggregate — declares two producers, lets the engine order them, and folds what they last reported into one digest Output |
| derived-summary | Derive, don't store — reads a source under the home, returns the summary, writes nothing; ttl_hours decides whether recomputing is worth it |
| note-intake | Operator text for one run — schedule: 'manual' plus a required input supplied with warpline run note-intake default --input note=<text>, routed whole to a file under the home |
| link-enrich | Fan in from three sources with per-source isolation — one refused source is a partial run that names it, every source refused is a failure; credentials are names on secrets |
| draft-writer | Config-heavy writer — every adopter choice is a declared input with a placeholder default, three reference files named by path and refused outside the home, the drafting handed off |
| announce-fanout | Config-heavy fan-out — channels, calls to action and a cadence as declared inputs, per-channel isolation, the per-channel rewrite handed off |
| change-watch | Watch pages you depend on — fetch each declared target, keep a normalised snapshot, report new, changed with a capped line diff, unchanged or failed; the defaults are three upstream runtimes' tag feeds, so a first run shows real data |
| search-console | Derive over one Bearer token — week-over-week clicks and impressions for the top queries and pages of a declared site; persists nothing |
| graph-sync | Idempotent per record — upsert by id so a re-run overwrites, one failing record isolated to its own error, every record failing is a failure |
| ledger-runner | Write a product file — read declared instruments, rewrite the ledger atomically, report attempted against succeeded; a failed instrument keeps its last value |
| cadence-replies → cadence-plan → cadence-send | Content approval, sending — a reply seam you replace with your inbox reader, a plan that works out each contact's due step from time and stops a contact who replied, and a sends_email sender that ships only the outbox an operator approved with warpline approve cadence-send --content, once per recipient |
| candidate-propose → candidate-promote | Content approval, promoting — at most three candidates a week, each proposal replacing the last, appended to a file only when an operator approves those exact bytes |
Copy any of them into your own home as a starting point:
npx warpline scaffold my-plugin --from <example> copies the directory with
only the manifest's name rewritten. That includes the example's
handler.test.ts, so the copy arrives with its tests. Nothing in warpline
runs that file, and the default .warpline/ home is a dot-directory that
bun test does not descend into; a home placed as a plain directory inside a
project (WARPLINE_HOME=./warpline-home) is on that project's discovery path,
and its own bun test will run the copied file.
Six examples carry a credential. Four name one *_TOKEN on secrets:
search-console, graph-sync, ledger-runner and cadence-send. Each sends
it only as an Authorization: Bearer header, and only over https or plain
http to localhost. link-enrich names three the same way, one per source, and
anomaly-issue reads GITHUB_TOKEN without declaring it. Until a declared
token is set, the runtime refuses the run and names the variable, and that
refusal is the first-run demo. To watch one work against a stub, run its test from your home
with Bun: bun test ./.warpline/plugins/<name>/handler.test.ts on a default
install (the leading ./ makes Bun read it as a path, not a name filter).
Refreshing the token is yours, for example a job that rewrites the variable
before an advance.
The two content-approval examples ship nothing until an operator approves the
exact bytes their producer made: the outbox cadence-plan wrote, or the
candidates candidate-propose chose. A session grant never runs them, not even
warpline approve --all. The companion skill approve-review, in the
warpline-examples plugin of this repository's Claude Code marketplace
(plugin-examples),
shows you those bytes first and runs
warpline approve <plugin> --content --not-before <soon> --not-after <when>
only on an explicit yes. The window opens a few minutes out, so the skill can
check the bytes the command bound before anything can ship them. A send that stops part-way is partial,
and re-approving the unchanged Output retries it, skipping what already went. A
send that stops before anything went out, a rejected token for example, is
failed, and so is a first request that throws, which may have delivered.
Either leaves the approval indeterminate. Check the sink for the fire's
effect id, and when nothing arrived, answer it with
warpline resolve <plugin> --not-shipped <effect-id> and approve again. On a default install
(review_gate: true) the level-0 producers are recorded gated and the run
stops there until you review them.
Authoring guide: docs/plugin-authoring.md.
# First run: create the home, copy one example plugin in, write its config.
# Asks for each input the plugin declares on a terminal; writes the declared
# defaults when stdin is not one. Safe to run again.
npx warpline init
# Scaffold a plugin — also prepares the home directory
npx warpline scaffold my-plugin
# Write a plugin's config from the inputs its manifest declares. Prompts on a
# terminal; takes --from '<json>' when stdin is not one. Never writes a secret.
# Safe to run again: a value already in the file is kept unless you replace it.
npx warpline configure my-plugin
# Preview what the next engine advance would do. Executes nothing.
npx warpline plan
# Execute everything that's due, unattended, and exit with a code a scheduler
# can read. --strict reports an approval gate still waiting on a human, or a
# content approval that refused the fire, as a failure instead of 0.
npx warpline advance
# Invoke one plugin handler directly
npx warpline run my-plugin default
# Answer whichever gate is waiting — record a parked result, or grant a
# side-effecting plugin permission to run for this session
npx warpline approve my-plugin
# Say no to what a plugin proposed. The answer is bound to the proposal, so
# the question comes back if what it proposes changes.
npx warpline deny my-plugin
# See what is waiting on a human, then one Ask with its content and the diff from what you last approved
npx warpline approvals
npx warpline approvals show <ask-id>
# Answer a content fire the runtime could not confirm, after the sink shows it
# did not ship. Takes the effect id the refusal printed; then approve again.
npx warpline resolve my-plugin --not-shipped <effect-id>
# Register who may act, by id; disable is the only way out, and every change is on the audit record
npx warpline principal add ops --type human
# Change one guardrail; the change is on the audit record before it is written
npx warpline prefs set review_gate true
# Print the head of the audit record, its seq and hash, to keep off this box
npx warpline audit head
# Pull every record after a seq as CloudEvents JSON lines, for your shipper or an archive
npx warpline audit export --after 0
# Check the record against a head you kept off this box: tamper-evident relative to the last exported Checkpoint
npx warpline audit verify --checkpoint anchor.txt
# Restart a standing grant's period before it lapses; a named human renews it, never the machine that holds it
npx warpline renew <grant-id> --principal ops
# Clear the session approval
npx warpline revokeThose fifteen subcommands are the whole CLI surface. advance is the one a
scheduler calls, and its exit codes are contract surface —
the exit code table is what a monitor
keys on. The same work is also a library call, runAdvance() from the package
root:
import { runAdvance } from 'warpline'
const result = await runAdvance()
// Another home, leaving the process home untouched:
const other = await runAdvance({ home: '/srv/tenant-a/.warpline' })A plugin sees that home only where it resolves it inside its handler call.
A module is evaluated once per process, outside any advance, so a path built
from warplineHome() at a handler's top level holds the process home for
every tenant (plugin authoring).
Where the LLM fits
If you can write an
if/elsefor it, it's code. If it needs understanding or judgment, it's an LLM task, and the plugin hands it off instead of calling a model.
Read the full doctrine: docs/doctrine.md.
Nowhere in this repo. That's the point. Plugins that reach a judgment step
return a [needs-llm] handoff (declared in the manifest as llm_handoff: true,
mapped to the delegated run status, never retried). An orchestrating Claude Code session consumes those handoffs via
companion skills, and a template lives in
skills/needs-llm-template/.
That directory is deliberately not shipped in the package, so the link is
absolute. It resolves the same from npm, from GitHub, and from node_modules.
Side effects that follow from judgment work still go through the approval
gate.
FAQ
Can the gate be bypassed?
Only deliberately, and only by a human at a keyboard. warpline approve --all
is the one route to a wildcard scopes: '*' grant, and it refuses to run if you
also name plugins, so no plugin name, glob or shell expansion can widen a grant
past what you typed. It prints the coverage it's about to grant before it writes
anything. The grant is session-scoped and it expires. See
the session approval file for
the default lifetime and the ceiling from first grant. And nothing inside a run
can widen its own scope. The grant helpers have no caller on the advance path,
so a run can only ever spend approval a human already gave.
Why not just let the plugin call a model?
Because a plugin that calls a model has quietly made every future rerun an
experiment. The deterministic half stops being reproducible, the judgment half
stops being reviewable, and you find out which was which when they disagree
with each other. If you can write an if/else for it, it's code. If it needs
understanding, it's a handoff. Keep the boundary in the manifest and you can
read a plugin and know which one you're looking at.
Do I need Bun / a Claude subscription?
No to both, in different ways. Node 22.18+ or 23.6+ is enough to install and
run warpline. 23.0–23.5 is excluded by engines.node, and npm reports
EBADENGINE there (a hard failure wherever engine-strict is set). Bun is the
development runtime for this repository's own test suite, not a user
requirement. The [needs-llm] half is a handoff, not an API call. It uses
whatever Claude Code session you already have, and if nobody ever picks a
handoff up, the deterministic work carries on running without it.
Docs
- docs/first-plugin.md — start here: build, run and gate a plugin in ten minutes
- docs/doctrine.md — the deterministic/LLM boundary
- docs/runtime-spec.md — manifest fields, retry/timeout/abort semantics, run artifacts
- docs/derive-dont-store.md — why there is no snapshot store, diff engine or resource cache, and what
ttl_hoursplus one overwritten file does instead - docs/board-spec.md — the Board: objects, Ask lifecycle, places, form, file formats. The board is a repo-only surface, so this spec is not shipped in the package and the link is absolute.
- docs/needs-llm-contract.md — the LLM handoff protocol
- docs/plugin-authoring.md — writing and testing plugins
- docs/scheduler-recipe.md — running
advanceon a fifteen-minute tick under systemd, launchd or cron, with every scheduler fact cited to the page it came from - docs/upgrading.md — what to change when you move between published versions; 0.6.0 breaks plugin names that 0.5.x loaded
- docs/why-the-gate-holds.md — the long argument: why the gate holds, and the objections it has to survive
From source
Cloning gets you the test suite and the board, neither of which ships in the package:
git clone https://github.com/warplinehq/warpline
cd warpline
bun install
bun run test # builds, then runs the full suiteRequires bun ≥ 1.4.2. This is the one place it's genuinely
required, because the suite is written against bun:test.
# The board — a repo-only surface, not wired into the published bin
bun run src/cli/board-cli.ts status
bun run src/cli/board-cli.ts tasksProvenance
Warpline was extracted in August 2026 from the private automation engine that's run one company's marketing operations since early 2026. The domain plugins stayed behind. The runtime, gates, board, and doctrine are what generalised.
License
Apache-2.0
