Tool calls and agents
An agent adds two steps to a turn. The model chooses a tool and its arguments, and the tool's response goes back to the model. jes checks both.
This page is for agents you build in Python. For Claude Code, Codex, and other coding agents, the hooks run these checks for you.
The full signatures are in the Guard reference.
check_tool_call
call = guard.check_tool_call("search", {"query": "refund policy"}, prompt=incoming)
nameis the tool the model asked for. It must be a non-empty, single-line string of at most 256 characters, with no leading or trailing whitespace. Anything else raisesPolicyError.argumentscan be the raw argument string, or the argument object. jes serializes an object one canonical way (sorted keys, compact separators, UTF-8), so the checked string is the string an allowed call keeps.promptis required: the user turn. Context-aware judgments such astool_safetysee it; every judgment sees the tool name.- The arguments are never rewritten. Editing the JSON would break the call
your application runs, so on this stage
secretsandpiiblock instead of redacting, and placeholders are never restored into arguments.pii(tool_call_mode="flag")lets the call through with a finding instead. Judgments still never see the values they found: the judges' copy of the arguments has them removed. SetGuard(..., fail_fast=True)to skip the judgments once a transform blocks.
check_tool_result
result = guard.check_tool_result(raw_response, name="search", prompt=incoming)
The tool's response is untrusted text, like a retrieved page. prompt is
the user turn, so indirect_injection can judge the response against it.
Transforms redact here as they do on untrusted, and pii uses its
untrusted_mode.
allowed_tools
from jes.policies import allowed_tools
allowed_tools(["search", "calculator"])
A transform on the tool_call stage. It blocks any call whose name isn't in
the set, with the finding label tool_name. It never edits the arguments.
Leaving allowed_tools out doesn't allow every tool. It means jes doesn't
check the name. Your application still decides whether this user may run this
tool with these arguments. jes checks text; it doesn't do authorization.
tool_safety
from jes.policies import tool_safety
tool_safety(threshold=0.5)
A judgment on the tool_call stage. It asks Jev whether the call, given the
user's request, "is destructive, exfiltrates data, changes privileges, or
goes beyond what the user asked." A user who asked to summarize a README
doesn't need rm -rf ~/projects or a curl that posts ~/.aws/credentials.
allowed_tools checks which tool runs. tool_safety checks what the call
does with it, so use both. The threshold is required; jes has no measured
value for it yet.
What runs on the tool stages by default
| Policy | tool_call | tool_result |
|---|---|---|
allowed_tools | yes (blocks unknown names) | — |
injection | yes | — |
tool_safety | yes | — |
indirect_injection | — | yes |
secrets | yes (block, arguments unchanged) | yes (redact) |
pii | yes (block, or flag with tool_call_mode="flag") | yes (mask, from untrusted_mode) |
canary | yes (blocks a leaked token) | — |
malicious_urls (recipe) | — | yes |
invisible_text, regex, substrings | — | yes |
The transforms (secrets, pii, canary, invisible_text, regex,
substrings) and judge() take stages= to change where they run. The built-in judgments
and allowed_tools have fixed stages.
Forward onward on every path
The tool stages have their own refusals, so the model reads a short message
in place of the tool's output. Results has
the full onward rules.
call = guard.check_tool_call(name, args, prompt=incoming)
if not call.ok:
tool_message = call.onward # "Tool call blocked."
else:
raw = run_tool(name, args) # your code decides whether it may run
result = guard.check_tool_result(raw, name=name, prompt=incoming)
tool_message = result.onward # sanitized response, or "Tool result blocked."
if result.ok:
tool_history.append(result)
outgoing = guard.check_output(reply, prompt=incoming, history=tool_history)
show(outgoing.onward)
- Give the model
onwardin place of the tool message, whether the check allowed or blocked. - Pass each allowed
check_tool_resultashistorytocheck_output, and leave blocked ones out. The reply is then judged against the tool text the model actually saw. - In
history, aMessage(role="tool", text=...)or atool_resultresult counts as a tool result, not as an assistant reply.
In your agent framework
Part 2 of the jes course runs these checks in five complete agents. Each runs
on hosted Jev in jev.py and fully on Ollama in local.py, against a clean
request, a prompt injection, and a tool that returns a poisoned page:
- OpenAI SDK tool loop: a Responses API loop written out by hand, with every check in plain sight.
- OpenAI Agents SDK: jes as input, tool, and output guardrails.
- A LangChain agent: one
JesMiddlewarechecks the user message, each tool call and result, and the final reply. - A LangGraph agent: the same checks as explicit graph nodes.
- A Deep Agent: a middleware on the main agent and on the subagent it hands work to.