How Adrian captures agent actions and reasoning, classifies them with a local LLM, and blocks or holds risky tool calls.

Prompt injection and data exfiltration are not theoretical. An agent can be steered into leaking secrets or deleting files, and static analysis will not catch it because the malicious behavior emerges at runtime, from the interaction between model reasoning and tool access. You need to watch what the agent does and why it does it, in real time, and be able to stop it before the action lands.

Adrian is an open-source runtime security engine that does exactly that. It sits between your agent framework and the actions your agent takes, capturing paired LLM calls and tool executions alongside reasoning traces, then classifying them with a local LLM against a policy you define. When a risky action is detected, Adrian can alert, hold it for human approval, or block it outright.

The project is active and substantial: roughly 14 contributors, recent SDK releases in Python and TypeScript, and a Go backend that persists to SQLite. It instruments LangChain, LangGraph, OpenAI, Anthropic, and Claude Code without requiring you to rewrite your agent code.

By the end of this article you will understand how Adrian captures and pairs agent events, how its classifier distinguishes benign from malicious behavior, and how verdicts are enforced at the SDK boundary.

What Adrian Is (and Isn’t)

Adrian is a runtime security layer for AI agents. It captures what an agent does—tool calls and LLM outputs—along with why it does it, by recording reasoning traces. These paired events stream over a WebSocket to a backend that classifies them with a local LLM and returns a verdict the SDK enforces locally.

The problem Adrian addresses is that agents are vulnerable to prompt injection, tool poisoning, and data exfiltration. These attacks slip past static analysis because they exploit the agent’s runtime context, and they slip past network monitoring because they use legitimate API calls. Adrian watches behavior and reasoning at the moment of execution, detecting malicious or out-of-remit actions and optionally intervening before the action lands.

Adrian is not a static analysis tool, a network firewall, or a model-level guardrail. Static analysis cannot see what an agent decides to do at runtime; a firewall cannot distinguish a benign file read from an exfiltration attempt; and model-level guardrails do not gate tool execution. Adrian is also not cloud-only—it is designed to run fully self-hosted and offline.

In the stack, Adrian sits between the agent framework (LangChain, OpenAI, Anthropic, Claude Code) and the actions the agent takes. Below it is the agent runtime that the SDKs instrument; above it is the operator’s dashboard and policy configuration. The project is open-source under Apache-2.0, with SDKs in Python and TypeScript and a Go backend, all deployable via Docker Compose without external dependencies.

Architecture: From Agent Call to Verdict

Call flow (1/3) (1/2)

Call flow (1/3) (2/2)

Call flow (2/3)

Call flow (3/3)

Architecture Overview

Data Flow: From Agent Call to Verdict

Adrian’s architecture is a client-server system with five layers: client runtime, control plane, storage, presentation, and deployment. The client runtime comprises the Python SDK, TypeScript SDK, and Claude Code plugin, which instrument agent frameworks to capture events. The control plane is a single Go backend process that ingests, classifies, and dispatches verdicts. SQLite provides storage, a Next.js dashboard handles presentation, and Docker Compose scripts manage deployment.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
flowchart LR
    subgraph Client["Client Runtime"]
        PY[Python SDK]
        TS[TypeScript SDK]
        CC[Claude Code Plugin]
    end
    subgraph Control["Control Plane"]
        WS[WebSocket Hub]
        ENG[Classifier Engine]
        LLM[Llama.cpp + Gemma]
        NOT[Notification Dispatcher]
    end
    subgraph Store["Storage"]
        SQL[(SQLite)]
    end
    subgraph UI["Presentation"]
        DASH[Next.js Dashboard]
    end
    PY -->|protobuf frames| WS
    TS -->|protobuf frames| WS
    CC -->|protobuf frames| WS
    WS --> ENG
    ENG -->|HTTP /v1/chat/completions| LLM
    WS --> SQL
    ENG --> SQL
    WS --> NOT
    WS -->|verdict frames| PY
    WS -->|verdict frames| TS
    WS -->|verdict frames| CC
    DASH -->|REST API| WS

