| name | cloudflare-agentic-inbox |
| description | Deploy and manage a self-hosted email client with AI agent on Cloudflare Workers |
| triggers | ["set up agentic inbox on cloudflare","configure email routing with ai agent","deploy self-hosted email client","create cloudflare email worker","build email client with workers ai","set up durable objects for email","configure cloudflare access for inbox","troubleshoot agentic inbox deployment"] |
Cloudflare Agentic Inbox
Skill by ara.so — AI Agent Skills collection.
Agentic Inbox is a self-hosted email client with an AI agent, running entirely on Cloudflare Workers. It uses Email Routing for receiving emails, Durable Objects with SQLite for per-mailbox storage, R2 for attachments, and Workers AI with the Cloudflare Agents SDK for AI-powered email assistance.
Installation & Deployment
Quick Deploy (Recommended)
-
Deploy via button (provisions R2, Durable Objects, Workers AI automatically):
-
Configure Cloudflare Access (required for production):
- Navigate to Worker Settings → Domains & Routes
- Enable one-click Cloudflare Access
- Note the
POLICY_AUD and TEAM_DOMAIN values
- Set as Worker secrets:
wrangler secret put POLICY_AUD
wrangler secret put TEAM_DOMAIN
-
Set up Email Routing:
- Go to your domain in Cloudflare dashboard
- Navigate to Email Routing
- Create a catch-all rule forwarding to this Worker
-
Enable Email Service:
- Add
send_email binding to wrangler.jsonc:
{
"send_email": [
{
"name": "SEB",
"destination_address": "you@example.com"
}
]
}
Manual Setup
git clone https://github.com/cloudflare/agentic-inbox.git
cd agentic-inbox
npm install
wrangler r2 bucket create agentic-inbox
npm run deploy
Configuration
wrangler.jsonc Structure
{
"name": "agentic-inbox",
"main": "worker/index.ts",
"compatibility_date": "2025-01-01",
"compatibility_flags": ["nodejs_compat"],
"vars": {
"DOMAINS": "yourdomain.com"
},
"durable_objects": {
"bindings": [
{
"name": "MAILBOX",
"class_name": "MailboxDurableObject",
"script_name": "agentic-inbox"
},
{
"name": "EMAIL_AGENT",
"class_name": "EmailAgentDurableObject",
Environment Variables (Secrets)
wrangler secret put POLICY_AUD
wrangler secret put TEAM_DOMAIN
Development
Local Development
npm run dev
Project Structure
agentic-inbox/
├── app/ # React frontend
│ ├── routes/ # React Router v7 routes
│ ├── components/ # UI components
│ └── lib/ # Utilities, stores (Zustand)
├── worker/ # Cloudflare Worker backend
│ ├── index.ts # Hono router, email handler
│ ├── mailbox-do.ts # Mailbox Durable Object
│ ├── email-agent-do.ts # AI Agent Durable Object
│ └── auth.ts # Access JWT validation
└── wrangler.jsonc # Cloudflare configuration
Key API Patterns
Creating a Mailbox
const response = await fetch('/api/mailboxes', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
address: 'hello@yourdomain.com'
})
});
const mailbox = await response.json();
Sending Email
const response = await fetch(`/api/mailboxes/${mailboxId}/send`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
to: ['recipient@example.com'],
subject: 'Hello',
body: '<p>Email content</p>',
cc: [],
bcc: [],
inReplyTo: null,
references: []
})
});
Accessing AI Agent
const ws = new WebSocket(`wss://yourapp.workers.dev/agents/${mailboxId}`);
ws.onopen = () => {
ws.send(JSON.stringify({
type: 'message',
content: 'Summarize my unread emails'
}));
};
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
};
Durable Object Implementation
Mailbox Durable Object
import { DurableObject } from 'cloudflare:workers';
export class MailboxDurableObject extends DurableObject {
async fetch(request: Request) {
const url = new URL(request.url);
if (url.pathname === '/emails' && request.method === 'GET') {
const stmt = this.ctx.storage.sql.exec(
'SELECT * FROM emails ORDER BY receivedAt DESC LIMIT 50'
);
return Response.json(stmt.toArray());
}
if (url.pathname === '/emails' && request.method === 'POST') {
const email = await request.json();
const result = this.ctx.storage.sql.exec(
`INSERT INTO emails (id, subject, from_address, to_address, body, receivedAt)
VALUES (?, ?, ?, ?, ?, ?)`,
email., email., email., email., email., .()
);
.({ : });
}
(, { : });
}
}
Email Agent Durable Object
import { AIChatAgent } from '@cloudflare/agents-sdk';
import { DurableObject } from 'cloudflare:workers';
export class EmailAgentDurableObject extends DurableObject {
private agent?: AIChatAgent;
async fetch(request: Request) {
if (!this.agent) {
this.agent = new AIChatAgent({
model: '@cf/moonshotai/kimi-k2.5',
binding: this.env.AI,
tools: [
{
name: 'read_inbox',
description: 'Read emails from the inbox',
parameters: {
type: 'object',
properties: {
limit: { type: 'number', default: 10 }
}
},
handler: async ({ limit }) => {
mailboxId = ...();
emails = .(mailboxId, limit);
{ emails };
}
},
{
: ,
: ,
: {
: ,
: {
: { : , : { : } },
: { : },
: { : }
},
: [, , ]
},
: ({ to, subject, body }) => {
...({
: .(),
to,
subject,
: [{ : , : body }]
});
{ : };
}
}
],
:
});
}
upgradeHeader = request..();
(upgradeHeader === ) {
[client, server] = .( ());
..(server);
(, { : , : client });
}
(, { : });
}
() {
{ content } = .(message);
( chunk ..(content)) {
ws.(.(chunk));
}
}
}
Email Routing Handler
import { EmailMessage } from 'cloudflare:email';
export default {
async email(message: EmailMessage, env: Env) {
const to = message.to;
const mailboxId = await env.KV.get(`address:${to}`);
if (!mailboxId) {
message.setReject('Mailbox not found');
return;
}
const id = env.MAILBOX.idFromString(mailboxId);
const stub = env.MAILBOX.get(id);
const emailData = {
id: crypto.randomUUID(),
from: message.from,
to: message.to,
subject: message.headers.get('subject'),
body: await message.text(),
receivedAt: Date.now()
};
await stub.(, {
: ,
: .(emailData)
});
}
};
Common Patterns
Access Authentication Middleware
import * as jose from 'jose';
export async function validateAccessToken(request: Request, env: Env) {
if (!env.POLICY_AUD || !env.TEAM_DOMAIN) {
throw new Error('Cloudflare Access must be configured in production');
}
const token = request.headers.get('Cf-Access-Jwt-Assertion');
if (!token) {
throw new Error('Missing Access token');
}
const certsUrl = env.TEAM_DOMAIN.includes('/cdn-cgi/access/certs')
? env.TEAM_DOMAIN
: `https://${env.TEAM_DOMAIN}/cdn-cgi/access/certs`;
const jwks = jose.createRemoteJWKSet(new URL(certsUrl));
const { payload } = await jose.jwtVerify(token, jwks, {
audience: env.POLICY_AUD,
issuer: env.TEAM_DOMAIN
});
return payload;
}
Agent System Prompt Customization
const response = await fetch(`/api/mailboxes/${mailboxId}/agent/system-prompt`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
systemPrompt: `You are a professional email assistant for sales@company.com.
Always be polite and concise. When drafting replies, maintain a friendly tone.`
})
});
Attachment Storage in R2
async function storeAttachment(env: Env, emailId: string, file: File) {
const key = `attachments/${emailId}/${file.name}`;
await env.R2.put(key, file.stream(), {
httpMetadata: {
contentType: file.type
}
});
return key;
}
async function getAttachment(env: Env, key: string) {
const object = await env.R2.get(key);
if (!object) return null;
return new Response(object.body, {
headers: {
'Content-Type': object.httpMetadata?.contentType || 'application/octet-stream'
}
});
}
Troubleshooting
Invalid or Expired Access Token
Issue: Invalid or expired Access token error when accessing deployed app.
Solution:
wrangler secret put POLICY_AUD
wrangler secret put TEAM_DOMAIN
npm run deploy
Emails Not Arriving
Issue: Catch-all rule configured but emails not appearing in inbox.
Checklist:
- Verify Email Routing is enabled for your domain
- Check catch-all rule forwards to the Worker (not an email address)
- Confirm mailbox exists for the recipient address:
curl https://yourapp.workers.dev/api/mailboxes
- Check Worker logs:
wrangler tail
Agent Not Responding
Issue: WebSocket connection fails or agent doesn't respond.
Solution:
{
"ai": {
"binding": "AI"
}
}
const ws = new WebSocket(`wss://yourapp.workers.dev/agents/${mailboxId}`);
ws.onerror = (error) => console.error('WebSocket error:', error);
ws.onclose = (event) => console.log('Closed:', event.code, event.reason);
R2 Bucket Not Found
Issue: Attachment upload fails with R2 error.
Solution:
wrangler r2 bucket create agentic-inbox
{
"r2_buckets": [
{
"binding": "R2",
"bucket_name": "agentic-inbox"
}
]
}
npm run deploy
Send Email Not Working
Issue: send_email binding not available or emails not sending.
Solution:
- Enable Email Service in Cloudflare dashboard for your account
- Add binding to
wrangler.jsonc:
{
"send_email": [
{
"name": "SEB",
"destination_address": "verified@yourdomain.com"
}
]
}
- Verify domain ownership for sending
- Redeploy Worker
Local Development Access Errors
Issue: Access errors when running npm run dev.
Note: Cloudflare Access is intentionally disabled in local development. If you see Access-related errors in production mode locally, set:
if (env.ENVIRONMENT === 'development') {
return { email: 'dev@localhost' };
}
CLI Commands
npm run dev
npm run build
npm run deploy
wrangler tail
wrangler secret put KEY
wrangler r2 bucket list
wrangler d1 execute DB --command "SELECT * FROM emails"
wrangler dev --remote
wrangler kv:key list --binding=KV
MCP Server Integration
Agentic Inbox includes an MCP server at /mcp for external AI tools (Claude Code, Cursor):
{
"mcpServers": {
"agentic-inbox": {
"url": "https://yourapp.workers.dev/mcp",
"headers": {
"Cf-Access-Client-Id": "YOUR_SERVICE_TOKEN_ID",
"Cf-Access-Client-Secret": "YOUR_SERVICE_TOKEN_SECRET"
}
}
}
}
MCP tools have access to all mailboxes by passing mailboxId parameter. Security relies on the Cloudflare Access policy.