ArdenArden/Docs

Python SDK

Arden documentation

Policy enforcement and human approval for AI agent tool calls. Install once, intercept everything.

Overview

Introduction

Arden sits between your AI agent and its tools. Every tool call is evaluated against policies you configure in the dashboard before the function runs. The policy engine returns one of three decisions:

allow

Function executes immediately and returns its result.

block

PolicyDeniedError raised. Function never runs.

requires_approval

Paused until a human approves or denies on the dashboard.

Every call is logged regardless of outcome — you get a full audit trail from day one, even for tools with no policy configured.

Getting started

Quick start

Install the SDK:

bash
pip install ardenpy

Get your API key from app.arden.sh. Keys starting with arden_test_ hit the test environment; arden_live_ keys hit production.

Configure once at startup:

python
import ardenpy as arden

arden.configure(api_key="arden_live_...")
# For LangChain, CrewAI, and OpenAI Agents SDK — that's it.
# Every tool call in this process is now intercepted, enforced, and logged.

For custom agents without a framework, wrap functions explicitly with guard_tool():

python
safe_refund = arden.guard_tool("issue_refund", issue_refund)
result = safe_refund(150.0, customer_id="cus_abc")
# → allowed · blocked · or held for human approval

Concepts

How it works

Every tool call follows the same sequence:

  1. 1The agent calls a tool (e.g. issue_refund).
  2. 2Arden intercepts the call and sends the tool name and all arguments to the policy engine.
  3. 3The engine looks up the policy for this agent and tool. If no policy exists, the call is allowed and logged.
  4. 4For requires_approval, the call pauses. A human approves or denies on the dashboard. For allow, the function runs immediately. For block, PolicyDeniedError is raised.

Tip

The original function is never called unless the policy allows it or a human approves. Arguments are captured before any execution occurs.

Integrations

Framework integrations

The integration path depends on which framework your agent uses.

LangChain and CrewAI — zero wrapping required

Arden automatically patches BaseTool.run at the class level when configure() is called. Every tool call in the process is intercepted — including tools created after configure is called.

bash
pip install ardenpy
python
import ardenpy as arden

arden.configure(api_key="arden_live_...")
# Done. No protect_tools(), no guard_tool(), no imports.

# LangChain — use tools exactly as before
from langchain.tools import Tool
tools = [Tool(name="issue_refund", func=issue_refund, description="...")]
agent = create_react_agent(llm, tools, prompt)
executor = AgentExecutor(agent=agent, tools=tools)
executor.invoke({"input": user_message})
# Every tool the agent calls is policy-checked automatically
python
# CrewAI — define BaseTool subclasses as usual
from crewai.tools import BaseTool

class RefundTool(BaseTool):
    name: str = "issue_refund"
    description: str = "Issue a refund to a customer"
    def _run(self, amount: float, customer_id: str) -> str:
        return f"Refund of {amount} issued"

agent = Agent(role="Support", tools=[RefundTool()], ...)
# Every _run() call is intercepted — no changes to tool classes needed

Tool names in the dashboard match each tool's .name attribute directly (e.g. issue_refund). Your API key already identifies which agent is making the call, so no prefix is needed.

Note

Tools explicitly wrapped with guard_tool() are automatically skipped by the auto-patcher — no double policy checks.

OpenAI

The OpenAI Agents SDK is auto-patched by configure() — no extra wrapping needed. Arden patches FunctionTool.__init__ so every tool created with @function_tool is intercepted automatically.

bash
pip install ardenpy
python
import ardenpy as arden
from agents import Agent, function_tool, Runner

arden.configure(api_key="arden_live_...")

@function_tool
def issue_refund(amount: float, order_id: str) -> str:
    return f"Refund of {amount} issued for {"{order_id}"}"

# No wrapping needed — configure() already intercepted this tool
agent = Agent(name="SupportBot", tools=[issue_refund])
result = await Runner.run(agent, "Refund order FF-4210 for $500")

Note

Tool names in the dashboard match the function name directly (e.g. issue_refund).

For the OpenAI Chat Completions API (raw tool-call loop), use ArdenToolExecutor:

python
# OpenAI Chat Completions loop
from ardenpy.integrations.openai import ArdenToolExecutor

executor = ArdenToolExecutor()
executor.register("issue_refund", issue_refund)
executor.register("send_email", send_email)

# In your tool-call loop:
result = executor.run(tc.function.name, json.loads(tc.function.arguments))

Custom agents

