跪拜 Guibai
← Back to the summary

Tether: A Locally-First AI Agent Desktop Workstation Built on Pi

Foreword: This article is not commercial soft copy; it's simply a record of my personal tinkering. The reason is straightforward — AI programming tools on the market are either cloud black boxes, or you can't even start when you want to modify something. So I wondered, could I build a locally-first, tangible Agent workstation myself based on an open-source agent ecosystem? That's how the following came about.

Official site: http://tether-code.xyz

Conclusion first: Don't reinvent the wheel

Many people, upon hearing "develop an Agent yourself," immediately think, "Doesn't that mean hand-coding everything from model invocation, message loops, tool protocols, to permission systems?"

Actually, no. The Agent field has accumulated a solid batch of open-source ecosystems over the years, such as Pi (the @earendil-works/pi-* set of npm packages). It already handles the agent loop, model protocol, coding-agent extensions, RPC, TUI components, and more.

My thinking was simple: Stand on the shoulders of Pi and spend energy on product boundaries and experience, rather than reinventing the wheel.

The final result is called Tether — an Electron desktop application where model invocation, workspace tools, terminal commands, permission prompts, session history, and diff review all converge into a single local workstation. Session and configuration data reside on your machine; model requests go directly to your configured provider with no intermediary relay service in between.

image.png

What does the architecture look like

A one-sentence summary of the architecture:

React Renderer (session / diff / settings UI)
        │  contextBridge / Electron IPC
        ▼
Electron Main (windows, workspace, credentials, Agent process host)
        │  JSON-RPC over stdio
        ▼
tether-agent-core (permissions, sandbox, tools, checkpoint, MCP, sessions)
        │
        ▼
Pi Ecosystem (agent loop · model protocol · coding-agent extension · RPC · TUI)

A key design decision here: The renderer process has zero Node.js privileges; all desktop capabilities go through typed IPC contracts. The Agent runs in an independent child process. If it crashes, the session file remains on disk and can be recovered as a conversation, but it will never silently replay unfinished commands.

image.png

Typed IPC contracts are more important than you think

The most common security failure in Electron is contextBridge exposing a large, catch-all object. I did the opposite here — first define the entire DesktopApi interface in src/shared/types.ts, and the preload is just a mechanical implementation of it:

// src/shared/types.ts (excerpt)
export interface DesktopApi {
  workspace: {
    choose(): Promise<string | null>;
    recent(): Promise<WorkspaceItem[]>;
    read(path: string, cwd?: string): Promise<{ path: string; content: string; binary: boolean }>;
    open(path: string, cwd?: string): Promise<void>;
    list(cwd?: string): Promise<string[]>;
    restore(files: Array<{ path: string; content: string | null; mode?: number }>, cwd?: string): Promise<{ restored: string[] }>;
    onChanged(listener: (root: string) => void): () => void;
  };
  agent: {
    start(options: AgentStartOptions): Promise<AgentSnapshot>;
    stop(): Promise<void>;
    command<T = unknown>(type: string, data?: Record<string, unknown>): Promise<T>;
    onEvent(listener: (event: AgentEvent) => void): () => void;
  };
}

The preload layer is pure translation, mixing in no logic:

// src/preload/index.ts (excerpt)
import { contextBridge, ipcRenderer } from "electron";
import type { AgentEvent, DesktopApi } from "../shared/types";

function subscribe<T>(channel: string, listener: (payload: T) => void): () => void {
  const handler = (_event: Electron.IpcRendererEvent, payload: T) => listener(payload);
  ipcRenderer.on(channel, handler);
  return () => ipcRenderer.removeListener(channel, handler);
}

const api: DesktopApi = {
  platform: process.platform,
  workspace: {
    choose: () => ipcRenderer.invoke("workspace:choose"),
    recent: () => ipcRenderer.invoke("workspace:recent"),
    read: (filePath, cwd) => ipcRenderer.invoke("workspace:read", filePath, cwd),
    restore: (files, cwd) => ipcRenderer.invoke("workspace:restore", files, cwd),
    onChanged: (listener) => subscribe<string>("workspace:changed", listener),
  },
  agent: {
    start: (options) => ipcRenderer.invoke("agent:start", options),
    stop: () => ipcRenderer.invoke("agent:stop"),
    command: (type, data) => ipcRenderer.invoke("agent:command", type, data),
    onEvent: (listener) => subscribe<AgentEvent>("agent:event", listener),
  },
};

contextBridge.exposeInMainWorld("harness", api);

The benefit is obvious: the renderer process wants to touch the file system? That method doesn't exist in DesktopApi, so the compiler throws an error immediately. Want to bypass it? contextIsolation is on, nodeIntegration is off — there's no way. The security boundary isn't maintained by discipline, but by type definitions.

Agent process host: a JSON-RPC client with timeouts

In the main process, I wrote an AgentHost responsible for spawning the agent child process, parsing JSON-RPC line by line, and handling timeouts:

// src/main/agent-host.ts (excerpt)
const DEFAULT_RPC_TIMEOUT_MS = 45_000;
const LONG_RPC_TIMEOUT_MS = 30 * 60_000;
const LONG_RUNNING_REQUESTS = new Set([
  "prompt", "steer", "abort", "get_entries",
  "get_fork_messages", "get_messages", "get_session_stats", "fork", "compact",
]);

