Screenshots and recordings in GitHub PRs and issues
Why this exists
GitHub's native image hosting (
github.com/user-attachments/…
) only works
from an authenticated browser session — there is no
CLI or REST endpoint
for it. Any image URL in a PR/issue body written with
must
already point at something publicly hosted. The
CLI and the
hosted MCP at
https://agents.uploads.sh/mcp
both host the file on uploads.sh
and return a stable public URL plus ready-to-paste markdown.
Which surface
Pick one transport and stay on it. This skill is the workflow. The
uploads-cli skill owns flags and MCP tool contracts.
| You have | Use |
|---|
| No shell (ChatGPT, or any host without a checkout) | Hosted MCP with + + + ( or ). Embed the returned / . Never imply you can run uploads attach ./shot.png
. |
| A checkout and the binary | The CLI examples below. Git can fill / . |
| A page or a selector annotate | CLI only (uploads screenshot --via local
). Remote render cannot reach your machine. |
| Neither MCP nor the CLI | Stop and say so. Do not treat as the ChatGPT path. |
On the hosted MCP there is no
tool and no git defaults. Stage with
+
+
. Once the PR exists,
with
+
+
, or
with
+
(optional
also promotes). The
managed comment is bot-only on that server.
Step 1 — Capture the visual
Skip this step if the visual is already in context (a ChatGPT attachment, a
file the host already holds). Go straight to hosted MCP
.
Prefer uploads screenshot <url|file.html>
— it captures
and hosts in
one step (drives a local Chrome, or falls back to a server-side render), so you
skip a separate host call. It takes
,
,
,
, and
(to also save the PNG).
Capturing your
own dev server? It hides known framework dev toolbars
(Astro/Next/Nuxt/Vite) automatically (opt out with
) and
takes
to settle animations — no manual DOM surgery. Use
for any other overlay (repeatable), and
/
(local backend) as an escape hatch to dismiss a banner or
freeze a specific animation.
bash
uploads screenshot http://localhost:4321 --viewport 1520x960@1x --out home.png --reduced-motion
uploads screenshot https://uploads.sh --selector main --dark
also drops a
sidecar next to the PNG — that's a
working file for a later
/
to pick metadata back up from, not
something to commit, so
it (
) or delete it once
you're done attaching.
Only reach for your harness's browser tools / Playwright / an existing file when
can't reach the target (e.g. a flow that needs auth or
interaction first). GIFs and video: capture with any tool and upload as-is — the
optimizer only rewrites still images (PNG/JPEG → WebP).
Step 2 — Host and embed
Two tiers, pick by whether a PR already exists:
- Simple — a PR/issue already exists.
uploads put shot.png --pr 123
(or
, which infers the PR from the current branch) —
one call, stable per-PR key, embed URLs back immediately, and the managed
comment collects that PR's media as a side effect.
- Advanced — stage pre-PR, before there's anything to target.
uploads attach shot.png --branch
(see below) — no PR/issue needed yet; promotion
and the comment happen automatically once the PR opens, but only for a
repo already bound to the workspace (see the caveat below). Reach for the
simple tier once the PR exists unless you're deliberately building up a
staged set across a longer branch.
Default loop: stage as you go, from the first visual milestone. Don't wait
for a PR to exist. The moment you have something worth capturing — mid-task,
still on a branch, no PR yet — attach it right then. As of issue #403, a
bare already does this automatically whenever you're inside
a git repo on a non-default branch with no
/
/
/
/
— it stages under the same branch-keyed path
would produce, so a plain
uploads put step1-before.png --meta path=/settings --state before
is enough. As of issue #469,
(with no
/
/
target) stages the same way, so capturing directly
from a URL before the PR exists carries every derived fact (path/url/env/
viewport, plus
) all the way through to the PR once it opens — no
extra flag needed for the auto-derived ones. Reach for
explicitly when you want its extras (uploading several files at once with
shared flags, or triggering promotion/comment sync as a side effect):
bash
uploads screenshot http://localhost:4321/settings --out step1-before.png --state before
uploads screenshot http://localhost:4321/settings --out step2-after.png --state after
# or, capturing an existing local file instead of a live URL:
uploads put ./step1-before.png --meta path=/settings --state before
# or, explicitly, e.g. to upload several at once:
uploads attach ./step1-before.png ./step2-after.png --branch --state after
This uploads under stable, branch-keyed paths (no PR/issue target needed, no
comment yet — there's nothing to comment on until a PR exists). Keep doing
this at each meaningful visual milestone as you work; don't batch everything
into one call at the end. On the default branch (or outside a git repo, or
with
),
/
fall back to their ordinary dated
layout — that's the opt-out, along with any explicit
/
/
/
.
Staging only auto-promotes into a bound repo — don't promise it blind.
Auto-promotion at PR-open time (webhook or CLI-triggered, below) requires the
repo already bound to a workspace: any earlier successful attach/comment/
promote call against that repo binds it implicitly, or
binds it explicitly. A repo that's never been bound and only ever staged with
sees
no error and no comment when the PR opens — it's a silent
no-op. If you can't confirm the repo is already bound (
uploads github link --status
), don't tell the user the screenshot will "just show up" in the PR.
The zero-setup fallback that works regardless of binding history: once the PR
exists, run
(or any targeted
against that PR) to promote and post explicitly.
Pass / and as a habit —
both, every time. Before/after is the whole point of most PR screenshots, and
it's the one thing no tool can infer from the image;
is the other
highest-value queryable tag, and it's just as easy to forget outside
(which derives it from the captured URL automatically —
/
of an existing file have nothing to derive it
from). Both cost one flag now and make
or
uploads find path=/settings
work months later, when the filenames mean
nothing to anyone.
for both sides of a same-URL before/after pair is the
straightforward path — its object name derives from the captured URL, and
folds into that derived name (
becomes
localhost-docs-mcp-before.webp
/
), so capturing the same URL
twice with different states lands two distinct objects instead of one
overwriting the other. An explicit
is unaffected — pass one when you
need a specific object name. A
of an already-existing file still needs
its own filenames or
to keep before/after distinct, since there's no
URL to derive a stem from:
bash
uploads screenshot https://app.example/settings --pr 123 --state before
uploads screenshot https://app.example/settings --pr 123 --state after
uploads put ./after.png --pr 123 --meta path=/settings --state after
(
also takes
,
, and
.)
/
/
print a
tip: add --meta path=/route so this shot is findable by page
on stderr (and a JSON
field) when an image lands with no
meta — don't ignore it. Viewport is derived for you on
— see the
uploads-cli skill for the full canonical vocabulary.
The PR comment assembles itself — you don't drive that step. Once the PR
opens (whether via
or the GitHub UI), every branch-staged file
gets promoted into that PR's attachments and the managed comment is created
automatically:
- With the uploads-sh GitHub App installed on the repo, a webhook does
this the moment the PR opens, reopens, or gets a new commit — no CLI call
required at all.
- Without the App, the next you run against that PR
triggers the same promotion + comment refresh as a side effect. If you have
nothing new to add right after opening the PR, run (zero file arguments) to promote and refresh explicitly — it
exits even if nothing was staged. Skip auto-promotion on a given call
with .
"PR already exists" is just the simple case of the same command — same
, just pointed at a PR/issue number instead of a branch, and
the comment updates immediately since there's already something to comment
on:
bash
uploads attach ./before.png ./after.png
uploads attach ./flow.gif --issue 45 --repo myorg/myapp
uploads attach ./shot.png --no-comment # stable URLs only, no comment
For a URL you'll hard-code in a PR/issue body (re-uploads overwrite in place,
URL never changes):
bash
uploads put ./after.png --pr 123 --alt "Dashboard after" --width 700
For a durable public link to share anywhere (Slack, docs, a teammate):
bash
uploads put ./demo.gif --format url
Always embed the returned
markdown (or
) in GitHub — it uses the
no-cache host so overwrites propagate. Don't hand-build storage URLs.
Comment briefly disappeared? Don't panic-repost. If the App is installed
and subscribed to the
event, a deleted or edited-out managed
comment self-heals automatically on the next webhook delivery — no need to
run
/
again just to bring it back.
Removed the wrong screenshots? the object(s) and re-run
(or the hosted
tool) to re-sync. Once the last attachment
is gone the comment is rewritten in place to a neutral empty state — it stays
on the PR (a later upload repopulates it) rather than leaving stale entries
that point at deleted files.
Bot comment not showing up at all? The managed comment needs a
repo↔workspace binding (normally created implicitly by the first
comment/promote call, or by installing the GitHub App). If a comment you
expected doesn't appear, check the binding first:
bash
uploads github link --status
That's read-only and shows the current binding (or that the repo is
unbound) without claiming anything. If the CLI reports
instead, the repo is already bound to a
different workspace — it
won't fall back to posting via your own
auth in that case. The fix is
uploads github unlink --repo owner/name
from the owning workspace, or
asking an operator to reassign the binding; switching to the workspace that
already owns it also works.
Curate, don't dump. The comment inlines up to
16 images; anything past
that collapses into a
overflow list. Name and pick shots
meaningfully (
/
, not
..
) rather
than attaching every incidental screenshot from a long session — a curated
handful of milestones reads better than a dumped folder.
Step 3 — Embed well
-
Meaningful alt text, always (
).
-
Constrain width on large shots with
(emits sized
).
-
Before/after reads best side by side:
markdown
| ------------------------------------ | ----------------------------------- |
| <img width="380" src="…/before.png"> | <img width="380" src="…/after.png"> |
-
Motion: GitHub markdown won't autoplay MP4 URLs — prefer a GIF, or a
still image that links to the video URL.
-
Write bodies to a file and use
/
gh issue comment --body-file
rather than inline HEREDOCs.
Setup and escalation
- No shell? Use the hosted MCP. Do not install the CLI.
- CLI missing on a machine with a shell?
npm install --global @buildinternet/uploads
- Not authenticated on the CLI? (one-time, opens a browser),
then to verify. Hosted MCP uses OAuth on first tool call.
- Everything deeper — flags, key layouts, MCP tool contracts, metadata and
search, galleries, config defaults, output formats, exit codes — lives in
the uploads-cli skill and .
Cautions
- Uploads are public and effectively permanent until deleted. GitHub repo
visibility is not an access control, and
gh/<owner>/<repo>/pull/<num>/…
keys are predictable. Never upload secrets, tokens, or customer PII —
crop/redact first.
- Private repos get an unguessable URL automatically. When the uploads
GitHub App can see a target repo is private, attachments key under
instead — no flag needed. The id is durable, not
access-controlled: anyone who gets the URL can read it until you rotate it
(
uploads github rotate-prefix
). See docs/private-attachments.md for
the full model.
Need to point at something in the screenshot?
Boxes, arrows, labels, freeform strokes, and redaction (solid for secrets
caught in a capture — blurred text can be recoverable) get baked onto a
screenshot with
uploads screenshot --annotate
or
— see the
annotate-screenshots
skill for the spec format and workflow, then come back here to attach.