# Agent Coordination

Source: https://molted.email/docs/agents

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](https://molted.email/docs/mcp-server.md#coordination)).

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**

```bash title="CLI"
molted agents register --agent-name "outreach-agent" --agent-role outbound
```

### Options

| Flag | Type | Required | Description |
| - | - | - | - |
| `--agent-name` | `string` | Yes | Agent name. Must be unique per tenant. |
| `--agent-role` | `string` | No | Agent role (e.g., `outbound`, `inbound`, `classifier`). Defaults to `general`. |
| `--metadata` | `json` | No | A JSON object sent along as `metadata`. Accepted but not stored today. |
| `--no-bind-key` | flag | No | Skip 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.

**API**

```
POST https://mailbox.molted.email/v1/agent/register
```

```bash title="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
  }'
```

| Field | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `agentName` | `string` | Yes | Agent name. Must be unique per tenant. |
| `agentRole` | `string` | No | Agent role. Defaults to `general`. |
| `bindKey` | `boolean` | No | Bind 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. |

**MCP**

```json title="register_agent"
{ "agent_name": "outreach-agent", "agent_role": "outbound" }
```

`bind_key` defaults to `true`.

### Response

```json title="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](#contact-leases) 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**

```bash title="CLI"
molted agents heartbeat --agent-name outreach-agent
```

| Flag | Type | Required | Description |
| - | - | - | - |
| `--agent-id` | `string` | No | Agent ID. Either `--agent-id` or `--agent-name` is required. |
| `--agent-name` | `string` | No | Agent name (resolved to ID server-side). |

**API**

```
POST https://mailbox.molted.email/v1/agent/coordination/heartbeat
```

```bash title="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"
  }'
```

| Field | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `agentId` | `string` | No | Agent ID. Either `agentId` or `agentName` is required. |
| `agentName` | `string` | No | Agent name (resolved to ID server-side). |

**MCP**

```json title="heartbeat"
{ "agent_name": "outreach-agent" }
```

### Response

```json title="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](#register-an-agent): `bindKey`, which the CLI and MCP default to on, or [agent adoption](https://molted.email/docs/adoption.md)). 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**

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

### Options

| Flag | Type | Required | Description |
| - | - | - | - |
| `--agent-id` | `string` | No | Agent ID. Either `--agent-id` or `--agent-name` is required. |
| `--agent-name` | `string` | No | Agent name (resolved to ID server-side). |
| `--contact-email` | `string` | Yes | Contact email to lease. |
| `--intent` | `string` | No | Lease intent (e.g., `outbound_sales`). Defaults to `general`. |
| `--duration` | `integer` | No | Lease duration in minutes. Defaults to 30. |

**API**

```
POST https://mailbox.molted.email/v1/agent/coordination/lease
```

```bash title="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
  }'
```

| Field | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `agentId` | `string` | No | Agent ID. Either `agentId` or `agentName` is required. |
| `agentName` | `string` | No | Agent name (resolved to ID server-side). |
| `contactEmail` | `string` | Yes | Contact email to lease. |
| `intent` | `string` | No | Lease intent. Defaults to `general`. |
| `durationMinutes` | `number` | No | Lease duration in minutes (fractions allowed). Defaults to 30. |

**MCP**

```json title="acquire_lease"
{ "agent_name": "outreach-agent", "contact_email": "user@example.com", "intent": "outbound_sales", "duration_minutes": 30 }
```

### Response

```json title="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:

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

### Release a lease

**CLI**

```bash title="CLI"
molted agents release-lease LEASE_ID
```

**API**

```
DELETE https://mailbox.molted.email/v1/agent/coordination/lease/:id
```

```bash title="curl"
curl -X DELETE "https://mailbox.molted.email/v1/agent/coordination/lease/LEASE_ID" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**MCP**

```json title="release_lease"
{ "lease_id": "d290f1ee-6c54-4b01-90e6-d701748f0851" }
```

### Response

```json title="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**

```bash title="CLI"
molted agents leases
```

**API**

```
GET https://mailbox.molted.email/v1/agent/coordination/leases
```

```bash title="curl"
curl "https://mailbox.molted.email/v1/agent/coordination/leases" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**MCP**

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

```json title="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**

```bash title="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

| Flag | Type | Required | Description |
| - | - | - | - |
| `--agent-id` | `string` | No | Requesting agent ID. Either `--agent-id` or `--agent-name` is required. |
| `--agent-name` | `string` | No | Requesting agent name (resolved to ID server-side). |
| `--action` | `string` | Yes | The proposed action. |
| `--contact-email` | `string` | Yes | Contact email the action applies to. |
| `--reason` | `string` | Yes | Reason for the request. |
| `--timeout` | `integer` | No | Timeout in minutes. Defaults to 5. |

