I wrote a coding agent by hand

I shipped a RAG pipeline on LangGraph and could not have told you what LangGraph was doing. So I built a terminal coding agent from scratch, against the raw API, with no framework. The loop turned out to be fifteen lines. Everything hard was somewhere else.

I had just shipped a RAG pipeline on LangGraph and could not have told you what LangGraph actually did. I knew which functions to call and what shape the state took. I could not have told you what happened between my code and the model, or why a run sometimes stalled. So I wrote a coding agent from scratch, against the raw chat-completions API, with no framework anywhere. It is called braze and it is about 420 lines.

It works the way you would expect a coding agent to work. You stand in a repo, tell it what you want, and it reads your files, edits them, runs your tests, and keeps going until they pass.

Two failing tests, one sentence of instruction, seven turns. The follow-up question at the end is answered from the same transcript.

The model cannot do anything

This is the thing I did not properly understand until I wrote the loop, and everything else follows from it. The model has no filesystem, no shell, no memory between calls. It is a function: text in, text out. That is the whole capability.

So when you give it a read_file tool, you have not given it the ability to read files. You have given it a vocabulary for asking you to read one. It emits a message meaning "please run read_file with this path and tell me what you get", and then it stops, because there is nothing else it can do.

The first time I ran this I thought it was broken. The reply came back with content set to null and I could not work out what had gone wrong. Nothing had. The model had asked me a question and my program hung up on it.

An agent is a conversation with someone who has no hands. They can tell you which drawer to open. They cannot open it.

The loop is fifteen lines

Once the round trip works, the agent is a while loop around it. Call the API, record what came back, and if it asked for tools, run them, record the results, and go again. That is it. That is the ReAct loop.

python
while True:
    completion = client.chat.completions.create(
        model=MODEL, tools=TOOL_SCHEMAS, messages=messages,
    )

    message = completion.choices[0].message
    messages.append(message)

    if not message.tool_calls:
        return message.content

    for call in message.tool_calls:
        result = run_tool(call.function.name, call.function.arguments)
        messages.append({
            "role": "tool",
            "tool_call_id": call.id,
            "content": result,
        })

Every framework I have used is wrapping those lines. LangChain's AgentExecutor is that loop. The tool decorator is the schema generation. ToolNode is the dispatch. Seeing the correspondence was worth more to me than any amount of documentation, because now when a LangGraph run misbehaves I know which of those six lines is misbehaving.

The distinction I had been fuzzy on also became sharp. My RAG pipeline was a workflow: retrieve, rerank, grade, generate, in that order, decided when I wrote it. This is an agent: the model picks the next move every turn and I do not know the path in advance. Same model, same API, completely different control flow.

A prompt is a wish, a counter is a guarantee

The first version had no exit. I asked it for something ambiguous and watched it call read_file on a file that did not exist, over and over, spending money each time.

The fix is three counters: a turn cap, a wall clock cap, and a token budget. All three are checked before the request, not after, because checking after means you already paid for the call that broke the budget. On a long transcript that is real money.

None of it goes in the system prompt. I tested that too. I wrote "only answer questions about code" into the prompt, then asked it the capital of France. It said Paris. It read the instruction, weighed it against being helpful, and decided one word was harmless. Meanwhile the turn cap has never once been negotiated with.

That is the whole lesson. If a constraint matters, it lives in code. Everything you put in the prompt is a request the model is free to decline.

A tool result is the model's entire view of the world

This is where most of the actual engineering turned out to be, and it is the part no tutorial had prepared me for.

Early on my read_file raised FileNotFoundError when the model asked for a path with a typo in it. The exception propagated, the process died, and the whole run was gone. Notice who got punished there. The model made a small recoverable mistake and my program was the thing that fell over.

The correct response is to hand back a sentence it can act on. Not a traceback, a sentence that says what went wrong and what to try instead.

python
def run_tool(name: str, raw_arguments: str) -> str:
    """Run one tool call. Never raises, because a raise here kills the run."""
    fn = TOOL_FUNCTIONS.get(name)
    if fn is None:
        return f"Error: no tool named {name!r}. Available: {', '.join(TOOL_FUNCTIONS)}"
    try:
        args = json.loads(raw_arguments)
    except json.JSONDecodeError as exc:
        return f"Error: arguments for {name} were not valid JSON: {exc}"
    try:
        return truncate(str(fn(**args)))
    except Exception as exc:
        return f"Error: {type(exc).__name__}: {exc}"

