Raw Content Hooks
raw_request and raw_response send a service you run the payload exactly as it was sent or
received, and act on what the service returns. On an HTTP call, that is the headers and the
body as bytes. On any other path, it is the message the agent sent or received.
Raw hooks do two jobs:
- They govern the paths the chain does not reach. The chain runs only on the agent's HTTP calls. On every other path, such as an MCP tool call, a raw hook is the only middleware there is.
- They do what the chain cannot do on HTTP. The chain sees only the conversational text an extractor pulls out of an LLM request, and can edit only that text. It never sees the headers, and it treats a body no extractor recognizes as read-only. A raw hook sees the whole request and can rewrite any part of it.
They are configured beside chain:, not in it. The chain's checks on what a middleware returns
do not apply to a raw hook, so your service is responsible for returning a correct payload.
content_middleware:
raw_request:
url: http://127.0.0.1:9900/request
timeout: 5s
on_error: deny
operations:
- agentvisor:http:request
- agentvisor:mcp:tool:call
- agentvisor:a2a:task:send
raw_response:
url: http://127.0.0.1:9900/response
on_error: deny
exclude_operations:
- content:stream:chunk # can fire per token
- content:checkpoint:* # loaded on every run
That inspects outbound LLM calls, tool calls and A2A tasks on the way out, and on the way back everything except the two noisiest families.
The rules for url are in the reference.
timeout bounds one call to your service and defaults to 5s.
Each hook is scoped on its own: what raw_request lists says nothing about raw_response. An
operation must be hooked in that hook's direction, or AgentVisor refuses to start; the
reference shows which direction each operation
is hooked in.
Each hook runs first in its own direction, so what it produces is what everything downstream interprets — including the chain, which still governs a rewritten request.
Where to run your hook
Run it on loopback, or behind https with a publicly-trusted certificate.
A raw hook is handed more than the chain is. On HTTP egress it runs before credential
substitution and sees the agent's symbolic mav-tok-… token, never the real key. Off HTTP
nothing is substituted: a store value, a tool result or a run's input reaches your service
verbatim, secrets included.
A raw hook also has no tls: or headers: settings yet. It cannot authenticate itself to your
service, verify a certificate issued by a private CA, or present a client certificate, and your
service's reply is trusted as-is. A sidecar on 127.0.0.1 needs none of those. (An
http checker has both settings and can run anywhere.)
What your hook receives
Every call carries the same top-level identity fields: operation, resource, phase,
principal, thread_id, run_id, agent and agentvisor_id. What else arrives depends on
whether the path is HTTP.
principal carries every claim in the caller's token, such as email and group membership, not
only the subject. An http checker
is sent the subject alone by default. A raw hook has no such setting, so run it where that
identity may go.
On HTTP
method, url and headers describe the call, and body is its bytes, base64-encoded —
compression included, nothing decompressed or normalized. A response adds the upstream's
status:
{
"operation": "agentvisor:http:request",
"resource": "mrn:agentvisor:http:api.openai.com/v1/chat/completions",
"phase": "request",
"principal": {"claims": {"sub": "alice", "email": "alice@corp.test"}},
"thread_id": "…", "run_id": "…", "agent": "…", "agentvisor_id": "…",
"method": "POST",
"url": "https://api.openai.com/v1/chat/completions",
"headers": {"Content-Type": ["application/json"]},
"body": "<base64 of the request body>"
}
Everywhere else
body carries the message, as JSON — in a shape that depends on the operation (see the
reference), then base64-encoded like an HTTP body. The
whole message is sent, not a selection of fields, so which parts of your agents' traffic are worth
governing stays your decision.
method, url and headers are absent. They describe an HTTP message and nothing else has
one. For example, an MCP tool call:
{
"operation": "agentvisor:mcp:tool:call",
"resource": "mrn:agentvisor:mcp:filesystem/read_file",
"phase": "request",
"principal": {"claims": {"sub": "alice", "email": "alice@corp.test"}},
"thread_id": "…", "run_id": "…", "agent": "…", "agentvisor_id": "…",
"body": "<base64 of {\"serverName\":\"filesystem\",\"toolName\":\"read_file\",…}>"
}
The document inside body differs per operation and always will — an MCP tool call's request and a
run's input are nothing alike — so branch on operation and expect the shape that operation
carries. For content:run:input and content:run:output it is the run's input or output directly,
and for content:stream:chunk it is {"chunk_type": …, "data": …}; every other operation sends its
RPC message as protojson.
A field the sender left unset is absent rather than present-and-zero, so "the caller did not ask for this" stays distinguishable from "the caller asked for the zero value".
When an MCP tool call fails to reach its server, the reply your hook sees on the response phase
carries isError and the server's error text in errorMessage, as for a tool error the server
reports itself. Other errors from an MCP server or another agent, which arrive with no reply for a
hook to read, reach the agent only as what failed, such as MCP read resource failed; the
server's own text is kept in the host log.
What your hook returns
{"action": "deny", "reason": "customer record in tool arguments"}
action is permit, deny or modify. Any failure — timeout, a status other than 200,
unparseable body, unrecognised action — is an error, handled by on_error. It defaults to deny,
so an unreachable hook blocks traffic rather than letting it through uninspected. With allow, the
call goes ahead and the audit record lists your hook in failed_open, at warning severity, so a
call your hook never looked at cannot pass for one it approved.
Redirects are not followed. The url you configure is where the raw payload is allowed to go, so
a 3xx is a failed call, and the error says to set url to the final address.
If the agent goes away while your hook is still deciding, the call is dropped whatever on_error
says. The audit record marks it not_inspected rather than blaming your hook. Two cases are not
refusals: a run attempt cancelled while content:run:output is being decided ends as a cancelled
attempt, which Temporal may retry, and a stream chunk whose own call was cancelled is dropped
without ending the run.
If AgentVisor cannot render a payload as JSON, such as a run result holding a NaN, your hook is
not called. on_error does not apply, because your hook did not fail, so the payload is not
refused: this fails open, even under on_error: deny. The audit record marks it
not_inspected, so you can alert on it.
reason is recorded as you send itA deny reason from your hook goes into the audit record exactly as you return it, and it is also
what the agent is told. Name the rule that matched, not the text it matched.
Only an HTTP call or its reply can be rewritten. On agentvisor:http:request, a modify
verdict carries replacement headers, body (base64), or both; each is applied only when
present.
AgentVisor does not re-compress a body your hook rewrites, or adjust Content-Encoding for it.
If your hook rewrites a compressed body, it must compress the replacement the same way, or
remove Content-Encoding from the headers it returns.
Every other path is permit-or-deny. A modify there is ignored: AgentVisor logs a warning and
the call proceeds with the original payload, rather than failing the agent's call over a hook
error.
No verdict can change the destination. The Policy Engine authorized the call for this URL, so a hook cannot send it elsewhere. To redirect, deny the call; the agent can then make a new call, which the Policy Engine evaluates.
Scoping a hook
operations: governs everythingThat includes content:run:input, which is reached by every run, whether or not the agent
makes an outbound call. A hook meant for your LLM traffic will also be called once per run.
Scope a hook by naming what you want:
raw_request:
url: http://127.0.0.1:9900/request
operations:
- agentvisor:http:request # or agentvisor:mcp:*, etc.
or by naming what you don't:
raw_request:
url: http://127.0.0.1:9900/request
exclude_operations:
- content:telemetry:log # everything else, including future paths
The two behave differently when a later release hooks a new operation. Given operations x
and y hooked in the current release, and a new operation z added in a new release:
| your config | does the hook see z after the upgrade? |
|---|---|
no operations:, exclude_operations: [x] | yes: with no operations: list, the hook runs on every hooked operation except x |
operations: [y] | no: it keeps seeing only y until you add z to operations: |
operations: with a wildcard that matches z, such as agentvisor:mcp:* when z is an MCP operation | yes, unless exclude_operations: also matches z |
In every case, a hook sees z only if z is hooked in that hook's direction. Leave out
operations: if you want new operations inspected by default. List them if you want to review
each one before your hook receives it.
An exclusion trims a selection — operations: [agentvisor:mcp:*] with
exclude_operations: [agentvisor:mcp:tool:call] is how "all MCP except tool calls" is written.
Naming the same operation exactly in both lists is a contradiction, and AgentVisor refuses to
start rather than guess which you meant.
resources: narrows by destination, as on a chain entry. Both are checked before anything is
held, so a call the hook does not admit passes untouched.
Scopes are checked at startup
AgentVisor refuses to start if either list names an operation that is not hooked, or one that is
hooked only on the other phase. raw_request runs only on the request phase and raw_response
only on the response. Operations such as agentvisor:http:request and
agentvisor:mcp:tool:call are hooked on both, but others carry content one way only, such as
content:checkpoint:save (request) and content:checkpoint:load (response). The
hooked operations table shows each operation's
phase.
A wildcard passes if it matches at least one operation on the hook's phase. For example,
raw_request with content:checkpoint:* covers save and write. The startup log shows which
operations it matched.
Holding or streaming HTTP responses
An HTTP response can be large, and it can arrive in pieces. The delivery option on
raw_response decides whether AgentVisor holds the whole response before calling your hook, or
calls your hook on each piece as the response streams to the agent. The choice decides whether
your hook can block a response:
delivery | your hook is called | can deny block the response? | large responses |
|---|---|---|---|
whole (default) | once, with the complete response | yes | refused above max_body (8 MiB by default) |
batched | several times, as the response arrives | only the rest of it: earlier pieces have already reached the agent | no size limit |
whole holds the entire response in memory, so your hook sees all of it before the agent sees any.
That is what lets a deny block the response, and it is why the size is bounded. A response larger
than max_body is refused rather than forwarded uninspected. Its 8 MiB default is far below the
proxy's stream_max_body_size (200 MiB) because the two limit different things: the proxy's limit
bounds what it forwards, while max_body bounds what AgentVisor holds in memory.
If whole would refuse responses the agent needs, for example a file the agent downloads, don't
raise max_body. Instead, list under resources: only the destinations whose responses you
want to inspect (see Scoping a hook). Responses from anywhere else are not
held, so they stream to the agent as before, up to the proxy's own limit:
raw_response:
url: http://127.0.0.1:9900/response
resources:
- "mrn:agentvisor:http:api.openai.com/*" # held and inspected
# everything else streams as before
Where large responses must be inspected, use batched. It streams a response of any size, but a
deny only cuts the response short: the agent keeps the pieces it already has. Each call carries a batch object so a stateless service
can tie the pieces of one response together:
batch field | what it is |
|---|---|
correlation_id | the same on every piece of one response |
sequence | the piece's position in the response, starting at 0 |
final | true on the last piece |
batch_bytes is the least AgentVisor gathers before each call, and batched requires it. A call
can carry up to 4 KiB more, because the reply is read in pieces of up to 4 KiB and everything held
goes in one call once it reaches batch_bytes. A value under 4 KiB therefore means about one call
per piece read.
max_body does not apply to batched, which holds nothing, so AgentVisor refuses to start if
both are set.
Either way, a response gets one audit record. A response your hook refuses is recorded as your
refusal, and still metered: the provider billed it, so the chain's response phase still runs,
token_meter reads its counts, and they go on that record. Other checks' verdicts are not
recorded, since your hook already decided the reply. If the agent stops waiting while your hook is
deciding, the record marks the response not_inspected, names your hook if it had already passed
earlier pieces, and is metered the same way. When the reply was cut off before the provider sent
its counts, usage_absent says so.
Off HTTP there is no delivery. Each message is already whole when it reaches the hook, so a deny
on the response phase blocks it: the result is withheld rather than truncated.
Path by path
The run boundary
content:run:input is what the agent was asked to do, and content:run:output is what it
answered. The other paths see the agent's individual calls; these are the only ones that see the
run as a whole, so they are where a tool call can be tied back to what was asked. The body
is the run's input or its result:
{"messages": [{"role": "user", "content": "Calculate sqrt(144) + 10"}]}
The agent's environment variables, where its API keys live, are not included.
content:run:input is inspected once, when the run is created and after the Policy Engine has
allowed it. A deny refuses the request with your hook's reason, and no run starts: the HTTP API
answers 403, and MCP and A2A return their own refusal. If your hook cannot answer,
on_error: deny, the default, refuses the run the same way, with the reason raw hook failed. A
run started over MCP or A2A on a new thread still leaves that thread behind when it is refused, as
it does when the Policy Engine refuses it.
content:run:output is inspected when the agent returns its result. A deny ends the run with your
hook's reason and withholds the result. Anything the run already streamed or saved stays where it
is; see Streamed output.
The run's error keeps at most 4 KiB of a reason, marked as truncated, here and when a stream chunk ends the run, since it is stored with the thread. The audit record keeps the whole reason.
Neither can be rewritten: a modify verdict is ignored and the original is used.
phase is the direction of the RPC, not of the dataOn HTTP, phase: "request" is content leaving the agent. On content:run:input it is
content arriving at the agent — the request phase of that call is ingress.
A hook that assumes request means outbound will misread this path. Check operation, not
phase alone.
Streamed output
content:stream:chunk is the agent's reply on its way to whoever started the run, a chunk at a
time, and agentvisor:a2a:task:stream is each update from another agent. Each is inspected as it
goes, with nothing held back.
What a chunk holds is the agent's choice. LangGraph's default updates mode sends one complete
node output per chunk, and message and state chunks are whole too, so a per-chunk check sees
everything it needs. A token-mode stream (LangGraph messages, or stream_token) is the
exception: anything that spans tokens — an SSN, an API key — is split across calls, and no
per-chunk check will match it. On those streams, use this operation to observe and meter, not to
detect.
A refusal stops the stream on both. On an A2A update it ends that stream: earlier updates
have already reached the agent, so it is advisory in the same way batched delivery is. On a stream
chunk it ends the run: the denied chunk and anything after it are never delivered, the agent is
stopped, and the run fails with the hook's reason. Chunks delivered before the deny stay delivered.
If your agent sends chunks from another thread or task, one can still be with your hook when the run
ends. It is decided before the run's result is: if your hook refuses it, the run fails and its
result is withheld. With content middleware configured at all, a chunk that arrives after the run
has returned is dropped, whether or not this operation is hooked. An agent that sends its chunks in
line, as the SDK does, finishes only after each has been decided.
Nothing at the run boundary takes back a streamed reply. A deny on content:run:output
runs when the run ends, after every chunk has been inspected on its own and delivered.
It withholds the run's return value, but the caller already has the chunks, and the stored chunks
stay retrievable until the thread's next run. To refuse an LLM's reply before the agent can stream
it, hook the LLM call itself: raw_response with delivery: whole denies before the agent sees
any of it.
content:stream:chunk can fire per token, so it is an obvious exclude_operations candidate.
Persistence
The store, thread-KV and checkpoint operations are hooked where they carry content. Deletes and listings that return only keys or namespace names are not hooked.
They fire constantly. A checkpoint is saved on every graph step, so a hook on this family can be called tens to hundreds of times per run, compared with a handful for outbound calls. Exclude it unless you need it.
The most valuable content is not readable. A checkpoint's state and a store value are
bytes, so they arrive base64-encoded inside the protojson, and decoding them is not enough: your
hook then needs to know the agent framework's serialization, which state_type names. What is
readable is channel_values, a struct carrying the conversation state alongside the opaque blob.
Inspect that.
A refusal here fails the operation rather than silently dropping it, so the agent never believes state was saved when it was not.
Telemetry
Telemetry can carry text that the chain removed from an LLM request. If the agent is
instrumented for tracing, for example with OpenLLMetry, each LLM call also produces a trace span
describing it. The span records the prompt and completion in attributes such as gen_ai.prompt
and input.value, taken from the agent's own copy, so the text is the original. The SDK sends
spans to AgentVisor in batches, and AgentVisor forwards them to your telemetry collector. The
chain does not inspect them, so an SSN that regex_dlp removed from the request to the provider
can still reach your collector in a span, unless a raw hook covers content:telemetry:spans.
A raw hook on content:telemetry:spans sees each batch decoded, not as raw OTLP bytes, so the
attributes are readable without a protobuf library. The hook cannot redact a span, but it can
deny the batch: the spans are dropped and the agent's call is not affected.
Agent logs can carry the same text, less of it per message but in far more messages. If your hook
covers content:telemetry:log, expect it to be called often. AgentVisor inspects only the agent's
logs, not its own, and only lines at or above the host's log level: a line the host would discard
goes nowhere, so your hook does not see it.
WebSocket upgrades
agentvisor:http:upgrade hooks the handshake — the GET with Upgrade: websocket, which
arrives with method, url and headers like any HTTP request. A deny refuses the connection
before it opens. Messages sent over the open socket are not inspected, so this controls which
endpoints an agent may open a socket to, not what it sends over one. A modify is ignored.
The chain does not run on a handshake, so an upgrade request that is not a GET with no body
is refused, and recorded as a denial, before your hook is asked. A reply that does not switch
protocols reaches the agent with its status and headers but not its body.