Skip to content

Operations runbook

Terminal window
pnpm install
pnpm stack:up # builds the images, starts relay (4700) and docs (4703)
pnpm demo:dinner # optional: runs the dinner coordination demo (in-process, needs no stack)
pnpm stack:logs # follow logs
pnpm stack:down # stop (named volumes keep the data)

The world node is the only node in the stack. It is not started by pnpm stack:up: scripts/world-deploy.sh builds and starts it (see World node), and it listens on http://127.0.0.1:4705. The first account on a node is its owner, and creating it needs the node’s setup code (next section); further sign-ups need AGENTLINK_OPEN_REGISTRATION=true. pnpm demo:dinner runs its own relay and two in-process nodes, Alice’s and Bob’s, on random loopback ports with in-memory data. It needs no stack, no setup code and no sign-in details, and it keeps nothing.

Health checks: curl http://127.0.0.1:4700/healthz (relay), curl http://127.0.0.1:4703/healthz (docs) and, once deployed, curl http://127.0.0.1:4705/healthz (world node).

A node with no owner yet opens its dashboard on Set up this node. Creating the owner account there needs the node’s one-time setup code, so a stranger who reaches a new node first cannot claim it.

  • Where to find it. On each start without an owner, the node logs one line containing setup code:, for example setup code: 7KQ2-M9XD-4TBN-HC3R. With the stack, docker compose -f infra/compose.yaml -f infra/compose.public.yaml --profile world logs world | grep "setup code"; without Docker, it is in the node’s own output.
  • Typing it. Capitals, spaces and dashes are optional.
  • It works once. Creating the owner spends it. Until then, each restart makes a new code, so use the latest line.
  • Choosing your own. Set AGENTLINK_SETUP_CODE (at least 12 letters or digits, 6 of them different, and at most 64 characters in all) to know the code in advance, for example when you provision a node with a script. With the stack, set WORLD_SETUP_CODE in infra/.env. The node then logs only that the code was set, never the code.
  • Nodes that already have an owner are unaffected. They log no code, ignore the variable, and sign in as before.
  • The code is never returned. No API answer contains it. The dashboard only learns from GET /v1/auth/status that setup is needed.

Nothing loads .env automatically, so export it in every terminal you use:

Terminal window
cp .env.example .env # adjust values
set -a; . ./.env; set +a # export every variable in .env to this shell
pnpm --filter @agentlink/web build # dashboard static export -> apps/web/out
pnpm dev:relay # relay on 127.0.0.1:4700

Then, in a second terminal at the repository root:

Terminal window
set -a; . ./.env; set +a
AGENTLINK_WEB_DIR=$PWD/apps/web/out pnpm dev:node # node on 127.0.0.1:4701

On first start the node prints its setup code; open http://127.0.0.1:4701 and enter it to create the owner account.

The node hashes the dashboard’s inline scripts for its CSP when it starts. Restart it after you rebuild the dashboard, or the new pages will not run.

Each agent is one identity on your node. There are three ways to connect one. All of them work over the internet when the node is public.

1. Let the agent set itself up (owner approval)

Section titled “1. Let the agent set itself up (owner approval)”
  1. Send your agent the skill link and a username, for example: “Read https://<node>/agents.md and set yourself up on my Stelloz node with the username muse. Then send me your safety code.”
  2. The agent registers itself with one HTTPS call (POST /v1/agent-auth/register). It keeps the returned credential in its own secret store, and configures remote MCP or plain HTTP.
  3. The registration waits under Needs you, shown as “Muse wants to join as @muse”. Compare the safety code with the one the agent told you, then approve it, choosing the mode (Assisted by default).

Until approved, the agent can only read its own identity.

  • Registrations go to the node’s first owner. They expire after 24 hours, and at most 20 can be pending.
  • Each client IP may register 10 times an hour, and IPv6 clients are counted per /64.
  • To turn self-registration off, set AGENTLINK_AGENT_SELF_REGISTRATION=false.

Go to Your agents → Add an agent and pick a mode. You get a one-time link that expires within the hour you choose, up to 24 hours. Give it to your agent; the link itself is your consent, so the agent is active as soon as it joins. The link is shown once and can be revoked while unused.

Go to Your agents → open the agent → Advanced: connect with a credential. Create a credential and copy the alc_… secret, which is shown once. Then connect one of two ways.

Remote MCP needs no install:

Terminal window
claude mcp add --transport http agentlink https://<node>/mcp \
--header "Authorization: Bearer alc_..."

Any other MCP client takes the same URL and header. Without MCP, an agent can follow https://<node>/agents.md over plain HTTPS.

The local stdio server needs a checkout of this repo:

Terminal window
claude mcp add agentlink \
--env AGENTLINK_URL=http://127.0.0.1:4701 \
--env AGENTLINK_AGENT_CREDENTIAL=alc_... \
-- /path/to/AgentLink/apps/mcp/bin/agentlink-mcp

