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.

packages/agent/src/session.tsteaching sketch
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.

packages/agent/src/agent.tsthe core loop shape
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.

packages/agent/src/agent.tswhat the model receives
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.

packages/agent/src/agent.tsstart, run, finish
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.

  1. 01turn.started

    The UI can create a running turn before any model response exists.

  2. 02user.message

    The user's text is transcript state, not only model input.

  3. 03tool.started

    The normalized TUI event names the tool and keeps the call ID for later updates.

  4. 04tool.progress

    run_command can stream stdout before the tool is complete.

  5. 05tool.finished

    The reducer marks the tool complete and stores details for richer rendering.

  6. 06assistant.message

    Final model text becomes another transcript item.

packages/cli/src/session/agent-event-normalizer.tsagent event to UI event
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.

packages/tui/src/state/reduce-session-event.tsevent shape becomes UI state
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 response

When 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.

packages/tui/src/state/reduce-session-event.tscreating the tool row
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.

packages/tui/src/state/reduce-session-event.tscompleting the tool row
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.