Skip to main content

NullRun

Ship AI agents with real-time budget, policy, and human-approval gates.

Zero-refactor cost control, tool policy enforcement, and audit trail for any LLM-powered agent — works with any LLM SDK that uses httpx, plus your own stack.

Quickstart · Docs · Examples

PyPI version Python versions License Downloads
CI Coverage Stars Last commit
protocol v4 Zero-code instrumentation Server-authoritative cost

⚠️ Status: alpha (v0.17.1). The public API may shift between minor versions. Pin your dependency and read the CHANGELOG before upgrading.


Why NullRun?

AI agents can overspend, call dangerous tools, and act without audit trails. Existing observability tools tell you after the fact. NullRun enforces before the action.

Without NullRun With NullRun
Agent calls gpt-4o 10,000 times → surprise $5,000 invoice Hard budget cap → SDK blocks at 402 before invocation
Agent runs bash rm -rf / Tool policy → SDK blocks at 403 before execution
Sensitive action with no human in the loop Approval flow → SDK pauses and waits for WS approval_resolved push
Cost & calls scattered across 4 libraries Single source of truth: per-org, per-workflow, per-execution
Runaway SDK loop calling /gate without /track Per-reservation rate cap → 402 budget error (see docs/errors/NR-R001.md)

Features

Hard & soft budget gates — atomic Redis-enforced Tool policy enforcement — block dangerous tools before execution
Human-in-the-loop approvals — pause agent and await approval_resolved via WS push Immutable audit trail — every decision, every tool call, every cent
Zero-code instrumentation — nullrun.init() patches httpx once for any vendor No vendor lock-in — works with any LLM SDK that uses httpx
Memory-safe streaming — 16 MiB response body; full body for usage extraction Lightweight — no LLM-key storage, no proxy required
Server-authoritative cost — server-minted execution IDs MCP support — expose tools to agents via Model Context Protocol

Architecture

%%{init: {
'flowchart': {
    'curve': 'basis',
    'htmlLabels': true,
    'nodeSpacing': 80,
    'rankSpacing': 90
}
}}%%

flowchart LR
%% =========================
%% AI RUNTIME
%% =========================
subgraph USER ["👤 AI Runtime"]
direction TB
A["🤖 Agent"]
end

%% =========================
%% NULLRUN LAYER
%% =========================

subgraph LIB ["📦 NullRun Enforcement Layer"]
direction TB
B["NullRun SDK<br/>Interceptor"]
C["🚦 Runtime Gate"]
P["📜 Policy Engine"]
H["👤 Human Approval"]

end

%% =========================
%% PRODUCTION
%% =========================

subgraph PROD ["⚙️ Production Actions"]
direction TB

T["🛠 Tools"]
API["🌐 External APIs"]
DB["🗄 Databases"]
end

STATE["🗂 Audit + Runtime State"]

%% =========================
%% FLOW
%% =========================

A -->|"protected action"| B
B -->|"authorize"| C
C --> P
P -->|"allow"| T
P -->|"allow"| API
P -->|"allow"| DB
C -->|"require approval"| H
H -->|"approved"| T
C --> STATE

%% =========================
%% COLORS
%% =========================
classDef user fill:#dbeafe,stroke:#2563eb,color:#0f172a
classDef sdk fill:#dcfce7,stroke:#16a34a,color:#0f172a
classDef srv fill:#fed7aa,stroke:#ea580c,color:#0f172a
classDef store fill:#f5d0fe,stroke:#a21caf,color:#0f172a
classDef ok fill:#bbf7d0,stroke:#16a34a,color:#0f172a
classDef wait fill:#fef08a,stroke:#ca8a04,color:#0f172a

class A user
class B sdk
class C,P,H srv
class STATE store
class T,API,DB ok
class H wait

style USER fill:#f8fafc,stroke:#64748b,stroke-width:1px
style LIB fill:#f8fafc,stroke:#64748b,stroke-width:1px
style PROD fill:#f8fafc,stroke:#64748b,stroke-width:1px

The gate is server-authoritative — the SDK never trusts client-supplied cost. Redis is the source of truth for budget and tool-policy state; Postgres holds the immutable audit log.


sequenceDiagram

participant Agent
participant SDK
participant Gate
participant Policy
participant Human
participant Tool


Agent->>SDK: execute(tool)
SDK->>Gate: authorize(action)
Gate->>Policy: evaluate rules

alt Allowed
Policy-->>Gate: allow
Gate-->>SDK: continue
SDK->>Tool: execute
else Approval required
Policy-->>Gate: approval_required
Gate-->>SDK: wait
Gate->>Human: request approval
Human-->>Gate: approved
Gate-->>SDK: resume
SDK->>Tool: execute
else Blocked
Policy-->>Gate: deny
Gate-->>SDK: exception
end

