Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
83 changes: 83 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
```
53 changes: 48 additions & 5 deletions app/api/generate-reply/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,27 +7,70 @@ 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();

if (error) {
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 });
Expand Down
8 changes: 4 additions & 4 deletions components/organization/WelcomeDashboard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -195,25 +195,25 @@ export function WelcomeDashboard({
<ol className="space-y-3">
<li className="flex items-start gap-3">
<StepBadge step={1} done={!!inboundEmail} />
<span className="font-mono text-sm text-gray-300">
<span className="font-mono text-sm text-muted-foreground">
Copy your inbound email address
</span>
</li>
<li className="flex items-start gap-3">
<StepBadge step={2} done={threadsCount > 0} />
<span className="font-mono text-sm text-gray-300">
<span className="font-mono text-sm text-muted-foreground">
Set up email forwarding from your support account
</span>
</li>
<li className="flex items-start gap-3">
<StepBadge step={3} done={sources.length > 0} />
<span className="font-mono text-sm text-gray-300">
<span className="font-mono text-sm text-muted-foreground">
Add data sources to power AI replies
</span>
</li>
<li className="flex items-start gap-3">
<StepBadge step={4} done={repliesCount > 0} />
<span className="font-mono text-sm text-gray-300">
<span className="font-mono text-sm text-muted-foreground">
Send a test email and watch Answerify reply!
</span>
</li>
Expand Down
8 changes: 8 additions & 0 deletions env.example
Original file line number Diff line number Diff line change
Expand Up @@ -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=
15 changes: 3 additions & 12 deletions tsconfig.json
Original file line number Diff line number Diff line change
@@ -1,10 +1,6 @@
{
"compilerOptions": {
"lib": [
"dom",
"dom.iterable",
"esnext"
],
"lib": ["dom", "dom.iterable", "esnext"],
"allowJs": true,
"skipLibCheck": true,
"strict": true,
Expand All @@ -22,9 +18,7 @@
}
],
"paths": {
"@/*": [
"./*"
]
"@/*": ["./*"]
},
"target": "ES2017"
},
Expand All @@ -35,8 +29,5 @@
".next/types/**/*.ts",
".next/dev/types/**/*.ts"
],
"exclude": [
"node_modules",
"supabase"
]
"exclude": ["node_modules", "supabase", "worker"]
}
21 changes: 21 additions & 0 deletions worker/package.json
Original file line number Diff line number Diff line change
@@ -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"
}
}
Loading