Tools
Also known as: function calling, actions, plugins.
One tool, unlimited reach. Four tools, no reach beyond the four. Same model, same weights — the entire blast radius of what it can do lives in what got named, typed, and handed to it to call.
Problem
Debug agent posts a live webhook secret into #eng-status
An on-call agent, asked to find out why the billing-webhook integration test was failing, was given exactly one tool: a shell. It found the bug. It also quoted a live secret, verbatim, in the status update it was told to post.
The tool it had access to — the entire action surface handed to the model — was one function wide:
export const runShellTool = {
name: 'run_shell',
description: 'Run any shell command in the project root and return its stdout.',
parameters: {
type: 'object',
properties: {
command: { type: 'string' },
},
required: ['command'],
},
handler: async ({ command }: { command: string }) => {
return execSync(command, { encoding: 'utf-8' });
},
};One name, one string parameter, no boundary on what command is allowed to be. Watch what a reasonable debugging instinct does with that much room:
Nothing here is a rogue model. env | grep STRIPE is exactly what a competent engineer runs when a secret might not be loading — it found the real bug on the second call. The failure belongs to the tool: run_shell can't tell "debug why this test fails" from "print every secret on the box," so neither could the model. A wider interface didn't make the model less careful — it just gave carefulness nowhere to stand.
Solution
Same model, same incident. What changes is the shape of what it's allowed to reach for — one shell replaced by a small set of tools, each scoped to exactly the action its name promises:
const SECRET_KEY_PATTERN = /(SECRET|TOKEN|KEY|PASSWORD)/i;
export const readEnvTool = {
name: 'read_env',
description:
"Check whether an environment variable is set. Returns the value for ordinary config, " +
"or '[REDACTED - set]' / '[unset]' for anything whose name looks like a secret — never the value itself.",
parameters: {
type: 'object',
properties: {
key: { type: 'string', pattern: '^[A-Z0-9_]+$' },
},
required: ['key'],
additionalProperties: false,
},
handler: async ({ key }: { key: string }) => {
const value = process.env[key];
if (value === undefined) return '[unset]';
if (SECRET_KEY_PATTERN.test(key)) return '[REDACTED - set]';
return value;
},
};read_env doesn't refuse to answer — it answers truthfully that the key is set, and refuses to be the thing that ever prints its value. The redaction isn't a system-prompt instruction the model could forget or an attacker could talk it out of; it's a branch in code that runs whether or not the model asked nicely.
Same model, both tool surfaces it actually reached for:
export const runShellTool = {
name: 'run_shell',
description: 'Run any shell command in the project root and return its stdout.',
parameters: {
type: 'object',
properties: {
command: { type: 'string' },
},
required: ['command'],
},
handler: async ({ command }: { command: string }) => {
return execSync(command, { encoding: 'utf-8' });
},
};Redaction is only half of it. Before read_env's handler runs at all, the loop checks the call against a schema — a malformed or out-of-shape request never reaches your code:
import Ajv from 'ajv';
const ajv = new Ajv();
const tools = [runTestsTool, readEnvTool, readFileTool];
export async function dispatchToolCall(call: { name: string; arguments: unknown }) {
const tool = tools.find((t) => t.name === call.name);
if (!tool) throw new Error(`Unknown tool: ${call.name}`);
const validate = ajv.compile(tool.parameters);
if (!validate(call.arguments)) {
// Malformed call dies here — the handler never runs, never sees it.
return { error: ajv.errorsText(validate.errors) };
}
return tool.handler(call.arguments as never);
}{
"type": "object",
"properties": {
"key": { "type": "string", "pattern": "^[A-Z0-9_]+$" }
},
"required": ["key"],
"additionalProperties": false
}The boundary lives in the handler, not in the model's judgment — and the schema is what lets the loop enforce that boundary before a single line of the handler runs. Nothing about the model changed between the two sessions above. What changed is how much of the world it could name.
Structure
Every tool is three parts bolted together. Hover or tap one.
The real code that runs once validation passes. Guardrails a schema cannot express — allowlists, redaction, rate limits — live here, enforced by code the model cannot argue with.
Applicability
Narrow a tool whenever a wide one would let the model do something you didn't ask for, not just something you did.
A single run_shell is faster to build and covers every future need you haven't thought of yet — which is exactly the problem. Its blast radius is the whole machine, and no amount of prompting shrinks that back down. A schema can only be checked against a shape you actually declared; "a string, unrestricted" isn't one.
| Signal | Verdict |
|---|---|
| The action can leak, delete, or mutate something outside the task | ✓ use it |
| The loop needs to validate a call before it runs | ✓ use it |
| Multiple call sites need the exact same guardrail | ✓ use it |
| Prototyping, trusted operator, fully sandboxed environment | − skip it |
| The real constraint is judgment, not shape | − skip it |
Tools are what a loop reaches through — the loop itself is what decides when to reach. Magent's tool layer follows this same narrowing discipline end to end — github.com/palamim/magent.
Context
Uncompacted, a transcript re-proposes the fix it already ruled out three tool calls ago. Curated, it remembers.
→ Explore Context