Datalumina

The harness

Code mode with Pi

What a coding agent harness is, how the app runs Pi in RPC mode and shows every tool call as a card, and the one setting Ollama needs. Then Pi fixes a broken project, and you build the app.

By the end of this page you switch the sidebar to Code, pick a folder, and give an instruction. Pi works in that folder on gpt-oss:20b. Its text streams in, and every tool call shows as a card you can open. Then you build Chat into a real app.

What a harness is

A chat model only writes text. It cannot open a file or run a command. A harness is the program around the model that gives it that reach. It tells the model which tools exist, runs the tools the model asks for, and feeds the results back, until the model says it is done.

Claude Code and Codex are harnesses. So is Pi, a small open-source coding agent. The app gives it four tools, read, write, edit, and bash, and Pi can talk to any model behind an OpenAI-compatible API, including Ollama.

The app does not reimplement any of this. It starts Pi, sends it your instruction, and draws what Pi reports back.

Install Pi

npm install -g @earendil-works/pi-coding-agent@1.5.0
pi --version

The version is pinned. The app reads Pi's events, and a different version might name them differently.

Restart Ollama with a larger context window

The context window is how many tokens of conversation a model sees at once. Chat mode sets it on every request, so you did not need this until now. Pi does not. It talks to Ollama's OpenAI-compatible endpoint, /v1, which cannot set the context window per request. With Ollama's small default, Pi's long system prompt and the tool results get cut off, and the agent loses track of its own tools.

So quit the Ollama app (its icon in the menu bar or the system tray, then Quit) and start the server yourself, with a default of 32768 tokens:

OLLAMA_CONTEXT_LENGTH=32768 ollama serve

Leave this terminal open while you use Code mode. Only one server can listen on port 11434, so the app must be closed first, or it keeps its own server without your setting.

Copy in the Pi module

git checkout step-3-pi -- src-tauri/src

This adds src-tauri/src/pi.rs and registers its four commands in main.rs. The Code view in React was in the starter all along. It was only missing these commands.

Full file on GitHub: pi.rs

RPC mode

Pi has an interactive terminal UI, and it has an RPC mode for programs. The app starts it like this, in the folder you pick.

pi --mode rpc --no-session --offline --provider ollama --model gpt-oss:20b --thinking low --tools read,write,edit,bash
  • --mode rpc replaces the terminal UI with JSON lines.
  • --no-session keeps Pi from saving its own session history. The app saves the conversation itself.
  • --provider ollama and --model pick the model, which the app defines in its own models.json (below).
  • --thinking low keeps the model's reasoning short.
  • --tools read,write,edit,bash turns on the four tools.
  • For --offline and everything else, the Pi RPC docs describe every flag.

In RPC mode Pi reads one JSON command per line on stdin and writes one JSON event per line on stdout. That is the same shape as Ollama's stream, so the Rust side uses the same line-reading loop. The app sends two commands.

{"type":"prompt","message":"Find the bug, fix it, and run the code to prove it works"}
{"type":"abort"}

prompt starts a task, and abort stops the current one. Pi keeps running after an abort, ready for the next instruction.

The events the app reads

Pi prints more event types than these. The app reads the ones below and ignores the rest.

EventFields the app readsWhat the app does
message_updateassistantMessageEvent.type and .deltagrows the current answer for a text_delta, or its thinking for a thinking_delta
tool_execution_starttoolCallId, toolName, argsadds a tool card in the running state
tool_execution_updatetoolCallId, partialResultfills in the output so far
tool_execution_endtoolCallId, result, isError, durationMsmarks the card done or failed, with its duration
responsesuccess, errorshows a notice when a command failed
agent_settledabortedends the task, with "Stopped." when you aborted it

The task ends at agent_settled, not at agent_end. Pi can retry after agent_end, so treating agent_end as the finish line would cut off the retry. Rust also adds one event of its own, exit, when the Pi process ends, with the last lines of Pi's log to explain a crash.

How Rust runs Pi

src-tauri/src/pi.rs
    // Pi reads its settings from this folder, never from the user's own ~/.pi/agent.
    let config_dir = store::data_dir(&app)?.join("pi");
    write_models_json(&config_dir, &model)?;

    let path = search_path();
    let mut command = pi_command(&pi_path, &path)?;
    command
        .args(["--mode", "rpc", "--no-session", "--offline"])
        .args(["--provider", "ollama", "--model", &model])
        .args(["--thinking", "low", "--tools", "read,write,edit,bash"])
        .current_dir(&folder)
        .env("PI_CODING_AGENT_DIR", &config_dir)
        .env("PATH", &path)
        .stdin(Stdio::piped())
        .stdout(Stdio::piped())
        .stderr(Stdio::piped());