Quickstart

Install:

pip install nullrun
export NULLRUN_API_KEY="nr_..."   # get one at https://nullrun.io/control-center/api-keys

Option — decorator (3 lines)

from nullrun import protect

@protect
def my_agent(prompt: str) -> str:
    return call_llm(prompt)

If you call @protect before init(), the SDK lazy-initializes the runtime from NULLRUN_API_KEY on the first decorated call. You can write your agent code with the decorator first and the init second — or skip init entirely if your environment is already configured.

For CLI scripts that want fail-fast on missing config, pass fail_on_exit=True — the SDK prints a four-line developer report and exits with code 1 instead of raising. nullrun.shutdown() is auto-registered via atexit inside init(), so a clean WS close on process exit happens without any explicit call.


How NullRun compares

NullRun LangChain callbacks Helicone Portkey OpenLLMetry
Enforce before execution ✅ ❌ ⚠️ async ⚠️ async ❌
Server-authoritative budget ✅ ❌ ❌ ❌ ❌
Tool-call policy ✅ ❌ ❌ ⚠️ limited ❌
Human-in-the-loop approvals ✅ ❌ ❌ ❌ ❌
Zero-code instrumentation ✅ ✅ ✅ ✅ ✅
Immutable audit trail ✅ ⚠️ ✅ ✅ ✅
Streaming memory cap (anti-OOM) ✅ ❌ ⚠️ ⚠️ ❌
MCP support ✅ ⚠️ ❌ ❌ ⚠️

NullRun is the only option that blocks expensive or dangerous calls before they happen, not just observes them.


Querying the audit log

Every gate decision, approval resolution, and execution lifecycle event is written to the org's hash-chained audit_events table on the backend. The SDK surfaces a typed read API at runtime.audit.* so backends on ADR-009 (schema_version = 3) return typed dataclasses — not raw dicts.

from nullrun import NullRunRuntime, AuditQuery
from datetime import datetime, timezone, timedelta

runtime = NullRunRuntime(api_key="nr_...")

# 1) Last 50 governance decisions in the last 24h.
since = (datetime.now(timezone.utc) - timedelta(hours=24)).isoformat()
page = runtime.audit.list(
    AuditQuery(event_type="authorization_decision", since=since, limit=50)
)
for entry in page.entries:
    print(entry.timestamp, entry.decision, entry.tool_name, entry.reason_code)

Available surfaces:

Method Returns Endpoint
runtime.audit.list(query=...) AuditLogPage (entries + meta) GET /api/v1/orgs/{org}/audit-log
runtime.audit.verify(since=...) AuditVerifyResult (chain head/tail/reason) GET /api/v1/orgs/{org}/audit-log/verify
runtime.audit.list_exports() list[AuditExportJob] GET /api/v1/orgs/{org}/audit-log/export
runtime.audit.create_export() dict (job_id, status) POST /api/v1/orgs/{org}/audit-log/export
runtime.audit.export_status(job_id) AuditExportStatus GET /api/v1/orgs/{org}/audit-log/export/{job_id}/status

AuditQuery filters on the canonical ADR-009 columns: event_type (authorization_decision / approval_decision / execution_lifecycle), decision, policy_id, execution_id, actor, since, until, limit. Pre-ADR-009 backends return legacy fields only — AuditEntry.is_governance is False for those rows, and the 13 governance columns default to None.

If you call runtime.audit.* before nullrun.init() (no org binding), the proxy raises NullRunAuthenticationError — not a silent 404 — so a misconfigured CI step fails loudly at the audit call site rather than silently dropping the query.


Closing orphan grants (v0.18+)

An approval row that lands at status='APPROVED' but never flips to CONSUMED is an "orphan grant" — the operator sees it on the dashboard forever (or until the sweeper runs). Two paths close the orphan:

  1. Success path — when the WebSocket approval push resolves outcome=approved, the SDK auto-calls POST /api/v1/approvals/{approval_id}/consume so the row flips to CONSUMED before the function body runs. Best-effort: a network blip is logged at DEBUG and the success path is not blocked.
  2. Exception path — @protect's _safe_cancel_active_execution calls cancel_execution and consume_approval (in that order) when an exception fires after /gate succeeded. The reverse-index lookup execution_id → approval_id is populated by the WS push handler, so if the SDK never reached the WS-approval branch the lookup returns None and consume_approval is a no-op.

The new endpoint is structurally distinct from the orchestrator's consume_approved SQL (no execution_id binding per ADR-046, so it does not participate in the cached-replay arm race window) and carries organization_id for C2 closure. Idempotent: replay returns already_consumed; PENDING/DENIED/EXPIRED rows return not_approved, both with HTTP 200. See src/nullrun/runtime.py::consume_approval and src/nullrun/transport.py::consume_approval.


