Build a Tool-Call Guard for Local Models in 58 Lines of Node

Build a Tool-Call Guard for Local Models in 58 Lines of Node

Dany Chausse

A zero-dependency wrapper that makes "the tool failed" impossible to mistake for "the tool found nothing" — plus a six-case harness that proves it on your own machine, and the same guard ported to Python.

The failure you are trying to prevent

A local model driving three CLI tools is a small loop: pick a tool, read the result, say something. The weak point is the middle step. When a command exits non-zero, stdout is usually empty — and an empty string inside a context window looks exactly like an empty result set. So the model fills the slot with something plausible: "the search returned a blank response", or a list of results it never received.

You cannot prompt that away reliably. You can remove the ambiguity at the seam between the model and the tool, where the exit code still exists and the raw bytes are still yours. That seam is one function.

Node 18 or newer. No dependencies. Six files by the end: three in Node, three in Python, both languages answering to the same contract.

Step 1 — Count the states before you write the wrapper

A naive wrapper returns stdout. That collapses five different situations into one string:

  • What happened — Exit code — Output the model sees
  • Command not found — 9009 on Windows cmd.exe, 127 on POSIX — often empty
  • Tool rejected the arguments — 0 — {"ok": false, "items": []}
  • Tool ran, found nothing — 0 — {"ok": true, "items": []}
  • Tool ran, found something — 0 — {"ok": true, "items": [...]}
  • Tool never exits — none — the turn simply stalls — nothing at all

Only the middle two are answers, and only one of them is a good answer. Rows one, two and five are failures wearing the costume of data — and a small model, handed the string without the context, will happily dress them up as findings.

So the guard's job is to return a typed result instead of a string. Five failure kinds (spawn, timeout, exit, protocol, refused) and two success kinds (results, empty). That is the whole vocabulary. If a state is not in it, the guard has a bug, not a judgement call.

Step 2 — Spawn, buffer, classify

exec hands you a combined blob after the fact. spawn gives you the streams and the exit status separately, which is the whole point. Create guard.mjs:

// guard.mjs — a tool-call guard for local models. Zero dependencies, Node 18+.
import { spawn } from 'node:child_process';

const MAX_DETAIL = 400;

/**
* Run a CLI tool and return a result the model cannot misread.
* On any failure `items` is null — never an empty array.
*/
export function runTool(command, args = [], { timeoutMs = 10_000 } = {}) {
return new Promise((resolve) => {
const child = spawn(command, args, { windowsHide: true });
let stdout = '';
let stderr = '';
let settled = false;
const done = (result) => {
if (!settled) { settled = true; resolve(result); }
};
const timer = setTimeout(() => child.kill(), timeoutMs);

child.stdout.on('data', (chunk) => { stdout += chunk; });
child.stderr.on('data', (chunk) => { stderr += chunk; });

child.on('error', (err) => {
clearTimeout(timer);
done({ ok: false, kind: 'spawn', exitCode: null, detail: err.message, items: null });
});

child.on('close', (exitCode, signal) => {
clearTimeout(timer);

if (signal) {
return done({ ok: false, kind: 'timeout', exitCode, detail: `killed after ${timeoutMs} ms`, items: null });
}

if (exitCode !== 0) {
const raw = stderr.trim() || stdout.trim() || 'no output on either stream';
return done({ ok: false, kind: 'exit', exitCode, detail: raw.slice(0, MAX_DETAIL), items: null });
}

let envelope;
try {
envelope = JSON.parse(stdout);
} catch {
return done({ ok: false, kind: 'protocol', exitCode, detail: 'stdout is not JSON', items: null });
}

if (envelope?.ok !== true) {
const code = envelope?.error?.code ?? 'UNKNOWN';
const message = envelope?.error?.message ?? 'ok is not true';
return done({ ok: false, kind: 'refused', exitCode, detail: `${code}: ${message}`, items: null });
}

const items = Array.isArray(envelope.items) ? envelope.items : [];
return done({
ok: true,
kind: items.length ? 'results' : 'empty',
exitCode,
detail: `${items.length} item(s)`,
items,
});
});
});
}

Three details carry the weight.

No `shell: true`. Arguments pass as an array, so a query containing & or a quote cannot become a second command.

Exit code first, JSON second. A command that failed never gets its output parsed as data. And a zero exit with nothing on stdout lands in protocol — "I do not know what this was" — instead of being parsed into an empty result.

