ngaycdn cdn /docs

AI Agent — Auto-upload Images to Ngaycdn CDN

Public URLs for this guide

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:

PathBest forAuth
MCP (recommended for Claude Desktop / Claude Code)Conversational agents that already speak MCPBearer API key, scope mcp
Direct REST APIScripts, cron jobs, n8n/Zapier, non-MCP agentsBearer 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)

  1. Sign in at https://cdn.ngay.net/admin/login.
  2. Sidebar → API KeysNew API Key.
  3. Name: claude-bot (or whatever identifies the agent).
  4. Scope:
    • mcp — only MCP tools + image upload/read. Recommended for AI agents.
    • wp — same as mcp plus the_content filter (only meaningful for WordPress).
    • ci — same as mcp. Use for CI/CD pipelines.
    • admin — full access (don’t give to bots).
  5. 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:

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_imagetag_imagecategorize_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):

FieldTypeRequiredNotes
filefileyesBinary image bytes. Max 20 MB.
slugstringnoCustom slug. Default: derived from filename (VN diacritics auto-transliterated).
altTextstringnoAccessibility alt.
tagIdsrepeated stringnoSend field multiple times for multiple tags.
categoryIdsrepeated stringnoSame pattern.
sourceenumnomcp / 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:

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:

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

StatusMeaningAction
401 unauthorizedToken invalid / revoked / wrong scopeRegenerate API key, check scope
403 forbiddenScope insufficient (e.g. wp can’t manage api-keys)Use mcp or higher-scope key
400 invalid_inputBody validation failedCheck required fields, max size 20 MB
409 conflictSlug already in usePass a different slug or omit and let server derive
413 payload_too_largeFile >20 MBResize before upload
502 fetch_failedURL 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


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