Agent Email Inbox
ResendOfficialCreated by resend
- Skill
- Tool integration
- Automation and integrations
- MIT
- Medium risk
AutoGineer summary
Written by AutoGineer from the skill’s own files, not by the author
This skill provides a robust architecture for integrating email communication into AI agents. It leverages webhooks for real-time, event-driven processing, eliminating the need for inefficient polling. The core focus is on security, offering multiple levels of validation—from strict sender allowlists to content filtering—to protect agents from untrusted or malicious inbound email content. It includes patterns for thread management, secure API key handling, and domain configuration.
What it does
- Sets up real-time email reception using webhooks for instant agent triggering.
- Implements multi-level security patterns to validate senders and filter content.
- Provides thread management to ensure agent replies stay within the original conversation.
- Offers guidance on secure API key management and environment configuration.
- Includes utilities for stripping quoted reply chains to prevent prompt injection.
- Supports both dedicated inbox setups and direct email receiving for flexible deployment.
When to use it
- When building automated support handlers or email-to-task pipelines.
- When an AI agent needs to receive and respond to emails programmatically.
- When processing untrusted inbound email that could potentially control system logic.
- When you need to maintain conversation context (threading) in agent-human email exchanges.
Advantages
- Real-time responsiveness via event-driven webhook architecture.
- High security posture through configurable validation levels.
- Reduced infrastructure overhead by avoiding email polling.
- Built-in protection against common email-based attack vectors like prompt injection.
- Seamless integration with existing email threading protocols.
Usage examples
- “Set up an email inbox for my support agent that only accepts emails from my company domain.”
- The agent will implement a Level 2 Domain Allowlist, configuring the webhook to reject any incoming email not originating from the specified domain.
- “How do I make sure my agent doesn't get tricked by malicious instructions hidden in email replies?”
- The agent will apply Level 3 Content Filtering, utilizing the provided utility to strip quoted reply threads before passing the email body to the AI model.
Install
Download the skill as a ZIP:
https://mp-staging.autogineer.com/agent-skills/resend/agent-email-inbox/downloadUnzip it into the cross-client skills folder of your project:
mkdir -p .agents/skills && unzip agent-email-inbox.zip -d .agents/skills
Documentation by resend
AI Agent Email Inbox
Overview
This skill covers setting up a secure email inbox that allows your application or AI agent to receive and respond to emails, with content safety measures in place.
Core principle: An AI agent's inbox receives untrusted input. Security configuration is important to handle this safely.
The skill builds the agent on a Resend Inbox. An inbox groups email into threads, and your agent replies inside the thread with one call. Resend sets the recipients and the threading headers. If the user has no custom domain, or their plan has no inbox available, use Receiving without an inbox instead. The security levels are the same for both.
Why Webhook-Based Receiving?
Resend uses webhooks for inbound email, meaning your agent is notified instantly when an email arrives. This is valuable for agents because:
Real-time responsiveness — React to emails within seconds, not minutes
No polling overhead — No cron jobs checking "any new mail?" repeatedly
Event-driven architecture — Your agent only wakes up when there's actually something to process
Lower API costs — No wasted calls checking empty inboxes
Architecture
Sender → Email → Resend Inbox → inbox.email.received webhook → Your Server → AI Agent
↓
Security Validation
↓
Process or Reject → Reply in the threadSDK Version Requirements
The inbox flow needs the Node.js SDK resend >= 6.34.1, which has resend.inboxes.* and the inbox.* webhook event types. Always install the latest SDK version. If the project already has a Resend SDK installed, check the version and upgrade if needed.
The Python SDK resend >= 2.50.0 also has the inbox methods. For the other languages, call the Inboxes API over HTTP until their SDK has inbox methods, or use Receiving without an inbox.
The table below lists the minimums for the Receiving flow: webhook verification (webhooks.verify()) and email receiving (emails.receiving.get()). The resend skill lists higher minimums because it also covers newer features such as domain claims (domains.claims.*). If both skills are loaded, use the higher version.
| Language | Package | Min Version |
|---|---|---|
| Node.js | resend | >= 6.9.2 |
| Python | resend | >= 2.21.0 |
| Go | resend-go/v4 | >= 4.0.0 |
| Ruby | resend | >= 1.0.0 |
| PHP | resend/resend-php | >= 1.1.0 |
| Rust | resend-rs | >= 0.20.0 |
| Java | resend-java | >= 4.11.0 |
| .NET | Resend | >= 0.2.1 |
Install the resend npm package: npm install resend (or the equivalent for your language). For full sending docs, install the resend skill.
Quick Start
Ask the user for their email address. You need a real email address to send test emails to. Ask the user and wait for their response before proceeding.
Choose your security level. Decide how to validate incoming emails before any are processed
Set up the domain. Verify the user's custom domain for sending, and for receiving unless the inbox uses forwarding (see Domain Setup section)
Create the inbox. Create the agent's address with
resend.inboxes.create()(see Create the Inbox section)Create webhook endpoint. Handle
inbox.email.receivedevents with security built in from the start. The webhook endpoint MUST be a POST route.Set up tunneling (local dev). Use Tailscale Funnel (recommended) or ngrok. See references/webhook-setup.md
Create webhook via API. Use the Resend Webhook API to register your endpoint programmatically. See references/webhook-setup.md
Connect to agent. Pass validated emails to your AI agent, and reply in the thread
Before You Start: Account & API Key Setup
First Question: New or Existing Resend Account?
Ask your human:
New account just for the agent? → Simpler setup, full account access is fine
Existing account with other projects? → The agent needs a full access key, which reaches every project. Prefer a new account for the agent (see API Key Permissions)
Creating API Keys Securely
Don't paste API keys in chat! They'll be in conversation history forever.
Safer options:
Environment file method: Human creates
.envfile directly:echo "RESEND_API_KEY=re_xxx" >> .envPassword manager / secrets manager: Human stores key in 1Password, Vault, etc.
If key must be shared in chat: Human should rotate the key immediately after setup
API Key Permissions
The agent's server needs a full access API key. A "Sending access" key can only reply, forward, create drafts and send drafts. Reading inbox emails or registering webhooks with it returns 401 restricted_api_key. A sending access key limited to one domain returns 403 on an inbox of another domain.
If your human has an existing Resend account with other projects, keep the risk contained:
Use a dedicated account for the agent when possible, so a leaked key cannot reach other projects
Keep the key on the server only. Never pass it to the AI model or put it in a prompt
Rotate the key if it was ever pasted in chat or committed
Domain Setup
An inbox address lives on a custom domain the user has added to Resend. The domain must be verified for sending (DKIM and SPF). To receive email on it, either enable receiving on the domain, or create the inbox with forwarding: true.
Use a subdomain (e.g., agent.example.com) to avoid disrupting existing email services.
Option 1: Receiving on the Domain (Recommended)
The user must enable receiving in the Resend dashboard: Domains page → toggle on "Enable Receiving".
Then add an MX record:
| Setting | Value |
|---|---|
| Type | MX |
| Host | Your domain or subdomain (e.g., agent.example.com) |
| Value | Provided in Resend dashboard |
| Priority | 10 (must be lowest number to take precedence) |
Tip: Verify DNS propagation at dns.email.
DNS Propagation: MX record changes can take up to 48 hours to propagate globally, though often complete within a few hours.
Option 2: Forwarding
Create the inbox with forwarding: true when the address already receives mail elsewhere (for example, Google Workspace) and the user cannot change its MX record. Resend gives the inbox a receiving address. The user sets up forwarding from their mailbox to it. Read the address from receiving_address with resend.inboxes.get().
Create the Inbox
Create one inbox for the agent's address:
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.inboxes.create({
emailAddress: '[email protected]',
name: 'AI Agent',
fromName: 'Acme Agent',
});
if (error) throw new Error(`Failed to create inbox: ${error.message}`);
console.log('Inbox created:', data.id);fromName is the name recipients see on the agent's replies. The part before the @ can contain only letters, numbers, ., _, % and -, so agent+test@ returns a 422. A 403 error means the plan has no inbox left; ask the user to upgrade or use Receiving without an inbox.
Security Levels
Choose your security level before setting up the webhook endpoint. An AI agent that processes emails without security is dangerous — anyone can email instructions that your agent will execute. The webhook code you write next should include your chosen security level from the start.
Ask the user what level of security they want, and ensure that they understand what each level means.
| Level | Name | When to Use | Trade-off |
|---|---|---|---|
| 1 | Strict Allowlist | Most use cases — known, fixed set of senders | Maximum security, limited functionality |
| 2 | Domain Allowlist | Organization-wide access from trusted domains | More flexible, anyone at domain can interact |
| 3 | Content Filtering | Accept from anyone, filter unsafe patterns | Can receive from anyone, pattern matching not foolproof |
| 4 | Sandboxed Processing | Process all emails with restricted agent capabilities | Maximum flexibility, complex to implement |
| 5 | Human-in-the-Loop | Require human approval for untrusted actions | Maximum security, adds latency |
For detailed implementation code for each level, see references/security-levels.md.
Level 1: Strict Allowlist (Recommended)
Only process emails from explicitly approved addresses. Reject everything else.
import type { GetInboxThreadEmailResponseSuccess as InboxEmail } from 'resend';
const ALLOWED_SENDERS = [
'[email protected]',
'[email protected]',
];
// `from` is either `address` or `Name <address>`. Compare the address only:
// a substring check would accept a display name such as "[email protected]".
function senderAddress(from: string): string {
const match = from.match(/<([^<>]+)>\s*$/);
return (match ? match[1] : from).trim().toLowerCase();
}
function isAllowedSender(from: string): boolean {
const sender = senderAddress(from);
return ALLOWED_SENDERS.some(allowed => sender === allowed.toLowerCase());
}
// Identifies the email, so the agent can reply in its thread
type ThreadRef = { inboxId: string; threadId: string; emailId: string };
async function processEmailForAgent(email: InboxEmail, thread: ThreadRef) {
if (!isAllowedSender(email.from)) {
console.log(`Rejected email from unauthorized sender: ${senderAddress(email.from)}`);
await notifyOwnerOfRejectedEmail(email);
return;
}
await agent.processEmail({
from: email.from,
subject: email.subject,
body: email.text || email.html,
thread,
});
}InboxEmail is the email returned by resend.inboxes.threads.emails.get(). The webhook payload carries the same fields without html and text. agent and notifyOwnerOfRejectedEmail stand for your own code.
Security Best Practices
Always Do
| Practice | Why |
|---|---|
| Verify webhook signatures | Prevents spoofed webhook events |
| Log all rejected emails | Audit trail for security review |
| Use allowlists where possible | Explicit trust is safer than filtering |
| Rate limit email processing | Prevents excessive processing load |
| Separate trusted/untrusted handling | Different risk levels need different treatment |
Never Do
| Anti-Pattern | Risk |
|---|---|
| Process emails without validation | Anyone can control your agent |
| Trust email headers for authentication | Headers are trivially spoofed |
| Execute code from email content | Untrusted input should never run as code |
| Store email content in prompts verbatim | Untrusted input mixed into prompts can alter agent behavior |
| Give untrusted emails full agent access | Scope capabilities to the minimum needed |
Webhook Endpoint
After choosing your security level and setting up your domain, create a webhook endpoint. The webhook endpoint MUST be a POST route. Resend sends all webhook events as POST requests.
Critical: Use raw body for verification. Webhook signature verification requires the raw request body.
Next.js App Router: Use
req.text()(notreq.json())Express: Use
express.raw({ type: 'application/json' })on the webhook route
Next.js App Router
// app/webhook/route.ts
import { Resend } from 'resend';
import { NextRequest, NextResponse } from 'next/server';
const resend = new Resend(process.env.RESEND_API_KEY);
export async function POST(req: NextRequest) {
const payload = await req.text();
let event;
try {
event = resend.webhooks.verify({
payload,
headers: {
id: req.headers.get('svix-id') ?? '',
timestamp: req.headers.get('svix-timestamp') ?? '',
signature: req.headers.get('svix-signature') ?? '',
},
webhookSecret: process.env.RESEND_WEBHOOK_SECRET!,
});
} catch {
return new NextResponse('Invalid signature', { status: 400 });
}
if (event.type === 'inbox.email.received') {
const { inbox_id, thread_id, email_id } = event.data;
// The webhook payload has no body. Fetch the email to read it.
const { data: email, error } = await resend.inboxes.threads.emails.get({
inboxId: inbox_id,
threadId: thread_id,
emailId: email_id,
});
if (error) {
// A non-2xx response makes Resend retry the webhook later
return new NextResponse('Error', { status: 500 });
}
// Apply the security level chosen above
await processEmailForAgent(email, {
inboxId: inbox_id,
threadId: thread_id,
emailId: email_id,
});
}
return new NextResponse('OK', { status: 200 });
}Express
import express from 'express';
import { Resend } from 'resend';
const app = express();
const resend = new Resend(process.env.RESEND_API_KEY);
const ALLOWED_SENDERS = (process.env.ALLOWED_SENDERS || '')
.split(',')
.map((sender) => sender.trim().toLowerCase())
.filter(Boolean);
const senderAddress = (from) => {
const match = from.match(/<([^<>]+)>\s*$/);
return (match ? match[1] : from).trim().toLowerCase();
};
const isAllowedSender = (from) => ALLOWED_SENDERS.includes(senderAddress(from));
app.post('/webhook', express.raw({ type: 'application/json' }), async (req, res) => {
let event;
try {
event = resend.webhooks.verify({
payload: req.body.toString(),
headers: {
id: req.headers['svix-id'],
timestamp: req.headers['svix-timestamp'],
signature: req.headers['svix-signature'],
},
webhookSecret: process.env.RESEND_WEBHOOK_SECRET,
});
} catch {
res.status(400).send('Invalid signature');
return;
}
if (event.type === 'inbox.email.received') {
const { inbox_id, thread_id, email_id, email } = event.data;
// The payload already has the sender, so reject before fetching the body
if (!email || !isAllowedSender(email.from)) {
console.log(`Rejected email from unauthorized sender: ${email ? senderAddress(email.from) : 'unknown'}`);
res.status(200).send('OK'); // Return 200 even for rejected emails
return;
}
const { data: fullEmail, error } = await resend.inboxes.threads.emails.get({
inboxId: inbox_id,
threadId: thread_id,
emailId: email_id,
});
if (error) {
res.status(500).send('Error'); // Resend retries the webhook later
return;
}
await processEmailForAgent(fullEmail, { inboxId: inbox_id, threadId: thread_id, emailId: email_id });
}
res.status(200).send('OK');
});
app.get('/', (req, res) => res.send('Agent Email Inbox - Ready'));
app.listen(3000, () => console.log('Webhook server running on :3000'));Resend signs inbox webhooks with the same svix-* headers as every other webhook. inbox.email.received fires for email the inbox receives. The agent's own replies fire inbox.email.sent instead, so subscribe to inbox.email.received only.
For webhook registration via API, tunneling setup, svix fallback, and retry behavior, see references/webhook-setup.md.
Replying from Your Agent
Reply inside the thread. Resend addresses the reply and sets the In-Reply-To and References headers, so the sender's mail client shows it in the same conversation:
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
async function replyInThread(
thread: { inboxId: string; threadId: string; emailId: string },
body: string,
) {
const { data, error } = await resend.inboxes.threads.emails.reply({
inboxId: thread.inboxId,
threadId: thread.threadId,
emailId: thread.emailId,
text: body,
});
if (error) throw new Error(`Failed to reply: ${error.message}`);
return data.id;
}The reply goes to the email's Reply-To addresses, or to its sender when it has none. A sender on your allowlist can set Reply-To to any address, so check email.reply_to against the allowlist too before the agent replies with anything sensitive. Leave replyAll off unless the agent should copy every recipient of the original email.
To start a new conversation, send from the inbox address with resend.emails.send(). For full sending docs, install the resend skill.
Receiving without an Inbox
Use Resend Receiving directly when the user has no custom domain, or their plan has no inbox available. The security levels above apply unchanged. What differs:
Address: use the auto-generated
<anything>@<your-id>.resend.appaddress (Dashboard → Emails → Receiving → "Receiving address"), or a custom domain with receiving enabled. No inbox is created.Webhook event: subscribe to
email.receivedinstead ofinbox.email.received. The payload hasemail_id,from,toandsubject, but no body.Reading the email: fetch it with
resend.emails.receiving.get(emailId).Replying: send with
resend.emails.send()and set theIn-Reply-Toheader yourself. There are no threads.
if (event.type === 'email.received') {
if (!isAllowedSender(event.data.from)) {
return new NextResponse('OK', { status: 200 });
}
const { data: email, error } = await resend.emails.receiving.get(event.data.email_id);
if (error) {
return new NextResponse('Error', { status: 500 });
}
await agent.processEmail({ from: email.from, subject: email.subject, body: email.text || email.html });
}The webhook verification is the same as in the inbox examples.
Environment Variables
# Required
RESEND_API_KEY=re_xxxxxxxxx
RESEND_WEBHOOK_SECRET=whsec_xxxxxxxxx
# Security Configuration
SECURITY_LEVEL=strict # strict | domain | filtered | sandboxed
[email protected],[email protected]
ALLOWED_DOMAINS=example.com
[email protected] # For security notificationsCommon Mistakes
| Mistake | Fix |
|---|---|
| No sender verification | Always validate who sent the email before processing |
| Trusting email headers | Use webhook verification, not email headers for auth |
| Same treatment for all emails | Differentiate trusted vs untrusted senders |
| Verbose error messages | Keep error responses generic to avoid leaking internal logic |
| No rate limiting | Implement per-sender rate limits. See references/advanced-patterns.md |
| Processing HTML directly | Strip HTML or use text-only to reduce complexity and risk |
| No logging of rejections | Log all security events for audit |
| Using ephemeral tunnel URLs | Use persistent URLs (Tailscale Funnel, paid ngrok) or deploy to production |
Using express.json() on webhook route | Use express.raw({ type: 'application/json' }) — JSON parsing breaks signature verification |
| Returning non-200 for rejected emails | Always return 200 to acknowledge receipt — otherwise Resend retries |
| Old Resend SDK version | resend.inboxes.* needs resend >= 6.34.1. emails.receiving.get() and webhooks.verify() also need recent versions. See SDK Version Requirements |
Comparing from as a whole string | from can be Name <address>. Extract the address before you compare it with the allowlist |
Replying to Reply-To without checking it | A trusted sender can point Reply-To anywhere. Check it against the allowlist before a sensitive reply |
| Using a "Sending access" API key | It can only reply, forward, create drafts and send drafts. Inbox reads and webhook setup need a full access key |
Passing secret or svix-* keys to webhooks.verify() | Pass webhookSecret, and headers as { id, timestamp, signature } |
Testing
Use Resend's test addresses for development:
[email protected]— Simulates successful delivery[email protected]— Simulates hard bounce
For security testing, send test emails from non-allowlisted addresses to verify rejection works correctly.
Quick verification checklist:
Server is running:
curl http://localhost:3000should return a responseTunnel is working:
curl https://<your-tunnel-url>should return the same responseWebhook is active: Check status in Resend dashboard → Webhooks
Send a test email from an allowlisted address and check server logs
Related Skills
For full sending, receiving and inbox docs, install the
resendskill
Files
- ATTRIBUTION.mdAdded by AutoGineer895 B
- LICENSELicence file added by AutoGineer1.0 KiB
- SKILL.md22.8 KiB
- references/advanced-patterns.md4.1 KiB
- references/security-levels.md11.3 KiB
- references/webhook-setup.md9.7 KiB
references/webhook-setup.md
Webhook Setup — Tunneling, Registration, and Local Dev
Table of Contents
Register Webhook via the API
Webhook Signing Secret and Verification
Webhook Retry Behavior
Local Development with Tunneling
Webhook Path
Production Deployment
Clawdbot Integration
Register Webhook via the API
Prefer the Resend Webhook API to create webhooks programmatically instead of asking users to do it manually in the dashboard. This is faster, less error-prone, and gives you the signing secret directly in the response.
The API endpoint is POST https://api.resend.com/webhooks. You need:
endpoint(string, required): Your full public webhook URL (e.g.,https://<your-tunnel-domain>/webhook)events(string[], required): Event types to subscribe to. For an agent inbox, use["inbox.email.received"]. For Receiving without an inbox, use["email.received"]
The response includes a signing_secret (format: whsec_xxxxxxxxxx) — store this immediately as RESEND_WEBHOOK_SECRET. This is the only time you'll see it in the response.
Node.js
import { Resend } from 'resend';
const resend = new Resend(process.env.RESEND_API_KEY);
const { data, error } = await resend.webhooks.create({
endpoint: 'https://<your-tunnel-domain>/webhook',
events: ['inbox.email.received'],
});
if (error) {
console.error('Failed to create webhook:', error);
throw error;
}
// IMPORTANT: Store the signing secret — you need it to verify incoming webhooks
// Write it directly to .env, never log it
console.log('Webhook created:', data.id);Python
import resend
resend.api_key = 're_xxxxxxxxx'
webhook = resend.Webhooks.create(params={
"endpoint": "https://<your-tunnel-domain>/webhook",
"events": ["inbox.email.received"],
})
print(f"Webhook created: {webhook['id']}")cURL
curl -X POST 'https://api.resend.com/webhooks' \
-H 'Authorization: Bearer re_xxxxxxxxx' \
-H 'Content-Type: application/json' \
-d '{
"endpoint": "https://<your-tunnel-domain>/webhook",
"events": ["inbox.email.received"]
}'
# Response:
# {
# "object": "webhook",
# "id": "4dd369bc-aa82-4ff3-97de-514ae3000ee0",
# "signing_secret": "whsec_xxxxxxxxxx"
# }Other SDKs
The webhook creation API is available in all Resend SDKs: Go, Ruby, PHP, Rust, Java, and .NET. The pattern is the same — pass endpoint and events, and read signing_secret from the response.
Webhook Signing Secret and Verification
The signing_secret returned when you create a webhook is used to verify that incoming webhook requests actually came from Resend. You must verify every webhook request.
Every webhook request includes three headers:
| Header | Purpose |
|---|---|
svix-id | Unique message identifier |
svix-timestamp | Unix timestamp when the webhook was sent |
svix-signature | Cryptographic signature for verification |
Use resend.webhooks.verify() to validate these headers against the raw request body. The verification is sensitive to the exact bytes — if your framework parses and re-stringifies the JSON before you verify, the signature check will fail.
Webhook Verification Fallback (Svix)
If you're using an older Resend SDK that doesn't have resend.webhooks.verify(), verify signatures directly with the svix package:
npm install sviximport { Webhook } from 'svix';
const wh = new Webhook(process.env.RESEND_WEBHOOK_SECRET);
const event = wh.verify(payload, {
'svix-id': req.headers['svix-id'],
'svix-timestamp': req.headers['svix-timestamp'],
'svix-signature': req.headers['svix-signature'],
});Webhook Retry Behavior
Resend automatically retries failed webhook deliveries with exponential backoff:
| Attempt | Delay |
|---|---|
| 1 | Immediate |
| 2 | 5 seconds |
| 3 | 5 minutes |
| 4 | 30 minutes |
| 5 | 2 hours |
| 6 | 5 hours |
| 7 | 10 hours |
Your endpoint must return 2xx status to acknowledge receipt
If an endpoint is removed or disabled, retry attempts stop automatically
Failed deliveries are visible in the Webhooks dashboard, where you can also manually replay events
Emails are stored even if webhooks fail — you won't lose messages
Local Development with Tunneling
Your local server isn't accessible from the internet. Use tunneling to expose it for webhook delivery.
Critical: Persistent URLs Required
Webhook URLs are registered with Resend via the API. If your tunnel URL changes (e.g., ngrok restart on the free tier), you must delete and recreate the webhook registration. For development, this is manageable. For anything persistent, you need either:
A permanent tunnel with stable URLs (Tailscale Funnel, paid ngrok, Cloudflare named tunnels)
Production deployment to a real server
Tailscale Funnel (Recommended)
Tailscale Funnel is the best solution for webhook development and persistent agent setups. It provides a permanent, stable HTTPS URL with valid certificates — completely free, with no timeouts or session limits.
Why Tailscale Funnel is better than ngrok for webhooks:
Permanent URL — Never changes, even across restarts
No timeouts — Free tier has no 8-hour session limits
Auto-reconnects — Survives machine reboots via systemd service
Valid HTTPS certificates — Automatic, trusted TLS certificates
Free forever — No paid tier required
Quick setup:
# 1. Install Tailscale (one-time)
curl -fsSL https://tailscale.com/install.sh | sh
# 2. Authenticate (one-time - opens browser)
sudo tailscale up
# 3. Enable Funnel (one-time approval in browser)
sudo tailscale funnel 3000
# Done! Your permanent URL:
# https://<machine-name>.tail<hash>.ts.netRunning in background:
# Tailscale Funnel runs as a systemd service automatically
# It will survive reboots and reconnect automatically
# Check status:
sudo tailscale funnel status
# Stop (if needed):
sudo tailscale funnel offYour webhook URL format:
https://<machine-name>.tail<hash>.ts.net/webhookngrok (Alternative)
Free tier limitations:
URLs are random and change on every restart
Must delete and recreate the webhook via the API after each restart
Fine for initial testing, painful for ongoing development
Paid tier ($8/mo Personal plan):
Static subdomain that persists across restarts
Recommended if using ngrok long-term
# Install
brew install ngrok # macOS
# Authenticate (free account required)
ngrok config add-authtoken <your-token>
# Start tunnel (free - random URL)
ngrok http 3000
# Start tunnel (paid - static subdomain)
ngrok http --domain=myagent.ngrok.io 3000Cloudflare Tunnel (Alternative)
Named tunnel (persistent — recommended):
# Install
brew install cloudflared # macOS
# One-time setup
cloudflared tunnel login
cloudflared tunnel create my-agent-webhook
# Create config file ~/.cloudflared/config.yml
# Run tunnel
cloudflared tunnel run my-agent-webhookNow https://webhook.example.com always points to your local machine.
Pros: Free, persistent URLs, uses your own domain Cons: Requires owning a domain on Cloudflare, more setup
VS Code Port Forwarding (Alternative)
Good for quick testing during development sessions.
Open Ports panel (View → Ports)
Click "Forward a Port"
Enter 3000 (or your port)
Set visibility to "Public"
Use the forwarded URL
Note: URL changes each VS Code session. Not suitable for persistent webhooks.
localtunnel (Alternative)
Simple but ephemeral.
npx localtunnel --port 3000Note: URLs change on restart. Same limitations as free ngrok.
Webhook Path
Pick a webhook path and commit to it. This exact path will be registered with Resend, and if you change it later, webhooks will 404 silently.
Keep your webhook route path stable after registering it with Resend. If you change
/webhookto/webhook/email, Resend will keep sending to the old path and every delivery will 404. If you need to change the path, update or recreate the webhook registration via the API.
Recommended path: /webhook
Production Deployment
For a reliable agent inbox, deploy your webhook endpoint to production infrastructure instead of relying on tunnels.
Recommended Approaches
Option A: Deploy webhook handler to serverless
Vercel, Netlify, or Cloudflare Workers
Zero server management, automatic HTTPS
Free tiers available for low volume
Option B: Deploy to a VPS/cloud instance
Your webhook handler runs alongside your agent
Use nginx/caddy for HTTPS termination
Option C: Use your agent's existing infrastructure
If your agent already runs on a server with a public IP
Add webhook route to existing web server
Example: Deploying to Vercel
vercel deploy --prod
# Your webhook URL becomes:
# https://your-project.vercel.app/webhookClawdbot Integration
Webhook Gateway (Recommended)
The best way to connect email to Clawdbot is via the webhook gateway:
async function processWithAgent(email: ProcessedEmail) {
const message = `
New Email
From: ${email.from}
Subject: ${email.subject}
${email.body}
`.trim();
await sendToClawdbot(message);
}Alternative: Polling
Clawdbot can poll the Resend API for new emails during heartbeats. This is simpler to set up but does not take advantage of real-time delivery.
Alternative: External Channel Plugin
For deep integration, implement Clawdbot's external channel plugin interface to treat email as a first-class channel.
Requirements and compatibility
Works with: Claude Code, Claude apps, OpenAI Codex, GitHub Copilot, Cursor, Gemini CLI, Kiro, OpenCode, Goose, Amp, Roo Code, JetBrains Junie, Factory Droid, Devin (ex-Windsurf), Mistral Vibe, OpenHands, Letta
“Works with” means the tool reads the open Agent Skills format; a skill written for one tool may use features others lack.
Needs: Needs network access, Needs an API key, Needs Node.js
Security
What our scanner noticed· Medium risk
- Possible data-exfiltration endpoints
- Instructions that use the network
- Downloads piped into a shell
A static scan is not a guarantee. Skills are third-party content: review a skill before you let an agent use it.
Attribution and licence
- Created by
- resend
- Publisher
- Resend (Official)
- Source
- resend/resend-skills · skills/agent-email-inbox · commit 389de56
- Licence
- MIT License (redistribution allowed)
- LICENSE (added by AutoGineer from LICENSE)
- ATTRIBUTION.md (written by AutoGineer)
- Synced
AutoGineer is not affiliated with or endorsed by resend.
Download ZIP
The ZIP holds the skill’s files, its licence files and an ATTRIBUTION.md. Download agent-email-inbox.zip