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.

Site pasty.youlidao.ai Verified against the source on 2026-10-16
MN3W2R
Share codes are 6 characters from a set of 31: 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

TraitFact
HTML pagesFor AI agents: host one HTML page with a token and get a /p/ URL that opens in the browser (see §9)
Sign-inNone. Neither creating nor reading is authenticated — the code is the only key
ContentText, one file, or text plus a file
Text limit50,000 characters (the JSON endpoint also caps the body at 100KB — see §4.2)
File limit100 MB, one file, any type
Expiry5 minutes / 30 minutes / 1 hour / 1 day / 7 days; default 1 day
Delete / edit / listNone of them. Once created it cannot be taken back — only wait for it to expire
StorageMetadata 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.

  1. 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.
  2. File (optional): drag onto the dashed area, click it to browse, or — when focus is outside the text box — press ⌘V (Ctrl+V on 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.
  3. Expiry: the dropdown, defaulting to 1 day.
  4. 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.
  5. 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

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:

ContentShown as
TextMonospace <pre>, newlines and indentation preserved, with a copy button underneath
Imagepng / jpeg / gif / webp / svg render in place; click opens the original in a new tab. Name and size below
PDFEmbedded 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 elsezip, Word, Excel, video…: a file card (icon, name, size, MIME) plus a download button
Text + fileText 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

FormExampleGood for
The code aloneMN3W2RSpoken, written down, or over the phone — they type it on the home page
Page linkhttps://pasty.youlidao.ai/MN3W2RChat or email: opens the whole page
Direct file linkhttps://pasty.youlidao.ai/api/pastes/MN3W2R/fileImages 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}

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

MethodPathPurposeRate limit (per IP)
GET/api/healthHealth checkNone
POST/api/pastesapplication/json = text only; multipart/form-data = file ± textJSON 10/min; multipart 5/min — counted separately
GET/api/pastes/:codeMetadata + textNone
GET/api/pastes/:code/fileStream the file for download or previewNone
POST/api/pagesHost one HTML page (token required): JSON html or multipart file10 per minute
GET/p/:codeRender a hosted page under the sandbox CSPNone
GET/p/:code/pdfExport 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

FieldRequiredRule
filethis or contentOne file ≤ 100MB (104,857,600 bytes), any type
contentthis or fileNon-empty after trimming, ≤ 50,000 characters
expiresInnoAs 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:

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:

  1. The file's magic number (images, PDF, zip, Office, audio and video)
  2. 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)
  3. The file extension
  4. 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:

HeaderValue
Content-TypeThe resolved MIME type
Content-Dispositionimage/* → inline; PDF → inline; filename=…; everything else → attachment; filename=… (RFC 5987 encoded, so non-ASCII names are safe)
Content-LengthFile size
Content-Security-PolicyInline 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.

StatuscodeWhen
400CONTENT_EMPTYcontent is empty or only whitespace
400INVALID_EXPIRYexpiresIn is not one of 5m/30m/1h/1d/7d
400INVALID_CODE_FORMATthe share code is not 6 valid characters
400NO_CONTENT_PROVIDEDthe multipart body has neither content nor file
400FILENAME_TOO_LONG / FILENAME_INVALIDfile name over 255 characters / containing path traversal
400VALIDATION_ERRORa malformed multipart body, or any other unclassified validation failure
400PP_SLUG_INVALIDthe slug is the wrong shape, length, or a reserved path
401PAGE_AUTH_REQUIREDPage created without a token, or with a wrong one
401PP_AUTH_REQUIREDpermanent page created without a token, or with a wrong one
404PASTE_NOT_FOUNDno such code, or it has expired
404NO_FILE_IN_PASTEthat code is text only
404FILE_NOT_FOUNDthe R2 object has already been cleaned up
413CONTENT_TOO_LARGEtext over 50,000 characters, or a JSON body over 100KB
413FILE_TOO_LARGEfile over 100MB
413PAGE_TOO_LARGEHTML over 5MB
409PP_SLUG_TAKENthat slug already holds a permanent page or a live share code
429RATE_LIMITED / FILE_RATE_LIMITEDtext creation / file upload limit — counted separately
501FILE_UPLOAD_DISABLEDa file arrived but the server has no R2 configured
501PAGE_HOSTING_DISABLEDPAGE_API_TOKENS is not set on the server, so page hosting is off
501PP_HOSTING_DISABLEDPP_API_TOKENS is not set on the server, so permanent pages are off
503SERVICE_UNAVAILABLE / SERVICE_BUSYRedis failure / 5 share-code collisions in a row
503STORAGE_UNAVAILABLE / FILE_UPLOAD_FAILEDR2 unavailable / the upload failed
503PDF_EXPORT_UNAVAILABLEPDF render failed, the render queue is full, or Chromium is not configured on the server
500INTERNALan 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:

  1. Upload the original bytes over multipart. The skill's hard rule: never convert a PDF or Word file to markdown and send that instead
  2. Default to expiresIn=1h, zipping first when there is more than one file
  3. Reply with the code and the page link, plus a direct /file link for images and PDFs
  4. 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

StageWhat happens
CreateRedis gets paste:{CODE} with a TTL of the chosen expiry; with a file, R2 gets pastes/{CODE}/<name> first
While validUnlimited reads, not counted
ExpiryThe Redis key disappears on its own and reads return 404
CleanupThe server sweeps hourly and deletes R2 objects whose records are gone; R2's own lifecycle rule is the backstop (documented as 8 days)
LogsStructured 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

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 content field, 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=en and 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.

PropertyFact
Who can createToken holders: header Authorization: Bearer <token>. With no token configured on the server the endpoint returns 501 and the feature is off
Who can viewAs with everything else: anyone with the code or link, no login
SizeOne HTML document ≤ 5 MB
Expiry5m / 30m / 1h / 1d / 7d, default 1 day
Share codeSame code space as text and files. /{CODE} shows a page card; /p/{CODE} renders the page
Rate limit10 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 @-

9.3 What a page can do when opened

/p/{CODE} is served as text/html; charset=utf-8 with a dedicated CSP:

AllowedNot allowed
Inline scripts, and scripts from these CDNs: cdnjs.cloudflare.com, cdn.jsdelivr.net, unpkg.com, cdn.tailwindcss.comNetwork calls: fetch / XHR / WebSocket are all blocked (connect-src 'none')
Inline styles, Google Fonts, data: fontsForm submission (form-action 'none')
Images, audio and video from any https / data: / blob: sourceEmbedded iframes and objects
Pop-ups, external links in a new tablocalStorage / 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

  1. 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.
  2. No network requests: put the data in the page instead of fetching JSON.
  3. No localStorage; if you must, wrap it in try/catch and make the page work without it.
  4. 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.
  5. Default expiry 1d; a report meant for people can use 7d. Reply with the url and the expiry, and say that anyone with the link can see it.
  6. 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.