Hand a file from an AI agent to a human being, and back.
melo.github.io/small-share-app — what it is, what it does, and how to run it, on one page.
An agent uploads a markdown report, an image or a PDF and gets back one random URL. It gives that URL to a person, who opens it in a browser and reads the thing — markdown with real typography and drawn mermaid diagrams, images shown, PDFs in the browser's own viewer. Fifteen days later the file is gone and so is the URL.
It works the other way too: the home page has a drop zone, so a human can hand a screenshot or a spec to an agent and paste back the URL.
That is the whole product.
One Perl process (Mojolicious::Lite), one SQLite file, one directory of blobs. No database server, no object store, no queue, no build step for the front end. The whole thing is about 1,500 lines.
Agents produce things worth looking at. Pasting 400 lines of markdown into a
terminal is not reading it, and neither is catting a PNG. Uploading to a
pastebin means an account and a third party; attaching to a chat means whatever
that chat supports. This is the small, boring, self-hosted answer: a URL that
renders the file properly and then expires.
git clone https://github.com/melo/small-share-app
cd small-share-app
cp .env.example .env # put a Tailscale auth key in it
docker compose up -dThe stack is the app plus a tailscale serve sidecar. The app publishes no
host ports; the sidecar joins your tailnet as its own node, gets a real
Let's Encrypt certificate automatically, and reverse-proxies to the app. You end
up with https://share.<your-tailnet>.ts.net, reachable by your devices and
nobody else's.
The serve config uses ${TS_CERT_DOMAIN}, substituted by Tailscale at startup
with the node's own name — so nothing in this repo has to know your tailnet, and
tailscale-serve.json works unchanged for everyone.
If your tailnet has tailnet lock enabled, the new node registers but stays unreachable until you sign it in the admin console. Check reachability from a different device, never from the host running the container: local
tailscaledresolves and serves an unsigned node's own name on its own host, socurlthere returns 200 while the name does not resolve anywhere else.
docker compose -f docker-compose.tsdproxy.yml up -dOne container instead of two: tsdproxy watches Docker for labelled containers and gives each its own tailnet node. No sidecar per service and no auth key in this stack. Pick this or the sidecar above, not both.
docker compose -f docker-compose.traefik.yml up -dRouted by label, TLS terminated by Traefik. Read Security first — unlike the two options above, this one very possibly puts a service with no authentication on the open internet.
docker compose -f docker-compose.local.yml up -d --build
open http://127.0.0.1:8080Bound to loopback deliberately — see Security.
claude mcp add --transport http share https://share.<your-tailnet>.ts.net/mcpA Streamable HTTP MCP server at /mcp, built on the CPAN MCP
distribution by the Mojolicious author. It speaks
protocol revision 2026-07-28 — stateless, no initialize handshake,
server/discover in its place — and answers the older handshake too, so clients
that have not caught up keep working.
Any MCP client handles the protocol for you. If you are hand-writing calls with
curl, note that this revision's HTTP binding also wants routing headers
(Mcp-Method, and Mcp-Name on tools/call) restating the body, plus the
protocol version and client capabilities in _meta on every request.
It never carries the file itself, in either direction. Every tool deals in URLs; the agent moves the bytes with curl, straight off disk. Four tools:
| tool | what it does |
|---|---|
get_upload_url |
a URL, and a ready-to-run curl command, for putting a file in |
list_shared_files |
what this session_id has shared, with both URLs for each |
get_shared_file |
one file: metadata, the human's url, and the content_url to fetch |
delete_shared_file |
delete now — needs the delete_password from the upload |
The reason is arithmetic. A tool argument or result passes through the model's
context verbatim, and base64 inflates by a third: a 20 KB screenshot costs
thousands of tokens to send and thousands more to read back, and a 3 MB PDF does
not fit at all. curl -F file=@… moves it off disk for nothing.
The upload flow is three steps and no state:
get_upload_url(filename: "report.md", path: "/tmp/report.md", session_id: …)
→ { "command": "curl -fsS -F 'file=@/tmp/report.md' 'https://…/api/v1/files?…'", … }
run the command
→ { "url": "https://share.…/f/rK7mQ2…", "content_url": "…", … }
give the human the "url"
Nothing is reserved and nothing is written until the bytes arrive, so an
abandoned get_upload_url costs exactly nothing — there is no half-finished
upload to expire and reap.
The cost, stated plainly: an MCP client with no shell and no HTTP tool cannot upload through this server at all. That is the trade, and it is the right one for a coding agent, which has both.
The initialize response carries instructions built from the running
configuration — your real retention, your real size cap, your own
SHARE_NOTICE — so an agent that has only seen the tool list still uses the
service correctly and never quotes someone else's numbers back at you.
S=https://share.your-tailnet.ts.net
# upload — raw body is the friendliest form
curl --data-binary @report.md "$S/api/v1/files?filename=report.md&session_id=$SESSION"
# ...or multipart, or JSON with base64
curl -F file=@screenshot.png "$S/api/v1/files?session_id=$SESSION"
curl -H content-type:application/json "$S/api/v1/files" \
-d '{"filename":"doc.pdf","content_base64":"'"$(base64 -w0 doc.pdf)"'"}'
curl "$S/api/v1/files?session_id=$SESSION" # what this session has shared
curl "$S/api/v1/files/$ID" # metadata
curl "$S/api/v1/files/$ID/content" # the bytes
curl -X DELETE -H "x-delete-password: $PW" \
"$S/api/v1/files/$ID" # delete early
curl "$S/api/v1/health" # livenessUpload answers 201 with JSON. Three fields matter: url is what you give a
person, content_url is what a machine fetches, and delete_password is
disclosed exactly once, here — no other call returns it. Optional on any
upload: session_id, title, note, ttl_days (shorter than the configured
maximum, never longer), and delete_password if you would rather choose it.
There is also /api — a page describing all of this, with the OpenAPI document
behind it at /api?openapi=1 or via Accept: application/openapi+json. Worth
knowing: the OpenAPI Specification defines no media type for serving a
description document and none is registered with IANA, so that negotiation is
convention. ?openapi=1 is the unambiguous form.
The REST API is the data plane for agents too — get_upload_url hands back a
URL into exactly these endpoints. It still accepts JSON with base64, which is
useful for a client that has HTTP but no shell.
The home page is the drop zone, always open — drag files in, paste a
screenshot, or pick them, and get back each URL with a one-click Copy
button. Everything explanatory lives at /how-to, one link away in the top bar,
because the common visit is "I have a file to hand over" and not "tell me what
this is".
Below the drop zone, Recent uploads lists what this browser has sent, newest
first, with expiry countdowns and Copy buttons. It is localStorage only —
the server keeps no such list, and there is deliberately no "everything"
endpoint — so it is pruned as files expire, and Forget these clears the
browser's memory without deleting anything from the server.
It is still a plain multipart form: with JavaScript off it uploads and lands on
a result page saying the same things. public/assets/upload.js only upgrades
that same form with drag-and-drop, paste, per-file progress, inline results, the
Copy buttons and the history. Hand-written rather than Dropzone.js, whose stable
line has not moved since 2021: ~300 lines, no styling opinions to fight, and no
CSP exemption of its own.
All of it is environment variables. All of it is optional except where noted.
| variable | default | what it does |
|---|---|---|
SHARE_ROOT |
/workspace |
the one directory: share.db + files/ |
SHARE_BASE_URL |
derived from the request | the base of every URL handed out |
SHARE_TTL_DAYS |
15 |
retention, and the ceiling an upload may ask for |
SHARE_MAX_BYTES |
33554432 |
per-file limit |
SHARE_NOTICE |
empty | one line of deployment truth, shown on /how-to and in the MCP instructions |
SHARE_MAX_TOTAL_BYTES |
50 GB | ceiling on everything held at once; the oldest are evicted over it |
SHARE_RATE_PER_SECOND |
1 |
upload attempts per client per second; 0 disables |
SHARE_RATE_PER_MINUTE |
10 |
upload attempts per client per minute; 0 disables |
SHARE_HEALTH_DETAIL |
off | let /api/v1/health report files and bytes held. Leave off in public. |
SHARE_SECRET_KEY |
generated into the workspace | HMAC key for signed upload URLs |
SHARE_REQUIRE_SIGNED_UPLOADS |
off | reject any upload without a signed ticket from get_upload_url |
MOJO_REVERSE_PROXY |
0 |
set to 1 behind a proxy that sets X-Forwarded-* |
TS_AUTHKEY |
— | required for the Tailscale stack |
TS_HOSTNAME |
share |
the node name, and therefore the hostname |
SHARE_NOTICE is worth setting. It is the one thing the code cannot know —
"Reachable on the office VPN only", "ask #infra for access" — and it reaches
both the humans on the home page and every agent through MCP.
make # list every target
make up # start the Tailscale stack
make dev # run locally on 127.0.0.1:8080, no Tailscale
make test # build through the test stage — the whole suite
make coverage # the same suite under Devel::Cover, with a 90% floor
make e2e # the browser suite, against a throwaway instance
make health # is it alive, and what is it holding
make list # what is currently shared, from the database
make reap # delete expired files now
make du # disk used
make backup # stop, tar the workspace, start
Everything persistent lives in one directory (./data by default): share.db
and files/. Back that up and you have backed up the service. Delete the
container and you have lost nothing.
Expired files are deleted by the app itself, hourly. Every worker holds the
timer and takes an atomic claim on a meta row, so exactly one of them does the
work. Blobs are unlinked before rows are deleted: an orphaned blob is silent
disk growth, an orphaned row is a 500, and the first is the better failure.
Read this before you deploy it anywhere interesting.
There is no authentication. Anything that can reach the port may upload, list and delete. This is a deliberate trade, not an oversight: it removes the token-distribution problem entirely, so an agent container needs no credential, no mount and no rotation. It is the right trade on a private network and the wrong one on the open internet.
If you need auth, the seam is the single under '/api/v1' in share.pl.
Nothing else in the app assumes anonymity.
The URL is the only credential. 32 base62 characters from /dev/urandom,
about 190 bits — not guessable, not enumerable, never derived from the filename
or the contents. Every response carrying it also carries X-Robots-Tag: noindex, Referrer-Policy: no-referrer and Cache-Control: private, no-store,
which are the three ways a URL leaks.
Uploaded markdown is treated as hostile, because an agent can be talked into writing anything. Three independent layers, none trusted alone:
- The rendered HTML is sanitised against a tag and attribute allowlist —
script,iframe,objectand friends removed outright, everyon*attribute stripped,href/srcrestricted to safe schemes. - The preview is served in an iframe with
sandbox="allow-scripts"and noallow-same-origin, so it runs in an opaque origin and cannot reach the app's API, cookies or storage even if something gets through. - A CSP naming the exact origin —
'self'matches nothing in an opaque origin, which is precisely why it is spelled out.
SVGs are previewed through an <img> tag, which never executes script, and
their raw bytes carry Content-Security-Policy: sandbox so that navigating
straight to them cannot execute them in the app's origin either.
Upload URLs are signed. get_upload_url returns a URL carrying an exp and
an HMAC sig over every other parameter, valid for an hour. Editing the
session_id, title, ttl_days or expiry invalidates it. To be clear about
what that is and is not: with no authentication it is not access control —
anything that can reach the service can POST to the endpoint directly. What it
buys today is that a ticket cannot be altered in transit or hoarded forever, and
what it buys later is a place for a real credential to live. Set
SHARE_REQUIRE_SIGNED_UPLOADS=1 to make tickets mandatory.
Nothing a stranger can open reports the inventory. How many files are held and how much disk is in play is the operator's business: it says how busy the box is, roughly how much disk is in play, and — watched for a few minutes — whether something a visitor uploaded is still there or has been evicted.
No page carries it at any setting. The single exception is /api/v1/health,
because a monitoring agent needs numbers; that is off by default, and
SHARE_HEALTH_DETAIL=1 turns it on for a deployment where only you can reach
it. Leave it off in public.
Uploads are rate limited, per client: one a second and ten a minute by
default, counted in SQLite rather than in process memory — the app runs prefork,
and an in-memory counter would hand each client the limit multiplied by the
worker count. Attempts are counted, not successes, so hammering the endpoint
with rejects is limited too. Over the limit is a 429 with Retry-After.
A disk ceiling, enforced by eviction rather than refusal. Past
SHARE_MAX_TOTAL_BYTES (50 GB by default) the oldest files are removed until
it fits. A public box that fills its disk goes down, which is a worse outcome
than losing the oldest thing on it.
Reading and deleting are separate capabilities. The share URL grants
reading. Deleting needs the delete_password returned by the upload — passed as
X-Delete-Password, a JSON field, or a form field. A wrong password and a file
that never existed get the same answer with the same status, so the endpoint
cannot be used to discover which ids exist. Lose the password and the file
simply expires on its own. In the browser this is invisible: the drop zone keeps
the password in localStorage beside the record, which is why Recent uploads
can offer a Delete button and a page you were merely sent cannot.
No route lets a client choose or overwrite an id. There is no PUT
anywhere, no client-supplied path, and nothing that modifies a stored file.
Every accepted upload mints a fresh secret from /dev/urandom; the same bytes
uploaded twice are two files.
The declared type must match the bytes. Every upload is classified by
extension and by magic bytes. A .png that is really a PDF is rejected rather
than stored and served with a lying Content-Type. HEIC is accepted — it is
what phones produce — but the viewer says plainly that only Safari can display
one, rather than showing a broken image icon.
Chrome pages run with no script source at all. Only the two pages carrying
the uploader get script-src 'self', and they ask for it by name.
One Perl process (Mojolicious::Lite), one SQLite file, one directory of blobs. No database server, no object store, no queue, and no build step for the front end — the CSS and JavaScript are served as written. About 1,500 lines in total, which is small enough that you can read all of it before trusting it with anything.
Everything persistent is in one directory. Back that up and you have backed up the service.
- docs/DESIGN.md — why it is shaped this way, and what was traded away for it
- CONTRIBUTING.md — running the tests, and what a good change looks like
- SECURITY.md — what is in scope for a report, and what is a documented trade
- MAINTAINING.md — releases, and publishing images from a fork
Issues and pull requests welcome. See CONTRIBUTING.md.
MIT. See LICENSE.