Skip to main content
Glama
README.md
# Tether

Tether watches your homelab's alerts channels on Discord. Whatever posts there (Komodo, an uptime monitor, Scrutiny,
your NAS, a certificate job, an audit bot), its triage agent looks into it, checking your hosts, Komodo and the web,
and posts what it found in a thread on the alert. Reply in the thread to ask follow-up questions or to have it fix
something.

The triage agent is Claude Code by default. Tether's own agent can run the same jobs, with the same tools and limits,
on the Anthropic API or on an OpenAI-compatible server (OpenAI, OpenRouter, or a model in your lab), with no Claude Code
involved (**Settings β†’ Models**). Tether is also an MCP server: your own coding agent (Claude Code, Codex, Cursor or
any MCP client) can use the lab through it (**Connect an agent**, below).

## How it works

Tether stays connected to Discord, so it shows as online and picks up new messages from webhooks and other bots
in the channel within seconds. People's messages and its own aren't alerts. While the agent works, the thread shows
"Tether is typing…" and a line saying what it's checking ("Asking Komodo: ListStackServices"), which becomes the
answer when it's done. A triage is a card coloured by its verdict (green for SAFE, FYI or RESOLVED, amber for
WATCH, NEEDS INFO or PLAN READY, red for HOLD or ACTION NEEDED) with a one-line headline, a sentence or two, and labelled lines
for the cause, risk, next step and anything unverified, when there's something to say. Each alert carries one
status reaction. Before any of that, a separate pass with no tools reads the alert only to check that it isn't
trying to instruct or steer an AI ("safe to deploy", "no need to check"); Komodo's own update alert, which has no
free text, skips it.


| Reaction | Meaning |
|---|---|
| none | not picked up yet |
| πŸ‘€ | working on it |
| βœ… | done; details in the thread |
| ❌ | failed; details in the thread, and a πŸ” to click to try again |
| β˜‘οΈ | ignored: an ignore rule matched, you skipped it with ⏭️, or a release-channel post wasn't a release; no thread |
| ↩️ | came back by itself: its recovery notice arrived while it waited, so it wasn't triaged (see **Waiting and emergencies**) |
| 🚩 | an alert or release post that looks like it's trying to instruct or steer an AI; not triaged, nothing from it kept, and πŸ” screens it again |

Discord's "Tether started a thread" line is deleted as soon as the thread opens; threads you start keep theirs.

Each alert is researched by topic, which Tether's code picks from its text (app/triage/topics.py), so the agent gets only
the steps for that kind of alert (app/triage/topic_prompts/):

| Topic | When | Looks in first | Then |
|---|---|---|---|
| Update | Komodo's update alert, or an alert saying a new version is available | The project's release notes and changelog; the release notes Tether kept | Which version runs where, and the compose file and environment the change touches |
| Advisory | It names a CVE or GHSA, or says vulnerable, vulnerability or exploited | OSV.dev, GitHub's advisory database and CISA's exploited-in-the-wild catalog (`advisory.py`), then the vendor's advisory | Where it runs, which version, and whether a login stands in front of it. Verdict URGENT, AFFECTED, NOT AFFECTED or NEEDS INFO, on the lab-check card |
| Incident | Everything else: something down or failing, or informational | Playbooks, your notes and the alert's history | The current state, logs, and what changed recently (deploys, restarts, disk); release notes only when a recent update is a suspect |

Tether can pick wrong, so the agent may say the alert is another kind and use that kind's verdicts; a playbook for
the alert overrides the steps. A release channel's security note gets the same advisory lookup in its lab check.

Cards in threads are short: the verdict, the headline, a sentence or two, and only the lines that decide what to do
by kind of answer:

| Card | Shows | The full report adds |
|---|---|---|
| Triage | Cause, Risk, Next | Unverified, Blocked |
| Update | Risk, Downtime, Next | Breaking, Migration, Sources |
| Upgrade plan | Before, Deploy, Downtime, Rollback | After, Unverified, Sources |
| Advisory and lab check | Where, Next; Exposure when it's urgent, Exploited when CISA lists it | Exposure, Exploited, Sources |
| Release note | Security, Breaking | Migration, Requires (on the Releases page) |
| Webhook result (a notification) | Title, verdict, the first sentence, and Next when it needs you | Everything, on the report page it links to |

The full report is on the web page: the card's headline links to it, and a small line says what's there
("breaking, unverified, 3 sources"). Until you've opened the web page once, Tether doesn't know its address and
posts the whole card instead.

### Tickets

Each alert and its thread work like a ticket:

| You | What happens |
|---|---|
| βœ… on the alert | Closed: Tether's πŸš€ and πŸ” go and the thread is archived. Reply in it to reopen. |
| ⏭️ on an alert Tether hasn't handled yet | Skipped: marked β˜‘οΈ and never triaged. Works on new alerts and on ones held (⏸️) for a Claude usage limit, whose hold line goes. :tether: or πŸ” on it takes it up after all. |
| πŸ” on the alert | Redone: Tether deletes its messages in the thread and triages the alert again. |
| `!redo` in a thread | Tether deletes its last answer and answers your question again (or redoes the triage). |
| `!close` in a thread | Same as βœ… on the alert. |
| @Tether in the channel | Tether opens a thread on your message and answers there, like a reply. |
| :tether: on any message in a watched channel | Tether takes it up. Your own message is answered like an @mention. Anything else (an alert an ignore rule hid, one too old to start on, someone's message, a release post) is triaged in a thread like a new alert, skipping ignore rules. A message Tether already handled is left alone: πŸ” redoes those. The emoji is the server's custom `:tether:`; set `TETHER_EMOJI` to use another name. |

- **Triage only looks.** While triaging an alert, the agent can run read-only commands on your hosts over SSH
  (docker ps and logs, systemctl status, journalctl, df, smartctl, curl and so on), query Komodo, and search and
  read the web. Anything that could change state is refused before it runs. Alert text is treated as data, since
  anyone with a webhook's URL can post.
- **Replies can act.** When you reply in a thread, the agent can also run any command on a host, but only what you
  ask for ("restart it", "prune the old images"). It checks before and after, and says exactly what it ran. Only
  your replies get answers. On a host it can do only what the `tether` user can. Komodo is read-only for Tether:
  when a stack needs a deploy, restart or pull, the agent says what to run in Komodo.
- **Linked posts are read.** Paste a link to a Discord message in your reply and Tether's bot fetches it (from any
  channel the bot can see), screens it like an alert, and gives the agent its text as data. Up to three per message.
- **Updates are researched.** For an "update available" alert, the agent (Sonnet by default) finds both versions,
  opens the release notes in between, checks dependencies, and lists the pages it read under **Sources**. Tether
  sees every page the agent opens, so a SAFE that isn't backed by notes it actually read becomes NEEDS INFO, with the
  reason on the card. SAFE means a plain redeploy; anything that has to happen first (a breaking change, a config
  or env change, a migration, a dependency to move) is HOLD.
