MOLTED EMAIL

Agent Coordination

Register agents, acquire contact leases, and run consensus votes for multi-agent coordination.

When multiple agents operate on the same tenant, the coordination API helps them stay out of each other's way. Agents register themselves, mark themselves active with heartbeats, take leases on contacts, and run consensus votes for shared decisions. Every route here is also a CLI command (molted agents ...) and an MCP tool (see MCP Server).

The registry, leases and votes are shared by every mailbox in the tenant, so registering, leasing, voting and changing an agent's config need a key with access to every mailbox: a mailbox-scoped key is refused. Registering, leasing and voting need a write scope; changing an agent's config needs manage.

Register an agent

Run once at startup to register your agent with the tenant. If an agent with the same name already exists, it is updated (upsert): it keeps its id, its heartbeat is refreshed, and its role is set to the one you pass (general when you pass none).

CLI
molted agents register --agent-name "outreach-agent" --agent-role outbound

Options

FlagTypeRequiredDescription
--agent-namestringYesAgent name. Must be unique per tenant.
--agent-rolestringNoAgent role (e.g., outbound, inbound, classifier). Defaults to general.
--metadatajsonNoA JSON object sent along as metadata. Accepted but not stored today.
--no-bind-keyflagNoSkip binding this API key to the agent (bound by default; see below).

When the key could not be bound (bound_to_other_agent or agent_has_other_key), the CLI prints a warning on stderr and still exits 0.

POST https://mailbox.molted.email/v1/agent/register
curl
curl -X POST https://mailbox.molted.email/v1/agent/register \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agentName": "outreach-agent",
    "agentRole": "outbound",
    "bindKey": true
  }'
FieldTypeRequiredDescription
tenantIdstringNoOnly on api.molted.email, where it is required; mailbox.molted.email fills it in from your key.
agentNamestringYesAgent name. Must be unique per tenant.
agentRolestringNoAgent role. Defaults to general.
bindKeybooleanNoBind the calling API key to this agent. Defaults to false on this raw route (the CLI and MCP tool default it to true). Only true binds.
register_agent
{ "agent_name": "outreach-agent", "agent_role": "outbound" }

bind_key defaults to true.

Response

Response
{
  "id": "agent_1f3c9a2b-7d4",
  "tenantId": "tenant_abc123",
  "agentName": "outreach-agent",
  "agentRole": "outbound",
  "lastHeartbeat": "2026-03-01T12:00:00Z",
  "registeredAt": "2026-03-01T12:00:00Z",
  "apiKeyBinding": "bound"
}

The agent id is agent_ followed by 12 characters; use it (or the agent name) on every other coordination call.

apiKeyBinding reports what happened to the calling API key: bound (now linked to this agent), already_bound (a re-register with the same key and agent), bound_to_other_agent (this key is bound to a different agent already -- it is never re-pointed; the response also carries boundToAgentId), agent_has_other_key (a different key already answers for this agent, so this key was not bound), not_requested (bindKey was false or omitted), or not_applicable (there was no key to bind: the env key, a human portal session, or a system hop).

Binding is permanent. There is no unbind or rebind route today. bound_to_other_agent and agent_has_other_key still return 200 -- the agent registration itself succeeded, only the key binding did not -- so check apiKeyBinding rather than only the HTTP status. To act as a different agent, register a new API key.

Binding a key to an agent means every send that key makes is attributed to that agent and picks up its per-agent config (e.g. humanizer_enabled set via PUT /v1/agent/agents/:agentId/config), without an X-Agent-Id header -- which no first-party client (CLI, MCP) sends. The key's bound agent also decides the contact lease check, and it wins over an X-Agent-Id header. A key with no bound agent can name one per request with the X-Agent-Id header on a direct API call, but only an agent registered in your workspace that no API key is bound to; any other value is ignored and the send runs as no agent.

Heartbeat

A heartbeat marks the agent as active. Only consensus uses it: an agent counts as active for a vote while its last heartbeat (or registration, or config update) is less than 5 minutes old. Leases do not depend on heartbeats, so an agent that stops sending them keeps its leases until they expire.

CLI
molted agents heartbeat --agent-name outreach-agent
FlagTypeRequiredDescription
--agent-idstringNoAgent ID. Either --agent-id or --agent-name is required.
--agent-namestringNoAgent name (resolved to ID server-side).
POST https://mailbox.molted.email/v1/agent/coordination/heartbeat
curl
curl -X POST https://mailbox.molted.email/v1/agent/coordination/heartbeat \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agentName": "outreach-agent"
  }'
FieldTypeRequiredDescription
tenantIdstringNoOnly on api.molted.email, where it is required; mailbox.molted.email fills it in from your key.
agentIdstringNoAgent ID. Either agentId or agentName is required.
agentNamestringNoAgent name (resolved to ID server-side).
heartbeat
{ "agent_name": "outreach-agent" }

