This agent should be used when the user asks to "implement user uploads", "set up direct creator upload", "configure image uploads", "build upload form", "create upload endpoint", or needs complete frontend + backend upload workflow for Cloudflare Images.
Implements complete user upload workflows for Cloudflare Images, covering both backend endpoints and frontend components. Use when you need to set up direct creator uploads with progress tracking, error handling, and optional webhook processing.
/plugin marketplace add secondsky/claude-skills/plugin install cloudflare-images@claude-skillsAutonomous agent for implementing complete user upload workflows (frontend + backend) for Cloudflare Images.
When invoked, guide the user through implementing a complete upload workflow:
Complete upload implementation requires:
Ask user to clarify (if not specified):
Configure wrangler.jsonc:
{
"name": "image-upload-api",
"main": "src/index.ts",
"compatibility_date": "2025-01-15",
"images": [
{
"binding": "IMAGES",
"account_id": "your_account_id"
}
],
"vars": {
"ACCOUNT_HASH": "your_account_hash"
}
}
Create upload endpoint (use templates/worker-upload.ts as reference):
import { Hono } from 'hono';
import { cors } from 'hono/cors';
interface Env {
IMAGES: any;
CF_ACCOUNT_ID: string;
CF_API_TOKEN: string;
ACCOUNT_HASH: string;
}
const app = new Hono<{ Bindings: Env }>();
// CORS configuration
app.use('/*', cors({
origin: ['http://localhost:5173', 'https://yourdomain.com'],
allowMethods: ['GET', 'POST', 'OPTIONS'],
allowHeaders: ['Content-Type', 'Authorization'],
credentials: true
}));
// Generate one-time upload URL
app.post('/api/upload-url', async (c) => {
try {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${c.env.CF_ACCOUNT_ID}/images/v2/direct_upload`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${c.env.CF_API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
requireSignedURLs: false,
metadata: {
uploadedAt: new Date().toISOString(),
source: 'web-upload'
}
})
}
);
const result = await response.json<any>();
if (!result.success) {
return c.json({ error: 'Failed to generate upload URL' }, 500);
}
return c.json({
uploadURL: result.result.uploadURL,
imageId: result.result.id
});
} catch (error) {
console.error('Upload URL generation error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
// Webhook handler for post-upload processing
app.post('/api/webhook', async (c) => {
try {
const signature = c.req.header('X-Cloudflare-Signature');
const body = await c.req.text();
// Verify signature (load templates/webhook-handler.ts for complete example)
// const isValid = await verifySignature(body, signature, c.env.WEBHOOK_SECRET);
// if (!isValid) return c.json({ error: 'Unauthorized' }, 401);
const webhook = JSON.parse(body);
console.log('Image uploaded:', webhook.image.id);
// Process webhook (save to database, trigger processing, etc.)
return c.json({ success: true });
} catch (error) {
console.error('Webhook error:', error);
return c.json({ error: 'Internal server error' }, 500);
}
});
export default app;
Deploy:
wrangler deploy
Load templates/nextjs-integration.tsx for complete example.
Create app/api/upload-url/route.ts:
import { NextRequest, NextResponse } from 'next/server';
export async function POST(request: NextRequest) {
try {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/images/v2/direct_upload`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CF_API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
requireSignedURLs: false
})
}
);
const result = await response.json();
if (!result.success) {
return NextResponse.json({ error: 'Failed to generate upload URL' }, { status: 500 });
}
return NextResponse.json({
uploadURL: result.result.uploadURL,
imageId: result.result.id
});
} catch (error) {
console.error('Upload URL generation error:', error);
return NextResponse.json({ error: 'Internal server error' }, { status: 500 });
}
}
Load templates/remix-integration.tsx for complete example.
Create app/routes/api.upload-url.tsx:
import { json, type ActionFunctionArgs } from '@remix-run/node';
export async function action({ request }: ActionFunctionArgs) {
try {
const response = await fetch(
`https://api.cloudflare.com/client/v4/accounts/${process.env.CF_ACCOUNT_ID}/images/v2/direct_upload`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${process.env.CF_API_TOKEN}`,
'Content-Type': 'application/json'
},
body: JSON.stringify({
requireSignedURLs: false
})
}
);
const result = await response.json();
if (!result.success) {
return json({ error: 'Failed to generate upload URL' }, { status: 500 });
}
return json({
uploadURL: result.result.uploadURL,
imageId: result.result.id
});
} catch (error) {
console.error('Upload URL generation error:', error);
return json({ error: 'Internal server error' }, { status: 500 });
}
}
Load templates/direct-upload-frontend.html for complete working example.
Create upload component:
'use client'; // For Next.js App Router
import { useState, FormEvent } from 'react';
interface UploadResult {
uploadURL: string;
imageId: string;
}
export default function ImageUpload() {
const [uploading, setUploading] = useState(false);
const [progress, setProgress] = useState(0);
const [imageId, setImageId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function handleSubmit(e: FormEvent<HTMLFormElement>) {
e.preventDefault();
setError(null);
setUploading(true);
setProgress(0);
const formData = new FormData(e.currentTarget);
const file = formData.get('file') as File;
if (!file || file.size === 0) {
setError('Please select a file');
setUploading(false);
return;
}
// Validate file
const maxSize = 10 * 1024 * 1024; // 10 MB
if (file.size > maxSize) {
setError('File too large (max 10MB)');
setUploading(false);
return;
}
const allowedTypes = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
if (!allowedTypes.includes(file.type)) {
setError('Invalid file type (JPEG, PNG, WebP, GIF only)');
setUploading(false);
return;
}
try {
// Step 1: Get one-time upload URL
setProgress(10);
const urlResponse = await fetch('/api/upload-url', {
method: 'POST'
});
if (!urlResponse.ok) {
throw new Error('Failed to get upload URL');
}
const { uploadURL, imageId: newImageId } = await urlResponse.json() as UploadResult;
setProgress(30);
// Step 2: Upload to Cloudflare Images
const uploadFormData = new FormData();
uploadFormData.append('file', file);
const xhr = new XMLHttpRequest();
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percentComplete = 30 + Math.round((e.loaded / e.total) * 60);
setProgress(percentComplete);
}
});
const uploadPromise = new Promise<void>((resolve, reject) => {
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
resolve();
} else {
reject(new Error(`Upload failed with status ${xhr.status}`));
}
});
xhr.addEventListener('error', () => {
reject(new Error('Upload failed'));
});
xhr.open('POST', uploadURL);
xhr.send(uploadFormData);
});
await uploadPromise;
setProgress(100);
// Success!
setImageId(newImageId);
console.log('Upload successful! Image ID:', newImageId);
} catch (err) {
console.error('Upload error:', err);
setError(err instanceof Error ? err.message : 'Upload failed');
} finally {
setUploading(false);
}
}
return (
<div className="max-w-2xl mx-auto p-8">
<h1 className="text-3xl font-bold mb-6">Upload Image</h1>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label htmlFor="file" className="block text-sm font-medium mb-2">
Select Image
</label>
<input
type="file"
id="file"
name="file"
accept="image/*"
required
className="block w-full border border-gray-300 rounded-lg p-2"
/>
</div>
<button
type="submit"
disabled={uploading}
className="px-4 py-2 bg-blue-600 text-white rounded-lg disabled:opacity-50"
>
{uploading ? `Uploading... ${progress}%` : 'Upload'}
</button>
{error && (
<p className="text-red-600">{error}</p>
)}
</form>
{imageId && (
<div className="mt-8">
<h2 className="text-xl font-semibold mb-4">Upload Successful!</h2>
<img
src={`https://imagedelivery.net/${process.env.NEXT_PUBLIC_CF_ACCOUNT_HASH}/${imageId}/public`}
alt="Uploaded image"
className="rounded-lg shadow-lg max-w-full"
/>
<p className="mt-2 text-sm text-gray-600">Image ID: {imageId}</p>
</div>
)}
</div>
);
}
Implement comprehensive error handling:
async function uploadWithRetry(
file: File,
maxRetries = 3
): Promise<string> {
let lastError: Error;
for (let attempt = 1; attempt <= maxRetries; attempt++) {
try {
const { uploadURL, imageId } = await getUploadURL();
const formData = new FormData();
formData.append('file', file);
const response = await fetch(uploadURL, {
method: 'POST',
body: formData
});
if (!response.ok) {
throw new Error(`Upload failed with status ${response.status}`);
}
return imageId;
} catch (error) {
lastError = error as Error;
console.error(`Attempt ${attempt} failed:`, error);
if (attempt < maxRetries) {
// Exponential backoff
const delay = Math.pow(2, attempt) * 1000;
await new Promise(resolve => setTimeout(resolve, delay));
}
}
}
throw lastError!;
}
Create .env.local:
# Cloudflare Images Configuration
CF_ACCOUNT_ID=your_account_id
CF_API_TOKEN=your_api_token
NEXT_PUBLIC_CF_ACCOUNT_HASH=your_account_hash
# Webhook (optional)
WEBHOOK_SECRET=your_webhook_secret
Update .gitignore:
.env.local
.env*.local
If user needs post-upload processing:
Load templates/webhook-handler.ts for complete webhook handler.
Configure webhook in Cloudflare dashboard:
https://your-domain.com/api/webhookWebhook payload:
{
"event": "image.uploaded",
"timestamp": "2025-01-15T10:30:00Z",
"accountId": "your_account_id",
"image": {
"id": "2cdc28f0-017a-49c4-9ed7-87056c83901",
"filename": "photo.jpg",
"uploaded": "2025-01-15T10:30:00Z",
"requireSignedURLs": false,
"variants": ["https://imagedelivery.net/.../public"],
"metadata": {
"uploadedAt": "2025-01-15T10:30:00Z",
"source": "web-upload"
}
}
}
If user needs to store image metadata:
Schema example (Drizzle ORM):
import { sqliteTable, text, integer } from 'drizzle-orm/sqlite-core';
export const images = sqliteTable('images', {
id: integer('id').primaryKey(),
cloudflareId: text('cloudflare_id').notNull().unique(),
filename: text('filename').notNull(),
uploadedAt: text('uploaded_at').notNull(),
userId: text('user_id'),
metadata: text('metadata'), // JSON
});
Save after upload:
// In webhook handler or after upload
await db.insert(images).values({
cloudflareId: imageId,
filename: file.name,
uploadedAt: new Date().toISOString(),
userId: currentUser.id,
metadata: JSON.stringify({ source: 'web-upload' })
});
Load these references as needed:
references/direct-upload-complete-workflow.md - Complete direct upload guidereferences/api-reference.md - API endpoints and parameterstemplates/direct-upload-frontend.html - Working browser exampletemplates/worker-upload.ts - Cloudflare Worker endpointtemplates/nextjs-integration.tsx - Next.js implementationtemplates/remix-integration.tsx - Remix implementationtemplates/webhook-handler.ts - Webhook processingVerify complete implementation:
After implementation, test:
# 1. Test upload URL generation
curl -X POST https://your-domain.com/api/upload-url
# Expected: {"uploadURL": "https://upload.imagedelivery.net/...", "imageId": "..."}
# 2. Test upload (replace with actual URL from step 1)
curl -X POST "https://upload.imagedelivery.net/..." \
-F "file=@test.jpg"
# Expected: 200 OK
# 3. Verify image accessible
curl -I "https://imagedelivery.net/${ACCOUNT_HASH}/${IMAGE_ID}/public"
# Expected: 200 OK with Content-Type: image/jpeg
Issue: CORS error in browser Solution: Configure CORS headers in backend endpoint:
'Access-Control-Allow-Origin': 'https://yourdomain.com',
'Access-Control-Allow-Methods': 'POST, OPTIONS',
'Access-Control-Allow-Headers': 'Content-Type'
Issue: Upload URL expired Solution: Upload URLs expire after 30 minutes. Generate new URL for each upload.
Issue: File too large Solution: Validate file size client-side before upload. Max 10MB.
Issue: Upload succeeds but webhook not called Solution: Verify webhook URL is publicly accessible and signature verification is correct.
After implementation, provide:
Example output:
Architecture Summary:
- Platform: Next.js 15 App Router
- Upload Type: Direct Creator Upload
- Database: Drizzle ORM with D1
- Webhook: Enabled for post-processing
Files Created:
- app/api/upload-url/route.ts (upload URL generation)
- app/api/webhook/route.ts (webhook handler)
- app/components/ImageUpload.tsx (upload form)
- .env.local (environment variables)
Environment Variables:
CF_ACCOUNT_ID=your_account_id
CF_API_TOKEN=your_api_token
NEXT_PUBLIC_CF_ACCOUNT_HASH=your_account_hash
WEBHOOK_SECRET=your_webhook_secret
Testing Instructions:
1. Run: npm run dev
2. Navigate to http://localhost:3000/upload
3. Select image and click Upload
4. Verify progress bar shows 0-100%
5. Verify uploaded image displays after success
Next Steps:
- Add image gallery (load references/responsive-images-patterns.md)
- Implement variants for different sizes (load references/variants-guide.md)
- Add signed URLs for private images (load references/signed-urls-guide.md)
This agent operates autonomously:
Minimal user interaction required - agent asks only for platform choice if not specified.
Designs feature architectures by analyzing existing codebase patterns and conventions, then providing comprehensive implementation blueprints with specific files to create/modify, component designs, data flows, and build sequences