The data flow begins when an SDK captures an LLM or tool execution. Both SDKs use an EventPairBuffer to pair start and end events into a PairedEvent, supporting parallel runs via multiple run_id keys. The SDK then sends a PairedEventBatch frame over an authenticated WebSocket connection to the Go backend.

The backend persists the event to SQLite, then hands it to the classifier engine. The engine maintains a per-(session, invocation, agent_id) sliding window that provides conversation history to the local LLM—Llama.cpp serving a Gemma model. The classifier builds a prompt from the paired event and the agent’s profile, and the model returns exactly one M-code (M0/M2/M3/M4). The backend applies the active policy mode—alert, hitl, or block—and the WebSocket hub pushes a Verdict frame back to the SDK, which enforces it locally by gating tool execution.

The WebSocket protocol uses protobuf frames. The first client frame must be a SessionLogin with schema_version=2; the server replies with a LoginAck containing the policy snapshot.

Key Features: What Adrian Does Differently

Key Features

Policy Modes

Adrian’s core differentiator is that it classifies both agent behavior and reasoning traces. The SDKs capture chain-of-thought alongside tool calls and LLM outputs, so the classifier can detect malicious intent that behavior-only monitoring misses. A model that plans to exfiltrate data before calling a tool leaves a reasoning trail that Adrian evaluates, not just the tool call itself.

Agent profiles give the classifier context to judge actions against. Operators define an agent’s remit, expected behaviors (M0), and known risks (M3), and these are spliced into the classification prompt. This lets the system catch novel attacks that generic training data would not flag, because the profile encodes what this specific agent is supposed to do.

Adrian separates classification from operational control. The classifier returns a MAD code verdict (M0/M2/M3/M4), and a policy layer decides what to do with it. Three modes are available: alert (dashboard-only visibility), hitl (queue for human review), and block (forward verdicts to the SDK for enforcement). Per-MAD-code toggles let operators act on M3 but ignore M2, for example, without changing the classifier.

In block mode, the SDK halts tool execution before the tool body runs when a verdict is M3/M4 and the corresponding policy flag is enabled. In hitl mode, risky actions are paused and queued in the dashboard for approval or rejection; approving a review publishes a resolution back to the waiting SDK over the WebSocket hub. The Claude Code plugin handles hitl inline in the terminal rather than through the dashboard queue.

Two visibility features round out the offering. Discord alerting fans out verdict notifications to configured webhooks, keeping operators informed without dashboard polling. An MCP server inventory reports each SDK’s connected MCP servers, giving a view of the agent’s external tool landscape.

Finally, Adrian defends the classifier itself from prompt injection. All untrusted content—user prompts, tool arguments, tool outputs—is wrapped in <adrian-untrusted> tags carrying a per-conversation GUID. The classifier treats tagged content as data, not instructions; a closing tag without the matching GUID is literal text. This prevents an injected instruction in tool output from steering the classifier’s verdict.

Use Cases: Where Adrian Fits and Where It Doesn’t

Use Cases

Adrian is a strong fit for LangChain-based customer service agents that need protection against prompt injection and data exfiltration. The Python SDK auto-instruments LangChain/LangGraph with minimal code changes, and agent profiles let operators define expected behaviors and known risks that the classifier uses as context. This combination catches novel attacks that static analysis would miss.

The Claude Code plugin suits security teams that need real-time oversight of coding agents. Because every hook invocation opens its own WebSocket connection to the backend, risky tool calls can be blocked or approved directly in the terminal without leaving the developer’s workflow. The plugin’s per-connection connection_id ensures parallel hooks sharing one session do not evict each other.

Organizations with strict data-sovereignty requirements are a natural fit. Self-hosting with Docker Compose and a local Gemma model via Llama.cpp keeps all event data, reasoning traces, and verdicts on the host. No telemetry leaves the box.

The poor fits are equally clear. CrewAI is on the roadmap but not yet supported, so teams using it would need manual instrumentation with no SDK guarantees. The backend is single-process with in-memory pub/sub and a SQLite store; horizontally scaled or high-throughput deployments would outgrow it quickly.

The operational burden is real. Classification requires a local LLM served by Llama.cpp, which needs a GPU for reasonable latency and roughly 10GB of disk for the model. Teams without GPU capacity or willingness to manage a model-serving container should look elsewhere.

