Run a bot on a headless machine
A bot on an always-on box (a VPS, a Mac mini, a server in a closet) trades its API key’s TEE wallets with a key signer that lives on that box. The vault stays on the laptop you sit at. This page is about the box: where the signer is stored there, how a service reads it under launchd, SSH and systemd, and what must never be on that box.
The short version:
- Give the box its API key through
CANDLE_API_KEY. Do not runcandle auth loginthere. - Run
candle tee signer new --key <prefix>on the box, in the same context the bot will run in. - Approve it from your own machine, then promote onto the key from the laptop.
- Run
candle doctorin the bot’s context. NoSigner slotrow may fail, and on a box that was never logged in there is noDevice token beside signerwarning.
Do not log the bot box in
Section titled “Do not log the bot box in”The API key can only request a signer. Approving one needs the account owner: a signed-in browser session or the CLI’s device token. A box that holds both the API key and a device token can approve its own request, and then a stolen box can register a signer without you. Keep the device token off the box.
-
Give the box an API key, not a login. Mint the key on your own machine (
candle keys create --label <name>or the agent console), which shows the plaintext once, and hand it to the bot throughCANDLE_API_KEYfrom a file only the service can read (below).CANDLE_API_KEYbeats any stored key, and the box never stores a device token. -
tee signer newandcandle doctorwarn when a device token sits in the same context as a key signer (Device token beside signer). On a box that was never logged in it does not appear. On a box that was, it stays after the fix below (see there). -
If the box is already logged in, remove the device token from the owner’s side. In the agent console, open Authorized devices, revoke the box’s device, and uncheck “Also revoke these keys” (it is checked by default). The device token stops working on the server and the API key keeps working. Do not reach for
candle auth logouthere: without--keep-keyit revokes the stored API key on the server, which strands every wallet on that key’s signer; with--keep-keyit still deletes the key from this machine; and on a machine with no profiles it also deletesconfig.json, which holds the key-signer index.The warning stays after this. Revoking the device on the server does not remove the token from the box’s secret store, and
candle doctorandtee signer newwarn on the token being present, not on it working. No CLI command yet removes only the device token. Until one ships, the warning is expected on a box you fixed this way: confirm the device shows as revoked under Authorized devices, and treat the warning as a fault only if it does not. -
A device token belongs on the box once: to move wallets after their key was revoked (
candle keys signer move <old> --to-key <live>, see Custody tiers).candle auth loginstores a device token and mints a new API key on the box. After the move, revoke that device under Authorized devices with “Also revoke these keys” left checked, so the key the login minted goes too. The bot keeps running on its own key throughCANDLE_API_KEY, which beats the stored one and which that login did not mint; check it is set before you revoke. The same stale-token warning as above stays afterwards.
Where the signer lives
Section titled “Where the signer lives”tee signer new stores the signer’s private half in the CLI’s secret store, under key_signer_<prefix>_<sha256>, and records a non-secret index of it (prefix, fingerprint, quorum) in config.json. Both live under CANDLE_CONFIG_DIR, which defaults to ~/.config/candle for the user running the command.
The CLI picks the secret store the same way every time:
| Machine | Store |
|---|---|
| macOS | The user’s login Keychain, through security. Always, when security is on PATH; there is no fallback to a file |
| Linux with a working Secret Service | libsecret, through secret-tool. The CLI stores and reads back a test value first; a secret-tool binary with no Secret Service answering does not count |
| Anything else, including Linux over SSH or under systemd | credentials.enc under CANDLE_CONFIG_DIR: AES-256-GCM, opened with CANDLE_KEYRING_PASSPHRASE |
The signer is only where it was written. A signer made in a desktop session on Linux goes into the Secret Service, and a systemd unit on the same box, which has no Secret Service, reads credentials.enc and finds nothing. The same applies to a different user, a different CANDLE_CONFIG_DIR, or a different passphrase. Run tee signer new as the service’s user, with the service’s CANDLE_CONFIG_DIR and CANDLE_KEYRING_PASSPHRASE, and check it with candle doctor in that same context.
Under systemd (Linux)
Section titled “Under systemd (Linux)”On a server there is no Secret Service, so the signer is in credentials.enc and the process needs CANDLE_KEYRING_PASSPHRASE. Give it the passphrase through LoadCredential= (or a file only the service user can read), not through Environment= in the unit and not through an export in a shell profile: those are readable by anything that can read the unit or the profile, and systemctl show prints Environment=.
The CLI takes the passphrase only from CANDLE_KEYRING_PASSPHRASE, and the bot’s key from CANDLE_API_KEY, so a small wrapper moves them from the credential files into the one process it starts, and nowhere else:
#!/bin/sh# /usr/local/bin/candle-env: load the service's credentials, then run the command given.CANDLE_KEYRING_PASSPHRASE="$(cat "$CREDENTIALS_DIRECTORY/candle-keyring")" || exit 1CANDLE_API_KEY="$(cat "$CREDENTIALS_DIRECTORY/candle-api-key")" || exit 1export CANDLE_KEYRING_PASSPHRASE CANDLE_API_KEYexec "$@"[Service]User=candle-botEnvironment=CANDLE_CONFIG_DIR=/var/lib/candle-bot/.config/candleLoadCredential=candle-keyring:/etc/candle-bot/keyring-passphraseLoadCredential=candle-api-key:/etc/candle-bot/api-keyExecStart=/usr/local/bin/candle-env /usr/local/bin/candle-botKeep /etc/candle-bot/* mode 0600, owned by root. LoadCredential= copies each file into a private directory only this service can read, for the life of the service. Do not pass either value on a command line: arguments show up in ps.
Make the signer in exactly that context. systemd-run starts a one-off service with the same user, directory and credentials, attached to your terminal so you can read the fingerprint and wait for the approval:
sudo systemd-run --pty --wait --collect \ -p User=candle-bot \ -p Environment=CANDLE_CONFIG_DIR=/var/lib/candle-bot/.config/candle \ -p LoadCredential=candle-keyring:/etc/candle-bot/keyring-passphrase \ -p LoadCredential=candle-api-key:/etc/candle-bot/api-key \ /usr/local/bin/candle-env candle tee signer new --key tr-2Run candle doctor the same way. With no passphrase and no terminal, the CLI refuses rather than falling back to anything in plaintext.
Under launchd (macOS)
Section titled “Under launchd (macOS)”On a Mac the signer is in the login Keychain of the user who ran tee signer new. Run the bot as a LaunchAgent for that user (~/Library/LaunchAgents), which runs inside the user’s session and reads their login Keychain. A LaunchDaemon runs outside that session and cannot rely on the login Keychain being open to it.
- The login Keychain must be unlocked, which it is while that user is logged in. A Mac that restarts into the login window has it locked until someone logs in; turn on automatic login for the bot’s user, or accept that the bot waits.
- Put
CANDLE_API_KEYin the agent’s environment from a file only that user can read, as on Linux, not in the plist itself.
Over SSH
Section titled “Over SSH”An SSH session is its own context, and it is where a signer most often ends up in the wrong store.
-
Linux: an SSH session usually has no Secret Service, so the CLI uses
credentials.enc. That is the right store for a systemd bot, providedCANDLE_CONFIG_DIRandCANDLE_KEYRING_PASSPHRASEmatch the service’s (above). -
macOS: SSH uses the same login Keychain as the GUI session, and it can read it only while that Keychain is unlocked. A Mac that restarted into the login window has it locked for every SSH session.
A locked Keychain looks empty to CLI 0.11.10 and earlier. Those releases cannot tell a locked Keychain from a missing item, so over SSH
candle doctorreportsSigner slotFAIL withmissing from the secret store(and, on a machine that holds a device token,Credentials presentFAIL withNo device token found), although nothing was deleted. Do not re-create the signer or log the box in because of it. Unlock the Keychain in that session and run doctor again:Terminal window security unlock-keychain ~/Library/Keychains/login.keychain-db # prompts for the login passwordcandle doctorLet it prompt. Never pass the password with
-p: a command-line argument lands in shell history and is visible to other users inps. If the rows pass after the unlock, the lock was the cause. An exportedCANDLE_API_KEYbeats the stored key, so the API key still works while the Keychain is locked; a signer does not. A bot that must survive a restart runs as a LaunchAgent with automatic login (above), not from an SSH session.
The --out PEM
Section titled “The --out PEM”tee signer new --out <path> also writes the signer’s private key as a PEM file, mode 0600, for a process that cannot read the CLI’s secret store (for example an SDK bot). It is opt-in, and it is weaker than the secret store:
- The PEM is plaintext. Anyone who can read that file, or a backup or snapshot of the disk, holds the key’s signer. With the key’s API key as well, they can trade every wallet on the key within its scopes and caps.
- The secret store copy is still written.
--outadds a second copy; it does not move the signer out of the store. - Nothing tracks the file.
candle doctorchecks the store, not the PEM.keys signer movedeletes an old signer’s store slot once its wallets have moved, and never touches a PEM you wrote. Delete it yourself when you rotate.
Use it only when the bot cannot use the CLI’s store, keep it on the box, and never commit it or bake it into an image.
Check it
Section titled “Check it”candle doctor, run in the bot’s context, reports on every signer this machine holds:
- FAIL
Signer slot: akey_signer_*orwallet_signer_*the CLI cannot open, that is missing from the store, or whose public half does not match. The wallets it owns cannot trade from here. - WARN
Key signer: the key’s active signer is not on this machine, so its wallets trade from wherever it is. - WARN
Device token beside signer: see Do not log the bot box in. - PASS
Key signers: every signer here opened, listed with its fingerprint, and(pending)for one that is not approved yet.
A machine with no key signer shows none of these rows.
Next steps
Section titled “Next steps”- Custody tiers: key signers: requesting, approving, promoting onto a key, and moving wallets between signers.
- SDK: trade with a key signer PEM: the
--outPEM in an SDK bot. - Candle CLI: every environment variable the CLI reads.