Skip to main content
Glama

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.

Related MCP server: Phosphor

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 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) from npm into a volume; it isn't in the image.

  2. First sign-in. Open https://<host>:8443 (see Without a reverse proxy), or the address your proxy serves it at (see Behind a reverse proxy). It asks for a setup code, which only someone with the host's logs can read:

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

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

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:

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. Commit messages follow Conventional Commits (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 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:

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

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:

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

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. If you run a changed version for other people over a network, you have to offer them its source.

Icons are from Lucide (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.

Related MCP Connectors

Related MCP Servers

  • A
    license
    Not graded
    quality
    C
    maintenance
    An MCP server that acts as a boundary agent, giving LLMs and automation systems controlled SSH-based access to remote machines for tasks like administration, diagnostics, and security research.
    Apache 2.0
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables MCP clients such as Claude Code, Claude Desktop, and Cursor to list hosts, read metrics, inspect containers, follow logs, and, when permitted, run commands, restart containers, manage authorized_keys, and edit host lists under per-host policy, Touch ID-backed credentials, deny-lists, rate limits, and an audit log.
    MIT
  • A
    license
    Not graded
    quality
    A
    maintenance
    Enables AI coding agents and MCP clients to securely execute shell commands, manage files, and access remote devices or SSH hosts through centralized authentication, routing, and auditing.
    Apache 2.0
  • A
    license
    B
    quality
    C
    maintenance
    Enables MCP-compatible AI assistants to inspect and manage local or remote servers in plain language, covering system health, Docker containers, services, ports, disks, scoped files, and read-only database queries over key-only SSH. Write actions such as container restarts, file edits, and allowlisted dev commands require client approval.
    14
    MIT