If you've ever had an image break on a published Distro story — the dreaded broken-image icon where a screenshot used to be — or you just want to drop a fresh image into a story without leaving your chat, this skill does it. You hand Claude an image (paste it, or point to a file), and Claude hosts it on Distro and, if you want, swaps it straight into the story.
distro-image-upload is a self-contained, single-file skill. It uses the Distro MCP to mint a presigned upload URL, pushes your image up with a one-line curl, and (optionally) replaces a broken <img> in an existing story with the freshly hosted one. No AWS account, no S3 console, no credentials — Distro brokers all of that for you.
It defaults to leaving the story exactly as it was except for the one image you changed, so nothing else in your draft is disturbed.
What you'll need
Distro Reader or Publisher MCP — connected and scoped to the publication whose story you're editing (or just to host an image).
A client with a shell — this is the important one. The actual image upload is a
curlPUT, so the skill runs in Claude Code or Claude Cowork (which has a sandboxed shell). It does not run on shell-less Claude Desktop yet, because Desktop can't perform the byte upload — that's tracked in DistroVerse-Repo issue #480 (a server-side byte-ingesting upload tool). Until that ships, Desktop users should use the Distro web app's image uploader.
What it does
Takes a user-supplied image — pasted into chat or given as a file path.
Mints a presigned S3 upload URL via
distro_media_upload_url, then PUTs the bytes withcurl(the URL is self-authenticating — no token, no AWS keys).Optionally replaces a broken image in an existing story: fetches the story, finds the broken
<img src>, swaps in the new URL, and saves — surgically, leaving the rest of the story untouched.Diagnoses 404 vs 403 first, so it only re-hosts images that are actually missing (404), not ones that merely need the story re-rendered (403).
Stores the bare asset URL and lets Distro re-sign it on every render — so the image keeps working long after any single signed URL would expire.
Verifies the result by re-fetching and checking each image resolves.
Install the skill
Ask Claude:
"Please fetch the distro-image-upload skill from Distro Skills and give me a
.skillfile I can install."
Claude will pull this story, extract the SKILL.md from the fenced block below, and package it as distro-image-upload.skill. The file will appear in your chat with a Save skill button — click it to install directly. That registers the skill with your Claude app so it shows up in your skills list and triggers on matching requests.
Requires the Distro Reader or Distro Publisher MCP connected so Claude can pull the story. While Distro Skills is private, this works for account holders with access. Public read access is coming.
Claude Code users: you have a direct path — ask Claude to drop the unzipped folder into ~/.claude/skills/distro-image-upload/ and restart the session. No packaging step needed.
No Distro MCP connected? Copy the SKILL.md contents from the fenced block below into a file called SKILL.md inside a folder called distro-image-upload, zip it (folder as zip root), and upload through Settings → Capabilities → Skills → Upload skill.
The skill source
This is the canonical SKILL.md:
---
name: distro-image-upload
description: Use when the user wants to upload a user-supplied image (pasted into chat or given as a file path) to Distro and either host it or place it in a story — including fixing/replacing a broken image in an existing Distro story. Triggers include "upload this image to Distro", "host this image", "replace/fix the broken image in story X with this one", "add this image to a Distro story". For USER-SUPPLIED images only — it does NOT fetch from Slack exports (that is publish-dbsd-chapter). Works for any story/publication. Self-contained and single-file; needs a shell (Claude Code or Claude Cowork) to upload the bytes.
---
# Distro Image Upload
Host a user-supplied image on Distro's S3 via the Distro MCP and, optionally, place
it into a story — most commonly to replace a broken image (one whose S3 object is
missing). Self-contained and single-file: no helper scripts, no sidecar. Runs on any
client with a shell (Claude Code, Claude Cowork). Not shell-less Desktop — see the
cross-platform note.
## How the upload works (read once)
The Distro MCP has no tool that ingests image bytes. `distro_media_upload_url`
returns a presigned S3 PUT URL plus a bare `assetUrl`; you transfer the bytes
yourself with a `curl` PUT. You never need AWS credentials:
- **Upload:** the presigned PUT URL is self-authenticating — the signature is in the
URL, so the PUT needs no token and no AWS keys. It is valid ~5 minutes; use it
promptly.
- **Read:** objects are stored private. The backend re-signs every `<img>` URL with a
fresh presigned GET each time the story renders. So store the **bare** `assetUrl`
(no query string) and let the render path sign it.
## Prerequisites
- Distro Reader or Publisher MCP connected and scoped to the target publication.
- A shell with `curl` (Claude Code, or the Claude Cowork sandbox).
## Workflow
### 1. Locate and validate the image(s)
User-supplied images arrive as files:
- Claude Code: pasted images land at `~/.claude/image-cache/<sessionId>/<n>.png`.
- Claude Cowork: uploads land under `/mnt/user-data/uploads/`.
- Or the user gives a path directly.
Run `file <path>` to confirm it is a real image and read its dimensions. Determine
`mimeType` from the actual bytes, not the extension. It must be one of:
`image/png`, `image/jpeg`, `image/jpg`, `image/gif`, `image/webp`, `image/svg+xml`,
`application/pdf`, `video/mp4`, `video/webm`.
### 2. Get a presigned upload URL (MCP tool-use)
Call `distro_media_upload_url({ fileName, mimeType, publicationId?, newsId? })`.
`fileName` is the name without extension. It returns `{ uploadUrl, assetUrl }`.
### 3. PUT the bytes (shell)
```bash
curl -sS -X PUT -H "Content-Type: <mimeType>" \
--data-binary "@<path-to-image>" \
"<uploadUrl>" -w "HTTP %{http_code}\n"
```
Expect `HTTP 200`. Keep the bare `assetUrl` from step 2 — that is what you store.
### 4. (Optional) Place it in a story
**Replace a broken image in an existing story:**
1. `distro_content_get({ contentId })` to get the story HTML.
2. Identify the target `<img src>`. Diagnose it first with a ranged GET:
```bash
curl -s -o /dev/null -w "%{http_code}\n" -H "Range: bytes=0-0" "<existing-src>"
```
- **404** = the S3 object is missing → re-host (this skill).
- **403** = the object exists but the URL is unsigned/expired → it does **not**
need re-hosting; just re-fetching the story re-signs it. Do not re-upload a 403.
3. Surgically replace **only that one** `src` string with the new `assetUrl`. Leave
the rest of the HTML exactly as-is — it may carry redactions or other edits.
4. `distro_content_update({ contentId, content: <updated-html>, contentFormat: "html" })`.
> Note: this passes the full story HTML as a tool argument, so it travels through
> the model's context. Fine for typical stories; heavy for very large chapters
> (100 KB+). For DBSD-scale transcripts, use `publish-dbsd-chapter` (Claude Code),
> which streams the body from disk instead.
**Or just host it:** return the `assetUrl` and let the user place it themselves.
When the user supplies several images for several broken slots, map them in document
order unless they say otherwise.
### 5. Verify
Re-fetch the story with `distro_content_get`. The stored bare URL comes back freshly
presigned. Ranged-GET each `<img>` URL and expect `200`/`206`. Report per-image
status and flag any slot still returning `404`.
## Gotchas
- **Store the bare `assetUrl`, never a presigned one.** Presigned GET URLs expire;
the render path re-signs the bare URL every time. A baked-in signed URL will 403
later.
- **Surgical replacement only.** Always fetch the current HTML and replace just the
one `src` — never rewrite a story from a stale copy.
- **mimeType from bytes, not filename.** A `.jpg` that is really PNG bytes will be
rejected or render wrong. Trust `file`.
- **404 ≠ 403.** Only 404 means re-host; 403 just needs a re-render.
## Cross-platform note
This skill needs a shell to PUT the bytes, so it runs in **Claude Code** and **Claude
Cowork** (sandboxed shell), but **not** in shell-less **Claude Desktop**. The durable
fix is a backend MCP tool that ingests the bytes and PUTs server-side — tracked in
DistroVerse-Repo issue #480. Until it ships, Desktop users should use the web app's
image uploader.
## When to use
- Host a user-supplied image on Distro and get a stable URL.
- Replace or fix a broken image in an existing Distro story.
- Add a supplied image to a story.
## When not to use
- **Republishing a Slack channel export as a chapter** → use `publish-dbsd-chapter`,
which auto-matches images from the export.
- **Bulk image migration across many stories** → script it directly.
When to use
An image broke on one of your Distro stories and you have the original to re-host.
You want to drop a supplied image into a story without leaving the chat.
You want a stable Distro-hosted URL for an image.
When not to use
Republishing a Slack channel as a chapter → use publish-dbsd-chapter, which pulls and matches the images from the Slack export for you.
Migrating images across many stories at once → that's a scripting job, not this skill.
Feedback
This is v1.0.0. Edge cases or suggestions → reply or ping Brad.