Attach & share
Attach & share
Stage as you work with put. Attach when a PR is already open. Capture, annotate, and share a URL.
Attach files to a PR #
When a pull request is already open, attach is the direct command. From a checked-out branch it
needs no arguments beyond the files:
uploads attach ./screenshot.png…which lands on the pull request as one managed comment:
Run attach again with new files and this same comment updates in place, instead of collecting a
trail of stale screenshot comments.
It uses the gh CLI to find the current repo and PR. Off a PR branch, or targeting somewhere else,
add --pr <n> or --issue <n>, and --repo owner/name for a different repo. Add --no-comment to
print the URLs and Markdown without posting anything.
Stage before a PR exists #
Don’t wait for a PR to capture something. A bare put on a non-default git branch already stages by
default — no --branch flag needed:
uploads put ./after.pngA 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:
uploads attach --branch ./before.png ./after.pngRun uploads staged to see what’s queued for the current branch, and whether it’ll actually
auto-attach:
$ uploads stagedafter.webp 94.2 KB staged 2026-07-22T14:00:00Z https://storage.uploads.sh/gh/you/app/branch/feat-nav/after.webpbinding: self — these auto-attach when this branch's PR opensonce the PR exists: uploads attach --promoteThe 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.
If you rename the branch, running any uploads staging or promote command on it — attach --branch, put, attach --pr, attach --promote — reads the rename from the branch’s git
reflog and tells the server, so promote also sweeps the old name. This only works if you run
uploads again before the PR opens; if you rename (or delete) the branch and go straight to
opening the PR, name the old branch explicitly:
uploads attach --pr 123 --from-branch old/branchThis command promotes fresh files from the old branch prefix and refreshes the managed comment. You can also add local files or existing keys to the same command. The staged files must still be inside the 30-day promotion window.
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:
uploads put ./hero-before.webp --state beforeIts counterpart uses --state after in place of before. Two files pair when they share a
--meta path value and one is before while the other is after — the form to use when a single
comment carries several pairs, since path is what keeps them from crossing:
uploads put ./settings-old.webp --meta path=/settings --state beforeWith 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 a file #
put is the simpler building block: upload one file, get back a public URL and Markdown you can
paste anywhere.
$ uploads put ./shot.png# 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.webpMARKDOWN: 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 aplain 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.webpMARKDOWN: Useful flags: --pr 123 also posts to that PR. --url fetches a public HTTPS file, or
http://localhost on the CLI, instead of a local path. The filename comes from the URL, or from
--name. --no-optimize uploads the original bytes instead of re-encoding. --frame phone adds
device chrome to a mobile screenshot.
uploads put --url https://cdn.example/shot.png --pr 123By 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.
Also: 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.
$ uploads screenshot https://app.example --pr 123# renders the page, then hosts the WebP in one step>> captured via local backendURL: https://storage.uploads.sh/gh/app/example/pull/123/app.example.webpMARKDOWN: Useful flags: --dark renders in dark scheme, --selector captures one element instead of the full
page, --no-upload --out <path> skips hosting and writes a local file, and --reduced-motion
settles animations for a clean dev-server shot.
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 that localhost 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). Hide any other overlay with --hide <selector>
(repeatable), or run setup JS first with --eval <js> / --init-script <file> (local backend).
Capturing a clicked or selected state of a React/Next app? A synthetic el.click() in --eval
fires before the framework hydrates, so it silently does nothing — gate on the app’s own
“interactive” signal with --wait-for <js> (local backend), which polls that expression until
truthy before --eval runs, e.g. --wait-for 'document.querySelector("[data-hydrated]")'.
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:
uploads screenshot http://localhost:4321/settings --via local --annotate ./callouts.jsonuploads annotate ./shot.png --spec ./callouts.json --out ./shot.marked.pngPrefer 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.