Documentation

Get started with Ardyn.

From zero to your first governed agent action in under five minutes. Works with any agent framework — OpenAI, xAI, Anthropic, LangGraph, CrewAI, n8n, or plain Python.

How Ardyn connects: Ardyn sits between the LLM's tool call decision and the tool actually executing. It doesn't replace your LLM or agent framework — it governs the dispatch loop that every framework already has.
1
Create your account
60 seconds — no credit card

Go to ardyn.ai/start and complete the four-step registration:

1
Name, email, organization
Enter your details and submit.
2
TOTP setup
Scan the QR code with Google Authenticator, 1Password, or Authy. Save your recovery codes.
3
Set a password
Used to access account settings.
4
Your API key appears here
Copy it now. It begins with ardyn_live_ and is shown once. Store it as an environment variable.
shell
export ARDYN_API_KEY="ardyn_live_..."
export ARDYN_TENANT_ID="your-tenant-id"  # shown at step 4
Your API key is shown once at step 4. If you miss it, generate a sub-key from the Console once logged in.
2
Your API key
One master key per org — create labeled sub-keys per agent

Create labeled sub-keys for individual agents or machines. Revoke any key instantly, no restart required.

POSThttps://api.ardyn.ai/v1/keys
curl
curl -X POST https://api.ardyn.ai/v1/keys \
  -H "Authorization: Bearer $ARDYN_API_KEY" \
  -H "content-type: application/json" \
  -d '{"label": "invoice-agent-prod-01"}'

The raw key is returned once. Manage all keys at ardyn.ai/console — Panel 4.

3
Make your first governed call
Choose your framework

All integrations use the same ardyn_governance.py helper. Copy it once, use it everywhere.

ardyn_governance.py — shared helper (all providers)

python — ardyn_governance.py
<span class="kw">import urllib.request, json, hashlib, os, logging

        logger = logging.<span class="fn">getLogger(<span class="str">"ardyn")
        ARDYN_KEY    = os.environ[<span class="str">"ARDYN_API_KEY"]
        ARDYN_TENANT = os.environ[<span class="str">"ARDYN_TENANT_ID"]
        GATEWAY      = <span class="str">"https://api.ardyn.ai"

        <span class="kw">def <span class="fn">_hash(s):
            <span class="kw">try:
                <span class="kw">import blake3; <span class="kw">return blake3.blake3(s.encode()).hexdigest()
            <span class="kw">except ImportError:
                <span class="kw">return hashlib.blake2b(s.encode()).hexdigest()

        <span class="kw">def <span class="fn">govern_and_attest(tool_name, tool_input, tool_result, agent_id=<span class="str">"my-agent"):
            <span class="cmt">"""Govern + attest in a single call. POST /v1/attest evaluates policy AND mints the AEA.
        <span class="cmt">    Returns {'decision': 'ALLOW', 'ddc_id': '...', 'receipt': {...}} on success.
        <span class="cmt">    Returns {'decision': 'BLOCK', 'reason': '...'} on governance rejection.
        <span class="cmt">    """
            <span class="kw">try:
                data = json.dumps({
                    <span class="str">"api_key":    ARDYN_KEY,
                    <span class="str">"tool_name":  tool_name,
                    <span class="str">"input_hash": <span class="fn">_hash(json.dumps(tool_input, sort_keys=<span class="kw">True)),
                    <span class="str">"output_hash": <span class="fn">_hash(str(tool_result)),
                    <span class="str">"metadata":   {<span class="str">"agent_id": agent_id},
                }).encode()
                req = urllib.request.Request(
                    f<span class="str">"{GATEWAY}/v1/attest", data=data,
                    headers={<span class="str">"Authorization": f<span class="str">"Bearer {ARDYN_KEY}",
                             <span class="str">"Content-Type": <span class="str">"application/json"},
                    method=<span class="str">"POST",
                )
                <span class="kw">with urllib.request.urlopen(req, timeout=<span class="num">10) <span class="kw">as r:
                    resp = json.loads(r.read())
                    resp[<span class="str">"decision"] = <span class="str">"ALLOW"
                    logger.<span class="fn">info(<span class="str">"Ardyn ALLOWED: tool=%s ddc_id=%s", tool_name, resp.get(<span class="str">"ddc_id"))
                    <span class="kw">return resp
            <span class="kw">except urllib.error.HTTPError <span class="kw">as e:
                <span class="kw">if e.code == <span class="num">403:
                    body = json.loads(e.read())
                    error_data = json.loads(body.get(<span class="str">"error", <span class="str">"{}"))
                    reason = error_data.get(<span class="str">"reason", <span class="str">"policy_blocked")
                    logger.<span class="fn">warning(<span class="str">"Ardyn BLOCKED: tool=%s reason=%s", tool_name, reason)
                    <span class="kw">return {<span class="str">"decision": <span class="str">"BLOCK", <span class="str">"reason": reason, <span class="str">"ddc_id": <span class="kw">None}
            <span class="kw">except Exception <span class="kw">as e:
                logger.<span class="fn">error(<span class="str">"Ardyn unreachable — FAIL-CLOSED (tool blocked): %s", e)
                <span class="kw">return {<span class="str">"decision": <span class="str">"BLOCK", <span class="str">"reason": <span class="str">"ardyn_unreachable", <span class="str">"ddc_id": <span class="kw">None}
