Skip to content
Yaman Jain
3 min read

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:

  1. 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.
  2. 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.
  3. Make “done” explicit. Give the model a finish tool. 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.


Enjoyed this? Get the next one by email.

I publish deep dives like this every few weeks. Drop your email and I'll send them straight to your inbox.