r2-upload
Upload images or files to Cloudflare R2 and get back a permanent public URL. Use this skill whenever any other skill (image generation, cover creation, infographic, comic, slide deck, etc.) produces a
SKILL.mdskills/r2-upload/SKILL.md
GitHubR2 Upload Skill
Upload a file to Cloudflare R2 and return its permanent public URL.
Step 0 — First-time setup (only when something fails)
Skip this step entirely if the user has used r2-upload before without issues —
go straight to Step 2. upload.py already reports clear errors on its own.
Run the environment checker only when:
- This is the user's first time using the skill, or
- An upload just failed with an unfamiliar error
python3 ~/.claude/skills/r2-upload/scripts/check_env.py
The script outputs JSON and exits with code 0 (all OK) or 1 (issues found).
Read the fix field of each failing item and work through them in order:
python_versionfails → Python is too old or missingboto3fails → runpip3 install boto3(Note:upload.pyalready detects this and prints the install command itself — you may not need check_env.py for this one)- Any
R2_*env var fails → walk the user through setting it up (see below)
After applying fixes, re-run the checker to confirm before proceeding.
If check_env.py itself fails to run (Python not found)
Python 3 is not available on this system. Please install it:
- macOS: brew install python3 OR download from https://python.org
- Ubuntu: sudo apt install python3
- Windows: download from https://python.org
After installation, restart your terminal and try again.
Step 1 — First-time setup guide
Only needed when env var checks fail. Walk the user through this:
1a. Create a Cloudflare R2 bucket (if not already done)
- Go to Cloudflare Dashboard → R2
- Click Create bucket, choose a name (e.g.
my-assets) - In bucket Settings → Public Access, enable public access
- Copy the
pub-xxx.r2.devURL shown — this isR2_PUBLIC_URL
1b. Create an R2 API token
- Go to Cloudflare Dashboard → R2 → Manage API tokens
- Click Create API token
- Set permissions: Object Read & Write on your specific bucket
- Save the generated Access Key ID (
R2_ACCESS_KEY_ID) and Secret Access Key (R2_SECRET_ACCESS_KEY) - Your Account ID appears in the URL:
dash.cloudflare.com/<account_id>/
1c. Set environment variables
Ask the user to add these five lines to their shell profile (~/.zshrc or ~/.bashrc):
export R2_ACCOUNT_ID="<your-cloudflare-account-id>"
export R2_ACCESS_KEY_ID="<your-r2-token-key-id>"
export R2_SECRET_ACCESS_KEY="<your-r2-token-secret>"
export R2_BUCKET_NAME="<your-bucket-name>"
export R2_PUBLIC_URL="https://pub-xxx.r2.dev"
Then reload the shell:
source ~/.zshrc # or source ~/.bashrc
After reloading, re-run the checker to confirm.
Step 2 — Running the upload
Auto-prefix by file type
When --prefix is not specified, the script automatically picks a prefix based on the
detected MIME type:
| File category | Auto prefix |
|---|---|
Images (image/*) |
images |
Videos (video/*) |
videos |
Audio (audio/*) |
audios |
| Everything else | files |
You can always override this with --prefix for finer-grained organisation
(e.g. --prefix images/covers).
From a local file (preferred)
python3 ~/.claude/skills/r2-upload/scripts/upload.py \
--file /path/to/image.png \
[--prefix images/covers] \
[--name my-image] \
[--bucket my-other-bucket]
From a base64 / data-URL string
# Short strings — inline is fine
python3 ~/.claude/skills/r2-upload/scripts/upload.py \
--base64 "data:image/png;base64,iVBORw0KGgo..."
# Long strings — always use --base64-file to avoid shell ARG_MAX limits
printf '%s' "<base64-data>" > /tmp/r2_b64.txt
python3 ~/.claude/skills/r2-upload/scripts/upload.py \
--base64-file /tmp/r2_b64.txt
rm -f /tmp/r2_b64.txt
From a remote URL (fetch + re-upload)
python3 ~/.claude/skills/r2-upload/scripts/upload.py \
--url "https://example.com/photo.jpg"
All options
| Flag | Description |
|---|---|
--file |
Path to a local file |
--base64 |
Base64 string or data URL (data:image/png;base64,…) |
--base64-file |
Path to a file containing base64 data (recommended for large payloads) |
--url |
Remote image URL to fetch and re-upload |
--prefix |
Path prefix inside the bucket, e.g. images/covers |
--name |
Custom filename without extension (auto-generated if omitted) |
--content-type |
Override auto-detected MIME type |
--bucket |
Override default bucket (R2_BUCKET_NAME env var) |
--cache-control |
Cache-Control header (default: public, max-age=31536000, immutable) |
--content-disposition |
Content-Disposition header, e.g. attachment; filename=doc.pdf |
--timeout |
Network timeout in seconds (default: 60) |
Step 3 — Output
On success, the script prints one JSON line to stdout:
{
"url": "https://pub-xxx.r2.dev/images/covers/my-image-a1b2c3d.png",
"key": "images/covers/my-image-a1b2c3d.png",
"size": 204800,
"contentType": "image/png",
"uploadedAt": 1710835200000
}
Capture the URL in a shell variable:
RESULT=$(python3 ~/.claude/skills/r2-upload/scripts/upload.py --file /tmp/out.png)
URL=$(echo "$RESULT" | python3 -c "import sys,json; print(json.load(sys.stdin)['url'])")
echo "Uploaded: $URL"
On error, the script writes to stderr and exits with a non-zero code. Surface the full error message to the user.
Presenting the URL to the user
Always display the final URL inside a code block so it can be copied in full without line-break truncation:
`https://pub-xxx.r2.dev/images/my-image-a1b2c3d.png`
Never paste the URL as plain inline text — long URLs wrap in the terminal and are easy to copy incompletely.
Integration pattern for other skills
When another skill generates a file and wants a hosted URL, the prefix is chosen
automatically based on file type (images → images/, videos → videos/, etc.),
so you rarely need to pass --prefix explicitly.
# 1. Upload — prefix is auto-selected from MIME type
OUTPUT=$(python3 ~/.claude/skills/r2-upload/scripts/upload.py \
--file /tmp/skill_output.png)
# 2. Extract URL
URL=$(echo "$OUTPUT" | python3 -c "import sys,json; print(json.load(sys.stdin)['url'])")
echo "Hosted at: $URL"
If the upload fails, run check_env.py to diagnose the issue.
Pass --prefix only when you need a specific sub-path (e.g. --prefix images/covers).
Troubleshooting upload errors
| Error message | Likely cause | Fix |
|---|---|---|
Missing required environment variable |
Env var not set | Re-run check_env.py and follow its fix instructions |
ModuleNotFoundError: No module named 'boto3' |
boto3 not installed | pip3 install boto3 |
Upload failed: InvalidAccessKeyId |
Wrong key ID | Check R2_ACCESS_KEY_ID in Cloudflare R2 → Manage API tokens |
Upload failed: SignatureDoesNotMatch |
Wrong secret | Regenerate API token and update R2_SECRET_ACCESS_KEY |
Upload failed: NoSuchBucket |
Bucket doesn't exist | Create the bucket in Cloudflare Dashboard first |
Failed to fetch URL (HTTP 403) |
Remote URL is access-controlled | Download manually, use --file instead |
Remote file is too large |
File exceeds 500 MB limit | Download the file manually and use --file instead |
File is too large |
Local file exceeds 500 MB limit | Split or compress the file before uploading |
Decoded data is too large |
base64 payload exceeds 500 MB after decoding | Use a smaller file or compress first |
Invalid base64 data |
Corrupted or truncated base64 string | Re-generate the base64 data and try again |
File not found |
Path passed to --file or --base64-file does not exist |
Check the file path and try again |
| URL returns 403 after upload | Bucket not public | Enable "Public access" in R2 bucket → Settings |