{
+ await env.EMAIL.send({
+ from: { name: "Acme Corp", email: "noreply@yourdomain.com" },
+ to: [
+ { name: "Alice", email: "alice@example.com" },
+ "bob@example.com"
+ ],
+ subject: "Your order #12345 has shipped",
+ text: "Track your package at: https://track.example.com/12345",
+ html: "Track your package at: View tracking
",
+ reply_to: { name: "Support", email: "support@yourdomain.com" }
+ });
+
+ return new Response("Email sent");
+ }
+} satisfies ExportedHandler;
+```
+
+### SendEmail Constraints
+
+- **From address**: Must be on verified domain (your domain with Email Routing enabled)
+- **Volume limits**: Transactional only, no bulk/marketing email
+- **Rate limits**: 100 emails/minute on Free plan, higher on Paid
+- **No attachments**: Use links to hosted files instead
+- **No DKIM control**: Cloudflare signs automatically
+
+## REST API Operations
+
+Base URL: `https://api.cloudflare.com/client/v4`
+
+### Authentication
+
+```bash
+curl -H "Authorization: Bearer $API_TOKEN" https://api.cloudflare.com/client/v4/...
+```
+
+### Key Endpoints
+
+| Operation | Method | Endpoint |
+|-----------|--------|----------|
+| Enable routing | POST | `/zones/{zone_id}/email/routing/enable` |
+| Disable routing | POST | `/zones/{zone_id}/email/routing/disable` |
+| List rules | GET | `/zones/{zone_id}/email/routing/rules` |
+| Create rule | POST | `/zones/{zone_id}/email/routing/rules` |
+| Verify destination | POST | `/zones/{zone_id}/email/routing/addresses` |
+| List destinations | GET | `/zones/{zone_id}/email/routing/addresses` |
+
+### Create Routing Rule Example
+
+```bash
+curl -X POST "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/email/routing/rules" \
+ -H "Authorization: Bearer $API_TOKEN" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "enabled": true,
+ "name": "Forward sales",
+ "matchers": [{"type": "literal", "field": "to", "value": "sales@yourdomain.com"}],
+ "actions": [{"type": "forward", "value": ["alice@company.com"]}],
+ "priority": 0
+ }'
+```
+
+Matcher types: `literal` (exact match), `all` (catch-all).
diff --git a/.agents/skills/cloudflare-deploy/references/email-routing/configuration.md b/.agents/skills/cloudflare-deploy/references/email-routing/configuration.md
new file mode 100644
index 0000000..3f9613e
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/email-routing/configuration.md
@@ -0,0 +1,186 @@
+# Email Routing Configuration
+
+## Wrangler Configuration
+
+### Basic Email Worker
+
+```jsonc
+// wrangler.jsonc
+{
+ "name": "email-worker",
+ "main": "src/index.ts",
+ "compatibility_date": "2025-01-01",
+ "send_email": [{ "name": "EMAIL" }]
+}
+```
+
+```typescript
+// src/index.ts
+export default {
+ async email(message, env, ctx) {
+ await message.forward("destination@example.com");
+ }
+} satisfies ExportedHandler;
+```
+
+### With Storage Bindings
+
+```jsonc
+{
+ "name": "email-processor",
+ "send_email": [{ "name": "EMAIL" }],
+ "kv_namespaces": [{ "binding": "KV", "id": "abc123" }],
+ "r2_buckets": [{ "binding": "R2", "bucket_name": "emails" }],
+ "d1_databases": [{ "binding": "DB", "database_id": "def456" }]
+}
+```
+
+```typescript
+interface Env {
+ EMAIL: SendEmail;
+ KV: KVNamespace;
+ R2: R2Bucket;
+ DB: D1Database;
+}
+```
+
+## Local Development
+
+```bash
+npx wrangler dev
+
+# Test with curl
+curl -X POST 'http://localhost:8787/__email' \
+ --header 'content-type: message/rfc822' \
+ --data 'From: test@example.com
+To: you@yourdomain.com
+Subject: Test
+
+Body'
+```
+
+## Deployment
+
+```bash
+npx wrangler deploy
+```
+
+**Connect to Email Routing:**
+
+Dashboard: Email > Email Routing > [domain] > Settings > Email Workers > Select worker
+
+API:
+```bash
+curl -X PUT "https://api.cloudflare.com/client/v4/zones/$ZONE_ID/email/routing/settings" \
+ -H "Authorization: Bearer $API_TOKEN" \
+ -d '{"enabled": true, "worker": "email-worker"}'
+```
+
+## DNS (Auto-Created)
+
+```dns
+yourdomain.com. IN MX 1 isaac.mx.cloudflare.net.
+yourdomain.com. IN MX 2 linda.mx.cloudflare.net.
+yourdomain.com. IN MX 3 amir.mx.cloudflare.net.
+yourdomain.com. IN TXT "v=spf1 include:_spf.mx.cloudflare.net ~all"
+```
+
+## Secrets & Variables
+
+```bash
+# Secrets (encrypted)
+npx wrangler secret put API_KEY
+
+# Variables (plain)
+# wrangler.jsonc
+{ "vars": { "THRESHOLD": "5.0" } }
+```
+
+```typescript
+interface Env {
+ API_KEY: string;
+ THRESHOLD: string;
+}
+```
+
+## TypeScript Setup
+
+```bash
+npm install --save-dev @cloudflare/workers-types
+```
+
+```json
+// tsconfig.json
+{
+ "compilerOptions": {
+ "target": "ES2022",
+ "module": "ES2022",
+ "lib": ["ES2022"],
+ "types": ["@cloudflare/workers-types"],
+ "moduleResolution": "bundler",
+ "strict": true
+ }
+}
+```
+
+```typescript
+import type { ForwardableEmailMessage } from "@cloudflare/workers-types";
+
+export default {
+ async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext): Promise {
+ await message.forward("dest@example.com");
+ }
+} satisfies ExportedHandler;
+```
+
+## Dependencies
+
+```bash
+npm install postal-mime
+```
+
+```typescript
+import PostalMime from 'postal-mime';
+
+export default {
+ async email(message, env, ctx) {
+ const parser = new PostalMime();
+ const email = await parser.parse(await message.raw.arrayBuffer());
+ console.log(email.subject);
+ await message.forward("inbox@corp.com");
+ }
+} satisfies ExportedHandler;
+```
+
+## Multi-Environment
+
+```bash
+# wrangler.dev.jsonc
+{ "name": "worker-dev", "vars": { "ENV": "dev" } }
+
+# wrangler.prod.jsonc
+{ "name": "worker-prod", "vars": { "ENV": "prod" } }
+
+npx wrangler deploy --config wrangler.dev.jsonc
+npx wrangler deploy --config wrangler.prod.jsonc
+```
+
+## CI/CD (GitHub Actions)
+
+```yaml
+# .github/workflows/deploy.yml
+name: Deploy
+on:
+ push:
+ branches: [main]
+jobs:
+ deploy:
+ runs-on: ubuntu-latest
+ steps:
+ - uses: actions/checkout@v3
+ - uses: actions/setup-node@v3
+ - run: npm ci
+ - run: npx wrangler deploy
+ env:
+ CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
+```
diff --git a/.agents/skills/cloudflare-deploy/references/email-routing/gotchas.md b/.agents/skills/cloudflare-deploy/references/email-routing/gotchas.md
new file mode 100644
index 0000000..20ea419
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/email-routing/gotchas.md
@@ -0,0 +1,196 @@
+# Gotchas & Troubleshooting
+
+## Critical Pitfalls
+
+### Stream Consumption (MOST COMMON)
+
+**Problem:** "stream already consumed" or worker hangs
+
+**Cause:** `message.raw` is `ReadableStream` - consume once only
+
+**Solution:**
+```typescript
+// ❌ WRONG
+const email1 = await parser.parse(await message.raw.arrayBuffer());
+const email2 = await parser.parse(await message.raw.arrayBuffer()); // FAILS
+
+// ✅ CORRECT
+const raw = await message.raw.arrayBuffer();
+const email = await parser.parse(raw);
+```
+
+Consume `message.raw` immediately before any async operations.
+
+### Destination Verification
+
+**Problem:** Emails not forwarding
+
+**Cause:** Destination unverified
+
+**Solution:** Add destination, check inbox for verification email, click link. Verify status: `GET /zones/{id}/email/routing/addresses`
+
+### Mail Authentication
+
+**Problem:** Legitimate emails rejected
+
+**Cause:** Missing SPF/DKIM/DMARC on sender domain
+
+**Solution:** Configure sender DNS:
+```dns
+example.com. IN TXT "v=spf1 include:_spf.example.com ~all"
+selector._domainkey.example.com. IN TXT "v=DKIM1; k=rsa; p=..."
+_dmarc.example.com. IN TXT "v=DMARC1; p=quarantine"
+```
+
+### Envelope vs Header
+
+**Problem:** Filtering on wrong address
+
+**Solution:**
+```typescript
+// Routing/auth: envelope
+if (message.from === "trusted@example.com") { }
+
+// Display: headers
+const display = message.headers.get("from");
+```
+
+### SendEmail Limits
+
+| Issue | Limit | Solution |
+|-------|-------|----------|
+| From domain | Must own | Use Email Routing domain |
+| Volume | ~100/min Free | Upgrade or throttle |
+| Attachments | Not supported | Link to R2 |
+| Type | Transactional | No bulk |
+
+## Common Errors
+
+### CPU Time Exceeded
+
+**Cause:** Heavy parsing, large emails
+
+**Solution:**
+```typescript
+const size = parseInt(message.headers.get("content-length") || "0") / 1024 / 1024;
+if (size > 20) {
+ message.setReject("Too large");
+ return;
+}
+
+ctx.waitUntil(expensiveWork());
+await message.forward("dest@example.com");
+```
+
+### Rule Not Triggering
+
+**Causes:** Priority conflict, matcher error, catch-all override
+
+**Solution:** Check priority (lower=first), verify exact match, confirm destination verified
+
+### Undefined Property
+
+**Cause:** Missing header
+
+**Solution:**
+```typescript
+// ❌ WRONG
+const subj = message.headers.get("subject").toLowerCase();
+
+// ✅ CORRECT
+const subj = message.headers.get("subject")?.toLowerCase() || "";
+```
+
+## Limits
+
+| Resource | Free | Paid |
+|----------|------|------|
+| Email size | 25 MB | 25 MB |
+| Rules | 200 | 200 |
+| Destinations | 200 | 200 |
+| CPU time | 10ms | 50ms |
+| SendEmail | ~100/min | Higher |
+
+## Debugging
+
+### Local
+
+```bash
+npx wrangler dev
+
+curl -X POST 'http://localhost:8787/__email' \
+ --header 'content-type: message/rfc822' \
+ --data 'From: test@example.com
+To: you@yourdomain.com
+Subject: Test
+
+Body'
+```
+
+### Production
+
+```bash
+npx wrangler tail
+```
+
+### Pattern
+
+```typescript
+export default {
+ async email(message, env, ctx) {
+ try {
+ console.log("From:", message.from);
+ await process(message, env);
+ } catch (err) {
+ console.error(err);
+ message.setReject(err.message);
+ }
+ }
+} satisfies ExportedHandler;
+```
+
+## Auth Troubleshooting
+
+### Check Status
+
+```typescript
+const auth = message.headers.get("authentication-results") || "";
+console.log({
+ spf: auth.includes("spf=pass"),
+ dkim: auth.includes("dkim=pass"),
+ dmarc: auth.includes("dmarc=pass")
+});
+
+if (!auth.includes("pass")) {
+ message.setReject("Failed auth");
+ return;
+}
+```
+
+### SPF Issues
+
+**Causes:** Forwarding breaks SPF, too many lookups (>10), missing includes
+
+**Solution:**
+```dns
+; ✅ Good
+example.com. IN TXT "v=spf1 include:_spf.google.com ~all"
+
+; ❌ Bad - too many
+example.com. IN TXT "v=spf1 include:a.com include:b.com ... ~all"
+```
+
+### DMARC Alignment
+
+**Cause:** From domain must match SPF/DKIM domain
+
+## Best Practices
+
+1. Consume `message.raw` immediately
+2. Verify destinations
+3. Handle missing headers (`?.`)
+4. Use envelope for routing
+5. Check spam scores
+6. Test locally first
+7. Use `ctx.waitUntil` for background work
+8. Size-check early
diff --git a/.agents/skills/cloudflare-deploy/references/email-routing/patterns.md b/.agents/skills/cloudflare-deploy/references/email-routing/patterns.md
new file mode 100644
index 0000000..2163677
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/email-routing/patterns.md
@@ -0,0 +1,229 @@
+# Common Patterns
+
+## 1. Allowlist/Blocklist
+
+```typescript
+// Allowlist
+const allowed = ["user@example.com", "trusted@corp.com"];
+if (!allowed.includes(message.from)) {
+ message.setReject("Not allowed");
+ return;
+}
+await message.forward("inbox@corp.com");
+```
+
+## 2. Parse Email Body
+
+```typescript
+import PostalMime from 'postal-mime';
+
+export default {
+ async email(message, env, ctx) {
+ // CRITICAL: Consume stream immediately
+ const raw = await message.raw.arrayBuffer();
+
+ const parser = new PostalMime();
+ const email = await parser.parse(raw);
+
+ console.log({
+ subject: email.subject,
+ text: email.text,
+ html: email.html,
+ from: email.from.address,
+ attachments: email.attachments.length
+ });
+
+ await message.forward("inbox@corp.com");
+ }
+} satisfies ExportedHandler;
+```
+
+## 3. Spam Filter
+
+```typescript
+const score = parseFloat(message.headers.get("x-cf-spamh-score") || "0");
+if (score > 5) {
+ message.setReject("Spam detected");
+ return;
+}
+await message.forward("inbox@corp.com");
+```
+
+## 4. Archive to R2
+
+```typescript
+interface Env { R2: R2Bucket; }
+
+export default {
+ async email(message, env, ctx) {
+ const raw = await message.raw.arrayBuffer();
+
+ const key = `${new Date().toISOString()}-${message.from}.eml`;
+ await env.R2.put(key, raw, {
+ httpMetadata: { contentType: "message/rfc822" }
+ });
+
+ await message.forward("inbox@corp.com");
+ }
+} satisfies ExportedHandler;
+```
+
+## 5. Store Metadata in KV
+
+```typescript
+import PostalMime from 'postal-mime';
+
+interface Env { KV: KVNamespace; }
+
+export default {
+ async email(message, env, ctx) {
+ const raw = await message.raw.arrayBuffer();
+ const parser = new PostalMime();
+ const email = await parser.parse(raw);
+
+ const metadata = {
+ from: email.from.address,
+ subject: email.subject,
+ timestamp: new Date().toISOString(),
+ size: raw.byteLength
+ };
+
+ await env.KV.put(`email:${Date.now()}`, JSON.stringify(metadata));
+ await message.forward("inbox@corp.com");
+ }
+} satisfies ExportedHandler;
+```
+
+## 6. Subject-Based Routing
+
+```typescript
+export default {
+ async email(message, env, ctx) {
+ const subject = message.headers.get("subject")?.toLowerCase() || "";
+
+ if (subject.includes("[urgent]")) {
+ await message.forward("oncall@corp.com");
+ } else if (subject.includes("[billing]")) {
+ await message.forward("billing@corp.com");
+ } else if (subject.includes("[support]")) {
+ await message.forward("support@corp.com");
+ } else {
+ await message.forward("general@corp.com");
+ }
+ }
+} satisfies ExportedHandler;
+```
+
+## 7. Auto-Reply
+
+```typescript
+interface Env {
+ EMAIL: SendEmail;
+ REPLIED: KVNamespace;
+}
+
+export default {
+ async email(message, env, ctx) {
+ const msgId = message.headers.get("message-id");
+
+ if (msgId && await env.REPLIED.get(msgId)) {
+ await message.forward("archive@corp.com");
+ return;
+ }
+
+ ctx.waitUntil((async () => {
+ await env.EMAIL.send({
+ from: "noreply@yourdomain.com",
+ to: message.from,
+ subject: "Re: " + (message.headers.get("subject") || ""),
+ text: "Thank you. We'll respond within 24h."
+ });
+ if (msgId) await env.REPLIED.put(msgId, "1", { expirationTtl: 604800 });
+ })());
+
+ await message.forward("support@corp.com");
+ }
+} satisfies ExportedHandler;
+```
+
+## 8. Extract Attachments
+
+```typescript
+import PostalMime from 'postal-mime';
+
+interface Env { ATTACHMENTS: R2Bucket; }
+
+export default {
+ async email(message, env, ctx) {
+ const parser = new PostalMime();
+ const email = await parser.parse(await message.raw.arrayBuffer());
+
+ for (const att of email.attachments) {
+ const key = `${Date.now()}-${att.filename}`;
+ await env.ATTACHMENTS.put(key, att.content, {
+ httpMetadata: { contentType: att.mimeType }
+ });
+ }
+
+ await message.forward("inbox@corp.com");
+ }
+} satisfies ExportedHandler;
+```
+
+## 9. Log to D1
+
+```typescript
+import PostalMime from 'postal-mime';
+
+interface Env { DB: D1Database; }
+
+export default {
+ async email(message, env, ctx) {
+ const parser = new PostalMime();
+ const email = await parser.parse(await message.raw.arrayBuffer());
+
+ ctx.waitUntil(
+ env.DB.prepare("INSERT INTO log (ts, from_addr, subj) VALUES (?, ?, ?)")
+ .bind(new Date().toISOString(), email.from.address, email.subject || "")
+ .run()
+ );
+
+ await message.forward("inbox@corp.com");
+ }
+} satisfies ExportedHandler;
+```
+
+## 10. Multi-Tenant
+
+```typescript
+interface Env { TENANTS: KVNamespace; }
+
+export default {
+ async email(message, env, ctx) {
+ const subdomain = message.to.split("@")[1].split(".")[0];
+ const config = await env.TENANTS.get(subdomain, "json") as { forward: string } | null;
+
+ if (!config) {
+ message.setReject("Unknown tenant");
+ return;
+ }
+
+ await message.forward(config.forward);
+ }
+} satisfies ExportedHandler;
+```
+
+## Summary
+
+| Pattern | Use Case | Storage |
+|---------|----------|---------|
+| Allowlist | Security | None |
+| Parse | Body/attachments | None |
+| Spam Filter | Reduce spam | None |
+| R2 Archive | Email storage | R2 |
+| KV Meta | Analytics | KV |
+| Subject Route | Dept routing | None |
+| Auto-Reply | Support | KV |
+| Attachments | Doc mgmt | R2 |
+| D1 Log | Audit trail | D1 |
+| Multi-Tenant | SaaS | KV |
diff --git a/.agents/skills/cloudflare-deploy/references/email-workers/README.md b/.agents/skills/cloudflare-deploy/references/email-workers/README.md
new file mode 100644
index 0000000..5a3e304
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/email-workers/README.md
@@ -0,0 +1,151 @@
+# Cloudflare Email Workers
+
+Process incoming emails programmatically using Cloudflare Workers runtime.
+
+## Overview
+
+Email Workers enable custom email processing logic at the edge. Build spam filters, auto-responders, ticket systems, notification handlers, and more using the same Workers runtime you use for HTTP requests.
+
+**Key capabilities**:
+- Process inbound emails with full message access
+- Forward to verified destinations
+- Send replies with proper threading
+- Parse MIME content and attachments
+- Integrate with KV, R2, D1, and external APIs
+
+## Quick Start
+
+### Minimal ES Modules Handler
+
+```typescript
+export default {
+ async email(message, env, ctx) {
+ // Reject spam
+ if (message.from.includes('spam.com')) {
+ message.setReject('Blocked');
+ return;
+ }
+
+ // Forward to inbox
+ await message.forward('inbox@example.com');
+ }
+};
+```
+
+### Core Operations
+
+| Operation | Method | Use Case |
+|-----------|--------|----------|
+| Forward | `message.forward(to, headers?)` | Route to verified destination |
+| Reject | `message.setReject(reason)` | Block with SMTP error |
+| Reply | `message.reply(emailMessage)` | Auto-respond with threading |
+| Parse | postal-mime library | Extract subject, body, attachments |
+
+## Reading Order
+
+For comprehensive understanding, read files in this order:
+
+1. **README.md** (this file) - Overview and quick start
+2. **configuration.md** - Setup, deployment, bindings
+3. **api.md** - Complete API reference
+4. **patterns.md** - Real-world implementation examples
+5. **gotchas.md** - Critical pitfalls and debugging
+
+## In This Reference
+
+| File | Description | Key Topics |
+|------|-------------|------------|
+| [api.md](./api.md) | Complete API reference | ForwardableEmailMessage, SendEmail bindings, reply() method, postal-mime/mimetext APIs |
+| [configuration.md](./configuration.md) | Setup and configuration | wrangler.jsonc, bindings, deployment, dependencies |
+| [patterns.md](./patterns.md) | Real-world examples | Allowlists from KV, auto-reply with threading, attachment extraction, webhook notifications |
+| [gotchas.md](./gotchas.md) | Pitfalls and debugging | Stream consumption, ctx.waitUntil errors, security, limits |
+
+## Architecture
+
+```
+Incoming Email → Email Routing → Email Worker
+ ↓
+ Process + Decide
+ ↓
+ ┌───────────────┼───────────────┐
+ ↓ ↓ ↓
+ Forward Reply Reject
+```
+
+**Event flow**:
+1. Email arrives at your domain
+2. Email Routing matches route (e.g., `support@example.com`)
+3. Bound Email Worker receives `ForwardableEmailMessage`
+4. Worker processes and takes action (forward/reply/reject)
+5. Email delivered or rejected based on worker logic
+
+## Key Concepts
+
+### Envelope vs Headers
+
+- **Envelope addresses** (`message.from`, `message.to`): SMTP transport addresses (trusted)
+- **Header addresses** (parsed from body): Display addresses (can be spoofed)
+
+Use envelope addresses for security decisions.
+
+### Single-Use Streams
+
+`message.raw` is a ReadableStream that can only be read once. Buffer to ArrayBuffer for multiple uses.
+
+```typescript
+// Buffer first
+const buffer = await new Response(message.raw).arrayBuffer();
+const email = await PostalMime.parse(buffer);
+```
+
+See [gotchas.md](./gotchas.md#readablestream-can-only-be-consumed-once) for details.
+
+### Verified Destinations
+
+`forward()` only works with addresses verified in the Cloudflare Email Routing dashboard. Add destinations before deployment.
+
+## Use Cases
+
+- **Spam filtering**: Block based on sender, content, or reputation
+- **Auto-responders**: Send acknowledgment replies with threading
+- **Ticket creation**: Parse emails and create support tickets
+- **Email archival**: Store in KV, R2, or D1
+- **Notification routing**: Forward to Slack, Discord, or webhooks
+- **Attachment processing**: Extract files to R2 storage
+- **Multi-tenant routing**: Route based on recipient subdomain
+- **Size filtering**: Reject oversized attachments
+
+## Limits
+
+| Limit | Value |
+|-------|-------|
+| Max message size | 25 MiB |
+| Max routing rules | 200 |
+| Max destinations | 200 |
+| CPU time (free tier) | 10ms |
+| CPU time (paid tier) | 50ms |
+
+See [gotchas.md](./gotchas.md#limits-reference) for complete limits table.
+
+## Prerequisites
+
+Before deploying Email Workers:
+
+1. **Enable Email Routing** in Cloudflare dashboard for your domain
+2. **Verify destination addresses** for forwarding
+3. **Configure DMARC/SPF** for sending domains (required for replies)
+4. **Set up wrangler.jsonc** with SendEmail binding
+
+See [configuration.md](./configuration.md) for detailed setup.
+
+## Service Worker Syntax (Deprecated)
+
+Modern projects should use ES modules format shown above. Service Worker syntax (`addEventListener('email', ...)`) is deprecated but still supported.
+
+## See Also
+
+- [Email Routing Documentation](https://developers.cloudflare.com/email-routing/)
+- [Workers Platform](https://developers.cloudflare.com/workers/)
+- [Wrangler CLI](https://developers.cloudflare.com/workers/wrangler/)
+- [postal-mime on npm](https://www.npmjs.com/package/postal-mime)
+- [mimetext on npm](https://www.npmjs.com/package/mimetext)
diff --git a/.agents/skills/cloudflare-deploy/references/email-workers/api.md b/.agents/skills/cloudflare-deploy/references/email-workers/api.md
new file mode 100644
index 0000000..74da66c
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/email-workers/api.md
@@ -0,0 +1,237 @@
+# Email Workers API Reference
+
+Complete API reference for Cloudflare Email Workers runtime.
+
+## ForwardableEmailMessage Interface
+
+The main interface passed to email handlers.
+
+```typescript
+interface ForwardableEmailMessage {
+ readonly from: string; // Envelope MAIL FROM (SMTP sender)
+ readonly to: string; // Envelope RCPT TO (SMTP recipient)
+ readonly headers: Headers; // Web-standard Headers object
+ readonly raw: ReadableStream; // Raw MIME message (single-use stream)
+ readonly rawSize: number; // Total message size in bytes
+
+ setReject(reason: string): void;
+ forward(rcptTo: string, headers?: Headers): Promise;
+ reply(message: EmailMessage): Promise;
+}
+```
+
+### Properties
+
+| Property | Type | Description |
+|----------|------|-------------|
+| `from` | string | Envelope sender (SMTP MAIL FROM) - use for security |
+| `to` | string | Envelope recipient (SMTP RCPT TO) |
+| `headers` | Headers | Message headers (Subject, Message-ID, etc.) |
+| `raw` | ReadableStream | Raw MIME message (**single-use**, buffer first) |
+| `rawSize` | number | Message size in bytes |
+
+### Methods
+
+#### setReject(reason: string): void
+
+Reject with permanent SMTP 5xx error. Email not delivered, sender may receive bounce.
+
+```typescript
+if (blockList.includes(message.from)) {
+ message.setReject('Sender blocked');
+}
+```
+
+#### forward(rcptTo: string, headers?: Headers): Promise
+
+Forward to verified destination. Only `X-*` custom headers allowed.
+
+```typescript
+await message.forward('inbox@example.com');
+
+// With custom headers
+const h = new Headers();
+h.set('X-Processed-By', 'worker');
+await message.forward('inbox@example.com', h);
+```
+
+#### reply(message: EmailMessage): Promise
+
+Send a reply to the original sender (March 2025 feature).
+
+```typescript
+import { EmailMessage } from 'cloudflare:email';
+import { createMimeMessage } from 'mimetext';
+
+const msg = createMimeMessage();
+msg.setSender({ name: 'Support', addr: 'support@example.com' });
+msg.setRecipient(message.from);
+msg.setSubject(`Re: ${message.headers.get('Subject')}`);
+msg.setHeader('In-Reply-To', message.headers.get('Message-ID'));
+msg.setHeader('References', message.headers.get('References') || '');
+msg.addMessage({
+ contentType: 'text/plain',
+ data: 'Thank you for your message.'
+});
+
+await message.reply(new EmailMessage(
+ 'support@example.com',
+ message.from,
+ msg.asRaw()
+));
+```
+
+**Requirements**:
+- Incoming email needs valid DMARC
+- Reply once per event, recipient = `message.from`
+- Sender domain = receiving domain, with DMARC/SPF/DKIM
+- Max 100 `References` entries
+- Threading: `In-Reply-To` (original Message-ID), `References`, new `Message-ID`
+
+## EmailMessage Constructor
+
+```typescript
+import { EmailMessage } from 'cloudflare:email';
+
+new EmailMessage(from: string, to: string, raw: ReadableStream | string)
+```
+
+Used for sending emails (replies or via SendEmail binding). Domain must be verified.
+
+## SendEmail Interface
+
+```typescript
+interface SendEmail {
+ send(message: EmailMessage): Promise;
+}
+
+// Usage
+await env.EMAIL.send(new EmailMessage(from, to, mimeContent));
+```
+
+## SendEmail Binding Types
+
+```jsonc
+{
+ "send_email": [
+ { "name": "EMAIL" }, // Type 1: Any verified address
+ { "name": "LOGS", "destination_address": "logs@example.com" }, // Type 2: Single dest
+ { "name": "TEAM", "allowed_destination_addresses": ["a@ex.com", "b@ex.com"] }, // Type 3: Dest allowlist
+ { "name": "NOREPLY", "allowed_sender_addresses": ["noreply@ex.com"] } // Type 4: Sender allowlist
+ ]
+}
+```
+
+## postal-mime Parsed Output
+
+postal-mime v2.7.3 parses incoming emails into structured data.
+
+```typescript
+interface ParsedEmail {
+ headers: Array<{ key: string; value: string }>;
+ from: { name: string; address: string } | null;
+ to: Array<{ name: string; address: string }> | { name: string; address: string } | null;
+ cc: Array<{ name: string; address: string }> | null;
+ bcc: Array<{ name: string; address: string }> | null;
+ subject: string;
+ messageId: string | null;
+ inReplyTo: string | null;
+ references: string | null;
+ date: string | null;
+ html: string | null;
+ text: string | null;
+ attachments: Array<{
+ filename: string;
+ mimeType: string;
+ disposition: string | null;
+ related: boolean;
+ contentId: string | null;
+ content: Uint8Array;
+ }>;
+}
+```
+
+### Usage
+
+```typescript
+import PostalMime from 'postal-mime';
+
+const buffer = await new Response(message.raw).arrayBuffer();
+const email = await PostalMime.parse(buffer);
+
+console.log(email.subject);
+console.log(email.from?.address);
+console.log(email.text);
+console.log(email.attachments.length);
+```
+
+## mimetext API Quick Reference
+
+mimetext v3.0.27 composes outgoing emails.
+
+```typescript
+import { createMimeMessage } from 'mimetext';
+
+const msg = createMimeMessage();
+
+// Sender
+msg.setSender({ name: 'John Doe', addr: 'john@example.com' });
+
+// Recipients
+msg.setRecipient('alice@example.com');
+msg.setRecipients(['bob@example.com', 'carol@example.com']);
+msg.setCc('manager@example.com');
+msg.setBcc(['audit@example.com']);
+
+// Headers
+msg.setSubject('Meeting Notes');
+msg.setHeader('In-Reply-To', '');
+msg.setHeader('References', ' ');
+msg.setHeader('Message-ID', `<${crypto.randomUUID()}@example.com>`);
+
+// Content
+msg.addMessage({
+ contentType: 'text/plain',
+ data: 'Plain text content'
+});
+
+msg.addMessage({
+ contentType: 'text/html',
+ data: 'HTML content
'
+});
+
+// Attachments
+msg.addAttachment({
+ filename: 'report.pdf',
+ contentType: 'application/pdf',
+ data: pdfBuffer // Uint8Array or base64 string
+});
+
+// Generate raw MIME
+const raw = msg.asRaw(); // Returns string
+```
+
+## TypeScript Types
+
+```typescript
+import {
+ ForwardableEmailMessage,
+ EmailMessage
+} from 'cloudflare:email';
+
+interface Env {
+ EMAIL: SendEmail;
+ EMAIL_ARCHIVE: KVNamespace;
+ ALLOWED_SENDERS: KVNamespace;
+}
+
+export default {
+ async email(
+ message: ForwardableEmailMessage,
+ env: Env,
+ ctx: ExecutionContext
+ ): Promise {
+ // Fully typed
+ }
+};
+```
diff --git a/.agents/skills/cloudflare-deploy/references/email-workers/configuration.md b/.agents/skills/cloudflare-deploy/references/email-workers/configuration.md
new file mode 100644
index 0000000..7928d04
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/email-workers/configuration.md
@@ -0,0 +1,112 @@
+# Email Workers Configuration
+
+## wrangler.jsonc
+
+```jsonc
+{
+ "name": "email-worker",
+ "main": "src/index.ts",
+ "compatibility_date": "2025-01-27",
+ "send_email": [
+ { "name": "EMAIL" }, // Unrestricted
+ { "name": "EMAIL_LOGS", "destination_address": "logs@example.com" }, // Single dest
+ { "name": "EMAIL_TEAM", "allowed_destination_addresses": ["a@ex.com", "b@ex.com"] },
+ { "name": "EMAIL_NOREPLY", "allowed_sender_addresses": ["noreply@ex.com"] }
+ ],
+ "kv_namespaces": [{ "binding": "ARCHIVE", "id": "xxx" }],
+ "r2_buckets": [{ "binding": "ATTACHMENTS", "bucket_name": "email-attachments" }],
+ "vars": { "WEBHOOK_URL": "https://hooks.example.com" }
+}
+```
+
+## TypeScript Types
+
+```typescript
+interface Env {
+ EMAIL: SendEmail;
+ ARCHIVE: KVNamespace;
+ ATTACHMENTS: R2Bucket;
+ WEBHOOK_URL: string;
+}
+
+export default {
+ async email(message: ForwardableEmailMessage, env: Env, ctx: ExecutionContext) {}
+};
+```
+
+## Dependencies
+
+```bash
+npm install postal-mime mimetext
+npm install -D @cloudflare/workers-types wrangler typescript
+```
+
+Use postal-mime v2.x, mimetext v3.x.
+
+## tsconfig.json
+
+```json
+{
+ "compilerOptions": {
+ "target": "ES2022", "module": "ES2022", "lib": ["ES2022"],
+ "types": ["@cloudflare/workers-types"],
+ "moduleResolution": "bundler", "strict": true
+ }
+}
+```
+
+## Local Development
+
+```bash
+npx wrangler dev
+
+# Test receiving
+curl --request POST 'http://localhost:8787/cdn-cgi/handler/email' \
+ --url-query 'from=sender@example.com' --url-query 'to=recipient@example.com' \
+ --header 'Content-Type: text/plain' --data-raw 'Subject: Test\n\nHello'
+```
+
+Sent emails write to local `.eml` files.
+
+## Deployment Checklist
+
+- [ ] Enable Email Routing in dashboard
+- [ ] Verify destination addresses
+- [ ] Configure DMARC/SPF/DKIM for sending
+- [ ] Create KV/R2 resources if needed
+- [ ] Update wrangler.jsonc with production IDs
+
+```bash
+npx wrangler deploy
+npx wrangler deployments list
+```
+
+## Dashboard Setup
+
+1. **Email Routing:** Domain → Email → Enable Email Routing
+2. **Verify addresses:** Email → Destination addresses → Add & verify
+3. **Bind Worker:** Email → Email Workers → Create route → Select pattern & Worker
+4. **DMARC:** Add TXT `_dmarc.domain.com`: `v=DMARC1; p=quarantine;`
+
+## Secrets
+
+```bash
+npx wrangler secret put API_KEY
+# Access: env.API_KEY
+```
+
+## Monitoring
+
+```bash
+npx wrangler tail
+npx wrangler tail --status error
+npx wrangler tail --format json
+```
+
+## Troubleshooting
+
+| Error | Fix |
+|-------|-----|
+| "Binding not found" | Check `send_email` name matches code |
+| "Invalid destination" | Verify in Email Routing dashboard |
+| Type errors | Install `@cloudflare/workers-types` |
diff --git a/.agents/skills/cloudflare-deploy/references/email-workers/gotchas.md b/.agents/skills/cloudflare-deploy/references/email-workers/gotchas.md
new file mode 100644
index 0000000..3700a50
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/email-workers/gotchas.md
@@ -0,0 +1,125 @@
+# Email Workers Gotchas
+
+## Critical Issues
+
+### ReadableStream Single-Use
+
+```typescript
+// ❌ WRONG: Stream consumed twice
+const email = await PostalMime.parse(await new Response(message.raw).arrayBuffer());
+const rawText = await new Response(message.raw).text(); // EMPTY!
+
+// ✅ CORRECT: Buffer first
+const buffer = await new Response(message.raw).arrayBuffer();
+const email = await PostalMime.parse(buffer);
+const rawText = new TextDecoder().decode(buffer);
+```
+
+### ctx.waitUntil() Errors Silent
+
+```typescript
+// ❌ Errors dropped silently
+ctx.waitUntil(fetch(webhookUrl, { method: 'POST', body: data }));
+
+// ✅ Catch and log
+ctx.waitUntil(
+ fetch(webhookUrl, { method: 'POST', body: data })
+ .catch(err => env.ERROR_LOG.put(`error:${Date.now()}`, err.message))
+);
+```
+
+## Security
+
+### Envelope vs Header From (Spoofing)
+
+```typescript
+const envelopeFrom = message.from; // SMTP MAIL FROM (trusted)
+const headerFrom = (await PostalMime.parse(buffer)).from?.address; // (untrusted)
+// Use envelope for security decisions
+```
+
+### Input Validation
+
+```typescript
+if (message.rawSize > 5_000_000) { message.setReject('Too large'); return; }
+if ((message.headers.get('Subject') || '').length > 1000) {
+ message.setReject('Invalid subject'); return;
+}
+```
+
+### DMARC for Replies
+
+Replies fail silently without DMARC. Verify: `dig TXT _dmarc.example.com`
+
+## Parsing
+
+### Address Parsing
+
+```typescript
+const email = await PostalMime.parse(buffer);
+const fromAddress = email.from?.address || 'unknown';
+const toAddresses = Array.isArray(email.to) ? email.to.map(t => t.address) : [email.to?.address];
+```
+
+### Character Encoding
+
+Let postal-mime handle decoding - `email.subject`, `email.text`, `email.html` are UTF-8.
+
+## API Behavior
+
+### setReject() vs throw
+
+```typescript
+// setReject() for SMTP rejection
+if (blockList.includes(message.from)) { message.setReject('Blocked'); return; }
+
+// throw for worker errors
+if (!env.KV) throw new Error('KV not configured');
+```
+
+### forward() Only X-* Headers
+
+```typescript
+headers.set('X-Processed-By', 'worker'); // ✅ Works
+headers.set('Subject', 'Modified'); // ❌ Dropped
+```
+
+### Reply Requires Verified Domain
+
+```typescript
+// Use same domain as receiving address
+const receivingDomain = message.to.split('@')[1];
+await message.reply(new EmailMessage(`noreply@${receivingDomain}`, message.from, rawMime));
+```
+
+## Performance
+
+### CPU Limit
+
+```typescript
+// Skip parsing large emails
+if (message.rawSize > 5_000_000) {
+ await message.forward('inbox@example.com');
+ return;
+}
+```
+
+Monitor: `npx wrangler tail`
+
+## Limits
+
+| Limit | Value |
+|-------|-------|
+| Max message size | 25 MiB |
+| Max rules/zone | 200 |
+| CPU time (free/paid) | 10ms / 50ms |
+| Reply References | 100 |
+
+## Common Errors
+
+| Error | Fix |
+|-------|-----|
+| "Address not verified" | Add in Email Routing dashboard |
+| "Exceeded CPU time" | Use `ctx.waitUntil()` or upgrade |
+| "Stream is locked" | Buffer `message.raw` first |
+| Silent reply failure | Check DMARC records |
diff --git a/.agents/skills/cloudflare-deploy/references/email-workers/patterns.md b/.agents/skills/cloudflare-deploy/references/email-workers/patterns.md
new file mode 100644
index 0000000..f1e65f5
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/email-workers/patterns.md
@@ -0,0 +1,102 @@
+# Email Workers Patterns
+
+## Parse Email
+
+```typescript
+import PostalMime from 'postal-mime';
+
+export default {
+ async email(message, env, ctx) {
+ const buffer = await new Response(message.raw).arrayBuffer();
+ const email = await PostalMime.parse(buffer);
+ console.log(email.from, email.subject, email.text, email.attachments.length);
+ await message.forward('inbox@example.com');
+ }
+};
+```
+
+## Filtering
+
+```typescript
+// Allowlist from KV
+const allowList = await env.ALLOWED_SENDERS.get('list', 'json') || [];
+if (!allowList.includes(message.from)) {
+ message.setReject('Not allowed');
+ return;
+}
+
+// Size check (avoid parsing large emails)
+if (message.rawSize > 5_000_000) {
+ await message.forward('inbox@example.com'); // Forward without parsing
+ return;
+}
+```
+
+## Auto-Reply with Threading
+
+```typescript
+import { EmailMessage } from 'cloudflare:email';
+import { createMimeMessage } from 'mimetext';
+
+const msg = createMimeMessage();
+msg.setSender({ addr: 'support@example.com' });
+msg.setRecipient(message.from);
+msg.setSubject(`Re: ${message.headers.get('Subject')}`);
+msg.setHeader('In-Reply-To', message.headers.get('Message-ID') || '');
+msg.addMessage({ contentType: 'text/plain', data: 'Thank you. We will respond.' });
+
+await message.reply(new EmailMessage('support@example.com', message.from, msg.asRaw()));
+```
+
+## Rate-Limited Auto-Reply
+
+```typescript
+const rateKey = `rate:${message.from}`;
+if (!await env.RATE_LIMIT.get(rateKey)) {
+ // Send reply...
+ ctx.waitUntil(env.RATE_LIMIT.put(rateKey, '1', { expirationTtl: 3600 }));
+}
+```
+
+## Subject-Based Routing
+
+```typescript
+const subject = (message.headers.get('Subject') || '').toLowerCase();
+if (subject.includes('billing')) await message.forward('billing@example.com');
+else if (subject.includes('support')) await message.forward('support@example.com');
+else await message.forward('general@example.com');
+```
+
+## Multi-Tenant Routing
+
+```typescript
+// support+tenant123@example.com → tenant123
+const tenantId = message.to.split('@')[0].match(/\+(.+)$/)?.[1] || 'default';
+const config = await env.TENANT_CONFIG.get(tenantId, 'json');
+config?.forwardTo ? await message.forward(config.forwardTo) : message.setReject('Unknown');
+```
+
+## Archive & Extract Attachments
+
+```typescript
+// Archive to KV
+ctx.waitUntil(env.ARCHIVE.put(`email:${Date.now()}`, JSON.stringify({
+ from: message.from, subject: email.subject
+})));
+
+// Attachments to R2
+for (const att of email.attachments) {
+ ctx.waitUntil(env.R2.put(`${Date.now()}-${att.filename}`, att.content));
+}
+```
+
+## Webhook Integration
+
+```typescript
+ctx.waitUntil(
+ fetch(env.WEBHOOK_URL, {
+ method: 'POST',
+ body: JSON.stringify({ from: message.from, subject: message.headers.get('Subject') })
+ }).catch(err => console.error(err))
+);
+```
diff --git a/.agents/skills/cloudflare-deploy/references/hyperdrive/README.md b/.agents/skills/cloudflare-deploy/references/hyperdrive/README.md
new file mode 100644
index 0000000..6626776
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/hyperdrive/README.md
@@ -0,0 +1,82 @@
+# Hyperdrive
+
+Accelerates database queries from Workers via connection pooling, edge setup, query caching.
+
+## Key Features
+
+- **Connection Pooling**: Persistent connections eliminate TCP/TLS/auth handshakes (~7 round-trips)
+- **Edge Setup**: Connection negotiation at edge, pooling near origin
+- **Query Caching**: Auto-cache non-mutating queries (default 60s TTL)
+- **Support**: PostgreSQL, MySQL + compatibles (CockroachDB, Timescale, PlanetScale, Neon, Supabase)
+
+## Architecture
+
+```
+Worker → Edge (setup) → Pool (near DB) → Origin
+ ↓ cached reads
+ Cache
+```
+
+## Quick Start
+
+```bash
+# Create config
+npx wrangler hyperdrive create my-db \
+ --connection-string="postgres://user:pass@host:5432/db"
+
+# wrangler.jsonc
+{
+ "compatibility_flags": ["nodejs_compat"],
+ "hyperdrive": [{"binding": "HYPERDRIVE", "id": ""}]
+}
+```
+
+```typescript
+import { Client } from "pg";
+
+export default {
+ async fetch(req: Request, env: Env): Promise {
+ const client = new Client({
+ connectionString: env.HYPERDRIVE.connectionString,
+ });
+ await client.connect();
+ const result = await client.query("SELECT * FROM users WHERE id = $1", [123]);
+ await client.end();
+ return Response.json(result.rows);
+ },
+};
+```
+
+## When to Use
+
+✅ Global access to single-region DBs, high read ratios, popular queries, connection-heavy loads
+❌ Write-heavy, real-time data (<1s), single-region apps close to DB
+
+**💡 Pair with Smart Placement** for Workers making multiple queries - executes near DB to minimize latency.
+
+## Driver Choice
+
+| Driver | Use When | Notes |
+|--------|----------|-------|
+| **pg** (recommended) | General use, TypeScript, ecosystem compatibility | Stable, widely used, works with most ORMs |
+| **postgres.js** | Advanced features, template literals, streaming | Lighter than pg, `prepare: true` is default |
+| **mysql2** | MySQL/MariaDB/PlanetScale | MySQL only, less mature support |
+
+## Reading Order
+
+| New to Hyperdrive | Implementing | Troubleshooting |
+|-------------------|--------------|-----------------|
+| 1. README (this) | 1. [configuration.md](./configuration.md) | 1. [gotchas.md](./gotchas.md) |
+| 2. [configuration.md](./configuration.md) | 2. [api.md](./api.md) | 2. [patterns.md](./patterns.md) |
+| 3. [api.md](./api.md) | 3. [patterns.md](./patterns.md) | 3. [api.md](./api.md) |
+
+## In This Reference
+- [configuration.md](./configuration.md) - Setup, wrangler config, Smart Placement
+- [api.md](./api.md) - Binding APIs, query patterns, driver usage
+- [patterns.md](./patterns.md) - Use cases, ORMs, multi-query optimization
+- [gotchas.md](./gotchas.md) - Limits, troubleshooting, connection management
+
+## See Also
+- [smart-placement](../smart-placement/) - Optimize multi-query Workers near databases
+- [d1](../d1/) - Serverless SQLite alternative for edge-native apps
+- [workers](../workers/) - Worker runtime with database bindings
diff --git a/.agents/skills/cloudflare-deploy/references/hyperdrive/api.md b/.agents/skills/cloudflare-deploy/references/hyperdrive/api.md
new file mode 100644
index 0000000..0e587b9
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/hyperdrive/api.md
@@ -0,0 +1,143 @@
+# API Reference
+
+See [README.md](./README.md) for overview, [configuration.md](./configuration.md) for setup.
+
+## Binding Interface
+
+```typescript
+interface Hyperdrive {
+ connectionString: string; // PostgreSQL
+ // MySQL properties:
+ host: string;
+ port: number;
+ user: string;
+ password: string;
+ database: string;
+}
+
+interface Env {
+ HYPERDRIVE: Hyperdrive;
+}
+```
+
+**Generate types:** `npx wrangler types` (auto-creates worker-configuration.d.ts from wrangler.jsonc)
+
+## PostgreSQL (node-postgres) - RECOMMENDED
+
+```typescript
+import { Client } from "pg"; // pg@^8.17.2
+
+export default {
+ async fetch(req: Request, env: Env): Promise {
+ const client = new Client({connectionString: env.HYPERDRIVE.connectionString});
+ try {
+ await client.connect();
+ const result = await client.query("SELECT * FROM users WHERE id = $1", [123]);
+ return Response.json(result.rows);
+ } finally {
+ await client.end();
+ }
+ },
+};
+```
+
+**⚠️ Workers connection limit: 6 per Worker invocation** - use connection pooling wisely.
+
+## PostgreSQL (postgres.js)
+
+```typescript
+import postgres from "postgres"; // postgres@^3.4.8
+
+const sql = postgres(env.HYPERDRIVE.connectionString, {
+ max: 5, // Limit per Worker (Workers max: 6)
+ prepare: true, // Enabled by default, required for caching
+ fetch_types: false, // Reduce latency if not using arrays
+});
+
+const users = await sql`SELECT * FROM users WHERE active = ${true} LIMIT 10`;
+```
+
+**⚠️ `prepare: true` is enabled by default and required for Hyperdrive caching.** Setting to `false` disables prepared statements + cache.
+
+## MySQL (mysql2)
+
+```typescript
+import { createConnection } from "mysql2/promise"; // mysql2@^3.16.2
+
+const conn = await createConnection({
+ host: env.HYPERDRIVE.host,
+ user: env.HYPERDRIVE.user,
+ password: env.HYPERDRIVE.password,
+ database: env.HYPERDRIVE.database,
+ port: env.HYPERDRIVE.port,
+ disableEval: true, // ⚠️ REQUIRED for Workers
+});
+
+const [results] = await conn.query("SELECT * FROM users WHERE active = ? LIMIT ?", [true, 10]);
+ctx.waitUntil(conn.end());
+```
+
+**⚠️ MySQL support is less mature than PostgreSQL** - expect fewer optimizations and potential edge cases.
+
+## Query Caching
+
+**Cacheable:**
+```sql
+SELECT * FROM posts WHERE published = true;
+SELECT COUNT(*) FROM users;
+```
+
+**NOT cacheable:**
+```sql
+-- Writes
+INSERT/UPDATE/DELETE
+
+-- Volatile functions
+SELECT NOW();
+SELECT random();
+SELECT LASTVAL(); -- PostgreSQL
+SELECT UUID(); -- MySQL
+```
+
+**Cache config:**
+- Default: `max_age=60s`, `swr=15s`
+- Max `max_age`: 3600s
+- Disable: `--caching-disabled=true`
+
+**Multiple configs pattern:**
+```typescript
+// Reads: cached
+const sqlCached = postgres(env.HYPERDRIVE_CACHED.connectionString);
+const posts = await sqlCached`SELECT * FROM posts ORDER BY views DESC LIMIT 10`;
+
+// Writes/time-sensitive: no cache
+const sqlNoCache = postgres(env.HYPERDRIVE_NO_CACHE.connectionString);
+const orders = await sqlNoCache`SELECT * FROM orders WHERE created_at > NOW() - INTERVAL 5 MINUTE`;
+```
+
+## ORMs
+
+**Drizzle:**
+```typescript
+import { drizzle } from "drizzle-orm/postgres-js"; // drizzle-orm@^0.45.1
+import postgres from "postgres";
+
+const client = postgres(env.HYPERDRIVE.connectionString, {max: 5, prepare: true});
+const db = drizzle(client);
+const users = await db.select().from(users).where(eq(users.active, true)).limit(10);
+```
+
+**Kysely:**
+```typescript
+import { Kysely, PostgresDialect } from "kysely"; // kysely@^0.27+
+import postgres from "postgres";
+
+const db = new Kysely({
+ dialect: new PostgresDialect({
+ postgres: postgres(env.HYPERDRIVE.connectionString, {max: 5, prepare: true}),
+ }),
+});
+const users = await db.selectFrom("users").selectAll().where("active", "=", true).execute();
+```
+
+See [patterns.md](./patterns.md) for use cases, [gotchas.md](./gotchas.md) for limits.
diff --git a/.agents/skills/cloudflare-deploy/references/hyperdrive/configuration.md b/.agents/skills/cloudflare-deploy/references/hyperdrive/configuration.md
new file mode 100644
index 0000000..6d429a9
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/hyperdrive/configuration.md
@@ -0,0 +1,159 @@
+# Configuration
+
+See [README.md](./README.md) for overview.
+
+## Create Config
+
+**PostgreSQL:**
+```bash
+# Basic
+npx wrangler hyperdrive create my-db \
+ --connection-string="postgres://user:pass@host:5432/db"
+
+# Custom cache
+npx wrangler hyperdrive create my-db \
+ --connection-string="postgres://..." \
+ --max-age=120 --swr=30
+
+# No cache
+npx wrangler hyperdrive create my-db \
+ --connection-string="postgres://..." \
+ --caching-disabled=true
+```
+
+**MySQL:**
+```bash
+npx wrangler hyperdrive create my-db \
+ --connection-string="mysql://user:pass@host:3306/db"
+```
+
+## wrangler.jsonc
+
+```jsonc
+{
+ "compatibility_date": "2025-01-01", // Use latest for new projects
+ "compatibility_flags": ["nodejs_compat"],
+ "hyperdrive": [
+ {
+ "binding": "HYPERDRIVE",
+ "id": "",
+ "localConnectionString": "postgres://user:pass@localhost:5432/dev"
+ }
+ ]
+}
+```
+
+**Generate TypeScript types:** Run `npx wrangler types` to auto-generate `worker-configuration.d.ts` from your wrangler.jsonc.
+
+**Multiple configs:**
+```jsonc
+{
+ "hyperdrive": [
+ {"binding": "HYPERDRIVE_CACHED", "id": ""},
+ {"binding": "HYPERDRIVE_NO_CACHE", "id": ""}
+ ]
+}
+```
+
+## Management
+
+```bash
+npx wrangler hyperdrive list
+npx wrangler hyperdrive get
+npx wrangler hyperdrive update --max-age=180
+npx wrangler hyperdrive delete
+```
+
+## Config Options
+
+Hyperdrive create/update CLI flags:
+
+| Option | Default | Notes |
+|--------|---------|-------|
+| `--caching-disabled` | `false` | Disable caching |
+| `--max-age` | `60` | Cache TTL (max 3600s) |
+| `--swr` | `15` | Stale-while-revalidate |
+| `--origin-connection-limit` | 20/100 | Free/paid |
+| `--access-client-id` | - | Tunnel auth |
+| `--access-client-secret` | - | Tunnel auth |
+| `--sslmode` | `require` | PostgreSQL only |
+
+## Smart Placement Integration
+
+For Workers making **multiple queries** per request, enable Smart Placement to execute near your database:
+
+```jsonc
+{
+ "compatibility_date": "2025-01-01",
+ "compatibility_flags": ["nodejs_compat"],
+ "placement": {
+ "mode": "smart"
+ },
+ "hyperdrive": [
+ {
+ "binding": "HYPERDRIVE",
+ "id": ""
+ }
+ ]
+}
+```
+
+**Benefits:** Multi-query Workers run closer to DB, reducing round-trip latency. See [patterns.md](./patterns.md) for examples.
+
+## Private DB via Tunnel
+
+```
+Worker → Hyperdrive → Access → Tunnel → Private Network → DB
+```
+
+**Setup:**
+```bash
+# 1. Create tunnel
+cloudflared tunnel create my-db-tunnel
+
+# 2. Configure hostname in Zero Trust dashboard
+# Domain: db-tunnel.example.com
+# Service: TCP -> localhost:5432
+
+# 3. Create service token (Zero Trust > Service Auth)
+# Save Client ID/Secret
+
+# 4. Create Access app (db-tunnel.example.com)
+# Policy: Service Auth token from step 3
+
+# 5. Create Hyperdrive
+npx wrangler hyperdrive create my-private-db \
+ --host=db-tunnel.example.com \
+ --user=dbuser --password=dbpass --database=prod \
+ --access-client-id= --access-client-secret=
+```
+
+**⚠️ Don't specify `--port` with Tunnel** - port configured in tunnel service settings.
+
+## Local Dev
+
+**Option 1: Local (RECOMMENDED):**
+```bash
+# Env var (takes precedence)
+export CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgres://user:pass@localhost:5432/dev"
+npx wrangler dev
+
+# wrangler.jsonc
+{"hyperdrive": [{"binding": "HYPERDRIVE", "localConnectionString": "postgres://..."}]}
+```
+
+**Remote DB locally:**
+```bash
+# PostgreSQL
+export CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="postgres://user:pass@remote:5432/db?sslmode=require"
+
+# MySQL
+export CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_HYPERDRIVE="mysql://user:pass@remote:3306/db?sslMode=REQUIRED"
+```
+
+**Option 2: Remote execution:**
+```bash
+npx wrangler dev --remote # Uses deployed config, affects production
+```
+
+See [api.md](./api.md), [patterns.md](./patterns.md), [gotchas.md](./gotchas.md).
diff --git a/.agents/skills/cloudflare-deploy/references/hyperdrive/gotchas.md b/.agents/skills/cloudflare-deploy/references/hyperdrive/gotchas.md
new file mode 100644
index 0000000..efa2ead
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/hyperdrive/gotchas.md
@@ -0,0 +1,77 @@
+# Gotchas
+
+See [README.md](./README.md), [configuration.md](./configuration.md), [api.md](./api.md), [patterns.md](./patterns.md).
+
+## Common Errors
+
+### "Too many open connections" / "Connection limit exceeded"
+
+**Cause:** Workers have a hard limit of **6 concurrent connections per invocation**
+**Solution:** Set `max: 5` in driver config, reuse connections, ensure proper cleanup with `client.end()` or `ctx.waitUntil(conn.end())`
+
+### "Failed to acquire a connection (Pool exhausted)"
+
+**Cause:** All connections in pool are in use, often due to long-running transactions
+**Solution:** Reduce transaction duration, avoid queries >60s, don't hold connections during external calls, or upgrade to paid plan for more connections
+
+### "connection_refused"
+
+**Cause:** Database refusing connections due to firewall, connection limits, or service down
+**Solution:** Check firewall allows Cloudflare IPs, verify DB listening on port, confirm service running, and validate credentials
+
+### "Query timeout (deadline exceeded)"
+
+**Cause:** Query execution exceeding 60s timeout limit
+**Solution:** Optimize with indexes, reduce dataset with LIMIT, break into smaller queries, or use async processing
+
+### "password authentication failed"
+
+**Cause:** Invalid credentials in Hyperdrive configuration
+**Solution:** Check username and password in Hyperdrive config match database credentials
+
+### "SSL/TLS connection error"
+
+**Cause:** SSL/TLS configuration mismatch between Hyperdrive and database
+**Solution:** Add `sslmode=require` (Postgres) or `sslMode=REQUIRED` (MySQL), upload CA cert if self-signed, verify DB has SSL enabled, and check cert expiry
+
+### "Queries not being cached"
+
+**Cause:** Query is mutating (INSERT/UPDATE/DELETE), contains volatile functions (NOW(), RANDOM()), or caching disabled
+**Solution:** Verify query is non-mutating SELECT, avoid volatile functions, confirm caching enabled, use `wrangler dev --remote` to test, and set `prepare=true` for postgres.js
+
+### "Slow multi-query Workers despite Hyperdrive"
+
+**Cause:** Worker executing at edge, each query round-trips to DB region
+**Solution:** Enable Smart Placement (`"placement": {"mode": "smart"}` in wrangler.jsonc) to execute Worker near DB. See [patterns.md](./patterns.md) Multi-Query pattern.
+
+### "Local database connection failed"
+
+**Cause:** `localConnectionString` incorrect or database not running
+**Solution:** Verify `localConnectionString` correct, check DB running, confirm env var name matches binding, and test with psql/mysql client
+
+### "Environment variable not working"
+
+**Cause:** Environment variable format incorrect or not exported
+**Solution:** Use format `CLOUDFLARE_HYPERDRIVE_LOCAL_CONNECTION_STRING_`, ensure binding matches wrangler.jsonc, export variable in shell, and restart wrangler dev
+
+## Limits
+
+| Limit | Free | Paid | Notes |
+|-------|------|------|-------|
+| Max configs | 10 | 25 | Hyperdrive configurations per account |
+| Worker connections | 6 | 6 | Max concurrent connections per Worker invocation |
+| Username/DB name | 63 bytes | 63 bytes | Maximum length |
+| Connection timeout | 15s | 15s | Time to establish connection |
+| Idle timeout | 10 min | 10 min | Connection idle timeout |
+| Max origin connections | ~20 | ~100 | Connections to origin database |
+| Query duration max | 60s | 60s | Queries >60s terminated |
+| Cached response max | 50 MB | 50 MB | Responses >50MB returned but not cached |
+
+## Resources
+
+- [Docs](https://developers.cloudflare.com/hyperdrive/)
+- [Getting Started](https://developers.cloudflare.com/hyperdrive/get-started/)
+- [Wrangler Reference](https://developers.cloudflare.com/hyperdrive/reference/wrangler-commands/)
+- [Supported DBs](https://developers.cloudflare.com/hyperdrive/reference/supported-databases-and-features/)
+- [Discord #hyperdrive](https://discord.cloudflare.com)
+- [Limit Increase Form](https://forms.gle/ukpeZVLWLnKeixDu7)
diff --git a/.agents/skills/cloudflare-deploy/references/hyperdrive/patterns.md b/.agents/skills/cloudflare-deploy/references/hyperdrive/patterns.md
new file mode 100644
index 0000000..bd794b9
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/hyperdrive/patterns.md
@@ -0,0 +1,190 @@
+# Patterns
+
+See [README.md](./README.md), [configuration.md](./configuration.md), [api.md](./api.md).
+
+## High-Traffic Read-Heavy
+
+```typescript
+const sql = postgres(env.HYPERDRIVE.connectionString, {max: 5, prepare: true});
+
+// Cacheable: popular content
+const posts = await sql`SELECT * FROM posts WHERE published = true ORDER BY views DESC LIMIT 20`;
+
+// Cacheable: user profiles
+const [user] = await sql`SELECT id, username, bio FROM users WHERE id = ${userId}`;
+```
+
+**Benefits:** Trending/profiles cached (60s), connection pooling handles spikes.
+
+## Mixed Read/Write
+
+```typescript
+interface Env {
+ HYPERDRIVE_CACHED: Hyperdrive; // max_age=120
+ HYPERDRIVE_REALTIME: Hyperdrive; // caching disabled
+}
+
+// Reads: cached
+if (req.method === "GET") {
+ const sql = postgres(env.HYPERDRIVE_CACHED.connectionString, {prepare: true});
+ const products = await sql`SELECT * FROM products WHERE category = ${cat}`;
+}
+
+// Writes: no cache (immediate consistency)
+if (req.method === "POST") {
+ const sql = postgres(env.HYPERDRIVE_REALTIME.connectionString, {prepare: true});
+ await sql`INSERT INTO orders ${sql(data)}`;
+}
+```
+
+## Analytics Dashboard
+
+```typescript
+const client = new Client({connectionString: env.HYPERDRIVE.connectionString});
+await client.connect();
+
+// Aggregate queries cached (use fixed timestamps for caching)
+const thirtyDaysAgo = new Date(Date.now() - 30 * 24 * 60 * 60 * 1000).toISOString();
+const dailyStats = await client.query(`
+ SELECT DATE(created_at) as date, COUNT(*) as orders, SUM(amount) as revenue
+ FROM orders WHERE created_at >= $1
+ GROUP BY DATE(created_at) ORDER BY date DESC
+`, [thirtyDaysAgo]);
+
+const sevenDaysAgo = new Date(Date.now() - 7 * 24 * 60 * 60 * 1000).toISOString();
+const topProducts = await client.query(`
+ SELECT p.name, COUNT(oi.id) as count, SUM(oi.quantity * oi.price) as revenue
+ FROM order_items oi JOIN products p ON oi.product_id = p.id
+ WHERE oi.created_at >= $1
+ GROUP BY p.id, p.name ORDER BY revenue DESC LIMIT 10
+`, [sevenDaysAgo]);
+```
+
+**Benefits:** Expensive aggregations cached (avoid NOW() for cacheability), dashboard instant, reduced DB load.
+
+## Multi-Tenant
+
+```typescript
+const tenantId = req.headers.get("X-Tenant-ID");
+const sql = postgres(env.HYPERDRIVE.connectionString, {prepare: true});
+
+// Tenant-scoped queries cached separately
+const docs = await sql`
+ SELECT * FROM documents
+ WHERE tenant_id = ${tenantId} AND deleted_at IS NULL
+ ORDER BY updated_at DESC LIMIT 50
+`;
+```
+
+**Benefits:** Per-tenant caching, shared connection pool, protects DB from multi-tenant load.
+
+## Geographically Distributed
+
+```typescript
+// Worker runs at edge nearest user
+// Connection setup at edge (fast), pooling near DB (efficient)
+const sql = postgres(env.HYPERDRIVE.connectionString, {prepare: true});
+const [user] = await sql`SELECT * FROM users WHERE id = ${userId}`;
+
+return Response.json({
+ user,
+ serverRegion: req.cf?.colo, // Edge location
+});
+```
+
+**Benefits:** Edge setup + DB pooling = global → single-region DB without replication.
+
+## Multi-Query + Smart Placement
+
+For Workers making **multiple queries** per request, enable Smart Placement to execute near DB:
+
+```jsonc
+// wrangler.jsonc
+{
+ "placement": {"mode": "smart"},
+ "hyperdrive": [{"binding": "HYPERDRIVE", "id": ""}]
+}
+```
+
+```typescript
+const sql = postgres(env.HYPERDRIVE.connectionString, {prepare: true});
+
+// Multiple queries benefit from Smart Placement
+const [user] = await sql`SELECT * FROM users WHERE id = ${userId}`;
+const orders = await sql`SELECT * FROM orders WHERE user_id = ${userId} ORDER BY created_at DESC LIMIT 10`;
+const stats = await sql`SELECT COUNT(*) as total, SUM(amount) as spent FROM orders WHERE user_id = ${userId}`;
+
+return Response.json({user, orders, stats});
+```
+
+**Benefits:** Worker executes near DB → reduces latency for each query. Without Smart Placement, each query round-trips from edge.
+
+## Connection Pooling
+
+Operates in **transaction mode**: connection acquired per transaction, `RESET` on return.
+
+**SET statements:**
+```typescript
+// ✅ Within transaction
+await client.query("BEGIN");
+await client.query("SET work_mem = '256MB'");
+await client.query("SELECT * FROM large_table"); // Uses SET
+await client.query("COMMIT"); // RESET after
+
+// ✅ Single statement
+await client.query("SET work_mem = '256MB'; SELECT * FROM large_table");
+
+// ❌ Across queries (may get different connection)
+await client.query("SET work_mem = '256MB'");
+await client.query("SELECT * FROM large_table"); // SET not applied
+```
+
+**Best practices:**
+```typescript
+// ❌ Long transactions block pooling
+await client.query("BEGIN");
+await processThousands(); // Connection held entire time
+await client.query("COMMIT");
+
+// ✅ Short transactions
+await client.query("BEGIN");
+await client.query("UPDATE users SET status = $1 WHERE id = $2", [status, id]);
+await client.query("COMMIT");
+
+// ✅ SET LOCAL within transaction
+await client.query("BEGIN");
+await client.query("SET LOCAL work_mem = '256MB'");
+await client.query("SELECT * FROM large_table");
+await client.query("COMMIT");
+```
+
+## Performance Tips
+
+**Enable prepared statements (required for caching):**
+```typescript
+const sql = postgres(connectionString, {prepare: true}); // Default, enables caching
+```
+
+**Optimize connection settings:**
+```typescript
+const sql = postgres(connectionString, {
+ max: 5, // Stay under Workers' 6 connection limit
+ fetch_types: false, // Reduce latency if not using arrays
+ idle_timeout: 60, // Match Worker lifetime
+});
+```
+
+**Write cache-friendly queries:**
+```typescript
+// ✅ Cacheable (deterministic)
+await sql`SELECT * FROM products WHERE category = 'electronics' LIMIT 10`;
+
+// ❌ Not cacheable (volatile NOW())
+await sql`SELECT * FROM logs WHERE created_at > NOW()`;
+
+// ✅ Cacheable (parameterized timestamp)
+const ts = Date.now();
+await sql`SELECT * FROM logs WHERE created_at > ${ts}`;
+```
+
+See [gotchas.md](./gotchas.md) for limits, troubleshooting.
diff --git a/.agents/skills/cloudflare-deploy/references/images/README.md b/.agents/skills/cloudflare-deploy/references/images/README.md
new file mode 100644
index 0000000..f1dd644
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/images/README.md
@@ -0,0 +1,61 @@
+# Cloudflare Images Skill Reference
+
+**Cloudflare Images** is an end-to-end image management solution providing storage, transformation, optimization, and delivery at scale via Cloudflare's global network.
+
+## Quick Decision Tree
+
+**Need to:**
+- **Transform in Worker?** → [api.md](api.md#workers-binding-api-2026-primary-method) (Workers Binding API)
+- **Upload from Worker?** → [api.md](api.md#upload-from-worker) (REST API)
+- **Upload from client?** → [patterns.md](patterns.md#upload-from-client-direct-creator-upload) (Direct Creator Upload)
+- **Set up variants?** → [configuration.md](configuration.md#variants-configuration)
+- **Serve responsive images?** → [patterns.md](patterns.md#responsive-images)
+- **Add watermarks?** → [patterns.md](patterns.md#watermarking)
+- **Fix errors?** → [gotchas.md](gotchas.md#common-errors)
+
+## Reading Order
+
+**For building image upload/transform feature:**
+1. [configuration.md](configuration.md) - Setup Workers binding
+2. [api.md](api.md#workers-binding-api-2026-primary-method) - Learn transform API
+3. [patterns.md](patterns.md#upload-from-client-direct-creator-upload) - Direct upload pattern
+4. [gotchas.md](gotchas.md) - Check limits and errors
+
+**For URL-based transforms:**
+1. [configuration.md](configuration.md#variants-configuration) - Create variants
+2. [api.md](api.md#url-transform-api) - URL syntax
+3. [patterns.md](patterns.md#responsive-images) - Responsive patterns
+
+**For troubleshooting:**
+1. [gotchas.md](gotchas.md#common-errors) - Error messages
+2. [gotchas.md](gotchas.md#limits) - Size/format limits
+
+## Core Methods
+
+| Method | Use Case | Location |
+|--------|----------|----------|
+| `env.IMAGES.input().transform()` | Transform in Worker | [api.md:11](api.md) |
+| REST API `/images/v1` | Upload images | [api.md:57](api.md) |
+| Direct Creator Upload | Client-side upload | [api.md:127](api.md) |
+| URL transforms | Static image delivery | [api.md:112](api.md) |
+
+## In This Reference
+
+- **[api.md](api.md)** - Complete API: Workers binding, REST endpoints, URL transforms
+- **[configuration.md](configuration.md)** - Setup: wrangler.toml, variants, auth, signed URLs
+- **[patterns.md](patterns.md)** - Patterns: responsive images, watermarks, format negotiation, caching
+- **[gotchas.md](gotchas.md)** - Troubleshooting: limits, errors, best practices
+
+## Key Features
+
+- **Automatic Optimization** - AVIF/WebP format negotiation
+- **On-the-fly Transforms** - Resize, crop, blur, sharpen via URL or API
+- **Workers Binding** - Transform images in Workers (2026 primary method)
+- **Direct Upload** - Secure client-side uploads without backend proxy
+- **Global Delivery** - Cached at 300+ Cloudflare data centers
+- **Watermarking** - Overlay images programmatically
+
+## See Also
+
+- [Official Docs](https://developers.cloudflare.com/images/)
+- [Workers Examples](https://developers.cloudflare.com/images/tutorials/)
diff --git a/.agents/skills/cloudflare-deploy/references/images/api.md b/.agents/skills/cloudflare-deploy/references/images/api.md
new file mode 100644
index 0000000..c172e22
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/images/api.md
@@ -0,0 +1,96 @@
+# API Reference
+
+## Workers Binding API
+
+```toml
+# wrangler.toml
+[images]
+binding = "IMAGES"
+```
+
+### Transform Images
+
+```typescript
+const imageResponse = await env.IMAGES
+ .input(fileBuffer)
+ .transform({ width: 800, height: 600, fit: "cover", quality: 85, format: "avif" })
+ .output();
+return imageResponse.response();
+```
+
+### Transform Options
+
+```typescript
+interface TransformOptions {
+ width?: number; height?: number;
+ fit?: "scale-down" | "contain" | "cover" | "crop" | "pad";
+ quality?: number; // 1-100
+ format?: "avif" | "webp" | "jpeg" | "png";
+ dpr?: number; // 1-3
+ gravity?: "auto" | "left" | "right" | "top" | "bottom" | "face" | string;
+ sharpen?: number; // 0-10
+ blur?: number; // 1-250
+ rotate?: 90 | 180 | 270;
+ background?: string; // CSS color for pad
+ metadata?: "none" | "copyright" | "keep";
+ brightness?: number; contrast?: number; gamma?: number; // 0-2
+}
+```
+
+### Draw/Watermark
+
+```typescript
+await env.IMAGES.input(baseImage)
+ .draw(env.IMAGES.input(watermark).transform({ width: 100 }), { top: 10, left: 10, opacity: 0.8 })
+ .output();
+```
+
+## REST API
+
+### Upload Image
+
+```bash
+curl -X POST https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1 \
+ -H "Authorization: Bearer {token}" -F file=@image.jpg -F metadata='{"key":"value"}'
+```
+
+### Other Operations
+
+```bash
+GET /accounts/{account_id}/images/v1/{image_id} # Get details
+DELETE /accounts/{account_id}/images/v1/{image_id} # Delete
+GET /accounts/{account_id}/images/v1?page=1 # List
+```
+
+## URL Transform API
+
+```
+https://imagedelivery.net/{hash}/{id}/width=800,height=600,fit=cover,format=avif
+```
+
+**Params:** `w=`, `h=`, `fit=`, `q=`, `f=`, `dpr=`, `gravity=`, `sharpen=`, `blur=`, `rotate=`, `background=`, `metadata=`
+
+## Direct Creator Upload
+
+```typescript
+// 1. Get upload URL (backend)
+const { result } = await fetch(
+ `https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v2/direct_upload`,
+ { method: 'POST', headers: { 'Authorization': `Bearer ${token}` },
+ body: JSON.stringify({ requireSignedURLs: false }) }
+).then(r => r.json());
+
+// 2. Client uploads to result.uploadURL
+const formData = new FormData();
+formData.append('file', file);
+await fetch(result.uploadURL, { method: 'POST', body: formData });
+```
+
+## Error Codes
+
+| Code | Message | Solution |
+|------|---------|----------|
+| 5400 | Invalid format | Use JPEG, PNG, GIF, WebP |
+| 5401 | Too large | Max 100MB |
+| 5403 | Invalid transform | Check params |
+| 9413 | Rate limit | Implement backoff |
diff --git a/.agents/skills/cloudflare-deploy/references/images/configuration.md b/.agents/skills/cloudflare-deploy/references/images/configuration.md
new file mode 100644
index 0000000..9fa2deb
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/images/configuration.md
@@ -0,0 +1,211 @@
+# Configuration
+
+## Wrangler Integration
+
+### Workers Binding Setup
+
+Add to `wrangler.toml`:
+
+```toml
+name = "my-image-worker"
+main = "src/index.ts"
+compatibility_date = "2024-01-01"
+
+[images]
+binding = "IMAGES"
+```
+
+Access in Worker:
+
+```typescript
+interface Env {
+ IMAGES: ImageBinding;
+}
+
+export default {
+ async fetch(request: Request, env: Env): Promise {
+ return await env.IMAGES
+ .input(imageBuffer)
+ .transform({ width: 800 })
+ .output()
+ .response();
+ }
+};
+```
+
+### Upload via Script
+
+Wrangler doesn't have built-in Images commands, use REST API:
+
+```typescript
+// scripts/upload-image.ts
+import fs from 'fs';
+import FormData from 'form-data';
+
+async function uploadImage(filePath: string) {
+ const accountId = process.env.CLOUDFLARE_ACCOUNT_ID!;
+ const apiToken = process.env.CLOUDFLARE_API_TOKEN!;
+
+ const formData = new FormData();
+ formData.append('file', fs.createReadStream(filePath));
+
+ const response = await fetch(
+ `https://api.cloudflare.com/client/v4/accounts/${accountId}/images/v1`,
+ {
+ method: 'POST',
+ headers: {
+ 'Authorization': `Bearer ${apiToken}`,
+ },
+ body: formData,
+ }
+ );
+
+ const result = await response.json();
+ console.log('Uploaded:', result);
+}
+
+uploadImage('./photo.jpg');
+```
+
+### Environment Variables
+
+Store account hash for URL construction:
+
+```toml
+[vars]
+IMAGES_ACCOUNT_HASH = "your-account-hash"
+ACCOUNT_ID = "your-account-id"
+```
+
+Access in Worker:
+
+```typescript
+const imageUrl = `https://imagedelivery.net/${env.IMAGES_ACCOUNT_HASH}/${imageId}/public`;
+```
+
+## Variants Configuration
+
+Variants are named presets for transformations.
+
+### Create Variant (Dashboard)
+
+1. Navigate to Images → Variants
+2. Click "Create Variant"
+3. Set name (e.g., `thumbnail`)
+4. Configure: `width=200,height=200,fit=cover`
+
+### Create Variant (API)
+
+```bash
+curl -X POST \
+ https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1/variants \
+ -H "Authorization: Bearer {api_token}" \
+ -H "Content-Type: application/json" \
+ -d '{
+ "id": "thumbnail",
+ "options": {
+ "width": 200,
+ "height": 200,
+ "fit": "cover"
+ },
+ "neverRequireSignedURLs": true
+ }'
+```
+
+### Use Variant
+
+```
+https://imagedelivery.net/{account_hash}/{image_id}/thumbnail
+```
+
+### Common Variant Presets
+
+```json
+{
+ "thumbnail": {
+ "width": 200,
+ "height": 200,
+ "fit": "cover"
+ },
+ "avatar": {
+ "width": 128,
+ "height": 128,
+ "fit": "cover",
+ "gravity": "face"
+ },
+ "hero": {
+ "width": 1920,
+ "height": 1080,
+ "fit": "cover",
+ "quality": 90
+ },
+ "mobile": {
+ "width": 640,
+ "fit": "scale-down",
+ "quality": 80,
+ "format": "avif"
+ }
+}
+```
+
+## Authentication
+
+### API Token (Recommended)
+
+Generate at: Dashboard → My Profile → API Tokens
+
+Required permissions:
+- Account → Cloudflare Images → Edit
+
+```bash
+curl -H "Authorization: Bearer {api_token}" \
+ https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1
+```
+
+### API Key (Legacy)
+
+```bash
+curl -H "X-Auth-Email: {email}" \
+ -H "X-Auth-Key: {api_key}" \
+ https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1
+```
+
+## Signed URLs
+
+For private images, enable signed URLs:
+
+```bash
+# Upload with signed URLs required
+curl -X POST \
+ https://api.cloudflare.com/client/v4/accounts/{account_id}/images/v1 \
+ -H "Authorization: Bearer {api_token}" \
+ -F file=@private.jpg \
+ -F requireSignedURLs=true
+```
+
+Generate signed URL:
+
+```typescript
+import { createHmac } from 'crypto';
+
+function signUrl(imageId: string, variant: string, expiry: number, key: string): string {
+ const path = `/${imageId}/${variant}`;
+ const toSign = `${path}${expiry}`;
+ const signature = createHmac('sha256', key)
+ .update(toSign)
+ .digest('hex');
+
+ return `https://imagedelivery.net/{hash}${path}?exp=${expiry}&sig=${signature}`;
+}
+
+// Sign URL valid for 1 hour
+const signedUrl = signUrl('image-id', 'public', Date.now() + 3600, env.SIGNING_KEY);
+```
+
+## Local Development
+
+```bash
+npx wrangler dev --remote
+```
+
+Must use `--remote` for Images binding access.
diff --git a/.agents/skills/cloudflare-deploy/references/images/gotchas.md b/.agents/skills/cloudflare-deploy/references/images/gotchas.md
new file mode 100644
index 0000000..6f52455
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/images/gotchas.md
@@ -0,0 +1,99 @@
+# Gotchas & Best Practices
+
+## Fit Modes
+
+| Mode | Best For | Behavior |
+|------|----------|----------|
+| `cover` | Hero images, thumbnails | Fills space, crops excess |
+| `contain` | Product images, artwork | Preserves full image, may add padding |
+| `scale-down` | User uploads | Never enlarges |
+| `crop` | Precise crops | Uses gravity |
+| `pad` | Fixed aspect ratio | Adds background |
+
+## Format Selection
+
+```typescript
+format: 'auto' // Recommended - negotiates best format
+```
+
+**Support:** AVIF (Chrome 85+, Firefox 93+, Safari 16.4+), WebP (Chrome 23+, Firefox 65+, Safari 14+)
+
+## Quality Settings
+
+| Use Case | Quality |
+|----------|---------|
+| Thumbnails | 75-80 |
+| Standard | 85 (default) |
+| High-quality | 90-95 |
+
+## Common Errors
+
+### 5403: "Image transformation failed"
+- Verify `width`/`height` ≤ 12000
+- Check `quality` 1-100, `dpr` 1-3
+- Don't combine incompatible options
+
+### 9413: "Rate limit exceeded"
+Implement caching and exponential backoff:
+```typescript
+for (let i = 0; i < 3; i++) {
+ try { return await env.IMAGES.input(buffer).transform({...}).output(); }
+ catch { await new Promise(r => setTimeout(r, 2 ** i * 1000)); }
+}
+```
+
+### 5401: "Image too large"
+Pre-process images before upload (max 100MB, 12000×12000px)
+
+### 5400: "Invalid image format"
+Supported: JPEG, PNG, GIF, WebP, AVIF, SVG
+
+### 401/403: "Unauthorized"
+Verify API token has `Cloudflare Images → Edit` permission
+
+## Limits
+
+| Resource | Limit |
+|----------|-------|
+| Max input size | 100MB |
+| Max dimensions | 12000×12000px |
+| Quality range | 1-100 |
+| DPR range | 1-3 |
+| API rate limit | ~1200 req/min |
+
+## AVIF Gotchas
+
+- **Slower encoding**: First request may have higher latency
+- **Browser detection**:
+```typescript
+const format = /image\/avif/.test(request.headers.get('Accept') || '') ? 'avif' : 'webp';
+```
+
+## Anti-Patterns
+
+```typescript
+// ❌ No caching - transforms every request
+return env.IMAGES.input(buffer).transform({...}).output().response();
+
+// ❌ cover without both dimensions
+transform({ width: 800, fit: 'cover' })
+
+// ✅ Always set both for cover
+transform({ width: 800, height: 600, fit: 'cover' })
+
+// ❌ Exposes API token to client
+// ✅ Use Direct Creator Upload (patterns.md)
+```
+
+## Debugging
+
+```typescript
+// Check response headers
+console.log('Content-Type:', response.headers.get('Content-Type'));
+
+// Test with curl
+// curl -I "https://imagedelivery.net/{hash}/{id}/width=800,format=avif"
+
+// Monitor logs
+// npx wrangler tail
+```
diff --git a/.agents/skills/cloudflare-deploy/references/images/patterns.md b/.agents/skills/cloudflare-deploy/references/images/patterns.md
new file mode 100644
index 0000000..c07bf3c
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/images/patterns.md
@@ -0,0 +1,115 @@
+# Common Patterns
+
+## URL Transform Options
+
+```
+width= height= fit=scale-down|contain|cover|crop|pad
+quality=85 format=auto|webp|avif|jpeg|png dpr=2
+gravity=auto|face|left|right|top|bottom sharpen=2 blur=10
+rotate=90|180|270 background=white metadata=none|copyright|keep
+```
+
+## Responsive Images (srcset)
+
+```html
+
+```
+
+## Format Negotiation
+
+```typescript
+async fetch(request: Request, env: Env): Promise {
+ const accept = request.headers.get('Accept') || '';
+ const format = /image\/avif/.test(accept) ? 'avif' : /image\/webp/.test(accept) ? 'webp' : 'jpeg';
+ return env.IMAGES.input(buffer).transform({ format, quality: 85 }).output().response();
+}
+```
+
+## Direct Creator Upload
+
+```typescript
+// Backend: Generate upload URL
+const response = await fetch(
+ `https://api.cloudflare.com/client/v4/accounts/${env.ACCOUNT_ID}/images/v2/direct_upload`,
+ { method: 'POST', headers: { 'Authorization': `Bearer ${env.API_TOKEN}` },
+ body: JSON.stringify({ requireSignedURLs: false, metadata: { userId } }) }
+);
+
+// Frontend: Upload to returned uploadURL
+const formData = new FormData();
+formData.append('file', file);
+await fetch(result.uploadURL, { method: 'POST', body: formData });
+// Use: https://imagedelivery.net/{hash}/${result.id}/public
+```
+
+## Transform & Store to R2
+
+```typescript
+async fetch(request: Request, env: Env): Promise {
+ const file = (await request.formData()).get('image') as File;
+ const transformed = await env.IMAGES
+ .input(await file.arrayBuffer())
+ .transform({ width: 800, format: 'avif', quality: 80 })
+ .output();
+ await env.R2.put(`images/${Date.now()}.avif`, transformed.response().body);
+ return Response.json({ success: true });
+}
+```
+
+## Watermarking
+
+```typescript
+const watermark = await env.ASSETS.fetch(new URL('/watermark.png', request.url));
+const result = await env.IMAGES
+ .input(await image.arrayBuffer())
+ .draw(env.IMAGES.input(watermark.body).transform({ width: 100 }), { bottom: 20, right: 20, opacity: 0.7 })
+ .transform({ format: 'avif' })
+ .output();
+return result.response();
+```
+
+## Device-Based Transforms
+
+```typescript
+const ua = request.headers.get('User-Agent') || '';
+const isMobile = /Mobile|Android|iPhone/i.test(ua);
+return env.IMAGES.input(buffer)
+ .transform({ width: isMobile ? 400 : 1200, quality: isMobile ? 75 : 85, format: 'avif' })
+ .output().response();
+```
+
+## Caching Strategy
+
+```typescript
+async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise {
+ const cache = caches.default;
+ let response = await cache.match(request);
+ if (!response) {
+ response = await env.IMAGES.input(buffer).transform({ width: 800, format: 'avif' }).output().response();
+ response = new Response(response.body, { headers: { ...response.headers, 'Cache-Control': 'public, max-age=86400' } });
+ ctx.waitUntil(cache.put(request, response.clone()));
+ }
+ return response;
+}
+```
+
+## Batch Processing
+
+```typescript
+const results = await Promise.all(images.map(buffer =>
+ env.IMAGES.input(buffer).transform({ width: 800, fit: 'cover', format: 'avif' }).output()
+));
+```
+
+## Error Handling
+
+```typescript
+try {
+ return (await env.IMAGES.input(buffer).transform({ width: 800 }).output()).response();
+} catch (error) {
+ console.error('Transform failed:', error);
+ return new Response('Image processing failed', { status: 500 });
+}
+```
diff --git a/.agents/skills/cloudflare-deploy/references/kv/README.md b/.agents/skills/cloudflare-deploy/references/kv/README.md
new file mode 100644
index 0000000..9e43e01
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/kv/README.md
@@ -0,0 +1,89 @@
+# Cloudflare Workers KV
+
+Globally-distributed, eventually-consistent key-value store optimized for high read volume and low latency.
+
+## Overview
+
+KV provides:
+- Eventual consistency (60s global propagation)
+- Read-optimized performance
+- 25 MiB value limit per key
+- Auto-replication to Cloudflare edge
+- Metadata support (1024 bytes)
+
+**Use cases:** Config storage, user sessions, feature flags, caching, A/B testing
+
+## When to Use KV
+
+| Need | Recommendation |
+|------|----------------|
+| Strong consistency | → [Durable Objects](../durable-objects/) |
+| SQL queries | → [D1](../d1/) |
+| Object storage (files) | → [R2](../r2/) |
+| High read, low write volume | → KV ✅ |
+| Sub-10ms global reads | → KV ✅ |
+
+**Quick comparison:**
+
+| Feature | KV | D1 | Durable Objects |
+|---------|----|----|-----------------|
+| Consistency | Eventual | Strong | Strong |
+| Read latency | <10ms | ~50ms | <1ms |
+| Write limit | 1/s per key | Unlimited | Unlimited |
+| Use case | Config, cache | Relational data | Coordination |
+
+## Quick Start
+
+```bash
+wrangler kv namespace create MY_NAMESPACE
+# Add binding to wrangler.jsonc
+```
+
+```typescript
+// Write
+await env.MY_KV.put("key", "value", { expirationTtl: 300 });
+
+// Read
+const value = await env.MY_KV.get("key");
+const json = await env.MY_KV.get("config", "json");
+```
+
+## Core Operations
+
+| Method | Purpose | Returns |
+|--------|---------|---------|
+| `get(key, type?)` | Single read | `string \| null` |
+| `get(keys, type?)` | Bulk read (≤100) | `Map` |
+| `put(key, value, options?)` | Write | `Promise` |
+| `delete(key)` | Delete | `Promise` |
+| `list(options?)` | List keys | `{ keys, list_complete, cursor? }` |
+| `getWithMetadata(key)` | Get + metadata | `{ value, metadata }` |
+
+## Consistency Model
+
+- **Write visibility:** Immediate in same location, ≤60s globally
+- **Read path:** Eventually consistent
+- **Write rate:** 1 write/second per key (429 on exceed)
+
+## Reading Order
+
+| Task | Files to Read |
+|------|---------------|
+| Quick start | README → configuration.md |
+| Implement feature | README → api.md → patterns.md |
+| Debug issues | gotchas.md → api.md |
+| Batch operations | api.md (bulk section) → patterns.md |
+| Performance tuning | gotchas.md (performance) → patterns.md (caching) |
+
+## In This Reference
+
+- [configuration.md](./configuration.md) - wrangler.jsonc setup, namespace creation, TypeScript types
+- [api.md](./api.md) - KV methods, bulk operations, cacheTtl, content types
+- [patterns.md](./patterns.md) - Caching, sessions, rate limiting, A/B testing
+- [gotchas.md](./gotchas.md) - Eventual consistency, concurrent writes, value limits
+
+## See Also
+
+- [workers](../workers/) - Worker runtime for KV access
+- [d1](../d1/) - Use D1 for strong consistency needs
+- [durable-objects](../durable-objects/) - Strongly consistent alternative
diff --git a/.agents/skills/cloudflare-deploy/references/kv/api.md b/.agents/skills/cloudflare-deploy/references/kv/api.md
new file mode 100644
index 0000000..35063f2
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/kv/api.md
@@ -0,0 +1,160 @@
+# KV API Reference
+
+## Read Operations
+
+```typescript
+// Single key (string)
+const value = await env.MY_KV.get("user:123");
+
+// JSON type (auto-parsed)
+const config = await env.MY_KV.get("config", "json");
+
+// ArrayBuffer for binary
+const buffer = await env.MY_KV.get("image", "arrayBuffer");
+
+// Stream for large values
+const stream = await env.MY_KV.get("large-file", "stream");
+
+// With cache TTL (min 60s)
+const value = await env.MY_KV.get("key", { type: "text", cacheTtl: 300 });
+
+// Bulk get (max 100 keys, counts as 1 operation)
+const keys = ["user:1", "user:2", "user:3", "missing:key"];
+const results = await env.MY_KV.get(keys);
+// Returns Map
+
+console.log(results.get("user:1")); // "John" (if exists)
+console.log(results.get("missing:key")); // null
+
+// Process results with null handling
+for (const [key, value] of results) {
+ if (value !== null) {
+ // Handle found keys
+ console.log(`${key}: ${value}`);
+ }
+}
+
+// TypeScript with generics (type-safe JSON parsing)
+interface UserProfile { name: string; email: string; }
+const profile = await env.USERS.get("user:123", "json");
+// profile is typed as UserProfile | null
+if (profile) {
+ console.log(profile.name); // Type-safe access
+}
+
+// Bulk get with type
+const configs = await env.MY_KV.get(["config:app", "config:feature"], "json");
+// Map
+```
+
+## Write Operations
+
+```typescript
+// Basic put
+await env.MY_KV.put("key", "value");
+await env.MY_KV.put("config", JSON.stringify({ theme: "dark" }));
+
+// With expiration (UNIX timestamp)
+await env.MY_KV.put("session", token, {
+ expiration: Math.floor(Date.now() / 1000) + 3600
+});
+
+// With TTL (seconds from now, min 60)
+await env.MY_KV.put("cache", data, { expirationTtl: 300 });
+
+// With metadata (max 1024 bytes)
+await env.MY_KV.put("user:profile", userData, {
+ metadata: { version: 2, lastUpdated: Date.now() }
+});
+
+// Combined
+await env.MY_KV.put("temp", value, {
+ expirationTtl: 3600,
+ metadata: { temporary: true }
+});
+```
+
+## Get with Metadata
+
+```typescript
+// Single key
+const result = await env.MY_KV.getWithMetadata("user:profile");
+// { value: string | null, metadata: any | null }
+
+if (result.value && result.metadata) {
+ const { version, lastUpdated } = result.metadata;
+}
+
+// Multiple keys (bulk)
+const keys = ["key1", "key2", "key3"];
+const results = await env.MY_KV.getWithMetadata(keys);
+// Returns Map
+
+for (const [key, result] of results) {
+ if (result.value) {
+ console.log(`${key}: ${result.value}`);
+ console.log(`Metadata: ${JSON.stringify(result.metadata)}`);
+ // cacheStatus field indicates cache hit/miss (when available)
+ }
+}
+
+// With type
+const result = await env.MY_KV.getWithMetadata("user:123", "json");
+// result: { value: UserData | null, metadata: any | null, cacheStatus?: string }
+```
+
+## Delete Operations
+
+```typescript
+await env.MY_KV.delete("key"); // Always succeeds (even if key missing)
+```
+
+## List Operations
+
+```typescript
+// List all
+const keys = await env.MY_KV.list();
+// { keys: [...], list_complete: boolean, cursor?: string }
+
+// With prefix
+const userKeys = await env.MY_KV.list({ prefix: "user:" });
+
+// Pagination
+let cursor: string | undefined;
+let allKeys = [];
+do {
+ const result = await env.MY_KV.list({ cursor, limit: 1000 });
+ allKeys.push(...result.keys);
+ cursor = result.cursor;
+} while (!result.list_complete);
+```
+
+## Performance Considerations
+
+### Type Selection
+
+| Type | Use Case | Performance |
+|------|----------|-------------|
+| `stream` | Large values (>1MB) | Fastest - no buffering |
+| `arrayBuffer` | Binary data | Fast - single allocation |
+| `text` | String values | Medium |
+| `json` | Objects (parse overhead) | Slowest - parsing cost |
+
+### Parallel Reads
+
+```typescript
+// Efficient parallel reads with Promise.all()
+const [user, settings, cache] = await Promise.all([
+ env.USERS.get("user:123", "json"),
+ env.SETTINGS.get("config:app", "json"),
+ env.CACHE.get("data:latest")
+]);
+```
+
+## Error Handling
+
+- **Missing keys:** Return `null` (not an error)
+- **Rate limit (429):** Retry with exponential backoff (see gotchas.md)
+- **Response too large (413):** Values >25MB fail with 413 error
+
+See [gotchas.md](./gotchas.md) for detailed error patterns and solutions.
diff --git a/.agents/skills/cloudflare-deploy/references/kv/configuration.md b/.agents/skills/cloudflare-deploy/references/kv/configuration.md
new file mode 100644
index 0000000..0aefa5f
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/kv/configuration.md
@@ -0,0 +1,144 @@
+# KV Configuration
+
+## Create Namespace
+
+```bash
+wrangler kv namespace create MY_NAMESPACE
+# Output: { binding = "MY_NAMESPACE", id = "abc123..." }
+
+wrangler kv namespace create MY_NAMESPACE --preview # For local dev
+```
+
+## Workers Binding
+
+**wrangler.jsonc:**
+```jsonc
+{
+ "kv_namespaces": [
+ {
+ "binding": "MY_KV",
+ "id": "abc123xyz789"
+ },
+ // Optional: Different namespace for preview/development
+ {
+ "binding": "MY_KV",
+ "preview_id": "preview-abc123"
+ }
+ ]
+}
+```
+
+## TypeScript Types
+
+**env.d.ts:**
+```typescript
+interface Env {
+ MY_KV: KVNamespace;
+ SESSIONS: KVNamespace;
+ CACHE: KVNamespace;
+}
+```
+
+**worker.ts:**
+```typescript
+export default {
+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise {
+ // env.MY_KV is now typed as KVNamespace
+ const value = await env.MY_KV.get("key");
+ return new Response(value || "Not found");
+ }
+} satisfies ExportedHandler;
+```
+
+**Type-safe JSON operations:**
+```typescript
+interface UserProfile {
+ name: string;
+ email: string;
+ role: "admin" | "user";
+}
+
+const profile = await env.USERS.get("user:123", "json");
+// profile: UserProfile | null (type-safe!)
+if (profile) {
+ console.log(profile.name); // TypeScript knows this is a string
+}
+```
+
+## CLI Operations
+
+```bash
+# Put
+wrangler kv key put --binding=MY_KV "key" "value"
+wrangler kv key put --binding=MY_KV "key" --path=./file.json --ttl=3600
+
+# Get
+wrangler kv key get --binding=MY_KV "key"
+
+# Delete
+wrangler kv key delete --binding=MY_KV "key"
+
+# List
+wrangler kv key list --binding=MY_KV --prefix="user:"
+
+# Bulk operations (max 10,000 keys per file)
+wrangler kv bulk put data.json --binding=MY_KV
+wrangler kv bulk get keys.json --binding=MY_KV
+wrangler kv bulk delete keys.json --binding=MY_KV --force
+```
+
+## Local Development
+
+```bash
+wrangler dev # Local KV (isolated)
+wrangler dev --remote # Remote KV (production)
+
+# Or in wrangler.jsonc:
+# "kv_namespaces": [{ "binding": "MY_KV", "id": "...", "remote": true }]
+```
+
+## REST API
+
+### Single Operations
+
+```typescript
+import Cloudflare from 'cloudflare';
+
+const client = new Cloudflare({
+ apiEmail: process.env.CLOUDFLARE_EMAIL,
+ apiKey: process.env.CLOUDFLARE_API_KEY
+});
+
+// Single key operations
+await client.kv.namespaces.values.update(namespaceId, 'key', {
+ account_id: accountId,
+ value: 'value',
+ expiration_ttl: 3600
+});
+```
+
+### Bulk Operations
+
+```typescript
+// Bulk update (up to 10,000 keys, max 100MB total)
+await client.kv.namespaces.bulkUpdate(namespaceId, {
+ account_id: accountId,
+ body: [
+ { key: "key1", value: "value1", expiration_ttl: 3600 },
+ { key: "key2", value: "value2", metadata: { version: 1 } },
+ { key: "key3", value: "value3" }
+ ]
+});
+
+// Bulk get (up to 100 keys)
+const results = await client.kv.namespaces.bulkGet(namespaceId, {
+ account_id: accountId,
+ keys: ["key1", "key2", "key3"]
+});
+
+// Bulk delete (up to 10,000 keys)
+await client.kv.namespaces.bulkDelete(namespaceId, {
+ account_id: accountId,
+ keys: ["key1", "key2", "key3"]
+});
+```
diff --git a/.agents/skills/cloudflare-deploy/references/kv/gotchas.md b/.agents/skills/cloudflare-deploy/references/kv/gotchas.md
new file mode 100644
index 0000000..5ad3213
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/kv/gotchas.md
@@ -0,0 +1,131 @@
+# KV Gotchas & Troubleshooting
+
+## Common Errors
+
+### "Stale Read After Write"
+
+**Cause:** Eventual consistency means writes may not be immediately visible in other regions
+**Solution:** Don't read immediately after write; return confirmation without reading or use the local value you just wrote. Writes visible immediately in same location, ≤60s globally
+
+```typescript
+// ❌ BAD: Read immediately after write
+await env.KV.put("key", "value");
+const value = await env.KV.get("key"); // May be null in other regions!
+
+// ✅ GOOD: Use the value you just wrote
+const newValue = "value";
+await env.KV.put("key", newValue);
+return new Response(newValue); // Don't re-read
+```
+
+### "429 Rate Limit on Concurrent Writes"
+
+**Cause:** Multiple concurrent writes to same key exceeding 1 write/second limit
+**Solution:** Use sequential writes, unique keys for concurrent operations, or implement retry with exponential backoff
+
+```typescript
+async function putWithRetry(
+ kv: KVNamespace,
+ key: string,
+ value: string,
+ maxAttempts = 5
+): Promise {
+ let delay = 1000;
+ for (let i = 0; i < maxAttempts; i++) {
+ try {
+ await kv.put(key, value);
+ return;
+ } catch (err) {
+ if (err instanceof Error && err.message.includes("429")) {
+ if (i === maxAttempts - 1) throw err;
+ await new Promise(r => setTimeout(r, delay));
+ delay *= 2; // Exponential backoff
+ } else {
+ throw err;
+ }
+ }
+ }
+}
+```
+
+### "Inefficient Multiple Gets"
+
+**Cause:** Making multiple individual get() calls instead of bulk operation
+**Solution:** Use bulk get with array of keys: `env.USERS.get(["user:1", "user:2", "user:3"])` to reduce to 1 operation
+
+### "Null Reference Error"
+
+**Cause:** Attempting to use value without checking for null when key doesn't exist
+**Solution:** Always handle null returns - KV returns `null` for missing keys, not undefined
+
+```typescript
+// ❌ BAD: Assumes value exists
+const config = await env.KV.get("config", "json");
+return config.theme; // TypeError if null!
+
+// ✅ GOOD: Null checks
+const config = await env.KV.get("config", "json");
+return config?.theme ?? "default";
+
+// ✅ GOOD: Early return
+const config = await env.KV.get("config", "json");
+if (!config) return new Response("Not found", { status: 404 });
+return new Response(config.theme);
+```
+
+### "Negative Lookup Caching"
+
+**Cause:** Keys that don't exist are cached as "not found" for up to 60s
+**Solution:** Creating a key after checking won't be visible until cache expires
+
+```typescript
+// Check → create pattern has race condition
+const exists = await env.KV.get("key"); // null, cached as "not found"
+if (!exists) {
+ await env.KV.put("key", "value");
+ // Next get() may still return null for ~60s due to negative cache
+}
+
+// Alternative: Always assume key may not exist, use defaults
+const value = await env.KV.get("key") ?? "default-value";
+```
+
+## Performance Tips
+
+| Scenario | Recommendation | Why |
+|----------|----------------|-----|
+| Large values (>1MB) | Use `stream` type | Avoids buffering entire value in memory |
+| Many small keys | Coalesce into one JSON object | Reduces operations, improves cache hit rate |
+| High write volume | Spread across different keys | Avoid 1 write/second per-key limit |
+| Cold reads | Increase `cacheTtl` parameter | Reduces latency for frequently-read data |
+| Bulk operations | Use array form of get() | Single operation, better performance |
+
+## Cost Examples
+
+**Free tier:**
+- 100K reads/day = 3M/month ✅
+- 1K writes/day = 30K/month ✅
+- 1GB storage ✅
+
+**Example paid workload:**
+- 10M reads/month = $5.00
+- 100K writes/month = $0.50
+- 1GB storage = $0.50
+- **Total: ~$6/month**
+
+## Limits
+
+| Limit | Value | Notes |
+|-------|-------|-------|
+| Key size | 512 bytes | Maximum key length |
+| Value size | 25 MiB | Maximum value; 413 error if exceeded |
+| Metadata size | 1024 bytes | Maximum metadata per key |
+| cacheTtl minimum | 60s | Minimum cache TTL |
+| Write rate per key | 1 write/second | All plans; 429 error if exceeded |
+| Propagation time | ≤60s | Global propagation time |
+| Bulk get max | 100 keys | Maximum keys per bulk operation |
+| Operations per Worker | 1,000 | Per request (bulk counts as 1) |
+| Reads pricing | $0.50 per 10M | Per million reads |
+| Writes pricing | $5.00 per 1M | Per million writes |
+| Deletes pricing | $5.00 per 1M | Per million deletes |
+| Storage pricing | $0.50 per GB-month | Per GB per month |
diff --git a/.agents/skills/cloudflare-deploy/references/kv/patterns.md b/.agents/skills/cloudflare-deploy/references/kv/patterns.md
new file mode 100644
index 0000000..8386074
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/kv/patterns.md
@@ -0,0 +1,196 @@
+# KV Patterns & Best Practices
+
+## Multi-Tier Caching
+
+```typescript
+// Memory → KV → Origin (3-tier cache)
+const memoryCache = new Map();
+
+async function getCached(env: Env, key: string): Promise {
+ const now = Date.now();
+
+ // L1: Memory cache (fastest)
+ const cached = memoryCache.get(key);
+ if (cached && cached.expires > now) {
+ return cached.data;
+ }
+
+ // L2: KV cache (fast)
+ const kvValue = await env.CACHE.get(key, "json");
+ if (kvValue) {
+ memoryCache.set(key, { data: kvValue, expires: now + 60000 }); // 1min in memory
+ return kvValue;
+ }
+
+ // L3: Origin (slow)
+ const origin = await fetch(`https://api.example.com/${key}`).then(r => r.json());
+
+ // Backfill caches
+ await env.CACHE.put(key, JSON.stringify(origin), { expirationTtl: 300 }); // 5min in KV
+ memoryCache.set(key, { data: origin, expires: now + 60000 });
+
+ return origin;
+}
+```
+
+## API Response Caching
+
+```typescript
+async function getCachedData(env: Env, key: string, fetcher: () => Promise): Promise {
+ const cached = await env.MY_KV.get(key, "json");
+ if (cached) return cached;
+
+ const data = await fetcher();
+ await env.MY_KV.put(key, JSON.stringify(data), { expirationTtl: 300 });
+ return data;
+}
+
+const apiData = await getCachedData(
+ env,
+ "cache:users",
+ () => fetch("https://api.example.com/users").then(r => r.json())
+);
+```
+
+## Session Management
+
+```typescript
+interface Session { userId: string; expiresAt: number; }
+
+async function createSession(env: Env, userId: string): Promise {
+ const sessionId = crypto.randomUUID();
+ const expiresAt = Date.now() + (24 * 60 * 60 * 1000);
+
+ await env.SESSIONS.put(
+ `session:${sessionId}`,
+ JSON.stringify({ userId, expiresAt }),
+ { expirationTtl: 86400, metadata: { createdAt: Date.now() } }
+ );
+
+ return sessionId;
+}
+
+async function getSession(env: Env, sessionId: string): Promise {
+ const data = await env.SESSIONS.get(`session:${sessionId}`, "json");
+ if (!data || data.expiresAt < Date.now()) return null;
+ return data;
+}
+```
+
+## Coalesce Cold Keys
+
+```typescript
+// ❌ BAD: Many individual keys
+await env.KV.put("user:123:name", "John");
+await env.KV.put("user:123:email", "john@example.com");
+
+// ✅ GOOD: Single coalesced object
+await env.USERS.put("user:123:profile", JSON.stringify({
+ name: "John",
+ email: "john@example.com",
+ role: "admin"
+}));
+
+// Benefits: Hot key cache, single read, reduced operations
+// Trade-off: Harder to update individual fields
+```
+
+## Prefix-Based Namespacing
+
+```typescript
+// Logical partitioning within single namespace
+const PREFIXES = {
+ users: "user:",
+ sessions: "session:",
+ cache: "cache:",
+ features: "feature:"
+} as const;
+
+// Write with prefix
+async function setUser(env: Env, id: string, data: any) {
+ await env.KV.put(`${PREFIXES.users}${id}`, JSON.stringify(data));
+}
+
+// Read with prefix
+async function getUser(env: Env, id: string) {
+ return await env.KV.get(`${PREFIXES.users}${id}`, "json");
+}
+
+// List by prefix
+async function listUserIds(env: Env): Promise {
+ const result = await env.KV.list({ prefix: PREFIXES.users });
+ return result.keys.map(k => k.name.replace(PREFIXES.users, ""));
+}
+
+// Example hierarchy
+"user:123:profile"
+"user:123:settings"
+"cache:api:users"
+"session:abc-def"
+"feature:flags:beta"
+```
+
+## Metadata Versioning
+
+```typescript
+interface VersionedData {
+ version: number;
+ data: any;
+}
+
+async function migrateIfNeeded(env: Env, key: string) {
+ const result = await env.DATA.getWithMetadata(key, "json");
+
+ if (!result.value) return null;
+
+ const currentVersion = result.metadata?.version || 1;
+ const targetVersion = 2;
+
+ if (currentVersion < targetVersion) {
+ // Migrate data format
+ const migrated = migrate(result.value, currentVersion, targetVersion);
+
+ // Store with new version
+ await env.DATA.put(key, JSON.stringify(migrated), {
+ metadata: { version: targetVersion, migratedAt: Date.now() }
+ });
+
+ return migrated;
+ }
+
+ return result.value;
+}
+
+function migrate(data: any, from: number, to: number): any {
+ if (from === 1 && to === 2) {
+ // V1 → V2: Rename field
+ return { ...data, userName: data.name };
+ }
+ return data;
+}
+```
+
+## Error Boundary Pattern
+
+```typescript
+// Resilient get with fallback
+async function resilientGet(
+ env: Env,
+ key: string,
+ fallback: T
+): Promise {
+ try {
+ const value = await env.KV.get(key, "json");
+ return value ?? fallback;
+ } catch (err) {
+ console.error(`KV error for ${key}:`, err);
+ return fallback;
+ }
+}
+
+// Usage
+const config = await resilientGet(env, "config:app", {
+ theme: "light",
+ maxItems: 10
+});
+```
diff --git a/.agents/skills/cloudflare-deploy/references/miniflare/README.md b/.agents/skills/cloudflare-deploy/references/miniflare/README.md
new file mode 100644
index 0000000..82baf7c
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/miniflare/README.md
@@ -0,0 +1,105 @@
+# Miniflare
+
+Local simulator for Cloudflare Workers development/testing. Runs Workers in workerd sandbox implementing runtime APIs - no internet required.
+
+## Features
+
+- Full-featured: KV, Durable Objects, R2, D1, WebSockets, Queues
+- Fully-local: test without internet, instant reload
+- TypeScript-native: detailed logging, source maps
+- Advanced testing: dispatch events without HTTP, simulate Worker connections
+
+## When to Use
+
+**Decision tree for testing Workers:**
+
+```
+Need to test Workers?
+│
+├─ Unit tests for business logic only?
+│ └─ getPlatformProxy (Vitest/Jest) → [patterns.md](./patterns.md#getplatformproxy)
+│ Fast, no HTTP, direct binding access
+│
+├─ Integration tests with full runtime?
+│ ├─ Single Worker?
+│ │ └─ Miniflare API → [Quick Start](#quick-start)
+│ │ Full control, programmatic access
+│ │
+│ ├─ Multiple Workers + service bindings?
+│ │ └─ Miniflare workers array → [configuration.md](./configuration.md#multiple-workers)
+│ │ Shared storage, inter-worker calls
+│ │
+│ └─ Vitest test runner integration?
+│ └─ vitest-pool-workers → [patterns.md](./patterns.md#vitest-pool-workers)
+│ Full Workers env in Vitest
+│
+└─ Local dev server?
+ └─ wrangler dev (not Miniflare)
+ Hot reload, automatic config
+```
+
+**Use Miniflare for:**
+- Integration tests with full Worker runtime
+- Testing bindings/storage locally
+- Multiple Workers with service bindings
+- Programmatic event dispatch (fetch, queue, scheduled)
+
+**Use getPlatformProxy for:**
+- Fast unit tests of business logic
+- Testing without HTTP overhead
+- Vitest/Jest environments
+
+**Use Wrangler for:**
+- Local development workflow
+- Production deployments
+
+## Setup
+
+```bash
+npm i -D miniflare
+```
+
+Requires ES modules in `package.json`:
+```json
+{"type": "module"}
+```
+
+## Quick Start
+
+```js
+import { Miniflare } from "miniflare";
+
+const mf = new Miniflare({
+ modules: true,
+ script: `
+ export default {
+ async fetch(request, env, ctx) {
+ return new Response("Hello Miniflare!");
+ }
+ }
+ `,
+});
+
+const res = await mf.dispatchFetch("http://localhost:8787/");
+console.log(await res.text()); // Hello Miniflare!
+await mf.dispose();
+```
+
+## Reading Order
+
+**New to Miniflare?** Start here:
+1. [Quick Start](#quick-start) - Running in 2 minutes
+2. [When to Use](#when-to-use) - Choose your testing approach
+3. [patterns.md](./patterns.md) - Testing patterns (getPlatformProxy, Vitest, node:test)
+4. [configuration.md](./configuration.md) - Configure bindings, storage, multiple workers
+
+**Troubleshooting:**
+- [gotchas.md](./gotchas.md) - Common errors and debugging
+
+**API reference:**
+- [api.md](./api.md) - Complete method reference
+
+## See Also
+- [wrangler](../wrangler/) - CLI tool that embeds Miniflare for `wrangler dev`
+- [workerd](../workerd/) - Runtime that powers Miniflare
+- [workers](../workers/) - Workers runtime API documentation
diff --git a/.agents/skills/cloudflare-deploy/references/miniflare/api.md b/.agents/skills/cloudflare-deploy/references/miniflare/api.md
new file mode 100644
index 0000000..e4df4d7
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/miniflare/api.md
@@ -0,0 +1,187 @@
+# Programmatic API
+
+## Miniflare Class
+
+```typescript
+class Miniflare {
+ constructor(options: MiniflareOptions);
+
+ // Lifecycle
+ ready: Promise; // Resolves when server ready, returns URL
+ dispose(): Promise; // Cleanup resources
+ setOptions(options: MiniflareOptions): Promise; // Reload config
+
+ // Event dispatching
+ dispatchFetch(url: string | URL | Request, init?: RequestInit): Promise;
+ getWorker(name?: string): Promise;
+
+ // Bindings access
+ getBindings>(name?: string): Promise;
+ getCf(name?: string): Promise;
+ getKVNamespace(name: string): Promise;
+ getR2Bucket(name: string): Promise;
+ getDurableObjectNamespace(name: string): Promise;
+ getDurableObjectStorage(id: DurableObjectId): Promise;
+ getD1Database(name: string): Promise;
+ getCaches(): Promise;
+ getQueueProducer(name: string): Promise;
+
+ // Debugging
+ getInspectorURL(): Promise; // Chrome DevTools inspector URL
+}
+```
+
+## Event Dispatching
+
+**Fetch (no HTTP server):**
+```js
+const res = await mf.dispatchFetch("http://localhost:8787/path", {
+ method: "POST",
+ headers: { "Authorization": "Bearer token" },
+ body: JSON.stringify({ data: "value" }),
+});
+```
+
+**Custom Host routing:**
+```js
+const res = await mf.dispatchFetch("http://localhost:8787/", {
+ headers: { "Host": "api.example.com" },
+});
+```
+
+**Scheduled:**
+```js
+const worker = await mf.getWorker();
+const result = await worker.scheduled({ cron: "30 * * * *" });
+// result: { outcome: "ok", noRetry: false }
+```
+
+**Queue:**
+```js
+const worker = await mf.getWorker();
+const result = await worker.queue("queue-name", [
+ { id: "msg1", timestamp: new Date(), body: "data", attempts: 1 },
+]);
+// result: { outcome: "ok", retryAll: false, ackAll: false, ... }
+```
+
+## Bindings Access
+
+**Environment variables:**
+```js
+// Basic usage
+const bindings = await mf.getBindings();
+console.log(bindings.SECRET_KEY);
+
+// With type safety (recommended):
+interface Env {
+ SECRET_KEY: string;
+ API_URL: string;
+ KV: KVNamespace;
+}
+const env = await mf.getBindings();
+env.SECRET_KEY; // string (typed!)
+env.KV.get("key"); // KVNamespace methods available
+```
+
+**Request.cf object:**
+```js
+const cf = await mf.getCf();
+console.log(cf?.colo); // "DFW"
+console.log(cf?.country); // "US"
+```
+
+**KV:**
+```js
+const ns = await mf.getKVNamespace("TEST_NAMESPACE");
+await ns.put("key", "value");
+const value = await ns.get("key");
+```
+
+**R2:**
+```js
+const bucket = await mf.getR2Bucket("BUCKET");
+await bucket.put("file.txt", "content");
+const object = await bucket.get("file.txt");
+```
+
+**Durable Objects:**
+```js
+const ns = await mf.getDurableObjectNamespace("COUNTER");
+const id = ns.idFromName("test");
+const stub = ns.get(id);
+const res = await stub.fetch("http://localhost/");
+
+// Access storage directly:
+const storage = await mf.getDurableObjectStorage(id);
+await storage.put("key", "value");
+```
+
+**D1:**
+```js
+const db = await mf.getD1Database("DB");
+await db.exec(`CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)`);
+await db.prepare("INSERT INTO users (name) VALUES (?)").bind("Alice").run();
+```
+
+**Cache:**
+```js
+const caches = await mf.getCaches();
+const defaultCache = caches.default;
+await defaultCache.put("http://example.com", new Response("cached"));
+```
+
+**Queue producer:**
+```js
+const producer = await mf.getQueueProducer("QUEUE");
+await producer.send({ body: "message data" });
+```
+
+## Lifecycle
+
+**Reload:**
+```js
+await mf.setOptions({
+ scriptPath: "worker.js",
+ bindings: { VERSION: "2.0" },
+});
+```
+
+**Watch (manual):**
+```js
+import { watch } from "fs";
+
+const config = { scriptPath: "worker.js" };
+const mf = new Miniflare(config);
+
+watch("worker.js", async () => {
+ console.log("Reloading...");
+ await mf.setOptions(config);
+});
+```
+
+**Cleanup:**
+```js
+await mf.dispose();
+```
+
+## Debugging
+
+**Inspector URL for DevTools:**
+```js
+const url = await mf.getInspectorURL();
+console.log(`DevTools: ${url}`);
+// Open in Chrome DevTools for breakpoints, profiling
+```
+
+**Wait for server ready:**
+```js
+const mf = new Miniflare({ scriptPath: "worker.js" });
+const url = await mf.ready; // Promise
+console.log(`Server running at ${url}`); // http://127.0.0.1:8787
+
+// Note: dispatchFetch() waits automatically, no need to await ready
+const res = await mf.dispatchFetch("http://localhost/"); // Works immediately
+```
+
+See [configuration.md](./configuration.md) for all constructor options.
diff --git a/.agents/skills/cloudflare-deploy/references/miniflare/configuration.md b/.agents/skills/cloudflare-deploy/references/miniflare/configuration.md
new file mode 100644
index 0000000..b269b24
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/miniflare/configuration.md
@@ -0,0 +1,173 @@
+# Configuration
+
+## Script Loading
+
+```js
+// Inline
+new Miniflare({ modules: true, script: `export default { ... }` });
+
+// File-based
+new Miniflare({ scriptPath: "worker.js" });
+
+// Multi-module
+new Miniflare({
+ scriptPath: "src/index.js",
+ modules: true,
+ modulesRules: [
+ { type: "ESModule", include: ["**/*.js"] },
+ { type: "Text", include: ["**/*.txt"] },
+ ],
+});
+```
+
+## Compatibility
+
+```js
+new Miniflare({
+ compatibilityDate: "2026-01-01", // Use recent date for latest features
+ compatibilityFlags: [
+ "nodejs_compat", // Node.js APIs (process, Buffer, etc)
+ "streams_enable_constructors", // Stream constructors
+ ],
+ upstream: "https://example.com", // Fallback for unhandled requests
+});
+```
+
+**Critical:** Use `compatibilityDate: "2026-01-01"` or latest to match production runtime. Old dates limit available APIs.
+
+## HTTP Server & Request.cf
+
+```js
+new Miniflare({
+ port: 8787, // Default: 8787
+ host: "127.0.0.1",
+ https: true, // Self-signed cert
+ liveReload: true, // Auto-reload HTML
+
+ cf: true, // Fetch live Request.cf data (cached)
+ // cf: "./cf.json", // Or load from file
+ // cf: { colo: "DFW" }, // Or inline mock
+});
+```
+
+**Note:** For tests, use `dispatchFetch()` (no port conflicts).
+
+## Storage Bindings
+
+```js
+new Miniflare({
+ // KV
+ kvNamespaces: ["TEST_NAMESPACE", "CACHE"],
+ kvPersist: "./kv-data", // Optional: persist to disk
+
+ // R2
+ r2Buckets: ["BUCKET", "IMAGES"],
+ r2Persist: "./r2-data",
+
+ // Durable Objects
+ modules: true,
+ durableObjects: {
+ COUNTER: "Counter", // className
+ API_OBJECT: { className: "ApiObject", scriptName: "api-worker" },
+ },
+ durableObjectsPersist: "./do-data",
+
+ // D1
+ d1Databases: ["DB"],
+ d1Persist: "./d1-data",
+
+ // Cache
+ cache: true, // Default
+ cachePersist: "./cache-data",
+});
+```
+
+## Bindings
+
+```js
+new Miniflare({
+ // Environment variables
+ bindings: {
+ SECRET_KEY: "my-secret-value",
+ API_URL: "https://api.example.com",
+ DEBUG: true,
+ },
+
+ // Other bindings
+ wasmBindings: { ADD_MODULE: "./add.wasm" },
+ textBlobBindings: { TEXT: "./data.txt" },
+ queueProducers: ["QUEUE"],
+});
+```
+
+## Multiple Workers
+
+```js
+new Miniflare({
+ workers: [
+ {
+ name: "main",
+ kvNamespaces: { DATA: "shared" },
+ serviceBindings: { API: "api-worker" },
+ script: `export default { ... }`,
+ },
+ {
+ name: "api-worker",
+ kvNamespaces: { DATA: "shared" }, // Shared storage
+ script: `export default { ... }`,
+ },
+ ],
+});
+```
+
+**With routing:**
+```js
+workers: [
+ { name: "api", scriptPath: "./api.js", routes: ["api.example.com/*"] },
+ { name: "web", scriptPath: "./web.js", routes: ["example.com/*"] },
+],
+```
+
+## Logging & Performance
+
+```js
+import { Log, LogLevel } from "miniflare";
+
+new Miniflare({
+ log: new Log(LogLevel.DEBUG), // DEBUG | INFO | WARN | ERROR | NONE
+ scriptTimeout: 30000, // CPU limit (ms)
+ workersConcurrencyLimit: 10, // Max concurrent workers
+});
+```
+
+## Workers Sites
+
+```js
+new Miniflare({
+ sitePath: "./public",
+ siteInclude: ["**/*.html", "**/*.css"],
+ siteExclude: ["**/*.map"],
+});
+```
+
+## From wrangler.toml
+
+Miniflare doesn't auto-read `wrangler.toml`:
+
+```toml
+# wrangler.toml
+name = "my-worker"
+main = "src/index.ts"
+compatibility_date = "2026-01-01"
+[[kv_namespaces]]
+binding = "KV"
+```
+
+```js
+// Miniflare equivalent
+new Miniflare({
+ scriptPath: "src/index.ts",
+ compatibilityDate: "2026-01-01",
+ kvNamespaces: ["KV"],
+});
+```
diff --git a/.agents/skills/cloudflare-deploy/references/miniflare/gotchas.md b/.agents/skills/cloudflare-deploy/references/miniflare/gotchas.md
new file mode 100644
index 0000000..dfcd157
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/miniflare/gotchas.md
@@ -0,0 +1,160 @@
+# Gotchas & Troubleshooting
+
+## Miniflare Limitations
+
+**Not supported:**
+- Analytics Engine (use mocks)
+- Cloudflare Images/Stream
+- Browser Rendering API
+- Tail Workers
+- Workers for Platforms (partial support)
+
+**Behavior differences from production:**
+- Runs workerd locally, not Cloudflare edge
+- Storage is local (filesystem/memory), not distributed
+- `Request.cf` is cached/mocked, not real edge data
+- Performance differs from edge
+- Caching implementation may vary slightly
+
+## Common Errors
+
+### "Cannot find module"
+**Cause:** Module path wrong or `modulesRules` not configured
+**Solution:**
+```js
+new Miniflare({
+ modules: true,
+ modulesRules: [{ type: "ESModule", include: ["**/*.js"] }],
+});
+```
+
+### "Data not persisting"
+**Cause:** Persist paths are files, not directories
+**Solution:**
+```js
+kvPersist: "./data/kv", // Directory, not file
+```
+
+### "Cannot run TypeScript"
+**Cause:** Miniflare doesn't transpile TypeScript
+**Solution:** Build first with esbuild/tsc, then run compiled JS
+
+### "`request.cf` is undefined"
+**Cause:** CF data not configured
+**Solution:**
+```js
+new Miniflare({ cf: true }); // Or cf: "./cf.json"
+```
+
+### "EADDRINUSE" port conflict
+**Cause:** Multiple instances using same port
+**Solution:** Use `dispatchFetch()` (no HTTP server) or `port: 0` for auto-assign
+
+### "Durable Object not found"
+**Cause:** Class export doesn't match config name
+**Solution:**
+```js
+export class Counter {} // Must match
+new Miniflare({ durableObjects: { COUNTER: "Counter" } });
+```
+
+## Debugging
+
+**Enable verbose logging:**
+```js
+import { Log, LogLevel } from "miniflare";
+new Miniflare({ log: new Log(LogLevel.DEBUG) });
+```
+
+**Chrome DevTools:**
+```js
+const url = await mf.getInspectorURL();
+console.log(`DevTools: ${url}`); // Open in Chrome
+```
+
+**Inspect bindings:**
+```js
+const env = await mf.getBindings();
+console.log(Object.keys(env));
+```
+
+**Verify storage:**
+```js
+const ns = await mf.getKVNamespace("TEST");
+const { keys } = await ns.list();
+```
+
+## Best Practices
+
+**✓ Do:**
+- Use `dispatchFetch()` for tests (no HTTP server)
+- In-memory storage for CI (omit persist options)
+- New instances per test for isolation
+- Type-safe bindings with interfaces
+- `await mf.dispose()` in cleanup
+
+**✗ Avoid:**
+- HTTP server in tests
+- Shared instances without cleanup
+- Old compatibility dates (use 2026+)
+
+## Migration Guides
+
+### From Miniflare 2.x to 3+
+
+Breaking changes in v3+:
+
+| v2 | v3+ |
+|----|-----|
+| `getBindings()` sync | `getBindings()` returns Promise |
+| `ready` is void | `ready` returns `Promise` |
+| service-worker-mock | Built on workerd |
+| Different options | Restructured constructor |
+
+**Example migration:**
+```js
+// v2
+const bindings = mf.getBindings();
+mf.ready; // void
+
+// v3+
+const bindings = await mf.getBindings();
+const url = await mf.ready; // Promise
+```
+
+### From unstable_dev to Miniflare
+
+```js
+// Old (deprecated)
+import { unstable_dev } from "wrangler";
+const worker = await unstable_dev("src/index.ts");
+
+// New
+import { Miniflare } from "miniflare";
+const mf = new Miniflare({ scriptPath: "src/index.ts" });
+```
+
+### From Wrangler Dev
+
+Miniflare doesn't auto-read `wrangler.toml`:
+
+```js
+// Translate manually:
+new Miniflare({
+ scriptPath: "dist/worker.js",
+ compatibilityDate: "2026-01-01",
+ kvNamespaces: ["KV"],
+ bindings: { API_KEY: process.env.API_KEY },
+});
+```
+
+## Resource Limits
+
+| Limit | Value | Notes |
+|-------|-------|-------|
+| CPU time | 30s default | Configurable via `scriptTimeout` |
+| Storage | Filesystem | Performance varies by disk |
+| Memory | System dependent | No artificial limits |
+| Request.cf | Cached/mocked | Not live edge data |
+
+See [patterns.md](./patterns.md) for testing examples.
diff --git a/.agents/skills/cloudflare-deploy/references/miniflare/patterns.md b/.agents/skills/cloudflare-deploy/references/miniflare/patterns.md
new file mode 100644
index 0000000..c89c3a5
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/miniflare/patterns.md
@@ -0,0 +1,181 @@
+# Testing Patterns
+
+## Choosing a Testing Approach
+
+| Approach | Use Case | Speed | Setup | Runtime |
+|----------|----------|-------|-------|---------|
+| **getPlatformProxy** | Unit tests, logic testing | Fast | Low | Miniflare |
+| **Miniflare API** | Integration tests, full control | Medium | Medium | Miniflare |
+| **vitest-pool-workers** | Vitest runner integration | Medium | Medium | workerd |
+
+**Quick guide:**
+- Unit tests → getPlatformProxy
+- Integration tests → Miniflare API
+- Vitest workflows → vitest-pool-workers
+
+## getPlatformProxy
+
+Lightweight unit testing - provides bindings without full Worker runtime.
+
+```js
+// vitest.config.js
+export default { test: { environment: "node" } };
+```
+
+```js
+import { env } from "cloudflare:test";
+import { describe, it, expect } from "vitest";
+
+describe("Business logic", () => {
+ it("processes data with KV", async () => {
+ await env.KV.put("test", "value");
+ expect(await env.KV.get("test")).toBe("value");
+ });
+});
+```
+
+**Pros:** Fast, simple
+**Cons:** No full runtime, can't test fetch handler
+
+## vitest-pool-workers
+
+Full Workers runtime in Vitest. Reads `wrangler.toml`.
+
+```bash
+npm i -D @cloudflare/vitest-pool-workers
+```
+
+```js
+// vitest.config.js
+import { defineWorkersConfig } from "@cloudflare/vitest-pool-workers/config";
+
+export default defineWorkersConfig({
+ test: {
+ poolOptions: { workers: { wrangler: { configPath: "./wrangler.toml" } } },
+ },
+});
+```
+
+```js
+import { env, SELF } from "cloudflare:test";
+import { it, expect } from "vitest";
+
+it("handles fetch", async () => {
+ const res = await SELF.fetch("http://example.com/");
+ expect(res.status).toBe(200);
+});
+```
+
+**Pros:** Full runtime, uses wrangler.toml
+**Cons:** Requires Wrangler config
+
+## Miniflare API (node:test)
+
+```js
+import assert from "node:assert";
+import test, { after, before } from "node:test";
+import { Miniflare } from "miniflare";
+
+let mf;
+before(() => {
+ mf = new Miniflare({ scriptPath: "src/index.js", kvNamespaces: ["TEST_KV"] });
+});
+
+test("fetch", async () => {
+ const res = await mf.dispatchFetch("http://localhost/");
+ assert.strictEqual(await res.text(), "Hello");
+});
+
+after(() => mf.dispose());
+```
+
+## Testing Durable Objects & Events
+
+```js
+// Durable Objects
+const ns = await mf.getDurableObjectNamespace("COUNTER");
+const stub = ns.get(ns.idFromName("test-counter"));
+await stub.fetch("http://localhost/increment");
+
+// Direct storage
+const storage = await mf.getDurableObjectStorage(ns.idFromName("test-counter"));
+const count = await storage.get("count");
+
+// Queue
+const worker = await mf.getWorker();
+await worker.queue("my-queue", [
+ { id: "msg1", timestamp: new Date(), body: { userId: 123 }, attempts: 1 },
+]);
+
+// Scheduled
+await worker.scheduled({ cron: "0 0 * * *" });
+```
+
+## Test Isolation & Mocking
+
+```js
+// Per-test isolation
+beforeEach(() => { mf = new Miniflare({ kvNamespaces: ["TEST"] }); });
+afterEach(() => mf.dispose());
+
+// Mock external APIs
+new Miniflare({
+ workers: [
+ { name: "main", serviceBindings: { API: "mock-api" }, script: `...` },
+ { name: "mock-api", script: `export default { async fetch() { return Response.json({mock: true}); } }` },
+ ],
+});
+```
+
+## Type Safety
+
+```ts
+import type { KVNamespace } from "@cloudflare/workers-types";
+
+interface Env {
+ KV: KVNamespace;
+ API_KEY: string;
+}
+
+const env = await mf.getBindings();
+await env.KV.put("key", "value"); // Typed!
+
+export default {
+ async fetch(req: Request, env: Env) {
+ return new Response(await env.KV.get("key"));
+ }
+} satisfies ExportedHandler;
+```
+
+## WebSocket Testing
+
+```js
+const res = await mf.dispatchFetch("http://localhost/ws", {
+ headers: { Upgrade: "websocket" },
+});
+assert.strictEqual(res.status, 101);
+```
+
+## Migration from unstable_dev
+
+```js
+// Old (deprecated)
+import { unstable_dev } from "wrangler";
+const worker = await unstable_dev("src/index.ts");
+
+// New
+import { Miniflare } from "miniflare";
+const mf = new Miniflare({ scriptPath: "src/index.ts" });
+```
+
+## CI/CD Tips
+
+```js
+// In-memory storage (faster)
+new Miniflare({ kvNamespaces: ["TEST"] }); // No persist = in-memory
+
+// Use dispatchFetch (no port conflicts)
+await mf.dispatchFetch("http://localhost/");
+```
+
+See [gotchas.md](./gotchas.md) for troubleshooting.
diff --git a/.agents/skills/cloudflare-deploy/references/network-interconnect/README.md b/.agents/skills/cloudflare-deploy/references/network-interconnect/README.md
new file mode 100644
index 0000000..e337f1b
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/network-interconnect/README.md
@@ -0,0 +1,99 @@
+# Cloudflare Network Interconnect (CNI)
+
+Private, high-performance connectivity to Cloudflare's network. **Enterprise-only**.
+
+## Connection Types
+
+**Direct**: Physical fiber in shared datacenter. 10/100 Gbps. You order cross-connect.
+
+**Partner**: Virtual via Console Connect, Equinix, Megaport, etc. Managed via partner SDN.
+
+**Cloud**: AWS Direct Connect or GCP Cloud Interconnect. Magic WAN only.
+
+## Dataplane Versions
+
+**v1 (Classic)**: GRE tunnel support, VLAN/BFD/LACP, asymmetric MTU (1500↓/1476↑), peering support.
+
+**v2 (Beta)**: No GRE, 1500 MTU both ways, no VLAN/BFD/LACP yet, ECMP instead.
+
+## Use Cases
+
+- **Magic Transit DSR**: DDoS protection, egress via ISP (v1/v2)
+- **Magic Transit + Egress**: DDoS + egress via CF (v1/v2)
+- **Magic WAN + Zero Trust**: Private backbone (v1 needs GRE, v2 native)
+- **Peering**: Public routes at PoP (v1 only)
+- **App Security**: WAF/Cache/LB (v1/v2 over Magic Transit)
+
+## Prerequisites
+
+- Enterprise plan
+- IPv4 /24+ or IPv6 /48+ prefixes
+- BGP ASN for v1
+- See [locations PDF](https://developers.cloudflare.com/network-interconnect/static/cni-locations-2026-01.pdf)
+
+## Specs
+
+- /31 point-to-point subnets
+- 10km max optical distance
+- 10G: 10GBASE-LR single-mode
+- 100G: 100GBASE-LR4 single-mode
+- **No SLA** (free service)
+- Backup Internet required
+
+## Throughput
+
+| Direction | 10G | 100G |
+|-----------|-----|------|
+| CF → Customer | 10 Gbps | 100 Gbps |
+| Customer → CF (peering) | 10 Gbps | 100 Gbps |
+| Customer → CF (Magic) | 1 Gbps/tunnel or CNI | 1 Gbps/tunnel or CNI |
+
+## Timeline
+
+2-4 weeks typical. Steps: request → config review → order connection → configure → test → enable health checks → activate → monitor.
+
+## In This Reference
+- [configuration.md](./configuration.md) - BGP, routing, setup
+- [api.md](./api.md) - API endpoints, SDKs
+- [patterns.md](./patterns.md) - HA, hybrid cloud, failover
+- [gotchas.md](./gotchas.md) - Troubleshooting, limits
+
+## Reading Order by Task
+
+| Task | Files to Load |
+|------|---------------|
+| Initial setup | README → configuration.md → api.md |
+| Create interconnect via API | api.md → gotchas.md |
+| Design HA architecture | patterns.md → README |
+| Troubleshoot connection | gotchas.md → configuration.md |
+| Cloud integration (AWS/GCP) | configuration.md → patterns.md |
+| Monitor + alerts | configuration.md |
+
+## Automation Boundary
+
+**API-Automatable:**
+- List/create/delete interconnects (Direct, Partner)
+- List available slots
+- Get interconnect status
+- Download LOA PDF
+- Create/update CNI objects (BGP config)
+- Query settings
+
+**Requires Account Team:**
+- Initial request approval
+- AWS Direct Connect setup (send LOA+VLAN to CF)
+- GCP Cloud Interconnect final activation
+- Partner interconnect acceptance (Equinix, Megaport)
+- VLAN assignment (v1)
+- Configuration document generation (v1)
+- Escalations + troubleshooting support
+
+**Cannot Be Automated:**
+- Physical cross-connect installation (Direct)
+- Partner portal operations (virtual circuit ordering)
+- AWS/GCP portal operations
+- Maintenance window coordination
+
+## See Also
+- [tunnel](../tunnel/) - Alternative for private network connectivity
+- [spectrum](../spectrum/) - Layer 4 proxy for TCP/UDP traffic
diff --git a/.agents/skills/cloudflare-deploy/references/network-interconnect/api.md b/.agents/skills/cloudflare-deploy/references/network-interconnect/api.md
new file mode 100644
index 0000000..85e5e12
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/network-interconnect/api.md
@@ -0,0 +1,199 @@
+# CNI API Reference
+
+See [README.md](README.md) for overview.
+
+## Base
+
+```
+https://api.cloudflare.com/client/v4
+Auth: Authorization: Bearer
+```
+
+## SDK Namespaces
+
+**Primary (recommended):**
+```typescript
+client.networkInterconnects.interconnects.*
+client.networkInterconnects.cnis.*
+client.networkInterconnects.slots.*
+```
+
+**Alternate (deprecated):**
+```typescript
+client.magicTransit.cfInterconnects.*
+```
+
+Use `networkInterconnects` namespace for all new code.
+
+## Interconnects
+
+```http
+GET /accounts/{account_id}/cni/interconnects # Query: page, per_page
+POST /accounts/{account_id}/cni/interconnects # Query: validate_only=true (optional)
+GET /accounts/{account_id}/cni/interconnects/{icon}
+GET /accounts/{account_id}/cni/interconnects/{icon}/status
+GET /accounts/{account_id}/cni/interconnects/{icon}/loa # Returns PDF
+DELETE /accounts/{account_id}/cni/interconnects/{icon}
+```
+
+**Create Body:** `account`, `slot_id`, `type`, `facility`, `speed`, `name`, `description`
+**Status Values:** `active` | `healthy` | `unhealthy` | `pending` | `down`
+
+**Response Example:**
+```json
+{"result": [{"id": "icon_abc", "name": "prod", "type": "direct", "facility": "EWR1", "speed": "10G", "status": "active"}]}
+```
+
+## CNI Objects (BGP config)
+
+```http
+GET /accounts/{account_id}/cni/cnis
+POST /accounts/{account_id}/cni/cnis
+GET /accounts/{account_id}/cni/cnis/{cni}
+PUT /accounts/{account_id}/cni/cnis/{cni}
+DELETE /accounts/{account_id}/cni/cnis/{cni}
+```
+
+Body: `account`, `cust_ip`, `cf_ip`, `bgp_asn`, `bgp_password`, `vlan`
+
+## Slots
+
+```http
+GET /accounts/{account_id}/cni/slots
+GET /accounts/{account_id}/cni/slots/{slot}
+```
+
+Query: `facility`, `occupied`, `speed`
+
+## Health Checks
+
+Configure via Magic Transit/WAN tunnel endpoints (CNI v2).
+
+```typescript
+await client.magicTransit.tunnels.update(accountId, tunnelId, {
+ health_check: { enabled: true, target: '192.0.2.1', rate: 'high', type: 'request' },
+});
+```
+
+Rates: `high` | `medium` | `low`. Types: `request` | `reply`. See [Magic Transit docs](https://developers.cloudflare.com/magic-transit/how-to/configure-tunnel-endpoints/#add-tunnels).
+
+## Settings
+
+```http
+GET /accounts/{account_id}/cni/settings
+PUT /accounts/{account_id}/cni/settings
+```
+
+Body: `default_asn`
+
+## TypeScript SDK
+
+```typescript
+import Cloudflare from 'cloudflare';
+
+const client = new Cloudflare({ apiToken: process.env.CF_TOKEN });
+
+// List
+await client.networkInterconnects.interconnects.list({ account_id: id });
+
+// Create with validation
+await client.networkInterconnects.interconnects.create({
+ account_id: id,
+ account: id,
+ slot_id: 'slot_abc',
+ type: 'direct',
+ facility: 'EWR1',
+ speed: '10G',
+ name: 'prod-interconnect',
+}, {
+ query: { validate_only: true }, // Dry-run validation
+});
+
+// Create without validation
+await client.networkInterconnects.interconnects.create({
+ account_id: id,
+ account: id,
+ slot_id: 'slot_abc',
+ type: 'direct',
+ facility: 'EWR1',
+ speed: '10G',
+ name: 'prod-interconnect',
+});
+
+// Status
+await client.networkInterconnects.interconnects.get(accountId, iconId);
+
+// LOA (use fetch)
+const res = await fetch(`https://api.cloudflare.com/client/v4/accounts/${id}/cni/interconnects/${iconId}/loa`, {
+ headers: { Authorization: `Bearer ${token}` },
+});
+await fs.writeFile('loa.pdf', Buffer.from(await res.arrayBuffer()));
+
+// CNI object
+await client.networkInterconnects.cnis.create({
+ account_id: id,
+ account: id,
+ cust_ip: '192.0.2.1/31',
+ cf_ip: '192.0.2.0/31',
+ bgp_asn: 65000,
+ vlan: 100,
+});
+
+// Slots (filter by facility and speed)
+await client.networkInterconnects.slots.list({
+ account_id: id,
+ occupied: false,
+ facility: 'EWR1',
+ speed: '10G',
+});
+```
+
+## Python SDK
+
+```python
+from cloudflare import Cloudflare
+
+client = Cloudflare(api_token=os.environ["CF_TOKEN"])
+
+# List, create, status (same pattern as TypeScript)
+client.network_interconnects.interconnects.list(account_id=id)
+client.network_interconnects.interconnects.create(account_id=id, account=id, slot_id="slot_abc", type="direct", facility="EWR1", speed="10G")
+client.network_interconnects.interconnects.get(account_id=id, icon=icon_id)
+
+# CNI objects and slots
+client.network_interconnects.cnis.create(account_id=id, cust_ip="192.0.2.1/31", cf_ip="192.0.2.0/31", bgp_asn=65000)
+client.network_interconnects.slots.list(account_id=id, occupied=False)
+```
+
+## cURL
+
+```bash
+# List interconnects
+curl "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cni/interconnects" \
+ -H "Authorization: Bearer ${CF_TOKEN}"
+
+# Create interconnect
+curl -X POST "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cni/interconnects?validate_only=true" \
+ -H "Authorization: Bearer ${CF_TOKEN}" -H "Content-Type: application/json" \
+ -d '{"account": "id", "slot_id": "slot_abc", "type": "direct", "facility": "EWR1", "speed": "10G"}'
+
+# LOA PDF
+curl "https://api.cloudflare.com/client/v4/accounts/${ACCOUNT_ID}/cni/interconnects/${ICON_ID}/loa" \
+ -H "Authorization: Bearer ${CF_TOKEN}" --output loa.pdf
+```
+
+## Not Available via API
+
+**Missing Capabilities:**
+- BGP session state query (use Dashboard or BGP logs)
+- Bandwidth utilization metrics (use external monitoring)
+- Traffic statistics per interconnect
+- Historical uptime/downtime data
+- Light level readings (contact account team)
+- Maintenance window scheduling (notifications only)
+
+## Resources
+
+- [API Docs](https://developers.cloudflare.com/api/resources/network_interconnects/)
+- [TypeScript SDK](https://github.com/cloudflare/cloudflare-typescript)
+- [Python SDK](https://github.com/cloudflare/cloudflare-python)
diff --git a/.agents/skills/cloudflare-deploy/references/network-interconnect/configuration.md b/.agents/skills/cloudflare-deploy/references/network-interconnect/configuration.md
new file mode 100644
index 0000000..0f1005c
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/network-interconnect/configuration.md
@@ -0,0 +1,114 @@
+# CNI Configuration
+
+See [README.md](README.md) for overview.
+
+## Workflow (2-4 weeks)
+
+1. **Submit request** (Week 1): Contact account team, provide type/location/use case
+2. **Review config** (Week 1-2, v1 only): Approve IP/VLAN/spec doc
+3. **Order connection** (Week 2-3):
+ - **Direct**: Get LOA, order cross-connect from facility
+ - **Partner**: Order virtual circuit in partner portal
+ - **Cloud**: Order Direct Connect/Cloud Interconnect, send LOA+VLAN to CF
+4. **Configure** (Week 3): Both sides configure per doc
+5. **Test** (Week 3-4): Ping, verify BGP, check routes
+6. **Health checks** (Week 4): Configure [Magic Transit](https://developers.cloudflare.com/magic-transit/how-to/configure-tunnel-endpoints/#add-tunnels) or [Magic WAN](https://developers.cloudflare.com/magic-wan/configuration/manually/how-to/configure-tunnel-endpoints/#add-tunnels) health checks
+7. **Activate** (Week 4): Route traffic, verify flow
+8. **Monitor**: Enable [maintenance notifications](https://developers.cloudflare.com/network-interconnect/monitoring-and-alerts/#enable-cloudflare-status-maintenance-notification)
+
+## BGP Configuration
+
+**v1 Requirements:**
+- BGP ASN (provide during setup)
+- /31 subnet for peering
+- Optional: BGP password
+
+**v2:** Simplified, less BGP config needed.
+
+**BGP over CNI (Dec 2024):** Magic WAN/Transit can now peer BGP directly over CNI v2 (no GRE tunnel required).
+
+**Example v1 BGP:**
+```
+Router ID: 192.0.2.1
+Peer IP: 192.0.2.0
+Remote ASN: 13335
+Local ASN: 65000
+Password: [optional]
+VLAN: 100
+```
+
+## Cloud Interconnect Setup
+
+### AWS Direct Connect (Beta)
+
+**Requirements:** Magic WAN, AWS Dedicated Direct Connect 1/10 Gbps.
+
+**Process:**
+1. Contact CF account team
+2. Choose location
+3. Order in AWS portal
+4. AWS provides LOA + VLAN ID
+5. Send to CF account team
+6. Wait ~4 weeks
+
+**Post-setup:** Add [static routes](https://developers.cloudflare.com/magic-wan/configuration/manually/how-to/configure-routes/#configure-static-routes) to Magic WAN. Enable [bidirectional health checks](https://developers.cloudflare.com/magic-wan/configuration/manually/how-to/configure-tunnel-endpoints/#legacy-bidirectional-health-checks).
+
+### GCP Cloud Interconnect (Beta)
+
+**Setup via Dashboard:**
+1. Interconnects → Create → Cloud Interconnect → Google
+2. Provide name, MTU (match GCP VLAN attachment), speed (50M-50G granular options available for partner interconnects)
+3. Enter VLAN attachment pairing key
+4. Confirm order
+
+**Routing to GCP:** Add [static routes](https://developers.cloudflare.com/magic-wan/configuration/manually/how-to/configure-routes/#configure-static-routes). BGP routes from GCP Cloud Router **ignored**.
+
+**Routing to CF:** Configure [custom learned routes](https://cloud.google.com/network-connectivity/docs/router/how-to/configure-custom-learned-routes) in Cloud Router. Request prefixes from CF account team.
+
+## Monitoring
+
+**Dashboard Status:**
+
+| Status | Meaning |
+|--------|---------|
+| **Healthy** | Link operational, traffic flowing, health checks passing |
+| **Active** | Link up, sufficient light, Ethernet negotiated |
+| **Unhealthy** | Link down, no/low light (<-20 dBm), can't negotiate |
+| **Pending** | Cross-connect incomplete, device unresponsive, RX/TX swapped |
+| **Down** | Physical link down, no connectivity |
+
+**Alerts:**
+
+**CNI Connection Maintenance** (Magic Networking only):
+```
+Dashboard → Notifications → Add
+Product: Cloudflare Network Interconnect
+Type: Connection Maintenance Alert
+```
+Warnings up to 2 weeks advance. 6hr delay for new additions.
+
+**Cloudflare Status Maintenance** (entire PoP):
+```
+Dashboard → Notifications → Add
+Product: Cloudflare Status
+Filter PoPs: gru,fra,lhr
+```
+
+**Find PoP code:**
+```
+Dashboard → Magic Transit/WAN → Configuration → Interconnects
+Select CNI → Note Data Center (e.g., "gru-b")
+Use first 3 letters: "gru"
+```
+
+## Best Practices
+
+**Critical config-specific practices:**
+- /31 subnets required for BGP
+- BGP passwords recommended
+- BFD for fast failover (v1 only)
+- Test ping connectivity before BGP
+- Enable maintenance notifications immediately after activation
+- Monitor status programmatically via API
+
+For design patterns, HA architecture, and security best practices, see [patterns.md](./patterns.md).
diff --git a/.agents/skills/cloudflare-deploy/references/network-interconnect/gotchas.md b/.agents/skills/cloudflare-deploy/references/network-interconnect/gotchas.md
new file mode 100644
index 0000000..9880807
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/network-interconnect/gotchas.md
@@ -0,0 +1,165 @@
+# CNI Gotchas & Troubleshooting
+
+## Common Errors
+
+### "Status: Pending"
+
+**Cause:** Cross-connect not installed, RX/TX fibers reversed, wrong fiber type, or low light levels
+**Solution:**
+1. Verify cross-connect installed
+2. Check fiber at patch panel
+3. Swap RX/TX fibers
+4. Check light with optical power meter (target > -20 dBm)
+5. Contact account team
+
+### "Status: Unhealthy"
+
+**Cause:** Physical issue, low light (<-20 dBm), optic mismatch, or dirty connectors
+**Solution:**
+1. Check physical connections
+2. Clean fiber connectors
+3. Verify optic types (10GBASE-LR/100GBASE-LR4)
+4. Test with known-good optics
+5. Check patch panel
+6. Contact account team
+
+### "BGP Session Down"
+
+**Cause:** Wrong IP addressing, wrong ASN, password mismatch, or firewall blocking TCP/179
+**Solution:**
+1. Verify IPs match CNI object
+2. Confirm ASN correct
+3. Check BGP password
+4. Verify no firewall on TCP/179
+5. Check BGP logs
+6. Review BGP timers
+
+### "Low Throughput"
+
+**Cause:** MTU mismatch, fragmentation, single GRE tunnel (v1), or routing inefficiency
+**Solution:**
+1. Check MTU (1500↓/1476↑ for v1, 1500 both for v2)
+2. Test various packet sizes
+3. Add more GRE tunnels (v1)
+4. Consider upgrading to v2
+5. Review routing tables
+6. Use LACP for bundling (v1)
+
+## API Errors
+
+### 400 Bad Request: "slot_id already occupied"
+
+**Cause:** Another interconnect already uses this slot
+**Solution:** Use `occupied=false` filter when listing slots:
+```typescript
+await client.networkInterconnects.slots.list({
+ account_id: id,
+ occupied: false,
+ facility: 'EWR1',
+});
+```
+
+### 400 Bad Request: "invalid facility code"
+
+**Cause:** Typo or unsupported facility
+**Solution:** Check [locations PDF](https://developers.cloudflare.com/network-interconnect/static/cni-locations-2026-01.pdf) for valid codes
+
+### 403 Forbidden: "Enterprise plan required"
+
+**Cause:** Account not enterprise-level
+**Solution:** Contact account team to upgrade
+
+### 422 Unprocessable: "validate_only request failed"
+
+**Cause:** Dry-run validation found issues (wrong slot, invalid config)
+**Solution:** Review error message details, fix config before real creation
+
+### Rate Limiting
+
+**Limit:** 1200 requests/5min per token
+**Solution:** Implement exponential backoff, cache slot listings
+
+## Cloud-Specific Issues
+
+### AWS Direct Connect: "VLAN not matching"
+
+**Cause:** VLAN ID from AWS LOA doesn't match CNI config
+**Solution:**
+1. Get VLAN from AWS Console after ordering
+2. Send exact VLAN to CF account team
+3. Verify match in CNI object config
+
+### AWS: "Connection stuck in Pending"
+
+**Cause:** LOA not provided to CF or AWS connection not accepted
+**Solution:**
+1. Verify AWS connection status is "Available"
+2. Confirm LOA sent to CF account team
+3. Wait for CF team acceptance (can take days)
+
+### GCP: "BGP routes not propagating"
+
+**Cause:** BGP routes from GCP Cloud Router **ignored by design**
+**Solution:** Use [static routes](https://developers.cloudflare.com/magic-wan/configuration/manually/how-to/configure-routes/#configure-static-routes) in Magic WAN instead
+
+### GCP: "Cannot query VLAN attachment status via API"
+
+**Cause:** GCP Cloud Interconnect Dashboard-only (no API yet)
+**Solution:** Check status in CF Dashboard or GCP Console
+
+## Partner Interconnect Issues
+
+### Equinix: "Virtual circuit not appearing"
+
+**Cause:** CF hasn't accepted Equinix connection request
+**Solution:**
+1. Verify VC created in Equinix Fabric Portal
+2. Contact CF account team to accept
+3. Allow 2-3 business days
+
+### Console Connect/Megaport: "API creation fails"
+
+**Cause:** Partner interconnects require partner portal + CF approval
+**Solution:** Cannot fully automate. Order in partner portal, notify CF account team.
+
+## Anti-Patterns
+
+| Anti-Pattern | Why Bad | Solution |
+|--------------|---------|----------|
+| Single interconnect for production | No SLA, single point of failure | Use ≥2 with device diversity |
+| No backup Internet | CNI fails = total outage | Always maintain alternate path |
+| Polling status every second | Rate limits, wastes API calls | Poll every 30-60s max |
+| Using v1 for Magic WAN v2 workloads | GRE overhead, complexity | Use v2 for simplified routing |
+| Assuming BGP session = traffic flowing | BGP up ≠ routes installed | Verify routing tables + test traffic |
+| Not enabling maintenance alerts | Surprise downtime during maintenance | Enable notifications immediately |
+| Hardcoding VLAN in automation | VLAN assigned by CF (v1) | Get VLAN from CNI object response |
+| Using Direct without colocation | Can't access cross-connect | Use Partner or Cloud interconnect |
+
+## What's Not Queryable via API
+
+**Cannot retrieve:**
+- BGP session state (use Dashboard or BGP logs)
+- Light levels (contact account team)
+- Historical metrics (uptime, traffic)
+- Bandwidth utilization per interconnect
+- Maintenance window schedules (notifications only)
+- Fiber path details
+- Cross-connect installation status
+
+**Workarounds:**
+- External monitoring for BGP state
+- Log aggregation for historical data
+- Notifications for maintenance windows
+
+## Limits
+
+| Resource/Limit | Value | Notes |
+|----------------|-------|-------|
+| Max optical distance | 10km | Physical limit |
+| MTU (v1) | 1500↓ / 1476↑ | Asymmetric |
+| MTU (v2) | 1500 both | Symmetric |
+| GRE tunnel throughput | 1 Gbps | Per tunnel (v1) |
+| Recovery time | Days | No formal SLA |
+| Light level minimum | -20 dBm | Target threshold |
+| API rate limit | 1200 req/5min | Per token |
+| Health check delay | 6 hours | New maintenance alert subscriptions |
diff --git a/.agents/skills/cloudflare-deploy/references/network-interconnect/patterns.md b/.agents/skills/cloudflare-deploy/references/network-interconnect/patterns.md
new file mode 100644
index 0000000..7ff9dd3
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/network-interconnect/patterns.md
@@ -0,0 +1,166 @@
+# CNI Patterns
+
+See [README.md](README.md) for overview.
+
+## High Availability
+
+**Critical:** Design for resilience from day one.
+
+**Requirements:**
+- Device-level diversity (separate hardware)
+- Backup Internet connectivity (no SLA on CNI)
+- Network-resilient locations preferred
+- Regular failover testing
+
+**Architecture:**
+```
+Your Network A ──10G CNI v2──> CF CCR Device 1
+ │
+Your Network B ──10G CNI v2──> CF CCR Device 2
+ │
+ CF Global Network (AS13335)
+```
+
+**Capacity Planning:**
+- Plan across all links
+- Account for failover scenarios
+- Your responsibility
+
+## Pattern: Magic Transit + CNI v2
+
+**Use Case:** DDoS protection, private connectivity, no GRE overhead.
+
+```typescript
+// 1. Create interconnect
+const ic = await client.networkInterconnects.interconnects.create({
+ account_id: id,
+ type: 'direct',
+ facility: 'EWR1',
+ speed: '10G',
+ name: 'magic-transit-primary',
+});
+
+// 2. Poll until active
+const status = await pollUntilActive(id, ic.id);
+
+// 3. Configure Magic Transit tunnel via Dashboard/API
+```
+
+**Benefits:** 1500 MTU both ways, simplified routing.
+
+## Pattern: Multi-Cloud Hybrid
+
+**Use Case:** AWS/GCP workloads with Cloudflare.
+
+**AWS Direct Connect:**
+```typescript
+// 1. Order Direct Connect in AWS Console
+// 2. Get LOA + VLAN from AWS
+// 3. Send to CF account team (no API)
+// 4. Configure static routes in Magic WAN
+
+await configureStaticRoutes(id, {
+ prefix: '10.0.0.0/8',
+ nexthop: 'aws-direct-connect',
+});
+```
+
+**GCP Cloud Interconnect:**
+```
+1. Get VLAN attachment pairing key from GCP Console
+2. Create via Dashboard: Interconnects → Create → Cloud Interconnect → Google
+ - Enter pairing key, name, MTU, speed
+3. Configure static routes in Magic WAN (BGP routes from GCP ignored)
+4. Configure custom learned routes in GCP Cloud Router
+```
+
+**Note:** Dashboard-only. No API/SDK support yet.
+
+## Pattern: Multi-Location HA
+
+**Use Case:** 99.99%+ uptime.
+
+```typescript
+// Primary (NY)
+const primary = await client.networkInterconnects.interconnects.create({
+ account_id: id,
+ type: 'direct',
+ facility: 'EWR1',
+ speed: '10G',
+ name: 'primary-ewr1',
+});
+
+// Secondary (NY, different hardware)
+const secondary = await client.networkInterconnects.interconnects.create({
+ account_id: id,
+ type: 'direct',
+ facility: 'EWR2',
+ speed: '10G',
+ name: 'secondary-ewr2',
+});
+
+// Tertiary (LA, different geography)
+const tertiary = await client.networkInterconnects.interconnects.create({
+ account_id: id,
+ type: 'partner',
+ facility: 'LAX1',
+ speed: '10G',
+ name: 'tertiary-lax1',
+});
+
+// BGP local preferences:
+// Primary: 200
+// Secondary: 150
+// Tertiary: 100
+// Internet: Last resort
+```
+
+## Pattern: Partner Interconnect (Equinix)
+
+**Use Case:** Quick deployment, no colocation.
+
+**Setup:**
+1. Order virtual circuit in Equinix Fabric Portal
+2. Select Cloudflare as destination
+3. Choose facility
+4. Send details to CF account team
+5. CF accepts in portal
+6. Configure BGP
+
+**No API automation** – partner portals managed separately.
+
+## Failover & Security
+
+**Failover Best Practices:**
+- Use BGP local preferences for priority
+- Configure BFD for fast detection (v1)
+- Test regularly with traffic shift
+- Document runbooks
+
+**Security:**
+- BGP password authentication
+- BGP route filtering
+- Monitor unexpected routes
+- Magic Firewall for DDoS/threats
+- Minimum API token permissions
+- Rotate credentials periodically
+
+## Decision Matrix
+
+| Requirement | Recommended |
+|-------------|-------------|
+| Collocated with CF | Direct |
+| Not collocated | Partner |
+| AWS/GCP workloads | Cloud |
+| 1500 MTU both ways | v2 |
+| VLAN tagging | v1 |
+| Public peering | v1 |
+| Simplest config | v2 |
+| BFD fast failover | v1 |
+| LACP bundling | v1 |
+
+## Resources
+
+- [Magic Transit Docs](https://developers.cloudflare.com/magic-transit/)
+- [Magic WAN Docs](https://developers.cloudflare.com/magic-wan/)
+- [Argo Smart Routing](https://developers.cloudflare.com/argo/)
diff --git a/.agents/skills/cloudflare-deploy/references/observability/README.md b/.agents/skills/cloudflare-deploy/references/observability/README.md
new file mode 100644
index 0000000..58feed6
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/observability/README.md
@@ -0,0 +1,87 @@
+# Cloudflare Observability Skill Reference
+
+**Purpose**: Comprehensive guidance for implementing observability in Cloudflare Workers, covering traces, logs, metrics, and analytics.
+
+**Scope**: Cloudflare Observability features ONLY - Workers Logs, Traces, Analytics Engine, Logpush, Metrics & Analytics, and OpenTelemetry exports.
+
+---
+
+## Decision Tree: Which File to Load?
+
+Use this to route to the correct file without loading all content:
+
+```
+├─ "How do I enable/configure X?" → configuration.md
+├─ "What's the API/method/binding for X?" → api.md
+├─ "How do I implement X pattern?" → patterns.md
+│ ├─ Usage tracking/billing → patterns.md
+│ ├─ Error tracking → patterns.md
+│ ├─ Performance monitoring → patterns.md
+│ ├─ Multi-tenant tracking → patterns.md
+│ ├─ Tail Worker filtering → patterns.md
+│ └─ OpenTelemetry export → patterns.md
+└─ "Why isn't X working?" / "Limits?" → gotchas.md
+```
+
+## Reading Order
+
+Load files in this order based on task:
+
+| Task Type | Load Order | Reason |
+|-----------|------------|--------|
+| **Initial setup** | configuration.md → gotchas.md | Setup first, avoid pitfalls |
+| **Implement feature** | patterns.md → api.md → gotchas.md | Pattern → API details → edge cases |
+| **Debug issue** | gotchas.md → configuration.md | Common issues first |
+| **Query data** | api.md → patterns.md | API syntax → query examples |
+
+## Product Overview
+
+### Workers Logs
+- **What:** Console output from Workers (console.log/warn/error)
+- **Access:** Dashboard (Real-time Logs), Logpush, Tail Workers
+- **Cost:** Free (included with all Workers)
+- **Retention:** Real-time only (no historical storage in dashboard)
+
+### Workers Traces
+- **What:** Execution traces with timing, CPU usage, outcome
+- **Access:** Dashboard (Workers Analytics → Traces), Logpush
+- **Cost:** $0.10/1M spans (GA pricing starts March 1, 2026), 10M free/month
+- **Retention:** 14 days included
+
+### Analytics Engine
+- **What:** High-cardinality event storage and SQL queries
+- **Access:** SQL API, Dashboard (Analytics → Analytics Engine)
+- **Cost:** $0.25/1M writes beyond 10M free/month
+- **Retention:** 90 days (configurable up to 1 year)
+
+### Tail Workers
+- **What:** Workers that receive logs/traces from other Workers
+- **Use Cases:** Log filtering, transformation, external export
+- **Cost:** Standard Workers pricing
+
+### Logpush
+- **What:** Stream logs to external storage (S3, R2, Datadog, etc.)
+- **Access:** Dashboard, API
+- **Cost:** Requires Business/Enterprise plan
+
+## Pricing Summary (2026)
+
+| Feature | Free Tier | Cost Beyond Free Tier | Plan Requirement |
+|---------|-----------|----------------------|------------------|
+| Workers Logs | Unlimited | Free | Any |
+| Workers Traces | 10M spans/month | $0.10/1M spans | Paid Workers (GA: March 1, 2026) |
+| Analytics Engine | 10M writes/month | $0.25/1M writes | Paid Workers |
+| Logpush | N/A | Included in plan | Business/Enterprise |
+
+## In This Reference
+
+- **[configuration.md](configuration.md)** - Setup, deployment, configuration (Logs, Traces, Analytics Engine, Tail Workers, Logpush)
+- **[api.md](api.md)** - API endpoints, methods, interfaces (GraphQL, SQL, bindings, types)
+- **[patterns.md](patterns.md)** - Common patterns, use cases, examples (billing, monitoring, error tracking, exports)
+- **[gotchas.md](gotchas.md)** - Troubleshooting, best practices, limitations (common errors, performance gotchas, pricing)
+
+## See Also
+
+- [Cloudflare Workers Docs](https://developers.cloudflare.com/workers/)
+- [Analytics Engine Docs](https://developers.cloudflare.com/analytics/analytics-engine/)
+- [Workers Traces Docs](https://developers.cloudflare.com/workers/observability/traces/)
diff --git a/.agents/skills/cloudflare-deploy/references/observability/api.md b/.agents/skills/cloudflare-deploy/references/observability/api.md
new file mode 100644
index 0000000..a0161de
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/observability/api.md
@@ -0,0 +1,164 @@
+## API Reference
+
+### GraphQL Analytics API
+
+**Endpoint**: `https://api.cloudflare.com/client/v4/graphql`
+
+**Query Workers Metrics**:
+```graphql
+query {
+ viewer {
+ accounts(filter: { accountTag: $accountId }) {
+ workersInvocationsAdaptive(
+ limit: 100
+ filter: {
+ datetime_geq: "2025-01-01T00:00:00Z"
+ datetime_leq: "2025-01-31T23:59:59Z"
+ scriptName: "my-worker"
+ }
+ ) {
+ sum {
+ requests
+ errors
+ subrequests
+ }
+ quantiles {
+ cpuTimeP50
+ cpuTimeP99
+ wallTimeP50
+ wallTimeP99
+ }
+ }
+ }
+ }
+}
+```
+
+### Analytics Engine SQL API
+
+**Endpoint**: `https://api.cloudflare.com/client/v4/accounts/{account_id}/analytics_engine/sql`
+
+**Authentication**: `Authorization: Bearer ` (Account Analytics Read permission)
+
+**Common Queries**:
+
+```sql
+-- List all datasets
+SHOW TABLES;
+
+-- Time-series aggregation (5-minute buckets)
+SELECT
+ intDiv(toUInt32(timestamp), 300) * 300 AS time_bucket,
+ blob1 AS endpoint,
+ SUM(_sample_interval) AS total_requests,
+ AVG(double1) AS avg_response_time_ms
+FROM api_metrics
+WHERE timestamp >= NOW() - INTERVAL '24' HOUR
+GROUP BY time_bucket, endpoint
+ORDER BY time_bucket DESC;
+
+-- Top customers by usage
+SELECT
+ index1 AS customer_id,
+ SUM(_sample_interval * double1) AS total_api_calls,
+ AVG(double2) AS avg_response_time_ms
+FROM api_usage
+WHERE timestamp >= NOW() - INTERVAL '7' DAY
+GROUP BY customer_id
+ORDER BY total_api_calls DESC
+LIMIT 100;
+
+-- Error rate analysis
+SELECT
+ blob1 AS error_type,
+ COUNT(*) AS occurrences,
+ MAX(timestamp) AS last_seen
+FROM error_tracking
+WHERE timestamp >= NOW() - INTERVAL '1' HOUR
+GROUP BY error_type
+ORDER BY occurrences DESC;
+```
+
+### Console Logging API
+
+**Methods**:
+```typescript
+// Standard methods (all appear in Workers Logs)
+console.log('info message');
+console.info('info message');
+console.warn('warning message');
+console.error('error message');
+console.debug('debug message');
+
+// Structured logging (recommended)
+console.log({
+ level: 'info',
+ user_id: '123',
+ action: 'checkout',
+ amount: 99.99,
+ currency: 'USD'
+});
+```
+
+**Log Levels**: All console methods produce logs; use structured fields for filtering:
+```typescript
+console.log({
+ level: 'error',
+ message: 'Payment failed',
+ error_code: 'CARD_DECLINED'
+});
+```
+
+### Analytics Engine Binding Types
+
+```typescript
+interface AnalyticsEngineDataset {
+ writeDataPoint(event: AnalyticsEngineDataPoint): void;
+}
+
+interface AnalyticsEngineDataPoint {
+ // Indexed strings (use for filtering/grouping)
+ indexes?: string[];
+
+ // Non-indexed strings (metadata, IDs, URLs)
+ blobs?: string[];
+
+ // Numeric values (counts, durations, amounts)
+ doubles?: number[];
+}
+```
+
+**Field Limits**:
+- Max 20 indexes
+- Max 20 blobs
+- Max 20 doubles
+- Max 25 `writeDataPoint` calls per request
+
+### Tail Consumer Event Type
+
+```typescript
+interface TraceItem {
+ event: TraceEvent;
+ logs: TraceLog[];
+ exceptions: TraceException[];
+ scriptName?: string;
+}
+
+interface TraceEvent {
+ outcome: 'ok' | 'exception' | 'exceededCpu' | 'exceededMemory' | 'unknown';
+ cpuTime: number; // microseconds
+ wallTime: number; // microseconds
+}
+
+interface TraceLog {
+ timestamp: number;
+ level: 'log' | 'info' | 'debug' | 'warn' | 'error';
+ message: any; // string or structured object
+}
+
+interface TraceException {
+ name: string;
+ message: string;
+ timestamp: number;
+}
+```
\ No newline at end of file
diff --git a/.agents/skills/cloudflare-deploy/references/observability/configuration.md b/.agents/skills/cloudflare-deploy/references/observability/configuration.md
new file mode 100644
index 0000000..483de4c
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/observability/configuration.md
@@ -0,0 +1,169 @@
+## Configuration Patterns
+
+### Enable Workers Logs
+
+```jsonc
+{
+ "observability": {
+ "enabled": true,
+ "head_sampling_rate": 1 // 100% sampling (default)
+ }
+}
+```
+
+**Best Practice**: Use structured JSON logging for better indexing
+
+```typescript
+// Good - structured logging
+console.log({
+ user_id: 123,
+ action: "login",
+ status: "success",
+ duration_ms: 45
+});
+
+// Avoid - unstructured string
+console.log("user_id: 123 logged in successfully in 45ms");
+```
+
+### Enable Workers Traces
+
+```jsonc
+{
+ "observability": {
+ "traces": {
+ "enabled": true,
+ "head_sampling_rate": 0.05 // 5% sampling
+ }
+ }
+}
+```
+
+**Note**: Default sampling is 100%. For high-traffic Workers, use lower sampling (0.01-0.1).
+
+### Configure Analytics Engine
+
+**Bind to Worker**:
+```toml
+# wrangler.toml
+analytics_engine_datasets = [
+ { binding = "ANALYTICS", dataset = "api_metrics" }
+]
+```
+
+**Write Data Points**:
+```typescript
+export interface Env {
+ ANALYTICS: AnalyticsEngineDataset;
+}
+
+export default {
+ async fetch(request: Request, env: Env): Promise {
+ // Track metrics
+ env.ANALYTICS.writeDataPoint({
+ blobs: ['customer_123', 'POST', '/api/v1/users'],
+ doubles: [1, 245.5], // request_count, response_time_ms
+ indexes: ['customer_123'] // for efficient filtering
+ });
+
+ return new Response('OK');
+ }
+}
+```
+
+### Configure Tail Workers
+
+Tail Workers receive logs/traces from other Workers for filtering, transformation, or export.
+
+**Setup**:
+```toml
+# wrangler.toml
+name = "log-processor"
+main = "src/tail.ts"
+
+[[tail_consumers]]
+service = "my-worker" # Worker to tail
+```
+
+**Tail Worker Example**:
+```typescript
+export default {
+ async tail(events: TraceItem[], env: Env, ctx: ExecutionContext) {
+ // Filter errors only
+ const errors = events.filter(event =>
+ event.outcome === 'exception' || event.outcome === 'exceededCpu'
+ );
+
+ if (errors.length > 0) {
+ // Send to external monitoring
+ ctx.waitUntil(
+ fetch('https://monitoring.example.com/errors', {
+ method: 'POST',
+ body: JSON.stringify(errors)
+ })
+ );
+ }
+ }
+}
+```
+
+### Configure Logpush
+
+Send logs to external storage (S3, R2, GCS, Azure, Datadog, etc.). Requires Business/Enterprise plan.
+
+**Via Dashboard**:
+1. Navigate to Analytics → Logs → Logpush
+2. Select destination type
+3. Provide credentials and bucket/endpoint
+4. Choose dataset (e.g., Workers Trace Events)
+5. Configure filters and fields
+
+**Via API**:
+```bash
+curl -X POST "https://api.cloudflare.com/client/v4/accounts/{account_id}/logpush/jobs" \
+ -H "Authorization: Bearer " \
+ -H "Content-Type: application/json" \
+ -d '{
+ "name": "workers-logs-to-s3",
+ "destination_conf": "s3://my-bucket/logs?region=us-east-1",
+ "dataset": "workers_trace_events",
+ "enabled": true,
+ "frequency": "high",
+ "filter": "{\"where\":{\"and\":[{\"key\":\"ScriptName\",\"operator\":\"eq\",\"value\":\"my-worker\"}]}}"
+ }'
+```
+
+### Environment-Specific Configuration
+
+**Development** (verbose logs, full sampling):
+```jsonc
+// wrangler.dev.jsonc
+{
+ "observability": {
+ "enabled": true,
+ "head_sampling_rate": 1.0,
+ "traces": {
+ "enabled": true
+ }
+ }
+}
+```
+
+**Production** (reduced sampling, structured logs):
+```jsonc
+// wrangler.prod.jsonc
+{
+ "observability": {
+ "enabled": true,
+ "head_sampling_rate": 0.1, // 10% sampling
+ "traces": {
+ "enabled": true
+ }
+ }
+}
+```
+
+Deploy with env-specific config:
+```bash
+wrangler deploy --config wrangler.prod.jsonc --env production
+```
\ No newline at end of file
diff --git a/.agents/skills/cloudflare-deploy/references/observability/gotchas.md b/.agents/skills/cloudflare-deploy/references/observability/gotchas.md
new file mode 100644
index 0000000..42bc738
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/observability/gotchas.md
@@ -0,0 +1,115 @@
+## Common Errors
+
+### "Logs not appearing"
+
+**Cause:** Observability disabled, Worker not redeployed, no traffic, low sampling rate, or log size exceeds 256 KB
+**Solution:**
+```bash
+# Verify config
+cat wrangler.jsonc | jq '.observability'
+
+# Check deployment
+wrangler deployments list
+
+# Test with curl
+curl https://your-worker.workers.dev
+```
+Ensure `observability.enabled = true`, redeploy Worker, check `head_sampling_rate`, verify traffic
+
+### "Traces not being captured"
+
+**Cause:** Traces not enabled, incorrect sampling rate, Worker not redeployed, or destination unavailable
+**Solution:**
+```jsonc
+// Temporarily set to 100% sampling for debugging
+{
+ "observability": {
+ "enabled": true,
+ "head_sampling_rate": 1.0,
+ "traces": {
+ "enabled": true
+ }
+ }
+}
+```
+Ensure `observability.traces.enabled = true`, set `head_sampling_rate` to 1.0 for testing, redeploy, check destination status
+
+## Limits
+
+| Resource/Limit | Value | Notes |
+|----------------|-------|-------|
+| Max log size | 256 KB | Logs exceeding this are truncated |
+| Default sampling rate | 1.0 (100%) | Reduce for high-traffic Workers |
+| Max destinations | Varies by plan | Check dashboard |
+| Trace context propagation | 100 spans max | Deep call chains may lose spans |
+| Analytics Engine write rate | 25 writes/request | Excess writes dropped silently |
+
+## Performance Gotchas
+
+### Spectre Mitigation Timing
+
+**Problem:** `Date.now()` and `performance.now()` have reduced precision (coarsened to 100μs)
+**Cause:** Spectre vulnerability mitigation in V8
+**Solution:** Accept reduced precision or use Workers Traces for accurate timing
+```typescript
+// Date.now() is coarsened - trace spans are accurate
+export default {
+ async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise {
+ // For user-facing timing, Date.now() is fine
+ const start = Date.now();
+ const response = await processRequest(request);
+ const duration = Date.now() - start;
+
+ // For detailed performance analysis, use Workers Traces instead
+ return response;
+ }
+}
+```
+
+### Analytics Engine _sample_interval Aggregation
+
+**Problem:** Queries return incorrect totals when not multiplying by `_sample_interval`
+**Cause:** Analytics Engine stores sampled data points, each representing multiple events
+**Solution:** Always multiply counts/sums by `_sample_interval` in aggregations
+```sql
+-- WRONG: Undercounts actual events
+SELECT blob1 AS customer_id, COUNT(*) AS total_calls
+FROM api_usage GROUP BY customer_id;
+
+-- CORRECT: Accounts for sampling
+SELECT blob1 AS customer_id, SUM(_sample_interval) AS total_calls
+FROM api_usage GROUP BY customer_id;
+```
+
+### Trace Context Propagation Limits
+
+**Problem:** Deep call chains lose trace context after 100 spans
+**Cause:** Cloudflare limits trace depth to prevent performance impact
+**Solution:** Design for flatter architectures or use custom correlation IDs for deep chains
+```typescript
+// For deep call chains, add custom correlation ID
+const correlationId = crypto.randomUUID();
+console.log({ correlationId, event: 'request_start' });
+
+// Pass correlationId through headers to downstream services
+await fetch('https://api.example.com', {
+ headers: { 'X-Correlation-ID': correlationId }
+});
+```
+
+## Pricing (2026)
+
+### Workers Traces
+- **GA Pricing (starts March 1, 2026):**
+ - $0.10 per 1M trace spans captured
+ - Retention: 14 days included
+- **Free tier:** 10M trace spans/month
+- **Note:** Beta usage (before March 1, 2026) is free
+
+### Workers Logs
+- **Included:** Free for all Workers
+- **Logpush:** Requires Business/Enterprise plan
+
+### Analytics Engine
+- **Included:** 10M writes/month on Paid Workers plan
+- **Additional:** $0.25 per 1M writes beyond included quota
diff --git a/.agents/skills/cloudflare-deploy/references/observability/patterns.md b/.agents/skills/cloudflare-deploy/references/observability/patterns.md
new file mode 100644
index 0000000..9135c68
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/observability/patterns.md
@@ -0,0 +1,105 @@
+# Observability Patterns
+
+## Usage-Based Billing
+
+```typescript
+env.ANALYTICS.writeDataPoint({
+ blobs: [customerId, request.url, request.method],
+ doubles: [1], // request_count
+ indexes: [customerId]
+});
+```
+
+```sql
+SELECT blob1 AS customer_id, SUM(_sample_interval * double1) AS total_calls
+FROM api_usage WHERE timestamp >= DATE_TRUNC('month', NOW())
+GROUP BY customer_id
+```
+
+## Performance Monitoring
+
+```typescript
+const start = Date.now();
+const response = await fetch(url);
+env.ANALYTICS.writeDataPoint({
+ blobs: [url, response.status.toString()],
+ doubles: [Date.now() - start, response.status]
+});
+```
+
+```sql
+SELECT blob1 AS url, AVG(double1) AS avg_ms, percentile(double1, 0.95) AS p95_ms
+FROM fetch_metrics WHERE timestamp >= NOW() - INTERVAL '1' HOUR
+GROUP BY url
+```
+
+## Error Tracking
+
+```typescript
+env.ANALYTICS.writeDataPoint({
+ blobs: [error.name, request.url, request.method],
+ doubles: [1],
+ indexes: [error.name]
+});
+```
+
+## Multi-Tenant Tracking
+
+```typescript
+env.ANALYTICS.writeDataPoint({
+ indexes: [tenantId], // efficient filtering
+ blobs: [tenantId, url.pathname, method, status],
+ doubles: [1, duration, bytesSize]
+});
+```
+
+## Tail Worker Log Filtering
+
+```typescript
+export default {
+ async tail(events, env, ctx) {
+ const critical = events.filter(e =>
+ e.exceptions.length > 0 || e.event.wallTime > 1000000
+ );
+ if (critical.length === 0) return;
+
+ ctx.waitUntil(
+ fetch('https://logging.example.com/ingest', {
+ method: 'POST',
+ headers: { 'Authorization': `Bearer ${env.API_KEY}` },
+ body: JSON.stringify(critical.map(e => ({
+ outcome: e.event.outcome,
+ cpu_ms: e.event.cpuTime / 1000,
+ errors: e.exceptions
+ })))
+ })
+ );
+ }
+};
+```
+
+## OpenTelemetry Export
+
+```typescript
+export default {
+ async tail(events, env, ctx) {
+ const otelSpans = events.map(e => ({
+ traceId: generateId(32),
+ spanId: generateId(16),
+ name: e.scriptName || 'worker.request',
+ attributes: [
+ { key: 'worker.outcome', value: { stringValue: e.event.outcome } },
+ { key: 'worker.cpu_time_us', value: { intValue: String(e.event.cpuTime) } }
+ ]
+ }));
+
+ ctx.waitUntil(
+ fetch('https://api.honeycomb.io/v1/traces', {
+ method: 'POST',
+ headers: { 'X-Honeycomb-Team': env.HONEYCOMB_KEY },
+ body: JSON.stringify({ resourceSpans: [{ scopeSpans: [{ spans: otelSpans }] }] })
+ })
+ );
+ }
+};
+```
diff --git a/.agents/skills/cloudflare-deploy/references/pages-functions/README.md b/.agents/skills/cloudflare-deploy/references/pages-functions/README.md
new file mode 100644
index 0000000..deaf461
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/pages-functions/README.md
@@ -0,0 +1,98 @@
+# Cloudflare Pages Functions
+
+Serverless functions on Cloudflare Pages using Workers runtime. Full-stack dev with file-based routing.
+
+## Quick Navigation
+
+**Need to...**
+| Task | Go to |
+|------|-------|
+| Set up TypeScript types | [configuration.md](./configuration.md) - TypeScript Setup |
+| Configure bindings (KV, D1, R2) | [configuration.md](./configuration.md) - wrangler.jsonc |
+| Access request/env/params | [api.md](./api.md) - EventContext |
+| Add middleware or auth | [patterns.md](./patterns.md) - Middleware, Auth |
+| Background tasks (waitUntil) | [patterns.md](./patterns.md) - Background Tasks |
+| Debug errors or check limits | [gotchas.md](./gotchas.md) - Common Errors, Limits |
+
+## Decision Tree: Is This Pages Functions?
+
+```
+Need serverless backend?
+├─ Yes, for a static site → Pages Functions
+├─ Yes, standalone API → Workers
+└─ Just static hosting → Pages (no functions)
+
+Have existing Worker?
+├─ Complex routing logic → Use _worker.js (Advanced Mode)
+└─ Simple routes → Migrate to /functions (File-Based)
+
+Framework-based?
+├─ Next.js/SvelteKit/Remix → Uses _worker.js automatically
+└─ Vanilla/HTML/React SPA → Use /functions
+```
+
+## File-Based Routing
+
+```
+/functions
+ ├── index.js → /
+ ├── api.js → /api
+ ├── users/
+ │ ├── index.js → /users/
+ │ ├── [user].js → /users/:user
+ │ └── [[catchall]].js → /users/*
+ └── _middleware.js → runs on all routes
+```
+
+**Rules:**
+- `index.js` → directory root
+- Trailing slash optional
+- Specific routes precede catch-alls
+- Falls back to static if no match
+
+## Dynamic Routes
+
+**Single segment** `[param]` → string:
+```js
+// /functions/users/[user].js
+export function onRequest(context) {
+ return new Response(`Hello ${context.params.user}`);
+}
+// Matches: /users/nevi
+```
+
+**Multi-segment** `[[param]]` → array:
+```js
+// /functions/users/[[catchall]].js
+export function onRequest(context) {
+ return new Response(JSON.stringify(context.params.catchall));
+}
+// Matches: /users/nevi/foobar → ["nevi", "foobar"]
+```
+
+## Key Features
+
+- **Method handlers:** `onRequestGet`, `onRequestPost`, etc.
+- **Middleware:** `_middleware.js` for cross-cutting concerns
+- **Bindings:** KV, D1, R2, Durable Objects, Workers AI, Service bindings
+- **TypeScript:** Full type support via `wrangler types` command
+- **Advanced mode:** Use `_worker.js` for custom routing logic
+
+## Reading Order
+
+**New to Pages Functions?** Start here:
+1. [README.md](./README.md) - Overview, routing, decision tree (you are here)
+2. [configuration.md](./configuration.md) - TypeScript setup, wrangler.jsonc, bindings
+3. [api.md](./api.md) - EventContext, handlers, bindings reference
+4. [patterns.md](./patterns.md) - Middleware, auth, CORS, rate limiting, caching
+5. [gotchas.md](./gotchas.md) - Common errors, debugging, limits
+
+**Quick reference lookup:**
+- Bindings table → [api.md](./api.md)
+- Error diagnosis → [gotchas.md](./gotchas.md)
+- TypeScript setup → [configuration.md](./configuration.md)
+
+## See Also
+- [pages](../pages/) - Pages platform overview and static site deployment
+- [workers](../workers/) - Workers runtime API reference
+- [d1](../d1/) - D1 database integration with Pages Functions
diff --git a/.agents/skills/cloudflare-deploy/references/pages-functions/api.md b/.agents/skills/cloudflare-deploy/references/pages-functions/api.md
new file mode 100644
index 0000000..5263372
--- /dev/null
+++ b/.agents/skills/cloudflare-deploy/references/pages-functions/api.md
@@ -0,0 +1,143 @@
+# Function API
+
+## EventContext
+
+```typescript
+interface EventContext {
+ request: Request; // Incoming request
+ functionPath: string; // Request path
+ waitUntil(promise: Promise): void; // Background tasks (non-blocking)
+ passThroughOnException(): void; // Fallback to static on error
+ next(input?: Request | string, init?: RequestInit): Promise;
+ env: Env; // Bindings, vars, secrets
+ params: Record; // Route params ([user] or [[catchall]])
+ data: any; // Middleware shared state
+}
+```
+
+**TypeScript:** See [configuration.md](./configuration.md) for `wrangler types` setup
+
+## Handlers
+
+```typescript
+// Generic (fallback for any method)
+export async function onRequest(ctx: EventContext): Promise {
+ return new Response('Any method');
+}
+
+// Method-specific (takes precedence over generic)
+export async function onRequestGet(ctx: EventContext): Promise {
+ return Response.json({ message: 'GET' });
+}
+
+export async function onRequestPost(ctx: EventContext): Promise {
+ const body = await ctx.request.json();
+ return Response.json({ received: body });
+}
+// Also: onRequestPut, onRequestPatch, onRequestDelete, onRequestHead, onRequestOptions
+```
+
+## Bindings Reference
+
+| Binding Type | Interface | Config Key | Use Case |
+|--------------|-----------|------------|----------|
+| KV | `KVNamespace` | `kv_namespaces` | Key-value cache, sessions, config |
+| D1 | `D1Database` | `d1_databases` | Relational data, SQL queries |
+| R2 | `R2Bucket` | `r2_buckets` | Large files, user uploads, assets |
+| Durable Objects | `DurableObjectNamespace` | `durable_objects.bindings` | Stateful coordination, websockets |
+| Workers AI | `Ai` | `ai.binding` | LLM inference, embeddings |
+| Vectorize | `VectorizeIndex` | `vectorize` | Vector search, embeddings |
+| Service Binding | `Fetcher` | `services` | Worker-to-worker RPC |
+| Analytics Engine | `AnalyticsEngineDataset` | `analytics_engine_datasets` | Event logging, metrics |
+| Environment Vars | `string` | `vars` | Non-sensitive config |
+
+See [configuration.md](./configuration.md) for wrangler.jsonc examples.
+
+## Bindings
+
+### KV
+
+```typescript
+interface Env { KV: KVNamespace; }
+export const onRequest: PagesFunction = async (ctx) => {
+ await ctx.env.KV.put('key', 'value', { expirationTtl: 3600 });
+ const val = await ctx.env.KV.get('key', { type: 'json' });
+ const keys = await ctx.env.KV.list({ prefix: 'user:' });
+ return Response.json({ val });
+};
+```
+
+### D1
+
+```typescript
+interface Env { DB: D1Database; }
+export const onRequest: PagesFunction = async (ctx) => {
+ const user = await ctx.env.DB.prepare('SELECT * FROM users WHERE id = ?').bind(123).first();
+ return Response.json(user);
+};
+```
+
+### R2
+
+```typescript
+interface Env { BUCKET: R2Bucket; }
+export const onRequest: PagesFunction = async (ctx) => {
+ const obj = await ctx.env.BUCKET.get('file.txt');
+ if (!obj) return new Response('Not found', { status: 404 });
+ await ctx.env.BUCKET.put('file.txt', ctx.request.body);
+ return new Response(obj.body);
+};
+```
+
+### Durable Objects
+
+```typescript
+interface Env { COUNTER: DurableObjectNamespace; }
+export const onRequest: PagesFunction = async (ctx) => {
+ const stub = ctx.env.COUNTER.get(ctx.env.COUNTER.idFromName('global'));
+ return stub.fetch(ctx.request);
+};
+```
+
+### Workers AI
+
+```typescript
+interface Env { AI: Ai; }
+export const onRequest: PagesFunction