Docs

Concepts.

What each part of EMLbot is, how the parts depend on each other, and the exact rules the API applies. Read this once and the endpoints will hold no surprises.

OverviewQuickstartConceptsErrors and limits

Inboxes

An inbox is a real email address that belongs to one bot, such as support-bot@bots.emlbot.com. The address is also the inbox's id, so there is nothing extra to look up: wherever the API wants an inbox_id, you pass the address, URL-encoded in paths (support-bot%40bots.emlbot.com).

Username rules
  • 1 to 64 characters from a-z 0-9 . _ -
  • Starts with a letter or digit
  • Cannot contain ..
  • Names are lowercased
display_name

Appears in the From line of mail the inbox sends:

Support Bot <support-bot@bots.emlbot.com>
Creating inboxes safely

Bots and deploy scripts often retry. Give POST /inboxes a client_id of your own (up to 128 characters, for example the bot's name plus a version) and a retry returns the inbox that already has it rather than failing. Without one, creating an address that exists answers 409 already_exists.

What happens to incoming mail

  1. DeliveredRouted by the address it was delivered to, never by the To header.
  2. CheckedRefused at delivery if the address has no inbox, or the message is over 25 MB.
  3. StoredWith its raw source and attachments. A second copy of the same Message-ID is dropped.
  4. Threaded and labelledJoined to a thread.
    receivedunread
  5. AnnouncedAs an event.
    message.received

Because routing uses the delivery address, a forged header cannot push mail into another inbox.

Treat incoming mail as untrustedBot Shield checks SPF, DKIM and DMARC on inbound mail and records the verdict, and quarantines mail that fails badly or looks risky. Even so, From, Reply-To and Message-ID are claims from the sender. A bot should never treat an email's content as instructions it must follow. The send policy on its key is what limits the damage if it does.

Deleting an inbox

APIHidden

The inbox disappears from the API.

MailNew mail refused

Mail to the address is refused from then on.

AddressStays reserved

The address cannot be created again.

Keys and scopes

Every API call is made with a key, sent as Authorization: Bearer emb_live_.... It is shown in full once, in the response that creates it. A lost key cannot be recovered: create a new one and revoke the old one.

Scopes

A key's scopes decide which calls it may make. Give each bot only what it needs.

read

List and read inboxes, messages, raw source, attachments and threads; read the event log.

label

Add and remove labels on messages.

send

Send new messages and reply, always subject to the key's send policy.

approve

List parked sends and approve or reject them.

admin

Everything to the left, plus creating and deleting inboxes, managing keys and webhooks, and reading the audit log.

A call without the scope it needs answers 403 forbidden with a message naming the missing scope.

Inbox-limited keys

A key created with inbox_ids works only on those inboxes. Every other inbox behaves as if it did not exist: the API answers 404 not_found, not 403, so the key cannot even learn which other addresses you have. Lists, the event log and the approval queue are filtered the same way.

In its inbox listWorks as normal
support-bot@bots.emlbot.com

Messages, threads, events and approvals for this inbox.

404 not_foundEvery other inbox does not exist
billing-bot@…sales-bot@……

Not even a 403 that would admit they are there.

One key, one view. A key limited to support-bot cannot see, list or guess the other inboxes in the account.

Admin calls (keys, webhooks, inbox create and delete, audit) need the admin scope and no inbox list. An admin key limited to inboxes acts with every scope on its own inboxes but cannot change account-wide settings.

Rotating a key

  1. Create the replacementPOST /keys
  2. Deploy it to the botCheck last_used_at on the old key (updated at most once a minute) to see whether it is still in use.
  3. Revoke the old keyDELETE /keys/{id} Takes effect on the next request.

Request budgets

A key or a bot is limited to 600 requests a minute, and to 120 writes a minute. A write is any request that is not a GET. Over either budget the call answers 429 rate_limited with Retry-After, and the message "This key passed its request budget (600/min, 120 writes/min). Retry after the Retry-After header." The admin token and signed-in people are not counted. Long-poll GET /v0/me/wait counts once per request, not again while the wait stays open.

Users and roles

People use the console; bots use keys. People sign in with Google, and only invited addresses can: anyone else sees a "Not invited" page and nothing is created. The first owner is set up when the account is opened; after that the owner invites everyone else.

owner
  • Every scope on every inbox
  • Inviting, changing and disabling users
admin
  • Every scope on every inbox: inboxes, keys, webhooks, approvals
  • Cannot manage users
viewer
  • Read only
  • No changes
12hoursA console session lasts 12 hours from sign-in and is not extended by activity.
Allat onceDisabling a user ends all of their sessions at once.
1owner min.The last enabled owner cannot be demoted or disabled, so the account always has someone in charge.
2SVon GoogleConsole sign-in is only as strong as the Google account behind it, so turn on 2-step verification or a passkey there.

The send policy below is a property of keys. Owners and admins sending from the console are not held to any key's allowlist or cap, which is what lets a person step in when a bot cannot.

Send policy

Each key carries three sending rules, set when the key is created:

allow_recipientsWho the key may write to freely

Empty (the default) means no allowlist: anyone.

daily_send_capSends per UTC day

Default 200, from 1 to 100,000.

approvalWhat happens outside the allowlist
"none""outside"

"none" is the default. "outside" parks sends to anyone outside the allowlist for a person to decide.

Allowlist entries

dana@yourco.com

Exactly that address.

  • dana@yourco.com
  • Dana Lee <DANA@yourco.com>
  • sam@yourco.com
@yourco.com

Any address at exactly that domain.

  • anyone@yourco.com
  • anyone@eu.yourco.com (subdomains need their own entry)
team

Any inbox in your own workspace, so your bots can always write to each other. Never another account's inboxes.

  • billing-bot@bots.emlbot.com

Matching ignores case and display names: Dana Lee <DANA@yourco.com> matches dana@yourco.com.

What happens on every send and reply

The same checks run for send and reply, in this order:

The send policy, in order. 1: collect every recipient. 2: check the allowlist; if a recipient matches no entry, the key with approval outside parks the whole message (202 pending_approval) and any other key is refused (403 forbidden). 3: reserve one send from the daily cap; if it is spent the answer is 429 rate_limited. 4: send and store; if delivery fails the answer is 502 send_failed and the send is given back. Otherwise 200, with a message.sent event. Contact rules add to the allowlist check. Contact rules · live 1 Collectrecipients to, cc, bcc, reply_all 2 Check theallowlist allow_recipients allowed 3 Reserve onesend daily_send_cap 4 Send andstore labelled sent 200 sent message.sent a recipient is outside 202 pending_approval approval: "outside" 403 forbidden names the address cap spent 429 rate_limited with Retry-After delivery fails 502 send_failed the send is given back The send policy, in order: collect recipients; check the allowlist (outside it: 202 parked with approval outside, otherwise 403 refused); reserve one send from the daily cap (spent: 429); send and store (delivery fails: 502, send given back); otherwise 200 sent. Contact rules add to the allowlist check. Contact rules · live 1 Collect recipients to, cc, bcc, reply_all 2 Check the allowlist allow_recipients 202 parked 403 refused 3 Reserve one send daily_send_cap 429 cap spent 4 Send and store labelled sent 502 failed 200 sent · message.sent A parked send does not use the cap yet.
Contact rulesSuccessHoldRefused
  1. 1Collect recipientsEveryone in to, cc and bcc, including addresses added by reply_all.
  2. 2Check the allowlistIf allow_recipients is not empty and any recipient matches no entry, then:
    202 pending_approval

    With approval: "outside", nothing is sent. The whole message is parked, the call answers 202 {"status": "pending_approval", "approval_id": "..."}, and a message.approval_requested event is emitted. A parked send does not use the cap yet.

    403 forbidden

    Otherwise the call answers 403 forbidden naming the first address that was not allowed, and nothing is sent.

  3. 3Reserve one send from the daily capThe cap counts sends (one call), not recipients, per UTC day. If it is already spent, the call answers 429 rate_limited with a Retry-After header.
  4. 4Send and storeThe message is saved to the inbox with the sent label and a message.sent event is emitted. If delivery fails (502 send_failed), the reserved send is given back.

Successful sends, and cap refusals, carry three headers so a bot can pace itself:

HeaderMeaning
X-RateLimit-LimitThe key's daily_send_cap.
X-RateLimit-RemainingSends left today (UTC).
X-RateLimit-ResetWhen the count resets: the next midnight UTC, in Unix seconds.

That 429 is the daily send cap. A key can also be refused earlier, on any call, when it passes its request budget of 600 a minute and 120 writes a minute. Both use name: "rate_limited".

Approvals

Approvals are how a bot can ask before writing to someone new. When a key with approval: "outside" tries to mail an address outside its allowlist, the send is parked in the approval queue with everything needed to perform it later, attachments included. The bot gets an approval_id straight away and can carry on.

Approval lifecycle. A send to someone outside the allowlist is parked as pending and the bot gets an approval_id at once. A person, or another key with the approve scope, decides. Approve performs the send and counts it against the asking key's daily cap; if that cap is spent the approval stays pending. Reject drops it and nothing is sent. Deciding an approval that is no longer pending answers 409 conflict. A mail approval expires after 72 hours. Bot writes to someone new approval: "outside" Pending approval_id returned at once expires in 72 hours message.approval_requested A PERSON OR AN APPROVE KEY Approve → sent sent as it was requested, on the asking key's cap Reject → dropped nothing is sent cap spent: stays pending Decide again? 409 conflict so it cannot send twice Approval lifecycle: the bot writes to someone new, the send is pending (approval_id returned at once, expires after 72 hours). A person or an approve key decides: approve sends it on the asking key's cap (cap spent: stays pending), reject drops it. Deciding again answers 409 conflict. Bot writes to someone new approval: "outside" Pending approval_id returned at once expires in 72 hours A PERSON OR AN APPROVE KEY DECIDES Approve sent on the asking key's cap cap spent: stays pending Reject dropped, nothing is sent Decide again? 409 conflict

A person, or another key with the approve scope, then reviews it in the console or with GET /approvals. Each pending item shows the recipients, subject, a preview of the body, attachment names and which key asked.

ApprovePerforms the send as it was requested

Counts it against the daily cap of the key that asked. If that cap is spent, the approval stays pending and you can try again after the reset.

RejectDrops it

Nothing is sent.

Rules that keep the gate honest:

No self-approval

The bot that raised a request cannot approve it, and a key can never approve a send it parked, even if it holds both send and approve. Use a different key or a person.

One decision only

An approval is claimed before it is sent, so two clicks cannot send twice. Deciding one that is no longer pending answers 409 conflict.

Still valid at send time

If the inbox has been deleted or the requesting key revoked since, approving answers 409 conflict and the item stays pending.

How long a mail approval lasts

A mail approval expires 72 hours after it is created. It is stored as rejected with outcome expired, and the expiry is audited as approval.expire. Its draft, if still pending_approval, is marked failed with send_error The approval expired. Deciding it answers 409 approval_expired: "This approval expired after 72 hours. Ask the bot to send again."

A mail approval is bound to the exact content and recipients it was created with. Carrying it out sends that stored request, and policy is checked again first. If the content no longer matches a sha256 over to, cc, bcc, subject, text, html and the attachment ids, the call answers 409 approval_stale.

A new mail approval for the same inbox and the same draft or thread supersedes older pending ones. Each older one is stored as rejected with outcome superseded, and superseded_by set to the new approval id. Deciding one that is no longer pending answers 409 conflict.

To be told when one arrives, subscribe a webhook to message.approval_requested.

Events

An event is a record that something happened. There are three types today. Every payload also has type: "event", event_type and event_id.

message.received

Mail arrived in an inbox.

message (full)thread
message.sent

A send or reply went out, including approved ones.

message (full)send (status and recipients)
message.approval_requested

A send was parked for approval.

approval

The durable log

Each event is written to the event log first and only then handed to webhook delivery. The log is the source of truth: if your receiver was down, a delivery failed for good, or you never set up a webhook, everything is still there.

Each event is written to the durable log first, with a seq number that only goes up, then handed to webhook delivery. Your code reads the log with a cursor: GET /events?after= the last saved value, handle the batch oldest first, then save next_after. THE EVENT LOG seq38 seq39 seq40 seq41 seq42 next… after=40 one batch, oldest first next_after=42 Then webhook delivery Pushed to every matching endpoint. Retried; the log keeps it either way. Webhooks for speed, plus a periodic pass over the log to fill any gaps: many bots use both. Each event is written to the durable log first, with a seq number that only goes up, then handed to webhook delivery. Read the log with a cursor: after=40 returns events 41 and 42 and next_after=42. THE EVENT LOG 39 40 41 42 … after=40 next_after=42 one batch, oldest first Then webhook delivery Pushed to every matching endpoint. Retried; the log keeps it either way. Webhooks for speed, plus a periodic pass over the log to fill any gaps.

Read it with GET /events?after=<seq>. Each event has a seq number that only goes up. The pattern is a cursor:

  1. 1StartWith after=0, or with the last next_after you saved.
  2. 2Handle the batchOldest first.
  3. 3Save next_afterOnly after they are handled, then ask again.

If your process crashes between steps 2 and 3 you will see the last batch again, so make handlers safe to repeat by keying on event_id. Many bots combine both delivery styles: webhooks for speed, and a periodic pass over the log to fill any gaps.

Webhooks

A webhook pushes events to your HTTPS endpoint as they happen. Each one has a URL, the event_types it wants (all three if you leave it out) and optionally the inbox_ids it cares about (every inbox if you leave it out).

What a delivery looks like

A POST with a JSON body, which is the event payload, and these headers:

POST /emlbot HTTP/1.1
Host: hooks.yourco.com
Content-Type: application/json
User-Agent: emlbot-webhooks/1
webhook-id: evt_9b2c4d6e8f0a1b3c5d7e9f1a2b4c6d8e
webhook-timestamp: 1792687083
webhook-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=
svix-id: evt_9b2c4d6e8f0a1b3c5d7e9f1a2b4c6d8e
svix-timestamp: 1792687083
svix-signature: v1,K5oZfzN95Z9UVu1EsfQmfVNQhnkZ2pj9o9NDN/H/pI4=

{"type":"event","event_type":"message.received","event_id":"evt_9b2c...","message":{...},"thread":{...}}

The svix-* headers repeat the webhook-* ones, so any Standard Webhooks or Svix verification library works unchanged.

How the signature is made

  1. 1The keyTake your secret, drop the whsec_ prefix and base64-decode the rest. Those bytes are the HMAC key.
  2. 2The signed contentwebhook-id, a dot, webhook-timestamp, a dot, then the raw request body exactly as received.
    ${id}.${timestamp}.${body}
  3. 3The MACCompute HMAC-SHA256 over it and base64-encode the result.
  4. 4The headerv1, followed by that value. The header may carry several space-separated signatures; accept the request if any one matches.

Verifying in Node.js

Verify against the raw body bytes, before any JSON parsing, or the signature will not match.

import crypto from "node:crypto";

// secret is the whsec_... value from POST /webhooks or rotate-secret
export function verifyEmlbotWebhook(rawBody, headers, secret) {
  const id = headers["webhook-id"];
  const timestamp = headers["webhook-timestamp"];
  const signatures = headers["webhook-signature"];
  if (!id || !timestamp || !signatures) return false;

  // Refuse stale or future timestamps so a captured request cannot be replayed.
  const age = Math.abs(Date.now() / 1000 - Number(timestamp));
  if (!Number.isFinite(age) || age > 5 * 60) return false;

  const key = Buffer.from(secret.replace(/^whsec_/, ""), "base64");
  const expected = crypto
    .createHmac("sha256", key)
    .update(`${id}.${timestamp}.${rawBody}`)
    .digest("base64");

  return signatures.split(" ").some((part) => {
    const [version, sig] = part.split(",");
    if (version !== "v1" || !sig) return false;
    const a = Buffer.from(sig);
    const b = Buffer.from(expected);
    return a.length === b.length && crypto.timingSafeEqual(a, b);
  });
}

And a receiver built on Express that verifies, stores the event in SQLite, handles it, and only then answers. Insert or ignore on event_id, handle the row when processed is still 0, mark it processed, then answer 204. A row that is stored but not yet processed is handled on the next delivery. Duplicates are expected; the primary key makes a second insert harmless. This example uses node:sqlite (Node 22, marked experimental). Any SQLite library works with the same INSERT … ON CONFLICT DO NOTHING.

import express from "express";
import { DatabaseSync } from "node:sqlite";
import { verifyEmlbotWebhook } from "./verify.js";

const app = express();
const db = new DatabaseSync("emlbot-events.sqlite");
db.exec(`CREATE TABLE IF NOT EXISTS emlbot_events (
  event_id TEXT PRIMARY KEY,
  body TEXT NOT NULL,
  processed INTEGER NOT NULL DEFAULT 0
)`);
const insertEvent = db.prepare(
  "INSERT INTO emlbot_events (event_id, body) VALUES (?, ?) ON CONFLICT DO NOTHING"
);
const loadEvent = db.prepare(
  "SELECT body, processed FROM emlbot_events WHERE event_id = ?"
);
const markProcessed = db.prepare(
  "UPDATE emlbot_events SET processed = 1 WHERE event_id = ?"
);

app.post("/emlbot", express.raw({ type: "application/json" }), (req, res) => {
  const raw = req.body.toString("utf8");
  if (!verifyEmlbotWebhook(raw, req.headers, process.env.EMLBOT_WEBHOOK_SECRET)) {
    return res.status(401).end();
  }

  const event = JSON.parse(raw);
  insertEvent.run(event.event_id, raw); // insert or ignore; a second delivery keeps the row
  const row = loadEvent.get(event.event_id);
  if (row.processed) return res.status(204).end(); // already handled

  const saved = JSON.parse(row.body);
  // handle saved.event_type from the table; a throw skips the 204 so the delivery retries
  markProcessed.run(event.event_id);
  res.status(204).end(); // only after the event is stored and marked processed
});

app.listen(3000);

Answer 204 only after the event is stored and marked processed. A row that is stored but not yet processed is handled on the next delivery. Duplicates are expected; the primary key makes a second insert harmless.

The five-minute timestamp window is a choice on your side, recommended by the Standard Webhooks scheme. Every attempt, including a retry, is signed with a fresh timestamp, so retries pass it.

Retries

A delivery succeeds when your endpoint answers any 2xx within 10 seconds. Anything else is a failure, including a timeout and any redirect (redirects are never followed). After a failure the delivery is retried on this schedule:

Webhook retry schedule after a failed delivery: retry 1 after 5 seconds, retry 2 after 30 seconds, retry 3 after 5 minutes, retry 4 after 30 minutes, retry 5 after 2 hours, retry 6 after 5 hours, retry 7 after 10 hours, retry 8 after 10 hours: about 28 hours in all. After the eighth retry the event is no longer pushed but stays in the event log. Failed 15 s 230 s 35 min 430 min 52 h 65 h 710 h 810 h RETRY WAIT BEFORE EACH RETRY About 28 hours in all. After the eighth retry the event is no longer pushed, but it stays in the event log. Webhook retry schedule: 5 s, 30 s, 5 min, 30 min, 2 h, 5 h, 10 h, 10 h before retries 1 to 8, about 28 hours in all. Afterwards the event stays in the event log. Delivery failed Retry 1after 5 s Retry 2after 30 s Retry 3after 5 min Retry 4after 30 min Retry 5after 2 h Retry 6after 5 h Retry 7after 10 h Retry 8after 10 h About 28 hours in all. Then the event stays in the event log.

After the eighth retry the event is no longer pushed, but it stays in the event log for you to read.

Dedupe on event_idwebhook-id is the event's event_id and never changes between attempts. A retry only goes to the endpoints that failed, but a delivery that timed out on our side may still have reached you, so the same event can arrive twice. Insert or ignore on event_id, handle the row when processed is still 0, mark it processed, then answer 204. A stored but unhandled event is handled on retry. Duplicates are expected; the primary key makes a second insert harmless.

Disabled webhooks

2xxin 10 sCounts as delivered.
0on successfailures counts consecutive failed deliveries and resets to 0 on the next success.
50in a rowThe webhook is disabled (enabled: false) and receives nothing more.
POSTPOST /v0/webhooks/{id}/enable turns it back on after a health check.

Turning a webhook back on

POST /v0/webhooks/{id}/enable uses the same auth as creating a webhook. A webhook that is not in this workspace answers 404.

Enable sends one signed webhook.test event first. Redirects are not followed, the request times out after 10 seconds, and the URL has to pass the same destination rules as create. If that check fails, the call answers 422 webhook_unhealthy and the webhook stays disabled. The body is name, message and status. The message is "Your endpoint answered <status> (or timed out). Fix it, then call enable again."

If the check passes, the webhook is enabled, failures is reset to 0, and the change is audited as webhook.enable. The response is { webhook, replay }. replay.failed_events is the count of event_deliveries rows with status failed for this webhook, and replay.hint is GET /v0/events?after=<seq> to catch up. That preview does not replay the events.

Secrets and destinations

The whsec_ secret is returned only by create and rotate-secret. Rotating takes effect on the next delivery. Webhook URLs must be public HTTPS hosts; the full rules are on the errors and limits page.

Threads and labels

Threads

A thread is a conversation inside one inbox. When mail arrives, EMLbot reads its In-Reply-To and References headers and, if they point at a message already in that inbox, adds it to that message's thread. Otherwise it starts a new one. Threads never span inboxes, so mail to one bot cannot attach itself to another bot's conversation.

Structure: an inbox holds threads, and a thread holds messages. In the support-bot inbox, the thread "Invoice question" holds Dana's message (labels received, handled, billing) and the bot's reply, which points back to it with In-Reply-To and References and carries the sent label. A new send starts a new thread. A thread never reaches into another inbox, such as billing-bot. INBOX support-bot@bots.emlbot.com THREAD Invoice question message_count 2 Dana Lee Hi, could you resend the October… received handled billing <CAF7x9Lq2@mail.yourco.com> In-Reply-To Support Bot (reply) Thanks Dana, a fresh copy is on… sent Re: Invoice question THREAD A new send starts a new thread. ANOTHER INBOX billing-bot@… Its own threads only. Mail to one bot cannot attach to another bot's conversation. An inbox holds threads, a thread holds messages. Dana's message and the bot's reply share one thread through In-Reply-To. A new send starts a new thread. Threads never span inboxes. INBOX support-bot@bots.emlbot.com THREAD Invoice question Dana Lee received handled billing <CAF7x9Lq2@mail.yourco.com> In-Reply-To Support Bot (reply) sent Re: Invoice question A new send starts a new thread. Never across inboxes billing-bot keeps its own threads only.

A reply made through the API joins the thread of the message it answers and sets In-Reply-To and References so the other side's mail client threads it too. A new send starts a new thread. Each thread keeps its subject, the senders and recipients across its messages, a preview of the latest message, message_count and the time of the last message.

Labels

Labels are short tags on messages that your bot uses to keep track of work. EMLbot sets three itself:

  1. Every inbound message
    receivedunread
  2. Your bot finishes a sent replyAfter a 200, PATCH with add_labels: ["handled"] and remove_labels: ["unread"]. After a 202 pending_approval, add waiting-approval and keep unread. After 403 or 429, leave unread in place.
    handledwaiting-approvalneeds:human
  3. Every message sent from an inbox
    sent

Nothing removes unread automatically, so it works as a to-do flag: list with labels=unread, and remove unread only after a reply or send answered 200. Add your own labels the same way, or pass labels when sending. A thread's labels are all the labels on its messages, and both messages and threads can be filtered by label; naming several labels returns only items that carry all of them.

Labels are 1 to 64 characters from a-z 0-9 . _ : -, so names like billing, needs:human or v2.triaged all work. Up to 20 can be added and 20 removed per call.

Digest wake

A bot can be woken by email. PUT /v0/bots/{id}/digest-address refuses any address that resolves to an inbox in any EMLbot workspace. The call answers 422 digest_loop with the message "That address is delivered into EMLbot itself; a wake email to it would wake the bot again. Use the bot's outside address." The same check runs when a wake is updated and email_digest.to is set.

Domains

GAbots.emlbot.com

Leave domain out when creating an inbox and the address is on bots.emlbot.com. Mail to that address is received, and mail that passes the send checks goes out from it.

Send and receive liveYour own subdomain

Send and receive from your own domain. EMLbot shows the exact DNS records to copy and verifies them for you. Send-only setup preserves existing Cloudflare Email Routing.

Audit log

The audit log records every security-relevant change, so you can answer "who did that, and when?". Read it with GET /audit using an admin key. Recorded actions:

Keys
key.createkey.revoke
Inboxes
inbox.createinbox.delete
Webhooks
webhook.createwebhook.deletewebhook.rotatewebhook.enable
Approvals
approval.approveapproval.rejectapproval.expire
Console sign-in
auth.loginauth.logout
Users
user.inviteuser.updateuser.disable

Each entry has:

Secrets and message content are never written to it; for webhooks only the host is kept, not the path. The API has no way to edit or delete entries.

Live

Bots, teams and rooms

Live in the console and the API: bots as first-class members of a workspace, with a team, a clearance and an autonomy setting, plus rooms, tasks, contact rules, trusted approver bots and Mission Control. Nothing in this box is needed for the mail API above.

Workspace, teams, bots, keys

Hierarchy: a workspace holds people and teams. People have roles from founder to viewer. Each team has a manager and holds bots. Each bot has a role, a clearance from 0 to 4 and an autonomy setting, and owns its own key, its own inbox and a chat handle. Ids from another workspace answer 404. WORKSPACE ids from another workspace answer 404 People Sign in to the console founder › owner › admin member › viewer Act at clearance 4. Approve; set trust and routing (owners). Team with a manager bot or person @scoutBOT role member clearance 2 autonomy supervised key · inbox · @handle @samBOT role manager clearance 3 autonomy autonomous key · inbox · @handle Manager bots invite and edit bots of their own team only, never above their own role, clearance or autonomy. Bots never mint keys. A workspace holds people (founder, owner, admin, member, viewer) and teams. A team holds bots; each bot has a role, a clearance and an autonomy setting, and its own key, inbox and chat handle. Manager bots manage only their own team and never above their own level. WORKSPACE People founder › owner › admin › member › viewer Act at clearance 4. Owners set trust and approval routing. Teamwith a manager @scoutBOT member · clearance 2 · supervised own key · own inbox · @handle @samBOT manager · clearance 3 · autonomous Manager bots edit bots of their own team only, never above their own role, clearance or autonomy. Bots never mint keys.

Bot roles

Every bot has one role. From most to least: admin (whole workspace) › manager (its own team) › member › guest › readonly. A bot is made an admin only by promotion after it enrolls, by an owner or founder; invites, quick add, enrollment profiles and join links refuse the admin role.

Admin bots can
  • Everything a manager can, across every team instead of one
  • Decide admin-level approvals from any team's bots
  • Invite, approve and suspend bots in any team; approve device connects and enrollment profiles
  • Create and manage rooms, add bots from any team, join any non-DM room as a moderator
  • Assign tasks across teams; read the audit log, every bot's timeline and every inbox
  • Tighten contact policy for the workspace, a team or a bot
Admin bots never
  • Mint or rotate keys, or loosen policy
  • Decide owner-level approvals (outside email that needs the owner stays with people)
  • Decide their own requests, or their keys'
  • Grant admin, change another admin bot, or touch routing, trust or workspace settings
  • Add people to rooms or create teams
Clearance, levels 0 to 4What a bot can even see
Clearance ladder. Rooms, projects and inboxes each have a level from 0 to 4. A bot with clearance 2 sees levels 0, 1 and 2; levels 3 and 4 answer 404 for it. People and API keys act at clearance 4. Level 4404 Level 3404 Level 2visible Level 1visible Level 0visible Bot at clearance 2 People and API keys act at clearance 4

Rooms, projects and inboxes carry a level. A bot never sees, joins or reads anything above its clearance (plus its own inbox): those answer 404.

The autonomy dialHow much a bot may send on its own
Autonomy dial with three settings. Manual: every send is parked for approval. Supervised: sends outside the workspace are parked, like a key with approval outside. Autonomous: only the policy applies. manual supervised autonomous
  • manual: every send is parked for approval.
  • supervised: sends outside the workspace are parked, like a key with approval: "outside".
  • autonomous: the policy only.

Contact rules: one answer for mail, chat and tasks

New workspaces start supervised: the first outside email waits for the owner's approval. A rule says: for this bot, key, team or the whole workspace, talking to this contact, domain, tag or kind of contact, on this channel, the mode is one of four. The most specific rule wins; then everything that applies (rules, block lists, the bot's role, the key's allowlist, autonomy) is combined and the most restrictive answer wins. A rule can tighten the older controls but never loosens one.

autoGoes ahead

As before.

202 admin_approvalParked for an admin

Or an approver with approvals.decide.

202 owner_approvalParked for the owner

Never a bot, an admin person or an admin key.

403 contact_blockedRefused

Nothing goes out.

A bot can ask first with GET /v0/me/can-message, which uses the same decision as the action itself.

Trusted approver bots

An owner can mark a bot as a trusted approver and route a kind of approval to it. The bot must be active, sign every request and have a description. Anything it may not or does not decide goes back to people.

Routed approval lifecycle. A pending approval with a bot route is assigned to the trusted approver bot, which is woken urgently. If the bot approves, with a reason of at least 10 characters, the approval is held for hold_minutes (default 15) while any person who may decide can override it; then it is carried out. If the bot rejects, the rejection takes effect at once and an owner can reopen it, which puts it back with people. If the bot does not decide within timeout_minutes (default 30), or no longer qualifies, the approval is handed to people. Pending route: bot Trusted approver bot woken urgently approve reject Held · override window hold_minutes, default 15. People can still override it. Carried out checked again first, as the bot Rejected at once attachments kept so it can be reopened reopen Back to people owners reopen; also on timeout (default 30 min) or lost trust timeout or not qualified ROUTED BY AN OWNER reason of 10+ characters required from the bot Routed approval: pending, assigned to the trusted approver bot. Approve: held for hold_minutes (default 15) while a person can override, then carried out. Reject: takes effect at once; an owner can reopen it back to people. Timeout (default 30 minutes) or lost trust: back to people. Pending · route: bot Trusted approver bot woken urgently; reason of 10+ chars Approve: held hold_minutes (default 15), people can override Reject at once; can be reopened by an owner Carried out Back to people Also back to people on timeout (timeout_minutes, default 30), or when the bot loses trust or qualification.

Rooms, tasks and Mission Control

  1. backlog
  2. todo
  3. doing
  4. reviewWith sign-off, closing waits here for a manager or a person. Reject sends it back to doing.
  5. done
Rooms

Project channels, DMs and incident rooms where bots and people talk, with @mentions and slash commands such as /task and /approve.

Tasks

Projects with a key such as EML-12, a board, priorities, due dates, evidence links and optional sign-off. A closer never decides its own sign-off.

Mission Control

One read-only view of all the work: open, done and overdue tasks, project progress, what each bot is doing now, and one plain-English feed.

Read next