One chokepoint on the one dangerous moment
An agent harness is a loop: the model proposes a tool call, the harness runs it, the result feeds back.
Lynx isn't the loop — it clamps onto the single point where a proposed call is about to touch the world,
and runs evaluate → mediate on every one.
Pure functions over immutable values. No database. No globals. No leaks. Lynx holds nothing between calls.
# A whole governed run — Lynx holds no state after it returns 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"), obligations={"notify-finance": notify_handler}, # side-actions policy can require environment="prod", # policy can match on this budget=Budget(steps=50, duration_seconds=600), # hard caps that STOP the run ) # → { correlation_id, bundle_id, final_answer, error, steps_taken, usage }
One gate on the one dangerous moment
An agent harness is a loop: the model proposes a tool call, something runs it, the result feeds back. Lynx isn't the loop — it clamps onto the single point where a proposed call is about to touch the world, and runs evaluate → mediate on every one.
Pure functions over immutable values. No database, no globals. Lynx holds nothing between calls.
result = await run_agent(agent, task,
tools=tools, policy=policy, sinks=(stdout_sink(),))
Five verdicts on every action
Policy is a pure function of (request, context) that returns exactly one verdict — and the action can't run unless it says so. allow runs it, deny refuses, dry_run previews via a side-effect-free shadow, approve_required pauses for a human, transform rewrites the args first.
No rule matched? It fails closed — and names the fall-through in the audit trail.
- match: { tool: shell, args.cmd.matches: 'rm -rf' }
decision: deny
Allow — but you must also…
Like XACML and AWS Cedar, a decision can carry mandatory side-actions. A pre obligation is a fail-closed gate: if "issue the 5-minute credential" fails, the action is denied and the tool never runs. A post obligation fires after — notify finance — and is flagged if it fails (physics: the side effect already happened).
Mechanism, not policy: the kernel ships no handlers — you wire an obligations={…} registry.
- match: { tool: refund, args.amount.gt: 1000 }
decision: allow
obligations: [{ id: notify-finance, phase: post }]
org → team → user, composed
Stack independent named policies. Each layer is evaluated on its own, then a developer-chosen Combiner resolves disagreements. The default strict_overrides_loose is fail-closed: a broader layer sets a floor narrower layers can only tighten — any layer can deny, none can secretly re-grant.
Non-matching layers abstain; provenance stays layer-tagged (team:block-http).
compile_policy([PolicyLayer("org", org),
PolicyLayer("team", team), PolicyLayer("user", user)])
Caps that stop a runaway run
Hard budgets on steps, duration, and tokens (separate input / output / combined) are enforced between steps — they halt the run, they don't just alert. Plus a kill-switch checked before every tool, and a repetition gate that breaks same-tool-same-args infinite loops.
Lynx counts; it never prices. You multiply step.usage by your own rates in a sink.
run_agent(..., budget=Budget(steps=50, output_tokens=100_000),
cancel=CancelToken())
Crash-resume, no double side effects
Pass a RunStore you own and a stable run_id. Every action is journaled before it runs. A crash mid-loop? On retry, completed steps replay from the journal — the model isn't re-called (no re-burned tokens) and journaled actions aren't re-executed (no double charges).
Two racing workers resolve to one winner; the loser exits superseded having executed nothing.
run_agent(..., store=my_store, run_id="invoice-0611")
An audit log you can't quietly edit
hash_chained_sink fingerprints every event and chains it to the one before — hash = sha256(prev + event). Edit a body, drop a denial, or reorder events and every fingerprint downstream breaks. A pure, stdlib-only sink that composes with the rest.
Verify with verify_chain() or lynx verify audit.jsonl — exits 1 if broken.
run_agent(..., sinks=(hash_chained_sink(f),))
verify_chain("audit.jsonl") # intact=True
Policy says whether — the executor says where
Every approved action flows through one Executor: in-process, a subprocess with rlimits, or your Docker / gVisor / E2B / Firecracker wrapper — one async callable. Route per-tool with @tool(isolation=…) + route_executor.
Routing fails closed: a tool that asks for a sandbox with no route gets a failed action, never a silent fallback to the host.
run_agent(..., executor=route_executor({
None: inline_executor(), "container": my_docker}))
Shrink results before they cost you
Metering measures spend; the compressor reduces it. 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.
Ships truncate, dedup, route, compose reference compressors and fails open — a broken compressor never drops a real output.
run_agent(..., compressor=truncate_compressor(max_chars=2000))
The edge is a permission boundary
Sequential multi-agent workflows where each node is one run_agent call with its own policy, tools, and budget. Per-node policy is enforced, not prompted — if triage tries to write, its node denies it; the orchestrator can't bypass its role.
And denials_gt routes: too many rejections escalate to a privileged node. Bounded by construction (max_transitions).
graph = compile_graph(spec_yaml) await run_graph(nodes, "Fix the bug", router=graph)
Pause for a human — with a deadline
The approve_required verdict blocks the run synchronously and calls your handler — a CLI prompt, a Slack button, a webhook. Grant it and the action proceeds; deny it and it's refused.
The wait is bounded: an enforced timeout_seconds means a request can't hang forever. No answer in time → it fails closed (denied).
run_agent(..., on_approval=cli_prompt_approval())
# or: callback_approval(slack_handler)
Events fan out to your sinks
Every step emits AuditEvents. Lynx streams them and stores nothing — multi_sink fans each one out concurrently to stdout, a JSONL file, a hash-chained log, OpenTelemetry spans, a webhook, whatever you wire.
Your sink owns the bytes: you buffer, rotate, and ship where you want. Write your own — it's just async __call__(event).
sink = multi_sink(stdout_sink(), jsonl_sink(f), otel_sink()) run_agent(..., sinks=(sink,))
Govern any MCP server, zero code change
Point your MCP client (Claude Desktop/Code, Cursor) at Lynx instead of the server. Every call_tool flows through the same evaluate → mediate path — all five verdicts, full audit stream — and a denied call never reaches the upstream server.
No rewrite on either side: the client thinks it's talking to the server.
await serve_mcp_proxy("mcp-server-foo",
policy=policy, sinks=(stdout_sink(),))
One policy, any model
The same run_agent + policy governs every provider. Swap the adapter — Claude, GPT, or any OpenAI-compatible (Grok, Mistral, DeepSeek, Groq, Ollama) through one registry — and the policy is unchanged.
Governance lives at the tool boundary, not in the model. The model is a plug-in.
agent = openai_compatible_agent("grok",
tools=tools, model="grok-2") # same policy, any model
Your framework drives — Lynx governs
When an agent framework owns the loop (OpenAI Agents SDK, LangChain, CrewAI, PydanticAI), drop a ToolGuard in front of its tool calls. await guard.check(name, args) runs the same evaluate → mediate kernel and returns a GovernedCall — so all five verdicts work with no proxy and no rewrite.
This is the inverse of an adapter: there Lynx drives the loop; here the framework drives, and Lynx governs each call inside it.
guard = ToolGuard(tools=tools, policy=policy)
call = await guard.check(tool_name, args) # GovernedCall
Install and govern your first tool call
Three dependencies. No server, no database. The lynx CLI ships with the core.
$ pip install lynx-agent $ lynx init # writes one file: policy.yaml $ python examples/01_hello_allow.py
What it owns — and what it composes with
The gaps are boundaries, not holes. Lynx owns a high-value spine and integrates with specialists for the rest.
✓ Lynx owns this
- Enforcement on every action — not observation after the fact
- Obligations — mandatory side-actions, fail-closed on the pre-gate
- Human-in-the-loop approvals with enforced timeouts
- Durable execution — crash-resume, no double side effects
- Cost control — token metering + caps that stop a run
- Compliance-grade audit — a tamper-evident evidence trail
✗ Compose with a specialist
- Prompt / content filtering → NeMo Guardrails, Guardrails AI
- Long-term memory → mem0, Zep, Letta (gated as tools)
- Dashboards → Langfuse, Phoenix, Datadog (fed by a sink)
- The agent's reasoning → LangGraph, CrewAI
- Storage & supervision → your Redis/Postgres + k8s
- Cluster orchestration → Temporal, Inngest
Common questions
Is Lynx an agent framework?
No. Lynx is the governance and safety layer for an agent loop, not the loop itself. It checks, audits, and bounds each tool call. Use it with frameworks like LangChain, CrewAI, or the OpenAI Agents SDK via ToolGuard, or drive a minimal stateless loop yourself with run_agent.
Does Lynx need a database or a server?
No. It is a pure Python library with three dependencies and no server. Audit events stream to sinks you own; durable crash-resume uses a RunStore you implement over your own Redis / Postgres / Dynamo. Lynx never opens a file or a connection.
Which model providers are supported?
Claude and GPT directly, plus any OpenAI-compatible provider through one registry — Grok (xAI), Mistral, DeepSeek, Groq, OpenRouter, Together, Fireworks, Perplexity, and Ollama. The same policy governs every model; swap the adapter, not the rules.
What are the five verdicts?
Policy is a pure function that returns exactly one verdict per action: allow (run it), deny (refuse), dry_run (preview via a side-effect-free shadow), approve_required (pause for a human with an enforced timeout), or transform (rewrite the arguments first). It fails closed by default.
What are obligations?
Obligations are mandatory side-actions attached to any verdict, in the XACML / AWS Cedar model. A pre obligation gates the action — it fails closed if the handler fails (“refund only if a scoped credential was issued”); a post obligation fires after (notify finance, write a special audit record). The kernel ships no handlers — you wire an ObligationRegistry.
Does Lynx work with MCP?
Yes. The MCP proxy sits 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 with an audit stream — zero code change on client or server.
How fast is policy evaluation?
Roughly a microsecond. Evaluation is a pure function over immutable values with no I/O, so the governance layer adds negligible overhead to a tool call.
Make every agent action safe, recoverable, and auditable.
Three dependencies. Zero infrastructure. One function.