diff --git a/.gitignore b/.gitignore
index 6b2330f..a973e7b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -2,6 +2,9 @@
# dependencies
/node_modules
+/worker/node_modules
+/worker/package-lock.json
+/worker/pnpm-lock.yaml
/.pnp
.pnp.js
.yarn/install-state.gz
diff --git a/README.md b/README.md
index a0e12c3..0038fda 100644
--- a/README.md
+++ b/README.md
@@ -141,3 +141,86 @@ GRANT ALL ON TABLE "public"."reply_edit" TO "service_role";
| `NEXT_PUBLIC_BASE_URL` | Base URL of your deployment (e.g. `https://answerify.dev`) |
| `RESEND_API_KEY` | Resend API key for sending emails |
| `GEMINI_API_KEY` | Google Gemini API key for embeddings (`gemini-embedding-001`) and completions (`gemini-3-flash-preview`) |
+| `CLOUDFLARE_AGENT_URL` | *(Optional)* URL of the deployed Cloudflare Worker (see below) |
+| `CLOUDFLARE_AGENT_SECRET` | *(Optional)* Shared secret to authenticate calls from Next.js to the Worker |
+
+---
+
+## Cloudflare Agents Integration
+
+The `worker/` directory contains a [Cloudflare Agents](https://agents.cloudflare.com/) Worker that handles inbound email processing and AI reply generation entirely on Cloudflare's infrastructure, replacing both the `/api/webhooks/inbound-email` and `/api/webhooks/reply` Next.js routes.
+
+### Why Cloudflare Agents?
+
+| | Next.js serverless | Cloudflare Agent Worker |
+|---|---|---|
+| Inbound email | HTTP webhook (base64 raw body) | Native `email` export – raw `EmailMessage` direct from Cloudflare Email Routing |
+| Execution timeout | ~60 s (Vercel) | Minutes to hours (Durable Objects) |
+| State persistence | None – stateless per request | Built-in SQLite per agent instance |
+| Parallelism | One function per request | One Durable Object per email thread |
+| Observability | Logs only | State introspection + scheduling |
+| Outbound reply | Resend API with manual `In-Reply-To` header | `this.replyToEmail()` – Cloudflare sets `In-Reply-To` natively; reply lands in customer's existing thread |
+
+The `EmailReplyAgent` extends the Cloudflare [`Agent`](https://agents.cloudflare.com/) class. Each email thread gets its own Durable Object instance, so parallel threads are fully isolated and never block each other. Agent state (`status`, `findings`, `reply`, `confidence`) is persisted across the research and writing steps, enabling safe retries if any step fails.
+
+### Deploying the Worker
+
+```bash
+cd worker
+npm install
+npm run deploy # wrangler deploy
+
+# Set secrets (run once):
+wrangler secret put SUPABASE_URL
+wrangler secret put SUPABASE_SERVICE_KEY
+wrangler secret put INBOUND_WEBHOOK_SECRET # shared with Next.js CLOUDFLARE_AGENT_SECRET
+```
+
+After deploying:
+1. Point your [Cloudflare Email Routing](https://developers.cloudflare.com/email-routing/) destination to this Worker (instead of the Next.js `/api/webhooks/inbound-email` URL).
+2. Set `CLOUDFLARE_AGENT_URL` and `CLOUDFLARE_AGENT_SECRET` in your Next.js environment so the dashboard's "Generate Reply" action calls the Worker.
+
+Without these variables the app falls back to the built-in Next.js webhook (original behaviour).
+
+### How it works
+
+```
+Customer sends email
+ │
+ ▼
+Cloudflare Email Routing
+ │ delivers raw EmailMessage
+ ▼
+Worker email handler
+ │ resolveEmailToAgent():
+ │ 1. In-Reply-To lookup → existing thread via Supabase
+ │ 2. new UUID → new thread
+ │
+ ▼ routeAgentEmail()
+EmailReplyAgent (Durable Object, one per thread)
+ │
+ ├─ onEmail()
+ │ ├─ isAutoReplyEmail check (RFC 3834)
+ │ ├─ Parse raw email (PostalMime)
+ │ ├─ Look up organization by recipient address
+ │ ├─ Create/reopen thread in Supabase
+ │ ├─ Insert email record
+ │ └─ generateReply() ──────────────────────────────────────┐
+ │ │
+ └─ onRequest() ◄── Next.js /api/generate-reply │
+ (manual trigger from dashboard → always saves draft) │
+ └─ generateReply() ────────────────────────────────────► │
+ ▼
+ Step 1: Research Agent (@cf/zai-org/glm-4.7-flash)
+ fetches up to 5 datasource URLs, extracts
+ plain text, summarises relevant facts
+ Step 2: Writing Agent (@cf/zai-org/glm-4.7-flash)
+ produces polished HTML reply (up to 4096 tokens)
+ Both steps use the Workers AI binding – no external
+ AI service or API key required.
+ Step 3: Auto-send or draft
+ • email path: this.replyToEmail() – Cloudflare
+ sets In-Reply-To natively; reply lands in the
+ customer's existing thread automatically
+ • HTTP path: always saves draft for review
+```
diff --git a/app/api/generate-reply/route.ts b/app/api/generate-reply/route.ts
index d60627c..db12a8c 100644
--- a/app/api/generate-reply/route.ts
+++ b/app/api/generate-reply/route.ts
@@ -7,7 +7,7 @@ export async function POST(request: Request) {
const supabase = await createServiceClient();
const { data: record, error } = await supabase
.from('email')
- .select()
+ .select('thread_id')
.eq('id', id)
.single();
@@ -15,19 +15,62 @@ export async function POST(request: Request) {
return new Response(JSON.stringify({ error: true }), { status: 500 });
}
- // Fire webhook request and wait for completion to ensure it's processed
+ // When a Cloudflare Agent Worker URL is configured, forward the request to
+ // the EmailReplyAgent Durable Object for this thread.
+ // The agent fetches all context (datasources, org, thread history) from
+ // Supabase itself, so we only need to pass the email ID.
+ const agentUrl = process.env.CLOUDFLARE_AGENT_URL;
+ const agentSecret = process.env.CLOUDFLARE_AGENT_SECRET;
+ if (agentUrl && agentSecret) {
+ try {
+ const threadId = record.thread_id as string;
+ const agentEndpoint = `${agentUrl}/agents/email-reply-agent/${threadId}`;
+
+ const agentResponse = await fetch(agentEndpoint, {
+ method: 'POST',
+ headers: {
+ 'Content-Type': 'application/json',
+ 'X-Agent-Secret': agentSecret,
+ },
+ body: JSON.stringify({ emailId: id }),
+ });
+
+ if (!agentResponse.ok) {
+ console.error(
+ 'Cloudflare Agent request failed:',
+ await agentResponse.text()
+ );
+ }
+ } catch (err) {
+ console.error('Cloudflare Agent request error:', err);
+ }
+
+ return new Response(JSON.stringify({ ok: true }), { status: 200 });
+ }
+
+ // Fallback: fire the local webhook (original behaviour, no Cloudflare Agent)
try {
+ const { data: fullRecord, error: fetchError } = await supabase
+ .from('email')
+ .select()
+ .eq('id', id)
+ .single();
+
+ if (fetchError) {
+ return new Response(JSON.stringify({ error: true }), { status: 500 });
+ }
+
const webhookResponse = await fetch(`${origin}/api/webhooks/reply`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
- body: JSON.stringify({ record }),
+ body: JSON.stringify({ record: fullRecord }),
});
if (!webhookResponse.ok) {
console.error('Webhook failed:', await webhookResponse.text());
}
- } catch (error) {
- console.error('Webhook request failed:', error);
+ } catch (err) {
+ console.error('Webhook request failed:', err);
}
return new Response(JSON.stringify({ ok: true }), { status: 200 });
diff --git a/components/organization/WelcomeDashboard.tsx b/components/organization/WelcomeDashboard.tsx
index 99ffda4..5d0bbd3 100644
--- a/components/organization/WelcomeDashboard.tsx
+++ b/components/organization/WelcomeDashboard.tsx
@@ -195,25 +195,25 @@ export function WelcomeDashboard({
-
-
+
Copy your inbound email address
-
0} />
-
+
Set up email forwarding from your support account
-
0} />
-
+
Add data sources to power AI replies
-
0} />
-
+
Send a test email and watch Answerify reply!
diff --git a/env.example b/env.example
index 179e159..66bf484 100644
--- a/env.example
+++ b/env.example
@@ -4,3 +4,11 @@ SUPABASE_SERVICE_KEY=
NEXT_PUBLIC_BASE_URL=http://localhost:3000
RESEND_API_KEY=
GEMINI_API_KEY=
+
+# Cloudflare Agents (optional)
+# Set these to route AI reply generation through the Cloudflare Worker instead
+# of the built-in Next.js webhook. The Worker uses Durable Objects for
+# stateful, timeout-resistant execution of the research + writing pipeline.
+# Deploy the worker/ directory with `wrangler deploy` to get the URL.
+CLOUDFLARE_AGENT_URL=
+CLOUDFLARE_AGENT_SECRET=
diff --git a/tsconfig.json b/tsconfig.json
index be66c31..daacfa2 100644
--- a/tsconfig.json
+++ b/tsconfig.json
@@ -1,10 +1,6 @@
{
"compilerOptions": {
- "lib": [
- "dom",
- "dom.iterable",
- "esnext"
- ],
+ "lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
@@ -22,9 +18,7 @@
}
],
"paths": {
- "@/*": [
- "./*"
- ]
+ "@/*": ["./*"]
},
"target": "ES2017"
},
@@ -35,8 +29,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
- "exclude": [
- "node_modules",
- "supabase"
- ]
+ "exclude": ["node_modules", "supabase", "worker"]
}
diff --git a/worker/package.json b/worker/package.json
new file mode 100644
index 0000000..52f9ca8
--- /dev/null
+++ b/worker/package.json
@@ -0,0 +1,21 @@
+{
+ "name": "answerify-worker",
+ "version": "0.1.0",
+ "private": true,
+ "main": "src/index.ts",
+ "scripts": {
+ "dev": "wrangler dev",
+ "deploy": "wrangler deploy",
+ "cf-typegen": "wrangler types"
+ },
+ "dependencies": {
+ "agents": "^0.6.0",
+ "common-tags": "^1.8.2",
+ "postal-mime": "^2.7.3"
+ },
+ "devDependencies": {
+ "@types/common-tags": "^1.8.4",
+ "typescript": "^5",
+ "wrangler": "^4.69.0"
+ }
+}
diff --git a/worker/src/index.ts b/worker/src/index.ts
new file mode 100644
index 0000000..f761de0
--- /dev/null
+++ b/worker/src/index.ts
@@ -0,0 +1,911 @@
+/**
+ * Answerify – Cloudflare Agents Worker
+ *
+ * This Worker integrates with Cloudflare Email Routing and the Cloudflare
+ * Agents SDK (https://agents.cloudflare.com/) to process inbound support
+ * emails end-to-end, replacing the Next.js /api/webhooks/inbound-email and
+ * /api/webhooks/reply routes.
+ *
+ * Flow:
+ * 1. Cloudflare Email Routing delivers raw emails to the `email` export.
+ * 2. A thread-aware resolver maps each message to the correct EmailReplyAgent
+ * Durable Object instance (one per thread, keyed by the Supabase thread ID).
+ * 3. EmailReplyAgent.onEmail() handles:
+ * a. Auto-reply detection (RFC 3834 / X-Auto-Response-Suppress headers)
+ * b. Raw email parsing via PostalMime
+ * c. Organization lookup by recipient address
+ * d. Thread creation/lookup in Supabase
+ * e. Email record insertion
+ * f. Research Agent – fetches datasource URLs, extracts relevant content,
+ * and summarises findings via @cf/zai-org/glm-4.7-flash
+ * g. Writing Agent – produces a polished HTML reply via @cf/zai-org/glm-4.7-flash
+ * (Cloudflare Workers AI binding – no external service needed)
+ * h. Auto-send via this.replyToEmail() (or save as draft for human review)
+ *
+ * Sending replies uses Cloudflare's native email.reply() via this.replyToEmail().
+ * This ensures the reply lands in the customer's existing email thread automatically
+ * (In-Reply-To / References headers are set correctly) without any external
+ * email-sending service.
+ *
+ * Each email thread gets its own isolated Durable Object instance so parallel
+ * threads never block each other and pipeline state survives transient errors.
+ *
+ * An optional HTTP path (onRequest) lets the Next.js dashboard manually
+ * trigger AI reply generation for a specific email ID; it always saves a draft
+ * for human review rather than auto-sending.
+ */
+
+import { Agent, routeAgentEmail, routeAgentRequest } from 'agents';
+import { isAutoReplyEmail } from 'agents/email';
+import type { AgentEmail } from 'agents/email';
+import { codeBlock } from 'common-tags';
+import PostalMime from 'postal-mime';
+
+// ---------------------------------------------------------------------------
+// Environment bindings (wrangler.toml / Cloudflare dashboard secrets)
+// ---------------------------------------------------------------------------
+
+export interface Env {
+ EMAIL_REPLY_AGENT: DurableObjectNamespace;
+ /** Cloudflare Workers AI binding – used for both the Research and Writing Agents. */
+ AI: Ai;
+ SUPABASE_URL: string;
+ SUPABASE_SERVICE_KEY: string;
+ /**
+ * Shared secret used to authenticate HTTP requests from the Next.js app to
+ * the Worker's onRequest handler.
+ */
+ INBOUND_WEBHOOK_SECRET: string;
+}
+
+// ---------------------------------------------------------------------------
+// Agent state
+// ---------------------------------------------------------------------------
+
+interface AgentState {
+ status: 'idle' | 'researching' | 'writing' | 'complete' | 'error';
+ organizationId?: string;
+ threadId?: string;
+ emailId?: string;
+ findings?: string;
+ confidence?: number;
+ citations?: string[];
+ reply?: string;
+ error?: string;
+}
+
+// ---------------------------------------------------------------------------
+// Supabase REST helpers (no SDK dependency required in Workers)
+// ---------------------------------------------------------------------------
+
+async function supabaseFetch(
+ supabaseUrl: string,
+ serviceKey: string,
+ path: string,
+ options: RequestInit = {}
+): Promise {
+ const response = await fetch(`${supabaseUrl}/rest/v1/${path}`, {
+ ...options,
+ headers: {
+ apikey: serviceKey,
+ Authorization: `Bearer ${serviceKey}`,
+ 'Content-Type': 'application/json',
+ Prefer: 'return=representation',
+ ...(options.headers ?? {}),
+ },
+ });
+
+ if (!response.ok) {
+ const body = await response.text();
+ throw new Error(
+ `Supabase ${options.method ?? 'GET'} /${path} failed (${response.status}): ${body}`
+ );
+ }
+
+ return response.json() as Promise;
+}
+
+// ---------------------------------------------------------------------------
+// URL content fetcher (Research Agent)
+//
+// Fetches datasource pages in parallel, strips HTML, and truncates to a safe
+// per-URL length so the combined context fits inside the model's token budget.
+// ---------------------------------------------------------------------------
+
+/** Maximum number of datasource URLs fetched per request. */
+const MAX_RESEARCH_URLS = 5;
+/** Maximum plain-text characters kept per fetched URL. */
+const MAX_CHARS_PER_URL = 3000;
+/**
+ * Confidence score assigned when at least one datasource URL was successfully
+ * fetched and contained relevant content. Matches the fallback value that was
+ * used with Gemini URL context when grounding metadata was unavailable.
+ */
+const URL_FETCH_SUCCESS_CONFIDENCE = 0.7;
+/** Maximum tokens the Writing Agent may generate. */
+const MAX_WRITING_TOKENS = 4096;
+
+/**
+ * Fetch a list of URLs in parallel and extract their plain-text content.
+ * Returns only the URLs that responded successfully and had non-empty content.
+ */
+async function fetchUrlsContent(
+ urls: string[]
+): Promise> {
+ const results = await Promise.allSettled(
+ urls.slice(0, MAX_RESEARCH_URLS).map(async (url) => {
+ const response = await fetch(url, {
+ headers: { 'User-Agent': 'Answerify-Support-Bot/1.0' },
+ signal: AbortSignal.timeout(5000),
+ });
+ if (!response.ok) return { url, content: '' };
+ // Use HTMLRewriter to remove