Who owns the loop

An agent is not a new kind of component. It is one wiring: reason, act, observe, repeat — a while around a single-turn filter, with tool dispatch in the body and the transcript on disk as memory.

Every agent harness on offer is a chatbot runtime: a loop, tools, a context, a session store. The operating system has been all four for fifty years. The shell is the loop, every program is a tool, the pipe is the context wire, the filesystem is the store. There is no harness to build here; there is a userland to populate.

What that leaves is one question, and it is the whole line between an SDK and a framework:

Your script owns the while. The capability is a filter it calls.

A framework inverts exactly this. It owns the loop and calls your code, so your logic arrives as callbacks, handlers, hooks, nodes in someone else’s graph — and from that moment everything you want to do that the author did not anticipate is a fight. Inversion of control is a fine bargain when the loop is boring and the framework is right. It is a terrible one when the loop is the product, which for an agent it is.

The loop, entire

Nothing below is pseudocode. It is a POSIX sh script, run against a fixture device so that it needs no credential:

#!/bin/sh
# The loop is yours. `llm` is a single-turn filter it calls.
set -e
DEV=/dev/llm/fixture/two-cities
TYPED="--input-format typed --input-encoding json --output-format typed --output-encoding json"

chat-codec message --user "$1" > transcript.jsonl

while :; do
    llm -d "$DEV" $TYPED < transcript.jsonl | chat-codec fold >> transcript.jsonl
    turn=$(tail -n 1 transcript.jsonl)

    case "$turn" in
        *functionCall*) ;;
        *) break ;;
    esac

    printf '%s' "$turn" | jq -c '{
        role: "CHAT_ROLE_USER",
        content: [ .content[].functionCall
                   | { functionResult: { callId: .id,
                                         content: [ { text: { text: ( .argumentsJson | fromjson | .city
                                                                      | if . == "Recife" then "28C" else "29C" end ) } } ] } } ]
    }' >> transcript.jsonl
done

printf '%s' "$turn" | jq -r '.content[].text.text'
$ ./agent.sh 'weather in both cities?'
29°C e céu limpo.

And the memory it accumulated, which is the entire state of that agent:

{"role":"CHAT_ROLE_USER","content":[{"text":{"text":"weather in both cities?"}}]}
{"role":"CHAT_ROLE_ASSISTANT","content":[{"functionCall":{"id":"a","name":"get_weather","argumentsJson":"{\"city\":\"Recife\"}"}},{"functionCall":{"id":"b","name":"get_weather","argumentsJson":"{\"city\":\"Olinda\"}"}}]}
{"role":"CHAT_ROLE_USER","content":[{"functionResult":{"callId":"a","content":[{"text":{"text":"28C"}}]}},{"functionResult":{"callId":"b","content":[{"text":{"text":"29C"}}]}}]}
{"role":"CHAT_ROLE_ASSISTANT","content":[{"text":{"text":"29°C e céu limpo."}}]}

Read what is and is not there. The conversation goes in as well as out — --input-format typed makes stdin a transcript, one message per line — so llm holds nothing between turns and is born and dies once per turn. There is no session object, because the session is a file. There is no memory subsystem, because memory is >>. There is no tool-dispatch abstraction: a function call is a record in a stream, and jq — a program that predates all of this and knows nothing about it — builds the reply. chat-codec fold closes each turn and feeds the next; its own --help documents the idiom as fold >> messages.jsonl.

The fixture’s answers are scripted, so what this shows is the circuit and the shape of the data crossing it, not a model’s behaviour. Point -d at /dev/llm/openai/gpt-4o-mini and the script does not change — the vendor is a driver, which is the kernel’s chapter.

Two things in it are worth reading twice. The dispatch line assembles a function result as JSON by hand, because a function result is a record on a wire and not a call into anything — jq builds it as readily as any library would. And the loop terminates by looking for functionCall in the last record: a shell reading a stream, rather than a state machine somebody had to be given.

Sophistication lives in the wiring

Once the loop is yours, everything that makes an agent good is a change to the schematic rather than a heavier component: retrieval before a turn, planning across several, summarising the transcript when it grows, a gate that asks a person before an expensive act, a second model reviewing the first. Each of those is a line in that while, in whatever language you like, using whatever program on your machine does the job.

And agency does not take the loop back. An actor here is woken by an occurrence, and from that instant it owns its control flow entire — nothing on the floor calls into your code, which is why an application can be built from actors without any of them being a plugin.