Grey Box agent monitoring

Docs

Grey Box scores an agent's proposed action before it executes, from the transcript alone. Two monitors share one interface. Pick by how much your deployment differs from your labelled data.

Text monitor (TextMonitor)Reader monitor (ReaderMonitor)
What it isTF-IDF on context and action + logistic regressionQwen3-8B reads the transcript; a linear probe on its activations
Hardware, latencyCPU or browser; 0.18 ms per action1 GPU (≈16 GB); 41 ms per action
Cost per action≈ $0your GPU time
Same tools and policies as trainingas good as the reader (Claude: 0.997 vs 0.991)0.99
Unseen task families0.9500.969
Unseen benchmark (AgentDojo)0.50 (chance)0.78
Unseen tasks, explicit rules over facts (τ²-bench, closed agents)0.520.54
Train on your labelsin the browser (trainer) or PythonPython (features + scikit-learn)

AUROC from preregistered tests T4–T12 (evidence). A monitor can miss a policy that is not like the policies in its training data. In one test (T10), the monitor ranked such violations high but escalated none at a 5% budget, until we added 20–40 labelled steps of that policy. In another (T12), a policy with similar trained policies transferred with no labels.

What neither monitor does. On τ²-bench (T21), a violation breaks a written rule that depends on facts of the case: a booking older than 24 hours, a cabin, an amount. Trained on other tasks, both monitors were near chance on new tasks (within-tool AUROC: word counts 0.52, reader 0.54). With the same tasks in training, word counts scored 0.99, because they memorize entities (reservation codes, names, dates) of those tasks. So: word counts need labels of the same tasks and entities; neither monitor checks a numeric, date or recipient rule on values it has not seen. For such rules, write the check in code, or hold the action for review. A zero-shot reasoning model that reads the written policy (T23, Qwen3-32B, no labels) separated violating from allowed calls of the same tool on four closed frontier agents (AUROC 0.77), but at a 5% review budget it caught fewer violations than the reader and than the tool alone. Use it as a second opinion on held actions, not as the only monitor.

Install

pip install "greybox-monitor[server]"     # core + HTTP server (scoring needs only numpy); then: import graybox
pip install "greybox-monitor[text]"       # + train the text monitor in Python
pip install "greybox-monitor[reader]"     # + the reader monitor (GPU)

The package is greybox-monitor on PyPI; you import graybox. The PyPI projects named graybox and greybox are unrelated: do not install them. Pin a version (greybox-monitor==0.7.0); the changes are in the changelog.

Read a model's latent geometry (no training)

The hidden coordinates of a closed model cannot be read from its outputs (a gauge symmetry). The geometry of its states can. Ask yes/no questions about each context, each in its own call. In the angles 2 arcsin √P(yes), the Fisher–Rao distance between two contexts' answers is plain Euclidean distance. By Čencov's theorem it is the only output geometry of this kind. No model is trained.

pip install "greybox-monitor[geometry]"
graybox-geometry contexts.txt --provider openai --model MODEL --out geometry.json            # with logprobs: exact
graybox-geometry contexts.txt --provider anthropic --model MODEL --samples 8 --out geometry.json   # sampled: cross-fit

The command prints the number of calls and a cost estimate, and it asks before it runs. The output holds the distances between your contexts, 2-D coordinates (classical scaling, exact up to a rotation) and a check of how Euclidean the estimate is. With log-probabilities it reads P(yes) in one call per question. Without them it samples two independent halves and uses the cross-fit, which is unbiased for every P(yes).

from graybox.geometry import interrogate, crossfit_distances, mds, rsa
(kA, nA), (kB, nB) = [interrogate(ask, contexts, questions, samples=4) for _ in range(2)]   # ask(context, question) -> text
D = crossfit_distances(kA, nA, kB, nB)
X, eigenvalues = mds(D, 2)
rsa(D, other_D)          # compare two geometries without fitting anything

