Walkthrough

One tiny tool.

A good first tool should be boring on purpose. list_files is read-only, easy to test, and still touches every important agent boundary: schema, handler, registry, event details, and UI rendering.

Tool shape

A tool has two audiences.

The model sees a strict JSON schema and receives a string output. The UI can receive structureddetails. Keeping those separate is one of tinyloop's most useful teaching points.

packages/agent/src/tools/list-files.tssmall read-only example
import { readdir } from "node:fs/promises"
import type { ToolHandler, ToolResult } from "./registry"
import { resolveWorkspacePath } from "./registry"

export type ListFilesToolDetails = {
  path: string
  entries: string[]
}

export function createListFilesTool(workspaceRoot: string): ToolHandler<ListFilesToolDetails> {
  return {
    definition: {
      type: "function",
      name: "list_files",
      description: "List files in a workspace directory.",
      parameters: {
        type: "object",
        properties: {
          path: { type: "string", description: "Directory path to list." }
        },
        required: ["path"],
        additionalProperties: false
      },
      strict: true
    },
    run: async (args): Promise<ToolResult<ListFilesToolDetails>> => {
      const path = typeof args.path === "string" ? args.path : "."
      const directory = resolveWorkspacePath(workspaceRoot, path)
      const entries = await readdir(directory)

      return {
        output: entries.join("\n"),
        details: { path, entries }
      }
    }
  }
}

The return value has a deliberate split. output is the plain text the model receives in the next turn. details is structured data for tinyloop's own UI; it can make rendering nicer without adding extra noise to the model's context.

packages/agent/src/tools/list-files.tsmodel output vs UI details
const entries = await readdir(directory)

return {
  // The model sees this string.
  output: entries.join("\n"),

  // The UI can use this structured data.
  details: {
    path,
    entries
  }
}

Registration

Adding the handler makes the schema visible to the model.

The agent creates the default tool map, turns each tool into a function definition, and passes those definitions to the model request. Once list_files is in createDefaultTools, the model can ask for it by name.

packages/agent/src/agent.ts + tools/index.tshow default tools reach the model
const tools = createDefaultTools(workspaceRoot)
const definitions = toolDefinitions(tools)

await client.responses.create({
  model,
  input,
  tools: definitions
})

export function createDefaultTools(workspaceRoot: string): ToolMap {
  return {
    read_file: createReadFileTool(workspaceRoot),
    write_file: createWriteFileTool(workspaceRoot),
    edit_file: createEditFileTool(workspaceRoot),
    run_command: createRunCommandTool(workspaceRoot),
    list_files: createListFilesTool(workspaceRoot)
  }
}

Call handling

The model's JSON arguments become local input.

A tool call arrives as a name, call ID, and JSON string. tinyloop looks up the handler, parses the arguments, runs local TypeScript, then wraps the result as a function_call_output for the next model turn.

packages/agent/src/tools/registry.tsfrom model call to local handler
const tool = tools[toolCall.name]
const args = parseToolArgs(toolCall.arguments)
const result = await tool.run(args, context)

return {
  output: {
    type: "function_call_output",
    call_id: toolCall.call_id,
    output: result.output
  },
  result
}

Type boundary

Details should become part of the event contract.

If a tool returns structured details, add the detail type to the registry's tool-name map. That keepstool.execution.finished precise without forcing the model to consume UI-only data.

packages/agent/src/tools/registry.tsdetails by tool name
export type ToolDetailsByName = {
  read_file: ReadFileToolDetails
  write_file: WriteFileToolDetails
  edit_file: EditFileToolDetails
  run_command: RunCommandLineToolDetails
  list_files: ListFilesToolDetails
}
packages/agent/src/agent.tsemitting typed details for the UI
case "list_files":
  return {
    type: "tool.execution.finished",
    name,
    callId,
    output,
    details: details as ListFilesToolDetails
  }

On the TUI side, the finished event updates the tool item that was already created when the tool started. The reducer keeps any streamed output, falls back to the final output when needed, and stores detailsso the renderer can choose a richer display.

packages/tui/src/state/reduce-session-event.tsdetails land in transcript state
case "tool.finished":
  updateToolItem(turn, event.callId, (tool) => ({
    ...tool,
    status: "completed",
    output: tool.output.length > 0 ? tool.output : event.output,
    details: event.details
  }))

Trace checklist

Follow the tool through one turn.

The interesting part is not the directory listing. The interesting part is how a capability crosses the model, local runtime, event stream, and renderer without collapsing those boundaries.

schema

The model learns that list_files accepts a directory path.

The schema is the only part of the tool the model sees before it decides to call it. Keeping the parameter list small makes the model's choice easy to inspect: it either asks for a path or it does not.

handler

The local runtime resolves the path inside the workspace and reads directory entries.

The handler is ordinary TypeScript running on the user's machine. resolveWorkspacePath keeps the path inside the workspace, which makes the safety boundary visible without introducing a full sandbox.

output

The model receives a plain newline-separated string it can reason over.

Tool output should be boring and model-readable. A newline-separated list is enough for the next model turn to summarize, choose a file, or ask for another tool.

details

The UI receives structured entries if it wants a cleaner rendering later.

Details are not for the model. They let the TUI render richer information without changing the text the model reasons over. That separation keeps the tool useful without making its prompt contract messy.

event

The session emits tool.execution.finished with the same call ID that started the tool.

The call ID is the thread that ties start, progress, and finish events together. The reducer can update the right tool row even when a turn contains more than one tool call.

Taste

Keep the first tool intentionally small.

list_files deepens the project because it makes the contract easier to see. A smarter search tool or project index would be useful, but it would teach several extra ideas at once.