Interface and Usage: Instrumenting an Agent

Quickstart with LangChain

Claude Code Plugin Install

The Python SDK instruments LangChain and LangGraph agents with two calls. adrian.init() establishes the WebSocket connection and installs the monkey-patches; adrian.shutdown() closes the connection and cleans up handlers.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
import asyncio
import adrian
from langchain_openai import ChatOpenAI

async def main():
    adrian.init(api_key="adr_live_...")
    llm = ChatOpenAI(model="gpt-4o")
    response = await llm.ainvoke("Find the most underpriced recent IPOs")
    print(response.content)
    adrian.shutdown()

asyncio.run(main())

The init() call defaults to ws://localhost:8080/ws and takes an API key generated from the dashboard. Auto-instrumentation works by monkey-patching LangChain’s Runnable, CallbackManager, BaseChatModel, BaseTool, and LangGraph’s ToolNode and AgentExecutor, as shown in sdk/python/adrian/langchain_handler.py. The patches inject an AdrianCallbackHandler into every call’s config, so no per-call wiring is needed.

For frameworks without auto-instrumentation, Adrian exposes explicit entry points: adrian.openai(client) wraps an OpenAI client with a Proxy to intercept chat.completions and responses calls, and adrian.patch_anthropic() instruments the Anthropic SDK directly.

Claude Code users install the plugin through the marketplace:

1
2
/plugin marketplace add secureagentics/Adrian
/adrian-init

The plugin maps Claude Code hooks (PreToolUse, PostToolUse, UserPromptSubmit, Stop) to subcommands in integrations/claude-code/adrian_cc/agent.py. Each hook invocation opens its own WebSocket to the backend, sends the event, and returns allow/deny JSON to Claude Code. Because hooks fire concurrently as separate processes, the plugin uses cross-platform file locking (fcntl on POSIX, msvcrt on Windows) to serialize writes to its shared state file.

Human oversight flows through the REST API. The dashboard calls POST /api/reviews/{id}/approve to resume a blocked SDK after HITL review, and PUT /api/settings/policy updates the execution mode (alert/hitl/block) and which MAD codes trigger action.

How It Compares: Adrian vs. Observability and Guardrails

What Adrian Does

Adrian vs. Alternatives

AxisAdrianLangSmithGuardrails AI
Primary use caseRuntime agent securityLLM observabilityLLM I/O validation
Common groundMonitors LLM/tool activityLangChain tracingLLM app guardrails
Key differenceBehavior + reasoning classificationTracing onlyI/O validation only
Main advantageCatches novel attacks via policyDeep framework integrationSimple validators
Main drawbackRequires local LLM + GPUNot security-focusedNo tool gating
Performanceunknownunknownunknown
MaturityActive (2026)MatureActive
Deployment modelSelf-hosted or cloudCloudCloud/self-hosted
LanguageGo, Python, TypeScriptPythonPython
ExtensibilityAgent profiles, policy modesCustom callbacksCustom validators
Operational burdenHigh (GPU, model mgmt)LowLow
LicenceApache-2.0ProprietaryApache-2.0

This comparison reflects general knowledge of LangSmith and Guardrails AI as of the analysis date and may be out of date; verify current capabilities before making procurement decisions. Performance characteristics for all three tools are listed as unknown because no benchmark data was available.

The decisive difference is that Adrian is a security control, not a tracing or validation tool. LangSmith captures what an agent did; Adrian can stop what an agent is about to do. Its SDKs gate tool execution in-process when the classifier returns a blocking verdict, and its HITL mode holds risky actions for human approval. Guardrails AI validates inputs and outputs against schemas but does not monitor tool-call sequences or reasoning traces, and it has no mechanism to halt a tool mid-execution.

The main tradeoff is operational. Adrian’s classification runs on a local LLM via Llama.cpp, which requires GPU hardware and roughly 10 GB of disk for the model. LangSmith is a managed cloud service with near-zero setup, and Guardrails AI can run self-hosted with modest resources. Teams that already operate GPU infrastructure will find Adrian’s data-sovereignty benefits attractive; teams without it face a real deployment cost.

Under the Hood: Prompt Injection Defense and Concurrency

Untrusted Content Wrapping