What to expect. On eight open models, the answer geometry over 88 questions agrees with the hidden geometry at Spearman 0.41–0.74 (0.22–0.64 on fresh contexts). The input text gives 0.22–0.30, the model's own replies 0.22–0.44. This is the geometry that models share: a model's answers match other models' hidden geometries as well as its own, so the measurement tells you where a context sits in the common latent space, not what is unique to one model. Within one kind of context the agreement is weaker (median 0.29). Use many questions: agreement rises up to about 88 questions. To detect that a model changed (a new system prompt), sampled replies are more sensitive than answers. With full log-probabilities, graybox.geometry.recover_gram recovers the exact geometry of the final hidden states from d(d+1)/2 contexts. Closed APIs do not return full log-probabilities.

Get a monitor without a browser

Train the text monitor on your own labelled logs, in seconds on a CPU. The input is the same as for the browser trainer: OpenAI or Anthropic conversations, one per line, with a "labels" field {"call_id": 0 or 1} (example: 300 GPT conversations, 150 Claude conversations).

python -m graybox.train_text labelled_logs.jsonl --out greybox-text-monitor.json      # add --chat for agents that talk with a user
# prints the cross-validated AUROC (pooled and within tool) and the share of violations caught at a 5% budget;
# the folds hold out whole tasks (conversations with the same first user request)
python -m graybox.train_text labelled_steps.jsonl --eval-only --group-by family       # report only: hold out whole task families

The privacy filter is on by default: emails and URLs are masked before the words are counted, and terms with digits, long codes, random-looking tokens or rare words are left out of the model file. Names and company names that recur in your logs stay (the filter cannot tell a name from a word), so keep the file internal if your logs name people. --no-privacy turns it off. The filter also reduces memorization: on our GPT example, with whole task families held out, AUROC 0.916 with the filter and 0.799 without it (0.946 and 0.942 with tasks held out). An existing --out file is not overwritten without --force.

Canonical mode (--canonical, also for graybox.train_reader and in the browser trainer): the monitor reads only the decisive fields of each call. A field named like free text (body, subject, message, reason, note, ...) becomes [free text]; in other strings, quoted prose of 3 or more words and # comments are removed. Short quoted literals and the words of a command stay. The model file records it, and Guard, the server and the browser then read actions the same way. An agent then cannot move the score by rewording what it says about its own call, however many times it tries. Tool results are not changed. It is off by default because it has a measured cost (a preregistered test): on two closed agents the canonical reader caught 51–59% of the violations at a 5% budget instead of 94–98%, because a message body can be the leaked content itself. Use it only when your policies never depend on what free text says.

The estimate is the mean of 3 cross-validations with different fold assignments (the report gives the range); tasks with a violation are spread over the folds. Python and the browser use the same folds and agree to about 0.001.

Read the within-tool AUROC. If it is much lower than the pooled AUROC, the monitor mostly knows which tool is called, not whether this call breaks the rule; the report then warns. On τ²-bench grouped by task, a monitor scored 0.82 pooled and 0.49 within tool.

Or in Python: tm = TextMonitor().fit(steps, labels, groups); tm.save("greybox-text-monitor.json"), with steps from graybox.guard.conversation_steps. The file is the same format as the browser trainer's, and the model gives the same scores in both (to 10−13).

Set the cut again after a change

A cut escalates its budget only on traffic like the one it was set on. After a change of agent, prompt, tools or tasks, set it again on new, unlabelled logs (no labels needed):

python -m graybox.recalibrate new_logs.jsonl --monitor greybox-text-monitor.json --out recalibrated.json
# reports how many actions the old cut would escalate on the new traffic, and writes the new cut

In a preregistered live test, a notice that the agent is monitored moved Claude's scores: the old cut escalated 16.6% of its actions. A cut set again on its new traffic escalated 4.7% (budget 5%), and the gate still blocked every violation. Identical actions get identical scores; the cut never lets such ties push the review load above the budget.

Put a monitor in your agent's loop

