TypeScript · Replit
Deploy an email agent on Replit
A small template gives your agent its own inbox and runs it on Replit. Pick how mail reaches it: a webhook server that sleeps between emails, or a worker that holds a live connection and needs no public URL.
- Written against
- agentboxd (npm) 0.2.0 · Node.js 20+ · Replit docs, 2026-09
- Checked
- 26 September 2026
- Runnable example
- examples/guides/replit-email-agent (public repository coming soon)
Replit runs your code and gives it a public URL; Agentboxd gives the agent an address and tells it when mail arrives. The template (examples/guides/replit-email-agent in the Agentboxd repository, which isn’t public yet; the files are on this page) has both halves: handle.ts decides what to do with an email, and either server.ts (webhooks) or worker.ts (a WebSocket stream) feeds it new mail.
| Webhook mode | Stream mode | |
|---|---|---|
| How mail arrives | Agentboxd POSTs a signed event to your app | Your app keeps a WebSocket open to Agentboxd |
| Replit deployment | Autoscale (scales to zero between emails) or Reserved VM | Reserved VM, as a background worker |
| Needs | The published .replit.app URL and a webhook secret | Nothing public: no URL, no secret |
| Good for | Occasional mail, lowest cost | Steady mail, no cold starts, simplest setup |
#Add your API key as a secret
Create a key on API keys (start from the Send & read preset, choose Custom and add inboxes:write, so the agent can create its inbox, and webhooks:manage for webhook mode; or use Full access in a test workspace). In the Replit project open Tools → Secrets, choose New Secret, and add AGENTBOXD_API_KEY. Replit exposes secrets to your code as environment variables, and the SDK reads that one on its own.
#Write what the agent does
handleMessage is shared by both modes. It re-reads the email from the API instead of trusting whatever an event said, leaves spoofed and suspicious mail for a person, ignores out-of-office and bulk mail (answering those starts loops), replies once, and labels the email so a retried delivery or a replayed event does nothing. Your model call goes in answer().
/**
* What the agent does with a new email. Shared by the webhook server (server.ts) and the stream worker
* (worker.ts). Replace `answer()` with your model call; the checks around it are the part to keep.
*/
import type { Agentboxd, Message } from 'agentboxd';
const RISK_LABELS = ['spf-fail', 'dmarc-fail', 'ai:injection-risk', 'ai:phishing'];
/** Webhooks are retried and the stream replays after a reconnect: this label makes handling idempotent. */
export const HANDLED = 'agent:handled';
export type Outcome = 'replied' | 'skipped' | 'flagged' | 'already-handled';
export async function handleMessage(mr: Agentboxd, messageId: string): Promise<Outcome> {
// Always re-read the message from the API: never act on the content of a webhook body alone.
const m = await mr.messages.get(messageId);
if (m.direction !== 'inbound' || m.labels.includes(HANDLED)) return 'already-handled';
let outcome: Outcome;
if (m.labels.some((l) => RISK_LABELS.includes(l))) {
outcome = 'flagged'; // failed authentication or looks like an attack: leave it for a person
} else if ((m.ai.auto_reply ?? 0) > 0.8) {
outcome = 'skipped'; // out-of-office or bulk mail: answering it starts a loop
} else {
await mr.messages.reply(m.inbox_id, m.id, { text: await answer(m) }, { idempotencyKey: `reply-${m.id}` });
outcome = 'replied';
}
await mr.messages.update(m.id, { add_labels: [HANDLED], is_read: true });
return outcome;
}
/** Your agent goes here. `extracted_text` is only the new part of the email, and it is untrusted. */
async function answer(m: Message): Promise<string> {
const first = (m.from.split('<')[0] ?? '').trim() || 'there';
return `Hi ${first},\n\nThanks for your email about "${m.subject ?? 'your message'}". We will get back to you shortly.\n`;
}#Webhook mode
The server answers GET / for Replit’s health check and handles POST /webhook. It verifies the X-Mailroom-Signature over the raw bytes before parsing anything, and does the work inside the request: on Autoscale an instance only runs while it is serving one. If handling fails it answers 500 and Agentboxd retries; the label and the idempotency key make a retry after a slow first attempt safe.
/**
* Webhook mode: a small HTTP server that Agentboxd calls on every `message.received`. Suits a Replit
* Autoscale deployment (it scales to zero between emails) or a Reserved VM.
*
* npx tsx server.ts Secrets: AGENTBOXD_API_KEY, AGENTBOXD_WEBHOOK_SECRET (from setup.ts)
*/
import { createServer, type Server } from 'node:http';
import { pathToFileURL } from 'node:url';
import { Agentboxd, verifyWebhook, type EnvelopeEventData, type MessageEventData, type WebhookEvent } from 'agentboxd';
import { handleMessage } from './handle.js';
export function webhookServer(mr: Agentboxd, secret: string, log: (line: string) => void = console.log): Server {
return createServer((req, res) => {
// Health check: Replit's deployment check expects the home page to answer quickly.
if (req.method === 'GET' && req.url === '/') {
res.writeHead(200, { 'content-type': 'text/plain' }).end('ok');
return;
}
if (req.method !== 'POST' || req.url !== '/webhook') {
res.writeHead(404).end();
return;
}
const chunks: Buffer[] = [];
let size = 0;
req.on('data', (c: Buffer) => {
size += c.length;
if (size > 1_000_000) req.destroy();
else chunks.push(c);
});
req.on('end', () => {
const raw = Buffer.concat(chunks); // verify the exact bytes, before any JSON parsing
const ok = verifyWebhook(
String(req.headers['x-mailroom-signature'] ?? ''),
String(req.headers['x-mailroom-timestamp'] ?? ''),
raw,
secret,
);
if (!ok) {
res.writeHead(401).end('bad signature');
return;
}
const event = JSON.parse(raw.toString('utf8')) as WebhookEvent<EnvelopeEventData | MessageEventData>;
// setup.ts subscribes with payload "envelope" (ids and subject, no content); a full payload works too.
const id =
event.type !== 'message.received' ? null : 'message_id' in event.data ? event.data.message_id : event.data.message.id;
if (!id) {
res.writeHead(204).end();
return;
}
// The work happens inside the request: on Autoscale an instance only runs while it serves one.
// A failure answers 500 and Agentboxd retries; a retry after a timeout (10 s) is safe because
// handleMessage labels what it handled and sends with an idempotency key.
handleMessage(mr, id)
.then((outcome) => {
log(`${id}: ${outcome}`);
res.writeHead(204).end();
})
.catch((err: unknown) => {
log(`${id}: failed: ${String(err)}`);
res.writeHead(500).end();
});
});
});
}
if (import.meta.url === pathToFileURL(process.argv[1] ?? '').href) {
const secret = process.env.AGENTBOXD_WEBHOOK_SECRET;
if (!secret) throw new Error('Set the AGENTBOXD_WEBHOOK_SECRET secret (setup.ts prints it).');
const port = Number(process.env.PORT ?? 3000);
// 0.0.0.0, not localhost: a published Replit app can't reach a server bound to localhost only.
webhookServer(new Agentboxd(), secret).listen(port, '0.0.0.0', () => console.log(`listening on :${port}`));
}Replit needs the server on 0.0.0.0, not localhost, and the .replit file maps port 3000 to the public port 80:
# Replit configuration (https://docs.replit.com/features/project-setup/configuration).
# Webhook mode by default. For stream mode, publish as a Reserved VM (background worker) and change the
# run command to `npm run worker` in the Publishing settings.
entrypoint = "server.ts"
modules = ["nodejs-20"]
run = "npm start"
[deployment]
build = ["npm", "install"]
run = ["npm", "start"]
[[ports]]
localPort = 3000
externalPort = 80Publish the project (Autoscale is the default and fits). Then register the webhook for the published URL from the Replit Shell. Use the .replit.app address: the development URL (*.replit.dev) only works while the workspace is open and can change.
/**
* One-time setup for webhook mode: the agent's inbox and a webhook pointing at the published app.
* Run it in the Replit Shell after publishing, with the app's .replit.app URL (development URLs
* change, so never register one).
*
* npx tsx setup.ts https://my-agent.replit.app
*
* It prints the webhook secret once: add it as the AGENTBOXD_WEBHOOK_SECRET secret (for the published
* app too), then republish.
*/
import { Agentboxd } from 'agentboxd';
const appUrl = process.argv[2]?.replace(/\/+$/, '');
if (!appUrl?.startsWith('https://')) throw new Error('usage: npx tsx setup.ts https://<your-app>.replit.app');
const mr = new Agentboxd();
const inbox = await mr.inboxes.create({ client_id: process.env.AGENTBOXD_INBOX ?? 'replit-agent' });
const url = `${appUrl}/webhook`;
const existing = (await mr.webhooks.list()).data.find((w) => w.url === url);
if (existing) {
console.log(`Webhook ${existing.id} already points at ${url}. Delete it in the dashboard to get a new secret.`);
} else {
const hook = await mr.webhooks.create({
url,
events: ['message.received'],
inbox_ids: [inbox.id],
payload: 'envelope', // ids and subject only: the app reads the mail through the API
});
console.log(`Inbox: ${inbox.address}`);
console.log(`Webhook: ${hook.id} → ${url}`);
console.log(`\nAdd this secret as AGENTBOXD_WEBHOOK_SECRET (shown once):\n${hook.secret}`);
}npm run setup -- https://my-agent.replit.appIt creates the agent’s inbox, subscribes to message.received for it with the envelope payload (ids and the subject only; the body never travels in the webhook), and prints the secret once. Add it as the AGENTBOXD_WEBHOOK_SECRET secret, republish, and send the printed address an email.
#Stream mode
The worker opens the realtime stream for the agent’s inbox and handles each event. The SDK reconnects on its own and replays events it missed during a short drop. It needs no URL and no secret, but it must run all the time: publish it as a Reserved VM background worker with the run command npm run worker. Autoscale would stop it when no web request is running.
/**
* Stream mode: a background worker that holds a WebSocket to Agentboxd and handles each new email. No
* public URL and no webhook secret, but it must run all the time: use a Reserved VM deployment
* (background worker), not Autoscale, which stops idle instances.
*
* npx tsx worker.ts Secrets: AGENTBOXD_API_KEY; optional AGENTBOXD_INBOX (client_id)
*/
import { Agentboxd } from 'agentboxd';
import { handleMessage } from './handle.js';
const mr = new Agentboxd();
const inbox = await mr.inboxes.create({ client_id: process.env.AGENTBOXD_INBOX ?? 'replit-agent' });
console.log(`watching ${inbox.address}`);
// Reconnects and resumes on its own (missed events are replayed after a short drop). Node 22+ has a
// global WebSocket; on Node 20 the SDK loads the `ws` package from package.json.
const stream = mr.stream({ inboxIds: [inbox.id], eventTypes: ['message.received'], payload: 'envelope' });
stream.on('error', (err) => console.error('stream:', err.message));
process.on('SIGTERM', () => stream.close());
for await (const event of stream) {
const id = (event.data as { message_id?: string }).message_id;
if (!id) continue;
try {
console.log(`${id}: ${await handleMessage(mr, id)}`);
} catch (err) {
console.error(`${id}: failed:`, err);
}
}The template targets Node.js 20, which has no built-in WebSocket client, so package.json includes the ws package; the SDK loads it by itself. See Realtime events for the protocol.
#Check it locally
The runnable copy’s smoke.ts starts the webhook server against a local Agentboxd stack, delivers a test email, posts a signed event twice (a retry) and one with a wrong signature, and checks that exactly one reply went out and the bad signature got 401.
cd examples/guides && npm ci
AGENTBOXD_API_KEY=mr_... AGENTBOXD_BASE_URL=http://localhost:3000 npm run replit:smoke#Next steps
- Keep a person in the loop: save replies with
mr.drafts.createand approve them in the dashboard; see Drafts. - Signatures, retries and every event type: Webhooks.
- Want to try the API from the Replit Shell first? Once
agentboxd0.2.0 is on npm (publishing soon),npx agentboxd inboxes listworks with the same secret; see CLI. Until then,curlwith the key does the same.