Headless & Remote Hosts
Lattis is normally a desktop app, but the parts that do the work — the lattisd
daemon and the sawyer coding agent — are plain binaries with no GUI
dependency. You can run them on a remote host and reach them over SSH.
The obstacle is never the daemon; it is authentication. Every interactive sign-in wants a browser, and a headless box has none. This page covers the ways around that, and is explicit about the one case that has no answer yet.
The shape
Section titled “The shape”The arrangement that works with the fewest moving parts:
your laptop ──ssh──▶ remote host ├── lattisd (127.0.0.1:5288) └── sawyer (talks to the daemon over loopback)Both processes live on the remote host and talk to each other over loopback.
Nothing is exposed to the network, so nothing needs a token. You SSH in and run
sawyer as you would locally.
Running the daemon on one machine and sawyer on another is possible but
awkward — see Exposing the daemon for why.
Install
Section titled “Install”Put lattisd and sawyer on the remote host. They ship as siblings inside the
Lattis bundle; copy the whole bundle directory rather than individual files, so
sawyer can find lattisd next to itself.
Then confirm the environment before going further:
lattisd doctordoctor prints the binary it resolved, the data directory, whether that
directory is writable, the configured port and whether anything already holds
it, and the telemetry store’s health. Fix anything it flags before continuing —
almost every “it doesn’t work headless” report is one of these.
Start the daemon in the foreground once to watch it come up:
lattisdIn another shell:
curl -s http://127.0.0.1:5288/healthProvider auth without a browser
Section titled “Provider auth without a browser”Anthropic subscription — claude setup-token
Section titled “Anthropic subscription — claude setup-token”A Claude Pro or Max subscription can be used headlessly, because Anthropic will mint a long-lived token from a browser flow you run somewhere else.
On any machine that has a browser (your laptop), with Claude Code installed:
claude setup-tokenComplete the sign-in in the browser. The command prints a token beginning
sk-ant-oat…. It is valid for about a year.
Copy it to the remote host and store it on an account:
curl -X POST http://127.0.0.1:5288/control/remote/anthropic/account/default/key \ -H 'content-type: application/json' \ -d '{"key":"sk-ant-oat01-…"}'default here is an account id you choose — any stable string. Use a
recognisable one (work, personal) if you plan to connect more than one
account.
Despite the endpoint being called /key, Lattis recognises the sk-ant-oat
prefix and stores the value as a subscription credential, not an API key.
That distinction matters: subscription tokens have to be sent as a bearer with
Anthropic’s OAuth headers, and would be rejected if sent the way a Console API
key is. You do not have to do anything to opt into this — the prefix is
unambiguous, since Console keys begin sk-ant-api.
The token is verified against Anthropic before it is stored, so a bad paste fails immediately with a readable error instead of silently producing a broken account.
API keys — every provider
Section titled “API keys — every provider”An API key needs no browser anywhere. The same endpoint takes one:
curl -X POST http://127.0.0.1:5288/control/remote/anthropic/account/default/key \ -H 'content-type: application/json' \ -d '{"key":"sk-ant-api03-…"}'Swap anthropic for openai, openrouter, bedrock, or pump. Bedrock also
takes a region; a local server takes a base_url. See
Cloud Providers for what each provider expects.
Verify the account is live:
curl -s http://127.0.0.1:5288/control/statuscurl -s http://127.0.0.1:5288/v1/modelsChatGPT / Codex subscription — not supported headless
Section titled “ChatGPT / Codex subscription — not supported headless”There is no equivalent of setup-token for a ChatGPT Plus or Pro subscription.
The only sign-in OpenAI offers requires a browser redirect to land on a loopback
port on the machine running the daemon, which a headless host cannot
receive.
Your options, in order of preference:
-
Use an OpenAI API key instead. Different billing, but it works headlessly today and needs nothing special.
-
Forward the callback port over SSH. The daemon binds a listener for the redirect; an SSH tunnel can carry your laptop’s browser to it:
Terminal window ssh -L 1455:127.0.0.1:1455 -L 1457:127.0.0.1:1457 you@remote-hostThen, on the remote host, begin the flow and open the returned URL in your laptop’s browser:
Terminal window curl -X POST http://127.0.0.1:5288/control/remote/openai/account/default/connectThe listener times out after five minutes, so have the tunnel up first.
This follows from how the flow is built, but it is not a path we exercise in testing — treat it as a workaround rather than a supported route, and fall back to an API key if it gives you trouble.
Reporting usage to Lattis
Section titled “Reporting usage to Lattis”Connecting the daemon to your Lattis organization — so usage and spend appear on the dashboard — normally happens through the desktop app’s browser sign-in. A headless host uses an API key instead.
Create a key
Section titled “Create a key”In the dashboard, go to Settings → API keys, give the key a name, and choose a scope:
| Scope | What it can do |
|---|---|
| Telemetry only | Ship usage batches and read which organizations you belong to. Nothing else. |
| Full access | Acts with your rights across the API. |
Choose telemetry-only unless you have a specific reason not to. It is all a daemon needs, and a leaked telemetry key cannot read your spend, your reports, or your organization’s settings.
Neither scope can create or revoke API keys — that always requires an interactive sign-in, so a leaked key can never mint itself a replacement.
The key is shown once, on creation. Only a hash is stored, so it cannot be recovered afterwards; if you lose it, revoke it and make another.
Use it
Section titled “Use it”Set it in the daemon’s environment:
export LATTIS_API_KEY='lak_….…'That is usually all you need — the daemon asks the backend which organizations the key’s owner belongs to and attributes usage the same way the desktop app does, including any per-project overrides.
To pin a specific organization:
export LATTIS_ORG_ID='<organization uuid>'The pin is honored only while you are an active member of that organization; a stale value falls back rather than failing.
A key set this way takes precedence over any desktop session left on the machine, so an operator’s explicit configuration always wins.
Revoking a key in the dashboard takes effect on the daemon’s very next batch — there is no cache to wait out.
Running sawyer
Section titled “Running sawyer”Once the daemon has a provider account, sawyer works normally:
sawyer --list-modelssawyer "explain what this service does"sawyer holds no credentials of its own — every model call goes through the
daemon — so there is nothing extra to configure. If the daemon is on a
non-default port or data directory, point at it:
sawyer --url http://127.0.0.1:2234 "…"--url also reads $LATTIS_DAEMON_URL, which is usually easier in a service
environment.
For scripting, --mode json emits structured output and --mode rpc speaks a
persistent stdio protocol. Both are useful when nothing is attached to a
terminal. See the full option list with sawyer --help.
Keeping the daemon running
Section titled “Keeping the daemon running”Over SSH, a plain lattisd & dies when your session ends. On Linux, run it as a
user service:
[Unit]Description=Lattis daemonAfter=network-online.target
[Service]Type=simpleExecStart=%h/lattis/lattisdRestart=on-failureRestartSec=5# Keep the secret out of the unit file itself.EnvironmentFile=%h/.config/lattis/env
[Install]WantedBy=default.targetWith ~/.config/lattis/env holding, mode 0600:
LATTIS_API_KEY=lak_….…Enable it:
systemctl --user daemon-reloadsystemctl --user enable --now lattisdThen allow it to keep running after you log out — without this, systemd stops your user services when your last SSH session closes:
loginctl enable-linger "$USER"Check on it with systemctl --user status lattisd and
journalctl --user -u lattisd -f.
Exposing the daemon on a network
Section titled “Exposing the daemon on a network”By default the daemon binds 127.0.0.1 and requires no authentication, on the
assumption that only local processes can reach it.
If you change host to a non-loopback address, the daemon refuses to start
unless an auth token is set. This is deliberate: the public and control APIs
would otherwise let anyone who can reach the port spend your cloud credentials.
export LATTIS_AUTH_TOKEN='<a long random string>'Requests must then carry Authorization: Bearer <token>.
Prefer the SSH tunnel. It needs no token, exposes nothing, and keeps sawyer
working.
Multiple instances on one host
Section titled “Multiple instances on one host”Give each its own data directory and port:
LATTIS_DATA_DIR=~/lattis-a LATTIS_PORT=5288 lattisdLATTIS_DATA_DIR=~/lattis-b LATTIS_PORT=2234 lattisdCredentials, telemetry, and configuration are per-directory, so the instances
stay fully independent. lattisd doctor reports which of these the current
environment resolves to.
Troubleshooting
Section titled “Troubleshooting”| Symptom | Likely cause |
|---|---|
refusing to bind a non-loopback address without an auth token | Working as intended — set LATTIS_AUTH_TOKEN or bind 127.0.0.1. |
| Anthropic requests fail after months of working | A setup-token expired. Run claude setup-token again on a browser machine and store the new value. |
That subscription token was rejected when storing a token | The token is expired or mistyped. It is checked before storage, so nothing was saved. |
| No usage on the dashboard | Check LATTIS_API_KEY is set in the daemon’s environment (not just your shell), and that the key has not been revoked. |
| Usage lands in the wrong organization | Set LATTIS_ORG_ID, or check the per-project override in the app. |
sawyer offers to start a daemon on a service-managed host | It cannot reach the service. Decline, then check systemctl --user status lattisd. |
| Daemon dies when SSH disconnects | loginctl enable-linger "$USER". |
More general issues are covered in Troubleshooting.