graybox.guard.Guard checks each proposed tool call before it runs, in each provider's own message format. A blocked call does not run; the agent gets a message that it was blocked. In our live test on a closed agent (T16, 500 scenarios with and without the gate, the GPU reader), the share of episodes with an executed violation fell from 6.6% to 0.2%, with no violation after a block, at 89 ms per check (server round trip). The text monitor takes about 0.3 ms per check.

from graybox.guard import Guard
from graybox.monitor import TextMonitor
guard = Guard(TextMonitor.load_json("greybox-text-monitor.json"))      # or Guard(load_reader(...), gate=QuantileGate(0.05))

# OpenAI Chat Completions
reply = client.chat.completions.create(model=..., messages=messages, tools=tools).choices[0].message
messages.append(reply)               # append the reply first: required for parallel calls
for call in reply.tool_calls or []:
    v = guard.check_openai(messages, call)
    result = run_tool(call) if v.allow else v.block_message
    messages.append({"role": "tool", "tool_call_id": call.id, "content": result})

# Anthropic Messages
messages.append({"role": "assistant", "content": response.content})   # append the reply first: required for parallel calls
results = []
for block in response.content:
    if block.type == "tool_use":
        v = guard.check_anthropic(system, messages, block)
        result = run_tool(block) if v.allow else v.block_message
        results.append({"type": "tool_result", "tool_use_id": block.id, "content": result})
messages.append({"role": "user", "content": results})

SDK objects (OpenAI messages, Anthropic content blocks such as response.content) and plain dicts give the same scores. (Before version 0.4.0, Anthropic content blocks inside a dict message lost the earlier calls: update.)

The guard builds each step with the same rule as the browser trainer's importer (graybox.guard.conversation_steps): system prompt, first user message, earlier calls with their results, the proposed call. On our example logs, the importer reproduces the research units byte for byte (979/979 GPT, 373/373 Claude), and the browser and Python give identical steps.

What the monitor sees. For a proposed call, the guard gives the monitor everything before the assistant reply that holds the call, then the earlier calls of that reply (with no results yet), then the call. The reply's own text is not included (it is, for later calls). Results of sibling calls that already ran do not change the score. Each tool result is cut to its first 2,000 characters, and a context of 30,000 characters or more keeps its first 6,000 and last 24,000, as in training: text deep inside a very long tool result is not seen.

The review cut on a new kind of task. A monitor file stores a cut set on its training actions. When the tasks change, the scores shift as a whole, and a stored cut can escalate almost nothing. We trained the text monitor on our GPT example logs without 8 task families and scored those families, three times with different families (public data only: scripts/114_cut_under_shift_public.py, results/tool/cut_under_shift_public.json):

So a live gate keeps the review load where you set it, but it does not restore detection on a new kind of task. For that, label actions of the new tasks and train again, or use the reader monitor (more robust to new tasks). To deploy a gate: Guard(monitor, gate=QuantileGate(0.05)), or "gate": true with a "stream" on the server. A new gate sends every action to review until it has seen 100; seed it from past unlabelled traffic: gate.fit(past_scores), or POST /v1/monitor/calibrate {"monitor": "text", "scores": [...]}. For the text monitor the scores are logits (the guard's v.score, the server's "score"); the server rejects probabilities there.

Parallel calls. When one reply holds several calls, pass the reply in messages before you check its calls: each call is then scored with the earlier calls of the same reply (with no results yet), as the trainer builds its steps.

Units. For the text monitor and the browser reader, v.score (and the server's "score") is a logit, compared with the file's threshold_logit; v.unit says so. For the reader monitor with a gate it is a probability, but act on v.allow: the probability does not transfer between agents.

In front of an MCP server

If your agent calls tools through MCP, put the monitor between the client and the server; the agent does not change:

pip install "greybox-monitor[mcp]"
python -m graybox.mcp_proxy --monitor greybox-text-monitor.json --instructions policy.txt --skip-tools read_file,list_files \
    --hold-tools issue_refund --audit-log decisions.jsonl -- <your MCP server command>

The proxy lists the server's tools unchanged and checks each call before it forwards it; a blocked call returns an error result with the block message. An MCP server never sees the conversation, so the monitor reads only the instructions and task you pass and the calls of the session. On our GPT example, the text monitor lost little from that (AUROC 0.79 with the full context, 0.77 without the user's task, 0.81 with the calls only; the same 45% caught at 5%; scripts/117), because word counts lean on the action. We did not measure this for the reader monitor. Use Guard in the agent loop when you can.

