stash-sh
v0.1.0
Published
ntfy moves signals. stash moves bytes. A topic-addressed artifact mailbox: curl a file up from one machine, curl it down from another.
Maintainers
Readme
The one-liner
# machine A, tonight
curl -T dist.tgz https://stash.legible.sh/rollout-h7x2rq/dist.tgz
# machine B, tomorrow — or even before A uploads: ?wait= long-polls until it lands
curl -fo dist.tgz "https://stash.legible.sh/rollout-h7x2rq/dist.tgz?wait=60"The hosted API at stash.legible.sh is in soft launch. To run the identical API yourself:
npx stash-sh serveand point the same commands athttp://localhost:4188.
Agents keep needing to move a file between two sessions that will never overlap. Session A is Claude Code on your laptop, finishing a build before shutdown. Session B is tomorrow's cloud sandbox that needs the tarball. Every existing answer is wrong in the same way: upload services mint a random URL after the upload, which session A must then relay to session B out-of-band — exactly the plumbing you don't have. With stash, the address is agreed on before the artifact exists, in the prompt: "stash it at rollout-h7x2rq/dist.tgz". Session A uploads to that name. Session B downloads from that name — hours later, from another machine, no coordination channel required. It's ntfy's pattern applied to bytes: the topic is the rendezvous.
The whole API
| Request | What happens |
|---|---|
| PUT /{topic}/{filename} | Upload bytes (curl -T native; POST --data-binary @f works too). Options: X-TTL: 30m\|12h\|7d\|<seconds> (default 24h, max 7d), X-Burn: N (delete after N downloads). → 201 {url, topic, name, size, expires, sha256, downloadsLeft?}. Re-PUT of the same name replaces it. |
| GET /{topic}/{filename} | The bytes. Content-Type guessed from extension, Content-Disposition set, X-Checksum: sha256:…, X-Expires, X-Downloads-Left. Counts against the burn counter; deletes at zero. ?wait=60 long-polls until the file exists (max 300s). 404 after expiry/burn — JSON for curl, a friendly page for browsers. |
| HEAD /{topic}/{filename} | Same headers, no body — and never consumes a burn download. |
| GET /{topic} | curl: {topic, files: [{name, size, expires, downloadsLeft, sha256}]}. Browsers: an HTML listing with download links and expiry countdowns. Accept: text/event-stream: live SSE — a list event on connect, then named put/delete events with JSON data, heartbeat comments every 25s. |
| DELETE /{topic}/{filename} | Remove early. → {deleted: true, topic, name}. |
| GET / | Usage — plain text for curl, HTML for browsers. |
| GET /README.md · /llms.txt | These docs, as text, for agents. Never token-gated; GET / with Accept: text/markdown serves the README too. Root-level only — /{topic}/README.md stays a stored file. |
Topics match [a-zA-Z0-9_-]{1,64} and are created by first use. Filenames start alphanumeric, then [a-zA-Z0-9._-], max 128. Errors are {"error": "...", "code": "...", "hint": "..."} with honest status codes — the hint spells out the correct next request. Limits — 25 MB/file, TTL ≤ 7d, 100 files/topic — are exported constants in src/limits.mjs.
Teach your agent
Paste this into your CLAUDE.md / AGENTS.md:
## stash — move files between machines/sessions (https://stash.legible.sh)
Pick one unguessable topic per job (e.g. handoff-x7k2mq9f) and treat it like a password.
upload curl -T dist.tgz https://stash.legible.sh/{topic}/dist.tgz
download curl -fo dist.tgz https://stash.legible.sh/{topic}/dist.tgz
wait for it curl -fo dist.tgz "https://stash.legible.sh/{topic}/dist.tgz?wait=60" # long-poll until uploaded
list curl https://stash.legible.sh/{topic}
peek curl -I https://stash.legible.sh/{topic}/dist.tgz # HEAD: metadata, never burns
delete curl -X DELETE https://stash.legible.sh/{topic}/dist.tgz
Upload options: -H "X-TTL: 7d" (default 24h) and -H "X-Burn: 1" (delete after 1 download).
Every upload response and X-Checksum download header carries the sha256 — verify it.
Files expire (24h default, 7d max), 25 MB max. Re-upload to the same name replaces.Self-hosting
npx stash-sh serve --data-dir ~/.stash # http://127.0.0.1:4188Or clone and run:
git clone https://github.com/legible-sh/legible.git
cd legible/stash
npm start -- --data-dir ~/.stash| Flag | Default | Does |
|---|---|---|
| --port | 4188 | Listen port |
| --host | 127.0.0.1 | Bind address (0.0.0.0 to expose) |
| --data-dir | temp dir + a loud warning | Blobs + a JSONL log, replayed on boot. Set this for any real use. |
| --token | off | Require Authorization: Bearer <t> on every topic route. The bytes are the sensitive data, so reads are gated too; only GET /, /README.md, and /llms.txt stay open. |
| --base-url | derived from Host header | Absolute URLs in responses and pages (set it behind a proxy) |
| --max-size | 25mb | Per-file cap, e.g. --max-size 200mb |
State is legible: blobs live under <data-dir>/blobs/, metadata in <data-dir>/meta.jsonl. Boot replays the log, drops expired files, compacts, and sweeps orphaned blobs.
CLI
The CLI is sugar over the same HTTP API — curl remains the contract. Base URL: --url, else $STASH_URL, else https://stash.legible.sh. Token: --token, else $STASH_TOKEN.
stash put <topic> <file...> [--ttl 24h] [--burn 1] # prints url + sha256
stash get <topic> <name> [-o out] [--wait 60] # verifies sha256; -o - for stdout
stash ls <topic>
stash rm <topic> <name>
stash serve [--port 4188] [--host H] [--data-dir D] [--token T] [--base-url U] [--max-size 25mb]Pro
Planned for the hosted instance, never gating the core verbs — self-host stays complete:
- Bigger files (the 25 MB cap is a hosted-abuse control, not a technical one — self-hosters already have
--max-size). - Longer TTLs — retention past 7 days.
- Read/write tokens — split upload and download rights on a topic without self-hosting.
- Virus scanning and content policies on the hosted instance.
- Orgs — shared token management and usage visibility for teams.
Straight talk
- The topic name is the whole access model. Anyone who can guess it can read, overwrite, or burn your files. Use high-entropy names (
handoff-x7k2mq9f, nottest); use--tokenwhen self-hosting anything sensitive. This is capability-by-obscurity, same trade as ntfy — we're honest about it, you should be too. - A download counts when it starts, not when it finishes. An interrupted transfer of a
X-Burn: 1file spends the download. UseHEADto peek andX-Burn: 2if you're nervous — that's why the counter takes an N. - Two racing downloads of a burn-1 file can both win. The window is one in-process await; it exists. If exactly-once matters, put a mutex in front (the family has one).
- Files are buffered in memory per request — right for ≤ 25 MB artifacts, wrong for a CDN. Don't put a CDN behind this.
- A hosted file mailbox is an abuse magnet. Exfil and malware distribution are the existential risks for stash.legible.sh; it ships with rate limits, scanning, and reporting or it doesn't ship. Self-hosting has none of those concerns — it's your disk. That's why self-host is the first-class path today.
- Everything expires. 7 days is the ceiling by design. stash is a mailbox, not storage — if you want an archive, you want S3.
The family
stash is one of the legible primitives, each an instantiation of the same principles — the whole API in a prompt, curl as the SDK, zero ceremony:
| | | |---|---| | gate (4180) | ntfy tells you things. gate asks you things. | | bigred (4181) | The big red button for your agent fleet. | | trail (4182) | The flight recorder for agent runs. | | slate (4183) | The blackboard from the multi-agent papers, as a URL. | | relay (4184) | The work queue your agents can provision themselves. | | mutex (4185) | flock(1) for agents that live on different machines. | | quorum (4186) | Coordination for agents that do not share a parent process. | | meter (4187) | The kill-brake for agent spend. 429-as-a-service. | | stash (4188) | ntfy moves signals. stash moves bytes. | | tally (4189) | StatHat reborn as ntfy. Three months too late — or right on time. |
License
MIT.