Examples

Runnable, copy-pastable examples live in a separate repo so you can adapt without cloning the SDK source:


Roadmap

Version Status Highlights
v0.14.x ✅ alpha Wire protocol v3.31, server-minted execution IDs, MCP, anti-OOM streaming cap
v0.15.x ✅ alpha ADR-009 governance audit surface, typed runtime.audit.*, capability probes for /audit-log/verify, fail-OPEN observability closure
v0.16.x ✅ alpha Phase-1+ action_digest on /gate, /execute tools propagation, transient-5xx retry on gate (NR-006), error-code parity (NR-007, 41→56 entries)
v0.17.x ✅ alpha Chain-setter Token discipline, _GATE_CACHE staleness closure, lazy-export repair, circuit-breaker lock unification (sync+async), op_id mint-fresh (DEF-OPID-REUSE-HASH-MISMATCH), error-code map closure (DEF-SDKT-004)
v0.18.x (current) ✅ alpha Close-orphan fix (ADR-047): SDK auto-calls POST /api/v1/approvals/{id}/consume after WS approval resolves to outcome=approved and on the @protect exception path. Closes the structural orphan where mode="inline" tools left approval rows at status=APPROVED past expires_at.
v1.0 🎯 beta target Stable wire contract, full async support, type-safe decisions

Full roadmap & RFCs →


Development setup

git clone https://github.com/nullrunio/nullrun-sdk-python
cd nullrun-sdk-python
python -m venv .venv && source .venv/bin/activate
pip install -e ".[dev]"
pytest -q

We follow Conventional Commits, require tests for new public API, and run ruff + mypy in CI.


Security

NullRun does not store or proxy your LLM provider keys — it sits beside your existing clients and observes the calls. The gate is server-authoritative for cost: even a malicious SDK cannot inflate spend by sending a fake cost_cents to /track.

See the security policy for the threat model and disclosure policy.


Community & support


Made with care by NullRun and contributors.

⭐ Star us on GitHub · 📖 Read the docs

Release files for nullrun 0.18.5

For a detailed explanation of source distributions (sdists) and built distributions (wheels), please see the package formats documentation.

Source distribution (sdist)

Source distribution for nullrun 0.18.5
File Size Uploaded
nullrun-0.18.5.tar.gz 399.6 kB Details

Built distribution (wheel)

Table of built distributions (wheels) for nullrun 0.18.5
File Interpreter ABI Platform
nullrun-0.18.5-py3-none-any.whl Python 3 none any Details

Total release size: 681.7 kB

Release files / nullrun-0.18.5.tar.gz

Download URL nullrun-0.18.5.tar.gz
Size 399.6 kB
Tags Source
SHA-256 checksum
How to use checksums
0824af8a6aa74429cb9b3fb1094ab84443645ac0f118aa9a65ad143acb5299c2
BLAKE2b-256 checksum
How to use checksums
1c14a6f9ca096cfbf4a98e9ce5694b016270897ee796980d552f050a8c1a0d09
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.

Transparency log

Release files / nullrun-0.18.5-py3-none-any.whl

Download URL nullrun-0.18.5-py3-none-any.whl
Size 282.0 kB
Tags Python 3
SHA-256 checksum
How to use checksums
9286f3a33db61ac57d5816ff20e340b53a6763292058cf3df24cf48776f80d43
BLAKE2b-256 checksum
How to use checksums
16f38ff25b31d965a079a4b94bbc42008b67042b716490833b81977cd115a8b2
Upload date
Uploaded using Trusted Publishing?
What is trusted publishing?
Yes
Uploaded via twine/7.0.0 CPython/3.13.14

Provenance

Provenance describes where a file came from. On PyPI, provenance is shared via attestations, which provide a verifiable record of the build or publishing details. View details, limitations and caveats.

PyPI Publish Attestation

PyPI verified that this artifact, at this checksum, originated from the publisher listed below.

Signed by GitHub Actions, verified by PyPI on Sep 26, 2026.

Transparency log

Release history Release notifications | RSS feed

This release

0.18.5 This release

2 release files

0.18.1

2 release files

0.18.0

2 release files

0.17.1

2 release files

0.17.0

2 release files

0.16.8

2 release files

0.16.7

2 release files

0.16.3

2 release files

0.15.2

2 release files

0.13.9

2 release files

0.4.0

2 release files

Anthropic, PBC Visionary sponsor Bloomberg Visionary sponsor Hudson River Trading Visionary sponsor Meta Visionary sponsor NVIDIA Visionary sponsor Microsoft Sustainability sponsor Depot Continuous Integration AWS Cloud computing and Security Sponsor Datadog Monitoring Fastly CDN Google Download Analytics Sentry Error logging StatusPage Status page