Audit log

Guard(..., audit_log="decisions.jsonl") appends one record per check (time, tool, arguments, score, decision, held, by rule, reviewer error, cut); on_decision=fn receives the same record. The server writes it with GB_AUDIT_LOG=/path/decisions.jsonl. A failing log never changes a decision. The arguments can hold personal data; the log stays on your machine.

Hold for review instead of block

Blocking has a cost. In our live tests, goal completion fell from 47.4% to 45.6% (GPT) and from 40.7% to 34.9% (Claude). Nearly all of that loss came from episodes in which the gate blocked a safe action (96% of it on Claude). In Claude episodes with no block, goals were unchanged (41.4% vs 41.1%). Give the guard a reviewer: an escalated action is held, and your function decides whether it runs.

We tested this on Claude (T16c, preregistered, 450 scenarios, with an oracle as the reviewer). Episodes with an executed violation went from 6.7% to 0.0%. Goals were reached in 38.7% of episodes (no gate: 40.7%; blocking: 34.9%). The preregistered test that review costs at most 3 points of goals failed (−2.0 points, 95% interval −5.1 to +0.7). Episodes in which the reviewer let a held action run kept their goals (60.8% vs 58.8%). The reviewer saw 0.20 actions per episode. A human reviewer makes errors and takes time, so these numbers are an upper bound.

def ask_human(step):                      # step: instructions, task, history, action, score, unit (+ context with chat=True)
    return review_queue.approve(step)     # True runs it; anything else (False, an error, "no") blocks it (fail closed)

guard = Guard(monitor, gate=QuantileGate(0.05), reviewer=ask_human)
v = guard.check_openai(messages, call)
if v.held: log_review(call, approved=v.allow)
result = run_tool(call) if v.allow else v.block_message

OpenAI Responses API, asyncio, reviewer timeout

OpenAI "custom" tool calls (free-text tools with no function name) are blocked: the guard fails closed on a call it cannot read. Use function tools for actions you want the monitor to score.

v = guard.check_openai_responses(input_items, function_call, instructions=instructions)   # Responses API items
v = await guard.acheck_openai(messages, call)        # also acheck_anthropic, acheck_openai_responses (a worker thread)
guard = Guard(monitor, gate=QuantileGate(0.05), reviewer=ask_human, reviewer_timeout=300)   # no answer in 300 s: blocked

Always review some tools

To send every call of a risky tool to review, whatever its score, list it: Guard(monitor, gate=QuantileGate(0.05), reviewer=ask_human, hold_tools={"issue_refund"}). v.by_rule is then True. The gate still sees these scores, so its cut does not change. Do not split the budget by tool: offline, a 5% budget for each tool caught 39% of Claude's violations and 46% of GPT's, against 94% and 98% with one 5% budget, because violations cluster in a few tools.

Agents that talk with a user

By default a step keeps the system prompt, the first user message and the earlier tool calls. In customer-service agents, the facts that decide an action often come later from the user (a cancellation reason, a confirmation). With chat=True, the context keeps every user turn and agent message in order (graybox.chat.chat_ctx):

guard = Guard(monitor, chat=True)                                   # score with conversation-aware context
python -m graybox.train_reader logs.jsonl --chat --out probe.joblib    # train on the same context

Train and score with the same setting. A monitor trained on the default context sees different text with chat=True. The browser trainer has the same option, and it gives identical context to Python (268/268 steps of 40 real τ²-bench conversations, 182 of them with later user turns).

