Skip to main content
Pre-release

This release is a pre-release and may not be stable for production use.

runbound

Runbound is a policy boundary around the tools an AI agent can call. Declare a tool with one decorator and every call is checked before its body runs: against rules in your code and rules set in a console, against a quota, and, when you say so, against a person's approval. When the control plane is unreachable the consequential tools fail closed. Every call leaves one OpenTelemetry span.

Add one initialization line and one decorator per tool. No agent rewrites or policy code required. runbound sees tool names, argument hashes, timings and outcomes; it never stores, logs or sends a prompt, a reply or a result, and an argument leaves the process only when the tool asks a person (the approver must see it). It puts no gateway in your hot path, and never speaks to your users: when it refuses something it hands control straight back to your own code.

1.0.0a6 is an alpha. The detection, provider-wrapper and cost features of 0.11.x are gone; see CHANGELOG.md for what was removed and how to migrate.

Install

pip install runbound --pre

Console tool rules need runbound 1.x. Plain pip install runbound still installs 0.11.2, because 1.0 is a pre-release, and a 0.x worker ignores the rules you set on a tool in the console: it enforces only the rules in your code. Install with --pre (or pin runbound>=1.0.0a2), and see Upgrading from 0.11.x.

Requires Python 3.10 or newer (pip refuses the install on older versions, and import runbound on 3.9 raises an ImportError that says so). No runtime dependencies: stdlib only. There are two optional extras: pip install "runbound[langchain]" and pip install "runbound[otel]". LangChain / LangGraph is an opt-in reference integration at four tested combinations, not a range: see the LangChain guide.

Quick start

import runbound

@runbound.tool(effects={"read"})
def lookup_order(order_id: str) -> dict:
    return {"order": order_id}

@runbound.tool(effects={"financial"}, approval="required", mode="deferred")
def issue_refund(order_id: str, amount: float) -> str:
    return "refunded"

runbound.init(token="rb_...", control_plane_url="https://plane.example", service="support-agent",
               otel_endpoint="https://plane.example")   # puts each call in the console's Traces (self-hosted: the plane's own address)

Decorate before init(). Import your tool modules, then call init(): the first heartbeat carries the tool report, and a tool declared afterwards can run for a moment before the plane knows its rules.

A refused call raises one typed exception, never a fake success:

try:
    lookup_order(order_id=123)          # wrong type: refused before the body runs
except runbound.ExecutionRefused as exc:
    print(exc.reason, exc.retryable)    # invalid_arguments False

The hook chain

Every call passes the same fixed chain; the first refusal wins and the body never runs:

your rewrite hooks, argument validation against the signature, the tool's rules, the console row, the capability class, the quota, the approval, your gate hooks, the body, and finally the post chain on what it returned (redact secret-named keys, truncate, scan for injection patterns, your post hooks). Hooks that fail are treated by class: a hook error refuses a consequential or unclassified tool and is logged and skipped for a read, write or external one. See How it works.

Per-tool policy

Rules come from two places and only ever tighten each other: the decorator (blocked, max_calls, constraint, require_approval, effects) and rows set in the console (blocked, session cap, approval, risk class, fail mode, quota). The most restrictive wins, field by field. A row you create is a draft; you start it as a dry run, which evaluates everything, refuses nothing and records would_refuse (or would_ask for a call it would have sent to a person), takes no quota unit and asks nobody, so you see what it would have stopped before you promote it to enforced (the console asks for a comment at each step). A tool with no row follows the service defaults: a new service starts with the standard defaults enforced, so a financial, destructive, privileged or unclassified tool asks a person until you give it a row.

Console rows are enforced by runbound 1.x only: a worker on 0.x ignores them and keeps just its code rules (see Upgrading from 0.11.x).

A quota is a fixed clock window: "20 per clock hour", per service or per key. A full quota refuses with quota_exceeded (retryable, with a retry_after). A quota the plane cannot confirm (it is unreachable, or answers the take with 400 or 404) follows the row's fail_mode: closed refuses with quota_plane_unavailable. A row never loosens a tool: its risk_class can raise what the code declares, and a tool that declares nothing stays unclassified, so it still fails closed. See Per-tool policy.

Approvals

@runbound.tool(approval="required", mode="sync" | "deferred") asks a person. Sync waits for the answer up to timeout_s. Deferred returns {"status": "pending_approval", "request_id": ...} at once; when a person approves, a worker verifies an HMAC seal (keyed by RUNBOUND_APPROVAL_SECRET, which never leaves your processes) and runs the tool with the approved arguments through the whole chain again. runbound.current_request() is the idempotency key. A parameter your app supplies (a DB session, a client, the tenant) is marked Annotated[Session, runbound.Injected]: it is never stored or shown, and init(provide={Session: make_session}) supplies it again when the approved request runs. Pydantic models, datetime, Decimal, UUID and Path arguments are supported when their JSON form gives the value back (checked before the approval is opened). Type hints must resolve at decoration: a TYPE_CHECKING-only hint refuses registration unless the parameter is marked runbound.Injected (then it is supplied by parameter name or default, not by type). init(approval_bind=True) (off by default) asks the plane to bind an approved request to one execution attempt, so it cannot run twice. See Injected parameters and Supported argument types.

An approval never outlives the policy. When the answer arrives (sync, deferred or through a grant), the call is checked again against the policy the worker holds at that moment; if the tool is now blocked, denied or capped, the call is refused with approval_superseded (not retryable) and never runs on the approval alone. See Approvals.

