Integrations · September 22, 2026

Replit Agents That Own an Email Address

A Replit agent email address for the build loop and a separate live inbox for the deployed app, and why the key prefix keeps them apart.

Two programs are involved in a Replit build, and only one of them is the product. Replit Agent writes the handler, installs the package, starts the server and exercises the flow to check that its own code works. The app it produced is a different process that runs later and serves strangers. Give both of them the same email credential and the build loop becomes a sender: the agent runs your signup path to see whether it compiles, and a welcome message leaves for whichever address was sitting in the test fixture.

This sets out the split that prevents that. The build loop gets a Replit agent email address on a test project whose inboxes cannot reach the internet at all. The deployed app gets one live inbox. The credential prefix is what makes the boundary real, because it is chosen by the server rather than by your config. If you want the general shape of an agent mailbox before the Replit specifics, the integrations overview covers what an agent can connect through.

The two programs, and which one is allowed to send

They need different projects, not different keys on one project. Environment is a property of the project, fixed when the project is created, and there is no endpoint that changes it afterwards.

Replit Agent, in the workspaceThe deployed app
What it does with mailruns your send path to test itcorresponds with actual people
Project environmenttestlive
Credential prefixefa_test_efa_live_
Addressgenerated, non-routable, on .mail.invalidon agents.emailforagents.ai
Can reach a real recipientno, the transport is simulatedyes
Inbox allowance3 active test inboxes per workspace1 complimentary live inbox
Costtest mail is never charged1 email unit per message in or recipient out

The reason to draw the line at the project rather than at a conditional in your code is that a conditional is a thing you can get wrong at 1am, and an environment is a thing the server checks on every request. The rest of this piece is mostly about that difference.

Why the build loop is the half that emails a stranger

The deployed app is the part everyone reviews. The build loop is the part that runs your send path forty times in an afternoon with nobody watching the outbound side.

Three things stack up, and each one is normal agent behaviour rather than a fault:

So the blast radius is not one message. It is the number of build iterations multiplied by the number of recipients in the fixture. The mistake that produces it is not recklessness; it is that a single credential makes “run the code” and “send the mail” the same action. Splitting the credential splits the action.

The test project: three inboxes, a simulated transport, no charge

Create the test project first, and stamp it with something that ties it back to the Repl so you can tell two of them apart later:

curl -X POST "https://api.emailforagents.ai/v1/projects" \
  -H "Authorization: Bearer $EFA_ADMIN_TOKEN" \
  -H "Content-Type: application/json" \
  -d '{
    "name": "invoice-app build loop",
    "environment": "test",
    "external_id": "repl:invoice-app"
  }'

Then the inbox, with no address fields at all:

curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_TEST_PROJECT_ID/inboxes" \
  -H "Authorization: Bearer $EFA_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{"display_name":"Build loop"}'

Sending local_part or domain_id on a test project is HTTP 422 test_mode_unsupported, with the message Test inboxes use generated non-routable addresses. The address you get back is generated from the inbox and project identifiers on a .mail.invalid domain, which is reserved precisely so that nothing resolves it. A test inbox cannot reach an internet recipient, and that is a property of the transport rather than a setting you could switch.

Inbound mail is injected rather than delivered:

curl -X POST "https://api.emailforagents.ai/v1/projects/$EFA_TEST_PROJECT_ID/test/inbound" \
  -H "Authorization: Bearer $EFA_TEST_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "inbox_id": "ibx_...",
    "from": {"email":"ap@example.com","name":"Accounts Payable"},
    "subject": "Invoice 4471",
    "text": "Please confirm receipt."
  }'

One scope detail catches people, and it is not in any error message you would guess from. This endpoint requires both test:simulate and messages:read. A key minted with test:simulate alone is refused with permission_denied, which reads like the simulation feature is unavailable rather than like a scope is missing. Grant both.

Test mode ceilings are enforced in code, not advertised as guidance: 3 active test inboxes per workspace, the last 100 messages retained, up to 10 MB, content expiring after 7 days. The rehearsal checklist that uses all of this is in testing an agent’s email without sending real mail.

The prefix is an output of the project, not a flag you pass

You do not choose between efa_test_ and efa_live_. The server reads the project’s environment when the key is minted and builds the prefix from it, so there is no request field, header or flag that produces a live key against a test project. The material is efa_, the environment word, an underscore, eight hex characters, another underscore, and a 43-character base64url secret.

Two enforcement rules sit behind that, and both fail at creation rather than at use:

The eight hex characters after the environment word are the lookup handle. Only the 43-character tail is secret, which gives you a safe assertion to run at boot inside a Repl, where console output is part of a session transcript the agent also reads:

const key = process.env.EFA_API_KEY ?? "";
console.log("efa credential:", key.slice(0, 17)); // efa_test_a1b2c3d4

const me = await fetch("https://api.emailforagents.ai/v1/me", {
  headers: { Authorization: `Bearer ${key}` },
}).then((r) => r.json());

if (me.environment !== process.env.EFA_EXPECTED_ENV) {
  throw new Error(`wrong environment: ${me.environment}`);
}

