Architecture
Trace the exact boundary between model, tools, events, and UI.
tinyloop is useful because the important agent mechanics are visible. A user message entersAgentSession, Agent asks the model what to do, tools run locally only when requested, and the terminal UI learns what happened from ordered events.
Agent loop
The model is asked again only when tools ran.
Agent.runOneUserTurn is a small loop around the Responses API. A model response either ends the turn with text, or asks tinyloop to run one or more local tools and send their outputs back as the next input.
- inputuser input
One message enters the turn.
- modelresponses.create
The model sees input plus tool definitions.
- toolsrunToolCalls
Local handlers run only if function calls were returned.
- returnoutput_text
The turn ends once no more tool calls are needed.
Loop shape
The core loop is deliberately plain.
The implementation in packages/agent/src/agent.ts is not an orchestration maze. It repeatedly asks the model for output. If there are no function calls, the turn is done. If there are tool calls, tinyloop runs them and feeds their outputs back into the next model request.
// One user message can take several model/tool turns.
for (toolTurn = 0; toolTurn < maxToolTurns; toolTurn += 1) {
response = responses.create(input, toolDefinitions, previousResponseId)
toolCalls = response.output.filter(isToolCall)
if (toolCalls.length === 0) {
return response.output_text
}
input = runToolCalls(toolCalls)
}Turn trace
A turn becomes a readable event sequence.
AgentSession.dispatch wraps each user request with a turnId, emits lifecycle events, and lets the agent stream tool work back through the same event sink. This is where invisible agent work becomes inspectable.
- 01
turn.startedA new turn ID is created before any model or tool work happens.
- 02
user.messageThe user's text is emitted as a transcript event, not just passed privately to the model.
- 03
tool.execution.startedIf the model returns a function call, tinyloop emits the tool name, call ID, and raw JSON args.
- 04
tool.execution.progressrun_commandstreams stdout chunks while the process is still running. - 05
tool.execution.finishedThe tool returns model-readable output and UI-readable details.
- 06
assistant.messageWhen the model stops asking for tools, final text is emitted to the transcript.
Tool contracts
Every tool has a model-facing schema and a local handler.
The important learning move is to separate what the model can ask for from what the runtime actually does.
read_filepathResolve inside the workspace and read UTF-8 text.File contents plus path details.write_filepath, contentCreate parent folders, write full contents, diff existing files.Created message or unified diff.edit_filepath, oldSnippet, newSnippetReplace exactly one occurrence, fail if missing or ambiguous.Unified diff and edit details.run_commandcommandSpawn a shell command from the workspace and stream stdout.Exit code, stdout, stderr.A concrete tool
Tool progress is just another event.
run_command is the easiest tool to understand hands-on: it spawns a shell process in the workspace, appends stdout as progress, then resolves with an exit code, stdout, and stderr.
child.stdout.on("data", (chunk) => {
emit({
type: "tool.execution.progress",
name,
callId,
progress: chunk
})
stdout += chunk
})Event boundary
The UI receives facts, not agent internals.
AgentSession turns private work into ordered facts. The TUI normalizes those facts, reduces them into state, and renders from state. This is the boundary that makes tinyloop easy to test and explain.
tool.execution.progressPayload plus sessionId, turnId, and sequence.
tool.progressNames are made UI-friendly without exposing the agent package to components.
tool.output += textThe reducer appends streamed output to the matching tool transcript item.
<ToolCard />The interface renders state. It does not run tools or call the model.
Reducer
The UI reacts to event shape, not tool code.
By the time events reach the TUI, they have already been normalized. The reducer only needs to know how a UI event changes transcript state. That is why the same UI path works for the real agent, the demo driver, and the fake driver used in tests.
case "tool.progress":
tool.output = tool.output + event.text
case "tool.finished":
tool.status = "completed"
tool.details = event.details
case "assistant.message":
transcript.push(event.text)Boundary conditions
What this architecture does not solve yet.
The file tools guard against paths escaping the workspace. run_command runs from the workspace, but tinyloop does not currently implement approvals, cancellation, persistence, or command sandboxing. Those are the natural next layers once the basic model/tool/event loop is clear.