The app starts Pi with the flags above, in the folder you picked. PI_CODING_AGENT_DIR points Pi at the app's own config folder (below). search_path fixes a macOS problem: an app opened from Finder does not get your shell's PATH, so it asks your login shell for it. Without that, the installed app could not find pi.

src-tauri/src/pi.rs
    // stdout is strict JSON lines. Forward each one as is; React decides what it means.
    std::thread::spawn(move || {
        for line in BufReader::new(stdout).lines().map_while(Result::ok) {
            if line.trim().is_empty() {
                continue;
            }
            let event = serde_json::from_str(&line)
                .unwrap_or_else(|_| json!({ "type": "log", "text": line }));
            if on_event.send(event).is_err() {
                break;
            }
        }
        let log = log.lock().unwrap().join("\n");
        let _ = on_event.send(json!({ "type": "exit", "log": log }));
    });

Rust does not look inside Pi's events. It parses each stdout line as JSON and forwards it over a Channel, the same way it forwards Ollama's answer. When Pi exits, Rust adds one event of its own, exit, with the last lines of Pi's log to explain a crash. When Pi adds a field in a later version, only TypeScript changes.

src-tauri/src/pi.rs
/// Send an instruction to Pi.
#[tauri::command]
pub fn code_prompt(message: String, state: State<'_, PiState>) -> Result<(), String> {
    send(&state, json!({ "type": "prompt", "message": message }))
}

/// Stop what Pi is doing now. Pi stays running for the next instruction.
#[tauri::command]
pub fn code_abort(state: State<'_, PiState>) -> Result<(), String> {
    send(&state, json!({ "type": "abort" }))
}

/// End the Pi process.
#[tauri::command]
pub fn code_stop(state: State<'_, PiState>) {
    shutdown(&state);
}

fn send(state: &PiState, command: Value) -> Result<(), String> {
    let mut guard = state.0.lock().unwrap();
    let pi = guard.as_mut().ok_or("Pi is not running")?;
    writeln!(pi.stdin, "{command}").map_err(|e| format!("Pi stopped: {e}"))?;
    pi.stdin.flush().map_err(|e| e.to_string())
}

Your instruction and Stop are one JSON line each on Pi's stdin.

src-tauri/src/pi.rs
/// Close stdin so Pi can exit cleanly, then kill it if it is still running after a second.
/// A forgotten Pi process can hold on to a lot of memory, so this runs on stop, window close, and exit.
pub fn shutdown(state: &PiState) {
    let Some(PiProcess { mut child, stdin }) = state.0.lock().unwrap().take() else {
        return;
    };
    drop(stdin);
    let deadline = Instant::now() + Duration::from_secs(1);
    while Instant::now() < deadline {
        if let Ok(Some(_)) = child.try_wait() {
            return;
        }
        std::thread::sleep(Duration::from_millis(50));
    }
    let _ = child.kill();
    let _ = child.wait();
}

A forgotten Pi process keeps running in the background with its model in memory. So main.rs calls shutdown when the window closes and when the app exits.

From events to tool cards

React turns each event into an item on the screen. This function was in the starter, in src/pi.ts.