For agents built without a framework, wrap each function with guard_tool(). The first argument is the tool name used for policy lookup in the dashboard.

python
import ardenpy as arden

arden.configure(api_key="arden_live_...")

safe_refund = arden.guard_tool("issue_refund", issue_refund)
safe_email  = arden.guard_tool("send_email",   send_email)

result = safe_refund(150.0, customer_id="cus_abc")

API reference

configure()

configure()

ardenpy.configure(
    api_key:        str   | None = None,      # or ARDEN_API_KEY env var
    environment:    str   | None = None,      # "live" | "test" — auto-detected from key
    timeout:        float | None = None,      # default 30.0 s
    poll_interval:  float | None = None,      # default 2.0 s
    max_poll_time:  float | None = None,      # default 300.0 s
    retry_attempts: int   | None = None,      # default 3
    signing_key:    str   | None = None,      # for webhook signature verification
) -> ArdenConfig

Sets the global SDK configuration and auto-patches installed frameworks (LangChain, CrewAI, OpenAI Agents SDK). Call this once at application startup — every tool call in the process is intercepted, enforced, and logged from that point on.

ParameterTypeDefaultDescription
api_keystrARDEN_API_KEYYour agent's API key. Keys prefixed arden_test_ automatically select the test environment.
environmentstr"live"Override the environment. Auto-detected from the API key prefix when possible.
timeoutfloat30.0Seconds before an HTTP request to the Arden API times out.
poll_intervalfloat2.0Seconds between status polls when waiting for human approval.
max_poll_timefloat300.0Maximum seconds to wait for a decision before raising ApprovalTimeoutError.
retry_attemptsint3Times to retry a failed request before raising ArdenError.
signing_keystrNoneWebhook signing key, used by verify_webhook_signature() to authenticate incoming webhook payloads.

Warning

Calling configure() a second time replaces the previous configuration globally.

guard_tool()

ardenpy.guard_tool(
    tool_name:     str,
    func:          Callable,
    approval_mode: str      = "wait",
    on_approval:   Callable | None = None,
    on_denial:     Callable | None = None,
) -> Callable

Wraps func with Arden policy enforcement. Returns a new callable with the same signature — the original function is not modified. Use this for custom agents; for LangChain and CrewAI, configure() alone is sufficient.

ParameterTypeDefaultDescription
tool_namestrName used to look up this tool's policy in the dashboard. Set this to match the policy you create.
funcCallableThe function to protect.
approval_modestr"wait"How to handle a requires_approval decision. One of "wait", "async", or "webhook".
on_approvalCallableNoneRequired for "async" and "webhook" modes. Called with a WebhookEvent when approved.
on_denialCallableNoneRequired for "async" and "webhook" modes. Called with a WebhookEvent when denied.

Arguments are captured using inspect.signature and bound_args.arguments, so positional calls like safe_refund(50.0) correctly send {"amount": 50.0} to the policy engine for rule evaluation.

handle_webhook()

ardenpy.handle_webhook(
    body:        bytes,
    headers:     dict[str, str],
    signing_key: str | None = None,
) -> None

Verifies a webhook payload, parses it into a WebhookEvent, and dispatches it to any callbacks registered via guard_tool(on_approval=..., on_denial=...). Raises ArdenError if the signature is invalid or the payload cannot be parsed.

ParameterTypeDefaultDescription
bodybytesRaw request body bytes.
headersdict[str, str]Dict of request headers. Must include X-Arden-Signature and X-Arden-Timestamp.
signing_keystr | NoneNoneOverrides the signing key set in configure(). If omitted, uses configure(signing_key=...).

verify_webhook_signature()

ardenpy.verify_webhook_signature(
    body:        bytes,
    timestamp:   str,
    signature:   str,
    signing_key: str,
) -> bool

Lower-level signature verification. Returns True if valid. Use this if you want to verify the signature manually without calling handle_webhook().

ParameterTypeDefaultDescription
bodybytesRaw request body bytes.
timestampstrX-Arden-Timestamp header value.
signaturestrX-Arden-Signature header value (e.g. sha256=abc123).
signing_keystrYour webhook signing key from the dashboard.

set_session()

ardenpy.set_session(session_id: str) -> None
ardenpy.get_session() -> str | None
ardenpy.clear_session() -> None

Attaches a session ID to the current async task or thread. Every policy-check call made after this point will include the session ID in the action record, allowing you to group and replay all actions from a single conversation on the dashboard.