Response

Response
{
  "ok": true
}

An agent id or name that isn't registered to your tenant returns 404 agent_not_found, here and on every route below that takes one.

A key bound to an agent acts only as that agent on the heartbeat, lease, release-lease and consensus routes. Naming a different agent in the body is refused with 403 and { "error": "agent_mismatch", "keyAgentId": "..." }, a bound key may only release its own leases, and a body that names no agent uses the key's. A key with no bound agent names the agent in the body, as described for each route, but only an agent no API key is bound to: naming an agent another key is bound to is refused with 403 and { "error": "agent_bound_to_key", "agentId": "..." } (#2514), and such a key may only release the leases of agents no key is bound to. So a key can't list the leases and delete a bound agent's to get around it.

Contact leases

A lease claims a contact for one agent for a while. While a lease is held, a single send to that contact (POST /v1/agent/request-send, send_email, molted send, and their dry runs) is blocked with reason lease_conflict and a leaseHolder object, unless the send is made as the lease's own agent. Batch sends are not checked against leases.

A send is made "as" the agent its API key is bound to (see Register an agent: bindKey, which the CLI and MCP default to on, or agent adoption). That holds on the CLI, the MCP server and direct API calls alike, so a bound key that holds a lease can send to that contact. A key with no bound agent can name one per request with the X-Agent-Id header on a direct API call; when both are present the key's bound agent wins. The header is honoured only for an agent registered in your workspace that no API key is bound to: naming an agent another key is bound to (for example the lease holder), an unknown id or another workspace's agent is ignored. A send from a key with no agent at all is treated as any other agent and is blocked by someone else's lease. Replies through the mailbox (molted threads reply, the MCP reply tool) are made as the replying key's own agent too, so a lease holder can reply to its contact.

Acquire a lease

Acquiring a lease you already hold on the contact extends it to the new duration.

CLI
molted agents lease --agent-name outreach-agent \
  --contact-email user@example.com --intent outbound_sales --duration 30

Options

FlagTypeRequiredDescription
--agent-idstringNoAgent ID. Either --agent-id or --agent-name is required.
--agent-namestringNoAgent name (resolved to ID server-side).
--contact-emailstringYesContact email to lease.
--intentstringNoLease intent (e.g., outbound_sales). Defaults to general.
--durationintegerNoLease duration in minutes. Defaults to 30.
POST https://mailbox.molted.email/v1/agent/coordination/lease
curl
curl -X POST https://mailbox.molted.email/v1/agent/coordination/lease \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agentName": "outreach-agent",
    "contactEmail": "user@example.com",
    "intent": "outbound_sales",
    "durationMinutes": 30
  }'
FieldTypeRequiredDescription
tenantIdstringNoOnly on api.molted.email, where it is required; mailbox.molted.email fills it in from your key.
agentIdstringNoAgent ID. Either agentId or agentName is required.
agentNamestringNoAgent name (resolved to ID server-side).
contactEmailstringYesContact email to lease.
intentstringNoLease intent. Defaults to general.
durationMinutesnumberNoLease duration in minutes (fractions allowed). Defaults to 30.
acquire_lease
{ "agent_name": "outreach-agent", "contact_email": "user@example.com", "intent": "outbound_sales", "duration_minutes": 30 }

Response

Response
{
  "id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
  "tenantId": "tenant_abc123",
  "agentId": "agent_1f3c9a2b-7d4",
  "contactEmail": "user@example.com",
  "intent": "outbound_sales",
  "expiresAt": "2026-03-01T12:30:00Z",
  "createdAt": "2026-03-01T12:00:00Z"
}

If another agent already holds an unexpired lease on the contact, the call still returns 200, with the conflict instead of a lease:

Response (conflict)
{
  "conflict": true,
  "leaseHolder": {
    "agentId": "agent_9e8d7c6b-5a4",
    "agentName": "other-agent",
    "expiresAt": "2026-03-01T12:30:00Z"
  }
}

Release a lease

CLI
molted agents release-lease LEASE_ID
DELETE https://mailbox.molted.email/v1/agent/coordination/lease/:id
curl
curl -X DELETE "https://mailbox.molted.email/v1/agent/coordination/lease/LEASE_ID" \
  -H "Authorization: Bearer YOUR_API_KEY"
release_lease
{ "lease_id": "d290f1ee-6c54-4b01-90e6-d701748f0851" }

Response

Response
{
  "released": true
}

released is false when no lease with that id exists in your tenant (already released, or cleaned up after expiring). Any agent's key can release any lease in the tenant.

List active leases

Lists the tenant's unexpired leases, newest first. Needs a read scope on a key with access to every mailbox.

