Custom Adapter
If your agent framework does not yet have a built-in AgentGuard adapter, you can add a custom adapter and connect it to Guard.
This page sits alongside the LangChain, AutoGen, and OpenAI Agents SDK integration pages, but it focuses on one thing: how to implement a new adapter yourself.
What this page is for
There used to be a separate Agent Adapter Contract page. Its purpose was very narrow: define what an adapter is responsible for inside AgentGuard, which parts must be implemented by the adapter, and which parts can be reused from BaseAgentAdapter.
Instead of keeping that as a separate page, this section folds the same ideas into Custom Adapter: first clarify the adapter boundary, then move into implementation patterns, example code, and Guard integration.
Adapter responsibilities
An adapter does not make policy decisions by itself. Its main job is to translate framework-native call sites into bindings that AgentGuard can process uniformly:
- locate tool invocation entry points
- locate LLM invocation entry points
- describe them as
ToolBinding/LLMBinding - hand them off to
BaseAgentAdapterfor shared patching and event wiring
In practice, a new adapter usually needs to implement at least:
can_wrap(...): decide whether the adapter applies to the agentgettools(...): return tool-call bindingsgetllm(...): return model-call bindingsgenerate(...): provide a best-effort single-turn execution entry
What the base class already handles
On the Python client, custom adapters are usually built on top of BaseAgentAdapter.
In most cases, you do not need to reimplement patchtool(...) or patchLLM(...). The base class already:
- calls
gettools(...)/getllm(...) - stores the results in
self.toolslist/self.llms - wraps and installs each binding automatically
- emits the shared runtime guard events around each call
So for many frameworks, the real adapter work is not writing wrapper logic from scratch, but translating framework-native objects into binding lists.
What ToolBinding and LLMBinding represent
Each item returned by gettools(...) is a ToolBinding. Its three core fields are:
- tool name
name - parameter description
parameters - the real callable
callable
It can also include:
owner/attr: where the callable is mounted on an objectcontainer/key: where the callable lives inside alist/dicttool/capabilities: extra tool metadatainstaller: a custom installation hook when the default patch flow is not enough
Each item returned by getllm(...) is an LLMBinding. Its core fields are:
labelcallable
and it can also carry:
owner/attrcontainer/keyinstaller
Minimal implementation steps
- inherit
BaseAgentAdapter - implement
can_wrap(...) - implement
gettools(...) - implement
getllm(...) - implement
generate(...) - override normalization only if your framework needs it
- use
adapter.attach(agent, guard)or add a convenience method onGuard
How to implement can_wrap(...)
The goal of can_wrap(...) is not to guess whether an object might be runnable. Its job is to reliably decide whether this adapter is the right adapter for the given agent object.
Good matching strategies usually include:
- checking whether
type(agent).__module__contains a stable framework signature - checking whether the agent exposes a stable set of key attributes
- combining module identity and object structure for a stricter match
For example:
def can_wrap(self, agent: Any) -> bool:
mod = type(agent).__module__ or ""
return "myframework" in mod and hasattr(agent, "tools") and hasattr(agent, "model")
A few practical guidelines:
- be conservative; avoid matching objects that belong to other frameworks
- prefer stable identifiers over temporary runtime attributes
- if multiple adapters could match similar objects, make
can_wrap(...)more specific - for fully custom project-local agents, structure-based matching alone can still be enough
You can think of can_wrap(...) as the adapter's recognizer. The more precise it is, the safer patching becomes.
How to implement gettools(...)
gettools(...) collects tool entry points and turns them into list[ToolBinding].
The core question it answers is: where is the real callable that actually executes tool logic in this framework?
Common sources include:
- tool lists like
agent.tools - name-to-tool maps like
agent.tools_by_name - registries such as
function_map - deferred registration APIs such as
register_function(...) - concrete execution methods like
func,_func,run_json,invoke,_run, orcoroutine
In simple cases, you can reuse the helpers already provided by the base class:
def gettools(self, agent: Any) -> list[ToolBinding]:
bindings: list[ToolBinding] = []
tools = getattr(agent, "tools", None)
if isinstance(tools, list):
bindings.extend(self.collect_tool_list(tools, func_attrs=("func", "_func")))
registry = getattr(agent, "function_map", None)
if isinstance(registry, dict):
bindings.extend(self.collect_function_map(registry))
if hasattr(agent, "register_function"):
bindings.extend(self.collect_register_function(agent))
return bindings
The most important design choice in gettools(...) is selecting the right patch point:
- if a tool exposes both
invoke(...)and a lower-levelfunc(...), prefer the one that preserves the real business arguments - if the public entry point wraps everything into a generic
input, patching the lower-level function often gives better guard visibility - if only the public entry point is available, patch it and recover the real arguments in normalization
- if the framework supports dynamic tool registration, patch both existing tools and the registration entry point
Each returned ToolBinding should answer three things clearly:
- what the tool is called
- which callable is the real execution entry point
- where the wrapped callable should be installed back
If the default installation logic is not enough, attach a custom installer to the binding.
How to implement getllm(...)
getllm(...) collects model invocation entry points and turns them into list[LLMBinding].
The key is to find the callable that actually sends the model request, not just a higher-level business wrapper.
Typical entry points include:
model.invoke(...)client.create(...)chat.completions.create(...)messages.create(...)create_stream(...)- deep client paths such as
_client.xxxin some frameworks
If the target object and method paths are clear, collect_llm_methods(...) is often enough:
def getllm(self, agent: Any) -> list[LLMBinding]:
model = getattr(agent, "model", None)
if model is None:
return []
return self.collect_llm_methods(model, methods=("create", "invoke", "chat"))
The label field acts as the logical name of that LLM entry point. It is useful in:
llm_inputpayloads or metadata- trace records when distinguishing different model paths
- debugging when one framework exposes multiple LLM call surfaces
Implementation tips:
- if one request flows through multiple wrappers, avoid patching it in a way that creates duplicate
llm_input/llm_outputpairs - if the framework supports sync, async, and streaming variants, collect each real call surface explicitly
- if different providers expose different client paths, branch on client type the way the AutoGen adapter does
In short, getllm(...) is how you expose the real model call site to AgentGuard.
What the normalization hooks do
Normalization converts framework-native objects into stable event payloads that AgentGuard runtime can consume consistently.
The base class already provides a minimal default implementation. If your framework mostly passes plain Python values around, you may not need to override anything.
But once a framework wraps messages, tool arguments, or results inside deeper objects, custom normalization becomes important.
normalize_llm_input(...)
This hook converts the pre-call LLM request into a normalized payload.
The default implementation usually preserves:
labelargskwargs- basic metadata
Override it when:
- the real messages live inside
kwargs["messages"],kwargs["input"], or framework-specific objects - you want to flatten message objects into a stable
{role, content}shape - you want to add extra metadata such as model name, provider name, or owner type
This directly affects what Guard sees in the llm_input event.
normalize_llm_output(...)
This hook converts the LLM return value into a normalized output payload.
The default implementation usually:
- converts values into primitives,
dict, or string representations when possible - adds basic output metadata
Override it when:
- the framework returns a complex response object that would otherwise degrade to
str(...) - you want to preserve structured fields like
content,tool_calls,response_metadata, orusage - you need to distinguish plain text output, message objects, and streaming chunks more clearly
This affects the quality and usefulness of the llm_output event.
normalize_tool_invoke(...)
This hook converts tool-call arguments into a normalized structure before execution.
The default implementation usually:
- receives already-bound
arguments - carries over
capabilities - adds basic metadata
Override it when:
- the real business arguments are nested inside
tool_call["args"],input["arguments"], or another wrapper object - the public tool entry point is too generic and direct argument binding loses useful detail
- you want to add explicit metadata such as tool source, invocation mode, or tool message id
This determines whether policy logic sees a clear structure like {command: ...} or only an opaque generic input.
normalize_tool_result(...)
This hook converts tool results or errors into a normalized post-call structure.
The default implementation usually:
- normalizes
result - passes through
error - adds basic metadata
Override it when:
- the tool returns framework-specific message objects and you want fields like
content,artifact, orstatus - you want structured error data instead of only a stringified exception
- blocked or sanitized tool results must be adapted into a framework-specific return type
This affects both the tool_result event and what after-phase guard logic can evaluate.
What the normalization return objects look like
The four normalization hooks do not return runtime events directly. They return small dataclasses first, and the patching layer then converts those into actual runtime events.
The mapping is:
normalize_llm_input(...)->LLMInputNormalizationnormalize_llm_output(...)->LLMOutputNormalizationnormalize_tool_invoke(...)->ToolInvokeNormalizationnormalize_tool_result(...)->ToolResultNormalization
A useful mental model is: the adapter first reshapes framework-native objects into a standard intermediate structure, then AgentGuard turns that intermediate structure into llm_input, llm_output, tool_invoke, and tool_result events.
LLMInputNormalization
It has two fields:
payload: Anymetadata: dict[str, Any] = {}
They mean:
payload: the actual body that will be written into thellm_inputeventmetadata: extra event metadata such as adapter name, label, owner type, and so on
The base implementation usually returns something like:
LLMInputNormalization(
payload={
"label": "chat.completions.create",
"args": [],
"kwargs": {
"messages": [{"role": "user", "content": "hello"}],
"model": "gpt-4o-mini",
},
},
metadata={
"adapter": "myframework",
"label": "chat.completions.create",
"owner_type": "Client",
"owner_module": "myframework.client",
},
)
That value is then used to build:
ev.llm_input(context, normalized.payload, **normalized.metadata)
So if you want to change what Guard actually sees as the request body, change payload. If you want to attach more call-site context, change metadata.
LLMOutputNormalization
It also has two fields:
payload: Anymetadata: dict[str, Any] = {}
They mean:
payload: the output content that will be written into thellm_outputeventmetadata: extra output metadata
The base implementation usually returns something like:
LLMOutputNormalization(
payload={
"content": "hello back",
},
metadata={
"adapter": "myframework",
"label": "chat.completions.create",
"owner_type": "Client",
"owner_module": "myframework.client",
},
)
If the output is just a plain string, it may also look like:
LLMOutputNormalization(
payload="hello back",
metadata={...},
)
That value is then used to build:
ev.llm_output(context, normalized.payload, **normalized.metadata)
So the key goal of LLMOutputNormalization is to preserve useful structure from complex provider responses instead of collapsing everything into an opaque string.
ToolInvokeNormalization
It has three fields:
arguments: dict[str, Any]capabilities: list[str] | None = Nonemetadata: dict[str, Any] = {}
They mean:
arguments: the actual tool arguments that thetool_invokeevent should exposecapabilities: capability labels such asshell,network, orfilesystemmetadata: extra metadata
The base implementation usually returns something like:
ToolInvokeNormalization(
arguments={
"command": "rm -rf /tmp/demo",
},
capabilities=["shell"],
metadata={
"adapter": "langchain",
"owner_type": "Tool",
"owner_module": "langchain.tools.base",
},
)
That value is then used to build:
ev.tool_invoke(context, tool_name, normalized.arguments, capabilities=normalized.capabilities, **normalized.metadata)
The most important field here is arguments. If this is poorly normalized, plugins may only see a vague generic input instead of the real structure such as {command: ...}, {url: ...}, or {body: ...}.
ToolResultNormalization
It has three fields:
result: Anyerror: str | None = Nonemetadata: dict[str, Any] = {}
They mean:
result: the tool return value after executionerror: the error string when execution failsmetadata: extra metadata
The base implementation usually returns something like:
ToolResultNormalization(
result={
"stdout": "done",
"exit_code": 0,
},
error=None,
metadata={
"adapter": "myframework",
"owner_type": "ShellTool",
"owner_module": "myframework.tools",
},
)
If the tool raises, it may instead look like:
ToolResultNormalization(
result=None,
error="permission denied",
metadata={...},
)
That value is then used to build:
ev.tool_result(context, tool_name, normalized.result, error=normalized.error, **normalized.metadata)
So result controls what after-phase guard logic can inspect, while error controls what failure information Guard and trace records can see.
A simple way to remember them
You can summarize the four dataclasses like this:
LLMInputNormalization:payload + metadataLLMOutputNormalization:payload + metadataToolInvokeNormalization:arguments + capabilities + metadataToolResultNormalization:result + error + metadata
Where:
payload/arguments/resultare the main event bodiescapabilitiesis tool-specific risk labelingerroris tool-result-specific failure informationmetadatais supplemental context that can travel with every event
When you should override normalization
A simple rule of thumb is:
- if the default implementation already produces clear, stable, structured event payloads, keep it
- if the default implementation loses critical arguments, collapses everything into strings, or fails to preserve framework semantics, override it
In practice, the most common first overrides are:
normalize_tool_invoke(...)normalize_llm_output(...)
because many frameworks either wrap tool arguments too aggressively or return overly rich LLM response objects.
Python example
from typing import Any
from agentguard.adapters.agent.base import BaseAgentAdapter, LLMBinding, ToolBinding
from agentguard.schemas.context import RuntimeContext
from agentguard.utils.errors import AdapterError
class MyAgentAdapter(BaseAgentAdapter):
name = "myframework"
def can_wrap(self, agent: Any) -> bool:
return hasattr(agent, "tools") and hasattr(agent, "model")
def gettools(self, agent: Any) -> list[ToolBinding]:
bindings: list[ToolBinding] = []
tools = getattr(agent, "tools", None)
if isinstance(tools, list):
bindings.extend(self.collect_tool_list(tools, func_attrs=("func", "_func")))
registry = getattr(agent, "function_map", None)
if isinstance(registry, dict):
bindings.extend(self.collect_function_map(registry))
if hasattr(agent, "register_function"):
bindings.extend(self.collect_register_function(agent))
return bindings
def getllm(self, agent: Any) -> list[LLMBinding]:
model = getattr(agent, "model", None)
if model is None:
return []
return self.collect_llm_methods(model, methods=("create", "invoke", "chat"))
def generate(
self,
agent: Any,
messages: list[dict[str, Any]],
context: RuntimeContext,
) -> Any:
_ = context
fn = getattr(agent, "invoke", None) or getattr(agent, "run", None)
if callable(fn):
return fn(messages)
raise AdapterError("myframework agent exposes no invoke/run")
How to plug it into Guard
Option 1: use the custom adapter directly
For a project-local integration, instantiate the adapter and call attach(...) directly:
from agentguard import Guard
adapter = MyAgentAdapter()
guard = Guard(...)
patched = adapter.attach(agent, guard)
print(patched)
Option 2: add a convenience method on Guard
If you want a first-class API like guard.attach_langchain(agent), add a thin wrapper in agentguard/guard.py:
def attach_myframework(
self,
agent: Any,
*,
wrap_tools: bool = True,
wrap_llm: bool = True,
) -> dict[str, Any]:
from agentguard.adapters.agent.myframework import MyAgentAdapter
return MyAgentAdapter().attach(
agent,
self,
wrap_tools=wrap_tools,
wrap_llm=wrap_llm,
)
Then your application code can simply call:
guard.attach_myframework(agent)
If you want to contribute it as a built-in adapter
If you want to upstream the adapter into this repository, you will usually also update:
src/client/python/agentguard/adapters/agent/myframework.pysrc/client/python/agentguard/adapters/agent/__init__.pysrc/client/python/agentguard/guard.pywithattach_myframework(...)tests/test_attach_adapters.pywith a minimal attach test
A simple verification checklist
At minimum, verify these three things:
adapter.attach(agent, guard)returns a sensible patch result- tool calls produce
tool_invoke/tool_result - model calls produce
llm_input/llm_output
If those three pass, the new adapter is usually wired into AgentGuard runtime enforcement correctly.