ParameterTypeDefaultDescription
session_idstrAny string identifying this session — a UUID, conversation ID, user ID, etc. Use get_session() to read it back; clear_session() to unset.

Note

Implemented with contextvars.ContextVar — safe for concurrent async requests with no locking required. See the Session tracking section for a full usage example.

Concepts

Approval modes

When a policy returns requires_approval, the SDK's behavior is controlled by the approval_mode you set on guard_tool() (or on protect_tools() if using explicit wrapping).

wait (default)

python
safe_refund = arden.guard_tool("issue_refund", issue_refund)
# approval_mode="wait" is the default

Blocks the calling thread and polls the Arden API every poll_interval seconds. Execution resumes once a human acts on the dashboard.

Approved

Function executes, result returned.

Denied

PolicyDeniedError raised.

Timeout

ApprovalTimeoutError raised.

Tip

Best for scripts, CLI tools, and synchronous request handlers where blocking is fine.

async

python
safe_refund = arden.guard_tool(
    "issue_refund", issue_refund,
    approval_mode="async",
    on_approval=handle_approved,
    on_denial=handle_denied,
)

result = safe_refund(150.0, customer_id="cus_abc")
# Returns PendingApproval immediately — never blocks
# on_approval / on_denial called from a background thread when a human acts

Tip

Best for long-running services or agents where blocking the main thread is not acceptable but you don't have a public HTTP endpoint.

webhook

python
safe_refund = arden.guard_tool(
    "issue_refund", issue_refund,
    approval_mode="webhook",
    on_approval=handle_approved,
    on_denial=handle_denied,
)

result = safe_refund(150.0, customer_id="cus_abc")
# Returns PendingApproval immediately
# Arden backend POSTs to your webhook URL when a human decides

Tip

Best for production services with a publicly reachable HTTP endpoint. No polling threads.

Observability

Audit trail

Every tool call — regardless of policy outcome — is written to your agent's action log. This happens automatically once you call configure(). No additional setup required.

DecisionLogged?Notes
allowYesIncludes all arguments and the matching policy.
allow (no policy)Yesreason: "no_policy_configured" — gives visibility before you add policies.
blockYesIncludes arguments. Function never ran.
requires_approvalYesAction record created; updated when a human decides.

View the action log for any agent at app.arden.sh. You can also check a specific action's status via the API:

bash
curl 'https://api.arden.sh/status/<action_id>' \
  -H 'X-API-Key: arden_live_...'

Observability

Token usage tracking

Arden automatically tracks token consumption and estimated cost for every LLM call. For LangChain, CrewAI, and the OpenAI Agents SDK, configure() patches the framework at startup — no extra setup needed. Usage is sent in a background thread and adds no latency to your agent.

FrameworkAutomatic?How
LangChainYesPatches BaseChatModel.invoke
CrewAIYesUses LangChain under the hood
OpenAI Agents SDKYesPatches Runner.run
OpenAI Chat CompletionsManualCall arden.log_token_usage()
Any other LLMManualCall arden.log_token_usage()

For custom agent loops, call log_token_usage() manually after each LLM response:

python
import ardenpy as arden

arden.configure(api_key="arden_live_...")

response = openai_client.chat.completions.create(
    model="gpt-4o",
    messages=messages,
)
arden.log_token_usage(
    model="gpt-4o",
    prompt_tokens=response.usage.prompt_tokens,
    completion_tokens=response.usage.completion_tokens,
)
ardenpy.log_token_usage(model, prompt_tokens, completion_tokens, session_id=None)
ParameterTypeDefaultDescription
modelstrModel name, e.g. "gpt-4o" or "claude-sonnet-4-6". Unknown models are logged with zero estimated cost.
prompt_tokensintNumber of input tokens consumed.
completion_tokensintNumber of output tokens generated.
session_idstr | NoneOptional. Falls back to the current set_session() value if not provided.

Note

Cost is estimated using published list prices. If you have a negotiated rate with your provider, configure custom rates per agent in the dashboard — Settings → Custom rates.

The dashboard shows cost broken down by model, day, and session — all scoped per agent. View it at app.arden.sh under the Usage tab for each agent.

Observability

Session tracking

Attach a session ID to a conversation or workflow run so every tool call from that session is grouped together in the action log. Use this to replay exactly what an agent did in a specific conversation — useful for debugging, auditing, and customer support.

Call arden.set_session() once at the start of each request or conversation turn. All subsequent policy-check calls in that async task or thread automatically carry the session ID.

