Nano Agent Loop
What an Agent Actually Does Between Prompts
This project started when I briefly saw a mention of a new Elixir wrapper around a bash sandbox. I had already worked on an agent harness, so I thought it would be good practice to build the loop again, this time as small as possible.
I wanted to make the agent loop visible without hiding it behind a framework.
I was also inspired by the Pi harness, which describes itself as a minimal agent harness. So I decided to see how quickly I could build the smallest version of the loop.
The result was elixir-nano-agent-loop.
The project name is a reference to Karpathy’s nanoGPT and nanochat projects.
Nano Agent Loop follows the same spirit, but focuses only on the agent loop. It is not a framework, a durable runtime, or a production agent system. It is a tiny example of the loop itself: prompt, model, tool, output, next step.
This matters because agent systems often look more mysterious than they are.
A good harness is still a complex system: tools, storage, sandboxing, UI, loops, queues, evals, and so on.
But if we remove all the scaffolding, the core idea is simple: the loop.
The Loop
- Send the current context to the model
- Let the model either answer or request a tool call
- Execute the tool call in the host program
- Append the tool result back into the conversation
- Repeat until the model produces a final answer
The interesting part is that the model can form a hypothesis, test it by running a deterministic command, read the output, notice that its assumption was incomplete, and choose the next step. This is a simple but powerful feedback loop.
If we trim it down to the basics, an agent loop is mostly a state transformation. There is a conversation, a set of tools, and a function that keeps asking the model what should happen next.
In simplified Elixir, the shape looks like this:
def run() do
tool = Tool.new!(name: "bash", callback: fn commands -> run_bash(commands) end)
context = Context.new([Context.system(@system_prompt), Context.user(prompt)])
loop(context, tool)
end
defp run_bash(commands) do
tool_output = Session.exec(commands)
tool_output
end
defp loop(context, tool) do
case ReqLLM.generate_text(context, [tool]) do
%{type: :tool_calls, tool_calls} ->
next_context = Context.execute_and_append_tools(tool_calls, tool)
loop(next_context, tool)
%{text: text} ->
text # final result
end
end
The model does not execute the tool. The harness does that. The model only asks to call a tool:
%{
name: "bash",
arguments: %{
command: "wc -l tx.txt && head -5 tx.txt"
}
}
The harness decides whether the request is allowed. Then it runs the command in a constrained environment, captures the output, and feeds the result back.
This closes the loop. The output becomes part of the next model call. The next step is no longer based only on the prompt. It is based on what actually happened.
Step by step, the LLM can move closer to the goal. It can also get stuck, or repeat the same kind of step in a cycle.
Demo Time
Let’s see how it works in action:
elixir nano_agent_loop.exs "Create /work/sales.csv, compute revenue by product with shell commands, write /work/report.txt, and show it."
The loop was just 5 turns:
turn 1
assistant -> bash
ls -la /work 2>/dev/null || mkdir -p /work && ls -la /work
tool ->
<no output>
turn 2
assistant -> bash
cat > /work/sales.csv <<'EOF'
product,quantity,unit_price
Widget,10,2.50
Gadget,5,9.99
Widget,4,2.50
Dadjet,7,3.75
Gadget,3,9.99
Dadjet,2,3.75
EOF
printf 'Created sales.csv:\n'
sed -n '1,20p' /work/sales.csv
tool ->
Created sales.csv:
product,quantity,unit_price
Widget,10,2.50
Gadget,5,9.99
Widget,4,2.50
Dadjet,7,3.75
Gadget,3,9.99
Dadjet,2,3.75
turn 3
assistant -> bash
awk -F, 'NR>1 {rev[$1]+=$2*$3} END {printf "Revenue by product\n"; printf "%-10s %10s\n", "Product", "Revenue"; for (p in rev) printf "%-10s %10.2f\n", p, rev[p]}' /work/sales.csv | sort -k1,1 > /work/report.tmp
# Keep header first, sorted data after header
{ sed -n '1,2p' /work/report.tmp; sed -n '3,$p' /work/report.tmp | sort; } > /work/report.txt
rm /work/report.tmp
printf 'Report contents:\n'
sed -n '1,20p' /work/report.txt
tool ->
Report contents:
Dadjet 33.75
Gadget 79.92
Product Revenue
Revenue by product
Widget 35.00
turn 4
assistant -> bash
{
printf 'Revenue by product\n'
printf '%-10s %10s\n' 'Product' 'Revenue'
awk -F, 'NR>1 {rev[$1]+=$2*$3} END {for (p in rev) printf "%-10s %10.2f\n", p, rev[p]}' /work/sales.csv | sort
} > /work/report.txt
printf 'Report contents:\n'
sed -n '1,20p' /work/report.txt
tool ->
Report contents:
Revenue by product
Product Revenue
Dadjet 33.75
Gadget 79.92
Widget 35.00
turn 5
final:
Created `/work/sales.csv`, computed revenue by product using `awk`, wrote `/work/report.txt`, and verified/showed the report:
Revenue by product
Product Revenue
Dadjet 33.75
Gadget 79.92
Widget 35.00
The useful part is not that it succeeds immediately. The useful part is that it can notice a bad result and try again.
This kind of loop was not always useful enough on its own. As LLMs became better, the same simple loop became much more useful.
What This Is Not
This nano loop is intentionally small, so it leaves many useful parts of a modern harness out of scope. But even without these parts, a tiny implementation can still be useful. If you give the model one capable tool, like bash, the loop already has enough to do real work.
Make the loop small enough, and the magic disappears. What remains is just a feedback loop: try, observe, repeat.