`items: null` on every failure. This is the invariant the rest of the tutorial protects. [] can only ever mean the tool ran and found nothing. There is no second interpretation to choose from.

Count the listing if you want: 76 lines as written, 58 of code once the blanks and the comment block are gone. The count is the smallest number in this article that people will argue about, so here is the definition up front.

Step 3 — Write the sentence the model reads

The guard is useless if you hand the model its object and let it narrate. Give it a pre-rendered sentence instead. Add this to guard.mjs:

/** One sentence the model can act on, built only from fields the guard verified. */
export function describe(result) {
if (!result.ok) {
return `Tool did not run successfully (${result.kind}, exit ${result.exitCode ?? 'n/a'}): ${result.detail}. Report this as a failure; do not describe any results.`;
}
if (result.kind === 'empty') {
return 'Tool ran and returned zero items. That is a real answer: say "no results", not "the tool is broken".';
}
return `Tool returned ${result.items.length} item(s): ${JSON.stringify(result.items)}`;
}

The failure sentence carries a negative instruction on purpose. "Do not describe any results" is the part a four-billion-parameter model needs, because its default move is to fill the silence. Note that describe reads no field the guard did not produce — no result.stdout, no result.data. If a sentence can be built from an unverified field, the model will eventually quote that field as fact.

Step 4 — Prove it with six fake responses

Do not test this against a live API first. Reproduce each state deliberately with a stub, fake-cli.mjs:

// fake-cli.mjs — stands in for a real agent tool while you wire the guard up.
// Usage: node fake-cli.mjs <results|empty|refused|crash|silent|hang>
const scenario = process.argv[2];

const envelopes = {
results: {
schema_version: 1,
ok: true,
channel: 'web',
action: 'search',
items: [
{ title: 'Node.js child_process documentation', url: 'https://nodejs.org/api/child_process.html' },
{ title: 'Ollama tool calling', url: 'https://ollama.com/blog/tool-support' },
{ title: 'Agent tool-use benchmarks', url: 'https://example.dev/agent-bench' },
],
error: null,
},
empty: {
schema_version: 1,
ok: true,
channel: 'web',
action: 'search',
items: [],
error: null,
},
refused: {
schema_version: 1,
ok: false,
channel: 'web',
action: 'search',
items: [],
error: { code: 'BAD_INPUT', message: 'unknown command or channel: --version' },
},
};

if (scenario === 'crash') {
process.stderr.write('spawn ENOENT: the CLI is not on PATH in this shell\n');
process.exit(9009); // Windows "command not found"; POSIX shells use 127
}

if (scenario === 'silent') {
process.exit(0); // exit 0, nothing on stdout: the "blank response" a model will narrate
}

if (scenario === 'hang') {
// no exit code ever arrives; only the guard's timer ends this process
setInterval(() => {}, 1000);
} else {
process.stdout.write(JSON.stringify(envelopes[scenario] ?? envelopes.refused));
}

Then a harness that asserts the expected kind for each case — including that a failure never exposes an array:

// demo.mjs — proves the guard separates six cases a raw exit code conflates.
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { runTool, describe } from './guard.mjs';

const here = path.dirname(fileURLToPath(import.meta.url));
const cli = path.join(here, 'fake-cli.mjs');

const cases = [
['results', 'results', true],
['empty', 'empty', true],
['refused', 'refused', false],
['crash', 'exit', false],
['silent', 'protocol', false],
['hang', 'timeout', false],
];

let failures = 0;

for (const [scenario, expectedKind, expectedOk] of cases) {
const result = await runTool(process.execPath, [cli, scenario], { timeoutMs: 2000 });
// A failure must never expose an array: that is the whole contract.
const noArrayOnFailure = expectedOk || result.items === null;
const pass = result.kind === expectedKind && result.ok === expectedOk && noArrayOnFailure;
if (!pass) failures++;

const items = result.items === null ? 'null' : result.items.length;
console.log(`${pass ? 'PASS' : 'FAIL'} ${scenario.padEnd(8)} kind=${result.kind.padEnd(8)} ok=${String(result.ok).padEnd(5)} items=${items}`);
console.log(` model sees: ${describe(result)}`);
}

console.log(`\n${cases.length - failures}/${cases.length} cases behaved as designed.`);
process.exit(failures ? 1 : 0);

Run it and this is the terminal output, verbatim from a Windows machine:

