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.3.0 · Node.js 20+ · Replit docs, 2026-09
Checked
14 October 2026

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 modeStream mode
How mail arrivesAgentboxd POSTs a signed event to your appYour app keeps a WebSocket open to Agentboxd
Replit deploymentAutoscale (scales to zero between emails) or Reserved VMReserved VM, as a background worker
NeedsThe published .replit.app URL and a webhook secretNothing public: no URL, no secret
Good forOccasional mail, lowest costSteady 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. It spots automatic mail from the message’s auto_reply flag and its headers, which are there as soon as the email arrives; the AI score comes later, or not at all when AI processing is off. Your model call goes in answer().

Both modes listen to message.received, message.enriched and message.released. In a workspace that screens agent mail, a new message’s content is withheld until its check passes, or until a person releases it if it was held: reading it before then returns withheld instead of the text. handleMessage then does nothing and leaves the email unlabelled, so the later event handles it. See what an API key gets.

handle.ts
/**
 * 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', 'local:injection-risk', 'ai:phishing'];
/**
 * The events that can bring a message's content: `message.received`, `message.enriched` once the check passed, and
 * `message.released` for held mail a person released. Subscribe to all three and handle each message once.
 */
export const EVENTS = ['message.received', 'message.enriched', 'message.released'] as const;
/** Webhooks are retried, the stream replays, and a message can have three events: this label makes handling idempotent. */
export const HANDLED = 'agent:handled';

export type Outcome = 'replied' | 'skipped' | 'flagged' | 'already-handled' | 'withheld';

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);
  // Not screened yet, or held: the content comes with a later message.enriched or message.released event.
  // Leave it unlabelled so that event handles it (https://agentboxd.com/docs/api#screening-reads).
  if (m.withheld) return 'withheld';
  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 (isAutomatic(m)) {
    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;
}

/**
 * Out-of-office replies, bulk and list mail. The server's `auto_reply` flag and the headers are there when the mail
 * arrives; the AI score (`ai.auto_reply`) only once the message was enriched, and never with AI processing off.
 */
export function isAutomatic(m: Message): boolean {
  const h = (k: string) => {
    const v = m.headers[k];
    return (Array.isArray(v) ? v.join(' ') : (v ?? '')).toLowerCase();
  };
  if (m.auto_reply || (m.ai.auto_reply ?? 0) > 0.8) return true;
  if (h('auto-submitted') && h('auto-submitted') !== 'no') return true;
  return /bulk|list|junk|auto_reply/.test(h('precedence')) || h('list-id') !== '';
}

/**
 * Your agent goes here. `extracted_text` is only the new part of the email, and it is untrusted. The reply goes to
 * the Reply-To when there is one, and the server sets its subject to "Re: <their subject>": don't copy more of the
 * sender's text into it.
 */
async function answer(_m: Message): Promise<string> {
  return 'Hi,\n\nThanks for your email. 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.

server.ts
/**
 * Webhook mode: a small HTTP server that Agentboxd calls when mail arrives (`message.received`), passes its
 * check (`message.enriched`) or is released (`message.released`). 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 { EVENTS, handleMessage } from './handle.js';

const mailEvents: readonly string[] = EVENTS;

/** The message an event is about, or null for other events and for content withheld from this event. */
export function eventMessageId(event: WebhookEvent<EnvelopeEventData | MessageEventData>): string | null {
  if (!mailEvents.includes(event.type)) return null;
  if ('message_id' in event.data) return event.data.message_id; // envelope: handleMessage reads the message
  return event.data.message.withheld ? null : event.data.message.id; // full payload: a later event brings it
}

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 = eventMessageId(event);
      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
# 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 = 80

Publish 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.

setup.ts
/**
 * 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';
import { EVENTS } from './handle.js';

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: [...EVENTS], // received, enriched (checked) and released (held mail let through)
    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}`);
}
Replit Shell
npm run setup -- https://my-agent.replit.app

It creates the agent’s inbox, subscribes to the three events 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.

worker.ts
/**
 * 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 { EVENTS, 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.
// message.enriched and message.released carry mail that wasn't screened yet when it arrived; handleMessage skips
// what is still withheld and handles each message once.
const stream = mr.stream({ inboxIds: [inbox.id], eventTypes: [...EVENTS], 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 and delivers a test email. It posts a signed event with a wrong signature, events whose content is withheld (in the payload, and when the message is read), then the message.enriched event twice (a retry). It checks that the bad signature got 401, the withheld events were skipped, and exactly one reply went out.

shell
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.create and approve them in the dashboard; see Drafts.
  • Signatures, retries and every event type: Webhooks.
  • Want to try the API from the Replit Shell first? npx agentboxd inboxes list works with the same secret; see CLI.