Fail-closed by design: if api.ardyn.ai is unreachable or returns an error, governance returns BLOCK. Your agent cannot run ungoverned. For availability-critical deployments where fail-open is necessary, set an explicit opt-in flag — but never as the default.

Integration by provider

CLI — No pip install required
Build once, then ardyn init + ardyn agent add and you're governed. The CLI handles registration, key creation, and receipt verification — no API calls to write.

Build from source

bash
git clone https://github.com/Kylewilson04/ardyn.git
cd ardyn
cargo build --release -p ardyn-cli
# Binary: ./target/release/ardyn-cli
sudo cp target/release/ardyn-cli /usr/local/bin/ardyn
ardyn --version
# ardyn 0.2.0

1. Register your organization

bash
$ ardyn init

Ardyn CLI — Setup
──────────────────────────
Email address: kyle@acme.ai
Organization name: Acme Insurance
Your name: Kyle Wilson

✓ Organization created
  Tenant ID: 3cf4f022-3e3e-45f1-8528-158434f9bcbc
  API Key:   REDACTED_SECRET_REMOVED

Config saved to ~/.ardyn/config.json
Run 'ardyn agent add' to connect your first agent.

2. Add an agent

bash
$ ardyn agent add

Agent name (e.g. invoice-agent-prod): invoice-agent
Framework:
  1) Python / REST (generic)
  2) OpenAI / ChatGPT
  3) xAI / Grok
  4) Anthropic / Claude
  5) LangGraph
  6) CrewAI
  7) n8n
  8) MCP Proxy
Select (1-8): 2
Environment (production/development/staging): production

✓ Key created: REDACTED_SECRET_REMOVED
  ⚠ Copy this key — it will not be shown again.

✓ ardyn_governance.py written to ./ardyn_governance.py

Add these 3 lines to your OpenAI dispatch function:

  from ardyn_governance import govern_and_attest
  aea = govern_and_attest(name, args, result, AGENT_ID)
  if aea.get("decision") != "ALLOW": print(f"BLOCKED: {aea.get("reason")}")

Docs: https://ardyn.ai/docs/install.html

3. Verify any receipt

bash
$ ardyn verify 40c2d8eb-1d4b-4e34-b642-f99e8e79af88

┌──────────────────────────────────────────────┐
│ AEA Verification                             │
│                                              │
│ AEA ID:    40c2d8eb-1d4b-4e34-b642-f99e8... │
│ Signature: ✓ Valid                           │
│ Chain:     ✓ Intact (sequence 4673)          │
│ Tier:      tier_1 (Bronze)                   │
│ Tool:      terminal                          │
│ Tenant:    3cf4f022-3e3e-45f1-8528-15843...  │
│ Timestamp: 2026-06-24 18:12:09 UTC           │
└──────────────────────────────────────────────┘