When the plane is unreachable

plane_status().mode is connecting, connected, degraded or unavailable. The last policy is kept on disk and enforced before the first heartbeat after a restart. While the plane is away read, write and external tools keep running on it; financial, destructive, privileged and unclassified tools refuse with reason="plane_unavailable" once consequential_grace_s (default 60) has passed. A new worker reads its policy on its first answered hello and releases those tools only once it holds the policy and the service defaults; until then, or while it holds neither a policy nor a saved one, they refuse. A service with enforced service defaults does not use the grace: it keeps running on its last policy confirmation for the offline window (15 minutes for a new service; an existing one keeps 24 hours until an operator changes it; init(offline_execution_s=) may only shorten it). Inside it a tool a row allows keeps running, and a tool with no row asks, so it is refused approval_unavailable while nobody can be reached; after it every financial, destructive, privileged and unclassified tool refuses plane_unavailable. After a restart, a consequential call whose cached policy confirmation has expired can wait for the first policy confirmation, within the first three seconds after boot. The call is then evaluated against the policy the worker holds, including restrictions received during that wait. If no valid confirmation arrives, the call remains refused. Waiting and restarting do not renew the offline window. Set confirm_wait_s=0 to disable the wait; the maximum setting is 10 seconds. Async calls do not block the event loop while waiting. Only an authenticated policy confirmation accepted by the worker renews the window; a merely answered or degraded hello does not, and no tool body runs on the strength of the wait alone. The three seconds are one allowance counted from boot, not a fresh one for every call. See Plane loss.

A worker with export_events=False exports nothing, refusals included, so the console will not show that worker's refusals.

OpenTelemetry spans

Each call is a span named execute_tool {tool}, scope runbound, sent to your OpenTelemetry provider or, with init(otel_endpoint=...), posted as OTLP/JSON. It carries the tool, the outcome, the decision and a hash of the arguments, never the arguments.

Every refusal, span and approval request also says what decided it, when the worker knows it: the rule revision the row was served under (rule_revision, when a row decided), the configuration the worker held (cfg_hash) and the process that decided (boot_id). Each heartbeat reports the configuration the worker accepted, so the console can show which workers enforce a change. Set init(worker_id=...) to a stable name (a pod or host name) so a restart is recognised as the same worker.

Every governed call also carries random identities (action_id, attempt_id, evaluation_id), and just before the tool's own code runs the SDK ends a zero-length execution_started marker span, so a call whose own span was lost can still be seen to have started. A receiver that counts every span counts the marker too; Runbound's ingest excludes it by name. Span batches carry an X-Runbound-Batch token so a retried insert is not counted twice, and the hello says where this worker's evidence can go. See OpenTelemetry and LangChain.

What runbound does not do

It guards the tools you decorate; it does not read prompts, wrap model providers, detect loops, price tokens or sandbox a tool's body. runbound.coverage() and python -m runbound check tell you which tools are guarded. See Boundaries and Guarantees.

Documentation

The manual is in docs/: Getting started, Configuration, API, Handling refusals, Privacy and three runnable examples.

Getting help

Open an issue at github.com/runboundai/runbound/issues, or email runboundai@gmail.com for anything you cannot post in public. Never paste a token, an approval secret, an argument or a real session key into an issue. Security issues go to email: see SECURITY.md.

import runbound
print(runbound.__version__)
print(runbound.coverage())   # after init() and your first guarded call

License

MIT, see LICENSE. Release history is in CHANGELOG.md; the invariants, each with the test that asserts it, are in INVARIANTS.md.

Metadata

Release files for runbound 1.0.0a6

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

Source distribution (sdist)

Source distribution for runbound 1.0.0a6
File Size Uploaded Provenance
runbound-1.0.0a6.tar.gz 580.0 kB With provenance Details

Built distribution (wheel)

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

Total release size: 814.8 kB

Release files / runbound-1.0.0a6.tar.gz

Download URL runbound-1.0.0a6.tar.gz
Size 580.0 kB
Tags Source
SHA-256 checksum
How to use checksums
eced578b6ff14e87fac0e33c2f4aa53900998200dda0b71801d8ff7603b1f092
BLAKE2b-256 checksum
How to use checksums
d06638f9c1e083f667189962f6f37d8a80042dd1714e818f541e2b9c0f896f46
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 Oct 19, 2026.

Transparency log

Release files / runbound-1.0.0a6-py3-none-any.whl

Download URL runbound-1.0.0a6-py3-none-any.whl
Size 234.8 kB
Tags Python 3
SHA-256 checksum
How to use checksums
ddc298b9b345048669b0e2b782007ab8ad2ea8d60b85cf4863696141d2ffbfe6
BLAKE2b-256 checksum
How to use checksums
a6e30466e79224860d5de1651a2867cebc564f5f6ab53c425f80948e5b1a36e6
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 Oct 19, 2026.

Transparency log

Security for runbound 1.0.0a6

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.

Full provenance

All 2 files in this release have been uploaded with provenance metadata.

Select from 2 files

Attestation for runbound-1.0.0a6.tar.gz

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 Oct 19, 2026.

Transparency log

A note on package security

PyPI is a repository for distributing packages, not a service for auditing them. While third-party security researchers and our staff do their best to detect and remove malicious packages, PyPI cannot guarantee the quality, security or suitability of any given package.

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