> node demo.mjs
PASS results kind=results ok=true items=3
model sees: Tool returned 3 item(s): [{"title":"Node.js child_process documentation","url":"https://nodejs.org/api/child_process.html"},{"title":"Ollama tool calling","url":"https://ollama.com/blog/tool-support"},{"title":"Agent tool-use benchmarks","url":"https://example.dev/agent-bench"}]
PASS empty kind=empty ok=true items=0
model sees: Tool ran and returned zero items. That is a real answer: say "no results", not "the tool is broken".
PASS refused kind=refused ok=false items=null
model sees: Tool did not run successfully (refused, exit 0): BAD_INPUT: unknown command or channel: --version. Report this as a failure; do not describe any results.
PASS crash kind=exit ok=false items=null
model sees: Tool did not run successfully (exit, exit 9009): spawn ENOENT: the CLI is not on PATH in this shell. Report this as a failure; do not describe any results.
PASS silent kind=protocol ok=false items=null
model sees: Tool did not run successfully (protocol, exit 0): stdout is not JSON. Report this as a failure; do not describe any results.
PASS hang kind=timeout ok=false items=null
model sees: Tool did not run successfully (timeout, exit n/a): killed after 2000 ms. Report this as a failure; do not describe any results.

6/6 cases behaved as designed.

Note the refused row: exit code zero, and the guard still calls it a failure. That is the row a naive wrapper gets wrong. And note the hang row: the child slept for sixty seconds, the guard cut it at two, and what came back is a sentence rather than a stalled turn.

Step 5 — Break the guard and watch the test fail

A test that cannot fail is a demo. Change items: null to items: [] in guard.mjs and run demo.mjs again: four rows flip to FAIL with items=0 on a tool that never returned anything, the summary reads 2/6, and the process exits 1. That is the regression you are buying. If someone "simplifies" the guard later, CI says no.

The check is worth automating the moment you have two of anything. check.ps1 runs both harnesses, breaks each guard in a temporary copy, and asserts the break is caught:

> powershell -File check.ps1
PASS node:demo exit=0
PASS python:demo exit=0
PASS node:broken exit=1
PASS python:broken exit=1
PASS python:stdlib-only import json; import subprocess
PASS node:core-only node:child_process
RESULT: PASS

The last two lines matter more than they look: a guard that pulls in a dependency is a guard that can fail to import, and an agent harness that cannot import its own safety check is worse than one that skipped it.

Step 6 — Decide what happens when the tool hangs

The timeout is the one failure a model cannot see at all. No exit code ever arrives, so the turn simply stalls, and the loop either retries the same call or narrates the pause as progress. Ten seconds is a defensible default for a local search or fetch; raise it per tool rather than globally. The hang case above exists because this branch is the one every implementation gets wrong — they write the timer and never give the model a sentence to read when it fires.

Keep timeoutMs in the signature, not in a constant at the top of the module. The tool that needs sixty seconds is usually the one you wrote, and you want that exception visible at the call site rather than buried in a config file nobody reads.

Step 7 — Point it at the real tool

Swap the command and its arguments:

const result = await runTool('C:\\Users\\you\\.local\\bin\\your-tool.exe', ['web', 'search', query]);
console.log(describe(result));

Pass describe(result) to the model, not result. Keep the whole object in your own log line, next to the sentence the model received — that pairing is how you catch the model contradicting a result it was actually given, which the guard cannot prevent.

Step 8 — The same guard in Python

Most local model stacks are Python: Ollama's clients, llama.cpp's bindings, anything LangChain-shaped. The same six states come out of subprocess, and the only structural difference is that the timer raises instead of returning: subprocess.run(timeout=...) throws TimeoutExpired, which is a cleaner seam than Node's signal argument.

"""guard.py — the same tool-call guard, in Python. Standard library only."""
import json
import subprocess

MAX_DETAIL = 400


def run_tool(command, args=None, timeout_s=10.0):
"""Run a CLI tool and return a result the model cannot misread.
On any failure ``items`` is None — never an empty list."""
try:
proc = subprocess.run(
[command, *(args or [])],
capture_output=True,
text=True,
encoding="utf-8",
errors="replace", # a tool that emits cp1252 must not crash the guard
timeout=timeout_s,
)
except FileNotFoundError as err:
return {"ok": False, "kind": "spawn", "exit_code": None, "detail": str(err), "items": None}
except subprocess.TimeoutExpired:
return {"ok": False, "kind": "timeout", "exit_code": None,
"detail": f"killed after {timeout_s:.2f} s", "items": None}