Agent frameworks

graybox.integrations places the Guard before every tool call of three frameworks. A blocked call does not run; the agent gets the neutral block message, never the score. Each adapter is tested offline against the real SDK.

# Claude Agent SDK: pip install "greybox-monitor[text,claude-agent]"
from claude_agent_sdk import ClaudeAgentOptions, HookMatcher
from graybox.integrations import claude_agent_hook
options = ClaudeAgentOptions(hooks={"PreToolUse": [HookMatcher(hooks=[claude_agent_hook(guard, instructions=SYSTEM)])]})
# on_escalate="ask" sends an escalated call to the user's permission prompt instead of denying it

# OpenAI Agents SDK: pip install "greybox-monitor[text,openai-agents]"
from graybox.integrations import openai_agents_guardrails
check, record = openai_agents_guardrails(guard)
@function_tool(tool_input_guardrails=[check], tool_output_guardrails=[record])   # attach both to every tool
def send_message(to: str, body: str) -> str: ...

# LangGraph: pip install "greybox-monitor[text,langgraph]"
from graybox.integrations import langgraph_tool_wrapper
node = ToolNode(tools, wrap_tool_call=langgraph_tool_wrapper(guard))   # awrap_tool_call=langgraph_tool_wrapper(guard, async_=True)

The OpenAI Agents SDK gives a tool guardrail only the run's original input, not the calls made earlier in the run. The record guardrail keeps them, so the monitor sees the whole run; without it, a test shows that a violation after an earlier read runs unchecked.

The text monitor (CPU, real time)

Train it in the browser trainer and download the model file, then:

from graybox.monitor import TextMonitor, Step

tm = TextMonitor.load_json("greybox-text-monitor.json")         # pure Python to score
history = [Step("read_file", {"path": "/hr/salaries.csv"}, "name,salary\n...")]
action = ("send_message", {"recipient": "press@news.example", "body": "..."})
if tm.escalate(system_prompt, user_task, history, action):    # the cut learned from your data
    hold_for_review()

Or train it without a browser: python -m graybox.train_text (see "Get a monitor without a browser" above).

The browser reader (no GPU, for new kinds of tasks)

A small neural reader (Qwen3-Embedding-0.6B with a probe trained on our CAPE-Bench steps) runs in the trainer, with no server. It is for a new kind of task only (preregistered test T19): on prompt injections against a closed agent it scored AUROC 0.605, against 0.335 for word counts and 0.840 for the GPU reader. On tasks like the training data it is lower (closed agents on CAPE-Bench: 0.912 and 0.884, against 0.989 and 0.996 for the GPU reader): use the text monitor or the GPU reader there.

The reader monitor (GPU, better on new kinds of tasks)

curl -LO https://github.com/AryaFayyazi/GreyBox/releases/download/v0.2.0/reader_probe_cape_agentdojo.joblib
from graybox.monitor import QuantileGate, Step

from graybox.reader_load import load_reader
mon = load_reader("reader_probe_cape_agentdojo.joblib")    # downloads Qwen/Qwen3-8B once (standard Hugging Face cache)
gate = QuantileGate(budget=0.05)                    # escalate the riskiest 5% of this agent's actions
gate.fit(past_scores)                               # optional: warm up on trusted past traffic
p = mon.score(system_prompt, user_task, history, action)
if gate(p):
    hold_for_review()
Act on the gate, not on p. The probe ranks a new agent's actions well, but its probabilities do not transfer: on Claude a 0.5 cut caught 3% of violations. QuantileGate sets its cut at the top budget of the first 500 actions it sees (no labels), then freezes it, so a burst of malicious actions cannot raise it. It escalates everything until it has seen 100 actions. Call gate.recalibrate() when the agent, its tools, its prompt or its policy change: in a live test, a notice that the agent is monitored moved its scores so that the old cut escalated 16.6% of actions; a cut set again on its new, unlabelled traffic escalated 4.7% and still blocked every violation.

