kolu
Docs

another computer

Your terminals,
on other machines.

Kolu can put terminals from other machines right on your canvas. A remote host’s terminals are first-class tiles — same Code tab and file preview, same paste, same session restore — just running on that host over ssh. The canvas shows one host at a time; a host row lets you switch which host you’re looking at — a strip in the top bar on desktop, and a row in the pull-down chrome sheet on a phone — and switching swaps the whole canvas live, with no reload. Each host keeps its own arrangement, so you@box looks the same from your laptop, your desktop, or your phone.

This is the other direction from From another device (and from From a Mac): those pages are about reaching the kolu app. This page is about kolu reaching another machine over ssh, once the app is already running.

Prerequisites

Passwordless ssh

You can ssh to the host without typing a password — an ~/.ssh/config alias or a user@host that just connects. kolu dials non-interactively, so if the host asks for a password, or its key isn’t verified yet, the connection stops with a card that names which one and how to fix it.

Nix on the remote

The host has Nix installed and your user is trusted by its nix-daemon — kolu provisions padi there with Nix over ssh. It must be runnable from a non-interactive ssh session, not only from a login shell.

A Nix-wrapped kolu

Launch kolu through its Nix wrapper, which bakes in its exact agent source. On first contact, kolu asks the host for its CPU/OS pair and resolves only that padi build — Linux and macOS, including cross-arch (a macOS kolu can bind a Linux box).

Add a host

The + add affordance on the host row is always present — you don’t have to set anything to reach it. It opens an ssh-target box — an anchored popover on desktop, a full-width section inside the pull-down sheet on a phone. What you type is added to the pool as a chip, and the canvas jumps to it. Your local host is always the first, unremovable chip. Two formats work:

  • an ~/.ssh/config alias — zest
  • a user@host destination — srid@zest

A host you add is remembered: it comes back on the host row after a kolu restart and reconnects on its own, so you build your fleet once rather than re-adding it every launch. The only way a host leaves your fleet is your explicit remove — a host that has since gone away comes back as a failed chip with the reason, never silently dropped.

Typing a bare loopback spelling — localhost, 127.0.0.1, or ::1 — maps to your existing local host instead of minting a duplicate chip. (user@localhost is not deduped: ssh-ing to the loopback as a different user is a genuinely different session, so it’s treated as a real remote.)

Pre-seed hosts at launch (optional)

Hosts you add from the host row already persist across restarts (above), so you rarely need this. Reach for it when you want a host present declaratively — a scripted launch, a shared dev box, a fresh profile with no saved fleet yet: seed KOLU_PADI_HOST with a comma-separated list of ssh targets:

KOLU_PADI_HOST=localhost,you@box kolu

That puts your local host (always first) plus each remote in the pool at boot. With nothing set and nothing added, the pool holds only your local host — a single local chip — with the + add affordance always beside it, ready to connect a machine.

First connect — provisioning over ssh

The first time you switch to a host that has never run padi, kolu provisions it over ssh: one Nix operation evaluates the exact agent, transfers what the machine needs, and realises it there. A cold provision can take minutes — mostly the transfer, plus working out which agent to send. You don’t install anything on the host by hand.

Where the bits come from depends on how kolu itself was installed. Run as a service through the home-manager module, kolu already holds every agent closure it can send: the installed generation carries them, so the transfer starts immediately and the host never compiles the daemon, even with no binary cache in reach. Launched with nix run or a plain nix profile install, kolu fetches the agent from the cache first, and falls back to building it when the cache doesn’t have it yet.

What arrives is the daemon plus the command-line tools a terminal on that host needskaval-tui, padi-tui, and kolu itself. Terminals kolu opens there have them on $PATH and $PADI_SOCKET already set, so an agent working on the remote machine can drive its sibling terminals and use the MCP server immediately — again with nothing installed by hand. Because the tools ride the same closure as the daemon, they are always the same build as the padi they talk to.

Each host is a tab: local is a Home icon plus the machine hostname kolu is running on; remotes show the ssh target. Every tab carries a connection pip so link health is always visible:

on the tabmeaning
Home + hostnamethis machine (local)
user@hosta remote ssh target
green pipthe whole chain is live — kolu reaches this host and its terminal daemon is serving
amber pip (pulsing)connecting or provisioning — coming up
amber pip, “kaval down”kolu reaches the host, but its terminal daemon is not serving. padi restarts it for you; the tooltip carries the daemon’s own verdict
red pip + struck labelunreachable — open diagnostics for the real error and retry

The pip reports the whole chain (kolu → this host’s padi → its kaval), not just the first link. A host you can reach whose terminal daemon is dead is not green: that combination is the one that used to read as healthy while nothing would run.

Click the connection pip for the diagnostics popover — connection state, terminal and awaiting counts, padi·kaval detail, remove-with-confirm. The label (Home or hostname) only switches hosts; diagnostics is never hover-opened.

