# 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)

1. Sign in at <https://cdn.ngay.net/admin/login>.
2. Sidebar → **API Keys** → **New 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):

```json
{
  "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 base64
- `search_images` — full-text + tag/category filter
- `get_image_url` — build delivery URL with preset or `?w=` etc.
- `tag_image` — replace or append tags
- `categorize_image` — replace or append categories

Full schemas: [`docs/mcp-tool-reference.md`](./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):**

```json
{
  "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

```bash
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)

```javascript
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`)

```python
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:

```bash
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

```javascript
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)

```bash
# 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:

```bash
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

```python
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

```python
# 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)

```yaml
- 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 / `.env` excluded 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 `mcp` for AI agents. Avoid `admin` scope for any non-human caller.
- **Audit usage.** The `last_used` column 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_image` MCP tool refuses `http://`. The REST endpoint accepts only `https://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`](../plans/260502-0013-cloudflare-image-cdn/phase-04-polish.md) and [`phase-05-ai-and-vector.md`](../plans/260502-0013-cloudflare-image-cdn/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
```
