I keep meeting teams that ship multi-agent systems on top of LangChain or CrewAI, get to production, and then realise nobody has a good answer to "what if the model picks the wrong tool" or "what if one of the agents gets compromised." The honest answer is usually a Slack channel and a prayer. Sometimes a regex on outbound traffic, which catches roughly nothing.

This post is the version of that conversation I now have on repeat. There is one decorator. There is no agent loop rewrite. You keep your LLM provider. You keep your framework. You add three lines at boot and one annotation per tool, and from that point forward every tool call goes through a 9-stage pipeline that catches the categories below.

What you actually get

Clampd sits between your agent and its tools as a self-hosted gateway. Every @clampd.guard-wrapped function call detours through it. Each stage is a specific attack class, not a marketing line.

What the live feed actually looks like

Talking about layers is one thing. Here is what the Risk Feed in the dashboard showed on our dev cluster while we were writing this post. Real agent IDs, real rule hits, real risk scores out of ag-risk. Nothing rehearsed.

Risk Feed · last 8 events

LIVE
05:10:45 test-research data.analyze 0.25 blocked Policy denied: delegation_not_approved (caller '0f20fe1f…' not approved for target 'd79ea397…')
05:10:21 test-orchestrator data.analyze 0.25 blocked Policy denied: delegation_not_approved (after workflow locked, reverse edge enforced)
04:58:23 test-research data.analyze 0.90 blocked task_replay_detected: duplicate delegation d79ea397-bd0 → 0f20fe1f-02c within 60s
04:48:29 PRE_REGISTRY database.query 0.85 blocked Non-ASCII character in agent ID 'research-bot-🤖' (codepoint U+1F916)
04:48:29 PRE_REGISTRY database.query 0.85 blocked Non-ASCII character in agent ID 'research-bot​' (codepoint U+200B, zero-width space)
04:48:29 PRE_REGISTRY database.query 0.85 blocked Non-ASCII character in agent ID 'research-botа' (codepoint U+0430, Cyrillic homoglyph)
04:14:05 test-research web.search 0.40 blocked descriptor_hash_mismatchtool descriptor changed between register and call
04:48:30 redteam-agent database.query 0.25 allow {"sql":"SELECT name FROM users WHERE id = 1"} - exempted, observed for baseline

A few notes on those rows, because they each correspond to a different layer firing.

Lock the delegation graph from the dashboard

Decorating tools gets you the data. Approving the graph is what turns it into enforcement. The dashboard has an A2A page that mirrors what's happening in agent_relationships, and you operate on it like this.

Auto: test-orchestrator observed

Three buttons, three states, three things they do.

We exercised the round-trip on dev. Three SDK calls, no manual surgery on Redis or Postgres at any point.

  1. Forward call (approved direction). Orchestrator delegated to research, research called data.analyze, gateway returned SUCCESS. Row 5 in the feed above, the {q:"enforce-test"} allow.
  2. Reverse call (no approval for B→A). Research tried to delegate back to orchestrator. Denied with delegation_not_approved. That is the 05:10:21 block.
  3. Revoke A→B and retry. Clicked Unlink in the dashboard, waited 22 seconds for sync plus cache, ran the forward call again. Denied with delegation_not_approved (the 05:10:45 block). Restored the edge afterwards. Total round-trip about 40 seconds wall-clock.

What the graph looks like on the dashboard

The workflow page renders agent_relationships as a node-and-edge graph. Edge counts are the actual observation_count column. The version below is a static reproduction of what we saw at the time of writing.

2,530 obs · observed → locked 221 obs · approved 23 obs · reverse · denied after lock orchestrator d79ea397 EMA 0.25 research 0f20fe1f EMA 0.30 · 1 replay hit writer 94f10ebe EMA 0.25 workflow Auto: test-orchestrator LOCKED
observed (auto-discovered) approved edge denied (no approval for direction)

The graph is identical in spirit to the in-product page - same node shapes, same colour code, same metadata in the corners. Auto-discovery runs in ag-control::local_sync on a 5-minute tick. Approval and lock both happen in the same panel. You don't have to write a single SQL statement or touch Redis to move an edge between states.

Cryptographic enforcement: signed delegation proofs

Locking the graph governs which delegations are allowed. Signing the proof governs that the delegation is real. With CLAMPD_DELEGATION_SIGNATURES=on, every multi-hop call carries an HS256-signed JWT minted by the leaf agent with its own credential hash. The gateway looks up the same hash in ag:agent:cred:{leaf} and verifies four things on every hop: the signature, the audience, the expiry (30 second TTL), and a SHA-256 of the delegation chain itself so a proof minted for chain [A, B, C] can't be replayed under [A, B, X].