if proc.returncode != 0:
raw = (proc.stderr or "").strip() or (proc.stdout or "").strip() or "no output on either stream"
return {"ok": False, "kind": "exit", "exit_code": proc.returncode,
"detail": raw[:MAX_DETAIL], "items": None}

try:
envelope = json.loads(proc.stdout or "")
except json.JSONDecodeError:
return {"ok": False, "kind": "protocol", "exit_code": proc.returncode,
"detail": "stdout is not JSON", "items": None}

if not isinstance(envelope, dict) or envelope.get("ok") is not True:
error = envelope.get("error") if isinstance(envelope, dict) else None
detail = (f"{error.get('code', 'UNKNOWN')}: {error.get('message', 'ok is not true')}"
if isinstance(error, dict) else "ok is not true")
return {"ok": False, "kind": "refused", "exit_code": proc.returncode,
"detail": detail, "items": None}

items = envelope.get("items")
items = items if isinstance(items, list) else []
return {
"ok": True,
"kind": "results" if items else "empty",
"exit_code": proc.returncode,
"detail": f"{len(items)} item(s)",
"items": items,
}


def describe(result):
"""One sentence the model can act on, built only from fields the guard verified."""
if not result["ok"]:
code = "n/a" if result["exit_code"] is None else result["exit_code"]
return (f"Tool did not run successfully ({result['kind']}, exit {code}): "
f"{result['detail']}. Report this as a failure; do not describe any results.")
if result["kind"] == "empty":
return ('Tool ran and returned zero items. That is a real answer: '
'say "no results", not "the tool is broken".')
return f"Tool returned {len(result['items'])} item(s): {json.dumps(result['items'], ensure_ascii=False)}"

Two lines carry the port's weight.

`errors="replace"`. Windows tools still emit cp1252, and a single undecodable byte raises UnicodeDecodeError inside the guard — which would make the guard itself the crash it exists to prevent. Decoding with replacement keeps the failure inside the envelope where it can be classified.

`except FileNotFoundError`. Python raises on a missing binary rather than reporting an exit code, so the spawn kind has to be caught, not parsed. Without that clause the exception escapes into your agent loop, where the model never sees it at all.

describe() is the same function with result["items"] is None where Node had items === null. Python spells the same absence as None, and the harness asserts on that identity rather than on falsiness — [] and None are both falsy, and conflating them is exactly the bug being defended against.

fake_cli.py mirrors the Node stub, including a hang scenario that sleeps for sixty seconds, and demo.py is the same six-case harness with the same table. The harness passes sys.executable as the command so the child runs under the interpreter already running the test, rather than whichever python happens to be first on PATH:

"""fake_cli.py — stands in for a real agent tool while you wire the guard up.
Usage: python fake_cli.py <results|empty|refused|crash|silent|hang>
"""
import json
import sys
import time

scenario = sys.argv[1] if len(sys.argv) > 1 else "refused"

ENVELOPES = {
"results": {
"schema_version": 1,
"ok": True,
"channel": "web",
"action": "search",
"items": [
{"title": "subprocess documentation", "url": "https://docs.python.org/3/library/subprocess.html"},
{"title": "Ollama tool calling", "url": "https://ollama.com/blog/tool-support"},
{"title": "Agent tool-use benchmarks", "url": "https://example.dev/agent-bench"},
],
"error": None,
},
"empty": {
"schema_version": 1,
"ok": True,
"channel": "web",
"action": "search",
"items": [],
"error": None,
},
"refused": {
"schema_version": 1,
"ok": False,
"channel": "web",
"action": "search",
"items": [],
"error": {"code": "BAD_INPUT", "message": "unknown command or channel: --version"},
},
}

if scenario == "crash":
sys.stderr.write("OSError: the CLI is not on PATH in this shell\n")
sys.exit(9009) # Windows "command not found"; POSIX shells use 127

if scenario == "silent":
sys.exit(0) # exit 0, nothing on stdout: the "blank response" a model will narrate

if scenario == "hang":
time.sleep(60) # no exit code ever arrives; only the guard's timer ends this
sys.exit(0)

print(json.dumps(ENVELOPES.get(scenario, ENVELOPES["refused"])))
"""demo.py — proves the guard separates six cases a raw exit code conflates."""
import os
import sys

sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
from guard import run_tool, describe # noqa: E402

