Loading diagram…
Diagram text
flowchart TB
accTitle: The five-module map
accDescr: A task enters through the command line, the agent coordinates the model and tools, and the terminal shows the result.
U["You give nanopi a task"] --> C["cli.ts connects the parts"]
C --> A["agent.ts runs the work loop"]
A --> L["llm.ts talks to the model"]
A --> T["tools.ts acts on the computer"]
C --> V["tui.ts shows progress"]“What I cannot create, I do not understand.” — Richard Feynman
Harness Live · Pi from Scratch follows one small coding agent, nanopi, through five TypeScript files. Think of it as a tiny workshop: one part talks, one plans, one uses tools, one runs the front desk, and one connects the wiring.
Three beginner terms appear often:
- An LLM is a large language model. It produces text and can ask the program to use a named tool.
- Context is the model's notebook: the system prompt, conversation, tool calls, and tool results stored as JSON-shaped data.
- An event is a small progress message sent while work is happening, rather than waiting for the whole turn to finish.
The guide starts with this map, then follows the data. You do not need to understand every TypeScript detail on the first pass.
llm.ts
Loading diagram…
Diagram text
flowchart TB
accTitle: The LLM adapter
accDescr: The LLM module converts nanopi context for an OpenAI-compatible API, reads the streamed response, and emits simple events.
C["Context is the notebook"] --> M["Convert it to API messages"]
M --> A["Call an OpenAI-compatible API"]
A --> S["Read small SSE chunks"]
S --> E["Emit StreamEvent values"]llm.ts is the translator at the workshop door. Different model servers have wire-format details that the rest of the program should not need to know.
Its stream() function sends the current Context to an OpenAI-compatible /chat/completions endpoint. The response arrives through SSE, a stream of small text lines. llm.ts turns those lines into four simple StreamEvent shapes:
text_delta: the next piece of model texttool_call: a complete request to use a tooldone: the stream ended, with a reasonerror: the request or stream failed
Tool-call arguments may arrive in several chunks. This module collects those chunks and joins them before the agent sees the call.
Context also lives here. It has an optional systemPrompt and a messages array. It is designed as plain data, so the CLI can save messages and load them again later.
Code notes
ModelkeepsapiKey,model, optionalbaseUrl, and optionalmaxTokenstogether.contextToOpenAIMessages()converts nanopi's content blocks into the API's message format.buildAssistantMessage()andbuildToolResultMessage()put model output back into nanopi's format.- An
AbortSignalis passed tofetch(), so a turn can be stopped. - This teaching implementation supports one OpenAI-compatible format, not pi's full provider matrix.
agent.ts
Loading diagram…
Diagram text
flowchart TB
accTitle: The agent loop
accDescr: The agent asks the model, runs requested tools in order, adds their results to context, and repeats until no tool is requested.
C["Read the current context"] --> M["Ask the LLM"]
M --> D{"Was a tool requested?"}
D -- "Yes" --> T["Run tools in order"]
T --> R["Add results to context"]
R --> M
D -- "No" --> E["End the turn"]agent.ts is the foreman. Its core is a while loop:
- Ask the LLM what to do next.
- If the LLM requests tools, run them.
- Add the assistant message and tool results to
Context. - Ask again until the LLM stops requesting tools.
runAgent() changes the supplied Context in place. The conversation is in that notebook; there is no separate hidden conversation store inside the agent.
The agent also translates low-level StreamEvent values into higher-level AgentEvent values: assistant text, a tool call, a tool result, or the end of a turn. That keeps model-protocol details away from the interface.
Code notes
- Tools run serially in this teaching version.
- If a tool name is unknown or execution throws, the error becomes a string result that the model can read.
- If
max_tokensmay have cut off tool arguments, nanopi does not execute them. It adds error results and lets the model try again. - Abort and error paths preserve a valid message history before ending the turn.
- At 50 messages,
compactContext()asks the LLM to summarize older messages and keeps the newest 20 unchanged. - There is no hard-coded step limit in this small loop.
tools.ts
Loading diagram…
Diagram text
flowchart TB
accTitle: The built-in tools
accDescr: The agent selects a filesystem or shell action, the tool affects or observes the computer, and a text result returns to the agent.
A["Agent requests an action"] --> R["read_file reads text"]
A --> W["write_file or edit changes files"]
A --> B["run_bash starts a command"]
R --> O["Return a string result"]
W --> O
B --> Otools.ts is the toolbox. builtinTools() returns four AgentTool objects:
read_filereads a text file.write_filecreates parent directories and overwrites a file.editreplaces one uniquely matching piece of text.run_bashruns a shell command and returns its output.
These tools are not pure functions. They observe the filesystem, change files, and start processes. Those are side effects. What stays simple is their connection to the agent: each tool receives arguments and returns a string, without reading or changing agent state directly.
Treat them like real power tools. run_bash, write_file, and edit can run commands or change files from the process's working directory, so experiment in a disposable copy of the project.
Code notes
read_fileandrun_bashkeep the last 200 output lines when output is large and save the full text in a temporary file.editrefuses to run whenold_stringis missing or appears more than once.run_bashhas a 30-second timeout and accepts the turn'sAbortSignal.run_bashturns command failures into text; the agent turns thrown tool errors into readable string results.- For simplicity, this teaching version describes parameters with JSON Schema but does not validate incoming arguments before execution.
tui.ts
Loading diagram…
Diagram text
flowchart TB
accTitle: The terminal interface
accDescr: The terminal reads one prompt, the CLI runs the agent, streamed progress is printed, and input opens again after the turn.
U["User enters one prompt"] --> R["Tui reads the line"]
R --> C["CLI runs the agent"]
C --> P["Tui prints streamed progress"]
P --> W["Wait for the next prompt"]tui.ts is the front desk. It uses Node.js readline for one-line input and process.stdout.write() for streamed output.
The Tui class knows how to accept a prompt, report Ctrl+C, mark itself busy, and print text, tool calls, tool results, and turn endings. It does not call the LLM or execute tools. The CLI decides which print method matches each AgentEvent.
The interface is replaceable. A future HTTP adapter could send the same agent events to a browser without changing the loop. This repository does not ship that web-agent backend. Its teaching website renders the guide and replays offline traces; it does not call models or run commands.
Code notes
- Busy mode prevents two prompts from starting overlapping agent loops.
- Ctrl+C triggers the registered abort callback only while the agent is busy.
setBusy(false)displays the next prompt after a turn.stop()closesreadlineand removes the keypress listener.
cli.ts
Loading diagram…
Diagram text
flowchart TB
accTitle: The command-line wiring
accDescr: The CLI reads nanopi settings and saved messages, connects the agent, tools, and terminal, then appends new messages to the session file.
E["Read NANOPI settings"] --> C["cli.ts builds the app"]
S["Load the nanopi session"] --> C
C --> A["Run the agent for a prompt"]
A --> V["Send events to the Tui"]
A --> P["Append new session messages"]cli.ts is the power strip. It is the one place that plugs the model, context, tools, agent, and terminal together.
At startup it reads NANOPI_API_KEY, plus the optional NANOPI_MODEL and NANOPI_BASE_URL. It creates the Context, loads earlier messages, gets the built-in tools, and starts the Tui.
For each prompt, the CLI adds the user's message, creates a fresh AbortController, and iterates over runAgent(). A small switch forwards each AgentEvent to the matching terminal print method. When the turn finishes, new messages are appended to ~/.nanopi/session.jsonl.
Code notes
- The default model name is
glm-5.2. - The default base URL is
https://api.openai.com/v1. - The CLI sets
maxTokensto 4096. - The session uses JSON Lines: one message object per line.
loadSession()skips malformed lines instead of discarding all history.- A new
AbortControlleris required for every prompt because an aborted controller cannot be reset.
How the pieces work together
Loading diagram…
Diagram text
flowchart TB
accTitle: One complete nanopi turn
accDescr: The terminal receives a task, the CLI updates context, the agent asks the LLM, and tool results loop back until output can be shown and saved.
I["tui.ts reads input and shows output"] --> C["cli.ts updates context"]
C --> A["agent.ts runs the loop"]
A --> L["llm.ts emits model events"]
L --> D{"Is there a tool call?"}
D -- "Yes" --> T["tools.ts acts and returns text"]
T --> A
D -- "No" --> IOne turn follows a simple path:
tui.tsreceives your prompt.cli.tsadds it toContextand startsrunAgent().agent.tsasksllm.tsfor streamed model events.- If the model requests a tool,
tools.tsperforms the action and returns text. - The agent records that result in
Contextand asks the model again. - Agent events travel back through the CLI to the terminal as the work happens.
- When the turn ends, the CLI appends the new messages to the nanopi session file.
The context is the notebook, the events are status slips, and the CLI is the wiring. Because those jobs are separate, an interface or model adapter can be replaced without rewriting the central work loop. Replacement is an architectural seam, not a shipped browser backend.
Code notes
llm.tsdoes not know about the agent or interface.agent.tsdepends on the LLM layer but not on the TUI.- Tools do not hold conversation state.
tui.tsdoes not import the LLM or tool implementations.cli.tsis the composition root that knows about every runtime piece.
With the map in place, the next chapter can build the loop one piece at a time.