Warden
Open source observability for AI agent trajectories and tool execution failures.
Warden is a full-stack platform — local SDKs, an ingest API, and a dashboard — that makes silent agent failures loud. The same MIT codebase runs in-process, self-hosted, or as managed Warden Cloud.
- Assert locally, before a tool body runs: missing prerequisites, parallel sequence bypasses, duplicate overlapping calls, or runaway retry loops.
- Trace every agent run and tool call with OpenTelemetry and
OpenInference semantic conventions (
agent.name,tool.name,tool.parameters,tool.output, …). - Export
AgentTracePayloaddocuments to the ingest API for replay in the dashboard.
Warden Cloud vs self-host vs SDK-only
There is no private folder in this repo. GitHub is all-or-nothing, so we follow the Langfuse model: one public tree, one license, Cloud is the hosted product.
| What you run | When to use it | |
|---|---|---|
| Warden Cloud | The SDK, pointed at our ingest | Fastest path. We operate ClickHouse, the API, and setup. Sign up and send traces. |
| Self-host | server/ + web/ on your infra |
Data stays on your machines. Same code as Cloud. See those READMEs (local uvicorn / Render + Vercel). |
| SDK only | Python warden-agent or @warden/sdk |
Assertions and traces in-process. Leave endpoint unset and exports go nowhere. |
Repository
| Path | Role |
|---|---|
src/ |
Python SDK (warden-agent) |
ts/ |
TypeScript SDK (@warden/sdk) |
server/ |
FastAPI ingest + query API |
web/ |
Next.js Cloud frontend |
contract/ |
Wire schema (warden.json) |
examples/ |
No-LLM harnesses |
Control plane
The Vercel-ready Next.js frontend lives in web/. It provides Clerk
authentication, a landing and pricing page, and a setup flow that issues a
project API key and walks through SDK install plus a first request. The trace
dashboard is not in this UI yet. See web/README.md for local
setup and deployment variables.
Wire contract
The payload schema (AgentTracePayload / AgentStep) is defined once in
the server's Pydantic schemas (server/src/warden_server/schemas.py) and
derived into a language-neutral artifact at contract/warden.json by
server/scripts/emit_contract.py. Consumers are generated or verified from
that artifact — nothing is hand-synced:
- TypeScript SDK —
ts/src/generated/contract.tsis generated from the artifact withpnpm codegen(ts/). - Python SDK —
tests/test_contract.pyvalidates a real SDK export against the artifact withjsonschema. - Server —
server/tests/test_contract.pyfails ifcontract/warden.jsongoes stale relative toschemas.py.
Change the contract like this:
# edit server/src/warden_server/schemas.py, then:
python server/scripts/emit_contract.py # regenerates contract/warden.json
(cd ts && pnpm codegen) # regenerates ts/src/generated/contract.ts
Run the full suite (uv run ruff check ., uv run pytest at the root, and
pnpm typecheck && pnpm test && pnpm build in ts/).
Installation
Using uv (recommended):
uv sync # creates .venv, installs the package + dev group
uv run pytest # run the test suite
uv run ruff check . # lint
Or with plain pip:
pip install -e ".[sdk]" # add `[dev]`-style deps with whatever tool you prefer
Quickstart
from warden import Warden
sentry = Warden(
endpoint="https://ingest.example.com/v1/traces",
api_key=os.environ["WARDEN_API_KEY"],
)
# --- decorate atomic actions --------------------------------------------
@sentry.track_tool
def verify_identity(user_id: str, password: str) -> bool:
return user_id == "root" and password == "hunter2"
# Refunds must only ever run after an identity check in this trajectory.
@sentry.track_tool(required_sequence=["verify_identity"])
def process_refund(order_id: str, amount: float) -> str:
return f"refunded ${amount:.2f} for {order_id}"
# LLM loops that retry with identical args trip this guard after 3 tries.
@sentry.track_tool(max_repeat_count=3)
def query_knowledge_base(question: str) -> str:
...
# --- decorate top-level agents ------------------------------------------
@sentry.track_agent
def refund_agent(order_id: str, session_id: str) -> str:
ok = verify_identity("root", "hunter2")
if not ok:
return "auth failed"
return process_refund(order_id, 19.99)
Every call to refund_agent() produces one AgentTracePayload:
{
"session_id": "9f4c...",
"status": "SUCCESS",
"total_duration_ms": 128.44,
"steps": [
{
"type": "agent",
"name": "refund_agent",
"inputs": {},
"outputs": "refunded $19.99 for ord_123",
"latency_ms": 1.31
},
{
"type": "tool",
"name": "verify_identity",
"inputs": {"user_id": "root", "password": "***"},
"outputs": true,
"latency_ms": 0.87
},
{
"type": "tool",
"name": "process_refund",
"inputs": {"order_id": "ord_123", "amount": 19.99},
"outputs": "refunded $19.99 for ord_123",
"latency_ms": 2.02
}
]
}
Traffic-Light Statuses
The status field on a payload tells you how the agent run finished:
| Status | Meaning |
|---|---|
SUCCESS |
Agent returned normally. Handled assertion steps may still be present. |
CRASHED |
An unhandled exception escaped the agent function. |
ASSERTION_FAILED |
An unhandled SequenceViolationError, ParallelInvocationError, or InfiniteLoopError escaped. |
Local Assertion Engine
Warden's killer feature. Rules run before a tool body is invoked, using the
trajectory captured so far (warden.assertions). The TypeScript package
@warden/sdk is a native in-process port of the same rules (Sentry-style:
npm i does not start Python).
Required sequence (including parallel harnesses)
A prerequisite must have completed successfully. If a harness launches
verify_identity and process_refund in the same asyncio.gather /
Promise.all turn, process_refund is rejected — in-flight does not count.
from warden import SequenceViolationError
try:
process_refund("ord_123", 19.99)
except SequenceViolationError as exc:
print(exc.missing) # ["verify_identity"]
Duplicate overlapping calls
@sentry.track_tool(allow_parallel=False)
def process_refund(order_id: str) -> str: ...
A second call that starts before the first finishes raises
ParallelInvocationError. Independent fan-out (two searches) stays allowed
by default (allow_parallel=True).
Infinite-loop prevention
from warden import InfiniteLoopError
@sentry.track_tool(max_repeat_count=3) # default is 3
def call_llm(prompt: str) -> str: ...
for _ in range(4):
call_llm("same prompt") # 4th consecutive identical call raises
# -> InfiniteLoopError: Tool 'call_llm' invoked 4 consecutive times with
# identical arguments (max_repeat_count=3): {'prompt': 'same prompt'}
The counter is per (tool, arguments-hash) and resets as soon as a tool is
called with different arguments. Set max_repeat_count=None to disable.
Assertion failure policy
Low-level track_tool keeps hard-fail behavior by default. Framework adapters
default to tool_error: the prohibited tool body stays blocked, while the host
receives a JSON-safe result and can continue its agent loop.
@sentry.track_tool(
required_sequence=["verify_identity"],
assertion_failure_mode="tool_error", # or "raise"
)
def process_refund(order_id: str): ...
The TypeScript equivalents are assertionFailureMode and
assertionFailureFormatter. Python uses assertion_failure_formatter.
Handled failures retain an ASSERTION_FAILED step with
assertion.handled=true, but an otherwise completed trajectory is SUCCESS.
Set the mode to raise for security-critical policies that must propagate to
the host. If a framework converts all tool exceptions into model feedback,
configure its own fatal-error policy as well.
Hermes built-in hooks are the exception: Hermes swallows hook exceptions, so
install() only accepts tool_error and always returns its safe block
directive. Use guard_handler(..., assertion_failure_mode="raise") when you
own the Hermes tool handler and need propagation.
Generic wraps
Chat Completions / Responses registries and Vercel tool({ execute }) objects
use the in-process engine without a host SDK:
from warden.adapters import wrap_openai_tools, invoke_openai_tool_calls
registry = wrap_openai_tools(sentry, TOOL_REGISTRY, {
"process_refund": {"required_sequence": ["verify_identity"], "allow_parallel": False},
})
results = await invoke_openai_tool_calls(registry, message.tool_calls)
# Blocked calls are returned as {"error": {"type": "warden_assertion", ...}}.
# Pass assertion_failure_mode="raise" for a hard failure.
import { trackVercelTool } from "@warden/sdk/vercel";
import { wrapOpenAITools } from "@warden/sdk/openai";
export const refund = tool(trackVercelTool(sentry, {
description: "Refund an order",
inputSchema: z.object({ order_id: z.string() }),
async execute({ order_id }) { return `refunded ${order_id}`; },
}, { name: "process_refund", requiredSequence: ["verify_identity"], allowParallel: false }));
OpenAI Agents, Hermes, Eve, and OpenClaw have host adapters (see
Framework adapters). wrap_callable is the shared
primitive those hosts sit on.
See examples/parallel_tools.py and ts/README.md.
Redaction & filtering
By default, values of keys like password, token, secret, api_key,
authorization, cookie and x-api-key are replaced with "***" in the
captured inputs:
@sentry.track_tool(redact={"password", "ssn"}) # customize
@sentry.track_tool(redact=()) # disable redaction
@sentry.track_tool(include=["username"]) # record only these params
Sessions & context
Every trajectory is scoped to a session_id, passed to your agent function
as a keyword argument (a UUID is used when omitted). Tools automatically join
the enclosing agent's session; tools called outside of an agent share a
per-thread context so assertion state still accumulates.
from warden import get_current_context, get_current_session_id
assert get_current_session_id() == my_session_id
ctx = get_current_context()
ctx.record_metric("tokens_used", 1234)
print(ctx.executed_tools) # ["verify_identity", "process_refund"]
print(ctx.memory) # shared mutable dict for the session
Framework-owned run loops (OpenAI Agents Runner.run, Hermes, OpenClaw) can bind
tools to a session without decorating the host:
with sentry.session_context("sess-001"):
verify_identity("root", "hunter2")
process_refund("ord_123", 19.99)
sentry.start_session("sess-001", agent_name="refund_agent")
sentry.end_session("sess-001") # flush + drop
Framework adapters
Warden wraps the tool surface of the agent frameworks we integrate with.
None of these packages are required at install time — adapters duck-type the
host APIs. Install the host SDK yourself when you want the real runtime
(pip install openai-agents, Hermes from Nous Research, OpenClaw from npm).
Those wrappers are the assertion path: they see tool calls, not the host's LLM round-trips. For the server/web dashboard, use an auto-instrumentation library so LLM inputs, tool names, latency, and outputs arrive as OpenTelemetry GenAI / OpenInference spans. See Auto-instrumentation.
OpenAI Agents SDK (Python)
from warden import Warden
from warden.openai_agents import track_openai_tool, wrap_function_tool
sentry = Warden(endpoint="https://ingest.example.com/v1/traces")
@track_openai_tool(sentry, required_sequence=["verify_identity"])
def process_refund(order_id: str) -> str:
return f"refunded {order_id}"
# Already constructed FunctionTool:
wrap_function_tool(sentry, existing_tool, required_sequence=["verify_identity"])
@sentry.track_agent
async def run(prompt: str, session_id: str) -> str:
result = await Runner.run(agent, prompt, context={"session_id": session_id})
return result.final_output
Put session_id on the run context so tools join the same trajectory.
uv run python examples/openai_agents_demo.py is the no-LLM harness.
The TypeScript equivalent is trackOpenAITool from @warden/sdk/openai-agents.
Hermes Agent
Install Warden as a plugin so built-in tools (terminal, etc.) are guarded.
pre_tool_call returns Hermes' {"action": "block", "message": ...} directive
on sequence/loop violations so the tool never runs:
from warden.hermes import install, guard_handler
def register(ctx):
install(
ctx,
sentry,
rules={"terminal": {"required_sequence": ["authenticate"]}},
)
ctx.register_tool(
name="issue_refund",
toolset="warden-demo",
schema={...},
handler=guard_handler(sentry, "issue_refund", issue_refund,
required_sequence=["authenticate"]),
)
uv run python examples/hermes_plugin_demo.py simulates the plugin host.
OpenClaw
TypeScript: wrap tools before api.registerTool / defineToolPlugin:
import { trackOpenClawTool } from "@warden/sdk/openclaw";
api.registerTool(trackOpenClawTool(sentry, {
name: "process_refund",
async execute(_id, params, ctx) { return { refunded: true }; },
}, { requiredSequence: ["authenticate"] }));
OpenClaw's own sandbox (agents.defaults.sandbox.backend: docker) is gateway
config; Warden traces the call regardless of where the host executes it.
Eve (TypeScript)
Same composition as OpenClaw: wrap, then hand to the host
(defineTool(trackEveTool(...)) from @warden/sdk/eve). Real Eve runs also
register createWardenSpanExporter because Eve's native telemetry is
OpenTelemetry spans and Eve owns the run loop.
Auto-instrumentation (server / web)
Observe and enforce are separate paths (the Langfuse lesson):
| Path | What it does | When to use it |
|---|---|---|
| Enforce | track_tool / harness wrappers run assertions before the tool body |
You own the tool execute / handler |
| Observe | OpenInference / GenAI / Eve OTel spans fold into AgentTracePayload |
The framework owns the run loop (Eve, ADK, Claude Agent SDK) or you want LLM I/O |
Use them together when you can. On Eve / ADK / Claude Agent SDK, trackAgent cannot wrap the host loop — register an OTel exporter (or POST /v1/otel/v1/traces) for the trajectory tree, and keep wrappers only on tools you can intercept. Assertion state from trackAgent is not shared with the OTel path.
An auto-instrumentation wrapper — openinference-instrumentation-openai-agents,
pydantic-ai[logfire], Eve otelIntegration, OpenClaw diagnostics-otel —
wraps the host SDK, extracts LLM inputs, tool names, latency, and outputs, and
emits OpenTelemetry GenAI / OpenInference spans. Warden folds those spans
into the same AgentTracePayload the simple SDK posts to /v1/traces, so the
dashboard can show the full trajectory — including span_id / parent_id
nesting, per-turn trace_id, and optional user_id.
Instrumentation scopes such as Eve durable workflow and @vercel/otel/fetch
are dropped so the GenAI / Eve parent is not orphaned by HTTP noise.
In-process (Python)
from opentelemetry.sdk.trace import TracerProvider
from opentelemetry.sdk.trace.export import SimpleSpanProcessor
from openinference.instrumentation.openai_agents import OpenAIAgentsInstrumentor
from warden.otel import WardenSpanExporter
provider = TracerProvider()
provider.add_span_processor(SimpleSpanProcessor(
WardenSpanExporter(
endpoint="https://api.example.com/v1/traces",
api_key="wk_...",
),
))
OpenAIAgentsInstrumentor().instrument()
pydantic-ai[logfire] is the same idea: Logfire's instrumentor emits GenAI
spans. Point a WardenSpanExporter (or OTLP below) at Warden instead of
Logfire Cloud. Host instrumentors are optional — they are not Warden
install-time dependencies.
uv run python examples/otel_auto_instrument.py folds a fake OpenInference
batch with no live model.
OTLP/HTTP (any language)
The OTLP exporter appends /v1/traces to OTEL_EXPORTER_OTLP_ENDPOINT:
export OTEL_EXPORTER_OTLP_ENDPOINT=https://api.example.com/v1/otel
export OTEL_EXPORTER_OTLP_PROTOCOL=http/json
export OTEL_EXPORTER_OTLP_HEADERS=Authorization=Bearer wk_...
JSON only (415 for protobuf). Gzip Content-Encoding is accepted. Auth is
the same project API key as POST /v1/traces. OpenClaw diagnostics-otel and
any OTel SDK can use this path; Eve TypeScript still prefers
createWardenSpanExporter in-process because Eve skips custom span processors.
Configuration
Warden(...) kwarg |
Default | Description |
|---|---|---|
endpoint |
None |
Ingestion URL. If unset, traces go to /dev/null. |
api_key |
None |
Sent as Authorization: Bearer <key>. |
headers |
None |
Extra HTTP headers for the exporter. |
exporter |
None |
Custom TraceExporter (e.g. OTLP, S3, in-memory). |
auto_flush |
True |
Export the payload when each agent finishes. |
timeout |
10.0 |
HTTP exporter timeout (seconds). |
OpenTelemetry
The SDK mirrors each Warden step to an outgoing OTel span (agent.* /
tool.*) when an opentelemetry-sdk provider is configured.
The reverse path — auto-instrumentation into Warden — is
Auto-instrumentation: GenAI / OpenInference
spans are folded into AgentTracePayload by warden.otel.WardenSpanExporter
or POST /v1/otel/v1/traces.
Development
uv sync # install deps + dev group into .venv
uv run pytest # run the test suite (incl. example harnesses)
uv run ruff check . # lint
uv run python examples/refund_agent.py
uv run python examples/live_agent_demo.py # live demo, no API key needed
uv run python examples/openai_function_calling_agent.py # offline without OPENAI_API_KEY
uv run python examples/openai_agents_demo.py # OpenAI Agents SDK adapter, no LLM
uv run python examples/hermes_plugin_demo.py # Hermes plugin hooks, no hermes-agent pkg
uv run python examples/otel_auto_instrument.py # OpenInference/GenAI fold, no live model
uv run python examples/parallel_tools.py # gather() sequence + duplicate guards
# Docker sandbox (as in CI):
WARDEN_SANDBOX_BACKEND=docker uv run pytest tests/test_harness_sandbox.py tests/test_framework_sandbox.py
The Python version is pinned in .python-version and dependencies are locked
in uv.lock for reproducible builds. Commit both.
License
This repository is MIT licensed — SDKs, server, and web. There is no separate commercial source tree today. Managed Warden Cloud (hosted ingest, retention, support) is how the project is funded.
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file warden_agent-0.1.1.tar.gz.
File metadata
- Download URL: warden_agent-0.1.1.tar.gz
- Upload date:
- Size: 61.3 kB
- Tags: Source
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.10.7 {"installer":{"name":"uv","version":"0.10.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1fbf5a4e0fc5318ec3fa966ce6ac08b4c01f173ae305fb831ed4a06915b23adf
|
|
| MD5 |
a65c74b0e7e5f5d2885677ec80481517
|
|
| BLAKE2b-256 |
d53fddf52721ad97be882d81e6c8a481f4f0914626fe4573c95f22014780758a
|
File details
Details for the file warden_agent-0.1.1-py3-none-any.whl.
File metadata
- Download URL: warden_agent-0.1.1-py3-none-any.whl
- Upload date:
- Size: 42.0 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? No
- Uploaded via:
uv/0.10.7 {"installer":{"name":"uv","version":"0.10.7","subcommand":["publish"]},"python":null,"implementation":{"name":null,"version":null},"distro":{"name":"macOS","version":null,"id":null,"libc":null},"system":{"name":null,"release":null},"cpu":null,"openssl_version":null,"setuptools_version":null,"rustc_version":null,"ci":null}
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
a3857896cc60b9c438a84c732792d828624d121249deef020f1fdb1846ee1a0b
|
|
| MD5 |
f53b893dbe0c9a018ae7e67d762ca66b
|
|
| BLAKE2b-256 |
4d0845f979061c83733230b7f02009d66dddd0d3ce0712f9886011373b0d9b15
|