CLI
molted agents leases
GET https://mailbox.molted.email/v1/agent/coordination/leases
curl
curl "https://mailbox.molted.email/v1/agent/coordination/leases" \
  -H "Authorization: Bearer YOUR_API_KEY"

list_leases (no arguments), or read the molted://leases resource.

Response
[
  {
    "id": "d290f1ee-6c54-4b01-90e6-d701748f0851",
    "tenantId": "tenant_abc123",
    "agentId": "agent_1f3c9a2b-7d4",
    "contactEmail": "user@example.com",
    "intent": "outbound_sales",
    "expiresAt": "2026-03-01T12:30:00Z",
    "createdAt": "2026-03-01T12:00:00Z"
  }
]

Consensus voting

When agents need to make a shared decision, one of them creates a consensus request and the others vote. After each vote, with threshold = more than half of the active agents (and never fewer than one vote), the request is approved when approve votes reach the threshold and outnumber reject votes, rejected when reject votes reach it and outnumber approve votes, and otherwise stays pending. Votes already cast by agents that have since gone inactive still count, so both sides can reach the threshold at once; the larger side then wins and an exact tie decides nothing. Active agents are those with a heartbeat in the last 5 minutes (registering and updating an agent's config also count). With two active agents, both must agree; with three, two. A tie decides nothing: the request stays pending until it times out. With no active agent at all (every heartbeat older than 5 minutes), one vote decides, in whichever direction it was cast, so keep your voting agents' heartbeats fresh.

A decision is final. A vote on a request that is already approved, rejected or expired, or whose timeoutAt has passed, is refused with 409 and { "error": "consensus_closed", "status": "<current status>" }, and is not recorded.

A consensus request is a shared record for your agents to act on. Molted never carries out the proposed action itself, and no send is gated on the outcome.

Create a consensus request

CLI
molted agents consensus create --agent-name outreach-agent \
  --action "pause outreach" --contact-email ceo@bigcorp.com \
  --reason "Fatigue scores are high" --timeout 5

Options

FlagTypeRequiredDescription
--agent-idstringNoRequesting agent ID. Either --agent-id or --agent-name is required.
--agent-namestringNoRequesting agent name (resolved to ID server-side).
--actionstringYesThe proposed action.
--contact-emailstringYesContact email the action applies to.
--reasonstringYesReason for the request.
--timeoutintegerNoTimeout in minutes. Defaults to 5.
POST https://mailbox.molted.email/v1/agent/coordination/consensus
curl
curl -X POST https://mailbox.molted.email/v1/agent/coordination/consensus \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "proposerAgent": "outreach-agent",
    "action": "pause outreach",
    "contactEmail": "ceo@bigcorp.com",
    "reason": "Fatigue scores are high",
    "timeoutMinutes": 5
  }'
FieldTypeRequiredDescription
tenantIdstringNoOnly on api.molted.email, where it is required; mailbox.molted.email fills it in from your key.
requestingAgentIdstringNoRequesting agent ID. Either requestingAgentId or proposerAgent is required.
proposerAgentstringNoRequesting agent name (resolved to ID server-side).
actionstringYesThe proposed action. proposedAction is accepted as an alias.
contactEmailstringYesContact email the action applies to.
reasonstringYesReason for the request.
timeoutMinutesnumberNoTimeout in minutes. Defaults to 5.
create_consensus
{ "agent_name": "outreach-agent", "action": "pause outreach", "contact_email": "ceo@bigcorp.com", "reason": "Fatigue scores are high", "timeout_minutes": 5 }

Response

Response
{
  "id": "b4c8e7a2-3f19-4d5e-a621-9c1d8f3e7b0a",
  "tenantId": "tenant_abc123",
  "requestingAgentId": "agent_1f3c9a2b-7d4",
  "action": "pause outreach",
  "contactEmail": "ceo@bigcorp.com",
  "reason": "Fatigue scores are high",
  "status": "pending",
  "timeoutAt": "2026-03-01T12:05:00Z",
  "createdAt": "2026-03-01T12:00:00Z"
}

Get a consensus request

CLI
molted agents consensus get CONSENSUS_ID
GET https://mailbox.molted.email/v1/agent/coordination/consensus/:id
curl
curl "https://mailbox.molted.email/v1/agent/coordination/consensus/CONSENSUS_ID" \
  -H "Authorization: Bearer YOUR_API_KEY"

get_consensus with { "consensus_id": "..." }, or read the molted://consensus/{id} resource.

Returns the same shape as the create response, with the current status. The individual votes are not returned; each vote's response carries the status after that vote. An unknown id is 404.

Cast a vote

An agent voting again on the same request replaces its earlier vote.

CLI
molted agents consensus vote CONSENSUS_ID \
  --agent-id agent_9e8d7c6b-5a4 --vote approve --reason "Agreed, signals look bad"

Options