Switch hosts, and per-host canvases

Click a chip to make that host active — the whole canvas switches to it live, no reload, bindings staying warm server-side. From the keyboard, type a host name in ⌘K (hosts are first-class root results in the switcher), or press ⌘⇧H (Ctrl+Shift+H elsewhere) — or the search icon in the host bar — to deep-link into the scoped host list; pick a host and the canvas switches. Once a tab has switched at least once, that list opens with the machine you came from already highlighted, so ⌘⇧H then Enter hops back and forth between two machines without your reading the list at all. The host strip stays as ambient status (connection dots, per-host attention counts) — glanceable without opening anything. Each host owns its own canvas: its terminals, splits, and focused tile are restored as you left them when you switch back. Switching away doesn’t tear a host’s work down; it keeps running on that host. If the active host ever leaves the pool, kolu switches you back to local and says so.

A tab also carries that host’s attention summary, so a machine you’re not looking at can tell you it needs you without your switching to it first — the same three counts, in the same colours, as everywhere else in kolu; see Notifications for what each one means and when it clears. Clicking the violet count jumps to the agent that’s waiting, switching host on the way. Attention reaches you the same way on every host: a background machine’s agent that finishes or starts waiting chimes and pops a notification just as the active machine’s does. An installed PWA surfaces the awaiting count as an app-badge and notifications across all your hosts.

Switching back to a host you’ve already visited is instant: kolu keeps each visited host’s canvas state — selection, camera, saved sessions, activity, and the Code tab’s reads (open file content, git status, and diff) — warm in memory, so a switch-back has nothing to reload. The switcher is different: it keeps a lightweight fleet index (terminal keys + metadata, not PTY bytes) live for every connected pool member, including hosts you have not opened yet, so ⌘K can jump cross-host without waiting for a first visit. Only the host you’re viewing gets a live on-screen renderer (its terminals’ WebGL/DOM canvases), which re-attaches in a blink when you switch back — kolu does not hold a live canvas per background host. Browser memory therefore scales with that retained per-host view state, the fleet metadata index, and the number of connected hosts — not with a full canvas for every host. If a multi-host session feels heavy, see kolu is using a lot of memory.

When a host is down

If a host’s padi can’t bind, its canvas is replaced by a card that names the cause in plain language and offers two actions — Reconnect (a real re-dial, once you’ve cleared the cause) and Switch to local (the always-available escape hatch).

When the failed attempt produced any output, the card shows the last lines of it beneath the cause — the same running log the canvas shows you while a host is coming up, kept where it is most useful. That output is usually what tells you which kind of failure you have: a machine that is genuinely unreachable looks nothing like one whose remote build failed to compile, and only the log distinguishes them. Those lines travel with the failure itself, not alongside it — so they survive a browser whose connection to kolu has gone quiet (a laptop that slept, a tab left open on flaky wifi): you get the reason and its evidence together, or the card simply shows no output block, which now means the failure printed nothing. The same tail appears on the host tab’s diagnostics popover, so you can read it without leaving the host you’re on.

The causes it distinguishes:

cardwhat happened
Another kolu owns this hosta different kolu supervisor already holds this host’s padi
This host runs an older koluthe remote padi speaks an older contract and refused to bind
This host runs a kolu from before the protocol changethe padi there speaks a protocol epoch this kolu can’t query; kolu tried to take it over and couldn’t. Update that machine’s kolu (or stop the padi there), then reconnect — retrying alone won’t help
This host needs passwordless sshthe host asked for a password. kolu connects non-interactively and never types one — set up key-based access (ssh-copy-id), then reconnect
This host isn’t trusted yetssh doesn’t recognise the host’s identity key. Run ssh <host> once in a terminal to review and accept it — or clear a changed-key warning — then reconnect
This host has no Nix that kolu can runssh worked, but the host’s shell couldn’t run nix-instantiate. Either Nix isn’t installed there, or it isn’t on the PATH of a non-interactive ssh session
Can’t reach this hostunreachable over ssh, or provisioning failed partway. These look identical from the outside, so read the output on the card: silence or an ssh error means the machine; a compiler or build error means the host was fine and its build wasn’t
This kolu run is missing its agent sourcekolu wasn’t launched through its Nix wrapper, so it has nothing to deploy
Kolu couldn’t prepare padi for this hostthe baked source could not resolve padi; the detail shows the Nix error, including an unsupported CPU/OS pair when that is the cause
This host’s padi never settleda contract upgrade’s drain never provably finished

A related — but distinct — card covers the host’s kaval: if the machine carries a kaval that speaks an incompatible wire contract (an outdated build a plain restart provably cannot fix — restarting respawns the same binary), the canvas says so with both versions (“this host’s kaval speaks 5.0, your kolu needs 5.2”) and offers Update & restart kaval behind a confirm: it re-provisions the host’s build and starts a correct-version kaval. The same verdict shows in the kaval dialog and on the host chip — the one badge that also flags an ordinary “newer build available”.

