LinkCode is a unified GUI for general-purpose coding agents. This document is the source of truth for how the system is put together: its layers, its single data contract, the split between the data plane and the system plane, the package layout, the key runtime contracts, and the questions still open.
- Overview
- Core principles
- System architecture
- Data plane vs system plane
- Packages & repo layout
- The host: engine, adapters, abstraction
- Transport & wire protocol
- Key contracts
- Data flows
- Technology stack
- Open questions
- Glossary
LinkCode runs a local host on the user's machine that takes over any number of coding agents — Claude Code, Codex, OpenCode, Pi, and Grok Build — and normalizes their divergent native events into one shared data contract. A user connects to that single host from desktop (PC), webview (browser), or mobile, and gets the same conversation and the same controls everywhere. Desktop and webview connect to the host directly on the local machine; mobile reaches it from outside the network through a relay Server that tunnels the same messages.
Because every client speaks the same contract over the same transport abstraction, the upper layers never know — or care — whether the host is a direct in-process connection, a fan-out to several local clients, or a tunnel from a phone across the internet.
- The zod schema is the only data contract. Every cross-process, cross-endpoint,
and post-abstraction business message type originates in
@linkcode/schema. The workflow is always "change the schema first, then the implementation," and every trust boundary — network, IPC, agent output — validates with zod at runtime. - End-to-end type safety. No
anyto bypass the contract; types derive from the schema viaz.infer. Other packages import from@linkcode/schemarather than redefining message types. - The data plane and the system plane are strictly separate. Business data travels only over the transport as zod messages. Electron's system- and UI-level operations travel only over TypeSafe IPC. The two never mix — TypeSafe IPC never carries business data.
- Local-first. The host runs on the user's machine. Desktop and webview connect to it directly; mobile reaches it through the Server tunnel. Local and remote share the same transport abstraction and the same zod messages.
- Interface-first, implementations swappable. TypeSafe IPC is an interface (tRPC is only the default implementation). Each new agent is one adapter implementing the unified interface — no per-vendor branching leaks into the upper layers.
- Transport is decoupled from its carrier. An in-process local connection, a
fan-out hub serving many local clients, and a WebSocket tunnel through the Server all
present the same
Transportand carry the same wire messages. Upper layers cannot tell which is underneath.
flowchart TB
subgraph LOCAL["User's machine"]
subgraph DAEMON["daemon — local host process"]
direction TB
ENGINE["Engine (the host)<br/>session lifecycle + orchestration"]
subgraph ADAPTERS["Agent adapters + abstraction layer"]
direction LR
A1["claude-code"]
A2["codex"]
A3["opencode"]
A4["pi"]
A5["grok-build"]
end
HUB["Hub + transport server<br/>127.0.0.1 · Socket.IO / ws"]
ENGINE --> ADAPTERS
ENGINE --- HUB
end
DESK["desktop · Electron<br/>renderer (coss-ui)<br/>+ TypeSafe IPC (system plane)"]
WEB["webview · browser<br/>coss-ui"]
end
SERVER["server — tunnel / relay<br/>token · perm · store · realtime"]
MOBILE["mobile · Expo / HeroUI"]
DESK <-->|"transport + zod"| HUB
WEB <-->|"transport + zod"| HUB
HUB <-->|"transport + zod · RPC / ws"| SERVER
SERVER <-->|"tunnel · ws"| MOBILE
The daemon is the runnable local host: it constructs the Engine, hosts the agent
adapters, and exposes a transport server on 127.0.0.1 that every local client
connects to. The Engine is the host proper — it owns session lifecycle and agent
orchestration. Clients are thin: they render the normalized conversation and send
normalized input back.
Listeners bind at the configured port (default 19523, ascii LC) and hunt upward
past foreign occupants. Every listener answers GET /linkcode with the daemon's
identity, which enforces one daemon per machine: a second instance detects the first
and exits. The actually-bound endpoints are advertised in ~/.linkcode/runtime.json
(removed on shutdown) so local clients — desktop main in particular — can discover
the endpoint instead of hard-coding it.
Two channels run side by side and never overlap:
- Data plane — all business data (sessions, agent events, tool calls, permission
prompts, history). It flows over the
transportas zod-validated wire messages, and is identical on every client and every carrier. - System plane — desktop-only operating-system and window/UI operations (window controls, native dialogs, file pickers). It flows over TypeSafe IPC between the Electron main and renderer processes, and exists only on desktop.
Keeping them apart is what lets webview and mobile drop the system plane entirely while reusing the exact same data-plane code, and it is why TypeSafe IPC must never be used to move business data — doing so would couple business logic to Electron.
A pnpm-workspaces + turborepo monorepo, all TypeScript. apps/* are runnable ends;
packages/*/* are shared libraries grouped by architectural ownership. The whole system is glued by one zod contract
(@linkcode/schema) carried over a transport.
| App | Description |
|---|---|
daemon |
Local host process — constructs the engine and exposes the data plane over a local Socket.IO/WebSocket server (127.0.0.1) that every client connects to. |
desktop |
Electron app: Vite renderer + main/preload. The renderer connects to daemon over transport; the system plane goes through TypeSafe IPC. |
webview |
Browser client — Vite + React Router + coss-ui. Connects to daemon over transport. No system plane. |
mobile |
Expo / React Native client (HeroUI). Reaches the host through the external LinkCode Cloud tunnel. |
The Server shown in the system diagrams and data-flow sections is the production relay owned by
the external linkcodehq repository; there is no Server app in this repository.
Scopes make ownership and navigation visible; they do not rename package imports and do not by themselves enforce dependencies. Package contracts, ESLint, and the root typecheck remain the dependency guardrails.
| Scope | Packages | Responsibility |
|---|---|---|
foundation |
schema, transport, common |
Stable contracts, communication primitives, and framework-agnostic utilities. |
host |
agent-adapter, assets, engine |
Daemon-side agent integration, managed assets, and orchestration. |
client |
core, sdk, workbench |
Client data plane, typed operations, runtime containers, and feature composition. |
presentation |
i18n, ui |
Locale data and business-free presentation driven by view-models and callbacks. |
system-plane |
ipc |
Desktop-only Electron system-plane communication. |
integrations |
im-render |
Reusable adapters at external integration boundaries. |
vendor |
coss-ui |
Upstream code preserved and consumed as-is. |
| Package path | Description |
|---|---|
foundation/schema |
zod schemas — the single data contract. Every cross-process / cross-end / post-abstraction message type derives from here (z.infer). |
foundation/transport |
Communication layer ("how messages travel"): local / ws / Socket.IO implementations, the Hub fan-out, and the versioned wire protocol. |
foundation/common |
Shared framework-agnostic utilities that do not belong to the data contract or a product layer (for example Zustand persistence helpers). |
host/agent-adapter |
One adapter per agent (claude-code / codex / opencode / pi / grok-build) plus the abstraction layer that normalizes native events into schema. |
host/assets |
Managed-asset store for verified agent CLI pairs, in-process package closures, and standalone toolchains. |
host/engine |
The host engine: session lifecycle and agent orchestration, driving agent-adapter. |
client/core |
@linkcode/client-core: shared client data layer, conversation view-model, and React bindings (LinkCodeProvider, useConversation). |
client/sdk |
Transport-backed SDK (LinkCodeSdkClient over a Transport): typed operations plus the Options / RequestResult types tayori is parameterized with. Hand-written RPC, not OpenAPI-generated. |
client/workbench |
Shared workbench runtime: connection control, providers, feature containers, and typed tayori/SWR data plumbing. It composes presentation but does not own reusable views. |
presentation/i18n |
Locale messages and locale resolution (use-intl). |
presentation/ui |
Shared, business-free presentation: chat and shell view components (AppShell). Receives view-models and callbacks; owns no routing, connection, or daemon state. |
system-plane/ipc |
TypeSafe IPC (system plane) for Electron; tRPC is the default implementation. Desktop only. |
integrations/im-render |
LinkCode-owned, platform-neutral AgentEvent / conversation-to-Markdown renderer. Telegram, Discord, and Slack escaping, splitting, buttons, and delivery remain in the external linkcodehq bridges. |
vendor/coss-ui |
Vendored COSS UI primitives (base-ui + Tailwind, from cal.com's COSS UI). Synced from upstream and excluded from formatting and linting. |
The host is the Engine plus the adapter and abstraction layers beneath it.
Engine. Manages many concurrent agent sessions. For each session it subscribes to
the adapter's normalized events and pushes them down to clients over the transport,
while routing inbound input back up to the matching adapter. The Engine is agnostic to
the carrier: a direct local connection, a fan-out Hub, or a Server tunnel all drive
the same Engine.
Sessions are persisted as SessionRecords through an injectable SessionStore (the
daemon backs it with SQLite via drizzle at ~/.linkcode/daemon.db). A record is the
stable Link Code identity: each start/resume appends a run that the adapter's
session-ref event binds to the provider-local history id. session.list includes
cold (stopped) sessions, session.resume wakes one under the same id, and
session.import registers a provider-local history session as a cold record.
Transcripts are not copied — they stay in provider-local history and are read back
through the history contract.
The daemon supplies concrete infrastructure through EngineInfrastructure: the transport,
persistent stores, PTY backend, assets, runtime probe, preview routes, and provider configuration.
EngineLive consumes that boundary and assembles the Engine features behind EngineService.
makeEngineLayer is only a convenience composition for tests and embedders that already hold
concrete infrastructure.
┌──────────────────────────────┐
│ daemon-owned infrastructure │
│ transport · stores · PTY │
│ assets · probes · routes │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ EngineInfrastructure Layer │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ EngineLive │
│ feature assembly + lifecycle │
└──────────────┬───────────────┘
▼
┌──────────────────────────────┐
│ EngineService │
└──────────────────────────────┘
createEngineRuntime is the single ownership point for the Engine's root Scope and root
FiberSet. Session, Schedule, Loop, and Script work all run beneath that owner; feature services
must not create another runtime, root scope, or root fiber registry. Layers model real construction
and resource boundaries, not every helper class. Schedule and Loop façades own request admission and
durable state transitions while their run coordinators own individual run execution and settlement.
ScriptService owns registry and terminal orchestration; ScriptPortPlan owns stable port/environment
planning, and the TCP probe owns its interruptible socket resource.
Effect owns the Engine control plane: startup/shutdown, request execution, session lifecycle, automation, process execution, interruption, and resource finalization. The high-volume terminal/PTTY frame path remains plain TypeScript; wrapping every input or acknowledgement frame in Effect tracing would add data-plane overhead without improving lifecycle correctness.
Engine exits have three distinct meanings:
- Expected failure —
RequestError,OperationError, orOperationTimeoutin the typed error channel. The wire boundary maps these to stable public codes and safe messages. - Defect — an unexpected cause. It is reported diagnostically, while the wire receives only
internal_error/Internal engine error. - Interruption — cancellation or shutdown. It remains interruption, runs finalizers, and is never converted into a normal business failure or persisted as one.
Private causes may be logged but never enter wire replies or durable user-visible failure strings.
Wire request outcomes are observed across WireResponder, because typed failures become successful
send effects after a request.failed reply. Span attributes may contain correlation identifiers;
metric attributes are limited to stable low-cardinality values such as subsystem, operation, request
kind, and outcome. Engine instrumentation covers request latency/outcome, session start/stop and live
session count, Schedule runs, Loop iterations, and child-process execution including timeout and
interruption outcomes.
The Engine emits only Effect logs, spans, and metrics. The daemon owns all exporters and sinks:
Effect logs bridge into Pino; Sentry's Pino integration reports error/fatal records; the daemon's
recursive sanitizer runs before both log emission and Sentry events. @effect/opentelemetry installs
only the Effect tracer over Sentry's global OpenTelemetry provider — it does not create a second
provider, exporter, or logging pipeline. Effect metrics are currently runtime-local; adding a metrics
exporter is a daemon boundary decision.
Agent adapters. One adapter per agent — claude-code, codex, opencode, pi, grok-build —
each hiding its SDK's differences behind the unified AgentAdapter interface. A shared
BaseAgentAdapter factors out the common machinery (event fan-out, pending permission
asks). Adapters advertise their historyCapabilities (list / read / resume), and any
unsupported operation rejects clearly rather than silently degrading.
The adapter lifecycle has four turn-level invariants: a turn-starting input emits running before
its dispatch resolves; an open permission/question keeps that running turn awaiting input; a normal
turn ends with stop followed by idle; and a permanently unavailable adapter emits stopped.
Cancel follows the same terminal stop/idle path and tears down every open interaction/tool, so
composer gates never infer liveness from a missing terminal event.
Abstraction layer. Adapters normalize each agent's native events into the zod
AgentEvent contract, and accept the
normalized AgentInput. The agent data vocabulary — content, tool calls, plans,
permissions, sessions, usage, history — is tailored to the five supported agents and
the front-end, not to any particular wire format.
The transport package answers "how messages travel." It offers one Transport
interface with several implementations — an in-process local transport, a WebSocket
transport, and a Socket.IO transport — plus a TransportServer that accepts client
connections and presents each as a Transport. A client Transport instance owns one
physical connection lifetime and never silently revives after close; recovery creates a
fresh transport and client generation. Carrier connection alone is not application
readiness: LinkCodeClient.connect() resolves only after a versioned ping / pong
round trip, so a wire-incompatible peer cannot become falsely ready.
The daemon serves many clients at once through a Hub, which composes every client
connection into the single Transport the host consumes. Inbound messages from every
client are merged into one host stream, while the Hub retains the originating connection
for each correlation id (clientReqId → replyTo) and returns the reply only there.
agent.event keeps its broadcast/attached-session behavior; live terminal.* frames go
only to connections attached to that terminal. Removing a physical or relay-virtual
connection detaches all of its terminal capabilities before the connection disappears.
The Cloud tunnel adds a relay-attested peer envelope outside WireMessage. The daemon's one
host uplink exposes each remote peer as a separate virtual Transport, so Hub reply and
terminal routing has the same connection boundary locally and remotely. Host frames are
directed to one peer; the relay never broadcasts one client's opaque wire replies to the
other clients of that host.
Every message is a versioned envelope (version, id, timestamp) wrapping a payload.
The payload is a discriminated union keyed by kind (session.start / session.started,
session.list / session.listed, history.list / history.listed, agent.event, and
so on). Local direct connections and remote tunnels share the identical format, and every
receiving end validates with zod at its trust boundary before delivery. Senders do not
re-validate (the per-frame send parse was hot on the terminal wire, CODE-231): send() takes
the branded ValidatedWireMessage, minted only by createWireMessage (typed construction)
and parseWireMessage (boundary validation), so an unchecked object cannot reach a send path
without an explicit cast. LocalTransport alone keeps a send-side parse so tests and the
dev-mock host catch schema drift behind the brand.
Any change to the payload union bumps WIRE_PROTOCOL_VERSION; the version is a validated
literal, so a stale peer rejects every message — after a bump, restart the daemon and all
clients together.
Interactive permission and question requests have a host-authoritative lifecycle. The Engine
records each advertised request as open, validates responses against that exact request, emits a
responding status while the adapter call is in flight, and emits one terminal resolved outcome.
Adapter rejection restores the request; tool completion, turn end, stop, and delete cancel any
remaining request explicitly. session.attach replays open/responding requests and terminal
outcomes from the current or most recently completed turn so reconnecting clients converge; the
next turn drops those resolved tombstones. Clients render pending requests in arrival-order FIFO,
keep drafts local, and submit a multi-question request as one ordered response.
All of the types below derive from @linkcode/schema; the signatures are the current
shape in packages/*/*, elided for readability.
// @linkcode/agent-adapter — one adapter per agent; native events → normalized AgentEvent
interface AgentAdapter {
readonly kind: AgentKind; // 'claude-code' | 'codex' | 'opencode' | 'pi' | 'grok-build'
readonly capabilities: AgentCapabilities; // { slashCommands, shellCommand }
readonly historyCapabilities: AgentHistoryCapabilities; // { list, read, resume }
start(opts: StartOptions): Promise<void>;
send(input: AgentInput): Promise<void>;
onEvent(cb: (e: AgentEvent) => void): Unsubscribe; // normalized by the abstraction layer
stop(): Promise<void>;
// provider-local history, when advertised:
listHistory(opts?: AgentHistoryListOptions): Promise<AgentHistoryListResult>;
readHistory(opts: AgentHistoryReadOptions): Promise<AgentHistoryReadResult>;
resumeHistory(opts: AgentHistoryResumeOptions, startOpts: StartOptions): Promise<void>;
}
// @linkcode/transport — the carrier-agnostic message pipe + its listener
interface Transport {
connect(): Promise<void>;
send(msg: ValidatedWireMessage): void | Promise<void>; // brand minted by createWireMessage / parseWireMessage
onMessage(cb: (msg: ValidatedWireMessage) => void): Unsubscribe; // zod-validated on receive
onClose(cb: () => void): Unsubscribe;
close(): void | Promise<void>;
}
interface TransportServer {
onConnection(cb: (conn: Transport) => void): Unsubscribe;
close(): Promise<void>;
}
// @linkcode/sdk — transport-backed client the front-end data layer is built on
class LinkCodeSdkClient {
constructor(options: { transport: Transport });
connect(): Promise<void>; // resolves after LinkCode ping/pong
onClose(cb: (error: Error) => void): Unsubscribe; // unexpected close after readiness
dispose(): void;
listSessions(): RequestResult<SessionInfo[]>;
startSession(opts: StartOptions): RequestResult<SessionId>;
stopSession(id: SessionId): RequestResult<{ ok: true }>;
resumeSession(id: SessionId): RequestResult<SessionId>; // cold session → live again, same id
importSession(kind: AgentKind, historyId: AgentHistoryId): RequestResult<SessionRecord>;
sendInput(id: SessionId, input: AgentInput): RequestResult<{ ok: true }>;
promptText(id: SessionId, text: string): RequestResult<{ ok: true }>;
cancel(id: SessionId): RequestResult<{ ok: true }>;
respondPermission(id: SessionId, requestId: string, outcome: PermissionOutcome): RequestResult<{ ok: true }>;
// history: listHistory / readHistory / resumeHistory
}The desktop system plane is a separate interface entirely, carrying only OS/UI capabilities and never business data:
// @linkcode/ipc — system plane, desktop only (tRPC is the default implementation)
interface SystemBridge {
window: { minimize(): void; maximize(): void; close(): void };
fs: { pickFile(): Promise<string | null> };
// system / UI capabilities only — business data always goes over transport
}- Local direct — desktop or webview ↔ daemon, over the transport with zod messages on the local machine.
- Multi-client routing — several clients attach to the daemon's
Hub; agent events broadcast according to the session subscription, correlated replies return to their origin, and terminal streams reach only attached connections. - Host uplink — daemon ↔ Server, RPC over WebSocket. A relay-attested outer peer envelope multiplexes virtual client connections while its payload remains the same normalized zod message.
- Remote access — mobile ↔ Server tunnel ↔ daemon, over WebSocket. The phone renders and controls the same host as any local client.
| Concern | Choice |
|---|---|
| Language | TypeScript (strict) across the stack |
| Data contract | zod |
| Monorepo | pnpm workspaces + turborepo |
| Wire protocol | Custom versioned transport envelope over Socket.IO / ws; in-process locally |
| Desktop shell | Electron (Vite renderer) |
| Desktop system bridge | TypeSafe IPC (tRPC default implementation) |
| Desktop / webview UI | coss-ui (base-ui + Tailwind) |
| Mobile | Expo / React Native (HeroUI) |
| Client data layer | sdk + tayori + SWR |
| Agents | Claude Code · Codex · OpenCode · Pi |
| Host control plane | Effect v4 (Layer, scoped resources, typed failures, interruption) |
| Host observability | Effect logs/spans/metrics → daemon Pino + Sentry/OpenTelemetry tracing |
| Lint / format | ESLint (eslint-config-sukka) / Biome (formatting only) |
These are genuinely undecided. Do not invent answers — raise them first.
- Server internals. The data models and protocols behind the Server's
token,perm,store, andrealtimecapabilities. - Multi-agent product behavior. Whether multiple agents run concurrently or are switched between, and how that is surfaced in the UI.
- Alternative system-plane implementations. Candidates for TypeSafe IPC beyond the default tRPC.
- The
piagent integration. The exact SDK and integration shape for Pi.
| Term | Meaning |
|---|---|
| Host | The core engine on the user's machine that takes over every agent. |
| Engine | The host implementation: session lifecycle + agent orchestration (@linkcode/engine). |
| daemon | The runnable local process that hosts the Engine and serves clients on 127.0.0.1. |
| Agent adapter | Per-agent integration that hides an SDK behind the unified AgentAdapter interface. |
| Abstraction layer | Normalizes native agent events into the zod contract. |
| Hub | Fan-out that composes many client connections into one Transport and broadcasts events. |
| transport | The carrier-agnostic message layer (local / ws / Socket.IO). |
| Wire protocol | The versioned envelope + kind-keyed payload union transmitted over the transport. |
| zod schema | The single data contract; the one source of every message type. |
| TypeSafe IPC | Type-safe Electron IPC for the system plane; desktop only; never carries business data. |
| tRPC | The default TypeSafe IPC implementation; swappable. |
| tayori | The typed request wrapper the client data layer is built on, backed by the sdk. |
| coss-ui | The desktop/webview UI primitive library (vendored from cal.com's COSS UI). |
| HeroUI | The mobile UI library. |
| Thread (UI) | The user-facing term for a Session. Product copy only — code and the wire protocol keep Session. |
| ACP | Agent Client Protocol — an early influence on AgentEvent, since dropped for a shape tailored to the five supported agents. |