GET /me returns environment, project_ids, inbox_ids and scopes for whatever credential you presented. Seventeen characters is exactly the non-secret half of the key, so the log line proves which credential loaded without printing one. The scope model those scopes come from is explained in agent email permissions and scopes.

The live inbox is claimed once, so spend it on the deployed app

The free allowance is one complimentary live inbox and 100 email units a month per verified owner identity, and the important word is identity. The entitlement is claimed by the first eligible workspace that uses it, the claim is recorded against the owner identity behind a unique index, and it does not move afterwards. Creating a second workspace does not renew it, and that second workspace is refused with payment_required and the message Live mail is not available.

Inside the workspace that holds the claim, a second live inbox is also payment_required, because the count of live inboxes that are not deleted is compared against a limit of one. Deleting a live inbox does free the slot, since deleted inboxes are excluded from that count, but the identity-level claim stays where it was.

For a Replit project that means one concrete rule: do not spend the live inbox on a staging copy of the app. Point staging at the test project, where inboxes are free, plural, and unable to reach anyone. Custom domains require a paid plan and checkout is not open, so the deployed app sends from the platform domain either way. The allowance arithmetic and the proposed plans are on the pricing page.

An email unit is one message received or one recipient sent to, so a send to three people costs three. A hundred a month is enough for an app that emails on a real event and nothing like enough for one that emails on every page view, which is worth working out before the deploy rather than after the allowance stops sending.

Wiring the split into Replit Secrets and Deployments

Store two secrets, never one, and make the app assert which one it got.

Put EFA_API_KEY and EFA_PROJECT_ID in the Secrets pane for the workspace, holding the test project and its efa_test_ key. Those are the values Replit Agent sees while it builds, and they are the values any run started from the editor picks up. Then configure the deployment’s own environment with the live project and its efa_live_ key, so the published app is the only thing that can reach a person.

Add EFA_EXPECTED_ENV alongside each, set to test in the workspace and live in the deployment. That is the variable the boot assertion above compares against, and it turns a copied secret into a startup crash rather than a delivered email. A crash in a Repl is loud, the agent reads the output, and it will usually tell you which value is wrong.

Two habits make this hold up over time. Do not paste a key into a file the agent can edit, because a .env committed into the workspace is a file inside the tree the agent is already refactoring. And when you rotate, rotate the deployment secret first and the workspace secret second, so the window where both are stale points at the harmless one.

The 404 that means you crossed the two

When a credential is pointed at a project or an inbox it cannot reach, the answer is HTTP 404 not_found with the message Resource not found. It is not a 403.

That is deliberate. A 403 would confirm that the identifier exists, which leaks the shape of someone else’s workspace to anyone holding a valid key. The practical consequence is that the single most common misconfiguration in this setup, a live project ID next to a test key, presents as though you mistyped the project ID. People spend an hour re-copying an identifier that was correct all along.

So debug in this order, and start with the credential rather than the resource:

What you seeWhat it usually isFirst check
404 not_found on a project visible in the consolekey environment does not match the projectGET /me, compare environment
422 test_mode_unsupported creating an inboxlocal_part or domain_id on a test projectdrop both fields
403 test_mode_required on /test/inboundlive project, or a live inbox in a test projectcheck both, the endpoint checks both
403 permission_denied minting a keya scope your own role lacks, or a cross-environment mintcompare scopes from GET /me
402 payment_required on the first live sendthe complimentary claim sits on another workspacecheck which workspace claimed it

The 403 test_mode_required row is worth reading twice, because the check runs at two levels. The endpoint first refuses a project whose environment is not test, then separately refuses an inbox whose own environment is not test, with Inbox is not a test inbox. Both paths exist because a project and an inbox can disagree, and neither one is allowed to fake mail into production.

Before the deployed app sends anything real

Set the inbox policy before you widen anything, not after. The policy endpoint takes an expected_version and returns HTTP 412 if the stored version moved underneath you, so two people cannot silently overwrite each other’s rules:

PUT /v1/projects/{project_id}/inboxes/{inbox_id}/policy

While the deployed app is new, approval_mode: "always" holds every send for a human decision, and a send that trips the rule comes back as approval_required with a reason_codes array rather than as a failure, so the app needs that branch either way. allow_domains and daily_recipient_limit narrow it further once you have watched the approval path work. The control surface is described on sending controls, and the request and error shapes for the whole two-way loop are in the agent email API loop.

Three things are worth saying plainly before you ship. Idempotency-Key is mandatory on every send, and a request without one is rejected with HTTP 400 before anything is evaluated, so derive it from business intent rather than from the retry attempt. No MCP client here is certified or supported, this one included, and every connection is recorded as unverified whatever opened it. And provider acceptance is not proof of delivery: accepted means one hop succeeded, and recipient outcomes arrive later and separately.

The order that works is test project, test inbox, injected inbound message, a reply the agent composes, then a live inbox created last and pointed only at the deployment. The three-step version of that setup is in the quickstart. Once the split exists, the worst outcome of a confused build loop is a 404 in a Repl console, which is the entire point of arranging it this way.


Keep reading

All guides · Documentation · Integrations