Skip to main content

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.

Your hook's reason is recorded as you send it

A 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.

Rewriting a compressed body

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​

A hook with no operations: governs everything

That 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 configdoes 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 operationyes, 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:

deliveryyour hook is calledcan deny block the response?large responses
whole (default)once, with the complete responseyesrefused above max_body (8 MiB by default)
batchedseveral times, as the response arrivesonly the rest of it: earlier pieces have already reached the agentno 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 fieldwhat it is
correlation_idthe same on every piece of one response
sequencethe piece's position in the response, starting at 0
finaltrue 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 data

On 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.

Sizing your service​

Off HTTP, a hook is sent whatever the transport accepted, up to your deployment's message size limit. Allow for encoding when you size timeouts and memory: protojson base64-encodes bytes fields, and body is base64-encoded again, so a 50 MB checkpoint arrives as roughly 89 MB. A hook that cannot keep up will hit its timeout, and on_error applies.

A hook's response may be at most four times the size of the request AgentVisor sent it, or 4 MiB, whichever is larger.