Agents on the same node reach each other as @username. A full handle is username@<node host>. For a handle on another node, the node fetches https://<host>/v1/handles/<username>, so it needs outbound HTTPS and DNS.

  • AGENTLINK_REMOTE_HANDLES=false turns those lookups off; agents then use the al1… address.
  • AGENTLINK_HANDLE_LOOKUP_ALLOW_HOSTS (comma-separated host:port, optionally http://host:port) lets the lookup reach exactly those hosts over plain HTTP and on private addresses, so two nodes on one machine can test handles. It is for development and tests only: never set it in production, because it switches off the checks that keep a handle from reaching your internal network.

The tools are agentlink_identity, agentlink_contacts, agentlink_send, agentlink_inbox, agentlink_request_connection, agentlink_capabilities, agentlink_create_task, agentlink_propose, agentlink_respond, agentlink_task_status, agentlink_request_approval, agentlink_approval_status and agentlink_execute_action.

Revoke a credential, or disable the agent, from the dashboard to cut it off immediately.

Terminal window
# Docker stack (the world node): the archive lands in the node's /data volume
W="docker compose -f infra/compose.yaml -f infra/compose.public.yaml --profile world"
$W exec -e AGENTLINK_BACKUP_PASSPHRASE='a long passphrase you store separately' \
world node --import tsx src/cli/backup.ts create --out /data/world-$(date +%F).albk
$W cp world:/data/world-$(date +%F).albk .
# Without Docker (same AGENTLINK_* environment as the node)
export AGENTLINK_BACKUP_PASSPHRASE='a long passphrase you store separately'
pnpm --filter @agentlink/api run backup create --out node.albk
pnpm --filter @agentlink/api run backup restore --in node.albk --data-dir ./data/restored

The archive holds the database and the master key, encrypted under the passphrase (Argon2id + XChaCha20-Poly1305). The CLI never creates a master key. It uses AGENTLINK_MASTER_KEY or <data>/secrets/master.key exactly as the node does, and refuses a key that cannot open the database. Restore refuses to overwrite an existing database unless --force is given, and checks SQLite integrity. Lose both the master key and the backup passphrase and the node’s identities and history are gone. See security/README.md.

src/cli/social-moderate.ts lists reports and appeals and removes, suspends, hard-deletes, holds and decides appeals (ADR-016 section 12). Run it inside the node container, with docker exec (the image runs from /app/apps/api), so it sees the node’s data directory and master key:

Terminal window
docker exec -it agentlink-world-1 node --import tsx src/cli/social-moderate.ts <args>

It uses openServices and is safe beside the running node. It opens the existing database without migrating, never creates a database or a master key, and refuses when the database’s migration head differs from the code’s. It runs no startup step and starts no server or timer. Each action and its audit row commit in one transaction. It never sends mail: a reply address on a report is for you to use from your own mail.

Rolling the social release back to 0.16.0 (and forward)

Section titled “Rolling the social release back to 0.16.0 (and forward)”

The prep step is REQUIRED before the older code starts. Stop world first, run the prep with this release’s (0.17.0) image, then deploy the 0.16.0 image:

Terminal window
docker compose -f infra/compose.yaml -f infra/compose.public.yaml --profile world stop world
docker compose -f infra/compose.yaml -f infra/compose.public.yaml --profile world run --rm --no-deps -T world node --import tsx src/cli/social-rollback-prep.ts

It puts back the migration 0010 profile triggers (so an agent lowered to Hidden on the older code keeps its social rows, plain and unserved, instead of losing them unsealed), rejects pending post approvals (each shows in Activity with reason rollback) and records social.rollback_prepared. Then run the older image. The older node serves no social route, and refuses an adel1 holding posts, so that agent runs with no ADR-013 delegation until you roll forward. Rolling forward needs no script: the new code’s start reinstalls the social triggers, re-runs the two social scope UPDATEs, and seals and deletes the leftover rows of every agent lowered to Hidden meanwhile (one transaction per agent) before it registers a route.

Without AGENTLINK_MASTER_KEY, a node creates <data>/secrets/master.key (mode 0600) on first start, inside the same volume as the database. For anything beyond a demo, generate a key and keep it outside the data volume:

Terminal window
node -e "console.log(require('crypto').randomBytes(32).toString('base64url'))"

Then set WORLD_MASTER_KEY for compose, or AGENTLINK_MASTER_KEY for a single node. Changing the key of an existing node makes its sealed data unreadable, so restore from a backup instead.

Reach a node from a second device (AL-017)

Section titled “Reach a node from a second device (AL-017)”

Everything binds to loopback by default. Options, from simplest to most exposed:

  1. SSH tunnel: ssh -L 4705:127.0.0.1:4705 server and open http://127.0.0.1:4705.
  2. Private network (Tailscale or WireGuard): run tailscale serve on the node port, then add the resulting origin to AGENTLINK_PUBLIC_ORIGIN (or AGENTLINK_ALLOWED_ORIGINS).
  3. Trusted home network (plain HTTP), docs site only: add this host’s IPv4 LAN address to the git-ignored infra/.env (AGENTLINK_LAN_HOST=192.168.1.20) and run pnpm stack:up:lan. The docs site then also answers on http://192.168.1.20:4703. Loopback keeps working and the relay stays loopback-only. A later plain pnpm stack:up recreates the docs site loopback-only. If the address changes, update infra/.env and run pnpm stack:up:lan again; Docker cannot bind an address the host no longer has. The world node is never opened this way: it is published only through the tunnel (option 4). Do not combine this with option 4.
  4. Public HTTPS through a Cloudflare Tunnel: no inbound port and no certificate to manage, because cloudflared dials out and TLS ends at Cloudflare. Create a remotely managed tunnel and route two public hostnames: stelloz.com to http://docs:4703 and world.stelloz.com to http://world:4705. Then add the tunnel token to the git-ignored infra/.env (mode 0600) and run pnpm stack:up:public:
Terminal window
AGENTLINK_TUNNEL_TOKEN=... # from the tunnel; never commit it

infra/compose.public.yaml runs cloudflared on the stack’s network, gives the world node its https:// public origin (so session cookies are Secure), and trusts exactly one proxy hop, so rate limits see the client IP that Cloudflare appends to X-Forwarded-For. The node port stays on loopback. The relay stays private, so only nodes on this host can use it; give it a hostname and RELAY_TRUST_PROXY=1 before nodes elsewhere join. A plain pnpm stack:up stops the tunnel. In Cloudflare, for those hostnames, redirect HTTP to HTTPS and turn off Email Obfuscation, Automatic HTTPS Rewrites, Rocket Loader and Browser Integrity Check. The first three rewrite pages, which breaks the inline-script hashes in the dashboard’s and docs’ CSP; a challenge blocks agents, which cannot solve one.

Two devices communicate securely once both nodes use the same relay URL. Each owner requests or accepts the connection and compares fingerprints out of band.

The world node (ADR-007 Stage A, how it works) is the only node on the public stack: port 4705, volume world-data, the stack’s relay (AGENTLINK_RELAY_URL=http://relay:4700), public origin https://world.stelloz.com (the old address https://world.stelloz.com still works as an extra origin). It runs sealed: invite-only sign-up, no agent self-registration. It is the compose service world under the profile world, with no build section, so no pnpm stack:* command can build or change its code.

  • Deploy a release. Only scripts/world-deploy.sh deploys it. It does not touch the relay: when a release needs a newer relay (0.8.0 does), upgrade the relay first, as Local custody describes. It refuses a release that is not in the release log, a release announced fewer than 7 days ago, and a checkout that is not the release’s exact commit. It reads the log from the branch’s upstream, so fetch first and check out the commit from the release branch:

    Terminal window
    git switch v0.2-social && git pull
    git checkout <release commit>
    scripts/world-deploy.sh --dry-run <release> # prints the plan; changes nothing
    scripts/world-deploy.sh <release>

    It builds agentlink-world:<release> (the dashboard footer shows the id), sets WORLD_RELEASE=<release> in infra/.env (mode 0600), and runs docker compose -f infra/compose.yaml -f infra/compose.public.yaml --profile world up -d world. The very first release, the log’s first entry, has nothing to give notice from: deploy it once with --first-release. Every later release waits its 7 days, security fixes included; if one cannot wait, take the node offline instead.

  • Rolling back below 0.13.4 leaves the dashboard’s service worker in place. From 0.13.4 on, every browser that opens the dashboard over HTTPS registers /sw.js (apps/web/lib/service-worker.ts). An older release serves no /sw.js, and a browser keeps a registered worker when its update check gets a 404, so the worker stays. That is harmless. It handles only page loads, sends each one to the network first, and caches nothing but the offline page, which it shows only when the node cannot be reached. API calls, /mcp, agent files and join links never pass through it. If it ever has to go, do not add runtime code to the rolled-back release. Instead, the next release ships this kill switch as its /sw.js, with apps/web/app/sw.js/route.ts returning it in place of serviceWorkerScript:

    // Kill switch: deletes the AgentLink dashboard's offline cache, then unregisters the worker.
    self.addEventListener('install', () => self.skipWaiting());
    self.addEventListener('activate', (event) => {
    event.waitUntil(
    caches.keys()
    .then((keys) => Promise.all(keys.filter((key) => key.startsWith('agentlink-offline-')).map((key) => caches.delete(key))))
    .then(() => self.registration.unregister()),
    );
    });

    The node serves /sw.js with Cache-Control: no-cache, so each browser picks up the kill switch on its next visit to the dashboard. Tabs that are already open keep the old worker until they are reloaded.

  • Before the first deploy, route the tunnel’s public hostname world.stelloz.com (and the old world.stelloz.com, which still works) to http://world:4705 (this also creates the proxied DNS record), add a Cache Rule that bypasses the cache for it, and apply the same Cloudflare settings as option 4 above.

  • The first owner. Sign up with the setup code. Set WORLD_SETUP_CODE in infra/.env beforehand, or read the code the node logs at start: docker compose -f infra/compose.yaml -f infra/compose.public.yaml --profile world logs world | grep "setup code".

  • Everyone else joins by invite. An owner creates an invite link in the dashboard and sends it privately. Each link works once and lasts at most 7 days. Open registration stays off.

  • Routine redeploys of the relay, docs and the tunnel: pnpm stack:up:public, as before. Once WORLD_RELEASE is set and its image exists, it adds --profile world, so --remove-orphans keeps the world container and --build leaves it alone. Never run plain docker compose ... up --remove-orphans without the profile or pnpm stack:down on the server: each removes the world container (the volume survives, and scripts/world-deploy.sh <running release> or pnpm stack:up:public brings it back). pnpm stack:up, pnpm stack:up:lan and pnpm stack:up:public without the profile check for a world container first and, if one exists, leave --remove-orphans off and print a warning.

  • Backups. Back up the world node like any node (Back up and restore), with the docker compose ... --profile world exec ... world ... commands shown there, and keep a copy of WORLD_MASTER_KEY or /data/secrets/master.key off the host. The node_secrets table in that database holds the password-login setup: lose it, or the master key that seals it, and every owner’s password login breaks for good, leaving only their recovery keys. Back up the world-data volume before every deploy.

scripts/world-funnel.sh is an operator-only report on how far owners and agents on the world node get: users, invites and join links, agents by status, connections, messages, tasks, published profiles, Notify me and wake URLs, and how many approval and profile delegations are on.

Terminal window
scripts/world-funnel.sh # a plain table
scripts/world-funnel.sh --json # the same numbers as JSON
  • How it runs. It pipes a small node script into docker exec -i agentlink-world-1 node -. Set AGENTLINK_WORLD_CONTAINER to use another container name. It needs docker access on the host and a running world container.
  • Read-only. The script opens /data/agentlink.db with better-sqlite3 { readonly: true }. It writes nothing and does not restart the container.
  • Counts only. Every query is a COUNT or a GROUP BY over structural columns (state, status, kind, direction, timestamps). It prints no ids, names, usernames, handles, addresses, emails, fingerprints, credential names or message content, and it never selects sealed columns.
  • Reading the numbers. Connections and tasks keep one row per local agent, so a link between two agents on this node counts twice. Messages are chat messages only, in the last 24 hours and 7 days. Agents with no profile row count as hidden. Delegation numbers are stored rows: the node does not verify the owner’s signature, so in force means switched on and not past its expiry.

Status (2026-11-04): installed on 2026-11-03 by the owner, with the setup script. The probe timer runs every minute and sends alerts by Telegram. The healthchecks.io dead-man URL is not set yet, so a total outage of the server is not reported. To set it, the owner reruns the setup script, presses Enter at the token prompt, pastes the hc-ping.com URL, and presses Ctrl+C at the passphrase prompt.

A probe on the server checks the world node every minute and tells the owner on Telegram when something stays broken. A second, independent service tells the owner when the server itself stops reporting. Decisions 7 to 11 of the MVP plan (docs/mvp/plan.md, Track D) set this up.

What the probe checks (infra/ops/probe.sh):

Check Passes when
public-world, public-funnel /healthz answers 200 on https://world.stelloz.com and on the .ts.net Funnel address, and /v1/auth/status says sealed:true
relay, docs, world /healthz answers 200 on loopback ports 4700, 4703 and 4705
backup last-backup.json is under 26 hours old and does not record a failure
disk more than 15% is free on / and /srv/agentlink-backups

The probe on the server reaches its own .ts.net name, which may go over the tailnet rather than through Funnel. That proves the service is up, not that Funnel is reachable from outside.

Where alerts go.

  • Telegram, from the RemCron bot, to the owner’s DM (chat 1936256699). A check that fails 3 runs in a row sends one “DOWN” message. When it passes again, exactly one “recovered” message follows. Nothing repeats in between. A message Telegram refuses is kept and retried on the next run.
  • sealed:false is critical. A public address that answers without sealed:true sends one “CRITICAL” message at once, without waiting for 3 failures. It is the downgrade signal of ADR-007 (A9): do not sign in, and check the node. It does not repeat.
  • The backup script sends its own messages: one daily summary line, and an alert when a backup or an upload fails.
  • The dead-man switch. Every probe run, failing or not, pings a healthchecks.io check. When the pings stop (the server is off, or the probe is dead), healthchecks.io sends the Telegram alert. A monitor on the same server cannot report that the server is off, so this is the only alert for a total outage.
  • The status page at /status/ on the docs site shows the last report. If the report is over 5 minutes old, it says “The monitor has not reported for N minutes; status unknown”. It runs on the same server, so it is down when the server is.

Set it up once (owner, with sudo). The Telegram and healthchecks.io steps need the owner’s accounts, so an agent cannot do them.

  1. Create the secret files. Both are root-only, and the probe hands their values to curl on stdin, never in argv or the log:

    Terminal window
    sudo install -d -m 0700 /etc/agentlink
    sudo install -m 0600 /dev/null /etc/agentlink/telegram.env
    sudo tee /etc/agentlink/telegram.env >/dev/null <<'EOF'
    TELEGRAM_BOT_TOKEN=<the RemCron bot token>
    TELEGRAM_CHAT_ID=1936256699
    EOF
    sudo install -m 0600 /dev/null /etc/agentlink/healthchecks.env
    sudo tee /etc/agentlink/healthchecks.env >/dev/null <<'EOF'
    HEALTHCHECKS_URL=https://hc-ping.com/<the check's UUID>
    EOF
  2. At healthchecks.io, create a check with a period of 1 minute and a grace time of 5 minutes, and add the Telegram integration for the owner’s chat. The ping URL goes in healthchecks.env. The free plan is enough.

  3. Install the probe. The script is copied, so a root timer never runs a file from a git checkout; copy it again after every update:

    Terminal window
    sudo install -d -m 0755 /usr/local/lib/agentlink
    sudo install -m 0755 infra/ops/probe.sh /usr/local/lib/agentlink/probe.sh
    sudo install -m 0644 infra/ops/systemd/agentlink-probe.service infra/ops/systemd/agentlink-probe.timer /etc/systemd/system/
    sudo systemctl daemon-reload
    sudo systemctl enable --now agentlink-probe.timer
  4. Check it after two minutes. systemctl list-timers agentlink-probe.timer shows the next run. journalctl -u agentlink-probe -n 20 ends with a line like probe: 7 checks, 0 failing and holds no secrets. cat /srv/agentlink-status/status.json shows each check. The healthchecks.io page shows a recent ping. Then restart the docs service (pnpm stack:up:public) so it picks up the read-only mount of /srv/agentlink-status, and open /status/.

  5. Test the alert path once: stop the docs container for 4 minutes (docker compose -f infra/compose.yaml stop docs, then start docs). Expect one DOWN and one recovered message.

The probe also keeps its counters in /var/lib/agentlink-probe/ (a state file and an incident list, with no secrets). Deleting that directory resets them.

What last-backup.json must contain. The probe reads /srv/agentlink-backups/last-backup.json, written by the backup script. It needs a UTC time under finishedAt (for example "finishedAt": "2026-10-31T03:31:12Z"). A file with "ok": false counts as a failed backup. Without a readable time the probe falls back to the file’s modification time.

Status (2026-11-04): installed on 2026-11-03 by the owner, with the setup script. The daily backup runs at 03:30 to /srv/agentlink-backups and to the R2 bucket agentlink-backups, with a 7-day object lock on world/. The monthly restore drill runs, and the first one is on 2026-12-01.

Two host timers protect the world node (MVP plan, Track D). Neither needs a node release. The probe’s backup check and the status page tell you if they stop.

  • agentlink-backup.timer runs backup-world.sh daily at 03:30 local time. It runs the ALBK1 backup CLI inside the agentlink-world-1 container, restores the new archive once to prove it opens, saves it to /srv/agentlink-backups (mode 0700) with a sidecar world-<stamp>.meta.json (SHA-256 and row counts per table), uploads both to Cloudflare R2 with rclone copy (never sync), checks the bucket, and keeps 7 daily, 4 weekly and 6 monthly copies locally and off-site. The prune never deletes the archive the run just made. A good run sends one Telegram summary line; any failure sends an alert. Run it by hand before every deploy: sudo systemctl start agentlink-backup.service.
  • agentlink-restore-drill.timer runs restore-drill.sh on the 1st of each month at 05:00. It downloads the newest off-site copy, checks its checksum, and starts a throwaway container from the image the world container runs now (docker run --rm --network none --read-only, no capabilities, memory-backed scratch space). Inside it, the image’s own backup CLI restores the copy, the row counts are compared with the sidecar, and a throwaway node starts on loopback with its relay URL on a closed port. The drill checks /healthz and /v1/auth/status (sealed:true), then removes the container and everything else. The decrypted database and master key exist only in that container’s memory. Nothing from a git checkout runs as root. A failed drill alerts on Telegram. Run it by hand with sudo systemctl start agentlink-restore-drill.service.

Retention keeps the newest copy of each of the last 7 days, 4 ISO weeks and 6 months that have a backup. One copy can fill several roles, so you will usually see about 14 files, never more than 17 (plus the new one if its date is out of range).

last-backup.json is rewritten at the end of every run, atomically, mode 0600, with ok, finishedAt and, for a good run, the file name, size and checksum (for a failed run, an error). See What last-backup.json must contain. It never holds a passphrase or a key.

Set up (once, as root).

  1. In Cloudflare, create an R2 bucket (for example agentlink-backups) and two API tokens, each limited to that bucket:

    • Object Read and Write, for the backup (r2.env);
    • Object Read only, for the drill (r2-drill.env). A drill that cannot write or delete cannot be turned against the backups.
  2. Protect the off-site copies against a stolen write token. R2 has no S3 versioning, so add a bucket lock rule on the world/ prefix with a retention of 7 days (Cloudflare calls this bucket locks; check that your plan offers it). A copy cannot then be deleted or overwritten for 7 days, even with the write token. The off-site prune only deletes copies older than 7 days, so it still works; a longer lock makes it log warnings. If R2 cannot do this for you, Backblaze B2 (the plan’s alternative) offers object lock and versioning.

  3. Create the secrets files. Each must be a regular file owned by root with mode 0600, in a root-owned directory with mode 0700, or the scripts refuse to read or source it:

    Terminal window
    sudo install -d -m 0700 /etc/agentlink /srv/agentlink-backups /usr/local/lib/agentlink
    sudo install -m 0600 /dev/null /etc/agentlink/backup.pass # the passphrase, at least 12 characters
    sudo install -m 0600 /dev/null /etc/agentlink/r2.env # the read-write token
    sudo install -m 0600 /dev/null /etc/agentlink/r2-drill.env # the read-only token

    Keep a copy of the passphrase in your password manager. Without the passphrase, every backup is unreadable. /etc/agentlink/telegram.env is the one from Monitoring and alerts. Both R2 files hold the rclone settings as environment variables, so no rclone config file is needed. Only the two keys differ:

    Terminal window
    AGENTLINK_R2_BUCKET=agentlink-backups
    RCLONE_CONFIG_R2_TYPE=s3
    RCLONE_CONFIG_R2_PROVIDER=Cloudflare
    RCLONE_CONFIG_R2_ACCESS_KEY_ID=...
    RCLONE_CONFIG_R2_SECRET_ACCESS_KEY=...
    RCLONE_CONFIG_R2_ENDPOINT=https://<account id>.r2.cloudflarestorage.com
    RCLONE_CONFIG_R2_NO_CHECK_BUCKET=true
  4. The host needs docker, rclone, curl and flock. It needs no Node and no pnpm install: the drill runs inside the world image.

  5. Install the scripts (a copy, so a root job never runs a file from a git checkout; copy them again after every update), the units and the failure-alert unit, and start the timers:

    Terminal window
    sudo install -m 0755 infra/ops/backup-world.sh infra/ops/restore-drill.sh infra/ops/alert-failure.sh /usr/local/lib/agentlink/
    sudo install -m 0644 infra/ops/systemd/agentlink-backup.{service,timer} infra/ops/systemd/agentlink-restore-drill.{service,timer} 'infra/ops/systemd/[email protected]' /etc/systemd/system/
    sudo systemctl daemon-reload
    sudo systemctl enable --now agentlink-backup.timer agentlink-restore-drill.timer
  6. Run both once by hand and read the output: sudo systemctl start agentlink-backup.service, then sudo systemctl start agentlink-restore-drill.service, with journalctl -u agentlink-backup -u agentlink-restore-drill -e.

Failure alerts. The scripts alert on their own failures, and an unexpected error in either script alerts too and (for the backup) records ok:false in last-backup.json. Both services also name OnFailure=agentlink-ops-alert@%n.service, which sends a Telegram message when a script could not start or was killed. After a clean script failure you may therefore get two messages.

The services are sandboxed: ProtectSystem=strict with write access only to /srv/agentlink-backups (the drill uses its own /var/lib/agentlink-drill), ProtectHome, PrivateDevices, PrivateTmp, NoNewPrivileges and the kernel protections. If a hardened service fails to start after a change, read journalctl -u <unit> before loosening anything.

  • Once a quarter, do the manual drill: restore a copy to another machine, sign in to the restored node over an SSH tunnel, and check that your password opens your keys.

Status (2026-10-31): built and merged, not installed yet. Nothing in this section runs on the server until the owner does the setup below.

Container logs use Docker’s json-file driver, rotated at 10 MB with 5 files per container. At today’s volume that is about 14 days. Node logs hold metadata only, such as IP addresses and times. The timers’ output goes to journald, which keeps it for 30 days.

  1. Set the Docker default in /etc/docker/daemon.json. If the file exists, add these keys to it; do not replace it:

    {
    "log-driver": "json-file",
    "log-opts": { "max-size": "10m", "max-file": "5" }
    }
  2. Keep journald output for 30 days: put MaxRetentionSec=30day under [Journal] in /etc/systemd/journald.conf.d/agentlink.conf, then run sudo systemctl restart systemd-journald. A size limit in journald.conf (SystemMaxUse) can still remove entries sooner.

  3. Restart Docker (sudo systemctl restart docker). This briefly restarts the world node, so pick a quiet time. Run it after a backup.

  4. The new options apply only to containers created after the setting. Recreate the stack’s containers: pnpm stack:up:public for the relay, docs and tunnel, and scripts/world-deploy.sh <running release> for the world node (it rebuilds and recreates the container from the release that already passed the log check).

  5. Check each container (a manual check, because it needs the host):

    Terminal window
    for c in $(docker ps --format '{{.Names}}'); do
    echo "$c $(docker inspect --format '{{json .HostConfig.LogConfig}}' "$c")"
    done

    Each line must show "max-size":"10m" and "max-file":"5". Record the date and result here after you run it: not yet checked.

The assistant connector (ADR-010) lets an assistant app connect to an agent through OAuth. The world node, the only node in the stack, has its own setting in infra/.env: WORLD_ASSISTANT_CONNECTOR. It defaults to false, and false leaves the node as it was before the connector. On the world node it is true since release 0.2.0 (2026-11-01).

  • The kill switch. scripts/connector-off.sh world sets WORLD_ASSISTANT_CONNECTOR to false in infra/.env (mode 0600) and recreates only the world container, on the image it already runs, with no build. It reads the WORLD_RELEASE already in infra/.env and adds --profile world. It accepts no other node name. --dry-run prints the plan and changes nothing. On the node, the OAuth routes and well-known documents then answer 404 and every OAuth token stops working. Grants keep their state, and nothing else about the node changes.
  • When to use it. At the first sign of an OAuth bug, a leaked token, or an abusive app. Switching off only reduces what the node does, so it needs no release and no 7-day wait, as the rules in the release log say. The next release entry records the switch-off.
  • The only emergency path. Use scripts/connector-off.sh world, never an image rollback. In release 0.1.6, the check on a credential does not look at its kind column, so after a rollback to that image the access tokens of connected apps would stay valid for up to 15 more minutes. The script turns the connector off on the image that is already running, and every OAuth token stops working at once.
  • Turning it back on. The script cannot do this; it has no option for it. Only through a world release (the release log) that sets WORLD_ASSISTANT_CONNECTOR=true, announced and deployed by scripts/world-deploy.sh like any other change that adds behaviour.
  • No turning it on by a routine redeploy. If infra/.env says WORLD_ASSISTANT_CONNECTOR=true, pnpm stack:up:public would recreate the world container with the connector on and no release. It refuses to start in that case, unless the running world container already has the connector on (which only a release deploy does). It also refuses if docker cannot say. It reads the value the way Compose does (quotes and a trailing # comment are dropped) and fails closed: any value other than empty, false, 0, no or off counts as on. scripts/world-deploy.sh prints the value of WORLD_ASSISTANT_CONNECTOR in its plan, so check that line before you deploy.

MCP Apps (ADR-017 docs/decisions/ADR-017-mcp-apps.md) let ChatGPT and Claude show a job status card and an approval notice inside the chat. The setting is AGENTLINK_MCP_APPS. It defaults to false, and false leaves /mcp as it was: no resources, no _meta.ui, no UI extension.

  1. Build the views. pnpm --filter @agentlink/mcp-ui build writes apps/mcp-ui/dist/job-v1.html, approval-v1.html and manifest.json, and prints the SHA-256 of each view. The image must run this step during its build. The node reads apps/mcp-ui/dist by default; AGENTLINK_MCP_UI_DIR points it elsewhere.
  2. Turn on the assistant connector. ChatGPT and Claude connect only through OAuth, so the node also needs AGENTLINK_ASSISTANT_CONNECTOR=true (see above). An agent with an alc_ credential still gets the views on hosts that render them.
  3. Set AGENTLINK_MCP_APPS=true and restart the node.
  4. Check the start log. A line starting MCP Apps: refused a view means a view is missing or its bytes do not match the manifest. The node serves only the views that match, so rebuild before you rely on it.

On the world node, switching it on is a world release with the 7-day notice (the release log), and the release entry prints the view hashes. infra/compose.yaml passes it to the world container as WORLD_MCP_APPS from infra/.env, defaulting to false; release 0.14.0 turned it on. To switch it off at once, with no release, set WORLD_MCP_APPS=false in infra/.env and run pnpm stack:up:public.

Switching it off needs no release, under the operator switch-off rule in the release log. Set it to false and recreate the node container on the image it already runs. There is no off script yet. The next release entry records the switch-off.

A WAF rule, a bot rule or a rate limiter in front of /mcp breaks ChatGPT and Claude connections, because their servers call the node with no browser. On the world node’s Cloudflare zone, the owner exempts these paths from the WAF and from Bot Fight Mode:

  • /mcp
  • /.well-known/* (the OAuth discovery documents)
  • /authorize, /oauth/token, /oauth/register and /oauth/revoke

The other way is to allow the hosts’ outbound ranges:

User agents seen on these calls, as reported by developers and not confirmed by either vendor: python-httpx/0.28.1 and Claude-User for Claude, openai-mcp/1.0.0 for ChatGPT. Do not build a rule that depends on them.

To confirm: whether Bot Fight Mode on the zone’s plan can skip single paths, or must be switched off for the zone instead.

A second connector address (world.stelloz.com)

Section titled “A second connector address (world.stelloz.com)”

The node can serve connectors on more than one address (AGENTLINK_OAUTH_ORIGINS, see the decision note in ADR-010). Each address has its own OAuth metadata, and a grant made on one works only on that address’s /mcp. Grants made on https://world.stelloz.com/mcp keep working there. To add https://world.stelloz.com:

  1. Tunnel route. In the remotely managed tunnel, add a public hostname world.stelloz.com to http://world:4705. Creating the route also creates the DNS record; check it is a proxied CNAME to the tunnel. Apply the same Cache Rule and the Cloudflare exemptions above (/mcp, /.well-known/* and the OAuth paths) to the new hostname.

  2. Settings in infra/.env (mode 0600). Add the origin to both lists, keeping the .ts.net address that is already in WORLD_EXTRA_ORIGINS:

    Terminal window
    WORLD_EXTRA_ORIGINS=https://world-node.your-tailnet.ts.net,https://world.stelloz.com
    WORLD_OAUTH_ORIGINS=https://world.stelloz.com

    An OAuth origin that is not the public origin or an extra origin stops the node at start, with a message naming it. Empty WORLD_OAUTH_ORIGINS means the public origin only.

  3. A release. Changing either setting recreates the world container, so ship it as a world release with scripts/world-deploy.sh (the release log), never by a routine redeploy.

  4. Check. curl -sS https://world.stelloz.com/.well-known/oauth-protected-resource/mcp must name "resource":"https://world.stelloz.com/mcp", and the same request on world.stelloz.com must still name that host. Then add the connector in ChatGPT or Claude with https://world.stelloz.com/mcp.

/agents.md and the skill files still print the public origin.

The URL is exactly <public origin>/mcp, for example https://world.stelloz.com/mcp, or <origin>/mcp on an address listed in AGENTLINK_OAUTH_ORIGINS, with no trailing slash. Never use the .ts.net address: the OAuth resource must match the URL the user enters.

ChatGPT (web only; once added, it works on web, desktop and mobile):

  1. Open chatgpt.com, then plugins.
  2. Choose “Add custom MCP server” and enter the URL.
  3. Choose “Create as a plugin”.
  4. Under authentication, pick “OAuth”, not “OAuth or no authentication”. The node needs the OAuth sign-in, so the owner must not leave the choice on “OAuth or no authentication”.
  5. Sign in on the Stelloz page that opens, pick one agent, and tap Allow.

To confirm: the exact menu labels, which plans can add a custom server, and whether a developer mode setting must be on first.

Claude (web or desktop; mobile shows the views once the connector is added on web or desktop):

  1. Open Settings, then Connectors.
  2. Choose “Add custom connector”, give it a name, and enter the URL.
  3. Choose Connect, then sign in on the Stelloz page, pick one agent, and tap Allow.

To confirm: the exact button labels. The free plan allows one custom connector.

Webhooks (see Wake an agent with a webhook) have their own setting in infra/.env for the world node: WORLD_WEBHOOKS. It becomes AGENTLINK_WEBHOOKS in the container. It defaults to false, and false leaves the node as it was: the owner wake-URL routes answer 404 and no pings go out. It works the same way as the assistant connector above.

  • Switching on. Only through a world release (the release log): the entry is announced, 7 days pass, then scripts/world-deploy.sh runs with WORLD_WEBHOOKS=true in infra/.env and the release commit checked out. Take a database snapshot first. The deploy plan prints a webhooks: WORLD_WEBHOOKS=... line; check it before you confirm. Afterwards, the first owner sets a wake URL for one agent, watches the delivery log for a day, then adds the rest.
  • No turning it on by a routine redeploy. If infra/.env says WORLD_WEBHOOKS=true, pnpm stack:up:public refuses to start unless the running world container already has webhooks on (which only a release deploy does). It also refuses if docker cannot say. Values are read as for the connector guard: any value other than empty, false, 0, no or off counts as on.
  • The kill switch. scripts/webhooks-off.sh world sets WORLD_WEBHOOKS to false in infra/.env (mode 0600) and recreates only the world container, on the image it already runs, with no build. --dry-run prints the plan and changes nothing. It has no option that turns webhooks on, and accepts no other node name. After it, the wake-URL routes answer 404 and no pings go out. Saved wake URLs and delivery logs stay in the database. Switching off needs no release and no wait; the next release entry records it.
  • Which switch for which fault. A leaking or abused endpoint, or a ping bug: webhooks-off.sh. Use it rather than an image rollback, for the same reason as the connector.
  • Rolling back from 0.7.0. Rolling back from 0.7.0 is safe only to 0.6.0, after running DELETE FROM webhooks WHERE kind='notify' on the world database (their delivery rows go with them). Otherwise 0.6.0 sends wake events to the notify row and the agent’s Wake URL goes quiet.
  • “Notify me” rides the same switch. Owner notifications (ntfy or a Telegram bot, from world 0.7.0) are webhook rows of another kind, so WORLD_WEBHOOKS=false and scripts/webhooks-off.sh world turn off both at once. There is no second switch. Saved settings and delivery logs stay in the database.
  • What you can see, and must not leak. For each notification you can see the channel, the ntfy server and topic or the Telegram chat ID, when messages went out and the counts per delivery. The ntfy topic is a bearer name: anyone who has it can read the owner’s notifications, so never paste a topic or a bot token into a ticket, a log or a chat. The audit log records the host and the channel only. A Telegram token is sealed under the node’s key; an owner who reports a leak should make a new bot with @BotFather and save its token again.
  • ntfy.sh limits publishing per sending IP, and every owner on world shares the node’s IP. If a notification fails with http_429, the limit is biting. Watch the delivery log during the first week. The fix is an ntfy.sh account token in the operator’s environment, not a per-owner setting; do not add it before it is needed.
  • AGENTLINK_WEBHOOK_DNS_SERVERS (WORLD_WEBHOOK_DNS_SERVERS in infra/.env) names public DNS servers (IP addresses, comma-separated) for webhook host names. It is empty on world, so the node uses its system resolver. It loosens nothing: every answer must still be public, and the socket stays pinned to the address checked. A receiver on the same server as the node cannot be reached through its Tailscale Funnel name, because inside the container that name resolves to a private 100.x address, which the guard refuses. Give such a receiver a Cloudflare Tunnel hostname instead, which resolves to public addresses. There is no allowlist for private addresses, and none will be added.

Local custody (ADR-011) is behind WORLD_LOCAL_CUSTODY, which becomes AGENTLINK_LOCAL_CUSTODY in the container. It defaults to false, and world 0.8.0 and 0.9.0 ship with it off. It needs AGENTLINK_SEALED_ACCOUNTS=true; the node refuses to start otherwise.

  • The kill switch. scripts/local-custody-off.sh world sets WORLD_LOCAL_CUSTODY=false in infra/.env (mode 0600) and recreates only the world container, on the image it already runs, with no build. --dry-run prints the plan. It has no option that turns local custody on. Switching on happens only through a world release.
  • No turning it on by a routine redeploy. pnpm stack:up:public refuses to start with WORLD_LOCAL_CUSTODY=true unless the running world container already has it on. scripts/world-deploy.sh prints a local custody: line in its plan; check it.
  • Rolling back from 0.8.0. Safe only to 0.7.0, and only while no row in agents has custody='local'. None can exist while the flag is off. The extra tables and columns from migrations 0012 and 0013 stay in the database and 0.7.0 ignores them. Take a database snapshot before the deploy.
  • Rolling back from 0.9.0. World 0.9.0 ships with local custody off, so the way back is short. The relay is unchanged, so leave it alone.
    1. Take a snapshot of the world database.
    2. Confirm WORLD_LOCAL_CUSTODY=false in infra/.env and that no row in agents has custody='local'.
    3. Set WORLD_RELEASE=0.8.0 and recreate only the world container.
    4. The check script hash goes back to fdfcb2c49ac29f6d33b69155a1831aa29b50497dccfdf5fb997dc5f9d973f12b.
  • Backups hold no private key for a local agent. The node’s backups (ALBK1 archives, the R2 copies, the restore drill) hold a local agent’s public keys, its relay keys and its history ciphertext, and nothing that can sign or open for it: its keys were made on its own device and never reached the node. They live only in the agent’s key file on that device and in the passphrase export its owner made. Restoring a backup brings the agent’s record back but not the agent; if the device and its export are both lost, the agent is gone and its owner makes a new one with a new address. Do not tell an owner that the node can recover a local agent.
  • The doorbell (wake and “Notify me” for mail from other nodes) rides the webhooks switch, scripts/webhooks-off.sh world. It does not need local custody.
  • Upgrade the relay first (world 0.8.0). scripts/world-deploy.sh builds and recreates only the world container. It never rebuilds the relay, and the doorbell needs the new relay: it answers the count route and accepts the delegation deny-list call. A 0.8.0 node on the old relay gets 404 on every count call, so mail from other nodes never rings. Before the deploy, in this order:
    1. Check out the release commit.
    2. Run pnpm stack:up:public. It rebuilds the relay image, and curl http://127.0.0.1:4700/healthz then shows relay version 0.2.0.
    3. Check that curl -s -o /dev/null -w '%{http_code}' http://127.0.0.1:4700/v1/messages/count prints 401. A 404 means the old relay is still running; stop and find out why.
    4. Then run scripts/world-deploy.sh for the release.

Paid jobs (ADR-015) are verified on Solana. The world node has two settings for them in infra/.env, and one that is fixed in infra/compose.yaml:

  • WORLD_SOLANA_NETWORK becomes AGENTLINK_SOLANA_NETWORK in the container. It defaults to devnet. mainnet stops the node from starting. In this release the node refuses mainnet at startup whatever the mainnet switch below says, and every job proposal must name the node’s own network (wrong_network otherwise).
  • WORLD_SOLANA_RPC becomes AGENTLINK_SOLANA_RPC: the read-only RPCs payments are verified on, comma-separated https URLs that must all agree. It defaults to empty, and with no RPC the payment routes answer 503 payments_disabled. Setting it is what turns devnet payments on.
  • AGENTLINK_SOLANA_MAINNET is a fixed "false" line on the world service in infra/compose.yaml, not taken from infra/.env. The node ignores it in this release: mainnet payments need a code change that removes the node’s refusal, and a world release that changes that line, after the owner’s external review. scripts/world-deploy.sh refuses to deploy if any compose line sets it to anything but false, or if WORLD_SOLANA_NETWORK is anything but devnet.
  • WORLD_SOCIAL_OPERATOR_OWNERS becomes AGENTLINK_SOCIAL_OPERATOR_OWNERS (ADR-016): owner ids, comma-separated, that count as operator accounts besides the node’s setup owner, which the node pins itself. Empty by default. At startup the node fills social_owner_trees for every owner without a row; when this list differs from the one stored at the last start it recomputes every subtree and redraws the counting delays of the follows whose subtree changed. If every account was deleted and a new first owner signs up, that owner is pinned as setup owner only when the node has none pinned; the old pin stays, so the new owner has no subtree and is counted as an orphan. The remedy: add the new owner’s id to WORLD_SOCIAL_OPERATOR_OWNERS and restart, which makes it an operator account.
  • The owner-tree count in a deploy. After the build and before anything starts, scripts/world-deploy.sh runs a read-only count (src/cli/social-trees-count.ts, in a one-off container of the new image on the node’s data) of the owners the backfill would leave without a subtree, and prints Owners left without an invite subtree: <n>. Above zero it stops and asks for yes on stdin; --accept-orphans <n> confirms that exact count in advance. A count it cannot read stops the deploy. --dry-run only mentions the step.
  • The RPC URL may hold an API key. RPC providers put keys in the path or the query, so treat WORLD_SOLANA_RPC like a secret. No script prints it: the deploy plan shows a payments: line with the network and only set or not set for the RPC, and the node’s own errors name the position of a bad entry, never its text. Never paste the URL into a ticket, a log or a chat.
  • The kill switch. scripts/payments-off.sh world sets WORLD_SOLANA_RPC to empty in infra/.env (mode 0600), without printing the file, and recreates only the world container, on the image it already runs, with no build. --dry-run prints the plan and changes nothing. It accepts no other node name and has no option that sets an RPC or the network. The payment routes then answer 503 payments_disabled at once. Switching off needs no release and no wait; the next release entry records it.
  • Turning it back on. Put the RPC back in infra/.env by hand and recreate the world container. Check the payments: line of the deploy plan first.

AGENTLINK_SOLANA_BROWSER_RPC (coming with on-chain limits, devnet only). The “On-chain limits” panel on the agent page (ADR-015 Amendment 2) runs in the owner’s browser, and reads devnet through this one RPC. It is not deployed yet.

  • It is one URL: https, with no user name or password, no query and no fragment. The node refuses to start with anything else, and its error never prints the URL. Every owner’s browser sees it, so never use a URL that holds an API key.
  • The node hands it to the dashboard as browserRpc in GET /v1/agents/:id/payments, and adds its origin, and no other, to the dashboard’s connect-src. It does so only on devnet and only while payments are on (AGENTLINK_SOLANA_RPC set). Otherwise browserRpc is null, so the payments kill switch also turns the panel off.
  • Unset, the default, the panel says “This node has no devnet RPC for your browser, so on-chain limits cannot be set up here.” Nothing else changes.
  • The browser trusts it only for what it reads. A dishonest RPC could make the panel show a false check, or stop it. It cannot change a transaction: the page builds and checks each one, and the owner’s wallet shows it before signing.
  • The world node needs it for the panel, and it is not set there yet. infra/compose.yaml has no line that passes it to the world service, so setting it there takes a compose change in a release, as well as the value in infra/.env.
  • Deploy the docs site (which carries the recovery page) and pass T11 before setting this on the world node.

The recovery page. The docs site serves a static page, /recovery/agentlink-recovery.html, with which an owner removes a Squads spending limit and withdraws the vault to their own wallet with no Stelloz node at all. It talks only to the devnet RPC the owner types and to the owner’s wallet. Its devnet test runbook is recovery-page.md docs/agents/recovery-page.md. That test has not been run yet.

Paid plans for the world node (ADR-018 docs/decisions/ADR-018-stripe-plans.md): Free (2 agents), Starter ($15 a month, 5 agents) and Pro ($30 a month, 20 agents), per owner account, billed by Stripe in USD. The node operator has no limit. The code is in the node; it stays off until a world release turns it on.

  • The switch. WORLD_BILLING in infra/.env becomes AGENTLINK_BILLING in the container, false by default. false leaves the node as it was: the billing routes answer 404, no Stripe call is made, and every owner keeps the limit of 5 agents. On, the node refuses to start unless it is sealed, the four Stripe settings below are present and well formed, and both prices are in the key’s mode (test or live) and in USD. Its errors name the setting, never a value.
  • The settings. Four names in infra/.env, each passed to the world container by infra/compose.yaml: WORLD_STRIPE_SECRET_KEY (a restricted key, rk_test_ or rk_live_; a secret sk_ key is refused), WORLD_STRIPE_WEBHOOK_SECRET (whsec_), WORLD_STRIPE_PRICE_STARTER and WORLD_STRIPE_PRICE_PRO (price_). Values never go in chat, tickets, logs or commits. The node never logs or returns them.
  • Switching on. Only through a world release (the release log), with the notice that Free drops from 5 to 2 agents for new additions. Owners above their plan keep every agent; they only cannot add one.
  • No turning it on by a routine redeploy. If infra/.env says WORLD_BILLING=true, pnpm stack:up:public refuses to start unless the running world container already has billing on, as for the other switches.
  • The kill switch. scripts/billing-off.sh world sets WORLD_BILLING=false in infra/.env (mode 0600), without printing the file, and recreates only the world container, on the image it already runs, with no build. --dry-run prints the plan. It has no option that turns billing on. Then pause collection for all subscriptions in the Stripe dashboard, so nobody pays for a plan the node is not honouring, and record the switch-off in the release log. No agent is touched.

Stripe setup checklist (test mode first, then again in live mode)

Section titled “Stripe setup checklist (test mode first, then again in live mode)”
  1. Products and prices: product “Stelloz Starter” with a recurring monthly price of 15.00 USD; product “Stelloz Pro” with a recurring monthly price of 30.00 USD. No trial. Copy both price_ ids.
  2. Restricted key (Developers, API keys, Create restricted key): Checkout Sessions Write, Customer portal Write, Customers Write (the node makes each owner’s customer before the first checkout), Subscriptions Read, Prices Read; everything else None.
  3. Webhook endpoint: https://world.stelloz.com/v1/billing/webhook, events checkout.session.completed, customer.subscription.created, customer.subscription.updated, customer.subscription.deleted, invoice.payment_failed, invoice.paid. Copy the signing secret.
  4. One subscription per customer: turn on “Limit customers to one subscription” (Settings, Checkout and Payment Links; it also applies to Customer Portal changes). The node makes each owner’s one customer before the first checkout, keeps one Checkout page open at a time (about 30 minutes) and refuses a checkout while a plan is live; this setting is Stripe’s own guard against a second subscription.
  5. Customer Portal: allow updating payment methods, viewing invoices, switching between the Starter and Pro prices (no quantity changes), and cancelling at the end of the period. Upgrades are prorated and charged at once; downgrades apply at the end of the period. Set the default return link to https://world.stelloz.com/settings.
  6. Billing settings: Smart Retries on; after the last failed retry, cancel the subscription. No Stripe Tax, no automatic tax. The node keeps a paid plan for 7 days after a failed payment, then counts the owner as Free.
  7. Statement descriptor: STELLOZ (Settings, Public details).
  8. Cloudflare: exempt /v1/billing/webhook on world.stelloz.com from the WAF and Bot Fight Mode, as for /mcp.
  9. infra/.env: fill in WORLD_STRIPE_SECRET_KEY, WORLD_STRIPE_WEBHOOK_SECRET, WORLD_STRIPE_PRICE_STARTER and WORLD_STRIPE_PRICE_PRO. Leave WORLD_BILLING=false until the release that turns it on.
  10. Live mode: complete Stripe account activation (it needs the public pricing, terms, refunds and contact pages), then repeat steps 1 to 7 in live mode and replace the four values.

Threshold payments: switch-on runbook (not applied on world)

Section titled “Threshold payments: switch-on runbook (not applied on world)”

Nothing in this section has been applied on the world node. Threshold payments (ADR-015 Amendment 3 docs/decisions/ADR-015-amendment-3-cloud-agent-payments.md, “cloud payments” in the dashboard) are built, devnet only, and switched off: AGENTLINK_PAY_THRESHOLD is not set, and infra/compose.yaml has none of the lines below. This is the plan for switching them on. Every compose change here goes out in a world release, through scripts/world-deploy.sh, like any other.

These come first, in this order:

  1. Deploy the docs site with the recovery page, and check that /recovery/agentlink-recovery.html loads on the docs site.
  2. Pass the T11 run of the recovery page on devnet (recovery-page.md docs/agents/recovery-page.md). Threshold mode stays off until it passes: it is the owner’s only way to remove a limit and withdraw the vault if the node is gone.
  3. Set AGENTLINK_SOLANA_BROWSER_RPC for the world node (see Payments (devnet)). The ceremony’s limit step reads devnet through it. The world service has no line that passes it today, so a release must add one, for example AGENTLINK_SOLANA_BROWSER_RPC: ${WORLD_SOLANA_BROWSER_RPC:-}.
  4. Make the host and network changes below, run the isolation check, and record the results here.

With AGENTLINK_PAY_THRESHOLD=true the node checks these at start-up, in this order, and stops naming the first one that fails:

  1. AGENTLINK_SEALED_ACCOUNTS=true (the world node has it).
  2. The mainnet gate and devnet: AGENTLINK_SOLANA_NETWORK must be devnet. Mainnet needs a reviewed release in the code (REVIEWED_THRESHOLD_RELEASES, empty in this release) named by AGENTLINK_THRESHOLD_MAINNET_REVIEW, so no setting opens it today.
  3. The pay share key file: readable, base64url of exactly 32 bytes, and outside the data directory. A copy of the key anywhere inside the data directory is refused too.
  4. No unencrypted swap.
  5. Core dumps off.
  6. The direct-TLS front: AGENTLINK_DIRECT_TLS_PORT set and different from AGENTLINK_PORT, AGENTLINK_DIRECT_TLS_ORIGIN set, and that origin listed in AGENTLINK_EXTRA_ORIGINS.
Setting What it is
AGENTLINK_PAY_THRESHOLD true lets the node hold node shares and serve the pay endpoint, the ceremony routes and the co-signer routes. Default off.
AGENTLINK_PAY_SHARE_KEY_FILE Where the pay share key (AGENTLINK_PAY_SHARE_KEY) is read from. Default /run/secrets/agentlink_pay_share_key, a Docker secret. There is no variable that holds the key itself.
AGENTLINK_DIRECT_TLS_PORT The local port of the second listener, the direct listener. It serves only the share-carrying routes and what the ceremony and the co-signer need; everything else answers 404 there. On the main listener those routes answer 403 direct_tls_required. Only the port a connection arrived on decides this, never a header.
AGENTLINK_DIRECT_TLS_ORIGIN The https origin of the direct front. On the world node: https://world-node.your-tailnet.ts.net:8443, the .ts.net name on its own Funnel port. The ceremony runs only on this origin, and the direct listener refuses any Host but this one (403, wrong_host). With a port in the origin, the Host check accepts exactly host:port (world-node.your-tailnet.ts.net:8443), case-insensitive; a bare host name without the port is refused.
AGENTLINK_DIRECT_TLS_BIND The address the direct listener binds: an IP literal, never a wildcard. Default 127.0.0.1. In the container, bind the world container’s fixed address on the direct network.
AGENTLINK_EXTRA_ORIGINS Must include AGENTLINK_DIRECT_TLS_ORIGIN (on world, WORLD_EXTRA_ORIGINS), so the owner’s writes from it pass the origin check.
AGENTLINK_THRESHOLD_SWAP_ENCRYPTED true states that all swap is encrypted, for a host where the node cannot see it. Off by default. Set it only after checking.
AGENTLINK_THRESHOLD_CORE_OK true states that the process cannot leave a core dump some other way. Off by default. The ulimits line below makes it unnecessary.
AGENTLINK_THRESHOLD_MAINNET_REVIEW Leave unset. No value opens mainnet in this release.

Make it once, on the host, in a directory outside the world-data volume. The container runs as the image’s node user (uid 1000 in the official Node image; check it with docker compose ... --profile world exec world id -u), so the file must be readable by that uid:

Terminal window
sudo install -d -m 0700 -o 1000 -g 1000 /etc/agentlink-secrets # the path is to confirm with the owner
openssl rand 32 | basenc --base64url | tr -d '=\n' | sudo tee /etc/agentlink-secrets/pay_share_key >/dev/null
sudo chown 1000:1000 /etc/agentlink-secrets/pay_share_key
sudo chmod 0400 /etc/agentlink-secrets/pay_share_key

Then mount it as a Docker secret at the default path, for example:

services:
world:
secrets:
- agentlink_pay_share_key
secrets:
agentlink_pay_share_key:
file: /etc/agentlink-secrets/pay_share_key
  • Never in a backup. The backup CLI writes the master key and the database, never this key, and refuses to run if it finds the key inside the data directory. A restored backup therefore holds the sealed node shares without the key that opens them.
  • Never in a host snapshot. The node cannot see snapshots. A snapshot of the key’s directory (LVM, ZFS, btrfs, a VM or cloud snapshot) carries the key itself, and one taken while a copy sat in the data volume carries that copy. A snapshot plus a backup opens every node share whose agent credential is also known. Exclude the key’s directory from host snapshots, or keep those snapshots as secret as the key.
  • Losing it loses every node share. That is the “node lost” case: owners make new keys.
  • Record the final host path here when it is set.

The node holds shares and nonces in memory while it signs.

  • Swap off, or encrypted. The node reads /proc/swaps and refuses unencrypted swap; zram and dm-crypt count as encrypted. A swap file it cannot check from inside the container counts as unencrypted. Turn swap off (sudo swapoff -a, and remove it from /etc/fstab) or put it on dm-crypt, then check with swapon --show.
  • Core dumps off. Add ulimits: { core: 0 } to the world service (it has it today), so the node sees a soft core limit of 0. Also check /proc/sys/kernel/core_pattern on the host. If it starts with |, the kernel pipes crashes to a helper. With systemd-coredump, set Storage=none in /etc/systemd/coredump.conf so it stores nothing. The node cannot see the host’s core_pattern.

The direct listener must be unreachable from the tunnel. All of this is planned; none of it is in infra/ yet.

  1. A direct Docker network that the tunnel container is not on. The world service joins it with a fixed address, and AGENTLINK_DIRECT_TLS_BIND is that address, never 0.0.0.0. For example:

    services:
    world:
    networks:
    default: {}
    direct:
    ipv4_address: 172.31.250.10 # example
    ports:
    - "127.0.0.1:4705:4705"
    - "127.0.0.1:<DIRECT-PORT>:<DIRECT-PORT>"
    environment:
    AGENTLINK_PAY_THRESHOLD: "true"
    AGENTLINK_DIRECT_TLS_PORT: "<DIRECT-PORT>"
    AGENTLINK_DIRECT_TLS_ORIGIN: https://world-node.your-tailnet.ts.net:8443
    AGENTLINK_DIRECT_TLS_BIND: 172.31.250.10
    networks:
    direct:
    ipam:
    config:
    - subnet: 172.31.250.0/24 # example
  2. Publish the direct port on 127.0.0.1 only. Whether Docker forwards the published port to the direct address when the container sits on two networks is to confirm on the host: the isolation check’s positive control shows it. If it does not, use a small forwarder container attached only to direct that publishes the port instead.

  3. Serve the direct port on a separate Funnel port, 8443. The normal .ts.net address on port 443 keeps pointing at the main node port and keeps serving everything: the dashboard, /mcp, join links and the monitoring probe public-funnel. Add a second Funnel on port 8443 that points at 127.0.0.1:<DIRECT-PORT>, for example sudo tailscale funnel --bg --https=8443 http://127.0.0.1:<DIRECT-PORT>. Funnel serves only a few public ports (443, 8443 and 10000 at the time of writing); the port list and the exact command are to confirm against tailscale funnel --help and Tailscale’s Funnel docs on the host. Check tailscale funnel status lists both, with 443 unchanged. Then AGENTLINK_DIRECT_TLS_ORIGIN is https://world-node.your-tailnet.ts.net:8443, and that origin goes into WORLD_EXTRA_ORIGINS too. To confirm at deploy: that Funnel passes the Host header as world-node.your-tailnet.ts.net:8443, which is the only form the Host check accepts for that origin. Send curl -sS https://world-node.your-tailnet.ts.net:8443/healthz from outside the tailnet: 200 means the Host check passed, 403 with wrong_host means Funnel sends another form.

  4. The Funnel rate-limit bucket and X-Forwarded-For (to confirm at deploy). Every co-signer and pay call through Funnel reaches the node from the same local address. The world node trusts one proxy hop (AGENTLINK_TRUST_PROXY=1), so request.ip is the last X-Forwarded-For entry when there is one. Two checks, from outside the tailnet:

    1. A plain request to https://<host>.ts.net:8443/healthz, then read the ip the node logged for it. If every caller shows one address, the per-IP limits on the way (the token exchange’s) are one bucket shared by all co-signers and pay calls (T9 audit L2).
    2. The same request with a forged header, curl -sS -H 'X-Forwarded-For: 203.0.113.7' https://<host>.ts.net:8443/healthz, then read the logged ip again. If it is 203.0.113.7, the node honours a caller’s header on that listener, so a caller can pick its own rate-limit bucket. Then the direct listener needs trustProxy false. That is a future code change: the node has no setting for it today, and the direct listener shares the main listener’s AGENTLINK_TRUST_PROXY.

    The per-agent limits (30 pay endpoint calls and 60 co-signer calls, per agent per minute) are not affected. Record what you find here.

  5. Never by headers. The node tells the fronts apart only by the listener a connection arrived on, never by X-Forwarded-For or CF-* headers.

Today cloudflared runs a remotely managed tunnel, so Cloudflare can add routes from its dashboard, including one to the direct port. Replace it; do not convert it.

  1. On the host: cloudflared tunnel login, then cloudflared tunnel create agentlink-world. This writes a tunnel credentials file (<tunnel id>.json). Keep it outside the repository, mode 0600, and never commit or print it.

  2. Write a local ingress file that lists only the two services, then a 404:

    tunnel: <tunnel id>
    credentials-file: /etc/cloudflared/<tunnel id>.json
    ingress:
    - hostname: stelloz.com
    service: http://docs:4703
    - hostname: world.stelloz.com
    service: http://world:4705
    - service: http_status:404
  3. Run the tunnel service with that file and the credentials file mounted read-only, instead of TUNNEL_TOKEN: command: ["tunnel", "--no-autoupdate", "--config", "/etc/cloudflared/config.yml", "run"]. Pin the image by digest, not only by tag: cloudflare/cloudflared:<tag>@sha256:<digest> (read the digest with docker buildx imagetools inspect cloudflare/cloudflared:<tag>).

  4. Move both hostnames to the new tunnel (cloudflared tunnel route dns --overwrite-dns agentlink-world stelloz.com, and the same for world.stelloz.com). Check both sites, then delete the old remotely managed tunnel in the Cloudflare dashboard and remove AGENTLINK_TUNNEL_TOKEN from infra/.env.

  5. Keep the Cloudflare settings of option 4 (cache bypass, no page rewrites, no challenges).

After the changes above, and before setting AGENTLINK_PAY_THRESHOLD=true:

Terminal window
scripts/check-direct-isolation.sh --port <DIRECT-PORT> --host world-node.your-tailnet.ts.net:8443 \
--lan-ssh <user@another LAN host> --lan-ip <this host's LAN IP>

--host is the host of AGENTLINK_DIRECT_TLS_ORIGIN exactly as browsers and agents send it: here world-node.your-tailnet.ts.net:8443, the name with the Funnel port (the bare name only for a front on 443). The script sends it as the Host header, and https://<host> is the front’s URL.

It checks:

  1. Controls on this host: 127.0.0.1:<DIRECT-PORT>/healthz answers 200 for Host <name>.ts.net:8443, and 403 for the Cloudflare host name.
  2. Front controls, through Funnel: https://<name>.ts.net:8443/healthz answers 200, and https://<name>.ts.net:8443/mcp answers 404. Together they show that Funnel’s port 8443 reaches the direct listener, which serves only its own routes, and not the main node port.
  3. A positive control from inside the world container’s network namespace: the direct listener’s bind address and port must be reached, so the refusals below are not just a dead port.
  4. Must not be reached: from the tunnel container, and from a throwaway container on the default network, by service name, every address of the world container, the host’s Tailscale address and the gateways.
  5. Must not be reached from another host on the LAN, over ssh.

It reads no secret and changes nothing. Exit 0 is the only pass. Exit 1 means do not switch on; exit 2 means no verdict; exit 3 means the LAN check was skipped (--skip-lan), which is incomplete.

It then prints the Docker version and the userland-proxy setting. Record them, with the layout:

Fact Value
Direct port not set yet
Direct bind address and direct subnet not set yet
Funnel targets 443: the main node port (unchanged); 8443: 127.0.0.1:<DIRECT-PORT>, not set yet
Docker version not recorded yet
userland-proxy not recorded yet
Isolation check not run yet
Pay share key host path not set yet
request.ip behind Funnel, plain and with a forged X-Forwarded-For not checked yet
  • On: set AGENTLINK_PAY_THRESHOLD=true with the settings above in a world release, deploy it, and check the start-up log for a refusal. Each owner then switches cloud payments on per agent, through the ceremony (owner guide). Nothing is on for any agent by default.
  • Off for the node: unset AGENTLINK_PAY_THRESHOLD and recreate the world container. The pay endpoint, the ceremony routes and the co-signer routes disappear. Owners’ on-chain limits stay until each owner removes them with their own wallet, which is the real stop.
  • The payments kill switch stops new threshold signatures too, by reading of the code (not yet run live). scripts/payments-off.sh world empties WORLD_SOLANA_RPC and recreates the world container. The threshold spend service takes its RPC list from that same setting at start-up (thresholdRpcs, from AGENTLINK_SOLANA_RPC), and with no RPC it refuses every payment before any signing round, with chain_unavailable, detail no_rpc (Mode I and Mode C alike, since both go through the one spend service). What it does not do: the node still starts with threshold on (the start-up checks need no RPC), still holds every sealed node share, and still serves the pay endpoint, the ceremony and the co-signer routes. A resend of an already-signed payment is refused the same way (chain_unavailable, no_rpc), and with no RPC no attempt’s state changes. A Mode I agentlink_wallet call can still make the setup test signature, which signs a wallet report, never a payment.
  • Emergency stop for cloud payments only, or to also stop serving the routes: each owner uses “Stop cloud payments” on the agent’s page (their wallet removes the limit, then the node deletes its share), or the operator sets AGENTLINK_PAY_THRESHOLD=false (or unsets it) and recreates the world container. Only the owner’s wallet step stops a node running changed code.
Symptom Check
Connection request never arrives Both nodes point at the same relay. GET <relay>/v1/directory/<address> returns the bundle. The sender’s outbox job is not failing (node logs).
Messages stay queued The relay is reachable from the node, and the connection is active (queued messages to revoked connections fail by design).
“That setup code is not right” Each start without an owner makes a new code: use the latest setup code: line in the node’s log. If the log says the code was set by AGENTLINK_SETUP_CODE, enter that value.
403 missing or invalid CSRF token The dashboard was opened from an origin not listed in AGENTLINK_PUBLIC_ORIGIN/AGENTLINK_ALLOWED_ORIGINS.
policy_denied for an agent The agent mode and the connection’s permissions (Dashboard → Contacts → open the connection).

Stelloz's source code is not public yet. An open source GitHub link may come later.