- **Breaking changes get a plan.** After a HOLD, a stronger model (Opus by default) picks up the research and posts
  an upgrade plan as a second card: **Before** (what to do first, with the real settings), **Deploy**, **After**
  (what to check) and **Rollback**. Its verdict is PLAN READY or HOLD, and it's held to the same rule about sources.
- **πŸš€ deploys updates, when they're advisable.** Tether adds a πŸš€ to a Komodo "update available" alert only when
  the research says SAFE or the plan says PLAN READY (then: do the Before steps first). Click it to run PullStack
  and DeployStack for that stack. Only your πŸš€ counts, not Tether's; you can still add one yourself to deploy
  against the advice. Tether removes its own once the deploy is done. No agent involved. With several Komodos,
  Tether asks each which one runs the stack and deploys there (a stack found on two, or none, is reported in the
  thread instead). **Settings β†’ Actions β†’ Updates** can pin one Komodo, or turn deploys off while the agent can still
  read them. This is being replaced: once Tether proposes changes as GitHub pull requests, πŸš€ will merge the
  proposed PR instead, and Komodo deploys it from the repository.
- **Ignore rules.** Messages you never want triaged ("Stack is now running") get β˜‘οΈ and nothing else. Rules match
  like a playbook (a pattern on the text, the sender, or both) and can be limited to one channel; add them on the
  **Signals** page. A built-in rule covers recovery notices (`OK βœ…`, `[UP]`, `RESOLVED`…).
  A rule with no channel covers the alerts channels only; to skip posts in a release channel, pick that channel.
  Every β˜‘οΈ is explained in the Status page's activity: which rule matched and on what, or why a release-channel
  post wasn't kept.
- **Waiting and emergencies.** Something failing (down, unreachable, unhealthy) waits 3 minutes before it's
  triaged. If its sender's recovery notice comes first ("OK βœ… | **edge** is now connected", "[Jellyfin] [βœ… Up]",
  "resolved: …"), it's marked ↩️ and not triaged. The third drop within an hour is triaged once, as flapping, with
  the drops listed; later drops are only marked. Updates, advisories, release posts and anything you :tether: don't
  wait. Only an emergency pings you: triage adds an **Emergency** line when the alert is one of your emergencies
  (by default data being lost, a disk or array failing, or signs of an attack). A red verdict alone doesn't ping,
  and an incident pings at most once in 12 hours. Both are on **Settings β†’ When to tell you**; 0 minutes turns
  waiting off, and the First look's **Pings** switch turns pings off.
- **Troubleshooting by layer.** Each source on the Sources page covers layers of the lab, from power and the
  network (the gateway, the WAN, the ISP) up through tunnels, DNS, proxies, hosts and containers to the apps. When
  something is unreachable, triage works down from the layer that failed and names the lowest one that's broken,
  rather than blaming the thing the alert names. It also sees the other alerts from the same 15 minutes and
  Tether's own internet and DNS, which Tether checks once a minute, so a dropped connection at Tether's site isn't
  mistaken for distant hosts going down. A layer with no source is reported as unchecked. UniFi consoles' own keys
  can't see internet health: add a **UniFi Site Manager** source (an API key from unifi.ui.com) for each site's
  WAN latency, packet loss and uptime.