Concurrency Control

Two engineering patterns in the Go backend make Adrian’s classification pipeline both secure and fast. The first defends the classifier itself from prompt injection; the second keeps classification correct under concurrent agent activity.

The classifier receives user and tool content that may contain malicious instructions aimed at the LLM. To prevent this, backend/internal/engine/policy.go wraps all untrusted content in delimited tags carrying a per-conversation GUID:

1
2
3
4
// backend/internal/engine/policy.go
func wrap(content, guid string) string {
    return "<adrian-untrusted id=\"" + guid + "\">" + content + "</adrian-untrusted>"
}

The closing tag without a matching ID is treated as literal text by the classifier prompt. An attacker who injects </adrian-untrusted> cannot escape the wrapper because the model only honors a closing tag whose ID matches the opening tag’s GUID. This makes the delimiter unforgeable without knowing the conversation’s random ID.

The second pattern addresses concurrency. The classifier engine in backend/internal/engine/window.go maintains a sliding window per (session, invocation, agent_id) key, providing conversation history to the stateless LLM. A mutex per key serializes read-classify-publish-push operations:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
// backend/internal/engine/window.go
type windowEntry struct {
    mu     sync.Mutex
    buffer []*pb.PairedEvent
    pos    int
}

func (e *windowEntry) lock() {
    e.mu.Lock()
}

Same-key classifications are serialized so the model never sees a torn history, while different keys classify in parallel. This matters because agent sessions interleave events; without per-key locking, one agent’s event could corrupt another’s context window. The ring buffer bounds memory usage per active session.

Gotchas and Operational Notes

Operational Considerations

Gotchas

The classifier depends on a local LLM served through Llama.cpp, and GPU acceleration matters in practice. CPU-only inference is functional but slow enough to add noticeable latency to every classified event, which can stall agent execution in block or HITL mode.

The sliding window that supplies conversation history to the classifier lives entirely in memory. Restarting the backend clears all warm state, so the first events after a restart are classified without conversational context until new turns accumulate.

The backend is a single Go process with an in-process WebSocket hub and SQLite storage. Horizontal scaling is not supported; running multiple backend instances would require a shared pub/sub layer and connection registry that do not exist.

The Claude Code plugin handles HITL approvals inline in the terminal, not through the dashboard review queue. Operators expecting to approve Claude Code actions from the web UI will not see them there.

The SDK persists session_id per working directory under ~/.adrian/projects/. In containerized environments without a mounted home directory, this state is lost between runs, which can surprise teams expecting stable session correlation.

The TypeScript SDK’s OpenAI integration covers only chat.completions and responses APIs. Other OpenAI client methods pass through uninstrumented. The Python SDK’s Anthropic integration gates terminal methods like get_final_message() but does not gate raw iteration over content_block_stop events.

Finally, the dashboard is intentionally grayscale. The Tailwind configuration maps semantic colors (accent, danger, warn) to monochrome ink tokens, so don’t mistake the lack of color for a rendering bug.

What to take away

Adrian demonstrates a practical pattern for agent security: separate the classifier from the enforcement policy. The backend returns a MAD code verdict; the operator decides whether that verdict alerts, blocks, or triggers human review. This separation lets you tune operational risk without retraining or re-prompting the model.

The most transferable technique is the untrusted-content wrapping. By tagging all agent- and user-supplied text with a per-conversation GUID, Adrian prevents injected instructions from reaching its own classifier. Any project that pipes agent output into a downstream LLM should adopt this pattern. The sliding-window-with-per-key-lock design is also worth studying: it gives the classifier conversation context while keeping same-key classifications serialized and different keys parallel.

Be clear about the boundaries. Adrian does not protect against attacks that never surface as tool calls or reasoning traces, and it cannot stop an agent that is already compromised at the model level. The classifier’s real-world latency and accuracy are not yet published, and the M1 severity code in the frontend does not map to the backend taxonomy. The backend is single-process, so high-throughput or multi-instance deployments are unsupported today.

The project is Apache-2.0 licensed and actively developed. Source, SDKs, and the Claude Code plugin are available at github.com/secureagentics/Adrian.

Further diagrams

Layers

Control flow

Takeaways