Pasty Guide
A small self-hosted hand-off board. Drop in text or a file, get a 6-character share code, take it with the code, gone when it expires.
23456789ABCDEFGHJKMNPQRSTUVWXYZ. The look-alikes 0/O/1/I/L are excluded so a code survives being read out loud; input is case-insensitive.1What it is
| Trait | Fact |
|---|---|
| HTML pages | For AI agents: host one HTML page with a token and get a /p/ URL that opens in the browser (see §9) |
| Sign-in | None. Neither creating nor reading is authenticated — the code is the only key |
| Content | Text, one file, or text plus a file |
| Text limit | 50,000 characters (the JSON endpoint also caps the body at 100KB — see §4.2) |
| File limit | 100 MB, one file, any type |
| Expiry | 5 minutes / 30 minutes / 1 hour / 1 day / 7 days; default 1 day |
| Delete / edit / list | None of them. Once created it cannot be taken back — only wait for it to expire |
| Storage | Metadata and text in Redis (with a TTL), files in Cloudflare R2 |
Good for: moving a snippet or a file between your own devices, reading a code out loud to someone in the room, handing a build artefact from a pod or server to a remote person, giving a client a short-lived download link.
Not for: anything you might need to retract, anything that must persist, and secrets — keys, credentials, personal data (see §7).
2Using the web app
2.1 Creating a paste
Open pasty.youlidao.ai. The Create tab is selected by default.
- Text: type or paste into the box. The counter at the bottom right shows
used / 50,000, turning red and disabling the button when you go over. - File (optional): drag onto the dashed area, click it to browse, or — when focus is outside the text box — press
⌘V(Ctrl+Von Windows) to paste an image from the clipboard. One file only; dropping several keeps the first and says so. Images show a thumbnail, anything else shows an icon, name, size and MIME type; × removes it. - Expiry: the dropdown, defaulting to 1 day.
- Press Create. With a file the button becomes a progress bar. Text over 50,000 characters and files over 100MB are rejected in the browser before any request goes out.
- The result screen shows the share code in large type, when it expires, and buttons to copy it or start another.
2.2 Retrieving a paste
- By code: switch to the Retrieve tab and type the 6 characters. The field upper-cases as you type and drops characters that are not in the set — and says which, rather than swallowing them silently. Half a second after the 6th character it queries and navigates; the button does the same immediately. A code that does not exist clears the field and says so.
- By link:
https://pasty.youlidao.ai/{CODE}, in any case.
2.3 What the view page shows
The top right carries the relative expiry ("Expires in 4 min", "Expires in 3 h", "Expires in 6 d"). Below it, by content type:
| Content | Shown as |
|---|---|
| Text | Monospace <pre>, newlines and indentation preserved, with a copy button underneath |
| Image | png / jpeg / gif / webp / svg render in place; click opens the original in a new tab. Name and size below |
| Embedded in the browser's own reader, with "open in new tab" and "download" beside it. iOS Safari shows only the first page in the frame — use "open in new tab" | |
| Anything else | zip, Word, Excel, video…: a file card (icon, name, size, MIME) plus a download button |
| Text + file | Text first, then an attachment divider, then the file |
A request that has not returned within 10 seconds raises a timeout toast.
2.4 Three ways to pass it on
| Form | Example | Good for |
|---|---|---|
| The code alone | MN3W2R | Spoken, written down, or over the phone — they type it on the home page |
| Page link | https://pasty.youlidao.ai/MN3W2R | Chat or email: opens the whole page |
| Direct file link | https://pasty.youlidao.ai/api/pastes/MN3W2R/file | Images and PDFs render; anything else downloads. Suitable for an <img> tag or wget |
3Command line and scripts
3.1 Sending text
curl -s -X POST https://pasty.youlidao.ai/api/pastes \
-H 'Content-Type: application/json' \
-d '{"content":"the text to share","expiresIn":"1h"}'
# → {"code":"7FZEN4","expiresAt":"2026-09-09T16:56:23.246Z"}
When the content comes from a file, build the JSON with jq rather than escaping by hand:
jq -n --rawfile c notes.md '{content:$c, expiresIn:"1d"}' \
| curl -s -X POST https://pasty.youlidao.ai/api/pastes \
-H 'Content-Type: application/json' --data @-
3.2 Sending a file (with an optional note)
curl -s -m 120 -X POST https://pasty.youlidao.ai/api/pastes \
-F "file=@/path/report.pdf" \
-F "content=one line on what this is" \
-F "expiresIn=1h"
# → {"code":"KTCK33","expiresAt":"…","hasFile":true}
- For several files,
zip -rthem into one first. The file endpoint allows 5 requests per minute per IP — do not loop over uploads. - No need to pass
type=: the server decides the MIME type from the file's own bytes (§4.4). - File names may be any Unicode, including CJK, and are preserved as sent.
3.3 Retrieving
# metadata + text
curl -s https://pasty.youlidao.ai/api/pastes/KTCK33
# text only
curl -s https://pasty.youlidao.ai/api/pastes/KTCK33 | jq -r .content
# download the file (-J uses the server's name, -O writes it out)
curl -s -O -J https://pasty.youlidao.ai/api/pastes/KTCK33/file
There is no /raw endpoint. GET /{CODE} returns the front-end's HTML. Scripts should always read /api/pastes/{CODE} and take .content.
3.4 Recommended flow for moving a file between machines
# sender
shasum -a 256 FILE
curl -s -X POST https://pasty.youlidao.ai/api/pastes -F "file=@FILE" -F "expiresIn=30m"
# receiver
curl -s -O -J https://pasty.youlidao.ai/api/pastes/CODE/file
shasum -a 256 FILE # compare with the sender
The transfer itself is lossless (zip and png round-trip byte-identical in testing); the checksum guards against typing the wrong code or a truncated download.
If your environment can only send JSON text (an old script, a restricted sandbox), fall back to base64: send base64 -i FILE | tr -d '\n' as content and base64 -d on the other side. Base64 inflates by ×1.33 and is bound by the 100KB body limit, so it only suits small files.
4API reference
4.1 Endpoints
| Method | Path | Purpose | Rate limit (per IP) |
|---|---|---|---|
GET | /api/health | Health check | None |
POST | /api/pastes | application/json = text only; multipart/form-data = file ± text | JSON 10/min; multipart 5/min — counted separately |
GET | /api/pastes/:code | Metadata + text | None |
GET | /api/pastes/:code/file | Stream the file for download or preview | None |
POST | /api/pages | Host one HTML page (token required): JSON html or multipart file | 10 per minute |
GET | /p/:code | Render a hosted page under the sandbox CSP | None |
GET | /p/:code/pdf | Export a hosted page as an A4 PDF (cached after the first render) | 10 per minute |
Every create response carries rate-limit headers: ratelimit-limit, ratelimit-remaining, ratelimit-reset (seconds) and ratelimit-policy (e.g. 10;w=60). Over the limit returns 429 with code set to RATE_LIMITED (text) or FILE_RATE_LIMITED (file).
4.2 POST /api/pastes · JSON
{ "content": "required, non-empty after trimming, ≤ 50,000 characters",
"expiresIn": "5m | 30m | 1h | 1d | 7d — optional, defaults to 1d" }
// 201
{ "code": "7FZEN4", "expiresAt": "2026-09-09T16:56:23.246Z" }
The request body is capped at 100KB. That is Express's JSON parser, and it applies before the character check: 40,000 CJK characters are about 120KB and come back as 413 request entity too large. Either keep CJK text under roughly 33,000 characters or use the content field of the multipart path, which has no such cap and accepts the full 50,000.
A validation failure looks like this (details only appears on the JSON path):
{ "error": "Content must not be empty",
"code": "CONTENT_EMPTY",
"details": [ { "path": "content", "message": "CONTENT_EMPTY" } ] }
4.3 POST /api/pastes · multipart
| Field | Required | Rule |
|---|---|---|
file | this or content | One file ≤ 100MB (104,857,600 bytes), any type |
content | this or file | Non-empty after trimming, ≤ 50,000 characters |
expiresIn | no | As above, defaults to 1d |
201 returns { "code", "expiresAt", "hasFile": true }; without a file there is no hasFile field. Order of operations: R2 first, Redis only on success; a failed R2 write means nothing is stored, and a failed Redis write rolls the R2 object back.
File name rules:
- Over 255 characters → 400
FILENAME_TOO_LONG - Containing
../,..\or their URL-encoded forms → 400FILENAME_INVALID, logged as a security event / \ : * ? " < > |become_; leading dots and spaces are stripped- An empty name becomes something like
unnamed.png, from the MIME type - CJK and other Unicode are preserved; they used to be mangled into latin1
4.4 How the MIME type is decided — preview or download
The server does not take the client's declared Content-Type at face value. In order:
- The file's magic number (images, PDF, zip, Office, audio and video)
- The client's declaration, but only when it is non-empty and not
application/octet-stream(this covers txt / csv / json / svg, which have no magic number) - The file extension
application/octet-stream
So curl -F file=@x.zip is stored as application/zip; a PDF with no extension and no declaration is still recognised and still previews inline; and a PDF renamed to .txt is still treated as a PDF.
4.5 GET /api/pastes/:code
The code must be exactly 6 characters, all from the set, or the response is 400. The server upper-cases it.
{
"content": "the note (an empty string when there is only a file)",
"type": "text | file | mixed",
"fileMetadata": {
"fileName": "screenshot.png", "fileSize": 74, "mimeType": "image/png",
"storageKey": "pastes/U9RBRW/screenshot.png"
},
"createdAt": "2026-09-09T17:00:41.045Z",
"expiresAt": "2026-09-09T17:05:41.045Z"
}
type and fileMetadata appear only when there is a file. Early text-only records may have no type at all; treat those as text.
4.6 GET /api/pastes/:code/file
Streams the file itself, without buffering it in memory. Response headers:
| Header | Value |
|---|---|
Content-Type | The resolved MIME type |
Content-Disposition | image/* → inline; PDF → inline; filename=…; everything else → attachment; filename=… (RFC 5987 encoded, so non-ASCII names are safe) |
Content-Length | File size |
Content-Security-Policy | Inline responses (images, PDF) add sandbox to the site baseline, so no script ever runs; attachments keep the baseline |
4.7 Error codes
Every error response is { "error": <English prose>, "code": <identifier> }. Branch on code: it is the stable contract, while error is prose for a human reader and its wording may change. The web app never displays error — it looks the code up in its own dictionary and shows it in the reader's language.
| Status | code | When |
|---|---|---|
| 400 | CONTENT_EMPTY | content is empty or only whitespace |
| 400 | INVALID_EXPIRY | expiresIn is not one of 5m/30m/1h/1d/7d |
| 400 | INVALID_CODE_FORMAT | the share code is not 6 valid characters |
| 400 | NO_CONTENT_PROVIDED | the multipart body has neither content nor file |
| 400 | FILENAME_TOO_LONG / FILENAME_INVALID | file name over 255 characters / containing path traversal |
| 400 | VALIDATION_ERROR | a malformed multipart body, or any other unclassified validation failure |
| 400 | PP_SLUG_INVALID | the slug is the wrong shape, length, or a reserved path |
| 401 | PAGE_AUTH_REQUIRED | Page created without a token, or with a wrong one |
| 401 | PP_AUTH_REQUIRED | permanent page created without a token, or with a wrong one |
| 404 | PASTE_NOT_FOUND | no such code, or it has expired |
| 404 | NO_FILE_IN_PASTE | that code is text only |
| 404 | FILE_NOT_FOUND | the R2 object has already been cleaned up |
| 413 | CONTENT_TOO_LARGE | text over 50,000 characters, or a JSON body over 100KB |
| 413 | FILE_TOO_LARGE | file over 100MB |
| 413 | PAGE_TOO_LARGE | HTML over 5MB |
| 409 | PP_SLUG_TAKEN | that slug already holds a permanent page or a live share code |
| 429 | RATE_LIMITED / FILE_RATE_LIMITED | text creation / file upload limit — counted separately |
| 501 | FILE_UPLOAD_DISABLED | a file arrived but the server has no R2 configured |
| 501 | PAGE_HOSTING_DISABLED | PAGE_API_TOKENS is not set on the server, so page hosting is off |
| 501 | PP_HOSTING_DISABLED | PP_API_TOKENS is not set on the server, so permanent pages are off |
| 503 | SERVICE_UNAVAILABLE / SERVICE_BUSY | Redis failure / 5 share-code collisions in a row |
| 503 | STORAGE_UNAVAILABLE / FILE_UPLOAD_FAILED | R2 unavailable / the upload failed |
| 503 | PDF_EXPORT_UNAVAILABLE | PDF render failed, the render queue is full, or Chromium is not configured on the server |
| 500 | INTERNAL | an unhandled server-side exception |
5Working with Claude Code / cc-hub
cc-hub sessions and build-centre pods on youlidao.ai ship with the pasty-share skill (v3). When someone asks the AI to "send me that" or "get this file to the client", it will:
- Upload the original bytes over multipart. The skill's hard rule: never convert a PDF or Word file to markdown and send that instead
- Default to
expiresIn=1h, zipping first when there is more than one file - Reply with the code and the page link, plus a direct /file link for images and PDFs
- State the expiry and that anyone holding the code can read it, every time
The skill's red lines: never upload .env files, private keys, tokens, credentials or database dumps; ask first when the content might be sensitive; and if the user insists, use a 5- or 30-minute expiry.
6Data lifecycle
| Stage | What happens |
|---|---|
| Create | Redis gets paste:{CODE} with a TTL of the chosen expiry; with a file, R2 gets pastes/{CODE}/<name> first |
| While valid | Unlimited reads, not counted |
| Expiry | The Redis key disappears on its own and reads return 404 |
| Cleanup | The server sweeps hourly and deletes R2 objects whose records are gone; R2's own lifecycle rule is the backstop (documented as 8 days) |
| Logs | Structured logs record IP, size, MIME type and the sanitised file name — never the content |
Codes come from a CSPRNG with rejection sampling for a uniform distribution: 316 = 887,503,681 combinations, with up to 5 retries on a collision.
7Security boundaries — read before using
- No authentication: the code is the only key, and anyone who has it — or the link — can read the content. Posting the link in a group chat means the whole group can read it.
- No retraction: a mistake can only be waited out. When in doubt pick 5 or 30 minutes.
- Do not upload: passwords, API keys, private keys, certificates, .env files, or anything with personal data.
- Uploads never execute: inline responses for images and PDFs carry a sandbox CSP, so even an SVG with a script in it does nothing (verified in a real browser). HTML, zip and the rest are always downloaded, never rendered.
- The pages cannot be framed by another site (
frame-ancestors 'self'). - Content pages are excluded from search engines: robots.txt disallows crawling share-code paths and the responses carry
X-Robots-Tag: noindex. That stops crawlers, not people holding the code. - Hosted HTML pages (§9) render only under
/p/{CODE}, with their own sandbox CSP: separate origin, no API calls, no form posts. Creating one needs a token.
8Frequently asked questions
- The browser says ERR_CONNECTION_CLOSED, or curl returns 000
- Occasional on direct connections from mainland China; a refresh or one retry gets through. The health check and read endpoints are themselves stable.
- The create button stays greyed out
- The text is empty and no file is selected, or the text is over 50,000 characters (the counter will be red).
- A PDF shows only its first page on my phone
- A limitation of the iOS Safari embedded frame. Use "open in new tab".
- A CJK file name downloads as mojibake
- Only for records uploaded before 2026-09-10, when the server decoded names as latin1. Uploads since then are fine, and the old records expire on their own.
- A zip uploaded with curl used to show as application/octet-stream
- curl does not recognise .zip and declares octet-stream, which the old code believed. The type now comes from the file's bytes, so
;type=is no longer needed. - Can I edit, delete or extend a paste
- No — there are no such endpoints. Create a new one.
- What do I do about a 429
- Wait for
ratelimit-reset(in seconds) from the response headers. Text is 10/min and files 5/min, per IP. Zip multiple files into one. - Long CJK text fails with 413 request entity too large
- The JSON body cap is 100KB. Use the multipart
contentfield, or split the text. - Can I change the interface language
- Yes. The footer has a language selector (简体中文 / English / 日本語 / 한국어) and the choice is remembered locally;
?lang=enand friends work too.
9HTML page hosting: temporary web pages for AI agents
9.1 What it is for
An AI agent has produced a report, dashboard or prototype and needs to hand a person a URL that opens in the browser, not a file to download. POST one HTML document, get back https://pasty.youlidao.ai/p/{CODE}, and it deletes itself when it expires.
| Property | Fact |
|---|---|
| Who can create | Token holders: header Authorization: Bearer <token>. With no token configured on the server the endpoint returns 501 and the feature is off |
| Who can view | As with everything else: anyone with the code or link, no login |
| Size | One HTML document ≤ 5 MB |
| Expiry | 5m / 30m / 1h / 1d / 7d, default 1 day |
| Share code | Same code space as text and files. /{CODE} shows a page card; /p/{CODE} renders the page |
| Rate limit | 10 per minute per IP |
9.2 Create a page
curl -s -X POST https://pasty.youlidao.ai/api/pages \
-H "Authorization: Bearer $PASTY_PAGE_TOKEN" \
-F "file=@report.html" \
-F "expiresIn=7d"
# → {"code":"MN3W2R","expiresAt":"…","url":"https://pasty.youlidao.ai/p/MN3W2R"}
When the HTML is already in a variable, use the JSON path; the field is html:
jq -n --rawfile h report.html '{html:$h, expiresIn:"1d"}' \
| curl -s -X POST https://pasty.youlidao.ai/api/pages \
-H "Authorization: Bearer $PASTY_PAGE_TOKEN" \
-H 'Content-Type: application/json' --data @-
- 201 response:
{ "code", "expiresAt", "url" }.urlis the link to send to a person. - The JSON request body may be up to twice the page limit, to leave room for escaping; for pages close to 5MB use multipart.
- Keep the token in an environment variable — never in the page, a log or a chat message.
9.3 What a page can do when opened
/p/{CODE} is served as text/html; charset=utf-8 with a dedicated CSP:
| Allowed | Not allowed |
|---|---|
| Inline scripts, and scripts from these CDNs: cdnjs.cloudflare.com, cdn.jsdelivr.net, unpkg.com, cdn.tailwindcss.com | Network calls: fetch / XHR / WebSocket are all blocked (connect-src 'none') |
| Inline styles, Google Fonts, data: fonts | Form submission (form-action 'none') |
| Images, audio and video from any https / data: / blob: source | Embedded iframes and objects |
| Pop-ups, external links in a new tab | localStorage / cookies: the page runs in its own sandbox (a separate origin) and access throws |
The sandbox means a page's scripts cannot act as pasty itself or read anything this site stores. Blocking outbound data keeps a page from working as a phishing form; it is not absolute (an image URL or a navigation can still carry data out), so the real gate is the token required to create one.
A code that does not exist, has expired, or holds text or a file rather than a page is redirected (302) to /{CODE}, where the normal view explains. An .html uploaded through the anonymous file endpoint is always a download and is never rendered under /p/.
9.4 Checklist for agents writing a page
- One file: inline CSS and JS; images as data: URIs or https links; load libraries only from cdnjs.cloudflare.com, cdn.jsdelivr.net, unpkg.com, cdn.tailwindcss.com, with a pinned version.
- No network requests: put the data in the page instead of fetching JSON.
- No localStorage; if you must, wrap it in try/catch and make the page work without it.
- Include
<meta charset="utf-8">and a viewport so it reads on a phone. Google Fonts is unreliable from mainland China, so give every font a system fallback. - Default expiry
1d; a report meant for people can use7d. Reply with theurland the expiry, and say that anyone with the link can see it. - No secrets, personal data or internal credentials — same rules as §7.
The full machine-readable description is at /llms.txt.
9.5 Export to PDF
Every hosted page has an Export PDF button in the bottom-right corner (shown in Chinese to readers whose browser language is Chinese). It points to /p/{CODE}/pdf, which you can also share directly.
- The server opens the same
/p/{CODE}in Chromium under the same sandbox rules and prints it to an A4 PDF: backgrounds kept, always in light mode, and the button itself left out. - The first export takes a few seconds up to around fifteen; the result is cached and later requests return it directly. The PDF expires and is cleaned up together with the page.
- Served
inlinewith a file name: phones and in-app browsers preview it in place; on a computer it can be saved. - Rate limit 10 per minute per IP. A failed render returns 503
PDF_EXPORT_UNAVAILABLE; retry. - For a good export, do not hide content behind hover or clicks, and put wide tables in a horizontally scrolling container; the PDF wraps them to A4 width.