Verdicts and audit
Every check produces a verdict, and every verdict is recorded. That record is the reason monitor mode is worth anything: it is how you find out what enforcement would have done before you turn it on, and how you answer "why was this refused" afterwards.
Reading the feed
- platformctl
- curl
- Console
One agent:
platformctl agents security verdicts research-buddy --limit 20
TIME DIRECTION DECISION TIER CATEGORIES LATENCY ID
2026-09-16 11:31:09 output allow pattern - 22ms gv_01J9X1M4S6ZQ8KC4
2026-09-16 11:31:07 tool_result block classifier - 41ms gv_01J9X1M4S6ZQ8KC2
2026-09-16 11:31:05 input mask semantic - 238ms gv_01J9X1M4S6ZQ8KC0
2026-09-16 11:18:02 input flag semantic jailbreak 511ms gv_01J9X1M4S6ZQ8K9F
Narrow it three ways:
# Only refusals, this week.
platformctl agents security verdicts research-buddy --decision block --since 7d
# Only masking, the last half hour, a short page.
platformctl agents security verdicts research-buddy --decision mask --since 30m --limit 10
# The whole record, for a script.
platformctl agents security verdicts research-buddy --limit 1000 -o json
--decision takes allow, flag, mask or block. --since takes a span written the way you would say it - 30m, 24h, 7d. --limit is 1–1000 and is always sent, because a feed with no bound prints ten thousand rows the first time an agent gets busy.
Across the whole project, how much is happening rather than what happened:
platformctl guard stats --window 24h
Read the two together: stats answers "how much", verdicts answers "what".
One agent:
curl -s "$CAI_API/v1/agents/research-buddy/security/verdicts?decision=block&since=24h&limit=50" \
-H "Authorization: Bearer $CAI_TOKEN"
{
"agent": "research-buddy",
"verdicts": [
{
"id": "gv_01J9X1M4S6ZQ8KC2",
"direction": "tool_result",
"decision": "block",
"reason": "The request looked like an attempt to override the agent's instructions.",
"risk": { "score": 0.96, "severity": "unsafe", "categories": [] },
"tier_reached": "classifier",
"short_circuited": true,
"scanners": [
{ "name": "injection", "tier": "classifier", "decision": "block", "latency_ms": 31, "error": null }
],
"session_hash": "9c4f1ab2e7d30f58",
"request_id": "r-8812",
"policy": { "source": "agent:research-buddy", "version": 5, "mode": "enforce" },
"latency_ms": 41,
"created_at": "2026-09-16T11:31:07Z"
}
],
"next_page_token": "..."
}
The whole project, across every agent:
curl -s "$CAI_API/v1/projects/$CAI_PROJECT/guard/verdicts?decision=block&since=7d&limit=100" \
-H "Authorization: Bearer $CAI_TOKEN"
Counts over a window:
curl -s "$CAI_API/v1/projects/$CAI_PROJECT/guard/stats?window=24h" \
-H "Authorization: Bearer $CAI_TOKEN"
next_page_token on a response means more rows exist; pass it back to continue.
Open Agents, expand the agent, and choose the Security tab. The What happened table at the bottom is this agent's verdict feed, newest first, with a decision filter above it.
It refreshes on its own every few seconds, and it refreshes only the table - the policy editor above it is a separate region, so a poll never repaints a form you are typing in.
The table says once, above itself, that it contains no message text. Without that sentence an empty content column reads as a bug, and the first thing anybody does with a security feed is look for the message that was blocked.
An empty table has two different meanings and the Console distinguishes them: "guardrails are off for this agent, so nothing has run" is not the same as "guardrails are on and nothing has fired yet".
What a verdict contains
| Field | What it is |
|---|---|
id | The verdict id, gv_ followed by a sortable identifier. The one thing to quote. |
direction | input, output, tool_args or tool_result. Read this first - tool_result means something your agent fetched was the problem. |
decision | allow, mask, flag or block. |
reason | One customer-safe sentence. No raw content, no detected value, nothing about how the check works. Safe to show a user, which is what include_reason does. |
risk | score 0–1, severity (safe, controversial, unsafe), and the safety categories that fired. Populated by the semantic tier. |
tier_reached | The last tier that ran. |
short_circuited | true when a decision stopped the run early. |
scanners | One entry per check that ran: name, tier, decision, latency_ms, error, plus for pii and secrets an entities array of {type, count}. |
session_hash | A hash of the session id - not the session id. Groups a conversation's verdicts without naming it. |
request_id | The request id you passed in, echoed back. |
policy | source (which layer supplied the policy), version and mode. |
model | Present only when the semantic tier ran: the profile and the tokens it used. |
latency_ms | The whole check, end to end. |
created_at | When. |
What it deliberately does not contain
- The text. Not the message, not the reply, not the tool result, not a truncated excerpt.
- Any detected value. The
piientry says[{"type":"EMAIL_ADDRESS","count":1}]. It never says which address. - The session id. Only a hash of it.
- Anything about the machinery. No engine names, no model identifiers beyond the profile name you chose, no internal addresses.
This is a design decision and not a limitation to work around. A store of everything your agents refused is a store of exactly the content somebody would most like to steal, and it would make the feature impossible to use in any regulated setting. What you get instead is a feed that is safe to read, safe to export, safe to paste into a ticket, and safe to hand to a vendor.
The cost is that you cannot see the blocked message here. If you need to, that is what session_hash and request_id are for: they line a verdict up against your own application logs, where your own retention and your own rules apply. You decide whether to keep the content; the platform has decided not to.
Log lines the platform writes about a check carry the same discipline: the text's length and a short hash of it, never the text.
The audit trail
A block and a flag also write to the project's audit log, alongside membership changes, credential issuance and secret reveals. So does a policy change.
| Action | Written when |
|---|---|
agent.guard.block | A request was refused. |
agent.guard.flag | A concern was recorded and the request went through - including every downgraded block in monitor mode. |
agent.guard.policy.change | Somebody changed a guardrail policy. |
- platformctl
- curl
- Console
platformctl audit --limit 20
curl -s "$CAI_API/v1/projects/$CAI_PROJECT/audit?page_size=20" \
-H "Authorization: Bearer $CAI_TOKEN"
{
"entries": [
{
"ts": "2026-09-16T11:31:07Z",
"actor": "",
"action": "agent.guard.block",
"target": "agent/research-buddy",
"detail": {
"verdict_id": "gv_01J9X1M4S6ZQ8KC2",
"tier": "classifier",
"categories": [],
"scanner": "injection"
}
},
{
"ts": "2026-09-16T10:58:41Z",
"actor": "admin@acme.example",
"action": "agent.guard.policy.change",
"target": "agent/research-buddy",
"detail": {
"changed": ["/mode", "/input/injection/threshold"],
"version": 5
}
}
],
"next_page_token": "cursor_12345"
}
Open Security → Audit log and filter on agent.guard. The three actions above appear there with the rest of the project's administrative history.
Two things about these entries:
targetisagent/<name>, so filtering the audit log by one agent gets you its refusals alongside every other administrative event that touched it - which is usually the question you actually have ("what changed about this agent, and when did it start refusing things").- A
blockorflagentry has no human actor. Nobody chose it; a check did. Apolicy.changeentry does have one, and that is the entry that answers "who turned this on".
detail carries the verdict id, so an audit entry is always one lookup away from the full verdict - and never carries content, for the same reason the verdict does not.
Retention
logging.retention_days on the policy sets how long a verdict is kept. It defaults to 30 days and accepts 1 to 365.
platformctl agents security set research-buddy --retention-days 90
# Or as the project-wide default, so every agent inherits it.
platformctl guard policy set --retention-days 90
logging.store_verdicts: false turns recording off entirely. Think hard before using it: you lose monitor mode, you lose the feed, and you lose the ability to answer "why was this refused" at all. The reason to keep it on is that verdicts carry no content, so there is very little to be gained by not having them.
Audit entries are not governed by retention_days. The project audit log is append-only and permanent, like every other entry in it. A verdict expires; the audit record that a refusal happened does not.
Using a verdict id with support
Turn on include_verdict_id and a refused user is handed something quotable:
platformctl agents security set research-buddy --refusal-include-verdict-id
Sorry, I can't help with that. Contact support@acme.example if you think this is a mistake. [gv_01J9X1M4S6ZQ8KC2]
That is the right thing to expose, because it reveals nothing on its own and your desk can resolve it in one command:
platformctl agents security verdicts research-buddy --decision block --since 24h -o json
Then find the id. Verdict ids sort by time, so the id itself tells you roughly when to look.
What the desk learns from a verdict, and can act on:
direction- was this the user's message, the agent's reply, or something the agent fetched? Atool_resultblock is not the user's fault at all.reason- one sentence saying what kind of thing fired.scanners- which check, at which threshold, and how long it took.policy.versionandpolicy.source- which document was in force, and which layer supplied it. This is what answers "it worked last Tuesday": compare against the policy history.request_idandsession_hash- the keys into your own logs, if you keep the content.
A useful pattern for a support desk: paste the quoted id into a small internal tool that runs the command above and prints direction, reason, the fired check and the policy version. Four facts, no content, and enough to decide whether the policy needs changing or the user needs a different route.
If the answer is "the policy is wrong", the fix is a threshold or an action, and monitor mode plus guard scan is how to verify it before shipping:
platformctl guard scan --agent research-buddy --text "the message the user quoted to you"
Watching for trouble
Three things worth an alert, all readable from the feed:
- A
tool_resultblock. Something one of your data sources is serving carries instructions aimed at your agent. That is an incident about the source, not about the agent. - A rising
flagrate witherrorpopulated on a scanner. A check is failing or timing out, andfail: openis letting requests through unchecked. The reason says so:A security check could not complete, so the request was recorded and allowed. - A non-zero unknown count on a restore. Placeholders reached an end user, which looks like a bug in your agent. Usually the per-session cap, or a mapping reference that was not carried forward.
Next steps
- Policies -
logging,modeand every other field. - Refusal messages -
include_verdict_idand the rest of the precedence. - The standalone API - the feed and stats routes in full.
- Break-glass and audit - the rest of the project audit trail.