$ ardyn verify --file ./receipt.json
# Same output — reads ddc_id from JSON file
Offline verification. ardyn verify works against any AEA ID — no account, no API key. The Merkle chain is public.
OpenAI / ChatGPT
Ardyn sits in the tool dispatch loop after chat.completions.create() returns. Every tool_call is governed before it executes.
python
from openai import OpenAI
import json
from ardyn_governance import govern_and_attest

client    = OpenAI(api_key="your-openai-key")
AGENT_ID  = "openai-agent-01"

def dispatch_tool(name, args):
    if name == "approve_invoice":
        return {"status": "approved", "invoice_id": args["invoice_id"]}
    raise ValueError(f"Unknown tool: {name}")

def run_governed_agent(messages, tools):
    response = client.chat.completions.create(
        model="gpt-4o",
        messages=messages,
        tools=tools,
    )
    message = response.choices[0].message
    for tool_call in (message.tool_calls or []):
        name = tool_call.function.name
        args = json.loads(tool_call.function.arguments)
        gov = govern_and_attest(name, args, result, AGENT_ID)
        if aea.get("decision") == "BLOCK":
            result = {"error": "blocked_by_ardyn", "reason": aea.get("reason")}
        else:
            result = dispatch_tool(name, args)
, AGENT_ID)
        print(f"Tool: {name} | Decision: {aea.get("decision")} | Result: {result}")
    return message
xAI / Grok
xAI's API is OpenAI-compatible. The only differences are base_url and the model name. The Ardyn integration is byte-for-byte identical to OpenAI.
python
from openai import OpenAI
import json
from ardyn_governance import govern_and_attest

client = OpenAI(
    api_key="your-xai-key",
    base_url="https://api.x.ai/v1",
)
AGENT_ID = "xai-agent-01"

def run_governed_agent(messages, tools):
    response = client.chat.completions.create(
        model="grok-3",
        messages=messages,
        tools=tools,
    )
    message = response.choices[0].message
    for tool_call in (message.tool_calls or []):
        name = tool_call.function.name
        args = json.loads(tool_call.function.arguments)
        gov = govern_and_attest(name, args, result, AGENT_ID)
        if aea.get("decision") == "BLOCK":
            result = {"error": "blocked_by_ardyn", "reason": aea.get("reason")}
        else:
            result = dispatch_tool(name, args)
, AGENT_ID)
    return message
The Ardyn wrapper for xAI is identical to OpenAI. If you have existing OpenAI integration, change api_key, base_url, and model — nothing else changes.
Anthropic / Claude
Anthropic returns tool_use blocks instead of tool_calls. The Ardyn integration point is the same — the dispatch loop after messages.create() returns.
python
import anthropic
from ardyn_governance import govern_and_attest

client   = anthropic.Anthropic(api_key="your-anthropic-key")
AGENT_ID = "claude-agent-01"

def run_governed_agent(messages, tools):
    response = client.messages.create(
        model="claude-opus-4-6",
        max_tokens=4096,
        tools=tools,
        messages=messages,
    )
    tool_results = []
    for block in response.content:
        if block.type != "tool_use":
            continue
        name = block.name
        args = block.input
        gov = govern_and_attest(name, args, result, AGENT_ID)
        if aea.get("decision") == "BLOCK":
            result = {"error": "blocked_by_ardyn", "reason": aea.get("reason")}
        else:
            result = dispatch_tool(name, args)
, AGENT_ID)
        tool_results.append({
            "type": "tool_result",
            "tool_use_id": block.id,
            "content": str(result),
        })
    return tool_results
Key difference from OpenAI: block.input is already a dict. No json.loads() needed. The tool_use_id must be returned in the next messages.create() call as a tool_result block.
LangGraph
Wrap tools with a governance decorator before passing them to ToolNode. Every tool execution in the graph is governed automatically — no changes to graph structure.
python
from langgraph.graph import StateGraph, END
from langgraph.prebuilt import ToolNode
from langchain_core.tools import tool
from ardyn_governance import govern_and_attest
import functools

AGENT_ID = "langgraph-agent-01"