Four scenarios, run against the production dev cluster:

S1 Signed proof, valid chain 200 ok · confidence=verified
S2 Proof stripped client-side 403 delegation_signature_required
S3 Proof minted for a different chain 403 chain_hash_mismatch
S4 Proof signed by wrong agent 403 jwt_invalid

S2 catches a hostile or out-of-date SDK that doesn't sign. S3 catches token replay across chains - the canonical case where a compromised intermediary tries to graft a stolen proof onto a new path. S4 catches the simplest forgery attempt: any agent signing on behalf of another. The verifier code is ag-gateway/src/delegation.rs::verify_signed_delegation; the same chain-hash function lives byte-for-byte in sdk/python/clampd/auth.py and sdk/typescript/src/auth.ts, with a Rust regression test that asserts the literal SHA-256 so a future drift breaks CI.

Kill cascade: what happens when you pull the plug

Approval governs the steady state. Enforcement catches violations. Kill is what you do when one of your agents is already compromised and the rest of the tree needs to come down with it.

The dashboard kill endpoint inserts a kill_agent command. ag-control picks it up over WebSocket and calls ag-kill with cascade_descendants=true by default. From there, the cascade walks the agent_relationships table and revokes every descendant in the tree up to depth 5. The credential hash is nulled on each agent, so the next gateway call from any of them fails with invalid_jwt: Agent authentication failed.

Live test against the dev cluster, killing a freshly-provisioned 3-agent tree:

cascade step 1 cascade step 2 kc-orch ea4e384d KILLED (root) kc-research 2ee553b7 CASCADED · parent ea4e384d kc-writer 915e2ed6 CASCADED · parent ea4e384d dashboard action Kill agent · cascade_descendants=true
killed (root) - dashboard action killed (cascaded) - walked by ag-kill

What the test asserted, in order:

  1. Provisioned three fresh agents via the dashboard API, granted them net:*, db:*, comms:* scopes.
  2. Ran a depth-3 delegation chain to populate agent_relationships: kc-orch → kc-research → kc-writer.
  3. Hit the kill endpoint on the root with reason e2e kill-cascade test.
  4. Eight seconds later, called the gateway as kc-orch via the SDK. Got back invalid_jwt: Agent authentication failed. This usually means the agent is suspended.
  5. Read the dashboard for all three agents. Root was killed with the test reason. Both descendants were killed with reason cascade: parent ea4e384d-d3e5-4b21-94e6-64ec540457.

Eight seconds, end-to-end. That's the wall-clock time from "click kill" to "every agent in the subtree refuses to authenticate." The 5-minute auto-discovery loop has nothing to do with it - kill is event-driven through ag-control's WebSocket command stream, not the polling sync.

The 10-minute onboarding

1. Install

# Python
pip install clampd

# TypeScript
npm install @clampd/sdk

2. Init at startup

import os
import clampd

clampd.init(
    agent_id="orchestrator",
    gateway_url=os.environ["CLAMPD_GATEWAY_URL"],   # e.g. http://localhost:8080
    api_key=os.environ["CLAMPD_API_KEY"],
    secret=os.environ["ORCHESTRATOR_SECRET"],
    agents={
        "orchestrator":   os.environ["ORCHESTRATOR_SECRET"],
        "research-agent": os.environ["RESEARCHER_SECRET"],
        "writer-agent":   os.environ["WRITER_SECRET"],
    },
)

Each agent has its own JWT secret, its own kill switch, its own EMA score. The gateway never sees your LLM provider keys. Org-scoped X-AG-Key authenticates the application; per-agent JWTs authenticate which agent is making any individual call.

3. Declare your tools at boot

from clampd import Category, Subcategory, Operation

clampd.register_tool(
    "db.query",
    category=Category.DB,
    subcategory=Subcategory.QUERY,
    operation=Operation.READ,
    description="Read-only SQL against the analytics replica",
    param_schema={"type": "object", "properties": {"sql": {"type": "string"}}},
)

This pre-classifies the tool against the taxonomy in ag-common/src/categories.toml. If you skip it, the first runtime call backfills the descriptor automatically, but you spend that first call in a "pending" state where stricter rules apply. Registering at boot is the trade you want.

4. Wrap an existing function with one line

This is the part that surprises people. The agent loop, the framework, the LLM client, all stay the same. You add one decorator.

Before:

def run_query(sql: str) -> str:
    return db.execute(sql)

After:

@clampd.guard("db.query")
def run_query(sql: str) -> str:
    return db.execute(sql)

