@leftoff/cli 0.8.0 → 0.8.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,152 @@
1
+ # Leftoff security notes
2
+
3
+ This is what Leftoff keeps, where it goes when you sync, what stays on each Mac, and what Leftoff does **not** protect you from. It covers 0.6.1 (Use from anywhere has [its own section](#use-from-anywhere-the-hosted-relay-060), and self-serve signup [its own](#self-serve-signup-061-what-the-relay-now-stores)). If something here is wrong or you find a hole, see [Reporting a problem](#reporting-a-problem).
4
+
5
+ ## What's in the Leftoff folder
6
+
7
+ The folder (`~/checkpoints`, or wherever you moved it) is plain Markdown and JSON:
8
+
9
+ - **Session records:** each Claude Code session's title, repo, folder path, branch, machine name, times and status.
10
+ - **Checkpoint summaries:** a few lines on what got done, where it stopped and what's next. They're written by the summarizer you chose, from your prompts and Claude's replies. They can **quote snippets of your prompts or code**. Leftoff's redaction (`redact` patterns in `config.json`, which cover API keys and tokens by default) runs before anything is written. Treat the folder as about as sensitive as your terminal history.
11
+ - **Lists, catch-ups and digests:** the checklists you saved, the recaps and the daily digest.
12
+ - **`answers.md`** per session: what was answered from the board, and from where.
13
+ - **`agents/<name>/`** (0.5.4): what an external agent's feed said (Tab's sessions, checkpoints, needs-you items and lists), the feed's URL and which Mac pulls it. Not the feed's key.
14
+ - **`machines/<machine>.json`:** each Mac's *public* key and the addresses it can be reached on.
15
+ - **`.secret/token`:** the board's API token. **`.secret/remote.json`:** phone sign-ins and pending sign-in codes, stored as hashes only.
16
+
17
+ ## Where it lives with each sync choice
18
+
19
+ | Choice | Where copies live | Who could read them |
20
+ |---|---|---|
21
+ | **Syncthing** (recommended) | Only on your Macs. Device to device, encrypted in transit with TLS; each device is identified by its certificate. | Your Macs. Syncthing's public relays and discovery servers may carry the traffic or help your Macs find each other. They see encrypted bytes, IP addresses and device IDs, not your files. |
22
+ | **iCloud Drive** | Your Macs and Apple's servers, under Apple's terms. | Without **Advanced Data Protection**, Apple holds the keys to iCloud Drive and could read it (for example, under a legal request). With Advanced Data Protection on, iCloud Drive is end-to-end encrypted and only your trusted devices can read it. |
23
+ | **Dropbox** | Your Macs and Dropbox's servers, under Dropbox's terms. | Dropbox can read it. It's encrypted at rest on their side, but with their keys. |
24
+ | **Another folder** (Google Drive, a NAS, …) | Wherever that tool keeps it. | Whoever runs that service or box. |
25
+
26
+ ## What never goes in the folder
27
+
28
+ | Item | Where it lives | Status |
29
+ |---|---|---|
30
+ | Machine private keys (cross-machine Answer and Go) | `~/.config/checkpoints/identity/<machine>.json`, mode 0600 | Never in the folder (since 0.5.2). Only the public half is in `machines/`. |
31
+ | Ollama cloud / Anthropic API keys | `~/.config/checkpoints/secrets/`, mode 0600 | **Moved out in 0.5.3.** Up to 0.5.2 they sat in `.secret/` inside the folder. The daemon now moves any it finds there at start, and `leftoff doctor` flags one that's still there. Each Mac has its own copy; a new Mac you join needs its key entered once. |
32
+ | Syncthing's API key | Syncthing's own `config.xml` | Leftoff reads it when it needs to talk to Syncthing on `127.0.0.1`. It never writes, logs or returns it. |
33
+ | Agent feed keys (0.5.4) | `~/.config/checkpoints/agents/<name>.key`, mode 0600, on the one Mac that pulls the feed | Never in the folder, logs or error messages. Sent to the feed's server as `Authorization: Bearer`, or as `?key=` if the server refuses the header. Redirects to another site are refused, so the key only goes to the feed's own origin. |
34
+ | Use from anywhere switch (0.6.0) | `~/.config/checkpoints/relay.json`, mode 0600 | Per Mac. The account name (`yourname`) is also in the folder at `relay/account.json`, so your other Mac's Settings knows it. |
35
+ | Paired browsers' keys (0.6.0) | `relay/devices/<id>.json` **in the folder, wrapped** | Each device key is encrypted to each Mac's own X25519 key (whose private half never leaves that Mac) and the file is signed by the Mac that wrote it. The sync service sees ciphertext. Revoking erases the wrapped copies. |
36
+ | Relay admin token (0.6.0) | A wrangler secret on your Cloudflare account, and wherever you keep it | Leftoff never stores it. `leftoff relay register` sends it once, over https, to your relay. `leftoff relay signup` (0.6.1) needs no token at all. |
37
+ | Pairing secret | `~/.config/checkpoints/syncthing-pairing.json`, mode 0600, first Mac only | Erased the moment a Mac pairs with it, or when it expires. |
38
+ | Board token (`.secret/token`) and phone sign-ins (`.secret/remote.json`, hashes) | In the folder | **With Syncthing:** kept on each Mac by Leftoff's `.stignore`, so each Mac has its own token. **With iCloud, Dropbox or another folder:** they sync, because those tools have no ignore file Leftoff can rely on. The token only works against a Mac's board, which listens on `127.0.0.1` unless you turned on remote access. Anyone who can read your synced folder *and* reach a board could use it, so treat the folder as private. |
39
+
40
+ Leftoff's `.stignore` for its Syncthing folder also keeps lock files, temp files, `.DS_Store` and the daemon's pid and log on each Mac.
41
+
42
+ ## Use from anywhere: the hosted relay (0.6.0)
43
+
44
+ **What shipped: end-to-end encryption for the board's data.** Not "TLS plus a relay that promises not to look". Every board API call a paired browser makes (the path, the query, the body) and every answer (status and body) is sealed with AES-256-GCM under a key that only the browser and your Macs hold. The relay forwards the ciphertext. How the key is made is in [relay.md](relay.md#pairing-step-by-step).
45
+
46
+ You deploy the relay yourself, from `relay/` in this repo, to your own Cloudflare account. So today "the relay operator" is you, plus Cloudflare as the host running the Worker.
47
+
48
+ ### What the relay can see
49
+
50
+ - Your account name (`yourname`), your Macs' names, their public signing keys and Leftoff versions.
51
+ - When each Mac connects and disconnects, and the time of the latest session activity on each Mac (it picks the default Mac with this). Not what the activity was.
52
+ - Each paired browser's name (e.g. "iPhone · Safari", which you can change when pairing), when it was paired, on which Mac, and when it was last used.
53
+ - For every request: that it happened, when, which device sent it, which Mac it went to, and how big the request and the answer were. Not what it was.
54
+ - Pairing: that a code with a given 4-character id was made and redeemed, and both sides' public keys. Not the code itself.
55
+ - Mac to Mac (Answer, reply, Go across Macs): which Mac talks to which, when and how much. The envelopes are signed and encrypted end to end ([peer-protocol.md](peer-protocol.md)).
56
+ - **The app's files.** The page, its scripts and its stylesheet travel from your Mac through the relay to the browser as they are. They're public code (the same files as in this repo), with no data in them.
57
+ - IP addresses of your Macs and your phone (Cloudflare sees these for any website).
58
+ - Requests to your other `leftoff.dev` hostnames (`www`, `feed`, anything in `PASSTHROUGH`). The `*.leftoff.dev/*` route runs the relay Worker in front of them, and it hands each one on untouched with `fetch()` without reading, storing or logging it. Your agent feed's own encryption and keys are unaffected.
59
+
60
+ ### What the relay can't do
61
+
62
+ - Read your sessions, prompts, checkpoints, digests, answers or anything else the board shows. They're only ever on the wire sealed.
63
+ - Make a request to your Macs on its own. A Mac opens only envelopes sealed with a paired device's key, checks that the envelope names this Mac, that it's no older than 2 minutes and that its id is new. So the relay can't forge, replay, or move a request from one Mac to the other.
64
+ - Get the device key at pairing. The code reaches the phone only in the QR code's `#fragment` (which browsers never send to a server) or typed by you. Both public keys are authenticated with an HMAC of the code, so a relay that swapped in its own keys would be caught while the code is live (10 minutes), and the code is useless afterwards.
65
+ - Use a stolen cookie for anything. The `lo_device` cookie (`HttpOnly; Secure; SameSite=Lax`) only gets a browser past the relay's own door; without the device key, every Mac refuses its requests.
66
+
67
+ ### The honest limits
68
+
69
+ - **The page code is trusted.** Like every web app, the code in the page can do anything the page can. Because the relay passes the app's files along, whoever runs the relay could in principle serve altered code to a paired browser. The device key is non-extractable (the code can use it but not read it out), but altered code could still read what the board shows while it's open. You run the relay, so this is your own Worker; for a future hosted tier this is the line to know, and signed app files are planned.
70
+ - Traffic analysis: sizes and timing can hint at what's going on (a long answer after opening a session, for example).
71
+ - Your phone holds the key. A lost phone: revoke it (Settings → Phone & remote, or `leftoff relay revoke`). Revoking deletes the device at the relay (401 at once) and erases its wrapped keys from the folder.
72
+ - No passkey (WebAuthn) second factor yet. It's planned.
73
+ - A Mac that's registered can add the other Macs in its folder (it vouches only for cards it already trusts, the same pinning as cross-machine Answer and Go). So the synced folder stays the trust root, as before.
74
+
75
+ ### Self-serve signup (0.6.1): what the relay now stores
76
+
77
+ `leftoff relay signup <name>` (and the same step in `leftoff setup` and Settings) lets anyone claim a free name on the hosted relay with an email link, no admin token. That adds to what the relay keeps:
78
+
79
+ - **On the registry record of each signed-up name: the email address, `plan: "beta"`, `via: "signup"`, the Mac's name and its public signing key, and when it was created.** The email is there for the confirmation link, for "one name per email", and for billing later; it is not shown on the board or sent anywhere else. Names made with the admin token keep `plan: "free"` and no email.
80
+ - **Pending signups**, for up to 30 minutes plus a day after they expire or are confirmed (so a polling CLI hears what happened): the name, email, Mac name and public key, and **SHA-256 hashes** of the link token and of the CLI's poll secret. The tokens themselves are never stored.
81
+ - **Rate-limit stamps** for an hour: a SHA-256 hash of the requester's IP address, and the email address, with the times of their signups (5 an hour per IP, 3 per email).
82
+ - **The confirmation email goes through Resend** (the relay's `RESEND_API_KEY`), so Resend sees the address, the subject and the link, the same as for the site's "Claim your name" emails.
83
+ - **leftoff.dev sees the name**, when the relay asks who claimed it, and **the name and email** when the relay tells it a signup was confirmed. Both calls carry the shared `RELAY_LOOKUP_KEY` secret as a bearer token; the site's answer to the lookup is the claim's status and email, which the relay compares and doesn't store.
84
+ - Confirming takes a **POST from the page's Confirm button**; opening the link (a GET) never confirms, so mail scanners and link previews can't. The name is then bound to the Mac's public key from the signup request: only a Mac holding the matching private key can connect as that Mac, exactly like `relay register`.
85
+
86
+ None of this touches the board's data: everything under [What the relay can see](#what-the-relay-can-see) still holds, and the end-to-end encryption is unchanged.
87
+
88
+ ### What a phone may do through the relay
89
+
90
+ A relayed request is replayed against the Mac's own board from inside the daemon (proved with a secret that never leaves memory) and counts as a **remote device**, with the same rules as a phone over Tailscale: it can read the board; answering prompts and pairing new devices need Settings → Privacy & security → *Trust my paired devices* on that Mac (`remote.allowAnswers`; 403 `trust_off` while it's off); revoking a paired device works from any paired device, itself included, since it only cuts access; Go, moving the folder, Syncthing, turning the relay on or off and the trust switch itself are refused (this Mac's business, loopback only). The daemon still listens only on 127.0.0.1: the relay connection is outbound.
91
+
92
+ ### Logging
93
+
94
+ The Worker never logs request or response bodies (it can't read them anyway) and stores only what's listed above, in your Durable Object. `wrangler.toml` turns Workers observability (request logs) off. Cloudflare's edge keeps its usual metadata for any site (IP, hostname, path such as `/_relay/api`, status), under its own policies.
95
+
96
+ ## Pairing a second Mac
97
+
98
+ The first Mac shows a code like `LO1-05NCN-6C30A-…`. It carries three things: that Mac's Syncthing device ID (which isn't secret), the folder ID (`leftoff`), and a random 128-bit one-time secret, plus an expiry 15 minutes out and a checksum.
99
+
100
+ 1. On the second Mac, `leftoff join <code>` (or the board's **Join another Mac**) first sets its own Syncthing device name to include a *proof*: `HMAC-SHA256(secret, "leftoff-pair-v1" + its own device ID + the first Mac's device ID + the folder ID)`, cut to 80 bits. Syncthing puts this device's configured name in the Hello it sends when it connects, and its TLS certificate authenticates the device ID.
101
+ 2. Only then does it add the first Mac as a Syncthing device (Syncthing dials a newly added device at once, so the very first Hello already carries the proof) and share a folder with the ID `leftoff` with that Mac only. If it already knew the first Mac, it pauses and resumes that one device so a fresh Hello goes out now. (0.5.3 and 0.5.4 set the name after adding the device, so the first Hello was plain; see the 0.5.5 changelog.)
102
+ 3. While the code is live, the first Mac's daemon looks at Syncthing's **pending devices**. It accepts a device only if the proof matches **that device's own ID**. A copied name doesn't help a different device. The first Mac then adds that device to the Leftoff folder, and to nothing else.
103
+ 4. The secret is erased **before** the device is added, so the code works exactly once. An expired or used code accepts nothing.
104
+ 5. A device that knocks **without** a valid proof is never accepted automatically. While the code is live, the first Mac dismisses that device's pending record **once** (Syncthing's `DELETE /rest/cluster/pending/devices?device=<that ID>`, which changes no configuration), so a record holding an old name, from an earlier attempt, can come back with the device's current name the next time it connects. After that one dismissal it is left alone. Leftoff logs the name it gave and the first 7 characters of its ID.
105
+ 6. The second Mac says it's connected only when the first Mac has really accepted it: connected **and** sharing the Leftoff folder back (Syncthing's `remoteState: "valid"` for the folder). A bare "connected" isn't enough: Syncthing briefly lists a peer as connected before that peer hangs up on a device it doesn't know. It then restores its own device name.
106
+
107
+ **If the proof doesn't get through** (an older Leftoff on the joining Mac, or anything else), there are two by-hand ways, both an explicit choice on the first Mac:
108
+ - After about a minute of waiting, the joining Mac prints, and shows on its board, the steps: on the first Mac open Syncthing at http://127.0.0.1:8384, click **Add Device** on the "<name> wants to connect" banner, then tick Leftoff on the **Sharing** tab.
109
+ - The first Mac's board (Settings → Sync) and `leftoff doctor` show "A Mac named X asked to connect without the pairing proof", with an **Accept X** button (or `leftoff sync accept X`, which asks first). It's only offered for a device that knocked while a code was live, only on the Mac itself (loopback, never the phone), and only after a confirm. It adds that one device and shares only the Leftoff folder with it, and it ends the pairing (the code stops working). Accepting a device by hand means trusting its name, which anyone can choose: only accept it if you're joining your own Mac right now, and check the 7-character ID if you can (Syncthing shows the full ID on the joining Mac under Actions → Show ID).
110
+
111
+ Why the device name, and not something else: before the first Mac accepts a device, Syncthing tells it only what's in that device's Hello (its ID, name and client version). Folder offers, which could carry a proof in a folder label, travel in the cluster config, which Syncthing only exchanges after both sides have added each other. So the pending folder list is empty until it's too late to be useful. Leftoff's own signed peer channel needs the synced folder, which doesn't exist yet, and a direct connection would mean a new open port and firewall prompt. The name in the Hello is the one channel Syncthing carries from an unknown device, and Syncthing v2.1.6 rewrites the pending record on every Hello, so a fresh Hello is all it takes.
112
+
113
+ What this means:
114
+ - Someone who sees the code within 15 minutes, before your Mac uses it, could pair *their* device instead. Share the code the way you'd share a password: copy and paste it between your own Macs, not in a group chat. If your Mac fails to pair, the code may have been used; cancel it and make a new one.
115
+ - Leftoff never changes any other Syncthing folder or device. Its client refuses, in code, to write anything except the folder with the ID `leftoff`, devices it added itself, and this device's own name during a pairing. The two narrow exceptions since 0.5.5: on the joining Mac it may pause and resume the first Mac's device (the one the code names) to send a fresh Hello, and on the first Mac it may dismiss the pending record of a device that knocked without the proof, once per pairing. Tests check every write against a fake Syncthing.
116
+ - Installing Syncthing, setting it up, pairing and joining only work from the Mac's own browser (loopback) or terminal. The phone, or a browser coming in through Tailscale, sees the status and nothing else. A pairing code is never shown remotely and never in `GET /v1/syncthing`.
117
+ - **Stop syncing** removes only the `leftoff` folder from Syncthing. Files stay on disk, and devices stay as they are.
118
+
119
+ ## Cross-machine Answer and Go
120
+
121
+ - Off by default, per Mac (Settings → Privacy & security). While it's off, the Mac doesn't listen at all.
122
+ - Each Mac has an Ed25519 signing key and an X25519 encryption key. The public halves are in `machines/<machine>.json` and are pinned the first time they're seen. A changed key is refused until you confirm it.
123
+ - Each command is signed by the sender, encrypted to the one target Mac, and expires after 60 seconds, and a replayed nonce is refused. It runs through the same code as that Mac's own board.
124
+ - Delivery is direct (LAN or Tailscale address) or through a relay. The relay only ever sees ciphertext: it can drop or delay messages but can't read or forge them. The details are in [peer-protocol.md](peer-protocol.md).
125
+
126
+ ## External agents' feeds (0.5.4)
127
+
128
+ - A feed is pulled, never pushed: your Mac fetches it, and nothing on your Mac listens for it. Only `https://` feeds are accepted (plain `http://` only on `localhost`).
129
+ - A feed is untrusted input. It's validated against a strict schema with a 1 MB limit; one bad field rejects the whole pull and the last good one stays. Every string is shown as escaped plain text (no HTML, no Markdown, no auto-links), control and bidi-override characters are stripped, and only `https://` links without credentials are kept.
130
+ - Feed data is written only under `agents/<name>/`: file names are a slug plus a hash of the session id, and every path is checked to be inside that folder.
131
+ - Feed sessions are read-only. They can't trigger Go, restore, answering or anything that runs on your Mac. Their "needs you" items link to the agent's own `https://` page.
132
+ - Adding and removing a feed (which handles the key) only work on the Mac itself. A signed-in phone sees the feeds and can ask for a pull.
133
+ - What a feed says syncs like the rest of the folder, so treat it as being as sensitive as the folder.
134
+
135
+ ## Remote access (the phone)
136
+
137
+ - The board listens on `127.0.0.1`. You reach it from a phone only if you turn on `leftoff tailscale on`, which uses Tailscale Serve, so only your tailnet can reach it. Funnel (the public internet) isn't supported.
138
+ - A phone signs in with a single-use QR link or a typed 6-character code, both good for 15 minutes. Ten wrong codes void all pending codes. Sessions last 30 days and only their hashes are stored.
139
+ - From a phone you can read everything. Answering prompts and ticking lists follow Settings → Privacy & security, which is off by default. Moving the folder, Syncthing, pairing and the cross-machine switch are never available remotely.
140
+
141
+ ## What Leftoff does NOT protect against
142
+
143
+ - **A compromised Mac.** Malware running as you can read the folder, the keys in `~/.config/checkpoints`, and Syncthing's API key, and can drive the board.
144
+ - **Anyone with access to the synced folder.** They can read your session summaries. With iCloud or Dropbox they can also read the board token and could add files. Leftoff signs cross-machine *commands*, not the folder's files.
145
+ - **The sync provider** for iCloud (without Advanced Data Protection), Dropbox or another service. They hold a readable copy.
146
+ - **What Claude Code itself stores** (its transcripts in `~/.claude`), and what your summarizer provider sees. A cloud model sees the prompt that summarizes your session; local Ollama keeps it on your Mac.
147
+ - **The agent's server.** It sees your key and your Mac's IP each pull, and decides what the feed says. Leftoff limits what a feed can do (read-only text and https links), not what it can claim.
148
+ - **Someone who intercepts a pairing code** in the 15 minutes before you use it (see above).
149
+
150
+ ## Reporting a problem
151
+
152
+ Please report security issues privately to hello@leftoff.dev. Include the Leftoff version (`leftoff --version`) and the steps to reproduce.
@@ -0,0 +1,104 @@
1
+ # Hosting a private agent feed
2
+
3
+ Leftoff can show an AI assistant's work on your board, as if it were another machine: its sessions, what it's waiting on you for, its checklists. The assistant (or any script) publishes a small JSON feed, and your Leftoff pulls it every 2 minutes. The format is in [agent-feed.md](agent-feed.md).
4
+
5
+ This page is about **where that feed lives**. It has to be reachable by your Mac, private (only someone with the key can read it), and writable by the agent. The easiest way is a tiny Cloudflare Worker on your own account, about 5 minutes the first time and free.
6
+
7
+ Using Tab? Skip to [Connect Tab](#connect-tab). Tab sets this up for you.
8
+
9
+ ## The Cloudflare Worker (recommended)
10
+
11
+ The package ships a ready-made Worker in `templates/agent-feed-worker/`. It stores the feed in Cloudflare KV and:
12
+
13
+ - answers `GET /feed.json` only with the **read key** (`Authorization: Bearer <key>`), with an `ETag`, so an unchanged pull is a free `304`;
14
+ - accepts `PUT /feed.json` only with the **write key**;
15
+ - holds no secrets. `wrangler.toml` has only the SHA-256 hashes of the two keys.
16
+
17
+ You need Node and a Cloudflare account. `npx wrangler login` once if you haven't.
18
+
19
+ **1. Copy the template.**
20
+
21
+ ```sh
22
+ cp -R "$(npm root -g)/@leftoff/cli/templates/agent-feed-worker" ~/my-agent-feed
23
+ cd ~/my-agent-feed
24
+ ```
25
+
26
+ (With pnpm: `$(pnpm root -g)/@leftoff/cli/templates/agent-feed-worker`.)
27
+
28
+ **2. Make the keys.**
29
+
30
+ ```sh
31
+ leftoff agents keygen
32
+ ```
33
+
34
+ It prints a **read key** (for Leftoff), a **write key** (for the agent) and two lines for `wrangler.toml`:
35
+
36
+ ```toml
37
+ READ_KEY_SHA256 = "…"
38
+ WRITE_KEY_SHA256 = "…"
39
+ ```
40
+
41
+ The keys are shown once. Save them in a password manager now.
42
+
43
+ **3. Make the storage.**
44
+
45
+ ```sh
46
+ npx wrangler kv namespace create DATA
47
+ ```
48
+
49
+ Paste the `id` it prints into `wrangler.toml`, and the two hash lines under `[vars]`.
50
+
51
+ **4. Deploy.**
52
+
53
+ ```sh
54
+ npx wrangler deploy
55
+ ```
56
+
57
+ It prints your Worker's address, like `https://leftoff-agent-feed.<you>.workers.dev`. Your feed URL is that plus `/feed.json`.
58
+
59
+ **5. Publish a feed.** Write one by hand from the [example](agent-feed.example.json), or have your agent write it. Then:
60
+
61
+ ```sh
62
+ pbpaste | leftoff agents publish feed.json --url https://leftoff-agent-feed.<you>.workers.dev/feed.json --write-key -
63
+ ```
64
+
65
+ (with the write key on the clipboard). `publish` checks the file with the same strict validator Leftoff uses when it pulls. If anything's wrong it lists each problem and sends nothing. Use `leftoff agents validate feed.json` to check without sending. The agent can also `PUT` the file itself with `Authorization: Bearer <write key>`; publishing is just an HTTP PUT.
66
+
67
+ **6. Add it to Leftoff** on the Mac that should pull it (with the read key on the clipboard):
68
+
69
+ ```sh
70
+ pbpaste | leftoff agents add myagent https://leftoff-agent-feed.<you>.workers.dev/feed.json --key -
71
+ ```
72
+
73
+ Or use **Settings → Agents → Add agent feed**. It shows up on the board within 2 minutes (right away from the CLI).
74
+
75
+ ### Your own domain
76
+
77
+ To serve it at, say, `feed.example.com` (a zone in the same Cloudflare account): uncomment the `routes` line in `wrangler.toml`, set the hostname, and deploy again. **No DNS changes needed:** a Custom Domain creates its own DNS record and certificate.
78
+
79
+ **If deploy fails with "This Worker does not exist on your account [code: 10007]"** right after "Uploaded …", the upload worked, and Cloudflare wasn't quite ready when wrangler attached the domain. This happens on the first deploy with a route. Either:
80
+
81
+ - run `npx wrangler deploy` again (usually enough), or
82
+ - comment `routes` out, deploy, then add the domain in the dashboard: **Workers & Pages → your Worker → Settings → Domains & Routes → Add → Custom domain**.
83
+
84
+ ### Good to know
85
+
86
+ - **Rotate a key:** run `leftoff agents keygen` again, paste the new hash, deploy. Give Leftoff the new read key with `leftoff agents add` again (same name and URL), or give the agent the new write key.
87
+ - **Committing the folder is fine,** `wrangler.toml` included. The KV id only works inside your Cloudflare account, and a key's SHA-256 hash can't be turned back into the key. Never commit the keys themselves.
88
+ - **Cost:** a pull every 2 minutes is about 720 reads a day, well inside the free plan.
89
+
90
+ ## Other ways to host it
91
+
92
+ Anything Leftoff can fetch works. The rules are in [agent-feed.md → Serving it](agent-feed.md#serving-it).
93
+
94
+ - **Any HTTPS endpoint** you already run: return the feed JSON at a URL, check `Authorization: Bearer <key>`, send an `ETag` and answer `If-None-Match` with `304`. Leftoff falls back to `?key=` if the server rejects the header.
95
+ - **A static file on a secret URL**, for example object storage behind a long random path. Use it with no key, or let the server ignore it. The URL is then the secret, and it's stored in your synced folder, so this suits personal setups only.
96
+ - **`http://localhost`** for an agent running on the same Mac: plain http is accepted on `localhost` and `127.0.0.1` only.
97
+
98
+ ## Connect Tab
99
+
100
+ If you use [Tab](https://tab.bot), text it:
101
+
102
+ > connect my Leftoff
103
+
104
+ Tab sets up the feed (the Worker above, on your Cloudflare account, or wherever you choose), publishes its work there, and texts you back the one line to run, `… | leftoff agents add tab <url> --key -`, with the read key. Tab's sessions then show on your board as **✦ Tab**. Its Needs you items get a **Text Tab** button, which opens Messages (on a phone, a text to Tab).
@@ -0,0 +1,103 @@
1
+ {
2
+ "version": "leftoff-agent-feed/1",
3
+ "agent": {
4
+ "name": "Tab",
5
+ "icon": "✦",
6
+ "url": "https://tab.bot",
7
+ "reply": {
8
+ "label": "Text Tab",
9
+ "url": "sms:+15555550123"
10
+ },
11
+ "replyHint": "reply by texting Tab"
12
+ },
13
+ "generatedAt": "2026-10-10T20:05:00Z",
14
+ "sessions": [
15
+ {
16
+ "id": "leftoff-054",
17
+ "title": "Leftoff 0.5.4: external agents",
18
+ "project": "leftoff",
19
+ "status": "active",
20
+ "startedAt": "2026-10-10T19:37:00Z",
21
+ "updatedAt": "2026-10-10T20:04:00Z",
22
+ "checkpoints": [
23
+ {
24
+ "id": "cp-1",
25
+ "at": "2026-10-10T19:40:00Z",
26
+ "summary": "Agreed the feed design: pull-based, one Mac pulls, sessions stored as Markdown.",
27
+ "done": "Design written down",
28
+ "next": "Build src/agents.js"
29
+ },
30
+ {
31
+ "id": "cp-2",
32
+ "at": "2026-10-10T20:04:00Z",
33
+ "summary": "Feed validation and the pull loop work; the board shows Tab as a machine.",
34
+ "done": "Schema, ETag pulls, board chip",
35
+ "next": "Settings → Agents and the e2e test"
36
+ }
37
+ ],
38
+ "needsYou": [
39
+ {
40
+ "id": "nk-1",
41
+ "text": "Pick a name for the agent chip: \"Tab\" or \"Tab (assistant)\"?",
42
+ "since": "2026-10-10T20:04:00Z"
43
+ }
44
+ ],
45
+ "lists": [
46
+ {
47
+ "name": "Ship 0.5.4",
48
+ "items": [
49
+ {
50
+ "n": 1,
51
+ "text": "Feed format and docs",
52
+ "done": true
53
+ },
54
+ {
55
+ "n": 2,
56
+ "text": "Pull every 2 minutes with ETag",
57
+ "done": true
58
+ },
59
+ {
60
+ "n": 3,
61
+ "text": "Settings → Agents",
62
+ "done": false
63
+ },
64
+ {
65
+ "n": 4,
66
+ "text": "Push to GitHub",
67
+ "done": false
68
+ }
69
+ ]
70
+ }
71
+ ],
72
+ "links": [
73
+ {
74
+ "label": "Repo",
75
+ "url": "https://github.com/acme/web"
76
+ }
77
+ ]
78
+ },
79
+ {
80
+ "id": "leftoff-dev-deploy",
81
+ "title": "leftoff.dev deploy",
82
+ "project": "leftoff.dev",
83
+ "status": "waiting",
84
+ "startedAt": "2026-10-09T15:00:00Z",
85
+ "updatedAt": "2026-10-10T18:30:00Z",
86
+ "checkpoints": [
87
+ {
88
+ "id": "d-1",
89
+ "at": "2026-10-10T18:30:00Z",
90
+ "summary": "The site builds; DNS for leftoff.dev isn't pointed yet.",
91
+ "next": "You add the CNAME at the registrar"
92
+ }
93
+ ],
94
+ "needsYou": [
95
+ {
96
+ "id": "dns",
97
+ "text": "Add a CNAME for leftoff.dev pointing at leftoff.sites.tab.bot",
98
+ "since": "2026-10-10T18:30:00Z"
99
+ }
100
+ ]
101
+ }
102
+ ]
103
+ }
@@ -0,0 +1,139 @@
1
+ # Agent feed format: `leftoff-agent-feed/1`
2
+
3
+ Leftoff 0.5.4 can show work done **outside Claude Code** on your board, as if it were another machine. An assistant you work with (Tab today; Codex or anything else later) publishes a small private JSON feed. Your Leftoff daemon pulls it every 2 minutes. The feed's sessions show up on the board, its checkpoints on each session's page, what it's waiting on in **Needs you**, its checklists on **Lists**, and all of it in **Catch me up** and the digest. Everything from a feed is **read-only**: you answer the agent the way you normally do (with Tab, by texting). The feed says how, if at all: an optional `agent.reply` puts a button like **Text Tab** on its Needs you cards.
4
+
5
+ To host a feed of your own (a Cloudflare Worker template, `leftoff agents keygen` and `leftoff agents publish`), see **[agent-feed-hosting.md](agent-feed-hosting.md)**.
6
+
7
+ The format is meant to be simple enough to write by hand. [agent-feed.example.json](agent-feed.example.json) is a complete example.
8
+
9
+ ## The shape
10
+
11
+ ```json
12
+ {
13
+ "version": "leftoff-agent-feed/1",
14
+ "agent": {
15
+ "name": "Tab", "icon": "✦", "url": "https://tab.bot",
16
+ "reply": { "label": "Text Tab", "url": "sms:+15555550123" },
17
+ "replyHint": "reply by texting Tab"
18
+ },
19
+ "generatedAt": "2026-10-10T20:05:00Z",
20
+ "sessions": [
21
+ {
22
+ "id": "leftoff-054",
23
+ "title": "Leftoff 0.5.4: external agents",
24
+ "project": "leftoff",
25
+ "status": "active",
26
+ "startedAt": "2026-10-10T19:37:00Z",
27
+ "updatedAt": "2026-10-10T20:04:00Z",
28
+ "checkpoints": [
29
+ { "id": "cp-2", "at": "2026-10-10T20:04:00Z", "summary": "The board shows Tab as a machine.", "done": "Schema, ETag pulls, board chip", "next": "Settings → Agents" }
30
+ ],
31
+ "needsYou": [
32
+ { "id": "chip-name", "text": "Pick a name for the agent chip", "since": "2026-10-10T20:04:00Z" }
33
+ ],
34
+ "lists": [
35
+ { "name": "Ship 0.5.4", "items": [ { "n": 1, "text": "Feed format and docs", "done": true }, { "n": 2, "text": "Push to GitHub", "done": false } ] }
36
+ ],
37
+ "links": [ { "label": "Repo", "url": "https://github.com/acme/web" } ]
38
+ }
39
+ ]
40
+ }
41
+ ```
42
+
43
+ ### Top level
44
+
45
+ | Field | Required | What it is |
46
+ |---|---|---|
47
+ | `version` | yes | Exactly `"leftoff-agent-feed/1"`. |
48
+ | `agent.name` | yes | Shown as the machine chip (up to 60 characters). |
49
+ | `agent.icon` | no | A short text icon, usually one emoji (up to 16 characters). It's text, not an image URL. |
50
+ | `agent.url` | no | The agent's home page. `https://` only. It only links the agent's chip and name; it is **not** a reply button. |
51
+ | `agent.reply` | no | (0.5.6) How to reply, if you can: `{ "label": "Text Tab", "url": "sms:+15555550123" }`. Shown as a button on the agent's Needs you cards, its session page and Catch me up. **No `reply`, no button.** `label` is up to 40 characters. `url` must be `https:`, `sms:`, `imessage:`, `mailto:` or `tel:`, with no spaces or quotes; anything else fails the feed. On a phone an `sms:` link opens Messages with a text to that number. |
52
+ | `agent.replyHint` | no | (0.5.6) A few words on how replying works, up to 120 characters, e.g. `"reply by texting Tab"`. Shown under each Needs you item as "From Tab · reply by texting Tab". Without it: "From Tab · Read-only here". |
53
+ | `generatedAt` | yes | When the feed was written. |
54
+ | `sessions` | yes | A list (may be empty), up to 200. |
55
+
56
+ ### A session
57
+
58
+ One project or errand. Leftoff keeps it in its own Markdown file.
59
+
60
+ | Field | Required | What it is |
61
+ |---|---|---|
62
+ | `id` | yes | Stable id, unique in the feed. Letters, digits and `._:@#/-`, starting with a letter or digit, up to 120. |
63
+ | `title` | yes | One line, up to 200 characters. |
64
+ | `project` | no | Groups sessions on the board and in Catch me up, like a repo (up to 120). |
65
+ | `status` | yes | `active` (the agent is on it), `waiting` (paused, usually on you) or `done`. |
66
+ | `startedAt`, `updatedAt` | yes | Times. |
67
+ | `checkpoints` | no | Up to 200. Each: `id` (unique in the session), `at`, `summary` (up to 4000 characters, may span lines), and optional `done` and `next`. |
68
+ | `needsYou` | no | Up to 50. Each: `id`, `text` (up to 2000), optional `since`. Shown in Needs you while the session isn't `done`. |
69
+ | `lists` | no | Up to 30. Each: `name` (unique in the session) and `items` (up to 300). Each item: `n` (a whole number from 1, unique in the list, and **stable**: the same item keeps its number), `text`, `done` (`true`/`false`, default `false`). |
70
+ | `links` | no | Up to 20. Each: `label` and `url` (`https://` only). |
71
+
72
+ ### Rules
73
+
74
+ - **Times** are ISO 8601 with a zone: `2026-10-10T20:05:00Z` or `2026-10-10T15:05:00-05:00`.
75
+ - **Strict.** A wrong type, a missing required field, a bad time, a duplicate id or item number, anything over its limit, or an unknown field fails the **whole** feed. Leftoff keeps showing the last good pull and reports the problem in Settings → Agents, `leftoff agents` and `leftoff doctor`, with each problem's path (`sessions[0].links[0].url: must be an https:// link`). Fields starting with `x-` are allowed anywhere and ignored, for your own notes.
76
+ - **Size.** The whole feed is at most 1 MB.
77
+ - **Plain text only.** Every string is shown as escaped text: no HTML, no Markdown, no auto-linked URLs. Control characters and bidirectional-override characters are removed. Titles, names and labels are one line.
78
+ - **Links** must be `https://`, without a user name or password in them. Anything else fails the feed. The one exception is `agent.reply.url`, which may also be `sms:`, `imessage:`, `mailto:` or `tel:` (never `javascript:`, `data:`, `http:` or anything else). The board checks the scheme again before it draws the button.
79
+ - **Older Leftoff.** Leftoff 0.5.4 and 0.5.5 don't know `agent.reply` or `agent.replyHint`, and a strict feed with an unknown field fails as a whole there. Leave them out if a reader might still be on 0.5.5.
80
+
81
+ ## History
82
+
83
+ Each pull updates the session's current state: title, status, needs you, lists and links are whatever the feed says now. **Checkpoints accumulate**: a feed can publish only its latest few, and Leftoff keeps the ones it saw before (up to 500 per session). A session that disappears from the feed stays on the board as ended, marked "no longer in the feed", and its needs-you items stop counting.
84
+
85
+ ## Serving it
86
+
87
+ The step-by-step guide, with a ready-made Cloudflare Worker, is [agent-feed-hosting.md](agent-feed-hosting.md). What any host has to do:
88
+
89
+ - Any HTTPS URL works: a static file, a gist-like private URL, an endpoint. Plain `http://` is accepted only on the Mac itself (`localhost`, `127.0.0.1`), for an agent that serves its feed locally.
90
+ - **The key.** Leftoff sends `Authorization: Bearer <key>`. If the server answers 401 or 403, it tries once more with `?key=<key>` on the URL and keeps using that way. Don't put the key in the URL you give Leftoff: the URL is stored in the synced folder, and the key isn't.
91
+ - **ETag.** Send an `ETag`. Leftoff sends it back as `If-None-Match`, and a `304 Not Modified` costs nothing.
92
+ - **Redirects** are followed only within the same origin, at most 3. Leftoff won't send your key to another site.
93
+ - Leftoff identifies itself as `User-Agent: Leftoff/<version>`.
94
+
95
+ ## Adding a feed
96
+
97
+ On the Mac that should pull it:
98
+
99
+ ```sh
100
+ leftoff agents add tab https://example.com/feeds/yourname.json --key <key>
101
+ # or keep the key out of your shell history:
102
+ pbpaste | leftoff agents add tab https://example.com/feeds/yourname.json --key -
103
+ ```
104
+
105
+ Or the board: **Settings → Agents → Add agent feed** (name, URL, key). Then `leftoff agents` lists the feeds, `leftoff agents refresh [name]` pulls now, and `leftoff agents remove <name>` stops pulling and moves the history to `agents/.removed/` (`--purge` deletes it instead).
106
+
107
+ The name (`tab`) is the feed's id in Leftoff: lowercase letters, digits and `-`. The chip shows the feed's own `agent.name`.
108
+
109
+ ## Where it goes
110
+
111
+ | What | Where |
112
+ |---|---|
113
+ | The feed's name and URL, which Mac pulls it | `agents/<name>/source.json` in the folder (syncs; no key) |
114
+ | The last pull: when, any error, the ETag | `agents/<name>/status.json` in the folder (syncs) |
115
+ | One file per session | `agents/<name>/sessions/<id-slug>-<hash>.md` in the folder (syncs). Markdown for people, with a data block at the end that Leftoff reads back. Leftoff rewrites it on each pull, so don't edit it. |
116
+ | The key | `~/.config/checkpoints/agents/<name>.key`, mode 0600, on the pulling Mac only |
117
+
118
+ Nothing from a feed is ever written outside `agents/<name>/`: file names come from a hash of the id, and every path is checked before writing.
119
+
120
+ ## Which Mac pulls
121
+
122
+ **One Mac pulls each feed: the one you added it on.** That's the Mac that holds the key, since keys never go in the synced folder. It writes the session files, and your other Macs read them through the sync, like any other machine's sessions. They show "pulled by studio" in Settings → Agents.
123
+
124
+ Why not every Mac? Two Macs pulling the same feed would write the same files, and sync tools turn that into conflict copies (Leftoff's folder is designed so each file has one writer). It would also mean copying the key to every Mac and pulling the feed twice as often for no new information. If the pulling Mac is retired, run `leftoff agents add <name> <url> --key <key>` on another Mac: that Mac takes over pulling.
125
+
126
+ ## Read-only
127
+
128
+ Feed sessions never get **Go**, **Copy resume**, **Restore**, answering, renaming, pinning or checkpoints. Their lists can't be ticked on the board; tell the agent and the next pull shows it. Nothing is answered on the board. If the feed has `agent.reply`, its button hands you off to where you do answer (Messages, mail, a web app). Without it there's no button, just "Read-only here" (or the feed's `replyHint`).
129
+
130
+ ## Publishing from a script
131
+
132
+ Any agent or script can update its feed with one command, given the write key:
133
+
134
+ ```sh
135
+ leftoff agents publish feed.json --url https://feed.example.com/feed.json --write-key - # key on stdin
136
+ leftoff agents publish feed.json --url … --dry-run # validate only (or: leftoff agents validate feed.json)
137
+ ```
138
+
139
+ `publish` runs the same validator the daemon uses and sends nothing if the feed has a problem. It sends the file byte for byte as an HTTP `PUT` with `Authorization: Bearer <write key>`, follows no redirects (so the key can't be carried elsewhere), and keeps the key out of its output. The key can also come from `LEFTOFF_AGENT_WRITE_KEY`.