def ardyn_governed(fn):
    """Decorator: wrap any LangChain tool with Ardyn governance."""
    @functools.wraps(fn)
    def wrapper(*args, **kwargs):
        tool_input = {"args": args, **kwargs}
        gov = govern_and_attest(fn.__name__, tool_input, result, AGENT_ID)
        if aea.get("decision") == "BLOCK":
            return f"Action blocked by governance: {aea.get('reason')}"
        result = fn(*args, **kwargs)
, AGENT_ID)
        return result
    return wrapper

@tool
@ardyn_governed
def approve_invoice(invoice_id: str, amount: float) -> dict:
    """Approve an invoice for payment."""
    return {"approved": True, "invoice_id": invoice_id, "amount": amount}

@tool
@ardyn_governed
def send_payment(vendor_id: str, amount: float) -> dict:
    """Send a payment to a vendor."""
    return {"sent": True, "vendor_id": vendor_id, "amount": amount}

tools     = [approve_invoice, send_payment]
tool_node = ToolNode(tools)
Apply @ardyn_governed before @tool (bottom-up decorator order). The decorator wraps the function that LangGraph calls, so governance fires for every tool invocation in the graph.
CrewAI
Wrap tool functions before passing them to CrewAI's Tool constructor. The agent never knows governance exists — it just calls tools normally.
python
from crewai import Agent, Task, Crew
from crewai.tools import BaseTool
from ardyn_governance import govern_and_attest
from pydantic import BaseModel
from typing import Type

AGENT_ID = "crewai-agent-01"

class ArdynToolWrapper(BaseTool):
    """Base class: wraps any CrewAI tool with Ardyn governance."""
    ardyn_tool_fn: callable = None
    def _run(self, **kwargs) -> str:
        name      = self.name
        tool_input = kwargs
        gov = govern_and_attest(name, tool_input, result, AGENT_ID)
        if aea.get("decision") == "BLOCK":
            return f"Blocked: {aea.get('reason')}"
        result = self.ardyn_tool_fn(**kwargs)
, aea.get("trace_id"), AGENT_ID)
        return str(result)

class ApproveInvoiceTool(ArdynToolWrapper):
    name: str = "approve_invoice"
    description: str = "Approve an invoice for payment"
    ardyn_tool_fn: callable = lambda self, **kw: {"approved": True, **kw}

finance_agent = Agent(
    role="Finance Automation Agent",
    goal="Process invoices within policy",
    tools=[ApproveInvoiceTool()],
    verbose=True,
)
n8n
Use an HTTP Request node to attest your action AFTER execution. POST /v1/attest evaluates policy, returns verdict (ALLOW/BLOCK), and mints a verifiable AEA. If BLOCKED, use an IF node to handle the rejection.

Option A — HTTP Request nodes (no-code)

n8n HTTP Request node config
Node: HTTP Request (place AFTER your action node)
Method: POST
URL: https://api.ardyn.ai/v1/attest
Authentication: Header Auth
  Name: Authorization
  Value: Bearer {{ $env.ARDYN_API_KEY }}
Body (JSON):
{
  "api_key":    "{{ $env.ARDYN_API_KEY }}",
  "tool_name":  "{{ $node['Previous Node'].name }}",
  "input_hash": "{{ $node['Previous Node'].json | hash }}",
  "output_hash": "{{ $json | hash }}"
}
Then: IF node
  Condition: {{ $json.decision }} equals "BLOCK"
  True branch:  Stop workflow — governance rejected this action
  False branch: Continue (AEA minted)

Option B — Code node (for attestation)

