WARNING: Not even alpha yet. Breaking changes will be common until we finish exploring the space.
Escapement regulates LLM agents the way a watch escapement regulates a mainspring.
A statechart-driven autonomous coding agent in Clojure/Babashka.
- Control flow is a statechart, not a free-form LLM loop. The chart, not the model, decides what happens next.
- An LLM conversation is bound to a chart state via a custom
InvocationProcessor. While the state is active a worker thread holds the conversation; when the chart leaves the state the conversation dies. - Two kinds of tools: real tools (fs/shell/repl, invisible to the chart) and event tools (synthesized from each
state's
:allowed-events; calls become chart events with Malli-validated payloads). - Multi-agent / team patterns via
:target-routed messages between concurrent LLM invocations andtell-other-llm. - Human-in-the-loop via the
:human-inputinvocation (text / select / multi-select / confirm / progress kinds) and thewith-llm-questionshelper that lets an LLM ask the user mid-conversation. - Persistent TUI (JLine, Mode 2026 atomic frames when supported) shows the live chart configuration, transcript
scrollback, and modal input. Esc sends
:ui.interruptto the chart. - File-backed artifacts under
<session>/artifacts/so agent outputs can be addressed by name in follow-on prompts via mustache-style{{name}}templates. - Self-cancelling invocations via
:max-turnsand:budget-msbudgets that fire SCXML-canonical:error.llm.*events. - Parallel regions get independent workers. Fan-out is natural.
- JSONL transcript of every LLM request, response, tool call, transition, and checkpoint. Full replay possible.
- Atomic checkpointing of working memory after every event for crash-resume.
- Backends: Anthropic Messages API, OpenAI Chat Completions / OpenRouter, and z.ai's GLM family (Anthropic-compat).
- Runs under Babashka, including the test suite (
bb test). No JVM required.
Bootstrap bbin (macOS):
brew install babashka/brew/bbinThen install Escapement:
# Local clone:
bbin install .
# Or from GitHub:
bbin install io.github.fulcrologic/escapement# Any one of these will be auto-detected (precedence top-to-bottom):
export ANTHROPIC_API_KEY=... # claude-sonnet-4-6 by default
export OPENAI_API_KEY=... # gpt-4o-mini by default (override via OPENAI_MODEL)
export OPENROUTER_API_KEY=... # openai/gpt-4o-mini by default (override via OPENROUTER_MODEL)
export ZAI_API_KEY=... # glm-4.6 (cheap dev option)
escapement run escapement.examples.hello/agentThe CLI auto-detects a backend from those env vars in the order above; pass --backend explicitly to override. A
logged-in OpenAI Codex OAuth token (between ANTHROPIC and OPENAI in precedence), OPENCODE_GO_API_KEY, and
OLLAMA_API_KEY are also picked up automatically when present. Useful run-time flags:
--param key=value— seed initial-data entries (repeatable; merges over--input <edn-file>).--debug— force the persistent TUI on (paused at first event so you can step or presscto continue). This is the recommended way to watch a non-interactive chart's LLM conversations stream by in real time.--no-tui— force the TUI off (useful in CI;:human-inputprompts fall back to stdin). Charts marked^{:interactive? true}open the TUI by default when a TTY is present.
Output goes under .escapement/<session>/{transcript.jsonl,checkpoints/}.
Expected final-config: [:run :finished].
Drop an optional .escapement.edn at the root of any project to pin its layout once. Keys: :source-paths (extra
classpath roots for chart utility namespaces), :deps (runtime Maven/git coordinates resolved via
babashka.deps/add-deps), :tools-ns (registration fn symbol or vector), :work-dir (default transcript/checkpoint
location), :default-chart (used when escapement run is invoked without a chart symbol). Escapement walks up from the
current directory to find the file, so invocation is location-independent. See the Project configuration section of
Guide.adoc for the full schema and precedence rules.
Add --api-server <port> to any run to publish a read-only EQL/Pathom API over the run's
work-dir (active session, past sessions, transcript, artifacts, the invocation drill-in) and serve an
optional browser inspector on the same origin:
escapement run escapement.examples.hello/agent --api-server 8920
# EQL API → http://localhost:8920/api (POST a transit+json EQL query)
# Web UI → http://localhost:8920/The browser app is a statechart-driven RAD explorer — a Sessions report that drills into a
session's events and artifacts — plus a live Debugger with an embedded statechart visualizer. The
same RAD reports/model also render to a terminal via the fulcro-tui target. The UI is optional and
lazily delivered (it never loads on a normal run). Its compiled bundle is not in git and not in the
Clojars jar: both jar and bbin installs fetch it once from the matching GitHub release
(SHA-256-verified) and cache it. Develop it with bb watch-ui, compile with bb build-ui, and
publish the release asset with bb release-ui (uses gh). See docs/web-ui.md for
the architecture, dev loop, and release steps.
Add --debug alongside --api-server to run headless and auto-paused, then drive the run from
the web Debugger:
escapement run escapement.examples.hello/agent --api-server 8920 --debug
# open http://localhost:8920/ → Debugger screen → Pause / Step / Continue / ArmThe pause gate lives in an instrumented event queue, and the browser drives it through control
mutations (escapement.control/{pause,step,continue,arm-pause-on-next-external}) and live resolvers
(:session/{paused?,step-budget,live-configuration,pending-events}) on the same /api surface. The
Chart screen highlights the live configuration over the embedded statechart visualizer as you step.
--tui=opentui renders the session through an out-of-process Bun + SolidJS + OpenTUI sidecar
(under tui/opentui/) instead of the default in-process JLine TUI. The agent runs headless and streams
its transcript events to the sidecar over a WebSocket on the api-server; human-input/control flow back
over the same socket and the sidecar owns the TTY. See docs/opentui-ui.md for
architecture, run/develop/test, and known limits.
Escapement has three usage modes:
- CLI — the
escapement run …flow above (see Quickstart). Unchanged. - Hosted library — call
escapement.lib/runfrom your own JVM/bb process. The facade validates a closed option map, generates a stable:run-id, defaults transcript + checkpoint to a fresh temp dir, and quiets statecharts-impl logging by default. It is hermetic: it never reads.escapement.ednfrom disk and never sniffs credential env vars — a host injects providers and config as explicit data on every call. This section. - Future daemon direction — a long-lived multi-session HTTP/socket service is an intended later direction. It is *
deferred / not in this slice*; no daemon API exists yet. Described for orientation only in
Guide.adoc.
escapement.lib/run takes a closed option map and returns a summary map. The only required keys are :chart and
:session-id; everything else is optional and defaults sensibly (no :transcript-path/:checkpoint-dir needed — a
fresh temp dir is created). Attach escapement.lib.event-sink to :transcript-tap to receive a stable, normalized
public event stream. Correlation stays in your closure — capture :run-id/:session-id yourself; no host
identifiers go into payloads.
Copy-run as written (from a clone of this repo):
;; bb -e "$(cat this-snippet.clj)" (or paste into a bb REPL)
(require '[escapement.lib :as lib]
'[escapement.lib.event-sink :as sink]
'[com.fulcrologic.statecharts.chart :as chart]
'[com.fulcrologic.statecharts.data-model.operations :as ops])
(import '[com.fulcrologic.statecharts.elements])
(refer 'com.fulcrologic.statecharts.elements :only '[state transition final script on-entry])
;; A tiny chart: assign into the data model, then transition to a final state.
(def greet
(chart/statechart
{:initial :run}
(state {:id :run :initial :go}
(state {:id :go}
(on-entry {} (script {:expr (fn [_ _]
[(ops/assign :msg "hi from embedded escapement")])}))
(transition {:target :done}))
(final {:id :done}))))
;; Correlation stays in the HOST closure: collect normalized public events,
;; tag them with whatever the host already knows. No host id is put in payloads.
(let [events (atom [])
adapter (sink/make-adapter)
result (lib/run {:chart greet
:session-id "demo-session"
:transcript-tap (fn [row]
(doseq [e (sink/feed! adapter row)]
(swap! events conj e)))})]
(println "run-id :" (:run-id result))
(println "status :" (:status result))
(println "final-config:" (:final-config result))
(println "transcript :" (:transcript result))
(println "public events:")
(doseq [e @events]
(println " " (:type e) "run-id=" (:run-id e)))
(println "all events carry run-id? :"
(every? #(= (:run-id result) (:run-id %)) @events)))Output (the :run-id and temp path differ each run; stderr is quiet by default):
run-id : 3d8e6cb3-7e0f-4bcb-a687-6c19a550aa0d
status : :done
final-config: [:run :done]
transcript : /tmp/escapement-run-13211622512067290675/transcript.jsonl
public events:
:run-started run-id= 3d8e6cb3-7e0f-4bcb-a687-6c19a550aa0d
:chart-config run-id= 3d8e6cb3-7e0f-4bcb-a687-6c19a550aa0d
:run-done run-id= 3d8e6cb3-7e0f-4bcb-a687-6c19a550aa0d
all events carry run-id? : true
A chart with an LLM conversation is driven entirely by injected data — no disk, no env on the lib path. A host
configures everything once at startup and passes it on every run:
:credentials— required: an ordered vector of provider descriptor maps. Each names a:providerkeyword plus the env-free secret/override it needs (:api-key,:base-url,:model, or:subscription true). The backend is assembled from these (an explicit:backendis an escape hatch that wins verbatim).:config— optional: the.escapement.edn-shaped map (:llm/preferences,:llm/ratings,:llm/eligibility-strict?). Absent ⇒ an empty ratings table and the built-in default preference order, never a disk fallback.
(lib/run
{:chart my.app.charts/agent
:session-id :req-42
;; REQUIRED for any chart with an :llm-conversation — the facade wires the
;; LLM processor only when BOTH a backend and a :tool-registry are present.
:tool-registry (escapement.tools.protocol/new-registry) ; or new-builtin-registry
:credentials [{:provider :z-ai-plan :subscription true}
{:provider :anthropic :api-key (System/getenv "ANTHROPIC_API_KEY")}]
:config {:llm/preferences [{:provider :z-ai-plan :model "glm-4.6"}
{:provider :anthropic :model "claude-opus-4-7"}]
:llm/ratings {"claude-opus-4-7" {:clojure 9}}
:llm/eligibility-strict? true}})A chart node expresses what it :needs (a flat eligibility gate that filters; ranking is the sole job of the sorted
:llm/preferences). Two run calls in one process with different :config ratings resolve independently — there is no
process global. The full worked reference example (host config + a two-node statechart + the resolution walk-through) is
in the Hosted library section of Guide.adoc.
The snippet above is deliberately a no-LLM chart so it runs with zero secrets. For a **realistic embedded LLM chart
** — authored with escapement.chart.helpers, sharing context between phases via file-backed artifacts, injecting
:credentials/:config/:initial-data/:session-dir, and streaming assistant tokens live via the
public :text-delta event (the supported alternative to hand-matching raw :llm/delta rows) — see the runnable
demos/lib/ example (bb -m lib.embed-example).
A run is made cancellable by passing :cancel an atom (or a delivered promise/future/delay); when it becomes truthy the
run aborts promptly at a safe boundary and the result map's :status is :aborted (omitting :cancel always yields
:status :done for a normal run). The full closed option schema, result-map keys, the public event union, the locked
design decisions, migration notes, and known limitations are in the Hosted library section of
Guide.adoc.
See Guide.adoc for the full guide
The guide covers:
- Introduction — design rationale and one-paragraph overview
- Quickstart — clone-to-running in five lines
- Core concepts — chart, bound state, real tool vs event tool, lifecycle, transcript, checkpoint, resume
- Authoring a chart — line-by-line walkthrough of
hello.cljplus annotated excerpts ofscan,parallel_demo,iterate - The
:llm-conversationinvocation — every flat authoring key (incl.:max-turns/:budget-ms),h/llm-conversation/h/tell-llm/h/tell-other-llm, SCXML-canonical:error.llm.*events, internal-vs-external transitions, bad-tool-use retry, event-tool naming - Human interaction and the TUI —
:human-inputinvocation kinds,human-inputchart helper,with-llm-questionsfor LLM-asks-human, the persistent TUI,^{:interactive? true}chart marker - Multi-agent / team patterns —
:target-routed messages, parallel-region advisors,tell-other-llm, file-backed artifact helpers (capture-llm-output/render-template/forward-llm-output) - LLM backends — Anthropic + z.ai + OpenAI/OpenRouter configuration, the caching wrapper
- File-based prompts —
escapement.promptsand the{{VAR}}substitution model (distinct from artifact templates) - Tools —
Toolprotocol, the built-ins (fs read/write/edit/multi-edit/glob/grep + shell + web fetch/search), how to register a custom tool - Transcript, runner, CLI — event vocabulary,
jqrecipes,--resume/--param/--debug/--no-tui, work-dir layout - Idioms and gotchas —
:type :internal,tell-llmmid-binding, parallel regions, the top-level-finaltrap, resume side-effect caveat - Testing — the bb-friendly harness (
escapement.engine.testing), mockingLLMBackend, stubbing tools, stubHumanRenderer, live smoke scripts; run withbb test - Project layout — one paragraph per top-level directory
- Known limitations and roadmap
- Contributing — adding a chart, tool, or backend
All bb-resident. The fs and search tools are at Claude-Code-parity ergonomics for the LLM.
| Tool | Purpose |
|---|---|
:fs/read |
Read with cat -n line numbers; pageable via offset/limit |
:fs/write |
Atomic UTF-8 write (temp-file + ATOMIC_MOVE) |
:fs/edit |
String-replacement edit; unique-match by default, opt-in replace-all |
:fs/multi-edit |
Atomic batch of edits to one file; later edits see earlier output |
:fs/glob |
PathMatcher-backed walk; matches **/*.foo at root and nested |
:fs/grep |
rg if available, grep -rE fallback; files / content / count modes |
:shell/run |
bash -lc with timeout |
:web/search |
Google search via Gemini google_search grounding (only registered when GEMINI_API_KEY is set) |
:web/fetch |
HTTP GET streamed to a temp file under $TMPDIR/escapement-fetch/; returns metadata so the LLM reads slices off disk |
These are seeded into escapement.tools.builtin/default-registry — a defonce singleton the CLI uses. Custom tools
self-register by side effect at the top of their namespace:
(ns my.app.tools
(:require [escapement.tools.builtin :as builtin]
[escapement.tools.protocol :as tp]))
(tp/register! builtin/default-registry (->HttpGetTool))Any chart whose require-graph reaches my.app.tools will then have :http/get available. By default a chart state's
:llm-conversation exposes every real tool in the registry; pass :real-tools [:fs/read :http/get] (vector or set)
to whitelist a subset. See the Tools section of the Guide for full schemas, the --tools-ns CLI flag,
and how to use a fresh isolated registry (new-builtin-registry) for tests or multi-tenant hosts.
escapement.prompts provides a tiny {{VAR}} template helper for charts whose system prompts are too large or too
parameterized for inline (str ...) blobs. Tokens of the form {{IDENT}} (uppercase + digits + underscores) are
substituted from a map keyed by keyword, symbol, or string; unresolved tokens fail loudly. See
demos/unit_test/prompts/ for example prompts and demos/unit_test/prompts.clj for the per-phase render wiring.
Small worked examples under src/escapement/examples/:
hello.clj— minimal single-region chart, one event toolscan.clj— real tool (:fs/read) plus fan-out of multiple event-tool callsparallel_demo.clj— two parallel regions, independent conversations, join on compound finaliterate.clj— non-trivial coding loop withtell-llmmid-binding,:max-iterationscap, retry, and give-up pathsclj_refactor.clj— gates model auto-selection on per-dimension ratings via a declarative:needseligibility gate ({:clojure [:>= 8] :tool-calling [:>= 6]})artifacts_demo.clj— sequential LLM phases sharing context through file-backed artifacts and{{name}}template renderingask.clj—:human-inputinvocation patterns (text / confirm) plus Esc-based:ui.interrupt
End-to-end demo under demos/:
demos/lib/— embedding Escapement as a library: a two-phase LLM chart driven viaescapement.lib/runwith injected credentials/config/initial-data, artifact-shared context, and live:text-deltastreaming viaescapement.lib.event-sink. The example a host project should read first.demos/unit_test/— port of the piunit_testextension. Drives an LLM through behaviors → abstraction → (write|gap-analysis) → (critique|patch) → refine to author and sealfulcro-spectests for a target function. Includes a sibling REPL-manager parallel region that establishes a project nREPL (cheap scripted discovery first; LLM-drivendeps.edninspection on miss) and hands the port to refine via shared data + one coordination event. Tested end-to-end againstfulcrologic/fulcro: generated and sealed a 52-assertion test file forresolve-tempids.
See Guide.adoc and CHANGELOG.md for design history.
Status: prototype.