That is the entire change. On every call the decorator:

  1. Enters a delegation context (tracks the caller chain, checks depth and cycles).
  2. Posts to the gateway's /v1/proxy with the tool name, params, and cached descriptor hash.
  3. Raises ClampdBlockedError if the gateway denies, with matched_rules and risk_score populated so your error handler can branch.
  4. Snapshots kwargs with copy.deepcopy before executing the underlying function. This closes a TOCTOU window between the policy check and the actual call.
  5. Stashes the returned scope_token in a contextvars.ContextVar so the tool itself can verify it.

Add check_response=True to also scan whatever the function returns. PII and secrets in tool responses are how exfiltration usually gets back into the LLM context.

5. Compose a 3-agent delegation chain

with clampd.agent("orchestrator"):
    plan = run_planner(goal)

    with clampd.agent("research-agent"):
        sources = guarded_search(query=plan.query)

        with clampd.agent("writer-agent"):
            draft = guarded_write(sources=sources)

The chain orchestrator -> research-agent -> writer-agent propagates through contextvars. Every guarded call inside sees the full chain and forwards delegation_chain plus delegation_trace_id to the gateway. Try to exceed depth 5 and the SDK raises ClampdBlockedError before the request leaves your process. Try to cycle and same story.

In TypeScript the equivalent is clampd.agent("orchestrator", async () => { ... }). The withDelegation primitive still exists for backward compatibility but new code should use clampd.agent.

6. Scan inputs and outputs explicitly when you need to

If your agent does its own LLM call (you have no OpenAI or Anthropic SDK in the path, you talk HTTP directly), the SDK exposes the scanners as plain functions:

scan = client.scan_input(user_prompt)
if not scan.allowed:
    raise RuntimeError(f"blocked: {scan.denial_reason} rules={scan.matched_rules}")

output_scan = client.scan_output(llm_response)
if not output_scan.allowed:
    handle_pii(output_scan.pii_found, output_scan.secrets_found)

scan_input runs prompts against the prompt-scoped rules. scan_output runs PII and secret detection and returns the actual hits, not just a boolean.

Framework adapters

If you don't want to decorate every function, the SDK ships one-line wrappers for the common frameworks. Each one does the same job at a different integration point.

A nuance worth being honest about

Hashing tool descriptors protects between deploys, not between minutes inside one MCP session. If a server mutates a tool while a long-lived session is open and the agent never re-discovers tools, the original hash still validates. Reasonable real-world MCP clients re-discover, but if yours doesn't, you have a smaller window the hash can't cover. We are working on streaming re-discovery and would take a PR if you've solved this elegantly.

What you don't need to do

The frequent objection to wrapping anything is "I can't rewrite my system." Fair. Here is what you don't have to give up.

The trust model in three lines

Per-agent API key, per-agent HS256-signed JWT, per-agent kill switch. The gateway never sees your LLM provider credentials. Kill one agent and the 8-layer cascade in ag-kill/src/cascade.rs walks the tree up to depth 5 and revokes every descendant, with a reason chain that names the root.

If you remember one thing

Add @clampd.guard to one tool today. The rest can wait. One protected tool call gives you the audit trail, the kill switch, and the per-call signed authorization. Everything else is incremental from there.

Two patterns I see most often

The first is teams that wrap their planner with clampd.agent("orchestrator") and decorate two or three high-risk tools (anything that writes, anything that talks outbound). Within a week they have enough audit data to argue with security about what's actually happening at runtime. That conversation used to go badly. Now it goes "here is the trace."

The second is teams that watch the auto-discovered graph for a week, lock it, and from that point on every new edge is queued for review instead of silently working. The risk feed and the workflow lock cycle in the section above is what that day-two operation actually feels like. Forward direction passes, reverse is denied, revocation propagates in roughly 20 seconds. No SQL, no Redis surgery, no manual JWT minting.

Closing

If you have one production agent and zero protection, you are not in a worse spot than most of the industry. You are also one decorator away from a real audit trail. Start with pip install clampd, point at a local gateway, decorate one tool. Watch the audit log fill up. Decide what else you want to guard.

For the adversarial side of this, the live demos at redteam.clampd.dev run the same attack categories against the same gateway code your SDK is about to call. Prompt injection, rug-pull schema swaps, delegation cycle attempts, kill cascade. You can rerun any of them against your own deployment.

Try Clampd in 60 seconds

One line of Python or TypeScript. Works with OpenAI, Anthropic, LangChain, CrewAI, Google ADK, and any MCP server. Self-hosted, source-available, no telemetry by default.

pip install clampd npm install @clampd/sdk
Get Started → Why Clampd