Skip to content

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 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.

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:

Terminal window
lattisd doctor

doctor 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:

Terminal window
lattisd

In another shell:

Terminal window
curl -s http://127.0.0.1:5288/health

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:

Terminal window
claude setup-token

Complete 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:

Terminal window
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.

An API key needs no browser anywhere. The same endpoint takes one:

Terminal window
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:

Terminal window
curl -s http://127.0.0.1:5288/control/status
curl -s http://127.0.0.1:5288/v1/models

ChatGPT / 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:

  1. Use an OpenAI API key instead. Different billing, but it works headlessly today and needs nothing special.

  2. 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-host

    Then, 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/connect

    The 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.

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.

In the dashboard, go to Settings → API keys, give the key a name, and choose a scope:

ScopeWhat it can do
Telemetry onlyShip usage batches and read which organizations you belong to. Nothing else.
Full accessActs 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.

Set it in the daemon’s environment:

Terminal window
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:

Terminal window
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.

Once the daemon has a provider account, sawyer works normally:

Terminal window
sawyer --list-models
sawyer "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:

Terminal window
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.

Over SSH, a plain lattisd & dies when your session ends. On Linux, run it as a user service:

~/.config/systemd/user/lattisd.service
[Unit]
Description=Lattis daemon
After=network-online.target
[Service]
Type=simple
ExecStart=%h/lattis/lattisd
Restart=on-failure
RestartSec=5
# Keep the secret out of the unit file itself.
EnvironmentFile=%h/.config/lattis/env
[Install]
WantedBy=default.target

With ~/.config/lattis/env holding, mode 0600:

Terminal window
LATTIS_API_KEY=lak_….…

Enable it:

Terminal window
systemctl --user daemon-reload
systemctl --user enable --now lattisd

Then allow it to keep running after you log out — without this, systemd stops your user services when your last SSH session closes:

Terminal window
loginctl enable-linger "$USER"

Check on it with systemctl --user status lattisd and journalctl --user -u lattisd -f.

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.

Terminal window
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.

Give each its own data directory and port:

Terminal window
LATTIS_DATA_DIR=~/lattis-a LATTIS_PORT=5288 lattisd
LATTIS_DATA_DIR=~/lattis-b LATTIS_PORT=2234 lattisd

Credentials, telemetry, and configuration are per-directory, so the instances stay fully independent. lattisd doctor reports which of these the current environment resolves to.

SymptomLikely cause
refusing to bind a non-loopback address without an auth tokenWorking as intended — set LATTIS_AUTH_TOKEN or bind 127.0.0.1.
Anthropic requests fail after months of workingA 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 tokenThe token is expired or mistyped. It is checked before storage, so nothing was saved.
No usage on the dashboardCheck 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 organizationSet LATTIS_ORG_ID, or check the per-project override in the app.
sawyer offers to start a daemon on a service-managed hostIt cannot reach the service. Decline, then check systemctl --user status lattisd.
Daemon dies when SSH disconnectsloginctl enable-linger "$USER".

More general issues are covered in Troubleshooting.