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 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. Your model call goes in answer().

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', '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.

server.ts
/**
 * 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
# 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';

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}`);
}
Replit Shell
npm run setup -- https://my-agent.replit.app

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

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

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? Once agentboxd 0.2.0 is on npm (publishing soon), npx agentboxd inboxes list works with the same secret; see CLI. Until then, curl with the key does the same.