javascript — n8n Code node
// Ardyn attestation — place AFTER your action node (governance + AEA in one call)
const ARDYN_KEY = $env.ARDYN_API_KEY;
const toolName  = "send_payment";
const prevNode  = $input.first().json;
const response  = await $http.post({
  url: "https://api.ardyn.ai/v1/attest",
  headers: { "Authorization": `Bearer ${ARDYN_KEY}` },
  body: {
    api_key:     ARDYN_KEY,
    tool_name:   toolName,
    input_hash:  sha256(JSON.stringify(prevNode)),
    output_hash: sha256(JSON.stringify(prevNode)),
  },
});
if (response.decision === "BLOCK") {
  throw new Error(`Ardyn BLOCKED this action: ${response.reason}`);
}
return [{ json: { ...prevNode, ddc_id: response.ddc_id } }];
javascript — n8n Code node (attestation, after action)
// Attestation node — place after your action node
const ddcId = $input.first().json.ardyn_ddc_id;
const result  = $input.first().json;
await $http.post({
  url: "https://api.ardyn.ai/v1/attest",
  headers: { "Authorization": `Bearer ${$env.ARDYN_API_KEY}` },
  body: {
    api_key:     $env.ARDYN_API_KEY,
    tool_name:   "send_payment",
    input_hash:  btoa(JSON.stringify($input.first().json)),
    output_hash: btoa(JSON.stringify(result)),
    metadata:    { trace_id: ddcId, agent_id: "n8n-workflow" },
  },
});
return [{ json: result }];
MCP Proxy
The MCP proxy intercepts JSON-RPC between an MCP client (Claude Desktop, Cursor) and an MCP server. No changes to the client or the upstream server required.

Build the proxy

bash
git clone https://github.com/Kylewilson04/ardyn.git
cd ardyn
cargo build --release -p ardyn-mcp-proxy
# Binary: ./target/release/ardyn-mcp-proxy

Create a config file per MCP server

toml — ~/.ardyn/proxy-filesystem.toml
upstream_command     = ["npx", "-y", "@modelcontextprotocol/server-filesystem", "/your/path"]
gateway_url          = "https://api.ardyn.ai"
api_key              = "ardyn_live_..."
tenant_id            = "your-tenant-id"
axiom_governance_url = "https://api.ardyn.ai"
axiom_api_key        = "ardyn_live_..."
inject_metadata      = true
log_level            = "info"

Wire into Claude Desktop

json — ~/Library/Application Support/Claude/claude_desktop_config.json
{
  "mcpServers": {
    "filesystem-governed": {
      "command": "/path/to/ardyn/target/release/ardyn-mcp-proxy",
      "args": ["--config", "/Users/you/.ardyn/proxy-filesystem.toml"]
    }
  }
}
The MCP proxy intercepts MCP protocol tool calls only. It does not intercept Claude Desktop's native built-in tools. For those, use the Python integration directly.
4
Verify the receipt
Any third party. No Ardyn account. Servers off.

Browser

Go to ardyn.ai/receipt-verify. Paste any AEA ID. Verification runs client-side — no servers contacted. Try a real receipt: 40c2d8eb-1d4b-4e34-b642-f99e8e79af88

API

GEThttps://api.ardyn.ai/v1/verify/:ddc_id
curl
curl https://api.ardyn.ai/v1/verify/40c2d8eb-1d4b-4e34-b642-f99e8e79af88
chain_valid: true means this receipt is in an unbroken Merkle chain. Verification works even after you cancel your subscription — your evidence history belongs to you.

Key management

Create one sub-key per agent or machine. Revoke instantly — no restart required.

curl
# Create
curl -X POST https://api.ardyn.ai/v1/keys \
  -H "Authorization: Bearer $ARDYN_API_KEY" \
  -H "content-type: application/json" \
  -d '{"label": "invoice-agent-prod-01"}'
# List
curl https://api.ardyn.ai/v1/keys \
  -H "Authorization: Bearer $ARDYN_API_KEY"
# Revoke immediately
curl -X DELETE https://api.ardyn.ai/v1/keys/ardyn_live_... \
  -H "Authorization: Bearer $ARDYN_API_KEY"

Endpoint reference

EndpointAuthPurpose
POST /v1/attestBearer keyGovern + attest a tool call — evaluates policy, returns verdict, mints verifiable AEA
GET /v1/verify/:idPublicVerify any AEA independently
GET /gateway/v1/trust/:agent_idBearer keyAgent trust profile — standing, AEA count, authority
POST /v1/keysBearer keyCreate labeled sub-key
GET /v1/keysBearer keyList all keys (raw values never returned)
DELETE /v1/keys/:idBearer keyRevoke key — immediate effect
GET /healthPublicGateway health check

The Console

ardyn.ai/console — four panels, no setup:


Questions? Contact the team or try the live demo.