Giving an LLM a Shell: Notes From Building a Tiny Agent
An agent is mostly a while-loop with good manners. Here is the smallest useful version, and the three things that actually made it reliable.
There’s a lot of mystique around “AI agents.” Most of it evaporates the moment you write one. At its core, an agent is a loop: ask the model what to do, do it, feed the result back, repeat until it says it’s done.
This post is that loop, and the three unglamorous fixes that took it from “cute demo” to “actually finishes the task.”
The whole loop
Here is the entire control flow, in TypeScript. Everything else is detail:
async function runAgent(task: string) {
const messages = [{ role: 'user', content: task }];
for (let step = 0; step < MAX_STEPS; step++) {
const reply = await model.chat({ messages, tools });
if (reply.toolCalls.length === 0) {
return reply.content; // model is done
}
for (const call of reply.toolCalls) {
const output = await runTool(call.name, call.args);
messages.push({ role: 'tool', name: call.name, content: output });
}
}
throw new Error('Agent hit the step limit without finishing.');
}
That’s it. The “intelligence” is the model; the “agency” is you letting its output pick the next tool call.
Tools are just typed functions
The one tool that makes it useful is a shell. You expose it with a schema so the model knows how to call it:
{
"name": "run_shell",
"description": "Run a shell command and return stdout + stderr.",
"input_schema": {
"type": "object",
"properties": {
"command": { "type": "string" }
},
"required": ["command"]
}
}
The three fixes that mattered
The naive loop works about 40% of the time. Three changes got it past 90% on my little benchmark:
- Return errors as data, not exceptions. When a command fails, feed the model the exit code and stderr instead of crashing the loop. It reads the error and fixes its own mistake far more often than you’d expect.
- Cap the output. Piping 200KB of log into the context poisons everything after it. Truncate tool output to a few kilobytes and tell the model you did.
- Make “done” explicit. Give the model a
finishtool. Guessing whether a plain text reply means “done” or “thinking out loud” is a coin flip; an explicit signal is not.
Where this goes
This tiny loop is the seed of every “coding agent” you’ve seen. The production versions add planning, memory, and a lot of guardrails - but the beating heart is still while (not done) { ask; act; observe; }.
If you build one, start with the sandbox and the error-as-data trick. Everything else is polish.