Walkthrough
One turn, end to end.
A tinyloop turn is not magic. It is a small chain: the session gives the turn an identity, the agent asks the model what to do, tools run only when requested, and the TUI renders from ordered events.
Boundary map
A turn starts as a session concern.
AgentSession.dispatch is where user text becomes an inspectable turn. The session emits lifecycle and transcript events before the agent does any model or tool work.
async dispatch(command) {
const turnId = randomUUID()
emitForTurn(turnId, { type: "turn.started" })
emitForTurn(turnId, { type: "user.message", text: command.text })
const response = await agent.runOneUserTurn(command.text, { emit })
emitForTurn(turnId, { type: "assistant.message", text: response })
emitForTurn(turnId, { type: "turn.completed" })
}Loop
The agent repeats one simple question.
Each model response either contains final text or one or more function calls. If there are function calls, tinyloop runs tools and feeds their outputs into the next model turn.
for (let turn = 0; turn < maxToolTurns; turn += 1) {
response = await createResponse(input)
toolCalls = response.output.filter(isToolCall)
if (toolCalls.length === 0) {
return response.output_text
}
input = await runToolCalls(tools, toolCalls, { emit })
}Model request
The tools are part of the request.
The model does not discover tools by reading local code. tinyloop builds function definitions from the tool map and passes them with each model request. That is the point where local capabilities become model-visible choices.
private async createResponse(input) {
const request = {
model: this.model,
input,
tools: this.toolDefinitions,
previous_response_id: this.previousResponseId,
store: this.storeResponses
}
return this.client.responses.create(request)
}Tool execution
A function call becomes two event edges.
tinyloop emits before and after the local handler runs. The model receives the string output. The UI receives the same completion event plus optional details for rendering.
for (const toolCall of toolCalls) {
emit({
type: "tool.execution.started",
name: toolCall.name,
callId: toolCall.call_id,
args: toolCall.arguments
})
const execution = await handleToolCall(tools, toolCall, { emit })
toolOutputs.push(execution.output)
emit({
type: "tool.execution.finished",
name: toolCall.name,
callId: toolCall.call_id,
output: execution.result.output,
details: execution.result.details
})
}Event trace
The hidden work becomes a visible sequence.
This is the key educational move in tinyloop. Tool work is not hidden behind UI components. It crosses the package boundary as ordinary events.
- 01
turn.startedThe UI can create a running turn before any model response exists.
- 02
user.messageThe user's text is transcript state, not only model input.
- 03
tool.startedThe normalized TUI event names the tool and keeps the call ID for later updates.
- 04
tool.progressrun_commandcan stream stdout before the tool is complete. - 05
tool.finishedThe reducer marks the tool complete and stores details for richer rendering.
- 06
assistant.messageFinal model text becomes another transcript item.
case "tool.execution.finished":
return {
...meta,
type: "tool.finished",
callId: event.callId,
name: event.name,
output: event.output,
details: event.details
}Reducer
Rendering is the last step, not the source of truth.
The TUI does not call the agent directly. It reduces events into TuiState, then renders that state. That is why the same components work with the real agent and with demo events.
event -> reduceSessionEvent(state, event) -> TuiState
turn.started creates a running turn
user.message appends a user transcript item
tool.started appends a running tool item
tool.progress appends streamed stdout
tool.finished completes the tool item
assistant.message appends the final responseWhen a tool starts, the reducer creates a placeholder transcript item. The important field iscallId: later progress and finish events use it to find this exact row. output starts empty because streamed progress may arrive before the tool is complete.
case "tool.started":
items: [...turn.items, {
type: "tool",
callId: event.callId,
name: event.name,
status: "running",
args: event.args,
output: "",
sequence: event.sequence
}]When the tool finishes, the reducer does not append a second tool. It updates the existing one with the samecallId, marks it completed, preserves streamed output when it exists, and stores structured details for the renderer.
case "tool.finished":
updateToolItem(turn, event.callId, (tool) => ({
...tool,
status: "completed",
output: tool.output.length > 0 ? tool.output : event.output,
details: event.details
}))Takeaway
The turn is the unit of understanding.
If you can trace one turn, you can reason about tools, streaming, failures, and UI updates without needing a bigger agent architecture.