Attach & share
Attach & share
The commands that get media onto a PR, an issue, or a plain URL — capture, annotate, and attach.
Attach files to a PR #
attach is the command you'll use most. From a checked-out branch with an open pull request, it needs no arguments beyond the files:
…which lands on the pull request as one managed comment:
📎 Attachments
Run attach again with new files and this same comment updates in place, so your PR doesn't collect a trail of stale screenshot comments.
It uses the gh CLI to find the current repo and PR. If you're not on a PR branch, or you want a different target, say so explicitly:
Want the URLs and ready-to-paste Markdown without touching any GitHub comment (say, to drop an image into a PR description yourself)?
Stage before a PR exists #
Don't wait for a PR to capture something. As of issue #403, a bare put on a non-default git branch already stages by default — no --branch flag needed:
A bare uploads screenshot (no --pr/--issue/ --branch) stages the same way, carrying its derived path/ url/env/viewport metadata through promotion — so capturing before the PR exists no longer loses that context.
attach --branch is the explicit, multi-file form of the same thing — reach for it to upload several files at once with shared flags, or to force staging outside a plain upload:
See what's queued for the current branch, and whether it'll actually auto-attach, with uploads staged:
after.webp 94.2 KB staged 2026-07-22T14:00:00Z https://storage.uploads.sh/gh/you/app/branch/feat-nav/after.webp binding: self — these auto-attach when this branch's PR opens once the PR exists: uploads attach --promote
The binding line is the thing worth reading: self means these files will actually auto-attach when the PR opens; none or other means they won't (the repo isn't linked to this workspace, or it's linked to a different one), and unknown means the check itself couldn't run. Pass --format json for a machine-readable version — it always prints a valid document, even with zero files staged. See GitHub App: staging before a PR exists for what each binding state means and how to fix it.
Once the PR opens, the next attach against it promotes those staged files automatically — or run uploads attach --promote with no files to do it explicitly (--no-promote opts out). Promotion is copy-and-keep: the staged original is never deleted, so a URL you already embedded (in the PR body, say, before it was even opened) keeps serving.
Pair a before and after #
Tag a capture with --state before or --state after and the two render side by side in the comment, under Before and After headings, instead of stacking:
Two files pair when they share a --meta path value and one is before while the other is after. That's the form to use when a single comment carries several pairs — the path is what keeps them from crossing:
With no usable path, filenames are the fallback: stems that differ only by a before or after token pair up, so hero-before.webp goes with hero-after.webp. The token has to be its own word — beforehand.webp doesn't match. A group with two befores and one after is ambiguous and won't pair; it falls back to the normal stacked list. Only images pair.
Pairing isn't only a comment thing — a file's public page shows its counterpart next to it, with a link either way. --state also takes empty, error, and loading for the other states worth capturing; those are searchable with uploads find but don't pair.
Get a URL for any file #
put is the simpler building block: upload one file, get back a public URL and Markdown you can paste anywhere.
# on a non-default git branch, bare put stages for that branch
>> optimized 411.5 KB → 94.2 KB (shot.webp)
URL: https://storage.uploads.sh/gh/you/app/branch/feat-shot/shot.webp
MARKDOWN: 
note: staged for branch feat/shot — auto-comments to pull request when opened
(or run: uploads attach --promote once it exists). Use --ref/--prefix for a
plain dated upload.On a branch, a bare put stages the file for that branch's future PR — same behavior as attach, above. Everywhere else it keeps the plain dated path shown next.
# on the default branch (or --no-git), the classic dated layout applies
>> optimized 411.5 KB → 94.2 KB (shot.webp)
URL: https://storage.uploads.sh/screenshots/app/2026-07-12/shot-9f2c1a.webp
MARKDOWN: A few useful variations:
By default, images are re-encoded to WebP (capped size, high quality) so GitHub embeds stay fast, and EXIF is stripped from the uploaded bytes. Use --keep-exif to keep it, or --no-optimize to skip all processing.
Before stripping, a small allowlist is read off the image and stored as searchable metadata: viewport, device, software, and captured. Those values are publicly visible on the file's page. GPS coordinates, serial numbers, and personal-name tags are never kept. Pass --no-auto to store none of it.
Each file also gets a shareable page on uploads.sh (under /f/…) that supports oEmbed — so chat apps, notes tools, and other unfurlers can embed the image when you paste the page link. Details are in Reference.
Details: any of --pr, --issue, --key, --ref, --prefix, or --destination opts a bare put out of branch staging, as does --no-git. Staged keys have no content-hash suffix (unlike the dated layout above) — they're stable per filename, so re-uploading the same file replaces it in place instead of minting a new URL.
Capture a screenshot #
Don't have the image yet? screenshot renders a URL or a local.html file and hosts the result in one step, with no browser install required.
# renders the page, then hosts the WebP in one step
>> captured via local backend
URL: https://storage.uploads.sh/gh/app/example/pull/123/app.example.webp
MARKDOWN: A few useful variations:
If a Chrome or Chromium is already on the machine, screenshot drives it directly (nothing to download); otherwise it renders on uploads.sh servers. Force either side with --via local or --via remote. Note thatlocalhost URLs are only reachable locally.
--out also writes a sidecar, <file>.uploads.json, recording this capture's derived metadata (path/url/env/viewport, plus --state if given) and a content hash. A later put or attach of that same file picks the metadata back up automatically — pass --no-sidecar to skip writing it.
When you shoot your own dev server, it hides known framework dev toolbars (Astro, Next, Nuxt, Vite) automatically (opt out with--no-hide-dev-tools) and takes --reduced-motion to settle animations. Hide any other overlay with --hide <selector>(repeatable), or run setup JS first with --eval <js> /--init-script <file> (local backend).
Annotate a screenshot #
Point reviewers at what changed — boxes, arrows, labels, freeform strokes, and redactions bake into the image before it is hosted. Two entry points, same JSON spec:
Prefer screenshot --annotate when you control the page: CSS selectors resolve against the live DOM (local capture backend only in v1 — remote rejects selector-bearing specs). Use annotate on an image you already have — pixel coordinates only, no selectors.
A minimal callouts file:
{
"version": 1,
"annotations": [
{ "type": "box", "selector": "#save-button" },
{ "type": "label", "text": "New: bulk save", "selector": "#save-button" },
{ "type": "redact", "selector": "[data-testid=api-key]", "style": "solid" }
]
}Annotation types: box, arrow, label,draw, redact, and svg. House style is fixed (hand-drawn stroke); optional per-annotation color is the only style knob. Use redact with style: "solid"for secrets — hosted files are public, and blurred text can be recoverable.
Full spec format, selector vs pixel rules, and workflow notes live in the annotate-screenshots agent skill (uploads install or npx skills add buildinternet/uploads). See also uploads annotate --help and uploads screenshot --help.