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.

tinyloop agent loopUser input is sent to the model, tool calls run locally, tool outputs return to the model, and final text exits.user inputone turnresponses.createmodel + toolsprevious_response_idoutput_textturn returnsrunToolCallslocal handlersfunction_call_output
  1. inputuser input

    One message enters the turn.

  2. modelresponses.create

    The model sees input plus tool definitions.

  3. toolsrunToolCalls

    Local handlers run only if function calls were returned.

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

packages/agent/src/agent.tsteaching sketch, not copied source
// 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.

  1. 01turn.started

    A new turn ID is created before any model or tool work happens.

  2. 02user.message

    The user's text is emitted as a transcript event, not just passed privately to the model.

  3. 03tool.execution.started

    If the model returns a function call, tinyloop emits the tool name, call ID, and raw JSON args.

  4. 04tool.execution.progress

    run_command streams stdout chunks while the process is still running.

  5. 05tool.execution.finished

    The tool returns model-readable output and UI-readable details.

  6. 06assistant.message

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

toolmodel can ask forhandler doesreturns to loop
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.

packages/agent/src/tools/run-command.tswhy command output appears before completion
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.

01AgentEventtool.execution.progress

Payload plus sessionId, turnId, and sequence.

02toUiSessionEventtool.progress

Names are made UI-friendly without exposing the agent package to components.

03reduceSessionEventtool.output += text

The reducer appends streamed output to the matching tool transcript item.

04Ink transcript<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.

packages/tui/src/state/reduce-session-event.tsthe important reducer cases
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.