Give an agent step tools
An agent step can look things up before it answers: read a file, query an API, search a ticket system. This guide gives a step two tools from a tool server, limits how much it may use them, and shows what Realm records about each call. At the end you have a step that answers a question from a folder of notes.
Before you start
Section titled “Before you start”You need:
- A workflow with an agent step. Write an agent step makes one.
realm agentset up to drive it. See Run a workflow withrealm agent.npx, which comes with Node.js, to start the example tool server.
The tools on this page are offered only when realm agent drives the step. An assistant connected over MCP is given the step’s prompt and nothing about its tools. It uses whatever tools it has itself, and Realm does not record those calls.
How it works
Section titled “How it works”A tool server is a program that offers tools over MCP, the same protocol assistants use to call Realm. The workflow names the servers it uses, and each agent step lists the tools it may call. When realm agent reaches the step, it starts the server, tells the model about the listed tools, carries out each call the model asks for, and records it.
1. Name the tool server
Section titled “1. Name the tool server”Add an mcp_servers block at the top level of the workflow. This one starts a public server that reads files in one folder:
mcp_servers: - id: notes transport: stdio command: npx args: ['-y', '@modelcontextprotocol/server-filesystem', './notes']| Key | Meaning |
|---|---|
id |
The name steps use for this server. |
transport |
How Realm talks to it. stdio is the only value: Realm starts the program and talks over its input and output. |
command |
The program to start. |
args |
Its arguments. |
env |
Environment variables for it. A value such as '${NOTES_TOKEN}' is filled from the shell that runs realm agent. |
Create the folder and one note for the server to read:
mkdir notesprintf '# Returns\n\nCustomers may return an item within 30 days of delivery.\n' > notes/returns.md2. List the step’s tools
Section titled “2. List the step’s tools”On the agent step, list each tool as server:tool:
steps: answer: description: Answer the question from the notes. execution: agent tools: - notes:list_directory - notes:read_text_file max_tool_calls: 5 tool_timeout: 20 prompt: | Answer this question using only the files in the notes folder. List the folder, read what you need, then answer.
Question: {{ run.params.question }} input_schema: type: object additionalProperties: false required: [answer, source_file] properties: answer: type: string source_file: type: stringThe model is told about the listed tools and no others. This server offers 14 tools, among them write_file and move_file. The step above can list a folder and read a text file, and nothing else.
The two limits are optional:
| Key | Limits | Without it |
|---|---|---|
max_tool_calls |
How many tool calls the model may make in this step. | 20 |
tool_timeout |
How many seconds one tool call may take. | 30 |
3. Run it
Section titled “3. Run it”realm agent --workflow ./ --params '{"question":"How long do customers have to return an item?"}' --provider anthropic --model claude-sonnet-5-5The command uses Anthropic and the model claude-sonnet-5-5; with an OpenAI key, use --provider openai --model <a model from OpenAI's list> (OpenAI’s models). Version 0.45.0 has a default model, so --model can be left out there.
It prints, among the tool server’s own start-up lines:
→ [agent] answer Answer the question from the notes. ✓ → completed
Run complete: b190d283-5b2e-473e-ad61-b086b0342a8a
Result (answer):{ "answer": "Within 30 days of delivery.", "source_file": "returns.md"}4. Read what was recorded
Section titled “4. Read what was recorded”Every tool call is part of the step’s evidence. Add --verbose to see what was sent and what came back:
realm run inspect <run-id> --verboseUnder the step it prints:
Tool calls (2): [notes:list_directory] 7ms args: {"path":"."} result: {"content":[{"type":"text","text":"[FILE] returns.md\n[FILE] shipping.md"}], …} [notes:read_text_file] 4ms args: {"path":"returns.md"} result: {"content":[{"type":"text","text":"# Returns\n\nCustomers may return an item within 30 days of delivery.\n…"}], …}Each entry names the server and tool, how long the call took, the arguments the model chose and the result it was given. Without --verbose, the same list shows one line for each call.
A call that fails is recorded too, with its error, and the model is told about the failure so that it can try something else:
[notes:list_directory] 7ms error: ENOENT: no such file or directory, scandir '[REDACTED]/notes/notes'realm agent replaces the values of its environment variables, and the secrets the manifest declares, with [REDACTED]: in tool results and in error messages, both in what it records and in what it shows the model, and in the prompt the agent step sends the model. Here it replaced the path of the working folder. A run’s params are kept as given, in the run’s file and in realm run export. It also replaces each value in the forms these programs print it in:
- JSON-escaped, as JavaScript’s
JSON.stringify, Python’sjson.dumps(every non-ASCII character as\uXXXX), Go’sjson.Marshal, PHP’sjson_encode(without itsJSON_HEX_…flags), Perl’sJSON::PP, jq’stojson, Newtonsoft.Json, Gson, System.Text.Json and Jackson write it, and inside another JSON string; - in quotes with backslash escapes, as Python’s
repr(),ascii(),unicode_escapeandrepr()of bytes, Node’sutil.inspect(console.logof an object), PHP’svar_exportandaddslashes, Perl’sData::Dumper, Go’s%q,%#vand%+q, Rust’s{:?}andescape_default(), Ruby’sinspect, PyYAML’s and js-yaml’s double-quoted strings, Windows argument quoting (Python’ssubprocess.list2cmdline) and bash’sdeclare -pwrite it, and with each"written\"; - shell-quoted, as bash’s xtrace,
${v@Q}andprintf %q, jq’s@sh, Python’sshlex.quoteand BusyBox ash’s xtrace write it; and with each'doubled (YAML single-quoted strings, SQL quoting), each"doubled (CSV quoting) or each$doubled (Makefiles, Compose files); - with HTML or XML escaping, as Python’s
html.escapeandxml.sax.saxutils, .NET’sSecurityElement.Escape, jq’s@html, lodash’s_.escape, Ruby’sCGI.escapeHTML, Go’shtml.EscapeStringand PHP’shtmlspecialcharswrite it; - percent-encoded with upper-case hex, as JavaScript’s
encodeURIComponentandencodeURI, Node’squerystring.escape,URLSearchParamsandURL, Python’squote,quote_plusandurlencode, Java’sURLEncoder, jq’s@uri, PHP’surlencodeand Go’surl.QueryEscape,url.PathEscapeandurl.UserPasswordwrite it; - in base64 and base64url, also when other text was encoded with it (the forms GitHub’s runner and Jenkins mask, and, when its base64 ends in
=padding, each without its last character), and in lower- or upper-case hex.
Each line of a multi-line value that has 8 or more characters and no spaces or other blank characters is hidden on its own, everywhere. So a declared credentials file also hides lines such as region=us-east-1. A line with blanks, such as key = value, is not hidden on its own. For every value, it also hides, in the same forms, the value with these escapes read, when that has 8 or more characters: \n, \r, \t, \b, \f, \/, \\, \", \uXXXX and \xHH (a value that ends in a lone \ has no such reading); and each line of that which has 8 or more characters and no spaces or other blank characters. For a declared secret, some parts of it are also hidden on their own, everywhere: the value without the double quotes around it, when the quoted value has more than 8 characters; the part up to and including its last & (or its first &+, when it has one), and the part after it, when that part has 6 or more characters (after &+, the part starts at the second character after the +, and it is hidden when it has 6 or more characters from there); and the value with \\, \', \" and \t read, or with each \ dropped, or with each ^ dropped (a doubled one kept once; not both at once), when that has 8 or more characters.
This does not catch everything, so do not rely on it to keep a secret out of a record. It leaves alone:
- an environment value of four characters or fewer, and a declared secret of three or fewer;
- an environment value that is the word
false, or two or three numbers joined by dots, such as the version number11.8.0(a declared secret with that value is hidden); - the values of three variables that npm sets:
npm_package_name,npm_package_versionandnpm_lifecycle_event; - in an environment value, the rest of a path under your home folder: only the home folder’s part is replaced;
- in base64 and base64url, a value of 6 bytes or fewer that was encoded together with other text (it is hidden at some positions and not at others);
- a value split across lines by the program that printed it, for example base64 wrapped at 76 characters, or a long YAML string folded at its spaces or after an escaped character; a multi-line value written one line at a time keeps its lines that hold blanks;
- a value that a template filter changed in the agent step’s prompt (for example
| truncateor| upper), except that a whole line of a multi-line value, and a part of a declared secret, are still hidden there; - HTML references the named escapers do not write, such as Mustache.js’s
/and=, Handlebars’=and`, a hex", decimal/, or PHPhtmlentities’éand ; - percent-encoding with lower-case hex, such as
%2f; - bash’s
$'…'quoting in a C or POSIX locale, which writes non-ASCII bytes as\NNN, and bash 5.2’s\~; - PyYAML double-quoted output with
allow_unicode; - colour codes (ANSI escape sequences) between a value’s characters;
- PHP’s
var_exportof a value holding a NUL character, which it writes as'…' . "\0" . '…', and Perl’sData::Dumperof a value that holds both a control character (a tab, ESC or DEL, for example) and a non-ASCII character, which it writes with the control character as it is and the non-ASCII character as\x{…}; - a character newer than the Unicode tables of the program that printed it, or of the Node.js that runs Realm, in a value that also holds another non-ASCII character (Python 3.12 to 3.14, older Go, Ruby and Rust; Node 22’s tables are older than Node 24’s);
- in a URL, its password,
user:passwordor token on its own, its Basic-auth base64, and the URL without its query string: the whole URL is hidden, and for a declared secret the parts around&; - other parts of a value than those named above, such as the password in a connection string written
Password=…or the token in a value writtenBearer …; - other escapings than those named above, or a value mixing several;
- the model’s own answers, and the arguments of its tool calls. Neither is checked.
The record does not yet include what the model’s requests cost for a step that uses tools. realm run inspect says so on the step’s cost line. That line was added in 0.46.0.
See Realm hold the limits
Section titled “See Realm hold the limits”When the step has used its tool calls, the model is not given another. Realm sends it this message, with none of the step’s tools, and takes its answer:
You have reached the maximum number of tool calls. Produce your final JSON answer now using only what you have already gathered. No further tool calls will be executed.With max_tool_calls: 1, the model listed the folder, was sent that message, and answered with what it had. The record shows one tool call. An answer that Realm refuses against the step’s schema uses up one of the step’s calls as well.
On a Claude model that thinks before it answers, such as Claude Sonnet 5.5, this last request leaves out the model’s earlier thinking. Anthropic refuses thinking made under a different list of tools, and this request offers only the answer tool, or none. The model still has its earlier tool calls, their results and what it wrote. This was added in 0.46.0.
A tool that the server does not have stops the step before the model is asked anything:
✗ Step 'answer' (tools) failed: Step 'answer' declares tool 'notes:read_pdf' which is not exposed by MCP server 'notes'. Check the tool name against the server's published tool list.A step that lists tools in a workflow with no mcp_servers is refused when the workflow is loaded:
Invalid workflow: Step 'answer': declares tools but the workflow defines no mcp_servers — no drive can ever offer these tools, so the declaration can never be satisfied. Define an mcp_servers block, or remove 'tools'. (step at line 14)So is a tool whose server is not in the block, and a tool_timeout on a step with no tools.
When a step fails in one of these ways while it runs, the run stays open. Fix the cause and continue it. See Operate runs.
Steps that start other runs
Section titled “Steps that start other runs”A tool server can be Realm itself, so that an agent step starts runs of other workflows. max_fan_out limits how many times the step may call start_run or start_run_batch. When the limit is reached, the model is sent the same message as above.
If you see something else
Section titled “If you see something else”MCP server 'notes': env var NOTES_TOKEN is not setA value in the server’senvnames a variable that the shell runningrealm agentdoes not have. Set it and run again.Step 'answer' (tools) failed: Cannot read properties of undefined (reading 'indexOf')The model asked for a tool that is not in the step’s list. The tool was not called. Run the step again; if it keeps happening, say in the prompt which tools the step has.
See also
Section titled “See also”- Agent-step controls reference covers
tools,max_tool_calls,max_fan_outandtool_timeoutin full. - Top-level fields reference covers
mcp_servers. - Evidence