- **More channels.** Besides the main alerts channel, Tether can watch others: on the **Signals** page, **Add
  channel**, give it a name, paste the channel's ID and pick what Tether does with it. Tether checks its bot can read
  and post there before it's added. **Triage** treats it like the main one; a playbook's `channel:` line (the name
  you gave it, Discord's name, or the ID) limits it to one. **Release notes** is for the announcement channels you follow (Komodo, Pangolin, Pelican): each post is screened for text
  aimed at an AI, then summarized into a release note (project, version, breaking changes, migrations, required
  versions). The agent has no tools at all for either step, and nothing in those posts is acted on. When that project
  later has an update, research reads the notes as leads and still checks the real release notes. Notes are on the
  **Releases** page.
- **Does it affect my lab?** Every release with a version (and any security advisory) also gets checked against
  your lab: what runs it, where, which version, and for a security fix, who can reach it (a login in front, like
  Pangolin's SSO, counts; a proxy alone doesn't). Announcements are often just a link, so the check reads the
  project's own release notes. A release stays a release even when it fixes vulnerabilities: its thread is
  "Release: …" (tagged "security fix" or "breaking"), and its card says whether to upgrade: **UPGRADE NOW** (it fixes
  a security flaw that's reachable in what you run: you're pinged), **UPGRADE** (you run an older version),
  **NO UPGRADE NEEDED** (you don't run it, you're already on it, or your notes pin it) or **NEEDS INFO**. A security
  advisory (a post about the vulnerability itself, not a new version) gets a "Security: …" thread and says
  **URGENT**, **AFFECTED**, **NOT AFFECTED** or **NEEDS INFO** instead. Pre-releases don't count unless you run that
  line. UPGRADE NOW, UPGRADE, URGENT and AFFECTED then get an upgrade plan on the plan model for your setup: what to do first, how to deploy, the downtime and what keeps working, how to confirm and roll back.
  Tether doesn't deploy from it: do it yourself, or πŸš€ the update alert when one comes.
- **Models.** Each job has its own model on the Settings tab: triage and research, upgrade plans (and patch
  plans), thread replies, screening, release summaries, and lab impact checks. A playbook can pick one for the alerts it matches with `model: opus`.
- **An OpenAI-compatible server.** Set one under Settings β†’ Models: any server with the OpenAI chat API (Ollama at
  `http://<host>:11434`, vLLM, LM Studio, llama.cpp, OpenAI or OpenRouter), the model's name, and a key if it needs
  one. Tether checks it answers before saving. Secrets are taken out of the prompt as they are for the agent, and a
  reasoning model's `<think>` block is dropped. With Claude Code as the triage agent, screening and release summaries
  (no tools) can run on it, so with a server in your lab their text never leaves it; if it fails, Claude Code answers
  instead and the activity log and Settings say so, so an alert never waits on it.
- **The triage agent.** Claude Code by default, or Tether's own agent (`app/triage/agent.py`) on the Anthropic API or
  the OpenAI-compatible server, chosen under Settings β†’ Models. Tether's agent gets exactly the limits Claude Code's
  tool permissions give: the hub's tools for that kind of run through the run's own token, `discord_read`, the
  guides and playbooks, and web pages (only the project's sites for release checks, never an address on your own
  network, no redirect to another host). It has no shell, files or web search, and every other tool call is refused
  and logged. Runs stop at their turn limit, timeout and a token cap, and on the Anthropic API at $5 a run, counted in
  the key's monthly cap. With it chosen, Claude Code isn't installed and nothing waits on a Claude login or limit.
- **Sources, playbooks and notes.** Three kinds of knowledge, kept apart:
  - **Sources** are what Tether can reach and how: hosts over SSH, Komodo, and any HTTP API with a URL and a key
    (Pangolin, Proxmox, Home Assistant...). Each kind brings its own guide for the agent (`app/hub/modules/<module>/guide.md`), included only
    when it's connected, so nothing else names a specific tool. The agent also gets which kinds aren't connected, so
    it can say what would help.
  - **Playbooks** say what to do in a situation, in terms of the job ("find where it runs"), preferring a resource
    without depending on it. `needs: komodo` (or `ssh`, `http`) limits one to labs with that resource.
  - **Lab notes** are facts about your setup.
  When the agent learns something on the way it proposes it for the right place, with its `propose` tool: a lab
  fact becomes a note, and know-how about a source ("a Pangolin resource's `sso` field says whether it needs a
  login") a tip, which goes into that source's skill. Playbooks it writes into its answer. Each waits for you on the web
  page's **Waiting for you** list (Memory tab), with a link back to its thread and, folded under it, the alert or
  post the agent was reading when it proposed it, so you can see whether the alert talked it into something; nothing
  is saved until you keep it.
- **When the agent is stuck** it tries another way first (another resource, another call), and it checks whatever a
  tool can reach before asking you to. If it still can't see something that matters, it proposes a blocker
  (saying which calls it tried), the card gets a **Blocked** line, and the Waiting list says what would get past it: a
  resource to connect, a host to add, or a question for you. Tool calls Claude Code refused are listed there too.
  Fix it and πŸ” the alert. For a release post's lab check, πŸ”“ on the post reruns it with the look tools triage has (web pages,
  network checks), which those checks don't get by default because the post came from outside your lab.
- **Playbooks.** Each kind of alert can have a playbook: what to check, in what order, and what the verdict means.
  Tether picks it by the alert's sender and text before the agent starts, and names it at the bottom of the triage.
  Built-in ones cover Komodo's alerts (updates, auto-updates, state changes, failed jobs, server health), uptime
  monitors, disk and array health, and certificates. Edit them or add your own on the
  **Playbooks** tab, or ask the agent in a thread ("make a playbook for alerts like this"); the playbook waits on the
  Waiting list for you to save. `!playbooks` lists them.
- **Memory.** Every run sees your lab notes and the recent history of the same stack or service. The agent can
  propose a note; it's saved only if you keep it on the Waiting list. `!remember <fact>`, `!notes` and
  `!forget <N>` in a thread manage notes directly, and the web page's Memory tab has an editor.
- **Threads hold only the answers.** What a run leaves for you (where it got stuck, tips, notes, playbooks) goes
  to the Waiting list, and the thread's last card gets one line linking there ("πŸ—‚ 1 blocker and 2 tips waiting for
  you"). Once a day, when something new is waiting, one message in the alerts channel says so; it replaces the
  last one, and goes once nothing is waiting.
- **Slim by default.** Triage cards stay short and replies lead with the answer; reply "details" for the full
  breakdown. Change how the agent writes on the Memory tab's **Response style**, or with `!style <instructions>` in a
  thread (`!style reset` goes back to slim). The card's layout stays the same whatever the style.
- **Pausing.** Testing something noisy? Pause triage from the web page for 30 minutes, 2 hours or until you
  resume, and choose whether alerts posted in the meantime get triaged.

### Upgrade plans on GitHub

Connect a **private** GitHub repository under **Settings β†’ Handoffs** and every upgrade plan (from a release post or an
update alert) is also filed there as an issue: the plan in full, with the lab check folded under it and the
sources it read. The Discord card shrinks to the verdict, the downtime and a link. There's one issue per project or
stack: a newer plan rewrites the open issue and comments that it changed, so 1.25 doesn't open a second Pangolin
issue. Your βœ… on the post, or a successful πŸš€ deploy, closes it, and so does closing it on GitHub. Tether's labels
are `tether`, `upgrade`, `security` and `hold`; labels you add stay. You can connect several repositories;
**Settings β†’ Handoffs β†’ Plans** picks the one plans go to, or keeps them in Discord. A plan is always closed in the
repository it was filed in.

The token only needs **Issues: Read and write** on that one repository (a fine-grained token). Tether files the
issues from its own code; the agent never sees the token and has no GitHub tool. Public repositories are refused,
since plans describe what's exposed and how the lab is laid out.

### Letting new versions cook

A plan that's ready to follow gets a **Recheck** line with a date: after the cook time set on the Settings page
(3 days by default, 0 turns it off), Tether comes back to it. By then early adopters have run into any regressions
and a follow-up patch may be out. The recheck posts a card in the same thread and pings you:

- **Still good**: follow the plan as written.
- **Updated**: move to the newer patch instead (only when the agent read a page that backs it), with the steps that
  changed.
- **Hold**: don't upgrade yet, and why. It comes back once more after another cook time, then leaves it to you.

Rechecks use the same tools and limits as the plan they recheck (a release post's stays offline), and a filed
GitHub issue gets the result as a comment, with `hold` added or removed to match. Urgent security patches are
never held back: their card says to patch now, and the recheck looks for follow-up fixes (an incomplete fix, a new
advisory, a regression the patch caused). Your βœ…, a πŸ”, or a πŸš€ deploy cancels a waiting recheck, and the Settings page lists and cancels
them too.

## Deploy

You need Docker Compose and a Discord server with an alerts channel. The image is on GitHub's registry
(`ghcr.io/sloraris/tether`) for amd64 and arm64, so there's nothing to build.

1. **Start.** Copy `compose.yaml` (and `manage.sh`, if you like) to a folder on the host, then `./manage.sh up`
   (or `docker compose up -d`). No `.env` is needed; see [Settings](#settings) for the few values you can set there
   instead of on the page. On first start Tether installs Claude Code (Anthropic's CLI, under
   [Anthropic's terms](https://www.anthropic.com/legal/commercial-terms)) from npm into a volume; it isn't in the
   image.
2. **First sign-in.** Open `https://<host>:8443` (see [Without a reverse proxy](#without-a-reverse-proxy)), or the
   address your proxy serves it at (see [Behind a reverse proxy](#behind-a-reverse-proxy)). It asks for a setup code, which only someone with the host's logs can read:

   ```bash
   sudo docker logs tether-web-1 2>&1 | grep "setup code"
   ```

   Enter it and choose the admin password. (With `ADMIN_PASSWORD` in `.env`, you sign in with that instead.)
3. **Setup.** The **Setup** page lists what Tether needs, in order, and checks each step as you finish it:
   - **Discord bot**: create a bot in the Developer Portal, turn on **Message Content Intent** (Bot β†’ Privileged
     Gateway Intents) and paste its token.
   - **Alerts channel**: Setup gives you an invite link with exactly the permissions Tether uses (View Channel,
     Read Message History, Send Messages, Add Reactions, Create Public Threads, Send Messages in Threads, Embed
     Links and Manage Messages), then lets you pick the channel from the ones the bot can see.
   - **You**: post the short code Setup shows in that channel from your own account, and press **Check**.
   - **Triage agent**: **Log in with Claude** for Claude Code, or choose the Anthropic API or an OpenAI-compatible
     server under Settings β†’ Models and set it up there.
   - **Backup recovery key**: copy it to your password manager (see [Backups](#backups)).

   Then the recommended ones, which you can skip and come back to: a first source, more ways for alerts to come
   in, and a backup webhook. A banner on every page says how many required steps are left.
4. **Already have a `.env`?** Values there count as done and win over the page, so an existing install only sees
   what's new (the recovery key).
5. **Connect your lab.** On the **Sources** page:
   - **Host over SSH**, for each host the agent should reach: give it a name and its address (and its SSH port, if it isn't 22), choose what it gets,
     run the command shown on the host as root, click **Check connection**, then **Add host**. The agent gets the list
     and uses the names. The command adds a `tether` user with no password and no sudo, in the `systemd-journal` and
     `adm` groups (the journal and /var/log), and Tether's key with `restrict` (commands only: no port forwarding,
     no terminal). Two switches add more, both off by default:
     - **Docker access**: the `docker` group, for things you ask for in a thread like restarting a container. It's
       root by another name, so leave it off where Komodo already shows the containers.
     - **SMART readings**: a sudoers rule for `smartctl -a/-x/-H/-i/-A` on whole disks and `smartctl --scan`, and
       nothing else (no self-tests, no settings). Needs smartmontools and sudo 1.9.10 or newer.

     After the first check, the command also locks the key to the address the host saw Tether connect from
     (`from="..."`): run it again and check again. Turn **Lock to Tether's address** off for a host that sees Tether
     at an address that changes, like a VPS reached over a home connection's public IP. Running it again is also how a changed switch is applied, and the
     check says when a host has more or less than you chose. Removing a host gives you the command that takes
     Tether off it: the `tether` user, its home folder with Tether's key, and the SMART sudo rule.
   - **Komodo**: its URL and an API key and secret, checked the same way before they're saved.
   - **Any HTTP API**: a name, its address, the key header and value (Tether adds them; the agent never sees the key),
     a test path, and a description of what it's for, which is what the agent reads to know when to use it. Give it a
     key that can only read: the agent only sends GET requests, only to that address, and never follows redirects.
6. **Tell it about your lab.** On the **Memory** tab, note anything worth knowing: what runs where, what depends
   on what, versions you've pinned on purpose.
7. **More channels (optional).** On **Signals β†’ Add channel**, name it, choose the channel from the list (the text
   channels the bot can read, by server; *read only* marks one where it can't post or start threads) and say what
   Tether does with it: Triage, or Release notes. Tether checks it can read and post there first; the bot needs the
   same permissions as on the main channel. If Discord can't be reached, paste the channel's ID instead (Developer
   Mode, then right-click β†’ Copy Channel ID).

To run it as a Komodo stack, point the stack at this repository (or paste `compose.yaml` into it) and put your
settings in the stack's environment, `COMPOSE_FILE` and `TETHER_HOST` included if you use Traefik. Komodo pulls the
image when it deploys; its update checks see new versions of the tag you chose.

### Containers and data

The stack is three containers from the one image:

| Container | Runs | Holds | Reachable from |
|---|---|---|---|
| `tether` (the hub) | `/mcp`, Claude's lab tools, connection checks, netwatch, backups | every lab key, the SSH key, triage's secrets, notes, guides, tokens, the audit log (`data` volume) | triage and web, on the stack's own network; `/mcp` for your Claude Code machines, through Traefik |
| `triage` | Discord, the three workers, the agent's runs | its secrets in memory only, copied from the hub | nothing: it only calls out |
| `web` | the page, sign-in, webhook intake | its password (`web` volume) | your reverse proxy |

Triage's state (settings, incidents, reports, the event log) is in the `shared` volume, which all three use; no
secret goes in it. Each piece calls the hub with a token the hub makes when it starts and hands over in memory, and
**Settings β†’ Data** shows that only the hub holds lab keys. The hub starts as root for a moment to set up the
folders (and, the first time after an upgrade from one container, to move triage's files out of its own), then drops
to Tether's user before running anything.

### Backups

Every night the hub writes an encrypted snapshot of what Tether can't rebuild (its data and triage's state, not
caches) to `/opt/appdata/tether` on the host, keeping the newest 14, for your own backup to collect. Set
`TETHER_BACKUP_DIR` in `.env` to put them elsewhere. They're encrypted with [age](https://age-encryption.org) to a
key Tether makes on first start. Its private half is the **recovery key**: Setup asks you to copy it into your
password manager once, then it's shown only on the host:

```bash
sudo docker exec tether-tether-1 python3 -m hub.backup key
```

To restore one (after a fresh install, say), copy it into `/opt/appdata/tether`, then:

```bash
sudo docker exec -i tether-tether-1 python3 -m hub.backup restore tether-2026-11-03T030512.tar.gz.age < recovery-key.txt
sudo docker compose restart
```

### Versions and releases

Pick the image tag with `TETHER_TAG` in `.env`: `latest` (the default) is the newest release, `0.2` follows a minor
version's fixes, `0.2.1` stays put, and `edge` is every change on `main`. `./manage.sh up` pulls and restarts.

Releases are made by [release-please](https://github.com/googleapis/release-please). Commit messages follow
[Conventional Commits](https://www.conventionalcommits.org) (`feat:`, `fix:`, ...); release-please keeps a pull
request open with the next version and its CHANGELOG entry, and merging it tags the release and builds the
images. `CHANGELOG.md` lists what changed in each.

The version shows in the logs at start and on the web page (hover over the Tether logo). An image built with
`./manage.sh build` takes it from git (`v1.2.3`, or `v1.2.3-4-g1a2b3c4` four commits later).

## Settings

| Setting | Required | What it's for |
|---|---|---|
| `ALERT_CHANNEL_ID` | no | The main Discord channel your alerts are posted to; Tether's own warnings go here too. Otherwise picked on Setup |
| `OWNER_ID` | no | Your Discord user ID (several, comma-separated). Only you get answers in threads, can deploy and can save notes. Otherwise claimed on Setup |
| `ADMIN_PASSWORD` | no | Password for the web page. Otherwise chosen at the first sign-in |
| `KOMODO_URL` | no | Your Komodo address, if you'd rather set Komodo here than on the Sources page |
| `KOMODO_API_KEY` | no | A Komodo API key |
| `KOMODO_API_SECRET` | no | Its secret |
| `TETHER_TAG` | no | The image version: `latest` (default), `0.2`, `0.2.1` or `edge` |
| `COMPOSE_FILE` | no | Add-ons: `compose.yaml:compose.traefik.yaml` for Traefik |
| `TETHER_HOST` | with Traefik | The host name Traefik serves the page and `/mcp` at |
| `WEB_TLS_NAMES` | no | The names and addresses the page's self-signed certificate covers, besides localhost (e.g. `tether.lan 192.0.2.10`) |
| `TETHER_MODULES` | no | Your own modules and themes, mounted at `/modules` (default `./modules`) |
| `TETHER_BACKUP_DIR` | no | Where the nightly encrypted snapshots go on the host (default `/opt/appdata/tether`) |

Everything else is set on the web page: more channels and the ignore rules on **Signals**; your hosts, Komodo and
APIs on **Sources**; and on **Settings** the Discord bot token, the triage agent and its login or key, an optional backup webhook for
warnings if the bot token stops working, the bot's Discord status, Handoffs, Actions and Notifiers, the model for each
job and the admin password. The `KOMODO_*` settings show on Sources as a Komodo connection named Komodo; saving it there takes
their place, and you can add more Komodos beside it.

**Handoffs** and **Actions** in Settings pick the connection for each of Tether's own jobs: **Plans** (a Handoff),
the *issue tracker* plans are filed in (a GitHub repository), and **Updates** (an Action), the *stack manager* a πŸš€
on an update alert deploys through (Komodo). Only connections whose module has that role are offered, and each job
can be Off. Updates defaults to **Whichever runs the
stack**; Plans, until you choose, go to the first repository added. Other stack managers (Dockhand, Arcane) and issue trackers (GitLab, Forgejo, a
ticketing tool) slot in as an adapter in `app/hub/deploys.py` or `app/hub/proposals.py` plus a module; see
`app/hub/modules/README.md`.

**Webhooks** on the Signals page are for alerts you want handled without a conversation. **Add signal β†’ Webhook**
gives one a name and an address, `https://<tether>/hook/<name>/<secret>`, that Uptime Kuma, Alertmanager, a script
or anything else can POST to (JSON or text, up to 64 KB, 30 a minute). Treat the address like a password: the secret
in it is the only key, and **New address** in its drawer replaces it. Behind a proxy with a login (Pangolin's SSO),
let `/hook/` through without one. Each message is:

1. checked against the ignore rules; a recovery (Uptime Kuma's "up", Alertmanager's "resolved", the built-in
   recovery rule) closes the issue its problem opened, and isn't triaged;
2. screened for text aimed at an AI, and not triaged if it is (said in the activity log and to your notifiers);
3. triaged with the same tools and playbooks as a Discord alert (a playbook's `channel:` or `from:` can name the
   webhook), and kept as a report page on Tether;
4. filed as an issue where plans go (**Settings β†’ Handoffs β†’ Plans**) when it needs you (a red or amber verdict),
   one per webhook and subject, so a repeat updates the same issue;
5. told to your notifiers as the webhook's **Notify me** says: *When it needs me* (and the all-clear after),
   *Every alert*, or *Off*. A notification links to the issue, or to the report page.

Nothing goes to Discord and there's no πŸš€: it's unattended. Webhooks wait like everything else while triage is paused,
The agent is down or a usage limit holds. Tether can run on webhooks alone, with no Discord at all.

**Notifiers** in Settings are where Tether tells you about itself: Claude's login rejected or the agent unreachable, a
usage limit, Discord rejecting the bot, and the all-clear after each. Each is an [Apprise](https://github.com/caronc/apprise/wiki)
URL, so ntfy, Pushover, Gotify, Telegram, email, Slack, Matrix and about 100 more work the same way
(`ntfys://ntfy.sh/your-topic`, `pover://user_key@app_token`, `mailtos://user:password@fastmail.com`). A notifier can
have several endpoints (**Add another endpoint**), which all get the same message. URLs are shown as you type them and
never again once saved: the drawer then lists each as its service and host, with a switch to remove it. Each is
checked by sending a test before it's saved. Each has two settings:

- **What it gets**: *Every Tether warning*, which also go to the main channel (and what webhook alerts came to, when
  a webhook asks), or *Only when Discord is down*, for the one warning the main channel can't carry. Every notifier
  gets that one.
- **Detail**: *Full* sends the warning as written; *Brief* sends what happened and a link to Tether, so error text
  stays off a service you don't run. On ntfy.sh anyone who knows a topic's name can read it, so Brief is the default.

### Behind a reverse proxy

`compose.traefik.yaml` puts the page behind Traefik at your host name, and routes the hub's `/mcp` at the same
address for your Claude Code machines, from private addresses only. In `.env`:

```bash
COMPOSE_FILE=compose.yaml:compose.traefik.yaml
TETHER_HOST=tether.example.com
```

Traefik needs a Docker network named `traefik` (`TETHER_TRAEFIK_NETWORK` for another) and a certificate for the
host. `TETHER_MCP_FROM` narrows who may reach `/mcp` (comma-separated ranges). For another proxy, point it at the
web container's port 8099 and leave `compose.traefik.yaml` out. No other setting is needed: Tether trusts
forwarded headers from private and loopback addresses, which is where a proxy on the same host or Docker network
connects from, and marks its login cookie HTTPS-only whenever the page is reached over HTTPS. Don't publish the
port directly on an untrusted network.

### Without a reverse proxy

Without `compose.traefik.yaml`, the web container serves the page over HTTPS on port 8443, with a self-signed
certificate it makes the first time (in the `web` volume, renewed before it expires). Open `https://<host>:8443`. Your browser warns about the certificate until you trust it; **Settings β†’ Access β†’ HTTPS**
shows its fingerprint to check the warning against.

Passkeys need more than HTTPS: browsers refuse them on a page with a certificate warning, and never keep one for a
bare address. To use them, open the page by a name (a DNS or hosts-file entry like `tether.lan`), put that name in
`WEB_TLS_NAMES`, restart, and trust the certificate on each device. Or use a certificate of your own: put `cert.pem`
and `key.pem` in the web volume's `tls` folder and Tether uses them as they are.

## Day to day

```
./manage.sh status          # health, heartbeats, pause state, recent events
./manage.sh logs
./manage.sh pause [triage|deploys|all] [minutes]
./manage.sh resume [triage|deploys|all] [--skip]
./manage.sh dry-run         # what would be triaged, without posting
./manage.sh triage-now      # one pass now
./manage.sh set-password    # if you've forgotten the admin password
./manage.sh agent laptop    # connect your coding agent on a machine called laptop
```

### Connect an agent

Your own coding agent can use the lab through Tether's MCP server: its hosts, connections, notes and guides, with
Tether adding the keys. On **Settings β†’ Tokens**, choose **Connect an agent**, name the machine, enter your page
password and confirm with a passkey. The page shows, once, how to add it: a `claude mcp add` line for Claude Code, a
`codex mcp add` line (with the token in `TETHER_TOKEN`) for Codex, and an `mcpServers` block for other clients. On
the host, `./manage.sh agent <machine>` makes one too.

**Passkeys** are on **Settings β†’ Access**: your phone, a password manager or a security key. They work at the page's
https address. The hub issues every passkey challenge and checks every answer, so once you have a passkey, your page
password alone (or someone who got into the page) can't make a token that runs commands, or add a passkey of their
own. The first passkey needs a one-time code the page prints to its log (`docker logs tether-web-1 2>&1 | grep
passkey`, or the container's log in Komodo); right after the first sign-in, which already needed a code, it doesn't.
Each further passkey needs one you already have.

The token can read the lab, run commands on hosts (`host_run`, as the `tether` user) and change the sources you
allow, and it works only from the address it's first used from. **Settings β†’ Tokens** lists every token with where
it's locked to and when it was last used, and revokes or unlocks it; it also makes read-only tokens, for a script or
a dashboard, without a passkey.

Everything starts read-only. To let the agent change a source, turn on **Allow changes** on its card on Sources: HTTP
APIs then take `http_request` (any method), Komodo takes `komodo_write` (deploy, restart, pull, edit a stack) and
GitHub takes `github_write` (branches, files, PRs). Its access check then counts what the key can write as allowed
rather than more than needed, and says so when the key can't write yet. On a host, **Admin commands** gives the
`tether` user full sudo (re-run its setup command after choosing it). Thread replies get the same tools; triage's own
runs never do. Every change is in the audit log.

A session that connects gets your **Conventions** from the Memory page (where code lives, branch rules, what never
to touch), and can read your lab notes, the module guides and the playbooks as MCP resources. What it proposes
(`propose`) lands on **Waiting**, marked with the machine it came from.

`/mcp` goes through Traefik at the page's address (`https://<your Tether host>/mcp`), from private addresses only
(the `default-whitelist` middleware in Traefik's config). The hub believes the caller's address that Traefik passes on
and no one else's, so a token's lock still holds. Set `HUB_PUBLIC_URL` in `.env` if your machines reach Tether at a
different address.

The web page is laid out by role:

| Role | What it is | On the page |
|---|---|---|
| **Signals** | How alerts reach Tether to be triaged | **Signals**: the Discord channels it watches (one connection each), webhooks, and the ignore rules |
| **Sources** | What the agent queries to see how things are actually running | **Sources**: hosts over SSH, Komodo, HTTP APIs |
| **References** | How things are meant to be set up | **Playbooks**, **Memory** (with **Waiting** and **Releases**) |
| **Handoffs** | Where Tether puts work that's ready for you | **Settings β†’ Handoffs**: an issue tracker for plans (GitHub) |
| **Actions** | What Tether does itself once you approve | **Settings β†’ Actions**: a stack manager for πŸš€ deploys (Komodo) |
| **Notifiers** | Where Tether tells you about itself | **Settings β†’ Notifiers**: any Apprise URL |

A module says which roles its connections play (`app/hub/modules/README.md`); Komodo is both a Source and an Action.
The Tether logo in the top bar is the triage agent's status: a signal runs along the line while
it works, and the line breaks with a red end when it doesn't (with a warning at the top of every page).
**Status** shows recent activity beside triage and deploys with their pause buttons. It stays quiet while things
work: a badge appears only when something is paused or off, and a warning only when part of Tether is failing or
has stopped checking in. **Signals** lists the Discord channels Tether watches, each with its name, what Tether does
with it (Triage or Release notes) and whether it could read it on its last pass, and the ignore rules. The main
channel is the one from Setup (or `ALERT_CHANNEL_ID`): it can't be changed or removed there, and Tether's own warnings go to it. **Memory** has your notes, the response style and the history per
subject; **Waiting** has the agent's proposals for you and **Releases** the notes from your release channels.
**Playbooks** lists every playbook beside an editor for the one you pick, lets you turn one off or go back to the
built-in version, and checks which playbooks (or ignore rule) a pasted alert would get. **Sources** is what Tether
can reach: one card per connection (your hosts over SSH, Komodo, HTTP APIs), each saying what the agent can do with it,
when it was last reached and the tips the agent learned about it, and a catalog to add more. Every connection is checked before it's added. **Settings** has what
Tether itself runs on (the Discord bot, the agent), Handoffs and Actions, the model for each job, and the web
password. Each is one quiet line while it works; one
that's missing or rejected is marked and shows its fix. Saved tokens and keys are never shown again.

Your playbooks and edits live in the data volume (`/data/playbooks`), so they survive rebuilds; built-in ones
update with Tether unless you've edited them.

To bring in what the Claude app knows about your setup, ask it *"Write out your memories of me verbatim"* and paste
the relevant parts into your notes. There's no automatic sync.

## When something breaks

Tether is built to run unattended and never loses an alert: when it can't work, alerts wait.

| Problem | What Tether does | What you do |
|---|---|---|
| Agent's token or key rejected | Alerts and replies wait; warns in the channel and through your notifiers right away, then every 12 h | Log in again, or replace the key, on the Settings tab |
| Claude usage limit reached (5-hour or weekly) | With a **Claude API backup** key: runs on the key until the limit resets, says so once in the channel, and goes back to the subscription after. Without one, or once the key's monthly cap is reached: holds alerts and replies instead of failing them: one notice in the channel with the reset time, ⏸️ and a line in each waiting thread, the bot shows Idle and the web page's header says so. Picks everything up when the limit resets, and says so | Nothing |
| Agent unreachable | Alerts wait instead of using up retries; rechecks every 15 min; warns after 6 h, and again when it's back | Usually nothing |
| Quiet channel | Tests the agent once a day, so a dead token is caught before the next alert | |
| Discord token rejected | Warns through every notifier and the backup webhook, if set, and on the Status page; says so again when it works | Paste a new token |
| Live connection drops | Reconnects by itself; meanwhile it checks every minute, so nothing is missed (the Triage card says why) | Usually nothing |
| An added channel can't be read | Skips it and carries on; the Signals page marks it and shows Discord's error | Give the bot access to that channel |
| Host refuses SSH | The agent says so in the thread; the Sources page marks the host and says why | Run the command from the Sources page's **Host over SSH** on that host, then **Retry** |
| A pass hangs | Exits after 90 minutes; Docker restarts it | |

Subscription tokens from `claude setup-token` last a year, and the Settings tab warns after 330 days. An
Anthropic API key doesn't expire.

**Claude API backup** (Settings β†’ Services) is an API key from console.anthropic.com that stands in for the
subscription: during a usage limit, and, if you choose, while the subscription's login fails. It's paid by use, so it
has a monthly cap in US dollars: the Settings row shows this month's cost, and past the cap Tether tells your notifiers
and waits for the subscription as it would without a key (a run can go over by its own cost, known only when it
ends). A run the key fails rests it for 30 minutes. The daily check of the subscription never uses the key, so a
broken login is still found and told.

## Security

- Triage can only look. The read-only check runs before a command reaches the host, and the run's token keeps the
  "act" tools out of triage runs altogether: the hub doesn't list or allow them, under Claude Code's tool permissions
  or Tether's own agent alike (`tests/test_agent.py`).
- Replies can change things, so a reply is only taken from the owner (claimed on Setup, or `OWNER_ID`). The agent is told to act only on what your
  latest message asks, never on alert text. Alert text still ends up in what it reads, so be specific in
  replies that act.
- Replies reach the lab through the lab hub's tool server (`app/hub/server.py`: MCP over HTTP at
  `/mcp`, which your Claude Code machines reach through Traefik with their own tokens): `host_read`, `network_check`, `netwatch`, `komodo_read`,
  `http_get`, `advisory_lookup` and `tether_sources` (your hosts and connections, no secrets). `discord_read` (any
  message link the bot can see, screened like a linked post) comes from triage's own small tool server
  (`app/triage/tools.py`), since Discord is triage's. Each tool runs the same script as before with its arguments as a list, so the same checks and secret
  removal apply. A reply's run names every tool it has (`--tools`, `--strict-mcp-config`): no file tools, no
  subagents, no other MCP servers, and no shell. Every job (triage, release checks, rechecks, plans, replies)
  runs this way; only a reply gets `host_run`, which changes things on a host through `act.py`, and the write
  tools (`http_request`, `komodo_write`, `github_write`) on connections you set to allow changes. Text from outside the lab (release posts, advisories) gets the
  `outside` tools: no `network_check` or `discord_read`, and WebFetch only on the sites it was given.
- Every call to the hub carries a token, and the token's scopes decide what it may do (`app/hub/rpc.py`): `read`,
  `outside`, `propose`, `run`, `act`, `admin`, `mint`. Each agent run gets its own token, minted for that run and
  gone when it ends (45 minutes at most): triage and release checks get `read` or `outside` plus `propose`, a reply
  also gets `run`. Every tool call, and every call that changes something, goes in `audit.jsonl` in the data
  folder with the token that made it.
- The agent's know-how comes as skills (`app/hub/skills.py`), built in a temporary folder for that run and removed
  after: one per connected module (its guide, its hosts or connections with your notes, and its approved tips)
  and one per enabled playbook (`playbook-<name>`). The agent sees each skill's one-line description and loads the
  full text when it picks one; the prompt itself carries only a one-line-per-source roster, your reference
  playbooks in full, and the names of the playbooks that match the alert. A module with nothing connected has
  no skill. Every run gets them this way, not only replies. You can edit any module's guide under **Playbooks β†’
  Guides**; your copy is kept in the data folder (`guides/<module>.md`) and wins over the built-in one until you
  reset it.
- The `tether` user on your hosts has docker access, which is effectively root. Give it sudo only if you want
  the agent to be able to use it.
- The agent has no file tools inside Tether, so it can't read Tether's own keys or settings. The container runs
  read-only, with no Linux capabilities.
- Release-channel posts come from outside your lab. The agent reads them with no tools at all (`--tools ""` and
  every tool denied) and none of your notes or hosts, so a post that fools it can at worst write a misleading
  release note, which research double-checks. Threads on those posts are never answered.
- The lab impact check and patch plan for those posts never see the post, only the note made from it, and they can
  only look: the hosts on the Sources page, by name (no other addresses, no `local`), Komodo reads, your HTTP
  APIs (GET, to their own address), web search, and web pages on github.com and the project's own site only. That
  site is taken by Tether's code from the links in the post (at most two; chat, social and link-shortener sites, IP
  addresses, local names and your own domains never count), and Claude Code refuses pages anywhere else. look.py's
  network commands (curl, dig, ping...) are off for them, reads inside Tether are kept to Tether's code folder, and
  anything else is refused. The trade-off: a fake or hijacked announcement can name a site its author runs, and a
  check that was fooled could then put something about your lab in a page address there. That's the one way out;
  GitHub doesn't show anyone the addresses requested from it. A poisoned post can still mislead a check, but can't
  change anything. Your πŸ”“ on the post lifts the network limits for one rerun (it still can't change anything); use
  it when the post is one you trust.
- Secrets are taken out of everything the agent reads (redact.py). Every tool the agent can run prints through it, as
  does the prompt itself, so a key in a command's output, a compose file, `docker inspect`, an API's reply or an
  error never reaches the model. Each secret Tether holds is matched exactly (its tokens, every connection's
  keys, its environment); others are recognised by name (`*PASSWORD*=`, `"apiKey": ...`, `token:`) and shape
  (private keys, Authorization headers, passwords in URLs, GitHub, GitLab, Slack, Anthropic, OpenAI, AWS and
  Discord tokens, JWTs). Flags, numbers, file paths and `${VARIABLES}` are kept, so triage can still see whether
  something is set. A removed value shows as `[redacted]` and the output says how many were. It can't catch a
  secret with an ordinary name and no known shape (`DB=hunter2`), or one that's encoded.
- **Aliases for private names** (Settings β†’ Privacy, off by default) swap your own names for stand-ins before
  anything reaches a model, Claude or a self-hosted one: your domains become `site-1.example` (from your hosts,
  this page's address, and Sources whose name resolves to a private address), each private /24 moves to
  `198.18.x.0/24` keeping the last number, host names become `host-1` if you choose, and any names you list become
  `name-1`. Tools get the real names back before they run and print aliases again; answers, cards, notes and the
  progress line in Discord show the real names. Aliases never change once given, and the section shows each
  one. While it's on, WebFetch can't open your own sites (the alias doesn't resolve), and names inside encoded
  text, or public addresses, aren't hidden.
- `advisory.py` sends only an advisory ID or a package name and version, only to OSV.dev, GitHub's API and CISA,
  so it's allowed even in the checks that start from a release post. What it returns is written by whoever filed
  the advisory, and is marked as data for the agent like an alert.
- HTTP API keys stay in `/data/auth`; http_read.py adds them to the request, never prints them, and refuses
  redirects, so a key can't be carried to another host.
- The GitHub token (upgrade plans as issues) is used by Tether's code only, needs Issues access to one repository,
  and can't touch code. Plans are written from release notes, which come from outside; read a plan before following
  it, as you would in Discord.
- The web page allows no scripts, rate-limits logins and checks the origin of every form.

## Development

```bash
python3 -m unittest discover -s tests       # the real code against an in-memory Discord and a live web server
python3 tools/dev_server.py                 # the web page with demo data at http://127.0.0.1:8099
./manage.sh build                           # build the image from this checkout and run it
./manage.sh dev                             # a dev stack (compose.dev.yaml): restart it to run this folder's code
```

`CLAUDE.md` has the rules for changing this repository: the checks, commit messages, and keeping real lab details
out of it.

To add a kind of connection, copy a template from `tools/module-templates/` (one per kind: a plain HTTP API,
one with an access check, one with probe code, one made of parts, and an engine with its own tool).

The code is in four packages under `app/`, the first step of splitting Tether into a lab hub and its clients:

- `hub/`: every connection, key and tool, the server the agent's runs and the other packages call (tokens, scopes, the
  audit log), the knowledge (modules, playbooks, memory) and backups.
- `triage/`: alerts, Discord, the agent runs and their prompts.
- `web/`: the page.
- `shared/`: the event log, the page's questions to triage (`asks.py`), and the stack's setup (`split.py`).

Triage and the web page use hub code only through `hub/client.py`, which never hands back a saved key, and
`tests/test_boundary.py` fails if anything goes around it. `runner.py hub|triage|web` starts one piece (compose.yaml
runs each in its own container); with no argument it starts all three in one process, the hub on 127.0.0.1, and
connects the client to it, so every call goes over HTTP with Tether's own token and the hub checks it (`hub/rpc.py` lists what
each call needs). In the tests the client calls the hub in-process; `TETHER_TEST_HUB=http` runs the suite over HTTP
instead, as Tether runs. Run a script as a module from `app/`: `python3 -m hub.look nas df -h`.

Tokens you keep (for a coding agent of your own) are made on the page with your password and a passkey, or on the host (`./manage.sh agent <machine>`,
or by hand below), never shown again, stored hashed in `auth/tokens.json`, and locked to the address each is first
used from:

```bash
sudo docker exec tether-tether-1 python3 -m hub.tokens create laptop read
sudo docker exec tether-tether-1 python3 -m hub.tokens unlock <id>
sudo docker exec tether-tether-1 python3 -m hub.tokens list
sudo docker exec tether-tether-1 python3 -m hub.tokens revoke <id>
```

On the Tether host itself, a session can also reach the hub through the container (read-only with a `read` token):

```bash
claude mcp add tether -e TETHER_TOKEN=tht_... -- sudo docker exec -i -e TETHER_TOKEN tether-tether-1 python3 -m hub.bridge
```

Tether is standard-library Python except for the live connection, which needs discord.py (`requirements.txt`,
installed in the image). Without it, everything works and Tether checks every minute instead.

The image runs Python 3.11 (Debian's), so run the tests with 3.11 before pushing: newer versions accept code 3.11
rejects, such as a `'` inside a `{}` in an f'...' string. `uv python install 3.11` gets one, then
`$(uv python find 3.11) -m unittest discover -s tests`.

The page's look is a theme module (`app/web/themes/tether/`): colour tokens for dark and light, on top of the
components in `app/web/static/components.css`, which use only those tokens. Themes of your own go in your modules
folder; `tools/module-templates/example-theme` is a copy to start from, and `python3 tools/page_lint.py page.html`
checks a saved page against the component rules. The logo sources are in `design/logo/`.

The image doesn't ship Claude Code. The Dockerfile pins its version (`CLAUDE_CODE_VERSION`), and
`app/shared/claude_cli.py` installs that version from npm into the `claude-code` volume when triage or the page
first starts, then removes older versions. A `claude` already on PATH, or `CLAUDE_BIN`, is used instead (the dev
server and tests). To upgrade, change the version, release, and use **Test** on the agent's line in the Settings tab.

## Known gaps

- The triage card format is new. If the agent's reply doesn't fit it, the card still shows the whole reply, just
  grey and without the labelled lines; adjust `app/triage/triage_prompt.md` if that happens often.
- The browser Claude login reads the CLI's output, so a CLI update could break it. Pasting a token from
  `claude setup-token` always works.
- Thread replies, act mode, memory proposals and most playbooks haven't been tried with real agent runs yet. Adjust
  the prompts in `app/triage/` if the agent acts too eagerly or proposes notes too often, and the playbooks on the
  Playbooks tab if one is matched wrongly or gives the agent bad steps.
- HTTP APIs, tips, Blocked lines and πŸ”“ haven't run with a real agent yet. If the agent guesses API paths instead of
  using the description, put the useful paths in the description.
- The Komodo guide's (`app/hub/modules/komodo/guide.md`) query names and parameters come from Komodo's API docs (`komodo_client`); only some
  have been used against a real instance.
- The Sources page's Komodo check (ListStacks, ListServers, GetVersion) and host check haven't been run against your real
  Komodo and hosts yet. A host's status only changes when you click **Test** or the agent connects to it; nothing
  checks hosts in the background.
- Hosts you listed in your lab notes before the Sources page existed aren't moved over; add them on the Sources page.
- The research check, the upgrade plan and the release-channel screening and summaries haven't been run with a real
  agent yet. Watch the first few: if good research keeps getting turned into NEEDS INFO (say, notes the agent read
  with `look.py local curl` that the check misses), or summaries come out empty, adjust `app/triage/research.py` or the
  prompts in `app/triage/`.
- The lab impact check and patch plan keep the agent's own reads inside its working folders (an empty one and
  Tether's code) with `--permission-prompts none`: any other read inside Tether needs approval, which nobody can
  give, so it's refused. Checked with Claude Code: `/data`, `~`, `/tmp` and `/proc` reads are refused, while
  `look.py <host> ls /var/log` runs. (`blockReadsOutsideWorkingDirectories` isn't used: it refused host paths too.)
- The no-tools flags for release posts (`--tools ""`, `--disallowedTools "*"`) come from Claude Code's CLI docs;
  the first release post after deploying is the real check. If the CLI rejects them, the post fails with the
  error in its thread rather than running with tools.

## Licence

Tether is licensed under the [GNU Affero General Public License v3.0](LICENSE). If you run a changed version for
other people over a network, you have to offer them its source.

Icons are from [Lucide](https://lucide.dev) (ISC licence, Β© Lucide Icons and Contributors), copied unchanged into
`app/hub/icons.py`. Claude Code isn't part of Tether: it's Anthropic's software, installed on first start under
[Anthropic's terms](https://www.anthropic.com/legal/commercial-terms).