Working examples for the nullrun Python SDK.
Each example is a self-contained, runnable file. The intent is to show the smallest possible change to add enforcement to a common agent framework.
For a categorized map (basic / policies / demos / probes), see
examples/INDEX.md. The README below covers
only the basic + policies tiers.
pip install nullrun
export NULLRUN_API_KEY=nr_live_...Get an API key from the NullRun dashboard.
examples/.env(real keys) is gitignored — never commit it.- If a key ever appeared in
.env.backupor any plain‑text file, treat it as compromised: rotate from the NullRun dashboard and from the vendor (OpenAI / Anthropic / ...) immediately. Disk‑only exposure is still exposure (cloud sync, lost laptop, shared screen recording). - Use a short‑lived key for local dev. Use a CI‑only key for smoke runs.
Every example reads its keys from examples/.env if
python-dotenv is
installed (pip install python-dotenv) — no shell export needed.
Copy the template and fill in your keys:
cp examples/.env.example examples/.env
$EDITOR examples/.env # set NULLRUN_API_KEY + per-vendor keys
python examples/raw_openai_basic.pyWithout python-dotenv the examples fall back to whatever the
developer's shell already has exported — the auto-load is purely a
convenience. examples/.env is in .gitignore; commit only
.env.example.
In SDK 0.18.1+ the runtime is created lazily — the first
@protect call patches the underlying HTTP transport and any
imported agent framework (openai, openai-agents, langgraph,
autogen, crewai, llama-index, ...) in a single process-wide
idempotent step. You get cost tracking without changing your call
sites; @protect is the gate layer (budget / kill / pause) that
runs before the call.
Because instrumentation is lazy, just pip install nullrun and
decorate. If the framework package is importable in the same
process, the corresponding hook attaches automatically on the
first @protect call (a single WARNING logs otherwise — see the
zero-activity diagnostic in the error catalog).
@protect
def refund_customer(refund_amount: Decimal, customer_id: str) -> str:
...@protect auto-attaches a default ToolParamsExtractor(include_all=True),
so every protected tool is eligible for ToolParameters Approval Rules
on the backend without any second decorator. SDK collects facts; the
server decides.
@protect— canonical entry point. Cheap, no/executeround-trip.@protect @sensitive(impact=money_outflow(...))— typed extractor for money rules. ReturnsMoneyImpactExtractorwith a SHA-256action_digest. The decorator chain walks inside-out, so the explicit extractor wins over the auto-attached default.@protect @sensitive(impact=tool_params({...}))— typed extractor for arbitrary tool-param rules. Rename map decouples the rule name from the function arg name.
See protect_only_public_api_demo.py
for a runnable verification of auto-attach, bounded extraction, and
the typed-extractor path.
| File | Framework | What it shows |
|---|---|---|
raw_openai_basic.py |
raw OpenAI | @protect on a single LLM call (lazy-init) |
anthropic_basic.py |
raw Anthropic | @protect on a messages.create call (lazy-init) |
mistral_basic.py |
raw Mistral | @protect on a chat.complete call (lazy-init) |
gemini_basic.py |
raw Gemini | @protect on models.generate_content (lazy-init) |
cohere_basic.py |
raw Cohere | @protect on client.chat (V2) (lazy-init) |
bedrock_basic.py |
AWS Bedrock | @protect + manual track_llm (boto3 uses urllib3, not httpx) |
langchain_basic.py |
LangChain | Auto-instrumented ChatModel.invoke (not via LangGraph) |
langgraph_basic.py |
LangGraph | Auto-instrumented StateGraph (recommended) |
langgraph_manual_wrapper.py |
LangGraph | nullrun.patch_langgraph_compiled for late-compiled graphs |
llama_index_basic.py |
llama-index | Auto-instrumented LLMChatEndEvent / FunctionCallEvent |
crewai_basic.py |
CrewAI | Auto-instrumented Crew.kickoff + usage_metrics flush |
autogen_basic.py |
AutoGen | Auto-instrumented BaseChatAgent.on_messages |
openai_agents_basic.py |
OpenAI Agents SDK | @protect on a multi-step agent run (lazy-init) |
cost_cap_demo.py |
any | Hard budget cap that halts the agent |
chain_soft_mode.py |
any | Soft-mode pass via active chain context |
on_error_hook.py |
any | nullrun.on_error hook + handle() for Sentry / dashboards |
Larger demos (LangGraph + approval rule, LangGraph + MCP, ToolParameters,
@protect-only public API, gate pre-flight) live under
examples/INDEX.md.
QA probes are also listed there under Probes — they are not
documented here because they assume specific workflow configurations.
export NULLRUN_API_KEY=nr_live_...
python examples/raw_openai_basic.pyAll examples are read-only — they do not modify org state, policies, or
keys on your account. They do emit track events to the gateway
(auto-instrumented HTTP traffic from init()), so a cost_attribution or
examples tag in the dashboard will pick them up.
Every example ends with nullrun.shutdown() in a finally block. This
sends a clean WebSocket close frame so the backend does not log
"Connection reset without closing handshake" on long-running scripts.
No-op if init() was never called.
The examples intentionally avoid any try/except NullRunError block.
The SDK does the work in one line:
with nullrun.handle():(context manager) — translates anyNullRunErrorraised inside the block into a four-line developer report (error_code/ what / where / why / how-to-fix) on stderr andsys.exit(1). This is the canonical pattern for 0.18.1+.
WorkflowKilledInterrupt (a BaseException) propagates unchanged
through handle() — kill signals must reach the top of the agent
loop. Non-NullRun exceptions also propagate so user bugs surface as
honest tracebacks.
For observability, register a hook with nullrun.on_error(...). The
hook fires before every NullRunError raise with the structured
fields (error_code, retryable, user_action, docs_url,
stage, workflow_id) — pair it with with nullrun.handle(): for
Sentry / dashboards:
import nullrun
@nullrun.on_error
def _to_sentry(err, ctx):
sentry_sdk.capture_exception(err, extra={
"error_code": err.error_code,
"retryable": err.retryable,
"stage": ctx.stage,
"workflow_id": ctx.workflow_id,
})If you need to branch on a specific error_code (operator dashboards,
per-code retry policies), reach for nullrun.NullRunError.error_code
and the structured fields — see the
error code catalogue
for the full list. But for the common "run an agent and print a
friendly message on failure" case, with nullrun.handle(): around
the entry point is enough.
| Symptom | Likely cause | Fix |
|---|---|---|
Connection reset without closing handshake on the backend |
forgot shutdown() in a long-running script |
add nullrun.shutdown() in finally (every example already does) |
NR-B004 NullRunBudgetError after a few calls |
workflow budget exhausted on the dashboard | raise the cap or wait for the reset |
WorkflowKilledInterrupt propagates up |
operator clicked "kill" on the dashboard | expected; the agent loop sees it as a BaseException and exits cleanly |
init_or_die() exits with a clean message |
NULLRUN_API_KEY not set |
cp examples/.env.example examples/.env and fill it in |
ImportError: mcp.server in the MCP demo |
modelcontextprotocol not installed |
pip install "nullrun[langgraph,mcp]" |
See CONTRIBUTING.md. Keep each basic example
under 80 lines. No external state beyond the NullRun API key. Use the
shared _env.py and _boilerplate.py helpers — do not reimplement
env loading per file.