AI Agent — Auto-upload Images to Ngaycdn CDN
Public URLs for this guide
- HTML (rendered): https://cdn.ngay.net/agent
- Markdown (machine-readable, ideal for AI ingestion): https://cdn.ngay.net/agent-guide.md
- Mirror: https://cdn.ngay.net/docs/ai-agent-auto-upload.md
Drop the HTML link in any chat with an AI agent and it’ll be able to read this guide and integrate.
How to wire any AI agent (Claude, ChatGPT, custom Python/Node script, n8n flow, Zapier, etc.) to upload images directly to https://cdn.ngay.net without going through the browser admin.
Two paths:
| Path | Best for | Auth |
|---|---|---|
| MCP (recommended for Claude Desktop / Claude Code) | Conversational agents that already speak MCP | Bearer API key, scope mcp |
| Direct REST API | Scripts, cron jobs, n8n/Zapier, non-MCP agents | Bearer API key, scope mcp or wp or ci |
Both write to the same backend. Pick by what your agent supports.
1. Get an API key (one-time)
- Sign in at https://cdn.ngay.net/admin/login.
- Sidebar → API Keys → New API Key.
- Name:
claude-bot(or whatever identifies the agent). - Scope:
mcp— only MCP tools + image upload/read. Recommended for AI agents.wp— same asmcpplusthe_contentfilter (only meaningful for WordPress).ci— same asmcp. Use for CI/CD pipelines.admin— full access (don’t give to bots).
- Click Create. Copy the full token:
cdn_<scope>_<prefix>_<secret>. Shown once. Save it now.
The token format: 8-hex-char prefix for fast lookup; the <secret> portion is 256-bit entropy, SHA-256 hashed at rest.
2. Path A — MCP (Claude Desktop / Claude Code)
If your agent supports MCP, this is the simplest path. The agent calls our 5 tools natively.
Configure
~/Library/Application Support/Claude/claude_desktop_config.json (macOS) or %APPDATA%/Claude/claude_desktop_config.json (Windows):
{
"mcpServers": {
"ngaycdn": {
"url": "https://cdn.ngay.net/mcp",
"headers": {
"Authorization": "Bearer cdn_mcp_REPLACE_PREFIX_REPLACE_SECRET"
}
}
}
}
Restart the agent. The 5 tools appear:
upload_image— fetch by URL (https only) or base64search_images— full-text + tag/category filterget_image_url— build delivery URL with preset or?w=etc.tag_image— replace or append tagscategorize_image— replace or append categories
Full schemas: docs/mcp-tool-reference.md.
Example prompts
Upload https://example.com/photo.jpg to my CDN. Tag it "demo" and categorize as "Reviews".
Find images of laptops uploaded in the last 30 days. Return their cover URLs.
Generate a short alt-text for image slug "macbook-air-2026" and update it.
The agent will call upload_image → tag_image → categorize_image in sequence, reading back results from each step.
Bulk upload via MCP
Ask:
Here are 10 image URLs (one per line). Upload all of them to my CDN, tagged "campaign-2026-q2", and give me the delivery URLs.
The agent will loop through upload_image calls. Be aware of CF rate limit (free tier 100k req/day; rate-limit per API key not yet enforced — see Phase 4.3).
3. Path B — Direct REST API
For scripts, n8n flows, Make.com, Zapier custom HTTP, GitHub Actions, etc.
Endpoint
POST https://cdn.ngay.net/api/upload
Authorization: Bearer cdn_mcp_<prefix>_<secret>
Content-Type: multipart/form-data
Body fields (multipart form):
| Field | Type | Required | Notes |
|---|---|---|---|
file | file | yes | Binary image bytes. Max 20 MB. |
slug | string | no | Custom slug. Default: derived from filename (VN diacritics auto-transliterated). |
altText | string | no | Accessibility alt. |
tagIds | repeated string | no | Send field multiple times for multiple tags. |
categoryIds | repeated string | no | Same pattern. |
source | enum | no | mcp / wp / ci / admin. Default mcp. |
Response (201 Created):
{
"ok": true,
"data": {
"id": "img_01HX...",
"slug": "macbook-air-2026",
"ext": "jpg",
"url": "https://cdn.ngay.net/<tenant>/macbook-air-2026.jpg",
"sizeBytes": 245670,
"width": 1920,
"height": 1080,
"deduped": false
}
}
deduped: true means the bytes already existed in R2 (SHA-256 match) — the new row points at the same R2 object. Idempotent and free from a storage cost.
curl example
curl -X POST https://cdn.ngay.net/api/upload \
-H "Authorization: Bearer cdn_mcp_PREFIX_SECRET" \
-F "file=@./photo.jpg" \
-F "altText=Sunset over Hanoi" \
-F "tagIds=tag_landscape" \
-F "tagIds=tag_2026"
Node.js (no deps)
import fs from 'node:fs'
import { Blob, FormData } from 'undici'
const TOKEN = process.env.CDN_API_KEY
const BASE = 'https://cdn.ngay.net'
async function uploadImage({ filePath, altText, tagIds = [], categoryIds = [] }) {
const form = new FormData()
const buf = fs.readFileSync(filePath)
form.set('file', new Blob([buf]), filePath.split('/').pop())
if (altText) form.set('altText', altText)
for (const t of tagIds) form.append('tagIds', t)
for (const c of categoryIds) form.append('categoryIds', c)
const res = await fetch(`${BASE}/api/upload`, {
method: 'POST',
headers: { Authorization: `Bearer ${TOKEN}` },
body: form,
})
const json = await res.json()
if (!json.ok) throw new Error(`upload failed: ${json.error.message}`)
return json.data
}
const result = await uploadImage({
filePath: './photo.jpg',
altText: 'Sunset over Hanoi',
tagIds: ['tag_landscape'],
})
console.log(result.url)
Python (no deps beyond requests)
import os
import requests
TOKEN = os.environ['CDN_API_KEY']
BASE = 'https://cdn.ngay.net'
def upload_image(file_path, alt_text=None, tag_ids=None, category_ids=None):
with open(file_path, 'rb') as f:
files = {'file': (os.path.basename(file_path), f)}
data = []
if alt_text:
data.append(('altText', alt_text))
for t in (tag_ids or []):
data.append(('tagIds', t))
for c in (category_ids or []):
data.append(('categoryIds', c))
r = requests.post(
f'{BASE}/api/upload',
headers={'Authorization': f'Bearer {TOKEN}'},
files=files,
data=data,
timeout=60,
)
j = r.json()
if not j.get('ok'):
raise RuntimeError(f"upload failed: {j['error']['message']}")
return j['data']
result = upload_image('./photo.jpg', alt_text='Sunset over Hanoi', tag_ids=['tag_landscape'])
print(result['url'])
Upload from a URL (no local file)
The MCP tool upload_image supports { type: "url", value: "https://..." }. For REST, fetch the bytes yourself and POST as multipart, OR call MCP via JSON-RPC:
curl -X POST https://cdn.ngay.net/mcp \
-H "Authorization: Bearer cdn_mcp_PREFIX_SECRET" \
-H "Content-Type: application/json" \
-d '{
"jsonrpc": "2.0",
"id": 1,
"method": "tools/call",
"params": {
"name": "upload_image",
"arguments": {
"source": { "type": "url", "value": "https://example.com/photo.jpg" },
"filename": "photo.jpg",
"alt": "Sunset over Hanoi",
"tagIds": ["tag_landscape"]
}
}
}'
SSRF guard: the worker rejects private IP ranges (10/8, 172.16/12, 192.168/16, 127/8, link-local) and http:// (only https:// allowed). Max 20 MB. 10 s fetch timeout.
4. Common patterns
Tag/categorize after upload
const img = await uploadImage({ filePath: './photo.jpg' })
// Tag it (replace mode = overwrite all tags)
await fetch(`${BASE}/api/images/${img.id}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${TOKEN}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ tagIds: ['tag_landscape', 'tag_2026'] }),
})
List your tags + categories first (so you have IDs)
# Tags
curl -H "Authorization: Bearer cdn_mcp_PREFIX_SECRET" \
https://cdn.ngay.net/api/tags
# Category tree
curl -H "Authorization: Bearer cdn_mcp_PREFIX_SECRET" \
https://cdn.ngay.net/api/categories
Cache the lists in your agent — they don’t change often. Match by name, fall back to creating via:
curl -X POST https://cdn.ngay.net/api/tags \
-H "Authorization: Bearer cdn_mcp_PREFIX_SECRET" \
-H "Content-Type: application/json" \
-d '{"name": "summer-2026", "color": "#f59e0b"}'
Build delivery URLs in your post-processing
After upload, the response contains url (full size). For variants:
https://cdn.ngay.net/<tenant>/<slug>.<ext> full
https://cdn.ngay.net/<tenant>/thumb/<slug>.<ext> 400px square
https://cdn.ngay.net/<tenant>/cover/<slug>.<ext> 1280×720
https://cdn.ngay.net/<tenant>/og/<slug>.<ext> 1200×630 (OpenGraph)
https://cdn.ngay.net/<tenant>/full/<slug>.<ext> full size, format=auto
Or ad-hoc: ?w=400&format=auto&q=80.
Idempotency / dedup
Same bytes = same SHA-256 = same R2 object. Re-uploading a file is safe and free:
- A new row is created (so the slug and metadata can differ)
- The R2 object is reused
- Response has
deduped: true
To avoid even creating duplicate rows, pass a stable slug and inspect the response: if BA returns 409 on slug conflict, the row already exists.
5. AI-driven workflow examples
a) Auto-upload screenshots from a folder
import glob, os, requests
TOKEN = os.environ['CDN_API_KEY']
for path in glob.glob('./screenshots/*.png'):
name = os.path.basename(path)
with open(path, 'rb') as f:
r = requests.post(
'https://cdn.ngay.net/api/upload',
headers={'Authorization': f'Bearer {TOKEN}'},
files={'file': (name, f)},
data={'tagIds': 'tag_screenshots'},
)
print(name, '→', r.json()['data']['url'])
b) AI generates image, your code uploads
# Pseudo: Imagen / Nano Banana / DALL-E generates `image_bytes`
import base64, requests
bytes_b64 = base64.b64encode(image_bytes).decode()
r = requests.post(
'https://cdn.ngay.net/mcp',
headers={'Authorization': f'Bearer {TOKEN}', 'Content-Type': 'application/json'},
json={
'jsonrpc': '2.0', 'id': 1, 'method': 'tools/call',
'params': {
'name': 'upload_image',
'arguments': {
'source': {'type': 'base64', 'value': bytes_b64},
'filename': 'ai-generated.png',
'alt': 'AI-generated landscape',
'tagIds': ['tag_ai_generated'],
},
},
},
)
print(r.json())
c) n8n / Zapier / Make.com
Use the HTTP Request node:
- Method: POST
- URL:
https://cdn.ngay.net/api/upload - Auth header:
Authorization: Bearer cdn_mcp_PREFIX_SECRET - Body type: form-data
- Form field
file→ binary from previous node - Form field
altText→ expression from prior step
Map the response data.url into the next step.
d) GitHub Actions (CI publishes screenshots / OG images)
- name: Upload OG image to CDN
env:
CDN_API_KEY: ${{ secrets.CDN_API_KEY }}
run: |
curl -X POST https://cdn.ngay.net/api/upload \
-H "Authorization: Bearer $CDN_API_KEY" \
-F "file=@./out/og.png" \
-F "tagIds=tag_og" \
-F "altText=Latest blog post OG image" \
| tee upload.json
# Use jq to extract the URL for downstream steps
echo "OG_URL=$(jq -r .data.url upload.json)" >> $GITHUB_OUTPUT
6. Errors and rate behavior
| Status | Meaning | Action |
|---|---|---|
401 unauthorized | Token invalid / revoked / wrong scope | Regenerate API key, check scope |
403 forbidden | Scope insufficient (e.g. wp can’t manage api-keys) | Use mcp or higher-scope key |
400 invalid_input | Body validation failed | Check required fields, max size 20 MB |
409 conflict | Slug already in use | Pass a different slug or omit and let server derive |
413 payload_too_large | File >20 MB | Resize before upload |
502 fetch_failed | URL upload failed (SSRF / unreachable) | Verify URL is https://, public |
429 too_many_requests | (Phase 4.3 — not enforced yet) | Back off, retry with jitter |
No rate limit is currently enforced server-side, but Cloudflare’s free tier caps Worker requests at 100k/day. If you hit that, your other agents share the cap. A per-key rate limit is on the backlog (Phase 4.3).
7. Security notes
- Treat the API key like a password. Don’t commit it to git. Store in env vars / secret manager /
.envexcluded from VCS. - Rotate keys by creating a new one, updating clients, then revoking the old one (admin → API Keys → Revoke). Effective immediately.
- Scope minimally. Use
mcpfor AI agents. Avoidadminscope for any non-human caller. - Audit usage. The
last_usedcolumn in admin → API Keys shows the most recent activity per key — useful for spotting unused (revoke them) or unexpectedly active (investigate) keys. - HTTPS only. The
upload_imageMCP tool refuseshttp://. The REST endpoint accepts onlyhttps://cdn.ngay.net(TLS via Cloudflare). - No public uploads. Every upload requires a valid token. There’s no anonymous upload form.
8. Roadmap (related backlog items)
- Rate limit per API key (Phase 4.3) — protects against runaway agents
- Webhooks (Phase 4.4) — get notified in Discord/Slack when an agent uploads
- Audit log (Phase 4.6) — full record of every upload attributed to its API key
- AI auto-alt (Phase 5.1) — server-side auto-generation of alt text after upload, so agents can skip the alt step
See plans/260502-0013-cloudflare-image-cdn/phase-04-polish.md and phase-05-ai-and-vector.md.
9. Quick reference card
Endpoint base https://cdn.ngay.net
Upload POST /api/upload multipart, scope mcp/wp/ci/admin
List images GET /api/images query: q, page, limit, tagId, categoryId
Get image GET /api/images/:id
Edit metadata PATCH /api/images/:id { slug?, altText?, tagIds?, categoryIds? }
Soft-delete DELETE /api/images/:id moves to Trash
Tags list GET /api/tags
Tags create POST /api/tags { name, color? }
Categories GET /api/categories returns { tree, flat }
MCP POST /mcp JSON-RPC 2.0, scope mcp/admin
Auth header Authorization: Bearer cdn_<scope>_<prefix>_<secret>
Max upload 20 MB
Max URL fetch 10 s, https only, no private IPs
Dedup by SHA-256 of bytes — automatic, free