https://github.com/hadihonarvar/lynx
Framework-agnostic policy-gated durable execution for AI agents. Every tool call gets a YAML policy check, a checkpoint, and a hash-chained audit event.
https://github.com/hadihonarvar/lynx
agentic agentic-ai agents ai audit claude crewai durable-execution langgraph llm lynx mcp openai policy policy-engine python reliability
Last synced: about 1 month ago
JSON representation
Framework-agnostic policy-gated durable execution for AI agents. Every tool call gets a YAML policy check, a checkpoint, and a hash-chained audit event.
- Host: GitHub
- URL: https://github.com/hadihonarvar/lynx
- Owner: hadihonarvar
- License: other
- Created: 2026-06-09T21:37:30.000Z (about 1 month ago)
- Default Branch: main
- Last Pushed: 2026-06-18T06:11:53.000Z (about 1 month ago)
- Last Synced: 2026-06-18T08:11:56.924Z (about 1 month ago)
- Topics: agentic, agentic-ai, agents, ai, audit, claude, crewai, durable-execution, langgraph, llm, lynx, mcp, openai, policy, policy-engine, python, reliability
- Language: Python
- Homepage: https://github.com/hadihonarvar/lynx
- Size: 554 KB
- Stars: 8
- Watchers: 0
- Forks: 0
- Open Issues: 0
-
Metadata Files:
- Readme: README.md
- Changelog: CHANGELOG.md
- Contributing: CONTRIBUTING.md
- License: LICENSE
- Code of conduct: CODE_OF_CONDUCT.md
- Security: SECURITY.md
Awesome Lists containing this project
README
# Lynx
[](https://pypi.org/project/lynx-agent/)
[](https://pypi.org/project/lynx-agent/)
[](https://github.com/hadihonarvar/lynx/blob/main/LICENSE)
[](https://github.com/hadihonarvar/lynx/actions/workflows/ci.yml)
[](https://lynxharness.com)
**A stateless, type-safe policy kernel for AI agent tool calls.**
π **Website (interactive feature tour): [lynxharness.com](https://lynxharness.com)**
Pure functions over immutable values. No database. No globals. No leaks. Five verdicts. Streaming events to user-owned sinks.
**Lynx is the governance and safety layer for your agent's loop β not the loop itself.** Every iteration of an agent loop proposes a tool call; Lynx checks each one (`allow / deny / dry_run / approve_required / transform`), audits it, and keeps the loop bounded β *before* it touches the real world. It is not an agent framework and won't write your loop's logic. Use it two ways:
- **Bring your own harness** (OpenAI Agents SDK, LangChain, CrewAI, PydanticAI) and drop `ToolGuard` into its tool calls β the framework drives the loop, Lynx governs each action inside it.
- **Or use `run_agent`** as a minimal, stateless loop of your own.
Either way you get the same five-verdict policy boundary on every action, plus the loop-control rails a harness needs: **budgets** (step/token/duration caps), a **kill-switch**, a **repetition gate** (breaks same-tool-same-args infinite loops), and **durable resume** (a crash mid-loop replays completed steps without re-running side effects).
```python
import asyncio
from lynx import (
ToolSet, tool, load_policy_file, run_agent,
stdout_sink, auto_deny,
)
@tool(reversible=False, scope=("filesystem:write",))
async def shell(cmd: str) -> str:
proc = await asyncio.create_subprocess_shell(
cmd,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE,
)
out, _ = await proc.communicate()
return out.decode()
result = await run_agent(
my_agent,
task="clean up old logs",
tools=ToolSet.from_functions(shell),
policy=load_policy_file("policy.yaml"),
sinks=(stdout_sink(),),
on_approval=auto_deny("no approvals configured"),
environment="prod", # policy can match on context.environment
# principal=Principal(kind="user", id="hadi"), # optional
# workspace=".", # optional
# budget=Budget(steps=50, duration_seconds=600), # this IS the default; Budget.unlimited() to opt out
# correlation_id=None, # auto-generated if None
)
# result: { correlation_id, bundle_id, final_answer, error, steps_taken }
# Lynx holds NOTHING. No DB. No state. No leaks.
```
## What Lynx does
- **Policy-gated execution** at the tool-call boundary. Five verdicts: `allow / deny / dry_run / approve_required / transform`.
- **Streaming events** to your sinks. We never store events β your sink can buffer, write to disk, ship to OTel, post to a webhook, whatever you choose.
- **Pure functions everywhere.** The kernel is one function: `run_agent(agent, task, *, tools, policy, sinks, on_approval, ...)`. No `Runtime` class. No singleton.
- **Immutable values.** Every public type is `frozen=True, slots=True`. Mutation raises at runtime; mypy catches it at write time.
- **No globals.** No tool registry, no broker, no module-level state. ToolSet is built explicitly at call site.
- **Hot-swappable policy.** Pass a different `PolicyBundle` on the next `run_agent` call β the bundle is an immutable value; the kernel holds nothing between calls. (Mid-run reload is not supported; build a new bundle and use it on the next run.)
- **Layered policy scopes** *(optional)*. Compose independent named policies β `compile_policy([PolicyLayer("org", β¦), PolicyLayer("team", β¦), PolicyLayer("user", β¦)])` β each evaluated on its own, then combined by a developer-chosen `Combiner`. Ships `strict_overrides_loose` (default, fail-closed: broadest layer sets a floor narrower layers can only tighten), `last_layer_wins` (most-specific layer may re-grant), and `first_layer_wins` β or bring your own for any trust model. Layers that match no rule abstain; provenance is layer-tagged (`team:block-http`). Mechanism, not policy: Lynx evaluates the layers; you decide who overrides whom. See example 39 and [`docs/02-policy-language.md`](docs/02-policy-language.md#layered-policy-scopes).
- **Obligations β "allow, *and also* do X"** *(optional)*. Attach mandatory side-actions to any verdict (the XACML/Cedar model), resolved against an `ObligationRegistry` you supply β the kernel ships none. A `pre` obligation runs *before* the action and **gates** it (handler fails β the tool never runs; *"refund only if a scoped credential issued"*); a `post` obligation runs after (notify-finance, write a special audit record). Unknown id or no registry β fail-closed; every obligation streams `obligation.required/fulfilled/failed` audit events. It is not a verdict β it rides on `allow`/`deny`/`transform`/etc. See [`docs/02-policy-language.md`](docs/02-policy-language.md#obligations--allow-and-also-do-x).
- **Durable runs, no double side effects** *(opt-in)*. Pass a `RunStore` you implement over your own storage and a stable `run_id`: a crashed run resumes at the first incomplete step β the model is not re-called for completed steps (no re-burned tokens) and journaled actions are not re-executed (no double charges). Two racing workers resolve to one winner; the loser exits `superseded` before executing anything.
- **Token metering and caps.** Adapters report per-step input/output token counts; the kernel streams them as `step.usage` events, totals them on `RunResult.usage`, and enforces `Budget(tokens=β¦, input_tokens=β¦, output_tokens=β¦)` between steps. The kernel counts and enforces counts β it never converts tokens to money; multiply by your own rates in a sink.
- **Token optimization (the compressor seam)** *(opt-in)*. Metering measures spend; this reduces it. Pass `compressor=` and every fresh tool result is shrunk *before* it enters the conversation, the journal, and any replay β so a 40 KB log dumped once isn't re-sent in full on every later step. Lynx ships pure-Python reference compressors (`truncate_compressor`, `dedup_compressor`, `compose_compressors`, `route_compressor` via `@tool(compress=β¦)`, `external_filter_compressor`) and **fails open** β a broken compressor never drops a real output. Lynx is *not* a token optimizer; it owns the seam where yours plugs in. Separately, the Claude adapter now enables Anthropic **prompt caching** (`cache_prompt=True`) so a long loop re-reads prior turns from cache instead of re-billing them. *(RTK β github.com/rtk-ai/rtk β has no stdin filter and is wired at the tool level: your shell tool runs `rtk `.)*
- **Pluggable execution (the executor seam).** Every approved action flows through one `Executor` β in-process by default, a subprocess with rlimits, or *your* Docker/gVisor/E2B wrapper (one async callable). Route per-tool via `@tool(isolation="container")` + `route_executor({...})`, failing closed when a requested isolation has no route. Lynx defines the seam; the security boundary is whatever you plug in.
- **Handoff graphs** *(optional)*. Sequential multi-agent workflows where **the edge is a permission boundary**: each node is one `run_agent` call with its own policy/tools/budget, and edges route on outcomes β including **denial counts**. Bounded by construction (`max_transitions`), explicit context passing, YAML-declarable, durable via the same `RunStore`. Just sugar over a loop of `run_agent` calls β skip it and write the loop yourself anytime.
- **MCP proxy** *(optional)*. Put Lynx *in front of* any MCP server: the client (Claude Desktop/Code, Cursor, β¦) points at Lynx instead of the server, and every `call_tool` flows through the same `evaluate β mediate` path β `allow / deny / dry_run / approve_required / transform` β with an audit stream, **zero code change** on client or server. `serve_mcp_proxy(upstream, policy=β¦, sinks=β¦)` wires the stdio transport; `GovernedProxy` / `govern_call` are the transport-free, unit-testable core. See example 34. *(`pip install lynx-agent[mcp]`.)*
- **Framework-native governance** *(optional)*. When an agent *framework* owns the loop (OpenAI Agents SDK, LangChain, CrewAI, PydanticAI) instead of Lynx, drop a `ToolGuard` in front of its tool calls β `await guard.check(tool_name, args)` runs the same `evaluate β mediate` kernel and returns a `GovernedCall`, so all five verdicts work at the boundary with no proxy and no rewrite. This is the inverse of an **adapter** (`lynx.adapters`, where Lynx drives the loop): here the framework drives, Lynx governs each call inside it. `governed_function_tools(tools, policy=β¦)` turns a `ToolSet` into governed OpenAI Agents SDK tools in one line. See example 40. *(`pip install lynx-agent[openai-agents]` for the SDK shim; `ToolGuard` itself is stdlib-only.)*
- **Loop control & operability.** The rails that keep an agent loop bounded: a kill-switch (`cancel=CancelToken()`) checked at every step boundary and before each tool runs β a cancelled run stops after at most one more action; a repetition gate (`Budget(max_repeated_calls=)`) that breaks same-tool-same-args infinite loops; hard `Budget` caps (steps / tokens / duration); and per-step / per-tool timeouts.
## What Lynx does NOT do
- **No storage** β durability journals to a `RunStore` *you* implement on *your* Redis/Postgres/Dynamo (the contract is two methods and one sentence); audit events stream to *your* sinks. Lynx never opens a file or a connection.
- **No process supervision** β Lynx does not restart dead workers; your supervisor (systemd, k8s, a queue) does. Lynx makes the restart cheap and safe.
- **No prompt filtering** β that's [NeMo Guardrails](https://github.com/NVIDIA/NeMo-Guardrails) or [Guardrails AI](https://github.com/guardrails-ai/guardrails).
- **No cluster orchestration** β that's [Temporal](https://temporal.io) or [Inngest](https://www.inngest.com).
- **No agent framework** β that's [LangGraph](https://langchain-ai.github.io/langgraph/) / [CrewAI](https://www.crewai.com); we wrap them via adapters.
## Install
```bash
pip install lynx-agent # core (3 deps)
pip install lynx-agent[anthropic] # Claude adapter
pip install lynx-agent[openai] # GPT + any OpenAI-compatible provider
pip install lynx-agent[langgraph]
pip install lynx-agent[crewai]
pip install lynx-agent[openai-agents] # govern the OpenAI Agents SDK (ToolGuard)
pip install lynx-agent[mcp]
pip install lynx-agent[otel] # OpenTelemetry audit sink
```
The `[openai]` adapter also targets any **OpenAI-compatible** provider β Grok (xAI), Mistral, DeepSeek, Groq, OpenRouter, Together, Fireworks, Perplexity, Ollama β via one registry, and the *same policy* governs every one:
```python
from lynx.adapters.openai_compat import openai_compatible_agent
agent = openai_compatible_agent("deepseek", tools=tools, model="deepseek-chat")
# swap "deepseek" β "grok" / "mistral" / "groq" / β¦ β run_agent(...) is unchanged
```
## Quickstart
```bash
pip install lynx-agent
lynx init # writes one file: policy.yaml
python examples/01_hello_allow.py
```
## Documentation
| Doc | What's in it |
|---|---|
| [`docs/concepts.md`](docs/concepts.md) | The model end-to-end: the loop, the five verdicts, every seam, and how they compose |
| [`docs/02-policy-language.md`](docs/02-policy-language.md) | Full policy reference β YAML schema, operators, Python rules, layered scopes |
| [`docs/cli.md`](docs/cli.md) | Complete CLI reference β every command, flag, output line, and exit code |
| [`docs/cookbook.md`](docs/cookbook.md) | Copy-pasteable policy patterns (block `rm -rf`, tiered approvals, layered org/team/user) |
| [`docs/integration-cookbook.md`](docs/integration-cookbook.md) | Wiring recipes β sinks (SQLite/Postgres/OTel/Splunk/HTTP), durability stores, Slack approvals, `ToolGuard` in your framework |
| [`docs/what-lynx-is-and-isnt.md`](docs/what-lynx-is-and-isnt.md) | The boundary Lynx owns vs. what to compose it with |
| [`docs/faq.md`](docs/faq.md) | Common questions β performance, MCP, framework support, hot-reload, cleanup |
| [`docs/roadmap.md`](docs/roadmap.md) | Shipped vs. planned, by phase |
## How it works
```
ββββββββββββββββββββββββββββββββββββββββββββββ
β Agent (any framework) β
ββββββββββββββββββββ¬ββββββββββββββββββββββββββ
β ToolCall
βΌ
βββββββββββββββββββββββββββββββββββββββββββββ
β run_agent (pure function) β
β 1. PDP evaluates β Decision β
β 2. Mediator dispatches by verdict β
β 3. Sinks called with each AuditEvent β
β 4. Approval handler called sync if neededβ
βββββββββββββββββββββββββββββββββββββββββββββ
β side effect
βΌ
ββββββββββββββββββββββββββββββββββββββββββββββ
β Real world β
ββββββββββββββββββββββββββββββββββββββββββββββ
```
Each agent step:
1. Build `ActionRequest` from the agent's `ToolCall`
2. `evaluate(policy, request, context)` returns a `Decision` (pure function)
3. `mediate(request, decision, tools, on_approval)` dispatches
4. Each step emits a few events; sinks consume them
5. Result is appended to a new `conversation` tuple; old tuple is freed
## Tools β `@tool` and `ToolSet`
Every tool is an `async def` decorated with `@tool`. The decorator attaches an
immutable `ToolDef` to the function (no global registry); you bundle decorated
functions into a `ToolSet` explicitly at the call site.
```python
from lynx import tool
@tool(
cost="low", # "low" | "medium" | "high" (default "low")
reversible=False, # if False, dry_run requires a .shadow
scope=("filesystem:write",), # free-form tags policy can match on
blast_radius_hint=None, # int | None β opaque to the kernel; readable by your rules via declared.blast_radius_hint
name=None, # override; default = fn.__name__
description=None, # override; default = first line of docstring
)
async def write_file(path: str, content: str) -> str:
"""Save text to a file."""
Path(path).write_text(content)
return f"wrote {len(content)} bytes to {path}"
```
### Shadows β pure previews for `dry_run`
If a tool is irreversible and policy chooses `dry_run`, the kernel calls the
**shadow** instead of the real function. Shadows must be pure (no I/O, no side
effects) and return a JSON-serializable preview.
```python
@write_file.shadow
async def _write_file_shadow(path: str, content: str) -> dict:
p = Path(path)
return {
"would_write": path,
"bytes": len(content.encode()),
"would_overwrite": p.exists(),
"preview": content[:120],
}
```
If no shadow is registered and policy defaults `on_missing_shadow: approve_required`
(the default), an irreversible tool with no rule match falls through to approval
rather than running blind.
Alternative attachment form:
```python
from lynx import shadow
@shadow(write_file)
async def _write_file_shadow(path, content): ...
```
### `ToolSet` β immutable, built at call site
```python
from lynx import ToolSet
tools = ToolSet.from_functions(write_file, shell, get_customer)
tools.names() # ("get_customer", "shell", "write_file")
tools.get("write_file") # ToolDef
tools.with_tool(other_def) # returns NEW ToolSet
tools.without_tool("shell") # returns NEW ToolSet
tools.union(other_toolset) # returns NEW ToolSet
len(tools) # 3
```
Every operation returns a new `ToolSet`; the original is untouched.
## Policy β full reference
A policy is a frozen `PolicyBundle` produced by `compile_policy(yaml_str)` or
`load_policy_file(path)`. Bundles are content-addressed by `bundle.id` and safe
to hot-reload β the kernel holds no policy state between calls.
### YAML schema
```yaml
version: 1 # int; currently only 1 is defined
defaults:
on_no_match: deny # verdict when no rule matches a request
on_missing_shadow: approve_required
# verdict when no rule matches AND the tool
# is irreversible AND has no .shadow
predicates: # named, reusable matchers
in_prod: { context.environment: prod }
is_kubectl: { tool: kubectl }
is_destructive_sql:
tool: sql_exec
args.sql.matches: '(?i)\b(UPDATE|DELETE)\b'
rules:
- id: hard-block-rm-rf-root # str; defaults to "rule_"
priority: 100 # int; higher runs first (default 0)
description: "..." # optional, free-form
match: { ... } # see "Match expressions" below
decision: deny # one of the five verdicts
reason: "rm -rf / is hard-blocked"
approvers: ["sre-oncall@acme.com"] # only used by approve_required
timeout_seconds: 1800 # only used by approve_required
transform: { ... } # only used by transform
```
Rules are sorted by `(-priority, file order)`. The first matching rule wins.
Python rules (see below) are interleaved with YAML rules by priority β a
higher-priority YAML rule beats a lower-priority Python rule, and vice versa.
### The five verdicts
| Verdict | What the mediator does |
|---|---|
| `allow` | Call `tool.fn(**args)` normally. |
| `deny` | Skip execution. Inject a `[denied]` tool message into the conversation. |
| `dry_run` | Call `tool.shadow_fn(**args)` instead of `fn`. Real side effects suppressed. |
| `approve_required` | Call `on_approval(...)` synchronously. On grant, proceed as `allow`; on deny, behave as `deny`. |
| `transform` | Rewrite `args` per the `transform:` block, then call `fn(**rewritten_args)`. |
### Match expressions
Match expressions read fields off the live `ActionRequest` and `ExecutionContext`.
**Paths** (the part before the operator):
| Path prefix | Reads from |
|---|---|
| `tool` | The tool name (string) |
| `args....` | The arguments the agent proposed |
| `declared.` | Tool metadata: `cost`, `reversible`, `scope`, `blast_radius_hint`, `has_shadow` |
| `context.` | `principal`, `environment`, `workspace`, `correlation_id`, `step_seq`, `timestamp`, `extra` |
**Operators** (suffix the path with `.`):
| Operator | Meaning | Example |
|---|---|---|
| (none) / `.eq` | Equality | `tool: kubectl` |
| `.matches` | Regex `re.search` (RE2-style guards reject catastrophic backtracking) | `args.cmd.matches: '^rm\s+-rf'` |
| `.in` | Value is in the listed sequence | `args.customer_id.in: ["C-789"]` |
| `.contains` | Container contains the value | `declared.scope.contains: filesystem:write` |
| `.contains_any` | Container contains any listed value | `declared.scope.contains_any: [a, b]` |
| `.contains_all` | Container contains all listed values | `declared.scope.contains_all: [a, b]` |
| `.gt` `.ge` `.lt` `.le` | Numeric comparison | `args.amount_usd.gt: 500` |
| `.between` | `lo <= v <= hi` | `args.amount_usd.between: [50, 500]` |
| `.not_between` | Inverse of `between` | |
**Composition** at any level:
```yaml
match:
all_of:
- is_kubectl # named predicate
- in_prod
- args.command.matches: '^(apply|delete|patch)\b'
# any_of: [ ... ]
# not: { tool: shell }
```
### `transform:` block
```yaml
decision: transform
transform:
jsonpath: "$.args.sql" # default "$.args"; the target arg key
append: " AND tenant_id = 'TENANT-A'" # one of: set | append | delete
```
- `set: ` β replace the value at `jsonpath`
- `append: ` β string-concatenate to the existing value
- `delete: true` β remove the key from `args`
### Python rules
Anything you can't express in YAML, write as a Python predicate. Rules are
explicit arguments to `compile_policy`; there is no decorator and no registry.
```python
from lynx import compile_policy
from lynx.policy import allow, deny, dry_run, approve_required, transform
def block_paths_outside_workspace(req, ctx):
if req.tool != "shell":
return None # skip β let YAML decide
if path_escapes(req.args["cmd"], ctx.workspace):
return deny("path escapes workspace")
return None
bundle = compile_policy(
yaml_source,
python_rules=(block_paths_outside_workspace,),
python_rule_priorities=(("block_paths_outside_workspace", 100),),
)
```
Each Python rule is `(ActionRequest, ExecutionContext) -> Decision | None`.
Return `None` to defer; the first non-`None` result wins. Python and YAML
rules are interleaved in a single priority-sorted evaluation order (default
priority `0`). If a rule raises during evaluation, it is recorded as a
diagnostic marker in `Decision.matched_rules` (e.g. ``)
and evaluation continues β buggy rules never silently fail-open.
### Decision constructors
For Python rules and tests:
```python
from lynx.policy import allow, deny, dry_run, approve_required, transform
allow(reason="", matched_rules=())
deny(reason, matched_rules=())
dry_run(reason="", matched_rules=())
approve_required(approvers=(), timeout_seconds=1800, reason="", matched_rules=())
transform(transform_args={"sql": "..."}, reason="", matched_rules=())
```
### Default behavior when no rule matches
1. If the tool is **irreversible AND has no shadow** β `defaults.on_missing_shadow`
(default `approve_required`).
2. Otherwise β `defaults.on_no_match` (default `deny`).
The matched rule id will be `""` or
`""` so you can see the fall-through in audit events.
### Layered policy scopes
For org/team/user-style composition, pass a list of `PolicyLayer` to
`compile_policy` instead of one source. Each layer is evaluated independently and
a developer-chosen `Combiner` resolves disagreements:
```python
from lynx import PolicyLayer, compile_policy, last_layer_wins
bundle = compile_policy(
[PolicyLayer("org", org_yaml), PolicyLayer("team", team_yaml), PolicyLayer("user", user_yaml)],
merge=last_layer_wins, # optional; defaults to strict_overrides_loose (fail-closed)
)
```
`strict_overrides_loose` (default) takes the most-restrictive verdict;
`last_layer_wins` lets the most-specific layer re-grant; `first_layer_wins` makes
the broadest authoritative β or pass your own `Combiner`. Non-matching layers
abstain; provenance is layer-tagged. Full reference:
[`docs/02-policy-language.md`](docs/02-policy-language.md#layered-policy-scopes).
### `run_agent` β all kwargs
```python
result = await run_agent(
agent, # implements async step(conv) -> ToolCall | FinalAnswer
task, # str β becomes the first user Message
*,
tools, # ToolSet
policy, # PolicyBundle
sinks=(), # Iterable[Sink]
on_approval=None, # ApprovalHandler; defaults to auto_deny
budget=Budget(steps=50, duration_seconds=600),
principal=Principal(kind="user", id="anonymous"),
environment="dev", # policy reads this via context.environment
workspace=".", # policy reads this via context.workspace
correlation_id=None, # auto-generated UUID4 if None
)
```
## Sinks β the audit replacement
```python
from lynx import stdout_sink, jsonl_sink, multi_sink
# Pretty-print + persist to jsonl in one go
with open("audit.jsonl", "a") as f:
sink = multi_sink(stdout_sink(), jsonl_sink(f))
await run_agent(..., sinks=(sink,))
# File is yours. You close it. You rotate it. You ship it where you want.
```
Built-in sinks:
| Sink | What it does |
|------|-------------|
| `stdout_sink(stream=...)` | Pretty-print events |
| `jsonl_sink(handle)` | One JSON line per event |
| `hash_chained_sink(handle)` | One JSON line per event, **tamper-evident** (hash-chained) |
| `otel_sink(tracer=...)` | Emit each event as an OpenTelemetry span (`pip install lynx-agent[otel]`) |
| `noop_sink()` | Discard (for tests) |
| `multi_sink(*sinks)` | Fan out concurrently |
| `callback_sink(fn)` | Wrap any async callable |
Write your own β it's just `async def __call__(event: AuditEvent) -> None`.
### Tamper-evident audit
An audit log you can quietly edit isn't an audit log. `hash_chained_sink` is a
drop-in for `jsonl_sink` that fingerprints every line and chains it to the line
before it β `hash = sha256(prev_hash + canonical_json(event))` β so editing a
body, dropping a denial, or reordering events breaks every fingerprint
downstream. It's a pure sink (no kernel change, stdlib-only) and composes with
`multi_sink`.
```python
from lynx import hash_chained_sink, verify_chain
with open("audit.jsonl", "a") as f:
await run_agent(..., sinks=(hash_chained_sink(f),))
verify_chain("audit.jsonl") # VerifyResult(intact=True, lines=42, ...)
```
```console
$ lynx verify audit.jsonl
intact: 42 events, chain verified
# tamper with one line, then:
$ lynx verify audit.jsonl
broken at line 17: hash mismatch (line was modified) # exits 1
```
This is tamper-*evident* (proves nobody altered the log). See example 37.
### OpenTelemetry
Already running OTel? `otel_sink` turns every governance decision into a span so
it lands in your existing backend (Datadog / Honeycomb / Grafana Tempo / Jaeger)
next to the rest of your telemetry β no custom plumbing. Each `AuditEvent`
becomes one short span named by `event.kind` with `lynx.*` attributes, and it
nests under the ambient trace automatically when the agent runs inside an
instrumented request. Stateless: every span is ended immediately, so nothing
accumulates over a long run.
```python
from lynx import otel_sink
await run_agent(..., sinks=(otel_sink(),)) # uses trace.get_tracer("lynx")
```
`pip install lynx-agent[otel]`. See example 38.
## Approvals β synchronous handlers
```python
from lynx import cli_prompt_approval, callback_approval, ApprovalDecision
# Built-in: prompt on stdin
await run_agent(..., on_approval=cli_prompt_approval())
# Or bring your own
async def slack_approval(req):
msg = await slack.post(f"Approve {req.request.tool}?")
button = await slack.wait_for_click(msg, timeout=3600)
return ApprovalDecision(granted=button == "approve", approver=button.user)
await run_agent(..., on_approval=callback_approval(slack_approval))
```
The `run_agent` call blocks on the handler. No queue. No broker. No cross-process resume. Your handler decides how to wait.
## Durability β crash-resume without double side effects
Opt in by passing a `RunStore` (your storage, your dependency) and a stable `run_id`:
```python
result = await run_agent(
agent, task,
tools=tools, policy=policy,
store=my_store, # you implement two methods (below)
run_id="invoice-2026-0611", # stable across retries
)
# Process dies mid-run? Your supervisor retries the same call.
# Completed steps replay from the journal: the model is NOT re-called,
# journaled actions are NOT re-executed. A finished run returns the same
# answer forever. Two racing workers resolve to one; the loser returns
# error="superseded: ..." having executed nothing.
```
The whole `RunStore` contract:
```python
class MyStore: # Redis / Postgres / Dynamo / a dict
async def append(self, record: StepRecord) -> None:
# MUST atomically raise DuplicateRecord if (run_id, seq) exists.
# Postgres: PRIMARY KEY (run_id, seq). Redis: HSETNX. That's it.
...
async def load(self, run_id: str) -> Sequence[StepRecord]:
... # ordered by seq
```
That one uniqueness rule is the concurrency story: the write-ahead intent
journaled before every action *is* the claim β no leases, no TTLs, nothing
to clean up when a worker dies. See
[`examples/24_durable_resume.py`](examples/24_durable_resume.py) for a
complete ~15-line store plus crash, resume, and supersede in action, and
[`docs/integration-cookbook.md`](docs/integration-cookbook.md) for Redis /
Postgres / file-backed recipes.
**The crash window, handled honestly.** If a worker dies *between* executing
an action and journaling its result, the action *may* have run. On resume,
Lynx re-proposes it to policy with `context.extra.uncertain_retry: true` β
so your policy decides: re-run it (idempotent tools), deny it, or escalate
to a human:
```yaml
- id: never-rerun-uncertain-payments
match: { context.extra.uncertain_retry: true, declared.reversible: false }
decision: approve_required
```
Inspect any journal with `replay(records)` (pure function) or `lynx trace
records.jsonl` (for file-backed stores).
## Execution isolation β the executor seam
Policy decides *whether* an action runs; the executor decides *where and
how*. By default approved tools run in-process. Pass an `Executor` and all
real execution (allow / transform / approval-granted) flows through it
instead:
```python
from lynx import inline_executor, route_executor, subprocess_executor
@tool(reversible=False, scope=("compute:exec",), isolation="container")
async def run_code(snippet: str) -> str: ...
result = await run_agent(
agent, task, tools=tools, policy=policy,
executor=route_executor({
None: inline_executor(), # default route
"subprocess": subprocess_executor(), # rlimits β crash protection
"container": my_docker_executor, # YOURS (~20 lines, see cookbook)
}),
)
```
A custom executor is one async callable β `(request, tool) -> ActionResult`
β so Docker, gVisor, Firecracker, E2B, or Modal plug in without Lynx
shipping any of them as dependencies. Routing **fails closed**: a tool that
declares `isolation="microvm"` when no microvm route exists gets a failed
action, never a silent fallback to the host. Dry-runs bypass the seam
(shadows are side-effect-free by contract), and a raising executor fails
the action β never the run.
Honesty, as always: Python has no reliable in-language sandbox, and
`subprocess_executor()` is **crash/runaway protection, not a security
boundary** (see [SECURITY.md](SECURITY.md)). Lynx is the chokepoint where
isolation attaches; the boundary itself is whatever you put behind the
seam β the same stance as "you bring the database."
## Handoff graphs β the edge is a permission boundary
Optional, and deliberately thin: a node is just a `run_agent()` call, so the
graph module is declarative sugar over a loop you could write yourself.
What it adds is the part multi-agent frameworks fumble β **enforced role
boundaries** and bounded, explicit routing:
```python
from lynx import GraphNode, compile_graph, run_graph
nodes = {
"triage": GraphNode(agent=triage, tools=tools, policy=read_only),
"fixer": GraphNode(agent=fixer, tools=tools, policy=can_write),
"reviewer": GraphNode(agent=reviewer, tools=tools, policy=read_only),
}
graph = compile_graph("""
start: triage
max_transitions: 8 # mandatory bound β runaway loops impossible
edges:
- { from: triage, when: { answer_matches: "(?i)needs fix" }, to: fixer }
- { from: triage, to: done }
- { from: fixer, to: reviewer }
- { from: reviewer, when: { answer_matches: "(?i)approved" }, to: done }
- { from: reviewer, when: { denials_gt: 2 }, to: privileged } # policy as a routing signal
- { from: reviewer, to: fixer } # rejected β loop back; cycles are fine
""")
result = await run_graph(nodes, "Fix the bug", router=graph)
```
- **Per-node policy is enforced, not prompted**: if the triage model tries to
write, *its node's policy denies it* β the orchestrator can't bypass its
role (the failure mode every role-based framework suffers).
- **Denial counts route**: `denials_gt` is a predicate no other orchestrator
has, because nobody else makes policy first-class.
- **Context passing is explicit**: the next node's task = the original goal +
the previous node's result, clearly marked (`compose_task=` to customize).
No hidden shared state, no live agent-to-agent messages, sequential only.
- **Python first**: skip YAML entirely β any `(NodeOutcome) -> str | None`
callable is a `Router`.
- **Durability composes**: pass `store=`/`run_id=` and node runs + routing
decisions journal; a crashed 3-node workflow resumes at the node it died
in, and racing graph workers resolve to one winner.
See [`examples/27_handoff_graph.py`](examples/27_handoff_graph.py) for the
triage β fixer β reviewer loop with an enforced role boundary.
## Token usage & budgets
Adapters (`ClaudeAgent`, `OpenAIAgent`) attach a `Usage` record to every model
step β input/output/cache token counts plus the model name. The kernel then:
```python
result = await run_agent(
agent, task, tools=tools, policy=policy,
budget=Budget(
steps=50,
duration_seconds=600,
input_tokens=500_000, # separate caps β input and output
output_tokens=100_000, # are priced differently
tokens=550_000, # or one combined cap
step_timeout_seconds=120, # a hung model call fails, never hangs
),
sinks=(my_cost_sink,), # step.usage events stream here
)
result.usage # Usage(input_tokens=..., output_tokens=...) β lifetime totals
```
- **`step.usage` events** carry per-step counts + running totals β your sink
multiplies by *your* rates for dollars, alerts, and attribution
(per-customer = group by `correlation_id`). Lynx ships no price tables;
they go stale weekly and your negotiated rates aren't list rates.
- **Caps are enforced between steps**, exactly like `steps` β when crossed,
the run stops with `error="output token budget exhausted (β¦)"`. Honest
caveat: like every in-loop limiter, a cap stops the *next* model call; the
step that crossed the line already happened.
- **Unmetered agents are unmetered.** A hand-rolled `Agent` that attaches no
`usage` produces no events and no enforcement β Lynx enforces what it can
see and nothing else. With durability, journal-replayed steps count toward
totals and caps (they were real spend in a prior attempt).
Scope, honestly: Lynx does not restart dead processes (your supervisor does);
durability needs no database, but *distributed* durability β runs surviving
machine loss, multiple workers β needs *your* database. Budgets count
replayed steps (resume a budget-exhausted run by passing a larger budget);
`duration_seconds` is per-attempt. Tool args/results should be
JSON-serializable (LLM tool calls always are). Resuming under a different
policy emits a `run.bundle_changed` warning; resuming with a different
ToolSet, or with an agent that isn't a pure function of the conversation
(e.g. the single-shot CrewAI adapter), is out of contract.
## Examples
| # | File | What it shows |
|---|------|--------------|
| 01 | [`01_hello_allow.py`](examples/01_hello_allow.py) | Smallest possible run |
| 02 | [`02_block_dangerous.py`](examples/02_block_dangerous.py) | DENY for `rm -rf /` |
| 03 | [`03_preview_writes.py`](examples/03_preview_writes.py) | DRY_RUN with file shadow |
| 04 | [`04_human_approval.py`](examples/04_human_approval.py) | Sync approval via stdin |
| 05 | [`05_real_llm_blocked.py`](examples/05_real_llm_blocked.py) | Real Claude / GPT |
| 06 | [`06_streaming_to_jsonl.py`](examples/06_streaming_to_jsonl.py) | Audit replacement: jsonl sink |
| 07 | [`07_refund_workflow.py`](examples/07_refund_workflow.py) | Multi-tier refund rules |
| 08 | [`08_sql_transform.py`](examples/08_sql_transform.py) | TRANSFORM verdict |
| 09 | [`09_fastapi_service.py`](examples/09_fastapi_service.py) | FastAPI integration |
| 10 | [`10_devops_assistant.py`](examples/10_devops_assistant.py) | All five verdicts (one policy, run in staging + prod) |
| 11 | [`11_flask_service.py`](examples/11_flask_service.py) | Flask integration |
| 12 | [`12_django_service.py`](examples/12_django_service.py) | Django integration |
| 13 | [`13_python_rules.py`](examples/13_python_rules.py) | Python rules + `` diagnostics |
| 17 | [`17_shadow_helpers.py`](examples/17_shadow_helpers.py) | Built-in fs/http/shell/sql shadows |
| 18 | [`18_sandboxed_tool.py`](examples/18_sandboxed_tool.py) | `subprocess_executor` resource caps |
| 24 | [`24_durable_resume.py`](examples/24_durable_resume.py) | Crash β resume, never double-charge |
| 26 | [`26_executor_seam.py`](examples/26_executor_seam.py) | Bring-your-own sandbox (`route_executor`) |
| 27 | [`27_handoff_graph.py`](examples/27_handoff_graph.py) | Handoff graph β the edge is a policy boundary |
| 32 | [`32_token_optimization.py`](examples/32_token_optimization.py) | Compressor seam |
| 33 | [`33_subagents.py`](examples/33_subagents.py) | A tool that runs an agent |
| 34 | [`34_mcp_proxy.py`](examples/34_mcp_proxy.py) | Govern any MCP server, zero code change |
| 35 | [`35_multi_provider.py`](examples/35_multi_provider.py) | One policy, any model provider |
| 36 | [`36_fastmcp_governed.py`](examples/36_fastmcp_governed.py) | Build with FastMCP, govern with Lynx |
| 37 | [`37_tamper_evident_audit.py`](examples/37_tamper_evident_audit.py) | Hash-chained audit + `verify_chain` |
| 38 | [`38_otel_audit.py`](examples/38_otel_audit.py) | OpenTelemetry audit sink |
| 39 | [`39_layered_policy.py`](examples/39_layered_policy.py) | Layered policy scopes + combiners |
| 40 | [`40_framework_native_governance.py`](examples/40_framework_native_governance.py) | `ToolGuard` β govern a framework's tool calls |
| 41 | [`41_obligations.py`](examples/41_obligations.py) | Obligations β `pre` gate (fail-closed) + `post` notify on any verdict |
All 41 with one-line descriptions: [`examples/README.md`](examples/README.md).
## CLI β seven commands
```
lynx --version
lynx init [--dir ] [--force] # write a starter policy.yaml (creates the dir)
lynx run # run a script's async main()
lynx verify <audit.jsonl> # check a hash-chained audit log (exits 1 if broken)
lynx trace <records.jsonl> [--run-id <id>] # reconstruct a durable run journal
lynx policy lint [path] # compile-check a policy + rule summary (default policy.yaml)
lynx policy bundle-id [path] # print a policy's content-addressed id
```
Every command also has `--help`. Full reference β flags, output lines, exit
codes, and the audit-log-vs-run-journal distinction β in
[`docs/cli.md`](docs/cli.md).
## Status
**Public API committed; SemVer.** Production-ready for the documented scope. The kernel β `run_agent`, the five verdicts, the policy language, the sink/executor/compressor/durability seams β is stable; new capabilities land as additive seams and adapters (recent: the MCP proxy, OpenAI-compatible providers, FastMCP), never as breaking changes to that core.
## Design
- [`docs/concepts.md`](docs/concepts.md) β vocabulary
- [`docs/what-lynx-is-and-isnt.md`](docs/what-lynx-is-and-isnt.md) β what Lynx owns vs. what it composes with (mem0/Zep, Langfuse, LiteLLM, MCP gateways, Temporal)
- [`docs/cookbook.md`](docs/cookbook.md) β policy patterns (YAML)
- [`docs/integration-cookbook.md`](docs/integration-cookbook.md) β wiring patterns for sinks (SQLite / Postgres / Splunk / OTel / HTTP) + approval handlers (Slack / email / webhook) + durability `RunStore` backends (Redis / Postgres / files / Temporal)
- [`docs/faq.md`](docs/faq.md) β common questions
## License
Apache 2.0.