The Python SDK lives in
nullrunio/nullrun-sdk-python.
Package name on PyPI: nullrun.
pip install nullrun # core only
pip install "nullrun[langgraph]"
pip install "nullrun[agents]" # openai-agents
pip install "nullrun[all]" # every optional extraAuto-instrumentation for httpx-based libraries (openai,
anthropic, openai-agents, …) is on by default once init() runs —
see Auto-instrumentation.
from nullrun import init, protect, workflow, span, agent, track_llm, track_tool, track_event| Symbol | Purpose |
|---|---|
init(api_key=None, api_url=None, debug=False) |
Initialise the SDK singleton. api_key is required (read from NULLRUN_API_KEY if not passed). The HMAC secret, batch size, flush interval, and transport mode are not parameters here — set them via env vars or by constructing NullRunRuntime directly. |
@protect |
Wrap a function for gate enforcement (budget pre-flight + kill/pause check + sensitive-tool decision). Takes no kwargs. |
@sensitive |
Parameterless decorator. Marks a function as a sensitive tool — @protect will pre-check runtime.execute(...) before the body runs. Fails CLOSED on transport error (ADR-008) regardless of the runtime's fallback mode. Chain with @protect in either order; the recommended form is @sensitive outside so add_sensitive_tool(fn.__name__) runs before the wrapper is built. |
workflow(name=None) |
Context manager. Sets the workflow_id contextvar that @protect and track_* attach to events. |
span(name=None) |
Context manager for nested trace spans. |
agent(name=None) |
Context manager for agent identity. |
on_error(hook) |
Register a global error hook (Layer 2 of "give the user a chance"). Called for every structured SDK failure (subclasses of NullRunError) BEFORE the exception propagates. Multiple hooks supported; fires in registration order; hook exceptions are caught and DEBUG-logged. Does NOT fire for WorkflowKilledInterrupt (BaseException — kill is a non-recoverable signal). Returns an idempotent unregister callable. |
track_llm(input_tokens, output_tokens=0, *, model=None, latency_ms=None, metadata=None) |
Manual escape hatch for non-HTTP LLM calls. |
track_tool(tool_name, duration_ms=None, *, is_retry=False, metadata=None) |
Manual tool-call tracking. |
track_event(event_type, **kwargs) |
Catch-all for custom events. |
format_user_message(exc, locale="en") |
Render a NullRunError (or any object with an error_code) as an end-user-facing string from the SDK's default catalog. Use this in place of str(exc) when showing exceptions to end users — see User-facing messages below. |
set_user_message(code, text) |
Override the user-facing message for a specific error_code for the lifetime of this process. Pass text="" to clear the override. |
get_user_message(code) |
Look up the raw user-facing message for an error_code. Returns the per-process override if set, otherwise the catalog default, otherwise the generic fallback. |
All track_* functions return a dict[str, Any] describing the
backend's response ({"allowed": bool, "actions": [...], ...}); they
buffer into the event batch and flush on the next @protect call or
flush_interval_ms.
The curated public surface in dir(nullrun) is the six core symbols
above plus on_error, plus the structured exception names
NullRunError, NullRunAuthError, NullRunConfigError,
NullRunBackendError, NullRunBudgetError, NullRunToolBlockedError,
and WorkflowKilledInterrupt (the kill signal). The legacy names
(WorkflowPausedException, WorkflowKilledException,
NullRunAuthenticationError, NullRunBlockedException) remain
importable via from nullrun import X for backward compatibility
but no longer appear in dir(nullrun).
All raised from nullrun.breaker.exceptions. Every public SDK
exception inherits from NullRunError and carries four structured
fields: error_code (e.g. "NR-B004"), user_action (imperative
hint), retryable (bool), docs_url. See
Errors for the full
hierarchy diagram.
| Class | When | Notes |
|---|---|---|
NullRunError |
Structured base for every user-facing SDK exception | Inherits BreakerError |
NullRunConfigError |
SDK misconfigured (e.g. missing api_key) |
error_code="NR-C000"-family. Never retryable. |
NullRunAuthenticationError |
Missing / invalid X-API-Key, bad HMAC |
401 / 403. Carries .message for backward compat. |
NullRunAuthError |
401 specifically (key rejected) | Subclass of NullRunAuthenticationError (NR-A003). |
NullRunTransportError |
Gateway unreachable | Carries .source (NETWORK_ERROR / GATEWAY_ERROR / BREAKER_OPEN / AUTH_ERROR) and .endpoint. Retryable. |
NullRunBackendError |
5xx from the gateway | Subclass of NullRunTransportError. NR-B002. Retryable. |
RateLimitError |
HTTP 429 | Subclass of NullRunTransportError. Carries .retry_after, .upgrade_url, .body. NR-R001. Retryable. |
NullRunBlockedException |
Generic policy block | Inspect .workflow_id, .reason, .action, .tool_name, .details. No .message — use str(exc). NR-X001. |
NullRunBudgetError |
Budget exhausted | Subclass of NullRunBlockedException. NR-B004. |
NullRunToolBlockedError |
Tool in block list | Subclass of NullRunBlockedException. NR-T001. Carries .tool_name. |
BreakerTransportError |
Transport misconfiguration (events cannot be delivered after retries) | Subclass of BreakerError (NOT NullRunError). Carries .events_lost, .buffer_size. |
InsecureTransportError |
HTTP used where HTTPS required | Subclass of BreakerTransportError. |
WorkflowPausedException |
Paused via control plane | Subclass of NullRunError. NR-W003. Carries .workflow_id, .reason, .resume_after. |
WorkflowKilledException |
Killed via control plane (parent) | BaseException, not Exception. Emits DeprecationWarning on construction. |
WorkflowKilledInterrupt |
Kill arrived mid-call | Subclass of WorkflowKilledException. BaseException per the kill contract — catch before except Exception. |
Removed in 0.4.0: CostLimitExceeded, ApprovalRequired,
BreakerTimeout, LoopDetectedException, RetryStormException,
RateLimitExceededException. These classes had no remaining callers
and are no longer reachable under any import path.
import nullrun
from nullrun import WorkflowKilledInterrupt, init, protect, workflow
# NullRunBlockedException lives in `nullrun.breaker.exceptions` — the
# top-level `nullrun.breaker` package does not re-export it. Several
# older re-exports (CostLimitExceeded, ApprovalRequired, BreakerTimeout,
# LoopDetectedException, RetryStormException, RateLimitExceededException)
# were removed in SDK 0.4.0 and are no longer reachable under any path.
from nullrun.breaker.exceptions import (
NullRunBlockedException,
RateLimitError,
WorkflowPausedException,
)
init(api_key="nr_live_...")
with workflow("my-agent"):
@protect
def step():
...
try:
step()
except WorkflowKilledInterrupt:
raise # kill contract — BaseException, not Exception
except WorkflowPausedException:
raise # paused — resume via WS / API, then retry
except NullRunBlockedException as exc:
... # budget / loop / retry / sensitive
except RateLimitError as exc:
time.sleep(exc.retry_after)For global observability (Sentry, OpenTelemetry, structured logs),
register a hook with nullrun.on_error(...) instead of wrapping every
call site. The hook fires for every NullRunError subclass BEFORE the
exception propagates, with the ErrorContext describing where the
failure fired (stage, workflow_id, tool_name, api_key_prefix).
Hook exceptions are caught and DEBUG-logged — a misbehaving hook
cannot break the SDK.
nullrun.format_user_message(exc, locale="en") renders a NullRunError
(or any object with an error_code attribute) as an end-user-facing
string. Use this instead of str(exc) whenever the message might be
shown to a person who is not the developer — str(exc) contains
internal identifiers like workflow_id and budget_cents that leak
the SDK's internals into product UI.
import nullrun
from nullrun import NullRunBudgetError
@nullrun.protect
def chatbot(message: str) -> str:
return agent.run(message)
try:
reply = chatbot(message)
except NullRunBudgetError as exc:
# Show the user a clean message instead of the raw exception text
# ("Workflow wf-31a blocked: budget_cents=500 exceeded...").
return nullrun.format_user_message(exc)The catalog of default messages is part of the NullRun product, not the
customer's integration code. Every Customer Support Bot built on
NullRun that hits NR-B004 shows the same "You've reached the usage
limit for this conversation. Please try again later." string. This
keeps the UX consistent across deployments and lets the product team
A/B test wording for upgrade-conversion without touching customer
code. Customers should not write their own code -> text mapping.
If a deployment wants its own wording for a single code (e.g. a
branded "out of credits ☕" message), call set_user_message once at
startup:
import nullrun
# Override the default message for budget-exceeded. Pass "" to clear.
nullrun.set_user_message(
"NR-B004",
"You've used all your support credits. Upgrade to keep chatting.",
)Overrides live in a per-process dict and are checked before the catalog default. They do not persist across processes and are not synced to the backend — they are pure presentation sugar.
format_user_message(exc, locale) accepts a locale code; in this SDK
version only English ("en") is shipped and any other value falls
back to the English message. The parameter is reserved for future
locale packs and matches the structure that user-message overrides
will take when they land.
Objects without error_code (plain Exception, raw values) get a
generic fallback ("Something went wrong. Please try again."). The
function never raises and never returns an empty string.