Two probes are in release v0.2.0 (4.3 MB each, with SHA-256 checksums; load them with the scikit-learn version given there):

The model card lists their training data, measured performance and limits. Both learned the policies in their training data. For another type of policy, add labelled steps of it: features from mon.features(prompt), then the same scikit-learn pipeline.

Train the reader monitor on your own labels (GPU)

Label actions in the browser trainer and download them, or use logs that carry a labels field. Then one command computes the reader's features and trains the probe:

python -m graybox.train_reader greybox-labelled-actions.jsonl --out my_reader_probe.joblib

It reports an AUROC from cross-validation with whole tasks held out (the mean of 3 fold assignments, as the text trainer) and the catch rate at a 5% budget, and writes a file that graybox.reader_load.load_reader, Guard and the server use (a probe trained with --chat keeps that setting). On our 300 example GPT conversations (1,515 actions), it took 110 seconds on one GPU: AUROC 0.972 (0.970–0.973), 86.6% of violations caught at 5% review; the text monitor on the same folds: 0.946 and 60.2%.

HTTP API (self-hosted)

GB_TEXT_MONITOR=greybox-text-monitor.json uvicorn graybox.monitor_server:app --host 127.0.0.1 --port 8000   # CPU only
GB_MONITOR_PROBE=reader_probe.joblib      uvicorn graybox.monitor_server:app --host 127.0.0.1 --port 8000   # + GPU reader

Security. Requests carry whole transcripts, which can hold credentials. Bind to localhost, or set GB_API_KEY (a long random string): every endpoint but /v1/health then needs Authorization: Bearer <key>. Use TLS (a reverse proxy) beyond one machine. Restarts. Gates live in memory; set GB_GATE_STATE=/path/gates.json to keep them (warm-up scores and frozen cuts) across restarts. Without it, a restarted server sends every action of a stream to review until it has seen 100 again. A gate belongs to a stream and a budget: a request with another budget starts a new, cold gate (seed it with /calibrate at that budget).

POST /v1/monitor/text scores with the browser-trained text monitor and its stored cut. POST /v1/monitor/action uses the reader with a frozen gate per stream. Both take the same body:

POST /v1/monitor/action
{"instructions": "...", "task": "...",
 "history": [{"tool": "read_file", "args": {"path": "/hr/a"}, "result": "..."}],
 "action": {"tool": "send_message", "args": {"recipient": "..."}},
 "stream": "billing-agent", "budget": 0.05}

→ {"p_unsafe": 0.000213, "escalate": false, "threshold": 0.00264, "calibrated": true, ...}

POST /v1/monitor/text     (same body; "args" may also be a JSON string, as in OpenAI's function.arguments)
→ {"score": -3.74, "unit": "logit", "escalate": false, "threshold": -2.03, "budget": 0.05, "ms": 0.2, "warning": null, "note": "..."}
   ("budget" on /text needs "gate": true; an empty tool name is refused with 422)

GET /v1/health
→ {"version": "0.7.0", "text_monitor": true, "text_monitor_error": null, "reader_monitor": false, "reader_loaded": false,
   "auth_required": false, "gate_state_persisted": false, "gates": 0}

For agents that talk with a user, send "context" (from graybox.chat.chat_ctx) in place of instructions, task and history, with a monitor trained on such contexts. Each stream calibrates its own frozen cut from its own traffic. Transcripts stay on your machine. A Dockerfile is in deploy/ of the repository.

Data format for labelled steps

{"instructions": "...", "task": "...", "history": [{"tool": "...", "args": {...}, "result": "..."}],
 "action": {"tool": "...", "args": {...}}, "label": 1, "group": "task-17"}

One proposed action per line: everything the monitor may see before it runs. label is 1 if executing the action violates your policy. group keeps a task's steps in one cross-validation fold. Text rendering is identical in Python and in the browser (verified byte for byte on 302 real agent steps).

How to evaluate a monitor honestly