HERE = os.path.dirname(os.path.abspath(__file__))
CLI = os.path.join(HERE, "fake_cli.py")

CASES = [
("results", "results", True),
("empty", "empty", True),
("refused", "refused", False),
("crash", "exit", False),
("silent", "protocol", False),
("hang", "timeout", False),
]

failures = 0

for scenario, expected_kind, expected_ok in CASES:
result = run_tool(sys.executable, [CLI, scenario], timeout_s=2.0)
# A failure must never expose a list: that is the whole contract.
no_list_on_failure = expected_ok or result["items"] is None
passed = result["kind"] == expected_kind and result["ok"] == expected_ok and no_list_on_failure
if not passed:
failures += 1

items = "None" if result["items"] is None else len(result["items"])
print(f"{'PASS' if passed else 'FAIL'} {scenario:<8} kind={result['kind']:<8} "
f"ok={str(result['ok']).lower():<5} items={items}")
print(f" model sees: {describe(result)}")

print(f"\n{len(CASES) - failures}/{len(CASES)} cases behaved as designed.")
sys.exit(1 if failures else 0)

Run it and the verdicts are the same six, in the same order, with None where Node printed null:

> python demo.py
PASS results kind=results ok=true items=3
model sees: Tool returned 3 item(s): [{"title": "subprocess documentation", "url": "https://docs.python.org/3/library/subprocess.html"}, {"title": "Ollama tool calling", "url": "https://ollama.com/blog/tool-support"}, {"title": "Agent tool-use benchmarks", "url": "https://example.dev/agent-bench"}]
PASS empty kind=empty ok=true items=0
model sees: Tool ran and returned zero items. That is a real answer: say "no results", not "the tool is broken".
PASS refused kind=refused ok=false items=None
model sees: Tool did not run successfully (refused, exit 0): BAD_INPUT: unknown command or channel: --version. Report this as a failure; do not describe any results.
PASS crash kind=exit ok=false items=None
model sees: Tool did not run successfully (exit, exit 9009): OSError: the CLI is not on PATH in this shell. Report this as a failure; do not describe any results.
PASS silent kind=protocol ok=false items=None
model sees: Tool did not run successfully (protocol, exit 0): stdout is not JSON. Report this as a failure; do not describe any results.
PASS hang kind=timeout ok=false items=None
model sees: Tool did not run successfully (timeout, exit n/a): killed after 2.00 s. Report this as a failure; do not describe any results.

6/6 cases behaved as designed.

The items=None column is the contract holding in a second language. A failure cannot masquerade as an empty list, because there is no list to read — and any downstream code that tries len(result["items"]) on a failure crashes loudly in your logs instead of quietly telling the model there were zero results. That crash is the feature.

Put it in front of every tool

Search, fetch, shell: each one gets a runTool call and a kind in the log. If you write one JSON line per call, the log becomes queryable — count "kind":"exit" across a week and you have the number this whole exercise exists to produce: how often the agent was working from a failure it never mentioned. Take that count before you switch models and after, and you will know whether the upgrade paid for itself.

What this does not fix

A tool that exits 0 with plausible but wrong data. The guard verifies that the call happened and that the envelope is honest. It cannot verify the payload. That still needs a check against the source.

A model that ignores the sentence. The guard narrows the hole from "indistinguishable" to "explicitly labelled". It does not close the loop. Count unverified claims per hundred successful calls on your own workload; that number is the one that tells you whether the cheap model is cheap.

Process trees. child.kill() on timeout kills the direct child, not its grandchildren; subprocess.run's timeout has the same scope — it kills the process it started and waits for the pipes, so a grandchild holding stdout open still stalls the call. On Windows, taskkill /PID <pid> /T /F; on POSIX, spawn with detached: true and kill -pid.

Long output. detail is truncated to 400 characters. That is deliberate — a stack trace pasted into a context window will eat the turn budget you were trying to save.

The guard is fifty-eight lines of Node, or fifty-five of Python, because it does one thing: it makes the difference between no answer and no results impossible to lose. Every other reliability trick in an agent stack is downstream of that. Start here.

Run it: `node demo.mjs` in the folder holding the three `.mjs` files (Node 18+, no `npm install`), or `python demo.py` beside the three `.py` files (standard library only). `check.ps1` runs both and then proves both harnesses fail when the guard is broken. Both outputs above were produced on Windows with Node 18.16.0 and Python 3.12.15. Word count: 1,766 of prose, 3,475 including the code.

Report Page