seamless docs

Share one daemon across a LAN

Run one seamlessd for several devices - the server config, TLS with mkcert or openssl, the client-config pairing command, and the captures a remote device honestly does not get.

On this page

The default install is one daemon per machine, bound to loopback. This guide is the other shape: one daemon, several devices, so a laptop, a desktop, and a build box share a single corpus instead of three that drift apart.

It is an opt-in with real tradeoffs. The bearer key is still the only authentication and there is still no per-client authorization, so the supported boundary is a trusted LAN - see the security posture. Some daemon-side captures stop working for remote devices, deliberately and visibly; what a remote device does not get is the honest list, and it is worth reading before you start.

The topology

One machine is the server: it runs seamlessd, owns ~/.seamless (the SQLite database and the markdown corpus), and is the only machine that needs a service. Every other machine is a client: it installs the two binaries and wires its agent clients' hooks and MCP registrations at the server's URL, and it runs no daemon of its own.

Server Client
role server (the default) client
Daemon seamlessd serve none - serve refuses outright
Service (launchd / systemd / Scheduled Task) installed none
Data dir, database, corpus here none; seam status does not even print a data dir
Config keys that matter addr, server_url, allowed_hosts, tls.cert_file, tls.key_file, mcp.api_key role, server_url, mcp.api_key, tls.ca_file
Console, gardener, embeddings here the server's

A client's config is deliberately tiny. role: client requires server_url - a client that dials nowhere is a contradiction, and loading one is an error - and tls.cert_file on a client is refused too: a client holds no server certificate, it trusts one with tls.ca_file.

Step 1: widen the server

Edit the server's ~/.config/seamless/seamless.yaml:

addr: "0.0.0.0:8081"                       # listen on every interface
server_url: "http://studio.local:8081"     # what clients dial

Both keys, not one. addr answers "where do I listen"; server_url answers "where do clients reach me", and they stop having the same answer the moment the bind is a wildcard. Leaving server_url unset on a wildcard bind derives http://127.0.0.1:8081 - an address that means "your own machine" on every client that receives it - and the daemon warns about exactly that combination at startup. Putting the wildcard in server_url instead is refused outright at config load: http://0.0.0.0:8081 is an address no client can dial, so it can never be the right answer to "where do clients reach me".

Naming server_url also arms the Host-header allowlist: the daemon then answers the loopback names, a concrete bind host, and the host of server_url, and refuses everything else with 421 Misdirected Request. Add any further name the daemon is reached by - a short hostname, a raw IP - to allowed_hosts. (A wildcard bind has no concrete host of its own, which is why naming one somewhere is what turns the guard on there.)

Restart the daemon and check it:

seamlessd restart
seamlessd doctor

Doctor's transport lines are the ones to read:

Line What it tells you
bind Loopback is OK. Non-loopback without TLS is a warn naming what travels in the clear; non-loopback with TLS is OK with the reminder that the bearer key is still the only authentication.
server_url Fetches /healthz through the advertised URL. A 421 here is a fail that names the Host header it just refused - the allowlist and the advertised name disagree.
tls Off, or the certificate's expiry and whether its SANs cover the advertised host.

Step 2: TLS

Plain http over a LAN is a legitimate choice and Seamless will not stop you - seamlessd client-config prints a warning and continues. But the bearer key and every memory it fetches cross the network unencrypted, so prefer https.

Both tls.cert_file and tls.key_file must be set; one without the other is refused at load, because it cannot serve TLS. With both set the listener is https (TLS 1.2 floor), the console session cookie is marked Secure, and server_url derives an https:// scheme.

The certificate must cover the host of server_url - bare host, no scheme, no port, lower-cased. That is the name clients verify and the name doctor checks the SANs against.

mkcert (the friction-free path)

mkcert installs a local CA into your system trust store and issues certificates from it:

mkcert -install                                       # once per machine
mkcert -cert-file ~/.config/seamless/seamless.crt \
       -key-file  ~/.config/seamless/seamless.key  studio.local
mkcert -CAROOT                                        # prints the CA directory

Server config:

addr: "0.0.0.0:8081"
server_url: "https://studio.local:8081"
tls:
  cert_file: "~/.config/seamless/seamless.crt"
  key_file: "~/.config/seamless/seamless.key"

Copy rootCA.pem from the mkcert -CAROOT directory to each client and point tls.ca_file at it there. That one file is what makes the seam CLI trust the server; it is the only TLS key a client has.

openssl (no extra tool)

A self-signed certificate works just as well as long as it carries the SAN and is usable as its own root - CA:TRUE, which is why the second -addext is not optional. Needs OpenSSL 1.1.1 or newer for -addext:

openssl req -x509 -newkey rsa:2048 -sha256 -days 825 -nodes \
  -keyout ~/.config/seamless/seamless.key \
  -out    ~/.config/seamless/seamless.crt \
  -subj   "/CN=studio.local" \
  -addext "subjectAltName=DNS:studio.local,IP:192.168.1.10" \
  -addext "basicConstraints=critical,CA:TRUE"

Include every name and address clients dial in subjectAltName; a client that dials an IP the certificate does not list fails in the TLS handshake. Then copy seamless.crt - the certificate, never the key - to each client as ~/.config/seamless/server-ca.crt, and write that client's whole config:

role: "client"
server_url: "https://studio.local:8081"
tls:
  ca_file: "~/.config/seamless/server-ca.crt"
mcp:
  api_key: "<the server's key>"

Renewal is manual on both paths - nothing auto-renews - which is why doctor's tls line warns from 30 days out.