That rule lives in one place rather than in each of the seven tools, so no individual tool can forget it. A tool that raises is no longer trusted to handle its own failure, because it does not have to be.

The results themselves get shaped for a reader too. read_file numbers its lines so the model can refer to them. Long output is truncated in the middle rather than the end, because the tail of a traceback is the useful half. write_file returns "wrote wc.py (38 lines)" instead of echoing the file back, which would be paying twice for text the model just sent.

The bug that succeeded

My favourite failure took ten turns and left no error at all.

The agent ran pytest, got "command not found", concluded pytest was unavailable, wrote its own test harness into a scratch file, verified against that instead, reported success, and left the file behind. The task did genuinely get done. Nothing looked wrong from the outside.

The cause was that launching via venv/bin/python does not put venv/bin on PATH, so the subprocess could not find pytest. One line prepending the running interpreter's directory fixed it, and the same task then took eight turns against the real test suite with no leftovers.

The dangerous failure is not the run that crashes. It is the run that finishes successfully having done the wrong thing.

The bug was in the tool, not the prompt. That happens more than you would think. Across the whole build, every failure I hit was in my code: bytes where a string was needed, Python's builtin id shadowing the tool call id, a tool returning a list when the API wanted text. The model was never the problem.

Confinement, and being honest about its ceiling

The agent decides what to do at runtime and you cannot review those decisions in advance. write_file will write to whatever path arrives in a JSON string, and that string came from a model influenced by whatever it has just read. So the boundary cannot be "the model behaves". It has to be code that refuses regardless.

python
def resolve(path: str) -> Path:
    """Resolve a path against the workspace, refusing anything that escapes it.

    The check runs after resolving because pathlib lets an absolute right-hand
    side win: WORKSPACE / "/etc/passwd" is simply /etc/passwd.
    """
    target = (WORKSPACE / path).resolve()
    if target != WORKSPACE and WORKSPACE not in target.parents:
        raise PermissionError(f"path escapes the workspace: {path!r}")
    return target

The ordering in that docstring is the entire trick. My first instinct was to screen the incoming string for double dots, which catches the obvious attack and misses the two that matter. An absolute path contains no double dots. Neither does a symlink pointing out of the directory, which looks like an ordinary relative path right up until you follow it.

Every filesystem tool routes through that one function, so there is one place to get right and one place to audit. I attacked it with a relative escape, an absolute path, and a symlink to /etc. All three refused.

And then there is run_command, which gets none of this. It hands a string to a shell, never constructs a Path, and cat ../../.ssh/id_rsa walks straight out. There is nothing to check because there is no path to check. The only gate is asking the user first, and a --yes flag removes even that.

That is the ceiling of the approach, and it is worth naming rather than papering over. This kind of confinement is an if statement inside your own process. It holds until your check has a bug, or a tool forgets to call it. Real isolation is a container with no network and no access to your home directory, where a successful escape lands somewhere that cannot hurt you. That is a different project.

Making it usable

The agent worked well before it was pleasant to use. Every tool call printed as a raw JSON blob, there was no signal during a thirteen-second API call, and errors looked exactly like successes.

Two decisions did most of the work. Each tool call became one line carrying a glyph, the tool name, the single argument worth seeing, and a right-aligned note about what came back, so a seven-step run reads as a column you can skim. And body text gets no foreground colour at all, which is deliberate rather than lazy: prose inherits the terminal's own colour and stays legible on light and dark alike, while only the chrome is coloured, in mid-tones that survive both.

Nothing is signalled by colour alone either. A failed call carries its own glyph, so the output still parses in a monochrome terminal.

What I would tell myself before starting

None of this made me stop using frameworks. It made the framework legible. When a LangGraph run stalls now, or burns tokens, or ends successfully having done nothing, I know which part is misbehaving instead of reading library source at midnight.

The code is on GitHub, including the practice repo it was built against. If you want to do this yourself, the piece worth writing by hand is the loop. Everything after it is plumbing, and the plumbing is where you will spend your time anyway.