FlagTypeRequiredDescription
--agent-idstringYesVoting agent ID.
--votestringYesYour vote: approve or reject.
--reasonstringNoReason for your vote.
POST https://mailbox.molted.email/v1/agent/coordination/consensus/:id/vote
curl
curl -X POST https://mailbox.molted.email/v1/agent/coordination/consensus/CONSENSUS_ID/vote \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "agentId": "agent_9e8d7c6b-5a4",
    "vote": "approve",
    "reason": "Agreed, signals look bad"
  }'
FieldTypeRequiredDescription
tenantIdstringNoOnly on api.molted.email, where it is required; mailbox.molted.email fills it in from your key.
agentIdstringNoVoting agent ID. Either agentId or agentName is required; it must be registered to your tenant, or the vote returns 404.
agentNamestringNoVoting agent name (resolved to ID server-side).
votestringYesYour vote: approve or reject (anything else is 400).
reasonstringNoReason for your vote.
vote
{ "consensus_id": "b4c8e7a2-3f19-4d5e-a621-9c1d8f3e7b0a", "agent_id": "agent_9e8d7c6b-5a4", "vote": "approve" }

Vote response

Response
{
  "id": "e7f3a1b2-8c4d-4e6f-b123-7d8e9f0a1b2c",
  "consensusRequestId": "b4c8e7a2-3f19-4d5e-a621-9c1d8f3e7b0a",
  "agentId": "agent_9e8d7c6b-5a4",
  "vote": "approve",
  "reason": "Agreed, signals look bad",
  "consensusStatus": "approved",
  "createdAt": "2026-03-01T12:01:00Z"
}

The consensusStatus field reflects the request's status after your vote: pending until the approve or reject votes reach the majority above.

Consensus statuses

StatusDescription
pendingVoting is still open.
approvedapprove votes reached a strict majority of the active agents and outnumber reject votes.
rejectedreject votes reached a strict majority of the active agents and outnumber approve votes.
expiredThe request was still pending when its timeoutAt passed.

A background job moves pending requests past their timeoutAt to expired every 15 minutes, so a request can read pending for up to about 15 minutes after its timeoutAt. It no longer takes votes in that time: a vote past timeoutAt gets the 409 above.

Agent config

Each agent can store configuration that controls its sends. Today that is the humanizer settings.

Get config

CLI
molted agents config AGENT_ID
GET https://mailbox.molted.email/v1/agent/agents/:agentId/config
curl
curl "https://mailbox.molted.email/v1/agent/agents/agent_1f3c9a2b-7d4/config" \
  -H "Authorization: Bearer YOUR_API_KEY"

Returns the stored config object itself, e.g. { "humanizer_enabled": true, "humanizer_style": "professional" }, or {} when nothing is set (also for an agent id that doesn't exist).

Update config

CLI
molted agents update-config AGENT_ID \
  --config '{"humanizer_enabled": true, "humanizer_style": "professional"}'
PUT https://mailbox.molted.email/v1/agent/agents/:agentId/config
curl
curl -X PUT "https://mailbox.molted.email/v1/agent/agents/agent_1f3c9a2b-7d4/config" \
  -H "Authorization: Bearer YOUR_API_KEY" \
  -H "Content-Type: application/json" \
  -d '{
    "humanizer_enabled": true,
    "humanizer_style": "professional"
  }'

The keys you send are merged into the stored config. The response is { "agentId": "...", "config": { ... }, "updated": true }, plus warnings when a key was ignored. Updating the config also counts as a heartbeat.

Allowed config keys

KeyTypeDescription
humanizer_enabledbooleanEnable AI email humanization for this agent. Turning it on needs a plan that includes the humanizer.
humanizer_stylestringHumanizer writing style: casual, professional, or friendly.
humanizer_providerstringAccepted for backward compatibility but no longer a choice: Molted always rewrites with Claude Sonnet 5.5. A value here is ignored and reported back in warnings as humanizer_provider_ignored; reads always come back anthropic.

Other keys are dropped. A body with none of the keys above is rejected, as is an agent id that isn't registered to your tenant; today both come back as a generic HTTP 500 rather than a 400 or 404, so check the agent id and keys before retrying.

This agent's own style overrides the mailbox's and the tenant's. Its on/off overrides the tenant's, but an explicit off wins over an on: a mailbox turned off stays off for this agent's sends, and this agent turned off stays off on every mailbox. A humanize: false/humanizeStyle field on a specific send from this agent can still override it in turn -- see Humanizer precedence. Turning it off always works from an agent key; turning it on, or changing the style while it is on, is a human decision. See Loosening a control.

Events

Coordination changes are published on the event stream: coordination.lease_acquired (a new lease, not an extension), coordination.lease_released and coordination.consensus_requested. They don't belong to one mailbox, so a mailbox-scoped key never receives them.