Writing a Content Checker
The http chain type is how you bring your own control into the chain — a DLP service, a
prompt-injection classifier, an allow/deny policy of your own. AgentVisor POSTs the spans of each
LLM request to your URL and acts on what you return.
A checker is a chain entry, configured under
content_middleware.chain like the built-in
types, so it runs only where the chain runs: on the agent's HTTP requests.
What AgentVisor sends your checker and what it accepts back are a published contract, versioned
by the X-AgentVisor-Content-Protocol header (currently 1). The full schema is in the
Content Middleware Reference.
What your checker receives
{
"operation": "agentvisor:http:request",
"resource": "mrn:agentvisor:http:api.openai.com/v1/chat/completions",
"phase": "request",
"principal": {"claims": {"sub": "alice"}},
"thread_id": "…", "run_id": "…", "agent": "…", "agentvisor_id": "…",
"spans": [
{"role": "user", "text": "my ssn is 123-45-6789", "ref": "/messages/1/content"}
],
"fields": {"url": "https://api.openai.com/v1/chat/completions", "method": "POST"}
}
Each entry in spans is one piece of the request's conversational text: its role, as the provider
wrote it (system, developer, user, assistant, tool or function), or empty where there is
none, as for Anthropic's top-level system prompt or OpenAI's prediction.content, the text, and a
ref — an RFC 6901 JSON Pointer to where it sits
in the request, or for an object key a key: ref (below). Return edits against ref; you never
need to interpret it beyond that prefix, and the same three fields arrive whichever provider the
agent called.
A request no extractor recognizes arrives, under the default on_unknown_format: whole_body, as
a span whose ref is empty ("") and whose role is empty, holding the whole body. A JSON body
arrives as two such spans: the body as sent, and its text with escapes decoded. Tool-call arguments
that name a key twice also arrive with a decoded span of this kind. A span with an empty ref is
read-only, since an edit to it cannot be written back, so answer deny when you find something in
one.
A tool call arrives as one span per value. For an Anthropic tool call the ref points into the
tool_use input, as in /messages/1/content/0/input/to. For an OpenAI tool call it continues below
the arguments string, as in /messages/2/tool_calls/0/function/arguments/to, because the arguments
are JSON carried inside that string. Edit either like any other ref. A number arrives as its text,
with "number": true, and cannot be edited, since the replacement would not be a number: an edit to
one denies the call, whatever the entry's on_error, so answer deny for a number your checker
would otherwise redact. Each key arrives as a span of its own just before its value. Its ref
starts with key: and names the object and the key's place among its keys, as in
key:/messages/1/content/0/input#2; the reference says how
keys are counted. It never contains the key itself, so a finding that names it does not copy the key
into the audit record. A key cannot be edited, since that would rename it: an edit to one denies the
call, whatever the entry's on_error, as your checker has said the key must not go out. To stop a
key that matches, return deny.
fields carries what AgentVisor knows about the call. It is kept apart from the top-level keys,
so the top-level shape stays the same as more is added and a checker written today keeps
working.
The reference lists every key and the phase it arrives on.
On the response phase
A checker is called on the request only, unless you opt in to the response:
options:
url: https://usage.internal/record
observe_response: true
On the response, a checker is called once the reply has reached the agent. It receives an empty
spans list and fields, and its verdict is recorded but not enforced: a deny or an edit
there changes nothing the agent receives. The audit record marks it enforced: false, and the first
time a checker returns one AgentVisor logs a warning saying so. A response-phase deny does not stop
the entries after it, so token metering still runs.
Use the response phase to observe, for example to record usage your own way. To block a reply,
use a raw_response hook with delivery: whole.
What your checker returns
{
"action": "modify",
"reason": "pii detected",
"edits": [{"ref": "/messages/1/content", "text": "my ssn is <SSN>"}],
"findings": [{"ref": "/messages/1/content", "type": "us_ssn", "confidence": 0.85}],
"fields": {"detector_version": "2.1", "policy_bundle": "abc"}
}
action—permit,deny, ormodify. Anything else is refused.edits— only honored onmodify. Arefthat matches no span is an error, and an emptyref(the whole payload) is refused. An edit or parameter edit that cannot be used counts as your checker failing, handled by itson_error; underallow, none of that verdict's edits or parameter edits are made.findings— recorded whatever the verdict. Apermitcarrying findings is the record of something noticed and deliberately allowed.parameter_edits— change a generation parameter rather than content, e.g.[{"ref": "/max_tokens", "value": 4096}]. Only honored onmodify. Because a parameter is a number, the audit record shows the new value. A token limit is only ever lowered, and on a request that sets none an edit adds one. The parameters you can set, and the rules for each, are in the reference.fields— your own annotations, such as a detector version. They are recorded on the audit entry aschecker_fieldsand go nowhere else: later checks in the chain do not see them, and nor does the provider. The record holds thefieldsof every checker in the chain together, and a later checker's key replaces an earlier one's of the same name, so give your keys a prefix of your own (for examplepresidio.version). See the warning below.
You may return the whole object you were handed with fields added. Anything AgentVisor does not act on is ignored rather than rejected.
That includes the fields AgentVisor sent you, so returning them is safe. The audit record keeps
the method, the endpoint (never the URL's query string) and token counts in fields of its own,
and every key AgentVisor sent is dropped from your returned fields. The audit entry's
checker_fields holds only the keys you added.
edits, not by changing spansIf your checker changes the spans it received and returns them, AgentVisor ignores the
change. Text is changed only by edits. AgentVisor logs this once per chain entry, so a mistake
shows up on the first request:
content checker returned modified spans, which are ignored; return edits instead
AgentVisor never copies message text or tool-argument values into the audit record itself (see what
it does record). Your checker's reason,
each finding's type and ref, and your fields are recorded exactly as you return them, and a
deny reason is also what the agent is told. Use them for rule names, detector versions, policy
bundle ids and your own latency. Putting matched text in any of them puts it in your audit stream.
Keep it small: the fields of every checker on a record are capped together at 4 KiB once encoded.
Past that the record holds {"truncated": true, "bytes": N} in place of all of them, so one large
checker's annotations replace every checker's.
A reference adapter
Roughly twenty lines, here wrapping Presidio:
@app.post("/inspect")
def inspect(req: dict = Body(...)):
edits, findings = [], []
for span in req["spans"]:
results = analyzer.analyze(text=span["text"], language="en")
if not results:
continue
findings += [{"ref": span["ref"],
"type": r.entity_type.lower(),
"confidence": r.score} for r in results]
if span["ref"] == "" or span["ref"].startswith("key:") or span.get("number"):
# The whole body, a key or a number cannot be redacted, so the
# call is refused.
return {"action": "deny", "reason": "sensitive data that cannot be redacted",
"findings": findings}
clean = anonymizer.anonymize(text=span["text"], analyzer_results=results)
edits.append({"ref": span["ref"], "text": clean.text})
return {"action": "modify" if edits else "permit",
"edits": edits, "findings": findings}
The adapter never needs to know which provider the agent called: it reads each span's text and
returns edits against the span's ref, refusing the call when the match is somewhere it cannot
redact. Body is FastAPI's (from fastapi import Body); without it FastAPI reads req as a query
parameter.
chain:
- name: presidio-adapter
type: http
on_error: deny
timeout: 5s
options:
url: https://dlp.internal/inspect
headers:
- name: Authorization
value_env: DLP_TOKEN
tls:
ca_file: /etc/ssl/internal-ca.pem
Unlike a raw hook, a checker can authenticate itself to your service with headers and verify it
against a private CA with tls, so it need not run on the same host.
Put credentials in a header rather than the URL. The rules for url are in the
reference.
Two entries of this type with different URLs are two middleware of one type, told apart in the
audit record by their name.
What identity your checker receives
By default, only the subject:
"principal": {"claims": {"sub": "alice"}}
That matches what the audit record keeps, and is all most checkers need — a DLP adapter needs no identity at all. Because the payload leaves the host, possibly to a vendor, the default sends as little identity as possible.
Widen it per entry when a checker needs more:
options:
url: https://budget.internal/check
principal_claims: [sub, groups]
Exactly the claims you name are sent. principal_claims: [] sends no principal at all. Names are
matched against the claims in the caller's token, including namespaced ones like
https://corp.test/roles; a name that matches nothing is left out, so a typo sends less
identity, never more. When none of them matches, no principal is sent.
Failure is a denial
Any failure — timeout, non-2xx status, a response over 4 MiB, unparseable body, unrecognised action
— is an error, handled by on_error. It defaults to deny: a checker that could not answer has not
approved the call. The entry's timeout bounds the whole call to your checker, and there is no
separate timeout under options: setting one refuses to start.
Redirects are not followed. The url you configure is where the payload and your headers are
allowed to go, so a 3xx is a failed call, and the error says to set url to the final address.
Retry is off by default and not allowed on the sync lane, because a retry uses up the
timeout the agent is waiting on. To retry, move the entry to lane: async — its verdict then
arrives after the request has gone, so it cannot deny.