tether — steer a long-running session from your inbox
Beta. The transport (e2a) and the flow are real; polish (adaptive
backoff, richer digests) is still open.
/tether keeps you connected to a working session over email. The agent emails
you updates when it judges there's something worth reporting (not on a
timer, not every turn), and checks your inbox on an interval so your replies —
questions or new instructions — are picked up within a few minutes. Reply
stop to end.
Transport primer. tether rides on e2a. The e2a operate-well manual —
the dependency-provided e2a skill — carries the
mental model this skill assumes: how threading really works (reply headers +
stable subject, not conversation_id), when a shared agents.e2a.dev
address is all you need vs. a custom domain, and how to handle send statuses.
Read the e2a skill by name if you're new to e2a; tether does not re-explain
those.
Architecture (why this shape)
Coding agents are turn-based; nothing native listens to an inbox mid-session. So
the two directions use different mechanisms:
- Send = agent-driven. The agent calls
tether.sh update "…" at meaningful
moments (finished a slice, hit a blocker, needs a decision). Cadence is the
model's judgment → no per-turn spam, nothing to throttle.
- Receive = real-time wait. The agent runs
tether.sh listen — it blocks
on the e2a CLI's WebSocket (e2a listen --once; no LLM tokens while waiting)
for the duration set at start --for, so replies are picked up within
seconds; if the WebSocket is unavailable it degrades automatically to
interval polling. It wakes the agent only when a reply actually arrives (or
the window ends). See Durability tiers for keeping it alive across
idle/sleep.
- Questions = ask by email. When the agent needs a decision or clarification
from you, it must not use a terminal prompt / AskUserQuestion — you're AFK
and can't see it, which would stall the whole session. It calls
tether.sh ask "<question>", which emails the question into the thread and
blocks until you reply, then prints your answer. This is the hard rule:
while tethered, every question goes over email, never the terminal.
- Blocked-alert = hook (optional). A
Notification hook emails you when the
agent is stuck on a permission prompt it can't proceed past. Note: an emailed
reply cannot answer a CLI permission prompt (there's no native way to inject
approval), so for unattended runs pre-authorize the tools the session needs
(a permission allowlist / a less-prompting mode). The hook is the safety net,
not the approval channel.
Setup (once)
Prerequisites: Node (the transport is the e2a CLI, currently the 2.x
line pinned as TETHER_MIN_CLI in lib.sh — resolved from $E2A_CLI, then
PATH, then fetched automatically via npx -y @e2a/cli@^MIN, so there is
nothing to install by hand) and Python 3 (local state handling only).
The range is major-bounded on purpose. tether parses the CLI's TSV columns
and branches on its exit codes, and a major bump is allowed to change both —
2.0.0 did. So a CLI newer than the pinned major is not adopted automatically
(tether falls back to npx and says so); someone re-verifies tether against the
new major and bumps TETHER_MIN_CLI. Failing inert beats failing silently in
an unattended session.
Fast path (recommended): tether.sh setup
tether.sh, install.sh, and tether.env.example all sit in this skill's own
directory, next to this SKILL.md. Substitute its absolute path for
$TETHER_DIR in every command below; the scripts resolve their own resources,
so any working directory works.
If the e2a CLI is logged in (e2a login in a browser, or a key persisted with
e2a config set api_key <key> on a headless box), one command does the whole
bootstrap:
"$TETHER_DIR/tether.sh" setup
It verifies the credential (e2a whoami), ensures a tether inbox (reusing an
existing tether-… agent or creating one on the shared domain — it will
not silently adopt a non-tether inbox), mints a least-privilege
agent-scoped key (e2a_agt_…), and writes ~/.e2a-tether.env (previous file
kept as .bak). Every step fails hard — it never stores the broad account key
and never reports "ready" with a half-working config. Flags: --email you@yourdomain to use/create a specific inbox, --new to force a fresh one.
Manual setup (fallback)
- Get an agent-scoped API key (
e2a_agt_… — least privilege, so a leaked
key can't touch the rest of the account): from the CLI with
e2a keys create --agent <inbox>, or from https://e2a.dev/api-keys.
No domain? Create the agent on the shared agents.e2a.dev domain
(e2a agents create name@agents.e2a.dev — live immediately, no DNS).
- Save the credentials:
cp "$TETHER_DIR/tether.env.example" ~/.e2a-tether.env
chmod 600 ~/.e2a-tether.env # fill E2A_API_KEY (e2a_agt_…) + E2A_AGENT_EMAIL
- (Optional) blocked-alert hook:
"$TETHER_DIR/install.sh" --to <repo-root>
Credentials resolve in order: explicit env vars → ~/.e2a-tether.env →
~/.e2a/config.json. Note that e2a login saves an account-scoped key and
does not set agent_email — so ~/.e2a/config.json alone is not enough for
tether, which needs a specific inbox. Either set both explicitly:
e2a keys create --agent <inbox> # least-privilege e2a_agt_… key
e2a config set agent_email <inbox> # or export E2A_AGENT_EMAIL
or put the agent key + inbox in ~/.e2a-tether.env and leave the CLI config alone.
Two ways a send can be accepted but never delivered — both fail loudly.
pending_review means the message was held for approval; a terminal failed
means the server persisted a delivery failure. tether.sh detects both on
every send: an affected intro makes start refuse to arm, and an affected
update/ask exits non-zero — held is 2 for update, 4 for ask;
terminal failure is 5 for both — so the session never mistakes either for
a delivered message. A terminal failure is
not retryable — the server already recorded an outcome for that message
id, so re-sending risks a duplicate. Inspect it with e2a messages get <id>.
First run (new user) — set them up, then tether
Before tethering, confirm the harness can actually send. Run "$T" status
(where T is defined below). If it reports config: MISSING, this is a
first-time user — help them get to a working setup instead of just failing:
- Is e2a connected at all? If they've never used e2a, hand them
e2a login — or, when e2a isn't installed globally, the npx form:
npx -y @e2a/cli login (same auto-fetch the harness itself uses). It opens
the browser sign-up/sign-in and saves an account-scoped key to
~/.e2a/config.json. (Headless box: they mint an account key in the
dashboard, persist it with e2a config set api_key <key>, then validate it
with e2a whoami.) Interactive sign-in is theirs to complete: hand them the
command, don't drive it.
- Run the bootstrap:
"$T" setup — it creates the inbox, mints the
agent-scoped key, and writes
~/.e2a-tether.env. See Setup above for what it refuses to do.
- Re-check.
"$T" status should now print config: OK (agent …). Proceed
to the runtime flow.
If status already prints config: OK, skip this — they're a returning user.
Don't put a configured user through onboarding.
Runtime flow (what the agent does when /tether is invoked)
Let T="$TETHER_DIR/tether.sh".
- Preflight. Run
"$T" status. If config: MISSING, do First run (new
user) above before continuing — don't call start and let it error out.
- Ask the user's email address and how long to stay tethered (e.g. 30m,
2h, 8h/overnight, or until they say stop). They're present at this step, so a
normal question is fine.
- Start:
"$T" start <email> --title "<work>" --for <duration> (or
--until <ISO>; omit both for until-stop) — sends the intro, opens the
thread, arms, records the window. --title is required (start refuses
without it): a short description of the work being done (e.g. "migrate loft → @e2a/ui", "fix webhook retries") — it becomes the thread's subject
line (Tether: <repo> — <title>), which is how the user tells this session
apart from others in their inbox. The subject is fixed at start (threading
needs it stable), so title the work, not the first step. --for takes a single
unit (30m, 2h, 8h, 1d); a compound
like 1h30m is rejected rather than silently treated as no-limit. If the intro
comes back pending_review, start refuses to arm because the intro was not
dispatched.
- Work, and send updates as you see fit — prefer HTML, it renders
far better in mail clients: write the HTML to a file and run
"$T" update --html <file> — a plain-text fallback is auto-derived (or pass
--text "<fallback>"). Plain "$T" update "<text>" is for quick one-liner
acks only. To send a file (a rendered PDF, a screenshot, a small log), add
--attach <file> — repeatable, on either form, capped at 15 MB total per
send (exit 3 = file not found, exit 4 = over the cap; past the cap,
upload the file somewhere and send a link instead). Good moments: finished a
slice, made a decision that's worth surfacing, hit a blocker, or before a
long unattended stretch. Skip trivial
turns. If update reports pending_review (exit 2), the update did
not reach the user — stop and fix the inbox configuration before
continuing. Exit 5 means the send reached a terminal failed outcome:
also undelivered, but do not re-send it (the server already recorded that
message id) — inspect it with e2a messages get <id> first.
- Need a decision from the user? Ask by email — never the terminal. Run
"$T" ask "<question>" (in the background); it emails the question and blocks
until the user replies, then prints the answer. --attach <file> works here
too — attach the artifact the decision hinges on (a diff, a mockup) rather
than describing it. Do not use AskUserQuestion
or a bare terminal prompt while tethered — an AFK user can't answer it and the
session stalls. ask coordinates with listen automatically (it holds a lock
so a background listen pauses and can't swallow your answer). Handle its exit
codes: exit 3 = timed out with no reply (default 30m) — re-ask, send a
nudge update, or keep working and listening, but never fall back to a
terminal prompt; exit 4 = the question was held for review and not
dispatched (fix the inbox configuration); exit 5 = the question hit a
terminal failed outcome — it never reached the user, so ask returns
immediately instead of blocking for the full timeout; don't blindly re-ask.
- Listen for the whole window: run
"$T" listen in the background. It
waits on the CLI's WebSocket (real-time, no tokens while waiting; degrades
to polling if the WS is unavailable) and exits with either:
REPLY_RECEIVED: + the message → act on it (then update with the result),
and relaunch listen for the remaining window; or
TETHER_EXPIRED → the window is up; run "$T" stop.
Replies are deduped by message-id and survive e2a's async parse, so none are
dropped or repeated. (poll is the same one-shot check if you want it manually.)
- Stop when the user replies
stop/done, the window expires, or the work
is complete: "$T" stop.
Writing good emails
The recipient is a person reading email (often on a phone), not a terminal.
Write for that medium, not for a CLI.
HTML (update --html <file> — the default; use it for any substantive update):
- HTML renders far better than plain text in real mail clients. Reach for it
for anything beyond a quick one-liner: a status update, a summary, a
question with options, a diagram, a table, a before/after.
- Mobile-first (learned the hard way):
max-width:~480px, inline styles
only (email strips <style>/<head>), readable sizes (14–15px), and a
vertical/stacked layout. Avoid wide tables and big ASCII in <pre> — they
force horizontal scroll and shrink to unreadable on phones.
- Prefer real elements (stacked
<div> boxes, small <table>s) over ASCII art.
- Use a system font stack; keep colors subtle.
update auto-derives the
plain-text fallback, so HTML sends are always safe.
Plain text (update "<text>" — quick one-liner acks only):
- Fine for a fast acknowledgement ("on it — rerunning the tests") or a
single-sentence status. Anything with structure should be HTML.
- No markdown —
**bold**, `code`, # headings render as literal
characters in a plain-text email. Use plain prose.
- Note:
ask bodies are plain-text only (no --html) — keep questions short
and prose-only there. --attach does work on ask: attach the artifact the
decision hinges on (a diff, a mockup) rather than describing it.
Both:
- Lead with the takeaway (what changed / what you need), then details. Keep it
short and scannable.
- Be concrete: name the file / PR / decision ("merged #357"), not "did some work".
- If you need something, end with one clear ask ("Reply A or B?").
- No large code/log dumps — summarize or link. Don't paste stack traces. If the
artifact itself matters (a rendered PDF, a screenshot, a report), send it as
an attachment (
--attach) instead of inlining it.
- Acknowledge fast. When a reply comes in, a quick "on it — doing X" beats
silence; there's inherent email latency, so don't leave the user wondering if
you heard them.
- Keep it in one thread — always
update, never a fresh send. tether.sh
threads by replying (In-Reply-To/References + a stable subject), which is what
Gmail/Outlook actually stitch on. e2a's conversation_id is application
correlation, and Gmail ignores it—so a fresh send with the same value still
lands as a second thread in the user's inbox (the split Gmail showed).
While tethered, send every update through "$T" update (it replies into the
thread); do not
reach for the e2a MCP send_message or start a new subject to reach the user
mid-session. One session = one thread = one subject.
Wait behavior & knobs
listen/ask block on the e2a CLI's WebSocket wait (no LLM tokens while
waiting), so reply latency is seconds. The poll interval only matters as the
degraded cadence when the WebSocket is unavailable, and as the backfill check
between waits. The agent is only woken (a real turn) when a reply actually
lands.
| env var |
default |
effect |
E2A_TETHER_POLL_INTERVAL |
20 (s) |
fallback poll cadence when the WS wait is unavailable |
E2A_TETHER_ASK_TIMEOUT |
1800 (s) |
how long ask blocks for an answer before giving up |
E2A_URL |
none — resolved from e2a login's stored ~/.e2a/config.json, else whatever the e2a CLI itself defaults to |
e2a deployment root (set explicitly for self-host if e2a login hasn't already stored the right one) |
E2A_CLI |
(auto) |
override the e2a CLI invocation (e.g. node /repo/cli/dist/bin/e2a.js) |
The only thing that costs a turn per tick is a /loop heartbeat (tier 2
below) — keep that coarse (e.g. 30m).
Durability tiers
- In-session (default):
listen polls for the whole --for window —
automatic while the terminal stays open, and cheap (curl only, no tokens).
This is what the duration setup buys you: one long-lived poller, not manual
restarts. Add listen --awake to keep the machine from idle-sleeping
during the window (macOS caffeinate, auto-released when listening ends).
Note: --awake does not survive closing the lid (macOS clamshell still
sleeps) — that's tier 3.
- Heartbeat (optional): a slow
/loop (e.g. every 30m) can relaunch
listen if it dies and keep the session warm. /loop wakes the agent (a
full turn each tick) — use it as a supervisor, not the poller.
- Always-on (survives a closed laptop): nothing in-session outlives a
closed terminal, regardless of duration — that needs an e2a webhook firing
a cloud Routine (a fresh session per fire, loses live context). Follow-on.
Multiple sessions
Each start opens a dedicated email thread (fresh send, fresh application
conversation ID, its own subject; replies anchor by In-Reply-To), and local
state is keyed per repo (git toplevel), so tethered sessions in different
repos coexist without
touching each other's thread, watermark, or ask-lock. Within one repo,
start refuses to arm over a live session instead of silently hijacking
its thread. To run a second session in the same repo, start it with
--parallel: it self-keys a fresh state file and prints a
TETHER_STATE="…" handle — prefix every subsequent tether call in that
session with it (TETHER_STATE="…" "$T" update …), and pass a distinct
--title so the inbox threads are tellable apart. Forgetting the prefix is
warned about (commands notice parallel peers exist) and every send echoes its
thread id, so misdirection is observable. (A pre-existing machine-global
state.json from an older tether keeps working until its session stops —
note that WHILE it exists it shadows repo keying, so it also blocks start
in other repos; stop that session to retire it.)
Files
| file |
role |
tether.sh |
runtime CLI: setup / start --title [--for] [--parallel] / update [--html] [--attach] / ask [--attach] / listen / poll / status / stop |
lib.sh |
config + e2a-CLI resolution (t_cli) + send/reply/wait helpers |
hooks/tether-notify.sh |
optional Notification hook (blocked-alert) |
install.sh |
wire/unwire the Notification hook; _selftest |
tether.env.example |
credentials template |
1---2name: tether3description: Beta — Stay in the loop over email during a long-running coding session. The agent sends threaded status updates to your inbox as it sees fit and picks up your emailed replies (questions/instructions) within a few minutes, so you can steer a working agent while AFK. Transport is e2a. Use when you want to walk away from a session but keep commanding it from your inbox.4---56# tether — steer a long-running session from your inbox78> **Beta.** The transport (e2a) and the flow are real; polish (adaptive9> backoff, richer digests) is still open.1011`/tether` keeps you connected to a working session over email. The agent emails12you updates **when it judges there's something worth reporting** (not on a13timer, not every turn), and checks your inbox on an interval so your replies —14questions or new instructions — are picked up within a few minutes. Reply15`stop` to end.1617> **Transport primer.** tether rides on e2a. The e2a *operate-well manual* —18> the dependency-provided `e2a` skill — carries the19> mental model this skill assumes: how threading really works (reply headers +20> stable subject, **not** `conversation_id`), when a shared `agents.e2a.dev`21> address is all you need vs. a custom domain, and how to handle send statuses.22> Read the `e2a` skill by name if you're new to e2a; tether does not re-explain23> those.2425## Architecture (why this shape)2627Coding agents are turn-based; nothing native listens to an inbox mid-session. So28the two directions use different mechanisms:2930- **Send = agent-driven.** The agent calls `tether.sh update "…"` at meaningful31 moments (finished a slice, hit a blocker, needs a decision). Cadence is the32 model's judgment → no per-turn spam, nothing to throttle.33- **Receive = real-time wait.** The agent runs `tether.sh listen` — it blocks34 on the e2a CLI's WebSocket (`e2a listen --once`; no LLM tokens while waiting)35 for the duration set at `start --for`, so replies are picked up within36 seconds; if the WebSocket is unavailable it degrades automatically to37 interval polling. It wakes the agent *only* when a reply actually arrives (or38 the window ends). See **Durability tiers** for keeping it alive across39 idle/sleep.40- **Questions = ask by email.** When the agent needs a decision or clarification41 from you, it must **not** use a terminal prompt / AskUserQuestion — you're AFK42 and can't see it, which would stall the whole session. It calls43 `tether.sh ask "<question>"`, which emails the question into the thread and44 **blocks until you reply**, then prints your answer. This is the hard rule:45 **while tethered, every question goes over email, never the terminal.**46- **Blocked-alert = hook (optional).** A `Notification` hook emails you when the47 agent is stuck on a *permission prompt* it can't proceed past. Note: an emailed48 reply cannot answer a CLI permission prompt (there's no native way to inject49 approval), so for unattended runs **pre-authorize** the tools the session needs50 (a permission allowlist / a less-prompting mode). The hook is the safety net,51 not the approval channel.5253## Setup (once)5455> **Prerequisites:** Node (the transport is the `e2a` CLI, currently the **2.x**56> line pinned as `TETHER_MIN_CLI` in `lib.sh` — resolved from `$E2A_CLI`, then57> PATH, then fetched automatically via `npx -y @e2a/cli@^MIN`, so there is58> nothing to install by hand) and Python 3 (local state handling only).59>60> The range is **major-bounded on purpose**. tether parses the CLI's TSV columns61> and branches on its exit codes, and a major bump is allowed to change both —62> 2.0.0 did. So a CLI newer than the pinned major is *not* adopted automatically63> (tether falls back to npx and says so); someone re-verifies tether against the64> new major and bumps `TETHER_MIN_CLI`. Failing inert beats failing silently in65> an unattended session.6667### Fast path (recommended): `tether.sh setup`6869`tether.sh`, `install.sh`, and `tether.env.example` all sit in this skill's own70directory, next to this `SKILL.md`. Substitute its absolute path for71`$TETHER_DIR` in every command below; the scripts resolve their own resources,72so any working directory works.7374If the e2a CLI is logged in (`e2a login` in a browser, or a key persisted with75`e2a config set api_key <key>` on a headless box), one command does the whole76bootstrap:7778```bash79"$TETHER_DIR/tether.sh" setup80```8182It verifies the credential (`e2a whoami`), ensures a tether inbox (reusing an83existing `tether-…` agent or creating one on the shared domain — it will84**not** silently adopt a non-tether inbox), mints a **least-privilege85agent-scoped key** (`e2a_agt_…`), and writes `~/.e2a-tether.env` (previous file86kept as `.bak`). Every step fails hard — it never stores the broad account key87and never reports "ready" with a half-working config. Flags: `--email88you@yourdomain` to use/create a specific inbox, `--new` to force a fresh one.8990### Manual setup (fallback)91921. **Get an agent-scoped API key** (`e2a_agt_…` — least privilege, so a leaked93 key can't touch the rest of the account): from the CLI with94 `e2a keys create --agent <inbox>`, or from **https://e2a.dev/api-keys**.95 No domain? Create the agent on the **shared `agents.e2a.dev`** domain96 (`e2a agents create name@agents.e2a.dev` — live immediately, no DNS).972. **Save the credentials:**98 ```bash99 cp "$TETHER_DIR/tether.env.example" ~/.e2a-tether.env100 chmod 600 ~/.e2a-tether.env # fill E2A_API_KEY (e2a_agt_…) + E2A_AGENT_EMAIL101 ```1023. **(Optional) blocked-alert hook:**103 ```bash104 "$TETHER_DIR/install.sh" --to <repo-root>105 ```106107Credentials resolve in order: explicit env vars → `~/.e2a-tether.env` →108`~/.e2a/config.json`. Note that `e2a login` saves an **account-scoped** key and109does **not** set `agent_email` — so `~/.e2a/config.json` alone is not enough for110tether, which needs a specific inbox. Either set both explicitly:111112```bash113e2a keys create --agent <inbox> # least-privilege e2a_agt_… key114e2a config set agent_email <inbox> # or export E2A_AGENT_EMAIL115```116117or put the agent key + inbox in `~/.e2a-tether.env` and leave the CLI config alone.118119> **Two ways a send can be accepted but never delivered — both fail loudly.**120> `pending_review` means the message was held for approval; a terminal `failed`121> means the server persisted a delivery failure. `tether.sh` detects both on122> every send: an affected intro makes `start` **refuse to arm**, and an affected123> `update`/`ask` exits non-zero — held is **2** for `update`, **4** for `ask`;124> terminal failure is **5** for both — so the session never mistakes either for125> a delivered message. A terminal failure is126> **not** retryable — the server already recorded an outcome for that message127> id, so re-sending risks a duplicate. Inspect it with `e2a messages get <id>`.128129## First run (new user) — set them up, then tether130131Before tethering, confirm the harness can actually send. Run `"$T" status`132(where `T` is defined below). If it reports **`config: MISSING`**, this is a133first-time user — **help them get to a working setup instead of just failing**:1341351. **Is e2a connected at all?** If they've never used e2a, hand them136 `e2a login` — or, when `e2a` isn't installed globally, the npx form:137 `npx -y @e2a/cli login` (same auto-fetch the harness itself uses). It opens138 the browser sign-up/sign-in and saves an account-scoped key to139 `~/.e2a/config.json`. (Headless box: they mint an account key in the140 dashboard, persist it with `e2a config set api_key <key>`, then validate it141 with `e2a whoami`.) Interactive sign-in is theirs to complete: hand them the142 command, don't drive it.1432. **Run the bootstrap:** `"$T" setup` — it creates the inbox, mints the144 agent-scoped key, and writes145 `~/.e2a-tether.env`. See **Setup** above for what it refuses to do.1463. **Re-check.** `"$T" status` should now print `config: OK (agent …)`. Proceed147 to the runtime flow.148149If `status` already prints `config: OK`, skip this — they're a returning user.150Don't put a configured user through onboarding.151152## Runtime flow (what the agent does when `/tether` is invoked)153154Let `T="$TETHER_DIR/tether.sh"`.1551560. **Preflight.** Run `"$T" status`. If `config: MISSING`, do **First run (new157 user)** above before continuing — don't call `start` and let it error out.1581. **Ask** the user's email address **and how long to stay tethered** (e.g. 30m,159 2h, 8h/overnight, or until they say stop). They're present at this step, so a160 normal question is fine.1612. **Start**: `"$T" start <email> --title "<work>" --for <duration>` (or162 `--until <ISO>`; omit both for until-stop) — sends the intro, opens the163 thread, arms, records the window. **`--title` is required** (start refuses164 without it): a short description of the work being done (e.g. `"migrate165 loft → @e2a/ui"`, `"fix webhook retries"`) — it becomes the thread's subject166 line (`Tether: <repo> — <title>`), which is how the user tells this session167 apart from others in their inbox. The subject is fixed at start (threading168 needs it stable), so title the *work*, not the first step. `--for` takes a **single169 unit** (`30m`, `2h`, `8h`, `1d`); a compound170 like `1h30m` is rejected rather than silently treated as no-limit. If the intro171 comes back `pending_review`, `start` refuses to arm because the intro was not172 dispatched.1733. **Work**, and **send updates as you see fit** — **prefer HTML**, it renders174 far better in mail clients: write the HTML to a file and run175 `"$T" update --html <file>` — a plain-text fallback is auto-derived (or pass176 `--text "<fallback>"`). Plain `"$T" update "<text>"` is for quick one-liner177 acks only. To send a file (a rendered PDF, a screenshot, a small log), add178 `--attach <file>` — repeatable, on either form, capped at 15 MB total per179 send (**exit 3** = file not found, **exit 4** = over the cap; past the cap,180 upload the file somewhere and send a link instead). Good moments: finished a181 slice, made a decision that's worth surfacing, hit a blocker, or before a182 long unattended stretch. Skip trivial183 turns. If `update` reports `pending_review` (**exit 2**), the update did184 **not** reach the user — stop and fix the inbox configuration before185 continuing. **Exit 5** means the send reached a terminal `failed` outcome:186 also undelivered, but do **not** re-send it (the server already recorded that187 message id) — inspect it with `e2a messages get <id>` first.1884. **Need a decision from the user? Ask by email — never the terminal.** Run189 `"$T" ask "<question>"` (in the background); it emails the question and blocks190 until the user replies, then prints the answer. `--attach <file>` works here191 too — attach the artifact the decision hinges on (a diff, a mockup) rather192 than describing it. **Do not** use AskUserQuestion193 or a bare terminal prompt while tethered — an AFK user can't answer it and the194 session stalls. `ask` coordinates with `listen` automatically (it holds a lock195 so a background `listen` pauses and can't swallow your answer). Handle its exit196 codes: **exit 3** = timed out with no reply (default 30m) — re-`ask`, send a197 nudge `update`, or keep working and listening, but never fall back to a198 terminal prompt; **exit 4** = the question was held for review and not199 dispatched (fix the inbox configuration); **exit 5** = the question hit a200 terminal `failed` outcome — it never reached the user, so `ask` returns201 immediately instead of blocking for the full timeout; don't blindly re-ask.2025. **Listen for the whole window**: run `"$T" listen` **in the background**. It203 waits on the CLI's WebSocket (real-time, no tokens while waiting; degrades204 to polling if the WS is unavailable) and exits with either:205 - `REPLY_RECEIVED:` + the message → act on it (then `update` with the result),206 and **relaunch `listen`** for the remaining window; or207 - `TETHER_EXPIRED` → the window is up; run `"$T" stop`.208 Replies are deduped by message-id and survive e2a's async parse, so none are209 dropped or repeated. (`poll` is the same one-shot check if you want it manually.)2106. **Stop** when the user replies `stop`/`done`, the window expires, or the work211 is complete: `"$T" stop`.212213## Writing good emails214215The recipient is a **person reading email (often on a phone)**, not a terminal.216Write for that medium, not for a CLI.217218**HTML (`update --html <file>` — the default; use it for any substantive update):**219- HTML renders far better than plain text in real mail clients. Reach for it220 for anything beyond a quick one-liner: a status update, a summary, a221 question with options, a diagram, a table, a before/after.222- **Mobile-first** (learned the hard way): `max-width:~480px`, **inline styles223 only** (email strips `<style>`/`<head>`), readable sizes (14–15px), and a224 **vertical/stacked layout**. Avoid wide tables and big ASCII in `<pre>` — they225 force horizontal scroll and shrink to unreadable on phones.226- Prefer real elements (stacked `<div>` boxes, small `<table>`s) over ASCII art.227- Use a system font stack; keep colors subtle. `update` auto-derives the228 plain-text fallback, so HTML sends are always safe.229230**Plain text (`update "<text>"` — quick one-liner acks only):**231- Fine for a fast acknowledgement ("on it — rerunning the tests") or a232 single-sentence status. Anything with structure should be HTML.233- **No markdown** — `**bold**`, `` `code` ``, `#` headings render as literal234 characters in a plain-text email. Use plain prose.235- Note: `ask` bodies are plain-text only (no `--html`) — keep questions short236 and prose-only there. `--attach` does work on `ask`: attach the artifact the237 decision hinges on (a diff, a mockup) rather than describing it.238239**Both:**240- Lead with the takeaway (what changed / what you need), then details. Keep it241 short and scannable.242- Be concrete: name the file / PR / decision ("merged #357"), not "did some work".243- If you need something, end with **one clear ask** ("Reply A or B?").244- No large code/log dumps — summarize or link. Don't paste stack traces. If the245 artifact itself matters (a rendered PDF, a screenshot, a report), send it as246 an attachment (`--attach`) instead of inlining it.247- **Acknowledge fast.** When a reply comes in, a quick "on it — doing X" beats248 silence; there's inherent email latency, so don't leave the user wondering if249 you heard them.250- **Keep it in one thread — always `update`, never a fresh send.** `tether.sh`251 threads by *replying* (In-Reply-To/References + a stable subject), which is what252 Gmail/Outlook actually stitch on. e2a's `conversation_id` is application253 correlation, and Gmail ignores it—so a fresh send with the same value still254 lands as a *second* thread in the user's inbox (the split Gmail showed).255 While tethered, send every update through `"$T" update` (it replies into the256 thread); do **not**257 reach for the e2a MCP `send_message` or start a new subject to reach the user258 mid-session. One session = one thread = one subject.259260## Wait behavior & knobs261262`listen`/`ask` block on the e2a CLI's WebSocket wait (**no LLM tokens while263waiting**), so reply latency is seconds. The poll interval only matters as the264degraded cadence when the WebSocket is unavailable, and as the backfill check265between waits. The agent is only woken (a real turn) when a reply actually266lands.267268| env var | default | effect |269|---|---|---|270| `E2A_TETHER_POLL_INTERVAL` | `20` (s) | fallback poll cadence when the WS wait is unavailable |271| `E2A_TETHER_ASK_TIMEOUT` | `1800` (s) | how long `ask` blocks for an answer before giving up |272| `E2A_URL` | none — resolved from `e2a login`'s stored `~/.e2a/config.json`, else whatever the `e2a` CLI itself defaults to | e2a deployment root (set explicitly for self-host if `e2a login` hasn't already stored the right one) |273| `E2A_CLI` | (auto) | override the e2a CLI invocation (e.g. `node /repo/cli/dist/bin/e2a.js`) |274275The only thing that costs a turn per tick is a `/loop` **heartbeat** (tier 2276below) — keep that coarse (e.g. 30m).277278## Durability tiers2792801. **In-session (default):** `listen` polls for the whole `--for` window —281 automatic while the terminal stays open, and cheap (curl only, no tokens).282 This is what the duration setup buys you: one long-lived poller, not manual283 restarts. Add **`listen --awake`** to keep the machine from *idle*-sleeping284 during the window (macOS `caffeinate`, auto-released when listening ends).285 Note: `--awake` does **not** survive *closing the lid* (macOS clamshell still286 sleeps) — that's tier 3.2872. **Heartbeat (optional):** a slow `/loop` (e.g. every 30m) can relaunch288 `listen` if it dies and keep the session warm. `/loop` wakes the *agent* (a289 full turn each tick) — use it as a supervisor, not the poller.2903. **Always-on (survives a closed laptop):** nothing in-session outlives a291 closed terminal, regardless of duration — that needs an **e2a webhook firing292 a cloud Routine** (a *fresh* session per fire, loses live context). Follow-on.293294## Multiple sessions295296Each `start` opens a **dedicated email thread** (fresh send, fresh application297conversation ID, its own subject; replies anchor by In-Reply-To), and local298state is **keyed per repo** (git toplevel), so tethered sessions in different299repos coexist without300touching each other's thread, watermark, or ask-lock. Within one repo,301`start` **refuses to arm over a live session** instead of silently hijacking302its thread. To run a second session in the *same* repo, start it with303`--parallel`: it self-keys a fresh state file and prints a304`TETHER_STATE="…"` handle — **prefix every subsequent tether call in that305session with it** (`TETHER_STATE="…" "$T" update …`), and pass a distinct306`--title` so the inbox threads are tellable apart. Forgetting the prefix is307warned about (commands notice parallel peers exist) and every send echoes its308thread id, so misdirection is observable. (A pre-existing machine-global309`state.json` from an older tether keeps working until its session stops —310note that WHILE it exists it shadows repo keying, so it also blocks `start`311in other repos; `stop` that session to retire it.)312313## Files314315| file | role |316|---|---|317| `tether.sh` | runtime CLI: `setup` / `start --title [--for] [--parallel]` / `update [--html] [--attach]` / `ask [--attach]` / `listen` / `poll` / `status` / `stop` |318| `lib.sh` | config + e2a-CLI resolution (`t_cli`) + send/reply/wait helpers |319| `hooks/tether-notify.sh` | optional Notification hook (blocked-alert) |320| `install.sh` | wire/unwire the Notification hook; `_selftest` |321| `tether.env.example` | credentials template |