Uploading files to uploads.sh and embedding in GitHub
What this does and why
GitHub's native image hosting (
github.com/user-attachments/…
) is only reachable
through an authenticated
browser session — there is no
CLI or REST endpoint
for it. So any image URL you put in a PR/issue body written with
must already point at something publicly hosted.
This skill covers both transports: the
CLI (local files, git,
localhost) and the hosted MCP at
https://agents.uploads.sh/mcp
(bytes you
already have, no checkout). Both PUT to the uploads.sh API and return a stable
public URL plus ready-to-paste markdown. For PRs and issues the managed
attachments comment is available on both — CLI via local
as a fallback,
hosted MCP bot-only.
MCP vs CLI
Same product, two transports. Skills do not install a binary.
| Need | Use | Why |
|---|
| Bytes already in context (ChatGPT attachment, base64) | Hosted MCP | files: [{ filename, contentBase64 }]
. Pass + ( | ). No git inference. |
| List, find, metadata, comment, promote | Either | Hosted: , , / , , . CLI: / / / / . |
| Local path or current-branch attach | CLI | Hosted server has no filesystem and no tool. Use instead. |
| / private-network screenshot | CLI uploads screenshot --via local
| Remote render cannot reach your machine. |
| Selector annotate on a live page | CLI uploads screenshot --annotate --via local
| Remote backend rejects selector-bearing specs. |
| Neither transport | Stop | Do not treat as the ChatGPT path. OAuth on https://agents.uploads.sh/mcp
is the published remote path. |
CLI examples in the rest of this skill assume a checkout and the
binary. Hosted tool contracts live under
Notes and cautions (the MCP
bullet) below.
For the common case, use
. It infers the current branch's
PR, uploads every file under stable attachment keys (in parallel), and maintains
the comment by default. One bad file does not block the rest — JSON includes
and
(exit
when any failed):
bash
uploads attach ./before.png ./after.png
uploads attach ./shot.png --issue 45 --repo buildinternet/uploads
Pass
when only stable URLs are wanted. Use
for lower-level
naming and output control.
Stage as you go, before a PR exists. uploads attach ./shot.png --branch [name]
stages files under
gh/<owner>/<repo>/branch/<branch>/<filename>
instead of a PR/issue number — same upload path, no target flags, no comment
(there's nothing to comment on yet). With no value,
resolves the
current git branch;
in the name sanitizes to
. Attach this way at every
visual milestone during the work, not just once at the end. Staged files carry
until promotion flips them to
, so
uploads find gh.status=staged
(add
to narrow) lists what's
still in flight. The server also stamps
/
(from
the token's minting user) on gh.*-tagged uploads, so
uploads find gh.status=staged gh.uploader=<login>
narrows to one
contributor's in-flight files.
Check what's staged: . A dedicated read-only view —
"what's staged for this branch, and will it auto-attach?" — instead of
hand-building the
/
query above:
bash
uploads staged # current branch, repo from gh/git remote
uploads staged --branch feature/thing --repo owner/name
uploads staged --format json
Same branch/repo resolution as
(current git branch by
default, worktree-safe). Human mode prints one compact line per staged file
(filename, size,
, public URL), then a
line and
once the PR exists: uploads attach --promote
(the promote line is omitted
for
— promoting from a non-owning workspace would be
rejected by the cross-tenant gate). Nothing staged prints a
single zero-state line.
(or global
) always emits a
valid document —
{ repo, branch, files, binding }
— even with zero files;
is
, never empty stdout.
folds in the same repo↔workspace check the stage-time warning uses
(see "Repo binding" below), so you don't have to separately reason about it:
| | Meaning |
|---|
| | Repo is bound to this workspace — staged files auto-attach on PR open. |
| | Repo isn't linked yet — link it () or nothing auto-attaches. |
| | Repo is linked to a different workspace — these files won't auto-attach from here. |
| | Binding check failed (offline, or an older server without the route) — advisory only, never blocks the view. |
The
/
wording is the exact same advisory text as the
stage-time warning (issue #398) — one source of truth, so
the two surfaces never drift.
Local stdio MCP mirrors this as the
tool (
/
args,
same
{ repo, branch, files, binding }
shape). The hosted MCP has no
dedicated
tool (no git defaults) — list/find_files recipes and
hosted
/
with explicit
/
are under
Notes and
cautions (the MCP bullet) below.
Getting those files into the PR's attachments comment needs no extra step
once a PR exists for that branch:
- GitHub App installed on the repo: a webhook auto-promotes staged files
into the PR's attachment prefix and creates/updates the managed comment the
moment the PR opens, reopens, or gets a new commit.
- No GitHub App: the next targeting that PR
auto-promotes those staged files into the PR's attachment prefix before
the comment refresh. If that first attach has nothing new to upload, run
(zero file arguments) to promote and refresh the
comment on its own; it exits even when nothing was staged. Skip
auto-promotion on a given call with .
Promotion only applies to PRs, never issues, and both paths degrade silently
(no error) if the workspace's server doesn't support promotion yet.
Promotion needs the repo already bound to the workspace. Both the webhook
and the CLI-triggered path above rely on the same repo↔workspace binding used
by the managed comment (see "Repo binding" below) — any earlier successful
/
/promote call against that repo binds it implicitly, or
claims it explicitly. A repo that has
never been
bound and is only ever staged with
sees no error and no comment —
promotion is a silent no-op at PR-open time. If you can't confirm the repo is
already bound, don't promise auto-attach; the zero-setup fallback that works
regardless of binding history is running
(or any
targeted
) once the PR exists.
Comment missing? First, give it a moment — if the App is installed and
subscribed to
, a deleted or mangled bot comment self-heals on
the next webhook delivery; don't panic-repost. If it's still missing, check
the repo↔workspace binding —
uploads github link --status
(read-only, shows
the binding without claiming it). See "Repo binding" below.
The killer feature for GitHub:
/
produce
hash-free, stable keys
(
gh/<owner>/<repo>/pull/<num>/<name>
), so re-uploading the same filename overwrites
in place and the URL never changes. There is
no confirmation prompt — hot-swap is
intentional for agents and re-runs. Human mode prints
>> replaced existing object (same URL)
after overwrite; JSON has
. Use
to preview: it prints
>> would replace existing object (same URL)
when the key already exists,
without writing.
Every other key is strict (issue #174): an explicit
, or the
default
path with no
/
, refuses to overwrite an existing
object — the CLI error names the existing object's URL and tells you to add
(MCP:
). Set
to restore
always-overwrite for those paths.
previews the refusal too:
>> would refuse: key already exists
. This never applies to
/
keys, which always hot-swap regardless.
Responses include two public URLs when the
shared dual-host setup applies:
| Field | Host (default) | Use for |
|---|
| | Durable link, click-through, non-GitHub embeds |
| | GitHub PR/issue markdown ( / ) |
is the same object with badge-style no-cache headers so GitHub Camo
revalidates after an overwrite. CLI/MCP
and the managed attachments
comment already prefer
. Override with
UPLOADS_EMBED_PUBLIC_BASE_URL
(empty disables; self-host set your no-cache CDN base).
Prerequisites
- No shell / ChatGPT? Skip this section. Use the hosted MCP
(
https://agents.uploads.sh/mcp
) and the table above. Do not install the CLI.
- Node.js ≥ 22.
- The CLI. Install globally for repeated agent use, or run it once with :
bash
npm install --global @buildinternet/uploads
npx @buildinternet/uploads --help
uploads --version
Every example in this skill uses the global binary (as after
install). Inside the uploads monorepo only, builds from
local source first — do not write product/PR examples that way.
Prefer or for scripted steps (keeps stderr clean and skips
optional update-available hints).
- A configured token (one-time — see below). Check with .
- CLI, authenticated — only for the / features that
write to a PR/issue. Plain uploads don't need it.
One-time setup
Config lives in a user-owned file so it survives skill reinstalls:
~/.config/buildinternet/config # or $XDG_CONFIG_HOME/buildinternet/config
Resolution is
per key, first match wins: CLI flags (
,
,
) →
environment vars →
→
→ the shared config file. For a one-off against a different
API or workspace, just export the var or pass
.
The fastest path is
. Have a workspace admin invite your
email to a workspace first, then run it once, interactively, to sign in:
bash
uploads login # opens a browser to approve sign-in, saves config, runs doctor
uploads login --workspace acme # only needed if your account can access more than one
If the account has no workspace yet,
prompts for a name and offers one
derived from your GitHub login as a bracketed default — press Enter to take it,
or type your own. Nothing is prefilled when no valid, unclaimed name can be
derived.
--workspace <name> --create
skips the prompt entirely, which is the
form to use in scripts.
That's a one-time, human-in-the-loop step (device sign-in needs a browser); once the
config file is written, every later
invocation — including from a
non-interactive agent — just reads the saved token. Routine agents never need
.
Inviting a teammate (workspace admin/owner only): open the people tab under
/account/workspaces/<name>/people
in the browser (invite, revoke pending
invites, promote members to admin), or:
bash
uploads invite create --email teammate@example.com --workspace acme
Device login as you (not
/ not a workspace token). The CLI prints an
accept URL to share if email isn’t configured. Invitee accepts, then
.
Workspace admins can promote existing members to admin on that people tab; only the
workspace owner can demote or remove other admins.
For headless machines with no browser at all, an operator can mint a token directly
(
,
-gated — see
) and hand it to the
agent as
, or an enrollment code (
, an alternative invite-link/code path — useful
when you don't have the recipient's email) can be exchanged with
.
Neither is the normal path for new setups.
The resulting token defaults to 90 days and
plus
; it cannot
delete files unless an administrator explicitly grants
. Verify or inspect
setup at any time:
bash
uploads setup # shows effective configuration
uploads doctor # version + health + auth + workspace
uploads doctor --json
Tokens encode their workspace (
), so the CLI infers
when you don't set it. Legacy administrator-minted tokens remain valid. See "Config
commands" for setting put defaults
(default repo, prefix, image width) once instead of per-command.
Core workflow:
Upload one or more files and get back URL(s) plus ready-to-paste markdown.
Multiple paths upload in parallel; multi-file JSON is
(exit
when any failed). Single-file JSON stays a flat object.
bash
uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New live feed cards" --width 700
uploads put ./before.png ./after.png
Human output goes to stderr; the URL and markdown to stdout, so you can pipe or
capture them. Use
as the file to read from stdin.
| Flag | Purpose |
|---|
| Alt text for the markdown (default: filename). Always write meaningful alt text. |
| Emit sized HTML instead of (markdown can't size images). |
| Repo segment of the auto key (default: git remote, or ). |
| PR/issue/branch/date segment (default: today, or ). |
| Typed root: | | (sets key prefix). |
| Key prefix (default: , or ). |
| Set the object key explicitly; skips the auto-naming below. |
| Clean filename for the key's leaf + default alt (no ); keeps the /default path. Not with . |
| Allow overwriting an existing object on a strict key (/default path). No effect on / (or ). |
| Resolve + print the key and final public URL without uploading; reports if the key would replace (or, on a strict key, be refused). Not with ; skips the managed comment sync even with /. |
| Override the content type (else inferred from extension; ignored when optimize rewrites the body). |
| Opt-in chrome before optimize: , , . |
| Address bar text for . |
--frame-fit cover|contain
| How the shot fills the screen (default: ). |
| Skip client-side image optimization (default: still images → WebP). Or . |
| Max long edge when optimizing (default: 2400). |
--optimize-quality <1-100>
| WebP quality when optimizing (default: 85). |
| Keep EXIF/XMP/ICC when optimizing (default: strip for privacy). Or . |
| Don't derive from the git remote (or ). |
--format human|url|markdown|json
| Control stdout. (global) forces json. |
| Override workspace (wins over env and token inference). |
Image optimization (default on): PNG/JPEG and similar still images are re-encoded to
WebP (long edge capped at 2400px, quality 85) before upload so PR/issue embeds stay
lean. The object key/filename extension follows the output (e.g.
→
).
EXIF/XMP is stripped by default (public URLs + privacy); pass
when the discussion needs the embedded image metadata. Animated GIF,
SVG, video, and non-images are left alone; if the optimized payload is not smaller,
the original is uploaded. Use
when you need lossless originals.
Frames (opt-in): (generic bezel),
, or
(community device art, cached under
). Default is
no frame.
How keys work — three paths, no extra naming modes:
| Intent | Command |
|---|
| Just upload it, give me a URL | |
| Explicit typed destination | uploads put ./file.png --destination screenshots
|
| Stable GitHub embed I might re-upload | uploads put ./file.png --pr <num>
|
| Stable path but a clean leaf | uploads put ./capture-2026-…Z.png --pr <num> --name hero.png
|
| I know exactly where it goes | uploads put ./file.png --key screenshots/…/x.png
|
Timestamped captures break stable
keys — pass
to keep a
clean leaf. Use
to preview the exact public URL before uploading.
Default
is the fast path; you don't need
,
, or
.
Inside a git repo, on a non-default branch, a bare now stages
automatically (issue #403) — same key/metadata as
(
gh/<owner>/<repo>/branch/<branch>/<filename>
), so it auto-attaches to that
branch's PR when one opens. This fires whenever none of
/
/
/
/
/
is set and
isn't passed; any of those flags (or the default branch, detached
HEAD, not being in a git repo, or
) falls back to the classic
dated layout:
<prefix>/<repo-name>/<ref-or-date>/<basename>-<shorthash>.<ext>
— the short
hash prevents collisions without random names or a separate "preserve name"
flag. Prefer
--destination screenshots
(or
with
/
) over
inventing roots — workspaces may allowlist only those destinations. Override
with
only when you have a reason, and keep the key under an allowed
root. Pass
/
/
explicitly for a plain dated
upload on a branch (the opt-out).
Output formats — pick what you'll consume:
bash
uploads put ./shot.png --format url # just the URL, for scripting
uploads put ./shot.png --format markdown # just the ![]()/<img> snippet
uploads put ./shot.png --json # {workspace,key,url,size,markdown}
The bare-put staging note (issue #403). Since a bare
on a
non-default branch now stages by default (see above), it prints a one-line
note confirming that instead of nudging you to do it yourself — human mode
writes it to stderr,
adds it as an additive optional
field on the same response:
text
note: staged for branch fix-header — auto-comments to pull request when opened
(or run: uploads attach --promote once it exists). Use --ref/--prefix for a
plain dated upload.
If the same call also trips the stage-time binding warning (issue #398/#400
— the repo isn't bound to this workspace), that warning takes the
slot instead (it's the more actionable of the two); both still print on
stderr in human mode. Suppress the note (not the staging itself) with
,
(env), or
in the config
file (
uploads config set UPLOADS_NO_NUDGE 1
).
The old "rerun with --pr" nudge (issue #393) still fires, unchanged, for
the narrower case a bare put still lands on the dated layout with a
detectable PR — in practice, an explicit
/
opting out of
staging while a PR is open for that branch:
text
note: on branch fix-header (PR #142 open) — rerun with --pr 142 for a stable
key plus a managed comment that collects this PR's media, or stage pre-PR
files with: uploads attach <file> --branch
It's best-effort (a quick
lookup, bounded to 3s) — no open PR
just widens the wording to a generic
. Same suppression as above.
Capturing a screenshot:
Capture a URL or a local
file and host it — no separate screenshot
tool needed, and no browser install required for the default path:
bash
uploads screenshot https://uploads.sh --pr 128 --comment
uploads screenshot ./card.html --out ./card.png
uploads screenshot ./card.html --no-upload --out ./card.png
After capture, a screenshot shares the exact
upload pipeline described
above: optional
, optimize-by-default,
/
attachment +
,
,
, and the same output formats. It also
ships as an MCP tool (
) alongside the CLI command.
Two capture backends, selected with
:
| Backend | What it is | Needs |
|---|
| Drives an already-installed Chrome/Chromium via | A discoverable browser on disk, or |
| Renders server-side via the uploads.sh render endpoint | Nothing local; counts against the workspace's monthly upload budget |
(the default) prefers local when a usable browser is found,
else falls back to remote. Set a persistent default with
UPLOADS_SCREENSHOT_VIA=auto|local|remote
(env,
, or the user
config file — see "Config commands"); the
flag always wins.
localhost/private-network targets are local-only. With
(or
falling back to remote) these fail fast with a clear error instead
of sending a request that could never work. Local
files work on both
backends — the remote backend receives the file's contents inline (≤ 2 MiB),
so anything the page references via
or relative paths only resolves
with
. A numeric
(fixed settle delay after load) is also local-only; use
--wait load|domcontentloaded|networkidle
for a backend-agnostic wait.
Use
to attach to a Chrome that's already running
(
or
) instead of launching a new one — handy when
an agent already has a Playwright MCP or
session open.
(or
/
) points at an
explicit executable.
Key options (
uploads screenshot --help
for all):
| Flag | Purpose |
|---|
| Capture backend (default: , or ). |
| Explicit local browser executable (or / ). |
| Attach to a running Chrome via CDP instead of launching one (local backend only). |
| Size + device scale factor (default: ). |
| Capture one element instead of the viewport. |
| Capture the full scrollable page. |
| Cap on capture height in CSS px (default: ; = uncapped). A page over the cap is clipped, with a note to stderr and a . Requires ; applies on both and . |
| / | Emulate (full media-query emulation on only — only sets the CSS property, so a page's own queries won't flip). |
--wait <load|domcontentloaded|networkidle|ms>
| Settle strategy (default: ); a millisecond count is local-only. |
| Also write the PNG to a local file, plus a sidecar manifest () with this capture's derived metadata (///, plus if given) and a content hash. A later / of that exact file picks the metadata back up automatically — explicit / still win. See . |
| Don't write the sidecar alongside . |
| Skip hosting; requires (local file only). |
| / / / | Same destination and attachment options as (see above); / also give a stable, hash-free key. |
| Stage against a branch, pre-PR — same key as (see below); this is also what a bare on a non-default branch does automatically (issue #469). |
| / / / | Same as — reused from the shared upload pipeline. |
Inside a git repo, on a non-default branch, a bare now stages
automatically too (issue #469, mirroring 's issue #403 default) — same
key/metadata as
/
(
gh/<owner>/<repo>/branch/<branch>/<filename>
), carrying every derived fact
(
/
/
/
, plus
) through to the PR once it
opens. This is what closes the gap a coding agent hits capturing before the PR
exists: capture early with a plain
uploads screenshot <url> --out shot.png
,
and the metadata rides along instead of being re-stated (or lost) at
time. Fires whenever none of
/
/
/
/
/
/
is set
and
isn't passed; the same set of flags (or the default branch,
detached HEAD, not being in a git repo, or
) falls back to the
classic dated
screenshots/<repo>/<date>/...
layout. Prints the same
staging note as bare
(see above) — same stderr wording, same JSON
field, same
/
suppression.
Errors and hints: a local capture with no usable browser fails with
(exit
) — hint: try
, or install a
browser (
npx playwright install chromium
). A remote render that the server
can't complete returns
. A burst rate limit on the render
endpoint returns
(exit
) — hint: wait ~60s and retry. A
remote render over the workspace's monthly upload budget surfaces the usual
code and hint (
, then delete objects or raise
limits) — renders and puts share one monthly counter.
reports which local browser (if any) was detected and which
backend
would currently pick.
Key derivation and . Whenever the object's filename is
auto-derived from the captured URL (host + path) — the default dated
layout, or the
/
leaf name — passing
folds it into
that derived filename stem —
becomes
localhost-docs-mcp-before.webp
/
localhost-docs-mcp-after.webp
— so capturing the same URL twice with
then
produces two distinct objects instead
of the second silently overwriting the first. Re-capturing the same URL with
the
same state still replaces the existing object in place (idempotent
re-capture). An explicit
is unaffected by
folding. On
overwrite, human mode prints
>> replaced existing object (same URL)
to
stderr (same wording as
's hot-swap note, above) and
adds
, plus a
field when a
capture
replaced an existing object.
Baking in callouts:
bakes hand-drawn boxes, arrows, labels, freeform
strokes, and redactions onto the capture before it's uploaded (JSON spec, a
file path or
for stdin). Selectors resolve against the live page — the
local backend only in v1, so a selector-bearing spec on
is
rejected up front:
bash
uploads screenshot http://localhost:3000 --via local --annotate ./callouts.json
For the spec format and an existing-image equivalent (
uploads annotate <image> --spec <file|->
, pixel-only, no selectors), see the
annotate-screenshots skill.
Custom metadata & search
Every object can carry queryable key-value metadata (distinct from optimize/frame
provenance) — tag uploads at put time, then find them later.
The canonical vocabulary
Metadata is only useful if it is spelled the same way every time. These ten keys
are the agreed vocabulary; most are derived for you, so the main job is not
to fight them by inventing a different spelling.
| Key | Source | Example |
|---|
| auto — screenshot target | https://app.example/settings
|
| auto — pathname | |
| auto — only | |
| auto — only when forced | |
| auto — capture opts / EXIF | |
| auto — image EXIF | |
| auto — image EXIF | |
| auto — image EXIF | |
| you — | | |
| you — | | |
and are the two highest-value keys — pass both, every time,
as a habit. is the key most worth getting right (the one agents most
often misspell as
,
, or
— a near-miss warns on stderr
and suggests the canonical spelling, but is never rewritten for you, so fix it
at the source) and
captures the before/after pattern that dominates
PR screenshots — nothing can infer either one from the image alone.
is
a closed set:
,
,
,
,
(a near-miss like
fails fast and suggests
).
derives
automatically from the captured URL, so
you only need to add
.
/
of an
already-existing file have nothing to derive
from, so pass it
explicitly with
— and
/
/
print a
tip: add --meta path=/route so this shot is findable by page
on
stderr (plus a JSON
field) when an image lands with no
meta, as
a reminder (issue #469 lever 3; respects
).
bash
uploads screenshot https://app.example/settings --state before
# → stamps url, path=/settings, viewport, state=before
uploads put ./after.png --pr 123 --meta path=/settings --state after --app web
uploads find path=/settings state=after # what it was all for
What is derived, and when
- knows its own target, so it stamps ,
(query stripped), , for a local target, and
when / forced one.
- / read the image's own EXIF before the optimizer
strips it, promoting an allowlist: (from pixel dimensions and DPI),
, , .
- / also read a sidecar manifest left by a prior
of that exact file (, content-hash
guarded — a regenerated/edited file silently loses it) and merge in its
derived metadata. This closes the capture-then-attach gap where a shot is
taken before a PR exists:
uploads screenshot ... --out shot.png --state after
now, uploads attach shot.png --pr 123
later, still gets ///
/ on the PR-keyed object. Disable writing it with
.
is only ever
. It is never set to
— inferring that from "not
localhost" would mislabel every staging and preview URL, and wrong metadata is
worse than none.
Never promoted from EXIF, regardless of : all GPS tags, body and
lens serial numbers,
/
/
, and free-form user
comments. Note the flip side:
and
were previously discarded
and now become queryable metadata that renders on the public
page.
Precedence: explicit
/
/
> screenshot capture facts >
sidecar manifest > EXIF > unset. Derived keys are also dropped first if the
24-key cap is reached — your own keys are never dropped, and a full key
budget never fails an upload.
Turn the whole derived tier off with
or
.
Rules and reserved keys
Validated client-side, fail-fast, before uploading: key
(lowercase, dot-namespacing allowed, e.g.
);
value 1–512 printable ASCII characters;
may repeat up to 24 times per
request; a value may itself contain
(only the first
splits key from value).
and
are reserved (server-computed / the real R2
visibility gate, respectively).
writes its own
reserved-by-convention keys automatically — see below.
on a
-keyed object also refreshes the managed PR/issue comment
whenever the write touches a rendered key (
/
) — best-effort, after
the metadata write already lands. On success it prints
refreshed the managed comment on <repo>#<num>
to stderr; if the bot endpoint
is unavailable it prints
uploads comment --pr <num>` to refresh
the PR comment` instead, and either way the metadata write itself never fails.
bash
uploads meta get screenshots/myapp/42/settings.webp
uploads meta set screenshots/myapp/42/settings.webp path=/onboarding --delete url
uploads meta set screenshots/myapp/42/settings.webp --meta path=/onboarding # same thing
uploads list --meta app=web --meta path=/settings # ANDed, repeatable
uploads find app=web path=/settings # same filter, positional pairs
uploads find --meta app=web # --meta works here too
uploads find hero # bare name = filename substring
uploads find --name hero --meta app=web # name + meta, either order
uploads meta keys # which meta keys exist here
uploads meta values app # values (with counts) for one key
and
accept pairs in either spelling: positional
, or the
repeatable
that
,
,
, and
use. Both
forms can appear in one call.
also takes a case-insensitive filename
substring (
, or a bare positional without
). When you don't
know which keys exist, start with
/
(or the MCP
tool) — keys are user/agent-defined, not a fixed schema.
On the default
path,
also auto-derives GitHub context and
stamps
/
/
/
from the current branch's PR (or
a numeric
), so the file's
page shows an "Attached to" link. This is
on by default and best-effort; disable it with
,
, or
.
On this auto path an explicit
overrides the auto-derived value — the
opposite of the
/
precedence below, where the target's own
always wins.
Both paths also stamp
with the resolved PR/issue title when local
can resolve one — best-effort, never blocks the upload if it can't.
Re-upload semantics: re-uploading to an existing key
with metadata replaces
that file's entire metadata set (delete-then-set, not a merge); re-uploading with
no metadata at all preserves the existing metadata untouched. Derived keys
count as metadata here, so a re-upload that derives anything replaces the set —
pass
when re-uploading a key whose metadata you curated with
.
Non-
metadata values supplied via
(CLI) or
(MCP) render
on the object's public
file page. Treat them like the URL itself:
don't put internal notes, secrets, tokens, IDs, or private paths in them.
Public media galleries
Use galleries when several existing public uploads should be shared as one ordered collection.
A gallery has an opaque, API-returned public URL; do not derive one in scripts. Anyone who
knows the URL can view the gallery and its media. GitHub repository visibility does not make
it private, and a gallery does not pin objects against retention.
bash
uploads gallery create --title "Settings redesign"
uploads gallery add gal_example screenshots/app/settings-before.webp screenshots/app/settings-after.webp
uploads put ./after.png --gallery gal_example --alt "Updated settings page"
uploads gallery show gal_example
uploads gallery link gal_example --github buildinternet/uploads#58
uploads gallery list --github https://github.com/buildinternet/uploads/pull/58
processes keys sequentially so it obtains a current optimistic version before
each mutation. With
, its stable
and
arrays make partial failures
safe for agents to inspect. A workspace may have up to 100 active galleries; each gallery permits up to 100 items and 20 linked external references. Deleting a gallery removes only its gallery record—not the objects.
Optionally link a gallery to a GitHub issue or PR with
uploads gallery link <gallery-id> --github <owner/repo#number>
. The CLI also accepts strict
https://github.com/<owner>/<repo>/issues|pull/<number>
URLs. Use
uploads gallery list --github <coordinate-or-url>
for the authenticated reverse lookup. This is metadata only: it does not make a gallery private or change its opaque identity.
Embedding in a GitHub PR or issue
Two ways, depending on whether you want a durable URL, a managed comment, or both.
Option A — stable attachment URL ( / )
Gives the file a hash-free, stable key so re-uploads overwrite in place and the
URL is safe to hard-code in a PR body you'll edit later:
bash
uploads put ./after.png --pr 123 --alt "Dashboard after"
# key: gh/<owner>/<repo>/pull/123/after.webp → stable public URL (PNG optimized to WebP)
does the same under
. The
comes
from
or the git remote.
/
can't be combined with
,
, or
(the key layout is fixed), and are mutually exclusive.
These keys are deliberately predictable: they include the owner, repository, PR or
issue number, and filename. uploads.sh does not check GitHub visibility, so a private
or internal repository does not make the uploaded file private. Before using this
mode, confirm the media is safe for a public, guessable URL; otherwise redact it or do
not upload it.
If the uploads GitHub App can see that the target repo is private, this key layout
changes automatically: the key becomes
, where
is a random
id minted per branch (or per repo, for issues), instead of the derivable
. No flag needed — public repos, and repos the App can't see,
keep the derivable layout. The URL is still unauthenticated and durable, not
access-controlled — anyone who obtains it can read it until you rotate the id with
uploads github rotate-prefix --branch <branch>
(or
for issues/ingested
assets). See
docs/private-attachments.md
for the full threat model.
Then reference the
embed URL in the PR/issue markdown you write with
(CLI
/ MCP
already do this):
markdown
<img width="700" alt="Dashboard after" src="https://embed.uploads.sh/default/gh/myorg/myapp/pull/123/after.webp">
Keep
(storage host) when you need a durable share link outside GitHub.
/
(and
, below) writes
/
/
/
as queryable metadata automatically, so
uploads find gh.ref=myorg/myapp#123
or
uploads list --meta gh.repo=myorg/myapp
finds
everything attached to that PR/issue without needing the
prefix. Add
extras for your own pairs on top — a
override loses
to the target's own
values. It also stamps
with the real
PR/issue title when resolvable via local
(best-effort; omitted rather
than failing the upload if
can't resolve one).
Option B — managed attachments comment (default with /, or )
/
(like
) uploads
and creates/updates a single marker-owned comment on the PR/issue by default — no separate flag needed. It keeps loose
attachments and every public gallery linked to that PR/issue in clearly separate sections, with up to three available gallery images inline. It finds its own prior comment via a hidden marker and edits it in place — it never touches the description or other comments:
bash
uploads put ./after.png --pr 123
Pass
to skip the sync (upload only), matching
.
is still accepted on
as a no-op — it's redundant now that the sync is the default, kept only for scripts written before this changed (#537).
The upload is authoritative; the comment is best-effort — if
is missing or
unauthenticated, the upload still succeeds and you get a warning. To (re)sync the
comment without uploading anything (e.g. after several
uploads or gallery links), use the standalone command:
bash
uploads comment --pr 123
uploads comment --issue 45 --repo buildinternet/uploads
Removed the wrong screenshots?
the object(s) and re-run
to
re-sync. When the
last attachment and gallery are gone, the managed comment
is rewritten in place to a neutral empty state (
No attachments are currently associated with this pull request.
) — it is never deleted (a later upload
repopulates it) and never created just to say it's empty:
bash
uploads delete gh/owner/name/pull/123/after.png # remove the asset
uploads comment --pr 123 # comment now shows the empty state
Past 16 inline images, the comment collapses the rest into a
link
list so a heavily-screenshotted PR stays readable. Each workspace gets its own
managed comment on a shared repo (namespaced under the hood) instead of
clobbering another workspace's — use
(below) to see or
set which workspace a repo is bound to.
Repo binding ( / / )
The managed comment and webhook auto-promotion use a first-claim-wins binding
between a repo and a workspace, normally created implicitly by your first
/
/promote call. Inspect, claim, or release it:
bash
uploads github link # claim the current repo for this workspace
uploads github link --repo owner/name # claim a specific repo
uploads github link --status # read-only: show the current binding, don't claim
uploads github unlink --repo owner/name # release a binding this workspace owns
uploads github doctor # check the App itself (config + webhook events)
Claiming an already-bound repo never steals it — the command reports the
existing owner instead.
only releases a binding this workspace owns;
it 403s if another workspace owns it (an operator can reassign or remove it
from the admin panel instead). On an older/self-hosted server without these
routes they fail with a clear "server does not support repo bindings/GitHub
App health check yet" message.
Claiming an
unbound repo is authorized, not just first-come (issue #297):
the server only lets a workspace make that first claim when its linked
GitHub account has push (or higher) access to the repo, checked live against
GitHub via the App's installation token. A token with no linked GitHub
identity — a legacy/enrollment/shared token, including
's — can
never claim a new repo, though it keeps working normally on any repo already
bound to it. Claiming reports
claimed: false, reason: "not_authorized"
when
this check fails; link a GitHub account with push access to the repo, or ask
an operator to bind it explicitly from the admin panel.
on / means either the repo is
bound to a different workspace, or it's unbound and this workspace couldn't
be verified as entitled to claim it (see above). Either way it's a hard
decline, not a degrade — the CLI does
not fall back to posting via local
in this case, unlike other bot-post failures. Run
uploads github link --status
to see the current binding (if any), switch to a workspace with a
linked GitHub account that has access, or ask an operator to bind the repo
explicitly.
checks the App's own configuration and webhook event
subscriptions (needs
+
;
is
recommended so a deleted/mangled bot comment self-heals instead of waiting
for the next PR push) — useful when webhook-driven behavior (auto-promotion,
title updates, self-healing) seems to be silently doing nothing.
Mirroring GitHub-native attachments ()
Images someone drops straight into a PR/issue via
github.com/user-attachments/…
only exist behind GitHub's own authenticated hosting — they're never public
URLs.
(or
) scans the description and
comments for that media, mirrors any new ones into the workspace (indexed,
not added to the managed comment), and detaches ones no longer referenced —
a reattached one un-detaches without a re-fetch:
bash
uploads ingest --pr 123
uploads ingest --issue 45 --repo owner/name --json
Requires the repo be linked to the workspace (
) and the
GitHub App installed — otherwise it fails with a clear error rather than
guessing. This is the manual/backfill entry point; the
knob only gates the automatic webhook path and has
no effect on running
directly.
Embedding best practices
- Meaningful alt text, always — it's what readers with images off and search see.
- Constrain width on large shots with so they don't dominate the page.
- Before/after reads best side by side in a table:
markdown
| ------------------------------------ | ----------------------------------- |
| <img width="380" src="…/before.png"> | <img width="380" src="…/after.png"> |
- Prefer writing the body to a file and using /
gh issue comment --body-file
over inline HEREDOCs.
- The host is agnostic — the same URLs work in issues, PR comments, discussions, and
plain markdown docs.
- Prefer short, compressed clips (or an animated capture) over large raw
video files when attaching to PRs. Per-file and workspace storage caps
apply underneath, and a multi-minute uncompressed recording is a poor PR
embed regardless — trim to the relevant seconds and compress before
uploading.
Managing uploads
bash
uploads list --prefix screenshots/ # list objects (key + url)
uploads list --pr 123 # everything attached to a PR
uploads list --meta app=myapp # filter by metadata (repeatable, ANDed)
uploads list --name hero --meta app=web # filename substring (+ optional meta)
uploads find app=myapp path=/settings # same filter, human-friendly positional pairs
uploads find hero # filename substring alone
uploads list --all --json # paginate fully, machine-readable
uploads meta get <key> # show an object's metadata
uploads meta set <key> k=v [k=v…] [--delete k]… # merge-set / delete metadata pairs
uploads meta set <key> --meta k=v # same, in put/list's flag spelling
uploads meta keys # discover workspace metadata keys
uploads meta values <meta-key> # distinct values for one key
uploads delete <key> # remove an object
uploads delete <key> --dry-run # show what would be deleted
uploads usage # storage / monthly upload counters (+ limits)
uploads reconcile # rebuild ledger from storage
uploads purge-expired # delete past retentionDays (if set)
uploads health # API liveness (no auth)
uploads doctor # version + health + auth + workspace + usage
uploads --version
is the first thing to run when something's off — it reports the installed
CLI version, distinguishes a down API / bad token / workspace mismatch /
local-vs-prod URL, and prints targeted hints.
Destructive preview: supports
.
does not
yet (
#78); preview via
/
and retention settings.
Config commands
Set shared defaults once instead of passing flags every time:
bash
uploads config show # effective settings (token redacted)
uploads config path # resolved config file path
uploads config set UPLOADS_DEFAULT_REPO myorg/myapp
uploads config set UPLOADS_DEFAULT_WIDTH 700
uploads config init --api-url http://localhost:8787 --workspace acme --token up_acme_…
writes only the keys you pass. With no flags it seeds
alone and deliberately sets no workspace: a
in the config
file outranks the workspace encoded in your token, so seeding one would pin
every later
to it. Pass
when you want it fixed.
Recognized keys:
,
,
,
,
,
,
,
,
,
,
,
,
.
Also read (env only, not config-file keys):
UPLOADS_EMBED_PUBLIC_BASE_URL
,
.
Local development
Point at a locally running API (
serves it on
). Tokens minted with
only work against localhost; prod tokens need
UPLOADS_API_URL=https://api.uploads.sh
.
flags this mismatch for you.
bash
uploads --api-url http://localhost:8787 doctor
Notes and cautions
-
Uploads are public and effectively permanent until deleted. GitHub repository
visibility is not an access control: private/internal PR and issue attachments remain
public, and
gh/<owner>/<repo>/pull|issues/<num>/<filename>
keys are predictable.
Never upload secrets, tokens, internal dashboards with sensitive data, or customer
PII visible in a shot — crop/redact first.
-
Edge cache / dual host: stable
responses carry
Cache-Control: max-age=60
.
For GitHub, use
(no-cache host) so overwrites propagate through Camo.
Prefer CLI/MCP
rather than hand-building storage URLs into PR bodies.
See repo
(dual public hosts).
-
Exit codes: usage/token/file,
auth/policy,
network,
other.
emits
— branch on
. Scripted formats
(
) also print failures on stdout. Usage errors:
hint: uploads <cmd> --help
.
-
Errors stay short (stderr), so trimming output never hides them. A missing
argument prints one
line, a runnable example (
uploads put ./shot.png --pr 123
), and that hint — not the command's help. In
the example rides along as
. A
mistyped command prints the error, a
did you mean: uploads <cmd>
line, and
the help pointers; with
it returns
{error,code:"USAGE",didYouMean}
on stdout. Read the first line; run
for the full help.
-
Update hints (stderr): successful human runs may note a newer npm release
(daily). Silence with
/
/
.
-
Telemetry: anonymous command-name pings (no paths/tokens). Opt out with
UPLOADS_TELEMETRY_DISABLED=1
,
, or
uploads telemetry disable
.
-
Reports: only when the user asks —
uploads report "what broke"
or
. Never auto-send logs. MCP tool:
.
-
MCP: (stdio) mirrors CLI tools; hosted MCP at
https://agents.uploads.sh/mcp
— the one to reach for when an agent has no
local filesystem or git checkout to shell out from (send base64 content
directly). Metadata:
/
/
/
(same as
/
/
/
|
).
accepts optional
(filename
substring) with or without
. Both support multi-file
in one call
— stdio takes
as paths, hosted takes
files: [{ filename, contentBase64, alt? }]
(max 20/call; per-item
overrides the top-level one) — returning
with
per-item results.
sets up this skill + hosted MCP +
Grok/Cursor hooks (short progress;
/
available).
Claude and Codex ship the same pre-PR reminder via their plugins
(
uploads hook pre-pr-screenshot
).
Hosted MCP comment parity (issue #392). The hosted
tool
accepts
/
(mutually exclusive, mirroring the CLI's
/
) plus a required
(
— the hosted server has no
git context to infer it from) to get the same stable
key the CLI
produces. With
/
the managed
attachments
comment is posted/updated by default, same as CLI
(#537) — pass
to skip it. Bot-only on this server, no local-
fallback; the default sync needs the
scope and is silently
skipped on a write-only token (explicit
errors instead).
Prefer
/
over just
returning a raw
/
whenever the caller is going to paste the
result into a PR/issue: it gets the stable overwrite-in-place key and
lands straight in the collected attachments comment instead of
a one-off link the caller has to hand-embed. A comment failure never fails
the upload — it's returned honestly in the result's
field as one
of
(App not installed on the repo),
(repo
bound to a different workspace, or unbound and this workspace isn't
entitled to claim it), or
(App installed but Issues/PR write
access not yet approved — includes a
to the org's permission-review
page), never as a thrown tool error; an unexpected error surfaces
separately as
.
Hosted MCP standalone tool. The hosted server also has a
tool (
,
required for the same
no-git-context reason) that refreshes the managed comment
without
re-uploading — the hosted equivalent of CLI
. Use it to
re-sync after deleting an asset:
the
key, then call
.
When the last attachment and gallery are gone the comment is rewritten in
place to a neutral empty state (never deleted, never created empty). It is
bot-only with the same honest declines as
's comment field. Both this
tool and
with
/
honor the target repo's
(same as the bot path — no separate MCP config; see
https://uploads.sh/docs/comment-config).
Hosted MCP: branch staging + promote. There is still no
tool
on the hosted server (no filesystem paths) — use
instead:
text
# Stage pre-PR (CLI attach --branch parity). repo + branch required.
put { contentBase64, filename, repo: "owner/name", branch: "feature/x", state: "after" }
# → key gh/owner/name/branch/feature-x/<filename>, gh.status=staged
# Promote staged files into a PR once it exists (CLI attach --promote).
promote { repo: "owner/name", pr: 123, branch: "feature/x" }
# optional comment: false to skip the managed comment refresh (default on)
# Or attach a new file to the PR and promote that branch in one call:
put { contentBase64, filename, repo: "owner/name", pr: 123, branch: "feature/x" }
Hosted MCP: checking what's staged (issue #405). There's no dedicated
tool on the hosted server — it has no local git context to default
from, so it always needs the caller's own
/
. Answer
"what's staged?" with the existing tools instead:
text
list { prefix: "gh/<owner>/<repo>/branch/<branch>/" }
# or
find_files { filters: { "gh.branch": "<branch>" } }
returns each match's metadata inline, so
gives
recency without a second call; add
"gh.repo": "<owner>/<repo>"
to the
filters when branch names aren't unique across repos you're working in. For
the binding question ("will these auto-attach?"), use the hosted
tool (issue #422):
text
repo_link_status { repo: "<owner>/<repo>" }
It returns
{ binding: "self" | "other" | "none" }
:
means this
repo is bound to this workspace and staged files will auto-attach,
means it's bound to a different workspace and they won't —
deliberately without ever naming that workspace — and
means the
repo is unbound.
-
Agents on the Worker side: the package also exports
createUploadsWorkerFileTools()
from
@buildinternet/uploads/agent
for exposing
upload/list/delete as AI-SDK tools inside a Worker — only relevant if you're
building agent tooling that runs on the server, not for everyday PR embeds.