**API**

```
POST https://mailbox.molted.email/v1/agent/coordination/consensus
```

```bash title="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
  }'
```

| Field | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `requestingAgentId` | `string` | No | Requesting agent ID. Either `requestingAgentId` or `proposerAgent` is required. |
| `proposerAgent` | `string` | No | Requesting agent name (resolved to ID server-side). |
| `action` | `string` | Yes | The proposed action. `proposedAction` is accepted as an alias. |
| `contactEmail` | `string` | Yes | Contact email the action applies to. |
| `reason` | `string` | Yes | Reason for the request. |
| `timeoutMinutes` | `number` | No | Timeout in minutes. Defaults to 5. |

**MCP**

```json title="create_consensus"
{ "agent_name": "outreach-agent", "action": "pause outreach", "contact_email": "ceo@bigcorp.com", "reason": "Fatigue scores are high", "timeout_minutes": 5 }
```

### Response

```json title="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**

```bash title="CLI"
molted agents consensus get CONSENSUS_ID
```

**API**

```
GET https://mailbox.molted.email/v1/agent/coordination/consensus/:id
```

```bash title="curl"
curl "https://mailbox.molted.email/v1/agent/coordination/consensus/CONSENSUS_ID" \
  -H "Authorization: Bearer YOUR_API_KEY"
```

**MCP**

`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**

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

### Options

| Flag | Type | Required | Description |
| - | - | - | - |
| `--agent-id` | `string` | Yes | Voting agent ID. |
| `--vote` | `string` | Yes | Your vote: `approve` or `reject`. |
| `--reason` | `string` | No | Reason for your vote. |

**API**

```
POST https://mailbox.molted.email/v1/agent/coordination/consensus/:id/vote
```

```bash title="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"
  }'
```

| Field | Type | Required | Description |
| - | - | - | - |
| `tenantId` | `string` | No | Only on `api.molted.email`, where it is required; `mailbox.molted.email` fills it in from your key. |
| `agentId` | `string` | No | Voting agent ID. Either `agentId` or `agentName` is required; it must be registered to your tenant, or the vote returns `404`. |
| `agentName` | `string` | No | Voting agent name (resolved to ID server-side). |
| `vote` | `string` | Yes | Your vote: `approve` or `reject` (anything else is `400`). |
| `reason` | `string` | No | Reason for your vote. |

**MCP**

```json title="vote"
{ "consensus_id": "b4c8e7a2-3f19-4d5e-a621-9c1d8f3e7b0a", "agent_id": "agent_9e8d7c6b-5a4", "vote": "approve" }
```

### Vote response

```json title="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

| Status | Description |
| - | - |
| `pending` | Voting is still open. |
| `approved` | `approve` votes reached a strict majority of the active agents and outnumber `reject` votes. |
| `rejected` | `reject` votes reached a strict majority of the active agents and outnumber `approve` votes. |
| `expired` | The 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**

```bash title="CLI"
molted agents config AGENT_ID
```

**API**

```
GET https://mailbox.molted.email/v1/agent/agents/:agentId/config
```

```bash title="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**

```bash title="CLI"
molted agents update-config AGENT_ID \
  --config '{"humanizer_enabled": true, "humanizer_style": "professional"}'
```

**API**

```
PUT https://mailbox.molted.email/v1/agent/agents/:agentId/config
```

```bash title="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

| Key | Type | Description |
| - | - | - |
| `humanizer_enabled` | `boolean` | Enable AI email humanization for this agent. Turning it on needs a plan that includes the humanizer. |
| `humanizer_style` | `string` | Humanizer writing style: `casual`, `professional`, or `friendly`. |
| `humanizer_provider` | `string` | Accepted 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](https://molted.email/docs/humanizer.md#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](https://molted.email/docs/errors.md#loosening-a-control).

## Events

Coordination changes are published on the [event stream](https://molted.email/docs/events.md#coordination-events): `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.

## Related

- [MCP Server](https://molted.email/docs/mcp-server.md#coordination) -- the coordination tools
- [Autonomy Levels](https://molted.email/docs/autonomy-levels.md) -- control how much human approval each mailbox requires
- [Sending Email](https://molted.email/docs/sending-email.md) -- how the policy engine evaluates send requests
- [Approval Queues](https://molted.email/docs/approval-queues.md) -- managing held sends