Port forwarding

A dev server on a remote host is running on that machine’s loopback, and loopback never leaves the machine it is on — so http://127.0.0.1:5173 in your browser means your laptop, which is the one machine that certainly isn’t running it. kolu closes that gap: click the port and it opens a door, then opens the tab.

Ports show up on their own. The Inspector’s Ports section lists what each terminal is serving — read from the host’s own socket table, not scraped from output — so the thing an agent just started is one click away instead of somewhere up the scrollback.

How a click becomes a page

kolu reads the port’s bind address and picks the mechanism; there is no guessing and no configuration.

where the server listenswhat kolu doeswhat opens
a remote host, on loopbackssh -L over a connection of its ownhttp://<the kolu host>:<port>
kolu’s own machine, on loopbacka plain TCP relayhttp://<the kolu host>:<port>
kolu’s own machine, all interfacesnothing — it already answershttp://<the kolu host>:<port>
a remote host, one specific interfacenothing it can dothe row says “not reachable”

That last row is the honest one. A port bound to a single named interface of a remote machine answers at that address and nowhere else, and neither mechanism reaches it — both connect to the far side’s loopback. kolu says so rather than offering a button that would fail.

Whenever it can, kolu gives you back the same port number you asked for, so the URL is predictable and a link you saved keeps working across a restart. When that number is already taken on the kolu host, the row shows the one you got.

Managing open doors

A forwarded port is one row wherever it appears, carrying a teal ⇄ :<port> pill that names where it answers, a copy button for the full address, and a that closes the door. You’ll find those rows in three places — the Inspector’s Ports list, the host tab’s dropdown, and the tab’s connection dot, which wears a thin teal ring while that host has doors open.

Each row also names the terminal serving that port, and the name is a link: click it and you land on the agent that started the thing.

A door you opened by hand, or one whose server has since died, trails the Ports list under “also forwarded on this host” — a door belongs to the machine, not to whichever terminal you happen to be looking at.

  1. Open one by clicking. Any port row that needs a door offers forward & open. Doors open lazily — only for ports you actually click — and close themselves when the scanner sees the listener die.

  2. Or forward a port kolu never saw. ⌘KForward a port…, then host:port (or just a port, for the host you’re looking at). Useful for a service outside any terminal’s process tree, or one that was already running before kolu started.

  3. Close it when you’re done. on the row. A forward you asked for by hand stays until you cancel it or its host leaves — there is no listener for kolu to watch on your behalf there.

Everything dies with the kolu server, guaranteed by the kernel rather than by a timer, so a deploy or a crash never leaves an orphaned door open.

A printed localhost URL uses the same door

Agents constantly print http://localhost:5173/ (and 127.*, [::1], 0.0.0.0). In your browser that hostname is the wrong machine — the laptop you are reading kolu from, not the host running the server. Click a loopback URL in the terminal and a small card appears at the cursor: when the scanner already sees that port on this terminal it offers forward & open (and copy), with the printed path carried through the door; when nothing is listening yet it says so instead of inventing a forward from text. ⌘/Ctrl-click opens the raw URL with no card. Non-loopback links keep the usual open behaviour.

The card is live — leave it open while a server starts and it upgrades the moment the listener is real. Printed text never creates a port fact of its own; it only looks up what observation already established. The Inspector’s Ports section documents the same card from the tile side.

Known limitations

A kolu remote terminal is an ssh session — not the machine’s GUI login session. A class of tools that “just work” in the box’s own terminal lean on that GUI session, so they behave differently over the bind. The following follow from that; each is ssh reality, not a kolu bug, but you’ll meet them here first.

symptom you seewhyremedy
On macOS, gh reports The token in default is invalidgh keeps its token in the login Keychain, unlocked only for the GUI sessiona $SSH_CONNECTION-guarded GH_TOKEN (a scoped fine-grained PAT), or security unlock-keychain, or gh auth login --insecure-storage
git push / clone fails Permission denied (publickey)your keys and ssh-agent live in the local session, not the ssh onekeep a key on the host, or forward your agent with ssh -A — confirm with ssh-add -l
pbcopy / pbpaste, launchctl, desktop notifications do nothingthese are GUI-session services; an ssh session runs in a different session contextrun them from the box’s own terminal; for launchctl name the domain: launchctl print gui/$(id -u)
a command is “not found”, locale warnings, missing $PATHssh starts a leaner login shell; GUI-launcher PATH isn’t inheritedput PATH/locale in the rc both shells read (~/.zshrc); check with echo $PATH and locale

This table grows by incident: every future “works in my local terminal, breaks over a kolu remote host” report earns a row — symptom, one-line cause, remedy. If you hit one that isn’t here, that’s the signal to add it.