A graph is a portable, compiler-validated description of a multi-node agent workflow.
The engine compiles a manifest against admitted connections, runs each node inside a
native OS sandbox (macOS Seatbelt or Linux bubblewrap — no Docker), and writes a
hash-chained, receipt-backed run directory that bl graph status and bl graph arena
can read and render.
This guide walks a single-node research_claim graph wired to the claude CLI.
The same shape works for codex, grok, muse, and agy — swap provider_id.
pip install bounded-loopsVerify:
bl graph --helpDefault: OPEN — your subscription CLI subprocess can reach any host, unchanged from running it directly. No configuration is needed to start; skip this step to keep the default.
To opt into the macOS Seatbelt ALLOWLIST cage (restricts network egress to a declared list; note that a compromised subprocess still has full filesystem access):
bl graph init --posture allowlist --allowlist api.anthropic.comBefore choosing ALLOWLIST, read these two facts:
- List every hostname your CLI actually contacts. An incomplete allowlist blocks any connection you did not name — including telemetry endpoints, update checks, or fallback hosts the CLI reaches without advertising it. The engine does not warn you about connections that get blocked; they simply fail.
- ALLOWLIST requires the OS sandbox (macOS Seatbelt or Linux bubblewrap). On any platform where the cage is unavailable, the engine refuses to run — it fails closed rather than silently falling back to OPEN egress.
bl graph init writes ~/.bounded-loops/egress.json atomically (temp file +
fchmod 0600 + fsync + round-trip verify + os.replace).
Interactive mode — three prompts:
Running bl graph init without flags opens an interactive session with exactly three
prompts in order:
| # | Prompt | Options | Default |
|---|---|---|---|
| 1 | Connector mode | local_cli (subscription CLI) / byok_https (bring-your-own key) |
local_cli |
| 2 | Egress posture | open / allowlist / broker |
open |
| 3 | Confirm write | Y / n |
Y |
Connector mode is informational only — it is not stored in egress.json; only the
posture and allowlist are written. To skip all prompts and accept the defaults in
one command: bl graph init --yes.
The Local-CLI connector runs your already-logged-in agent CLI as a subprocess. The engine never reads, stores, or logs your credentials. Authentication happens out-of-band in the CLI's own config (subscription / print mode).
For this example, claude must be installed and logged in:
which claude # must resolve
claude -p "echo test" >/dev/null # must exit 0The supported provider IDs and their subscription-mode invocations are:
provider_id |
CLI binary | How the prompt is delivered |
|---|---|---|
claude |
claude -p |
stdin |
codex |
codex exec --skip-git-repo-check |
positional argument |
grok |
grok -p |
positional argument |
muse |
muse exec |
positional argument |
agy |
agy -p |
positional argument |
These files come directly from the engine's own test suite. Copy them verbatim and they will pass the compiler.
manifest.yaml — the portable graph (no credentials, no absolute paths):
api_version: "bounded-loops.dev/graph/v1"
graph_id: agent-run
version: "1.0.0"
nodes:
- id: agent
kind: research_claim
inputs: {}
outputs: {claim: text}
budget: {max_attempts: 1, max_wallclock_s: 30}
effects: [workspace_write]
isolation: process_restricted
connection_slot: model
edges: []
connection_slots:
- id: model
requires: [text_generation]
data_class_max: public
policies:
data_class: public
fail_mode: fail_closedconnections.json — the admitted connection for the model slot.
The object must contain exactly these 17 keys — the compiler rejects any extra or
missing field. The admission_digest and route_policy_digest are sha256 strings
that your admission workflow produces; the values below are valid stand-ins for a
local run:
[
{
"binding_id": "binding-1",
"slot_id": "model",
"connector_id": "local-cli",
"connector_version": "1.0.0",
"connection_id": "conn-1",
"admission_digest": "sha256:bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb",
"route_policy_digest": "sha256:cccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccccc",
"provider_id": "claude",
"model_target": "subscription",
"region": "local",
"fallback": false,
"capabilities": ["text_generation"],
"data_class_max": "public",
"allowed_effects": ["workspace_write"],
"isolation": "process_restricted",
"transport": "local_cli",
"admitted": true
}
]Connection constraints the compiler enforces at compile time:
capabilitiesmust satisfy everyrequiresin the slot (text_generationhere).data_class_maxrank must be >= the graph'sdata_class(public>=public).allowed_effectsmust include every effect the node declares (workspace_write).isolationrank must be >= the node'sisolation(process_restricted>=process_restricted).admittedmust betrue.
inputs.json — run-time prompts, one per node ID. Prompts are never baked into
the portable graph; they are supplied here at execute time:
{"agent": "Summarize the current AI Agent Reliability Engineering landscape in 200 words."}Subscription required. This step invokes your logged-in
claudeCLI and sends the prompt to Anthropic's API. It counts against your Claude subscription or API quota. If you do not have a subscription, skip to step 9 (built-in sandbox demo) or step 10 (in-process demonstration — no credentials required).
bl graph run --execute manifest.yaml \
--connections connections.json \
--inputs inputs.json \
--out ./my-runThe engine:
- Compiles the manifest against
connections.json(compile errors exit early with a clear message). - Runs a preflight check — refuses any node whose binding is neither
local_clinorhttps(with a matching admitted connection). Approval nodes are not refused; they are skipped at preflight and the run pauses when they are reached. - Probes the platform for native sandbox support (macOS Seatbelt / Linux bubblewrap).
- Runs each admitted
local_clinode: invokesclaude -p, pipes the prompt to stdin, captures stdout as the content-addressed output artifact. - Gates each node with an independent structural acceptance gate.
- Writes a hash-chained run directory to
./my-run.
Exit codes:
0— SUCCEEDED (all nodes passed their gates)2— compile error, preflight refusal, or node failure3— PAUSED (the run has paused at an approval node; usebl graph approveto record a decision and resume)
Sample terminal output on success:
Local-CLI graph run — REAL execution (your own subscription agent CLI)
==============================================================
run_state : SUCCEEDED
OK node 'agent': SUCCEEDED artifact=sha256:a1b2c3d4e5f6...
out : ./my-run
Open the visual Arena: bl graph arena --run ./my-run
The prompt is intentionally not persisted in the run directory. After a successful run the directory contains:
artifacts/ ← content-addressed node outputs
connections.json
controller-events.jsonl ← the append-only hash-chained event log
controller-events.jsonl.lock
manifest.yaml
plan.json
run-meta.json
If the run contained an approval node and you ran bl graph approve, two additional
files appear: approvals.json and approvals.lock.
If your graph contains an approval node and the run pauses there, bl graph run
exits with code 3 and prints output such as:
Local-CLI graph run — REAL execution
==============================================================
run_state : RUNNING
pause_status : PAUSED — awaiting human decision on 1 node(s): review
?? node 'review': AWAITING_APPROVAL artifact=-
out : ./my-run
To continue:
bl graph approve --run ./my-run --node review --decision approved|rejected
run_state is RUNNING because the run-level projection has no AWAITING_APPROVAL
state — the run is live but durably paused at a node. The pause_status line makes
the paused state unambiguous: this is exit code 3, not success (0) or failure (2).
In the hint line above, approved|rejected uses | as notation for "or" — it is
not a shell pipe. Do not type that string literally. Pick one value and run the
command with only that value:
# Accept the node's output:
bl graph approve --run ./my-run --node review --decision approved
# Reject it:
bl graph approve --run ./my-run --node review --decision rejectedbl graph approve exits 0 if the run has now SUCCEEDED, 2 if it FAILED, and 3
if the run is still PAUSED (more approval nodes remain — run bl graph approve
again for the next node named in the output).
Click-to-approve in a browser: If you prefer not to type the CLI command, start the loopback console in another terminal while the run is paused:
bl graph console --run ./my-run
# Prints: http://127.0.0.1:<port>/ token=<token>
# Open the printed URL in any browser on this host.The console is local-only (127.0.0.1, never 0.0.0.0) with a per-invocation token.
Console lifecycle — two things to know:
- Auto-close: once every pending approval node in the run is decided (whether via
the browser or a concurrent
bl graph approvecall), the console server stops on its own. There is no automatic resume step; check the run state withbl graph status --run <dir>. - URL not opened: if you start the console but never open the printed URL in a
browser, the server keeps running and waiting. Press
Ctrl-Cto stop it, then usebl graph approveon the command line instead.
Arena — a self-contained, read-only HTML page:
bl graph arena --run ./my-run
# Writes ./my-run/arena.html by default.
open ./my-run/arena.html # macOS; use xdg-open on LinuxStatus — a text projection of the event log:
bl graph status --run ./my-runOutput includes a LOCAL/UNVERIFIED notice. The projection is read from the local
event log; it has not been verified against a remote Arena server. This is the
expected posture for a local run.
Before running, validate the manifest in isolation:
bl graph lint manifest.yamlCompile against connections to check binding resolution and policy constraints:
bl graph plan manifest.yaml --connections connections.jsonBoth commands accept --json for machine-readable output.
To prove native OS sandbox isolation without any agent CLI or credentials:
bl graph run --execute --out ./sandbox-demoNo manifest is required. The engine runs a built-in probe node inside macOS Seatbelt or Linux bubblewrap, attempts network access and out-of-workspace writes, and gates on whether the OS actually denied them. The independent gate — not the node itself — decides SUCCEEDED or FAILED.
bl graph demo --out ./demo-runThis is a DEMONSTRATION. A prominent banner in the output and in run-meta.json
marks it explicitly: nodes are not executed in a sandbox; no isolation,
network, or execution-isolation enforcement (E2 — the native OS sandbox gate)
applies. Use it to inspect the run-directory structure and exercise
bl graph status / bl graph arena without any agent CLI installed.
E2 also appears in bl graph --help ("E2 required for run"). It refers to the
admission check that the engine runs before executing any node: a node must have
an admitted connector record, and the native OS sandbox must be available. The demo
command deliberately bypasses both checks so you can try the run-directory tools
without credentials or a supported sandbox platform.
bl graph studio --out ./graph-studio.html
open ./graph-studio.htmlOpens a self-contained HTML authoring tool. Pass --from manifest.yaml to load an
existing graph for editing (the manifest is validated before loading).
| Error | Cause | Fix |
|---|---|---|
compile failed [no_admitted_connection] |
No connection in connections.json satisfies the node's slot |
Check that capabilities, allowed_effects, isolation rank, and data_class_max rank all match |
node 'agent' binds provider 'claude', which is not a known agent CLI |
provider_id in connections.json is not a known profile |
Use one of: claude, codex, grok, muse, agy |
local-CLI node 'agent' exited 1: ... |
The agent CLI exited with an error (expired login, etc.) | Log in to the agent CLI and retry |
agent CLI 'claude' is not installed on this host |
claude not in PATH |
Install the Claude CLI and verify which claude |
no run-time prompt was supplied for local-CLI node 'agent' |
inputs.json does not contain a key for "agent" |
Add "agent": "your task" to inputs.json |
graph run --execute requires --out <dir> |
--out missing |
Add --out <directory> |