Communication and Email
This guide covers the comm pack: actor-addressed messaging inside khive, and the optional email channel that bridges that same messaging model to an external mailbox.
What messages are
Messages are notes with kind=message, managed by the comm pack (crates/khive-pack-comm/). comm.send writes both an outbound copy (in the sender’s namespace) and an inbound copy (addressed to the recipient), so a send always produces two notes and no cross-namespace write occurs even when to names a different actor.
New messages follow the stable properties v1 contract. Workers may set KHIVE_PROCESS_REF to an opaque run or job reference; comm.send and comm.reply then persist it verbatim as sent_by_process on both copies. It is provenance only and does not change addressing or authorization.
Actor addressing
Actors are labeled strings such as lambda:leo or lambda:khive. comm.send stores the caller’s actor label as from_actor and the to argument as to_actor on both the outbound and inbound copies.
request(ops="comm.send(to=\"lambda:leo\", content=\"PR #610 merged\")")
comm.inbox filters by to_actor for the calling actor. Legacy messages written before actor addressing existed have no to_actor field and remain visible to every actor (an EqOrMissing match), so older history is not hidden by the newer filter.
Send
| Param | Type | Required | Notes |
|---|---|---|---|
to | string | yes | Actor label, e.g. "lambda:leo". |
content | string | yes | Message body. Must not be empty. |
subject | string | no | Optional subject line. |
thread_id | uuid | no | Optional full thread UUID. Prefixes are rejected because a thread root is an explicit stable reference. Accepted complete spellings normalize to canonical lowercase dashed form. |
self_send | bool | no | Default false. Required when to matches the configured sender actor; otherwise the send is rejected. The anonymous local fallback is exempt. Use true only for an intentional note to self. |
A configured actor that addresses itself must opt in with self_send=true. If the target was meant to be a distinct parent or sub-agent, configure distinct actor identities instead of opting in; the rejection is intended to expose that identity collapse.
The response includes canonical 36-character full_id and thread_id handles even in Agent mode. Reuse the returned thread_id unchanged to send another message in that thread.
Confirm an uncertain internal delivery
Rows, FTS documents, and vectors for a send/reply pair commit atomically. Ordinary failures leave neither copy. If the writer cannot establish whether an accepted request committed, the operation instead returns an ambiguous error containing outbound_id=<full-uuid>. Confirm the paired inbound write before retrying:
request(ops="comm.delivered(id=\"<full-outbound-uuid>\")")
A successful lookup returns status="delivered" and delivered=true when at least one live inbound note has properties.outbound_ref equal to that UUID. It returns status="undelivered", delivered=false, and inbound_count=0 when none does. The lookup does not require the outbound row to remain present and never compares message bodies. It is scoped to the caller’s namespace and sender actor, so another actor cannot inspect a sender’s outcome even if it learns the correlation UUID. If the lookup itself errors, the outcome is still uncertain.
This operation confirms khive’s internal inbound sibling only. It does not confirm asynchronous SMTP or another external transport; see How outbound delivery works for that separate state machine.
If the entire MCP response is lost, the caller receives neither the result nor the structured error and therefore does not know the server-generated UUID. Use a caller-chosen key for that wider response-loss case.
Recover a send or reply by key
comm.send and comm.reply accept an optional idempotency_key. Pin the same sending actor, write namespace, key and request across attempts:
first = session.send("actor:recipient", "Ready for review", subject="Update",
idempotency_key="update-42", namespace="project")
recovery = session.send("actor:recipient", "Ready for review", subject="Update",
idempotency_key="update-42", namespace="project")
A successful first call returns replayed: false; a matching replay returns replayed: true with the original full_id, recipient_id, thread_id and sent_at. It creates no message copies, sends no new inbox signal, and does not repeat a reply’s parent mark-read. Python Session.send and Session.reply return the raw per-operation outcome, including errors; they do not retry an operation error or a lost response automatically.
Keys are exact strings of at most 512 UTF-8 bytes and cannot contain U+0000. Empty keys and other control characters are allowed. Different sending actors or explicit write namespaces have independent keys. Matching requests preserve the operation kind, trimmed recipient, exact body, optional subject, optional caller-supplied thread UUID, and tags. Omitted tags equal an empty list; order and duplicates otherwise matter. For replies, the resolved original message UUID also matters: a prefix and full spelling of that same UUID match, while two distinct parent notes do not. Omitted send thread IDs remain distinct from explicitly supplying the generated root UUID on a later call.
Reusing a live key for different mail, or finding a missing or inconsistent recipient copy, returns a structured conflict with details.reason=key_conflict, the logical key, existing_id, and domain_disposition=not_committed for that attempt. No repair is performed. If a competing claim’s holder disappears during reconciliation, the outcome is unknown; the server does not silently start a fresh insert.
Both copies expose properties.idempotency_key through inbox and thread readback; fields=["full_id", "idempotency_key"] projects it directly. Marking an inbound message read retains the property. Outbound messages keep their existing mark-read refusal. Deleting the outbound claim releases the key: reuse can create a new pair even if the old inbound copy remains. This is recovery while the live claim exists, not permanent deduplication. Calls without a key keep their existing behavior and create a new pair each time. Older servers reject the unsupported idempotency_key argument instead of silently sending unkeyed mail. The client preserves that error without fallback.
Inbox
| Param | Type | Required | Notes |
|---|---|---|---|
limit | integer | no | Default 20, max 200. |
box | string | no | inbox (default) | sent; sent rows are scoped to the caller. |
offset | integer | no | Default 0; offset in the fully-filtered newest-first result set. |
status | string | no | Inbox-only: "unread" (default) | "read" | "all". |
wait_ms | integer | no | Long-poll only when the initial page is empty; default 0, max 30,000. |
from_actor | string | no | Exact sender; mutually exclusive with from_prefix. |
from_prefix | string | no | Sender prefix; mutually exclusive with from_actor. |
exclude_from_actor | string | no | Exclude an exact sender actor label. |
to_actor | string | no | Sent-only exact recipient actor filter. |
since | string | no | Inclusive RFC 3339 lower bound on response created_at. |
before | string | no | Exclusive RFC 3339 upper bound on response created_at. |
subject_contains | string | no | Case-insensitive non-empty subject substring; missing subjects do not match. |
content_contains | string | no | Case-insensitive non-empty body substring. |
fields | array | no | Non-empty message-field projection shared with comm.thread. |
request(ops="comm.inbox(limit=10)")
request(ops="comm.inbox(status=\"all\")")
request(ops="comm.inbox(status=\"all\", content_contains=\"timeout\", since=\"2026-07-31T00:00:00Z\")")
request(ops="comm.inbox(box=\"sent\", to_actor=\"lambda:leo\", fields=[\"id\",\"subject\",\"sent_at\"])")
request(ops="comm.inbox(limit=10, wait_ms=30000)")
Responses include offset, has_more, and next_offset. Repeat the same call with offset=<next_offset> until next_offset is null to enumerate every matching message without changing its read state. Filters are ANDed and offsets apply after all filters. Time bounds use the always-present top-level created_at, not optional transport sent_at metadata.
Omit fields for the full message body and properties. When supplied, it is a strict whitelist shared with comm.thread; unknown fields and an empty list fail loudly. Stable property aliases such as from_actor, to_actor, and sent_at can be projected directly.
Long-polling preserves that paginated response shape and every actor/status/ sender/time/text filter, including the requested offset. Existing matches return immediately. A new committed message wakes the call and causes the full filtered query to run again; unrelated messages cannot leak through or end the wait early. limit=0 remains immediate.
Read and mark
comm.read fetches inbound messages and marks them read. Successful results include subject, content, from, to, direction, and created_at; pass body=false to retain the previous acknowledgement-only shape. comm.mark_read is the named bulk acknowledgement and does not return message content. Outbound messages cannot be marked read.
request(ops="comm.mark_read(ids=[\"<message_id_1>\", \"<message_id_2>\"])")
request(ops="comm.mark_read(ids=[\"<message_id_1>\", \"<message_id_2>\"], atomic=true)")
request(ops="comm.read(id=\"<message_id_or_prefix>\")")
request(ops="comm.read(ids=[\"<message_id_1>\", \"<message_id_2>\"])")
request(ops="comm.read(id=\"<message_id_or_prefix>\", body=false)")
comm.mark_read requires ids with 1-500 full UUIDs or 8-character hex prefixes. It validates every target before mutation, deduplicates resolved IDs, and returns ordered results plus requested_count, unique_count, marked_count, unknown_count, and failed_count. Each item carries status=success|failed|unknown; the aggregate carries status=success|partial|failed|unknown. The default atomic=false reuses the best-effort bulk behavior: later storage failures appear in each result’s read=false and mark_error without rolling back an earlier success. A write whose execution seam terminated after being accepted (so it may already have applied) instead carries status=unknown, read=null — check the message’s current state through comm.inbox before re-issuing that mark; re-issuing is safe, since marking a message read is idempotent. With atomic=true, all unique marks are guarded and committed in one transaction; any failed recheck or storage statement rolls back the full set.
comm.read remains compatible with the 0.7.0 surface: exactly one of id or ids is required, and its bulk form remains best-effort. Prefer the named verb for new bulk callers.
Reply
Replies thread against the original message. If the original had no subject, the reply carries no subject either; otherwise the reply subject is prefixed Re: (and not re-prefixed if it already starts with Re:).
request(ops="comm.reply(id=\"<message_id_or_prefix>\", content=\"Thanks, following up now\")")
Thread
Retrieves every message in a conversation thread, ordered chronologically, given the thread root’s id.
request(ops="comm.thread(id=\"<root_message_id_or_prefix>\", limit=50)")
request(ops="comm.thread(id=\"<root_message_id_or_prefix>\", fields=[\"id\",\"from_actor\",\"sent_at\"])")
limit defaults to 100 and caps at 500. fields uses the same strict projection vocabulary as comm.inbox; omission returns the full thread view.
Health
comm.health() is a read-only, no-argument verb that reports per-channel polling state, keyed by (channel_kind, channel_slug). It never returns a computed healthy boolean. It does expose the nominal cadence and a narrower, nullable schedule-staleness advisory; overall health judgment stays with the caller. Quarantine counts are orthogonal: a successful poll can remain current while one or more terminally parked messages require operator attention.
request(ops="comm.health()")
Each entry in the returned channels array carries:
| Field | Notes |
|---|---|
channel_kind | e.g. "email". |
channel_slug | Per-credential identifier (the configured mailbox address for the email channel), so two accounts of the same channel_kind get distinct rows. |
poll_interval_secs | Positive nominal/minimum poll cadence, or null for a legacy/malformed heartbeat row. |
stalled | Advisory schedule staleness: true after three missed nominal intervals, false when current, or null when the facts are unknown or backoff is active. |
last_success_at | Timestamp of the most recent successful poll attempt, or null. |
last_failure_at | Timestamp of the most recent failed poll attempt, or null. |
last_poll_attempt_at | Timestamp of the most recent poll attempt regardless of outcome. |
last_error | {class, message, at} of the most recent failure, or null when none was observed. class is an open enum (auth, transport, config, or a future value). |
consecutive_failures | Resets to 0 on success, increments on failure, or null for a quarantine-only identity with no heartbeat evidence. |
quarantined_count | Live parked messages carrying this exact channel identity. |
last_error is retained after a later success: a success updates last_success_at and resets consecutive_failures to 0 but never clears last_error. Compare last_error.at against last_success_at to tell a resolved failure from one that is still live.
stalled is computed against the response’s single as_of timestamp and is deliberately not a supervisor verdict. The loop polls channels sequentially, and a transport operation can run longer than the nominal interval, so true means the persisted schedule is overdue; it does not prove the task is dead. Rows with consecutive_failures > 0 report stalled: null because intentional exponential backoff can exceed the nominal cadence. ADR-119’s component supervisor remains responsible for authoritative hung-task detection/restart.
comm.heartbeat persists under its dispatch-authorized namespace (token.namespace(), khive #917). The shipped local poll loop explicitly dispatches heartbeat writes to the fixed local operational namespace, regardless of KHIVE_EMAIL_INGEST_NAMESPACE; an authorized per-tenant writer can instead write its own namespace. comm.health reads from the caller’s injected namespace, the same namespace= escape / "local" default every other comm verb resolves. A scoped read never falls back to "local". The response carries a namespace field naming the namespace actually read. It also carries namespace-wide quarantined_count and unattributed_quarantined_count totals. A quarantine identity with no heartbeat row in that namespace is included as a channel entry with nullable heartbeat fields.
The role field is "daemon" (with source: "daemon-heartbeat") whenever any persisted heartbeat row exists in the namespace read, and "client" otherwise. A client-role response can contain quarantine-only channel entries; those rows are message evidence, not fabricated daemon ownership. This distinguishes who owns the channel loops, not which process answered the call.
Inspect parked rows with the full comm.inbox(status="all") view and the properties.quarantined marker, then use get(id=...) for detail. delete(id=...) removes one from the live parked count; hard=true permanently purges it. No automatic trusted release exists because quarantine means the attribution gate did not establish a sender identity. Generic message create/update cannot set or clear quarantined, channel_kind, or channel_slug; comm.ingest is the only supported writer for those transport-owned facts.
Known ambiguity: an empty channels array cannot distinguish “no daemon has ever run” from “channels are configured but a poll has never completed.” The comm pack has no visibility into channel configuration (that lives in khive-mcp / khive-channel-email), so role: "client" with an empty channels array means only “no daemon heartbeat state exists in the namespace read,” not “nothing is configured.” The namespace field disambiguates which namespace that is. A call scoped to a non-local namespace= returns role: "client" until an authorized writer has produced heartbeat state there, even while the shipped local loop is actively heartbeating under "local"; its channels array may still contain quarantine-only message evidence. Check the response’s namespace field before reading that role as “no daemon running.”
Results are capped at 200 channels. Heartbeat rows take precedence and retain their persisted order; quarantine-only identities fill remaining capacity in lexical (channel_kind, channel_slug) order. Later identities are omitted, but the top-level quarantine totals remain namespace-wide. This ordering prevents a real heartbeat beyond a full heartbeat page from being presented as a synthetic unknown-liveness row.
The email channel
The email channel (crates/khive-channel-email/) bridges comm.send / comm.inbox to a real mailbox over SMTP and IMAP. It is not part of the default build; see Feature gating below.
Addressing an email recipient
Send to an email address by prefixing to with email: and passing an explicit subject. Because the outbox loop reads subject off the stored note, a mail sent without subject goes out with (no subject) in the subject line.
request(ops="comm.send(to=\"email:prof.sheng@example.edu\", subject=\"Draft ready for review\", content=\"...\")")
How outbound delivery works
comm.send and comm.reply do not talk to SMTP directly. The serving host resolves one recipient policy at boot, independently of connector startup or credentials, and shares it with admission and the outbox. A configured denied recipient is refused before a fresh message is written. Nonempty trimmed KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS entries take precedence; otherwise, including when that variable holds only blanks and separators, the first configured maintainer address is the fallback. Entries and requested recipients use the same normalization: display names are removed and the addr-spec has its ASCII letters lowercased; every other character compares exactly. Stored recipient spelling is unchanged. A refusal names the requested recipient in that normalized form, or as requested when it does not parse as an address. Invalid or unreadable configured policy prevents startup, as does a set but empty policy with no fallback recipient. A KHIVE_EMAIL_MAINTAINER_ADDRESS that is set is read the way the email connector reads it at start: comma separated, trimmed, empty values dropped, every remaining value must parse and at least one must remain. A value that fails prevents startup even beside a valid explicit list. An unset maintainer beside a valid explicit list is configured: allowed recipients are admitted and their messages stay pending while the connector cannot start, and the start-up warning names the missing value. With neither policy variable set, messages can still queue for later delivery, without a new backlog age cutoff.
An exact keyed retry returns the original committed pair even if policy has since changed to deny a fresh send. It does not reset delivery state or request retransmission. A fresh denied keyed request claims no key; if another request commits after its final no-holder observation, a later retry can retrieve that receipt.
A background outbox loop polls every 5 seconds for undelivered outbound notes. A note is eligible only when it has no terminal delivery value and its optional RFC 3339 next_attempt_at deadline is due:
list(namespace=<ingest_namespace>, kind="message", direction="outbound", delivered=false, limit=200)
Delivery requires a configured policy; starting it with absent policy returns a permanent component error without sending or restarting. The loop selects eligible email rows and defensively checks each recipient against the same policy, including messages queued by an earlier process. Stored Message-ID verification precedes recipient classification, so an unverifiable ID remains a visible hold. Passing notes are sent over SMTP, using the note’s subject, content, and any thread_id/in_reply_to_message_id/references_chain properties to set the RFC 822 Message-ID, In-Reply-To, and References headers so replies group correctly in native mail clients.
Delivery outcomes are durable note properties. Success records delivery="delivered" and delivered_at; a definitive configuration, authentication, allowlist, SMTP 5xx, or transport-specific client rejection records terminal delivery="failed", failed_at, and last_error. Network failures, token-endpoint pressure, SMTP 4xx responses, Telegram 408/429/5xx responses, and similar transient errors leave the note pending, increment delivery_attempts, and set next_attempt_at using exponential backoff from 5 seconds to a 30-minute ceiling. A Telegram 429 with retry_after raises the retry delay to at least that interval and pauses the Telegram outbound pass; polls skip the note until its deadline. Telegram outbound delivery is at-least-once without receiver-side deduplication: if Telegram accepts a send but its response is lost, or the daemon stops before recording delivery, a retry can post the same message twice. There is no attempt-count promotion to failure; a later successful delivery clears delivery_attempts and next_attempt_at.
How inbound ingestion works
A separate poll loop reads the IMAP mailbox every 5 seconds and, for each new message, calls the pack-internal comm.ingest subhandler (not callable directly over the MCP wire) with the parsed envelope: from, to, content, subject, channel_kind, the exact per-credential channel_slug, external_id (an IMAP-derived dedup key of the form imap:{host}:{uidvalidity}:{uid}), sent_at, and the wire threading fields wire_message_id / wire_references. Every channel poller must supply both Channel::kind() and Channel::slug(); kind alone cannot distinguish two accounts using the same adapter. Duplicate external_id values are ignored, making re-delivery idempotent.
Configuration
EmailChannelConfig::from_env reads configuration exclusively from environment variables; there is no file-based config for this channel. See Configuration for the full khive-wide environment variable reference. The email-specific variables are:
Required:
KHIVE_EMAIL_SMTP_HOSTKHIVE_EMAIL_IMAP_HOSTKHIVE_EMAIL_USERNAMEKHIVE_EMAIL_MAINTAINER_ADDRESS(comma-separated; the first entry is primary and used for outbound-allowlist defaulting)KHIVE_EMAIL_AUTHSERV_ID(the trust anchor for validating inboundAuthentication-Resultsheaders; the reserved value!topmost-no-authserv-idselects trust of the topmost header when the receiving boundary emits noauthserv-idat all, as with Exchange Online’s internal-hop stamp)
Auth mode (choose one):
- Basic:
KHIVE_EMAIL_PASSWORD - OAuth (Exchange Online app-only client-credentials flow):
KHIVE_EMAIL_OAUTH_CLIENT_ID,KHIVE_EMAIL_OAUTH_TENANT_ID,KHIVE_EMAIL_OAUTH_CLIENT_SECRET(all three required together; a partial set is a config error, never a silent fallback to Basic)
Optional, with defaults:
KHIVE_EMAIL_SMTP_PORT(default587)KHIVE_EMAIL_IMAP_PORT(default993)KHIVE_EMAIL_MAILBOX(default: same asKHIVE_EMAIL_USERNAME)KHIVE_EMAIL_QUARANTINE_STORE(defaulttrue; when a message fails the sender-authentication or allowlist gate, store it as an unattributed quarantine record instead of dropping it)KHIVE_EMAIL_QUARANTINE_MAX_RETAINED(default256; the most live quarantine records that may hold a stored copy of the original message. Past that count a quarantined message is still recorded, without its original bytes and withquarantine_original_retained: "false";0stores no originals)KHIVE_EMAIL_INGEST_NAMESPACE(defaultlocal; target namespace for ingested messages)KHIVE_EMAIL_DEFAULT_ACTOR(defaultchannel:email; actor assigned to fresh, uncorrelated email messages. Set it tolocalonly to retain the previous shared inbox routing.)KHIVE_EMAIL_SEND_ALLOWED_RECIPIENTS(comma-separated outbound allowlist; falls back to the maintainer address when unset or when it holds only blanks and separators)
An anonymous local caller cannot read the delegated channel:email mailbox; the mailbox gate denies that read with mailbox_read_not_granted. To grant a reader, configure the process that owns the mailbox with its actor id and the exact reader actor labels:
[actor]
id = "channel:email"
mailbox_readers = ["lambda:email-reader"]
The listed actor can read it by selecting the mailbox explicitly:
request(ops="comm.inbox(mailbox_actor=\"channel:email\")")
The request must resolve to one of the configured reader actor labels.
Sender labels are not proof of origin. Ingested mail is stored with the sender label email:<address>, but a local caller chooses its own actor label, and that label becomes from_actor on what it sends. A comm.send run with KHIVE_ACTOR=email:forged@example.com is stored with from_actor email:forged@example.com, and comm.inbox(from_prefix="email:") returns it beside ingested mail. Use from_prefix="email:" to filter by sender label; it does not show that a message arrived through the email channel.
Feature gating
channel-email is an optional Cargo feature (crates/khive-mcp/Cargo.toml), not compiled into the plain cargo build --workspace --release invocation used by make build or by any release/CI workflow. It is enabled explicitly by make local (cargo build --release --features channel-email). A binary built without this feature has no email channel code at all: to="email:..." sends still write a note (the comm pack has no awareness of channels), but nothing polls IMAP or drains the outbox, so the message is never delivered.
Daemon-only channel loops
The email poll loop and the outbox loop are spawned only by the persistent daemon process (kkernel mcp --daemon), never by a plain stdio kkernel mcp client. This is a deliberate role gate (issue #602): before it existed, every stdio client process spawned its own independent IMAP poll loop against the same mailbox, and nine concurrent pollers exhausted Exchange Online’s per-mailbox connection slots, taking inbound email down for about 19 hours on 2026-07-04.
The gate logs one line at startup either way, so the decision is observable:
email channel loops: spawning (daemon role)
email channel loops: skipped (client role; daemon owns channel loops)
If the ingest namespace fails authorization, the loops are not started at all (fail-closed) and this is logged separately:
email channel loops NOT started: ingest namespace authorization failed (fail-closed)
If no daemon is running, mail is simply not polled until one starts. That is the intended behavior, not a silent failure. Once demand-mode retirement (ADR-049 Amendment 11) ships, a daemon that a client started automatically does not run these loops either; start it explicitly with kkernel mcp --daemon (or under a supervisor) to have mail polled and delivered.
Limitations
Actor addressing (to_actor filtering on comm.send/comm.inbox) is a view-layer convention for cooperating, co-located actors, not a security boundary (ADR-063). Any process with access to the underlying SQLite store can read every message row regardless of to_actor, and there is no per-principal storage partition on the local backend. Where authorization is enforced, it lives at a single seam, the Gate (ADR-018), not at the comm pack’s inbox filter.
See also
- Agent Sessions and Data Ingest: a different ingestion path, transcript mirroring rather than message channels.
- Configuration: the full environment variable reference.
Raw markdown for this page: /md/communication.md