export class AgentHost {
  private child?: ChildProcessWithoutNullStreams;
  private pending = new Map<string, PendingRequest>();

  async start(options: AgentStartOptions & { cwd: string }): Promise<AgentSnapshot> {
    await this.stop();
    const args = [
      getTetherRpcEntryPath(),
      "--mode", "rpc",
      "--provider", options.provider,
      "--permission", options.permission,
      "--sandbox", options.sandbox,
    ];
    if (options.network) args.push("--network");
    // spawn child process, start listening to stdout lines
  }

  async snapshot(): Promise<AgentSnapshot> {
    const [state, messages, models, thinkingLevels, stats, commands] = await Promise.all([
      this.request("get_state"),
      this.request("get_messages"),
      this.request("get_available_models"),
      this.request("get_available_thinking_levels"),
      this.request("get_session_stats").catch(() => undefined),
      this.request("get_commands").catch(() => ({ commands: [] })),
    ]);
    return { state, messages, models, thinkingLevels, stats, skills: parseSkillCommands(commands.commands) };
  }
}

A detail: requests like prompt, fork, compact can run for minutes, while normal requests have a 45-second timeout. So I created a whitelist — long tasks use a 30-minute timeout, the rest keep the short timeout for fast failure. Don't underestimate this: an RPC client without timeout control will freeze the entire UI if a single request hangs.

Permission model: plan / ask / auto / full

An Agent can write files and run commands; the permission boundary must be explicit. Four modes:

Mode Behavior
plan Read-only analysis and planning; diagnostic commands run in a read-only sandbox
ask Prompts before write operations, network access, or boundary escalation
auto Automatically executes routine workspace operations; prompts on escalation
full Disables the workspace sandbox for explicitly trusted projects

The underlying layer uses macOS Seatbelt for sandboxing, with an experimental sandbox helper on the Windows side (requires separate installation and activation). The sandbox is defense in depth, not a reason to skip reviewing commands — you still need to audit what's happening in unfamiliar repositories.

Patch checkpoint: making /undo actually roll back

The scariest part of AI modifying code is "it messed up the file." My solution: all file changes go through patches; each write creates a checkpoint, and a /undo command in the UI can restore the previous round of changes.

// src/renderer/conversation.ts (excerpt)
export interface RestoreFile {
  path: string;
  content: string | null;  // null means delete
}

export function lastTurnRestoreFiles(entries: SessionEntryLike[]): RestoreFile[] {
  // Find all checkpoints from the last turn in session entries and assemble a restorable file list
}

export function dropLastTurn(messages: ChatMessage[]): ChatMessage[] {
  // Remove the last turn's rendered messages, used together with workspace.restore
}

Paired with the main process's workspace:restore IPC, clicking undo in the UI causes the main process to write the files back to disk. This mechanism seems unremarkable, but in practice it's a lifesaver — especially when you realize the agent's refactoring of a large file went wrong.

image.png

Session data: purely local, resumable

All data resides under ~/.tether, with no telemetry and no relay. A crash isn't scary: the session file on disk can be read as a conversation to continue, but it will never silently replay unexecuted commands — better to let you manually resend than pretend everything is normal.

A few small things were also done:

Skills / MCP / Hooks: not just for show

Skills in the Pi ecosystem are loaded at runtime. Tether doesn't write a separate loader; SKILL.md includes frontmatter (name + description, missing items prevent loading). MCP and Hooks are also wired up in agent-core, and the desktop shell is responsible for displaying them and passing user configuration down.

A project's own skills are accumulated this way, too. For example, UI consistency uses .agents/skills/tether-ui, and long tasks use the plan-then-act, init-long-run, continue-long-run group. One of the biggest lessons from writing agent applications: turn team conventions into skills, rather than relying on word-of-mouth in chat logs.

Pitfalls encountered / Lessons learned

  1. Don't treat Electron like a browser. If the boundary between the renderer and main process isn't clearly drawn from the start, every subsequent feature becomes a fight with security configurations.
  2. The agent process must be independent of the UI lifecycle. Initially, I considered putting it in the main process, but later found that a crashing agent could take down the entire application. An independent child process + disk-based sessions is the only reliable combination.
  3. Timeouts are a first-class citizen of RPC. Without whitelist-based timeout management, mixing long and short tasks will eventually freeze the entire UI.
  4. Patches are better than full-file writes. Letting AI write an entire file every time is too dangerous; patch + checkpoint is what makes "undo" possible.

Code is on GitHub: https://github.com/tt-11-dd/tether-ai, MIT license, welcome to take a look.

Finally

Honestly, the journey from "can't even modify an AI tool" to "built a handy workstation myself" — the most valuable part wasn't the lines of code, but figuring out which parts should reuse the ecosystem and which parts you must build yourself. The Agent ecosystem is still evolving rapidly. Standing on the shoulders of giants and polishing the product boundaries and user experience might be more valuable than writing everything from scratch.

If this article inspired you, or you want to discuss Agent application architecture, see you in the comments.


If you found this useful, feel free to Star / Share; also welcome to share the Agent development pitfalls you've encountered in the comments.