python
import ardenpy as arden
import uuid

arden.configure(api_key="arden_live_...")

# FastAPI example — set once per request
@app.post("/chat")
async def chat(request: Request):
    body = await request.json()

    # Use an ID your app already tracks, or generate one
    session_id = body.get("session_id") or str(uuid.uuid4())
    arden.set_session(session_id)

    reply = await run_agent(messages)
    return JSONResponse({"reply": reply, "session_id": session_id})

The session ID is stored in a ContextVar, so it is scoped to the current async task. Concurrent requests never share a session ID, even without any extra locking.

Tip

Session tracking is completely optional. If set_session() is never called, nothing changes — existing integrations are unaffected.

Session ID shows up as a top-level field on every action record in the log:

FieldDescription
session_idThe value passed to set_session(). Omitted if never called.
action_idUnique ID for this specific tool call.
tool_nameName of the tool that was called.
decisionallow · block · requires_approval
contextAll arguments captured at call time.
ardenpy.set_session(session_id: str) -> None
ardenpy.get_session() -> str | None
ardenpy.clear_session() -> None
ParameterTypeDefaultDescription
session_idstrAny string identifying this session — a UUID, conversation ID, user ID, etc.

Webhooks

Webhook setup

When using approval_mode="webhook", Arden POSTs a signed payload to your endpoint when a human approves or denies. Configure your webhook URL in the dashboard under agent settings.

Payloads are signed with HMAC-SHA256. Verify the signature before processing:

python
# FastAPI
from fastapi import FastAPI, Request
import ardenpy as arden

app = FastAPI()

@app.post("/arden/webhook")
async def webhook(request: Request):
    body = await request.body()
    arden.handle_webhook(
        body=body,
        headers=dict(request.headers),
    )
    # event_type is "action_approved" or "action_denied"
    # callbacks registered via guard_tool(on_approval=...) are called automatically
    return {"ok": True}
python
# Flask
from flask import Flask, request
import ardenpy as arden

app = Flask(__name__)

@app.post("/arden/webhook")
def webhook():
    arden.handle_webhook(
        body=request.get_data(),
        headers=dict(request.headers),
    )
    return {"ok": True}

Note

Set signing_key in configure() to enable signature verification. Get your signing key from the dashboard under webhook settings.

Error handling

Error handling

python
import ardenpy as arden

try:
    result = safe_refund(150.0, customer_id="cus_abc")

except arden.PolicyDeniedError:
    # Blocked by policy, or denied by a human (wait mode)
    return {"ok": False, "reason": "not_allowed"}

except arden.ApprovalTimeoutError as e:
    # Nobody approved within max_poll_time
    return {"ok": False, "reason": "timeout", "action_id": e.action_id}

except arden.ArdenError as e:
    # API error, configuration error, etc.
    logger.error("Arden error: %s", e)
    return {"ok": False, "reason": "policy_check_failed"}

For async and webhook modes, only PolicyDeniedError (immediate block) and ArdenError can be raised at call time. ApprovalTimeoutError only applies to wait mode.

Tip

In production, we recommend catching ArdenError broadly and failing open (allowing the call) or failing closed (blocking) depending on your risk tolerance.

Configuration

Environments

Arden has two isolated environments — test and live. Each has its own API keys, agents, policies, and action log.

EnvironmentAPI endpointKey prefix
Testhttps://api-test.arden.sharden_test_
Livehttps://api.arden.sharden_live_

The environment is auto-detected from the API key prefix. Use an environment variable to switch without code changes:

python
import os, ardenpy as arden

arden.configure(api_key=os.environ["ARDEN_API_KEY"])
# ARDEN_API_KEY=arden_test_... → test environment
# ARDEN_API_KEY=arden_live_... → live environment

Reference

Exceptions

All exceptions inherit from ArdenError.

ArdenError
class ArdenError(Exception)

Base class. Catch this to handle any Arden-related error.

PolicyDeniedError
class PolicyDeniedError(ArdenError):
    tool_name: str

Raised when a call is blocked by policy or denied by a human (wait mode).

ApprovalTimeoutError
class ApprovalTimeoutError(ArdenError):
    action_id: str
    timeout: float

Raised in wait mode when no decision is received within max_poll_time.

ConfigurationError
class ConfigurationError(ArdenError)

Raised when configure() has not been called before a tool call.

For dashboard, API keys, and policy configuration — app.arden.sh. Questions? team@arden.sh