Why https changes the Claude Code wiring

Under an https:// base URL, install-hooks writes a different shape on purpose: every Claude Code hook becomes a command hook (including UserPromptSubmit, normally the one http hook), and the MCP registration becomes the seam mcp-proxy stdio bridge instead of a direct URL. Claude Code performs http hooks and http MCP connections with its own client, which has nowhere to be told about tls.ca_file; against a private CA that request dies in the TLS handshake. Routing both through seam puts them on the CLI's trust store, which does read tls.ca_file. Under plain http the shapes are unchanged.

Step 3: pair a client

On the server, ask for the pairing block:

seamlessd client-config            # add --redact to paste it into a ticket

It prints the server URL, the key, the minimum client version, and three paste-able commands: the macOS/Linux installer one-liner, the PowerShell one, and the manual form for a machine that already has the binaries:

seamlessd install-hooks --server-url https://studio.local:8081 --api-key <key>

That command writes role: client, server_url, and mcp.api_key into ~/.config/seamless/seamless.yaml on first run and wires the detected agent clients against the server's URL. Like every other config bootstrap it never edits a config file that already exists: it errors and names the exact lines to add by hand. The one existing file that is not an error is one that already says exactly this, so re-running the installer is safe.

The installer one-liners are the same thing with the binaries and the download included - SEAMLESS_SERVER_URL plus SEAMLESS_MCP_API_KEY, which is a pair: the URL without the key is a hard error. A client install skips the service branch entirely - the run prints the service step as skipped, naming the server it is a client of - and polls the server's /healthz instead of a local one.

client-config refuses rather than printing a command that cannot work:

Refusal Why
this install is role: client it has no clients of its own to pair; run it on the server
mcp.api_key is empty a client would have nothing to authenticate with
server_url is loopback pasted on another machine it dials that machine's port 8081. It names the fix: server_url plus addr: 0.0.0.0:8081, then restart

On the client, confirm:

seam doctor          # resolved URL, server reachable, key accepted, tools/list count
seamlessd doctor     # the client report: role, server_url, key, hooks, MCP

A client's seamlessd doctor is a deliberately short list: role and server_url reachability, the API key, the MCP tool count, and the same desired-state hook and MCP comparisons the server runs. The database, schema, repo map, feature skills, gardener, LLM, and embedder checks are all absent, because every one of them describes a machine that is somewhere else. The server_url probe is a fail on a client rather than the server's info line: until the server answers there are no briefings, no memories, and no tools on this machine.

What a remote device does not get

Sessions, projects, memories, notes, tasks, trials and recall all work over the network. What does not is every capture where the daemon reads the agent's filesystem, because on a remote device those paths are not the daemon's to read:

Capture Remote behavior
Claude Code plan-mode capture (plan-file saves, presentation, approval) skipped
Subagent transcript capture and spawn-prompt matching skipped
Git HEAD stamps on captured plans left empty, which reads as unknown downstream
Session-end transcript harvest, token harvest, model sniff skipped
Codex rollout harvest skipped
The gardener's ship-evidence pass (git history behind a stale plan) no evidence from repos mapped to another host

This is a host check, not a "does the file exist" check, and that is the point: two devices with the same username and home layout produce the same transcript and plan-file paths, so a missing-file test would sometimes find a real file - the wrong one - and capture another machine's session as this one's.

The same host scoping protects project mapping. The repo map is keyed by (host, path), and the moved-repo heal that re-points a project when its mapped path has vanished only ever stats this daemon's rows. A remote client's path is never stat'd on the server's disk.

One consequence to know about: a session on another host that sends no repository root cannot be placed in a project. The daemon will not derive one - that would mean reading its own disk to answer a question about the client's - so the session succeeds with global scope and says so, as a warning field on session_start and as a register-project-remote-root hook error event. Current seam resolves the roots locally and sends them, so this is the signature of a client too old to do that.

How doctor reports it

None of this is silent. On the server:

  • remote sessions - an info line listing which other machines used this daemon in the last 24 hours and how many local captures were skipped for them. With no remote sessions it reads none in 24h, which is also how you notice a client that is not reaching you.
  • repo map - counts local mapped paths and stats only those; rows belonging to other hosts are reported as present but "not verifiable from here" rather than treated as missing.

Every skip is also an event in the log (hook.error, stage remote-host-skip, with the capture name and the host), recorded at INFO - on a shared daemon a skip is the design working, not a fault.

Windows: a second user on one box

The Windows install is per-user in where it writes (%USERPROFILE%) but not isolated in what it registers: the Scheduled Task name (Seamless) is global and the port is shared, so the last installer to run wins and a task belonging to another account cannot be replaced without admin.

The client role is the clean way out of that collision, because a client install registers no Scheduled Task at all. The first user runs the server; every other user on the box pairs as a client against it. They do not even need the LAN setup above - client-config says so in its own loopback refusal - a second user on the same machine can pair directly:

seamlessd install-hooks --server-url http://127.0.0.1:8081 --api-key <key>

seamlessd uninstall on such a client reports the service as not installed and leaves the running daemon alone, and --purge removes only the config directory - never the data dir, which belongs to the server.

  • Install & deploy - the security posture you are accepting, and the installer overrides.
  • Configuration - role, server_url, allowed_hosts, and the tls: block with every default.
  • seamlessd CLI - client-config, install-hooks --server-url, and serve under TLS.
  • Import, back up & restore - seamlessd export and a merge import, which is how you fold each device's existing local instance into the shared one before switching it to a client.
  • Hooks - the identity the hooks send, and what the daemon does with it.