src/pi.ts
/** Apply one Pi event to the list of items. Unknown events change nothing. */
export function applyPiEvent(items: CodeItem[], event: PiEvent): CodeItem[] {
  switch (event.type) {
    case "message_update": {
      const update = event.assistantMessageEvent as { type: string; delta?: string } | undefined;
      if (!update?.delta) return items;
      const field = update.type === "text_delta" ? "text" : update.type === "thinking_delta" ? "thinking" : null;
      if (!field) return items;
      const last = items[items.length - 1];
      if (last?.kind === "assistant") {
        return [...items.slice(0, -1), { ...last, [field]: last[field] + update.delta }];
      }
      return [...items, { kind: "assistant", text: "", thinking: "", [field]: update.delta }];
    }
    case "tool_execution_start":
      return [
        ...items,
        {
          kind: "tool",
          id: String(event.toolCallId),
          name: String(event.toolName),
          args: (event.args as Record<string, unknown>) ?? {},
          output: "",
          status: "running",
        },
      ];
    case "tool_execution_update":
    case "tool_execution_end":
      return items.map((item) => {
        if (item.kind !== "tool" || item.id !== String(event.toolCallId)) return item;
        if (event.type === "tool_execution_update") return { ...item, output: textOf(event.partialResult) };
        return {
          ...item,
          output: textOf(event.result),
          status: event.isError ? "error" : "done",
          durationMs: event.durationMs as number | undefined,
        };
      });

tool_execution_start adds a card in the running state. tool_execution_end marks it done or failed, with its output and duration. Text and thinking deltas grow the current answer. Unknown events change nothing, so a newer Pi with extra events does not break the view.

Pi gets its own config folder

Pi normally reads its settings from ~/.pi/agent. The app never touches that folder. It writes its own models.json into its app data folder and points Pi there with the PI_CODING_AGENT_DIR environment variable. Your own Pi setup stays exactly as it was.

This is the file the app writes for gpt-oss:20b.

<app data>/pi/models.json
{
  "providers": {
    "ollama": {
      "api": "openai-completions",
      "apiKey": "ollama",
      "baseUrl": "http://127.0.0.1:11434/v1",
      "compat": {
        "supportsDeveloperRole": false
      },
      "models": [
        {
          "contextWindow": 32768,
          "id": "gpt-oss:20b",
          "maxTokens": 8192,
          "reasoning": true
        }
      ]
    }
  }
}

Ollama needs no API key, but the OpenAI-style client expects one, so the file sets a placeholder. The keys come out sorted, because serde_json writes objects in alphabetical order by default.

Why supportsDeveloperRole is false

OpenAI's newer API sends the system instructions with the role developer instead of system, and Pi does the same by default. Ollama drops messages with that role. Pi's whole system prompt, including the description of its tools, never reaches the model. The model answers as a plain chat model and never learns it has tools.

"compat": {"supportsDeveloperRole": false} tells Pi to send its instructions with the system role. It is one line, and without it Code mode does not work at all.

Fix a broken project

Pi needs something to fix. Copy the small project from the repo to a folder outside it, so Pi edits a copy and not your course files. You need Python 3.

cp -R demos/broken-project /tmp/broken-project
python3 /tmp/broken-project/main.py

It crashes. These are the two files.

broken-project/invoices.py
def parse_amount(text):
    """Turn a European amount such as '1.234,56' into 1234.56."""
    return float(text.replace(",", "."))


def total(amounts):
    return sum(parse_amount(amount) for amount in amounts)
broken-project/main.py
from invoices import total

amounts = ["1.234,56", "89,90", "12,00", "2.500,00"]
result = total(amounts)
print(f"Total: {result:.2f}")
assert abs(result - 3836.46) < 0.001, f"Expected 3836.46, got {result}"
print("OK")

parse_amount turns 1.234,56 into 1.234.56, which is not a number. The fix is to remove the thousands separators before swapping the comma.

Check

pnpm tauri dev
  1. Switch the sidebar to Code and pick the broken-project folder. On macOS the folder dialog hides /tmp: press Cmd+Shift+G, type /tmp/broken-project, and press Return.
  2. Click "Find the bug, fix it, and run the code to prove it works" and press Enter.
  3. Cards appear while Pi works. It reads the files, runs python3 main.py, edits invoices.py, and runs it again. The exact steps differ per run, because the model decides them.

Then run the project yourself.

python3 /tmp/broken-project/main.py
# Total: 3836.46
# OK

Start another task, such as "Add a short README that explains how to run this project", and press Stop while it runs. "Stopped." appears. Quit the app and check that no Pi process is left.

pgrep -fl "pi --mode rpc"
# no output

Build and install

pnpm tauri build

On macOS the app is in src-tauri/target/release/bundle/macos/Chat.app, with a .dmg next to it in bundle/dmg/. Drag Chat.app into Applications. On Windows the build writes a setup .exe to src-tauri\target\release\bundle\nsis\. Build each platform on that platform.

The app is not code-signed. On macOS, right-click it and choose Open the first time. Open it from Applications, not from a terminal, and give Pi a task. If it says "Pi is not installed", set the full path to pi in Settings. which pi in a terminal prints it.

Windows is untested

The Windows build is written from the code and the Tauri configuration, but it has not run on a Windows machine yet. That includes the installer, the pi.cmd handling in pi.rs, and the PowerShell commands in this course.

What local is good and bad at

  • Good: your code and data never leave the machine, there is no API bill, and it works offline. Small jobs, such as a one-line fix or a code edit with speculative decoding, land in seconds. And you can measure everything.
  • Bad: the models are smaller. gpt-oss:20b fixes a bug in a two-file project. Do not expect it to refactor a large codebase the way a frontier model does. Memory is the hard limit, and the context window adds to it. Pi runs with --no-session, so a reopened task starts without memory.
  • Pi can run any command in the folder you pick. Pick a folder you are fine with it changing, and keep it under git.

On this page