● stateless · pure · provable

The governance layer
for your agent's tool calls.

Lynx is a stateless policy kernel for AI agents. Every action an agent proposes gets a five-verdict policy check with mandatory obligations, a crash-safe checkpoint, and a tamper-evident audit event — before it touches the real world. A library, not a server.

$ pip install lynx-agent
How it works

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.

Model
proposes a tool call
Lynx
evaluate → mediate
Executor
your sandbox runs it
Result
audited, fed back

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 }
01 The chokepoint

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(),))
run_agent()statelesspure
model → lynx → executor → result, audited & fed back
02 The boundary

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
allowdenydry_runapprove_requiredtransform
one request in, exactly one verdict out
03 Decision + obligations new

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 }]
phase: pre → gatephase: post → flagfail-closed
pre-gate must clear before the action; post fires after
04 Layered policy scopes

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)])
strict_overrides_looselast_layer_winsfirst_layer_wins
a request drops through each layer; the strictest wins
05 Loop control & rails

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())
Budget(steps, tokens, …)CancelTokenmax_repeated_calls
token budget fills as steps run — at the cap, the run stops
06 Durable execution

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")
write-ahead journalreplaysupersede
write → crash → replay completed steps → continue
07 Tamper-evident audit

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
hash_chained_sinkverify_chainotel_sink
tamper with one block → the chain breaks from there on
08 The executor seam

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}))
inline_executorsubprocess_executorroute_executor
each action routes to its isolation — missing route = blocked
09 The compressor seam

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))
compressor=fails openprompt caching
40 KB result → compressor → 1.2 KB into the conversation
10 Handoff graphs

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)
compile_graphdenials_gtdurable
triage → fixer ⇄ reviewer; repeated denials escalate
11 Human-in-the-loop

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)
approve_requiredcli_prompt / callbacktimeout_seconds
paused → approve / deny / timed-out (fail-closed)
12 Streaming audit

Events fan out to your sinks

Every step emits AuditEvents. Lynx streams them and stores nothingmulti_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,))
multi_sinkotel_sinkjsonl_sinkcallback_sink
one event → many sinks, concurrently — Lynx keeps nothing
13 MCP proxy

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(),))
serve_mcp_proxyGovernedProxyallow / deny / transform
client → Lynx → server; denied calls stop at Lynx
14 Model adapters

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
ClaudeAgentOpenAIAgentopenai_compatible_agent
swap the model adapter — the policy never changes
15 Framework-native governance

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
ToolGuardguard.check()governed_function_tools
the framework owns the loop; Lynx checks each call inside it
Get started

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
lynx-agent[anthropic] · Claude [openai] · GPT + OpenAI-compatible [langgraph] · [crewai] · [openai-agents] [mcp] · MCP proxy [otel] · OpenTelemetry
Honest scope

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
FAQ

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.