Build the mental model
This project combines tool calling with the agent-safety principles from Lesson 18, and the combination is the entire point: a capable local coding agent and a safe one are built from the same pieces, arranged so that capability never outruns supervision.
Tool calling, from the agents chapter, is what lets the local model do more than talk — it can request read-file, list-directory, and run-tests calls, and the application executes them and returns real results the model reasons over next.
Here, the tool set is deliberately small and explicit: every tool the model can call is read-only or low-risk, listed on a fixed allowlist rather than discovered dynamically, so there is no path from "the model decided to" to "an arbitrary shell command ran."
A workspace sandbox restricts every one of those tools to a single project directory resolved and checked before each call, so a path like ../../.ssh/id_rsa is rejected the same way a request to touch a file outside the workspace would be, regardless of how the model phrases the request.
The part that makes this an agent rather than a single tool call is the loop: the model can call a tool, read the result, and decide to call another tool before answering, chaining read-file and run-tests calls until it has enough information to explain a file, point at a likely bug, or draft a fix.
The loop stops before anything destructive
But drafting a fix is where the loop stops on its own. Writing or deleting anything requires an explicit confirmation step a human approves — matching, not contradicting, the same never-autonomous-destructive-action rule Lesson 18 established.
LOCAL CODING AGENT PERMISSION LOOP
----------------------------------
USER REQUEST
|
v
AGENT (local chat model, tool-calling loop)
|
|--- tool call ---> ALLOWLISTED TOOLS (sandboxed to ./workspace)
| readFile read-only
| listDirectory read-only
| runTests low-risk, timeout-limited
|<-- tool result ------------|
|
(loop repeats: model may call more tools before it answers)
|
v
PROPOSED ANSWER
- explanation / likely-bug pointer --> shown to user directly
- proposed file write or delete --> NOT executed yet, shown as diff
|
v
HUMAN CONFIRMATION GATE (review diff, click Approve)
|
v
only now: writeFile / deleteFile actually runs on disk
(writeFile/deleteFile were never tools the model could call itself)Connect it to a real scenario
Build three pieces around one project directory: the tool layer, the agent loop, and the confirmation gate.
Build the tool layer
Define a small fixed set of functions — readFile(path), listDirectory(path), runTests() — each resolving the requested path against the workspace root and refusing anything that resolves outside it, before doing any real filesystem or process work.
Run the agent loop
Send the user's request plus the tool definitions to the local model's chat completions endpoint. Whenever the response contains a tool call, run the matching function, append the tool result to the conversation, and call the model again.
Stop at a plain answer
Repeat the loop until the model returns a plain answer instead of another tool call.
Gate destructive changes
When the agent's answer includes a proposed file write or delete, do not execute it — render the change as a diff and wait for an explicit approve action from the human.
Apply only after approval
Only after approval, call a separate writeFile or deleteFile function that the model itself was never given as a callable tool.
"Done" for this project means:
- asking it to explain a file or find a likely bug produces a correct, grounded answer built from real tool results, not guesses
- asking it to fix something produces a reviewable diff and stops there
- attempting a path outside the project directory is rejected by the tool layer itself
- no file on disk changes without a human clicking approve
Try the working example
// A local coding agent: read-only tools, a workspace sandbox, and a human
// confirmation gate before anything on disk actually changes.
import { readFile, readdir } from "node:fs/promises";
import path from "node:path";
const CHAT_URL = "http://localhost:11434/api/chat";
const CHAT_MODEL = "qwen2.5-coder:7b";
const WORKSPACE_ROOT = path.resolve("./workspace");
// Reject any path that resolves outside the workspace, however it's phrased.
function resolveInWorkspace(relativePath: string): string {
const resolved = path.resolve(WORKSPACE_ROOT, relativePath);
if (!resolved.startsWith(WORKSPACE_ROOT + path.sep)) {
throw new Error(`Path escapes workspace sandbox: ${relativePath}`);
}
return resolved;
}
// --- the allowlisted tool set: read-only / low-risk only ---
const tools = {
async readFile(relativePath: string) {
return readFile(resolveInWorkspace(relativePath), "utf-8");
},
async listDirectory(relativePath: string) {
return readdir(resolveInWorkspace(relativePath));
},
async runTests() {
// Runs the project's existing test command in a subprocess with a
// timeout; output is returned as text, never executed as new code.
return runShellWithTimeout("npm test", WORKSPACE_ROOT, 30_000);
},
};
const toolSchema = [
{ name: "readFile", description: "Read a file's contents.", parameters: { path: "string" } },
{ name: "listDirectory", description: "List files in a directory.", parameters: { path: "string" } },
{ name: "runTests", description: "Run the project's test suite.", parameters: {} },
];
type ToolCall = { name: keyof typeof tools; arguments: Record<string, string> };
type AgentMessage = { role: "system" | "user" | "assistant" | "tool"; content: string };
// --- the tool-calling loop ---
export async function runAgent(userRequest: string): Promise<string> {
const messages: AgentMessage[] = [
{
role: "system",
content:
"You are a coding assistant restricted to the current workspace. " +
"Use the provided tools to gather real evidence before answering. " +
"You may explain code, point at a likely bug, or propose a fix as " +
"a diff -- but you must never claim a file was changed. Only a " +
"human-approved confirmation step actually writes or deletes files.",
},
{ role: "user", content: userRequest },
];
for (let step = 0; step < 8; step++) {
const res = await fetch(CHAT_URL, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
model: CHAT_MODEL,
stream: false,
messages,
tools: toolSchema,
}),
});
if (!res.ok) throw new Error(`Agent request failed: ${res.status}`);
const data = (await res.json()) as {
message: { content: string; tool_calls?: ToolCall[] };
};
if (!data.message.tool_calls?.length) {
return data.message.content; // plain answer -- loop ends here
}
for (const call of data.message.tool_calls) {
const result = await tools[call.name](
...(Object.values(call.arguments) as [string]),
);
messages.push({ role: "tool", content: JSON.stringify(result) });
}
}
return "Agent stopped after too many tool-call steps without an answer.";
}
// --- confirmation gate: called only after a human clicks "Approve" ---
// writeFile/deleteFile are never in `tools`, so the model cannot call them.
export async function applyApprovedChange(
relativePath: string,
newContents: string,
): Promise<void> {
const { writeFile } = await import("node:fs/promises");
await writeFile(resolveInWorkspace(relativePath), newContents, "utf-8");
}
async function runShellWithTimeout(
command: string,
cwd: string,
timeoutMs: number,
): Promise<string> {
const { execFile } = await import("node:child_process");
return new Promise((resolve) => {
const child = execFile(command, { cwd, timeout: timeoutMs, shell: true });
let output = "";
child.stdout?.on("data", (d) => (output += d));
child.stderr?.on("data", (d) => (output += d));
child.on("close", () => resolve(output));
});
}Not runnable here
This code calls your own local AI server (e.g. Ollama) — this site's browser playground cannot reach a local server on your machine (network access is restricted there for safety). Run it yourself in a terminal or file where a local server is actually running.
When it runs correctly, asking it to explain a file returns a concrete answer grounded in real readFile/listDirectory results, and asking it to find a likely bug points at a real line or function in the code. Asking for a fix produces a diff-style proposed change with an Approve/Reject control -- nothing on disk changes until Approve is clicked. If the model attempts to read a path outside the workspace (say, ../../.env), the tool layer rejects it immediately and returns a sandbox-violation error that the agent receives back as just another tool result.5-minute try-it
Add an output-size limit to the runTests tool (for example, truncate past 200 lines) so an unusually long test run doesn't blow past the context window by itself. Make the truncation explicit in what's returned so the agent knows the output was cut.
One important caution
Putting writeFile/deleteFile in the model-callable tool list -- it bypasses the confirmation gate and lets the agent change files autonomously
Passing a raw user-supplied path straight into a filesystem call without resolving and sandbox-checking it first -- a ../ path traversal can read or write outside the workspace
OpenAI Platform -- Function Calling — Local AI / Local LLM