# Local Agent Messaging Guide

MeMesh provides two complementary collaboration surfaces on one machine:

- shared durable memory, including the `team` namespace, for knowledge, decisions, and coarse handoffs;
- the `message` tool for explicit durable messages to one named recipient on the same MeMesh instance.
- `message discover` for a bounded, project-scoped view of currently live host registrations.

Use `memesh message discover --project <name> [--limit 1..100]` to read the
router directory. Results expose `session_id`, `principal_id`, `host_kind`,
`project`, `model` (always `null`: no host tells MeMesh which model a session
runs, so none is shown; the key stays for older clients), declared
`work_summary` (`null` when absent), `active`, `generation`, and
`lease_expires_at_ms`. Discovery performs no send, fetch, ACK, replay, or
receipt operation; router unavailability is an explicit error, never an empty
directory.

Briefing follows the same trust boundary. Generic `briefing` has no recipient
identity, so it never aggregates or announces unread message activity, and
neither do the SessionStart and prompt hooks unless the session declares one:
start it with `MEMESH_RECIPIENT=<exact recipient id>` (for example
`MEMESH_RECIPIENT=claude-implementer claude`). The hooks then say, at session
start and at every prompt, how many messages are waiting for exactly that
recipient and in which project to poll (senders choose the project string, so
each project with a waiting message is named, up to five, most waiting first),
until the session records the `intake` action for them (fetching alone does not
end it). An ordinary Claude Code session started without the channel flag
has no other identity a sender could address, so without this variable — and
without the owner-private fallback below — it only sees messages it polls for
itself. A value over 200 characters, or shaped like
a filesystem path (the same rule the `message` tool refuses a recipient by —
`/root`, `C:\work`, `\\host\share`), is refused rather than silently accepted,
with no database required: one line on stderr (`MEMESH_RECIPIENT ignored: ...`),
one `notified` record in `hook-outcomes.jsonl`, and — since a hook that exits 0
never shows its stderr to the user or the model — the same rejection text in
SessionStart's own visible output, every session start.

The lookup behind that reminder is not limited to messages addressed to the
recipient string itself (#490). A message sent with `target_kind: "session"`
is addressed to the HOST's own `session_instance_id`, not to `MEMESH_RECIPIENT`
or its owner-private fallback — a different string an ordinary reminder would
never match. SessionStart, the prompt hook and the Stop gate below all also
find a delivery like that when the target session is registered, in the
delivery's own project, under the resolved recipient as ITS principal, AND is
LIVE right now (a connection with no disconnect and an unexpired lease). A
session that has since disconnected or let its lease expire is not
surfaced — this never nags about a dead session. The reminder line for a
match like this names the target session as the recipient to use, not the
principal: `... waiting for the live session "<session id>" ... fetch each
message_id with target_kind "session" (fetch or intake using your own
principal id instead of "<session id>" will not match this session-targeted
message) ...`. `intake` or a `disposition` for such a delivery from a caller
that is itself another registered session of the project
(`CLAUDE_CODE_SESSION_ID` / `CODEX_THREAD_ID`) is refused with
`intended_for_other_session` (#497). A caller with no session id, or with an
id no session registered, still can — a Codex MCP process has none. The
reminder and the Stop gate follow the same line (#566): a hook that knows its
session id counts a session-targeted delivery only when it is addressed to
that session (or to an id it had before `/clear`), so another live session of
the same principal is neither reminded of it nor blocked on it. Under
Codex, SessionStart and the prompt hook do not remind about a delivery the
router already pushed into THIS thread with its body (its host acceptance
names this thread and is not a `content: "notice"` acceptance — see below)
while the MeMesh host connection that accepted it is still live; a delivery
pushed into a different thread of the same principal, or accepted by a host
connection that has since ended — for example an acceptance recorded by an
older release, before the queue carried only notices, whose `codex exec` run
exited before running it — is still reminded until the recipient records
intake (#514). Under Claude Code they still remind.

Every Claude Code session in one project shares one principal, so a message
sent to that principal reaches all of them. A message can instead name the
one session it is meant for (#497): `send` with `target_kind: "principal"` and
`intended_session: "<session id>"`. The session id is the one the host's
hooks receive as `session_id`: for Claude Code, `CLAUDE_CODE_SESSION_ID` (set
in the MCP servers and shell commands it starts); for an ordinary Codex CLI
thread, its thread id, which Codex sets as `CODEX_THREAD_ID` only for
commands it runs in its shell — not for MCP servers. A session registered
through any other host (`acp`, or `codex-app-server`, which registers a
configured id rather than the thread id) is refused, because nothing there
could say which session it is when it records intake. An id the router never
registered is allowed — a Claude Code session without the channel host is the
normal case. A `target_kind: "session"` send with
`fallback_to_principal: true` does this by itself when the session refuses the
message (`recipient_unavailable`): it sends the same message to the principal
that session registered under, with `intended_session` set to it, and returns
that message with a `fallback` field naming the refused one.
`fallback.intended_session_connected` says whether that session is connected
now; when it is not, `fallback.note` says that only it can take the message in
when it next runs, so a sender whose target has ended for good knows to send to
the principal without `intended_session` (#518). A session the
router never registered has no known principal, so that send still fails,
saying so, and so does a refused session registered through a host that
cannot be named.
An unreachable router (`router_unreachable`) does not trigger the
fallback. The refused session delivery is kept, so if that session comes back
it can see the message twice — its own session copy and the principal copy —
and should record intake for both. Retrying the same call (the same
`idempotency_key`) returns the principal message made by the first one, even
when the session has come back since or the retry leaves out
`fallback_to_principal`; it does not deliver again. For a message like this:

- only that session's hooks (SessionStart, the prompt hook, the Stop gate)
  count it as waiting, and so does `briefing` where it knows the session: the
  MCP tool under Claude Code, or the CLI run from a Codex shell. Every other
  session of the principal does not, and neither does a caller with no
  session id;
- only that session can record `intake` or a `disposition` for it. The MCP
  tool and the CLI take the caller's session from `CLAUDE_CODE_SESSION_ID`,
  else `CODEX_THREAD_ID`. Any other caller — another session, or HTTP and the
  dashboard, which have no session — gets `intended_for_other_session`, and
  the message stays waiting for the session it is meant for. A Codex session
  records it with the CLI from its shell (`memesh message intake ...`),
  because Codex does not pass its thread id to MCP servers. `poll`, `fetch`,
  `ack` and `activation` are not restricted;
- the router pushes it only to that session's connection. With that session
  not connected it stays durable, for the hooks to surface.

After `/clear`, Claude Code gives the session a new id for its hooks and
Bash, while the memesh MCP server and the channel host keep the one they
started with. On macOS and Linux, memesh links the two through the `claude`
process both run under (its pid and start time, recorded at every
SessionStart), so a message meant for the session reaches it, and can be
intaken, under either id. On Windows (no `ps`) the ids are not linked, and
neither are they when the session could not be recorded (the database was
busy at SessionStart, for example); that failure is recorded in
`hook-outcomes.jsonl`.

A message with no `intended_session` (every message sent before this
existed, too) keeps reaching every session of the principal. The Claude
channel host registers with the router under `CLAUDE_CODE_SESSION_ID` when its
config names no `session_instance_id`, so the router's session id, the hooks'
`session_id` and `intended_session` are the same string.

If the declared recipient's inbox is empty everywhere, that reads exactly like
a typo'd id — so SessionStart (only; not every prompt), once a database with
the messaging tables exists, additionally checks whether the id has ever been
addressed in ANY project at all, and if not, adds one hint line:
`MEMESH_RECIPIENT "<id>" has never been seen in any project —
check it for a typo (or ignore this if it is a genuinely new recipient id)`.
It is worded as a hint, not a fact — a legitimately new recipient id answers
the same "never seen" question a typo does, and this cannot tell them apart.
An id that was once seen but has gone quiet since gets no hint either: telling
them apart would mean revealing that some OTHER recipient is still getting
mail, which this hint must never do.
If the inbox cannot be read (its receipts table will not
load, for example), the prompt or session start goes ahead without the
reminder, says so on stderr, and records an `error` in `hook-outcomes.jsonl`
(`inbox: uncaught <code>`), so a run that could not look is not mistaken for
one where nothing was waiting. A caller that already knows its exact
logical recipient may pass both `project` and `recipient` to `briefing`; the
result reports only that recipient's unfetched deliveries and tells it to
`message poll` with the exact scope before fetching each returned
`message_id`. Fetching remains separate from intake and acknowledgement.

The messaging path keeps durable store-and-forward compatibility. For an
exact active local Claude session, MeMesh sends one bounded full message
through the authenticated native host channel and waits for native
acceptance; no marker-to-fetch step is required. For an exact active local
Codex session it queues only a short notice naming the message, and the agent
reads the body from the inbox. An oversized full envelope
returns `native_message_too_large`; other unavailable or rejected exact sessions
return `recipient_unavailable` while scoped recovery data remains durable.
Principal targets retain asynchronous store-and-forward semantics.
The JSON-encoded durable payload is limited to 65,536 UTF-8 bytes (64 KiB).
Native delivery separately limits the complete envelope, including routing
metadata and payload, to 16,384 bytes (16 KiB). Fitting the durable limit does
not guarantee that a native envelope fits; exact-session messages should stay
comfortably below the native cap.
The size failure is permanent for that exact envelope and is reported as
`native_message_too_large`, so callers do not retry it as transient session
unavailability.
`poll`/`watch` are compatibility and diagnostic APIs. A queue admission or
`host_accept` is not proof that an agent read the payload, acknowledged it, or
accepted the work.
A delivery attempt that the router stopped waiting for (`host_outcome_timeout`)
does not mean the host did not run it: a host that finishes later still sends
its acceptance, and the router records it for exactly that attempt on that
same, still-current connection, so the delivery is not sent again. The
timed-out attempt stays recorded in the database, and a sender that was told
the send failed was told so for that attempt. A host that stops before
sending its acceptance can still be sent the same delivery again: delivery is
at least once, not exactly once.

## Claude Code recipient fallback, Stop gate, and channel-flag warning (#468)

Three Claude-Code-only pieces close the gap where a live channel misconfiguration
left a session with no way to learn a message was waiting at all.

**Recipient fallback.** When `MEMESH_RECIPIENT` is unset (not merely empty) and
this hook run is Claude Code, SessionStart and the prompt hook read
`principal_id` — never `auth_token`, and never `project` (below) — from the
owner-private `hosts/claude.json` the [Claude channel runner](#claude-channel-runner)
setup below writes, and use it as the recipient whenever the file is present
and valid. A missing or unreadable file, or a `principal_id` that fails the
same length/path-shape rules above, is quiet: no recipient, exactly as an
unset `MEMESH_RECIPIENT` is today. This makes the reminder work even when the
channel itself never connected — the config file only has to exist,
independent of whether the launch flag below was actually honoured. Codex
loads the same `hooks/hooks.json` and never uses this fallback: it has its own
automatic thread-scoped registration and would otherwise silently adopt
Claude's principal in a shared project.

`hosts/claude.json` may still carry a `project` field from an older setup
(#474 changed what the Claude channel host itself does with one — see the
setup section below); this fallback ignores it, the same as the channel host
does. `principal_id` is one stable identity for every Claude Code session on
this machine, in every project — a reminder or a Stop-hook block for that
principal can be about a message sent from a different project than the one
the current session is in; `message discover --project <name>` and the
reminder's own project line tell you which.

**Stop gate.** A separate Claude Code Stop hook, registered after
`session-summary.js`, resolves the recipient the same way (declared or
fallback) and blocks the stop — `{"decision":"block","reason":"..."}`, printing
the same waiting-message lines the prompt hook shows (including a
live-session-targeted match, #490 above) plus one instruction line — once per
message id that has no intake receipt yet and that this session has not
already been blocked for. Whether a `host_accept` row exists is irrelevant to
this: `host_accept` is not proof of delivery (see above), so a message a
stopped or disconnected exact session never actually received still blocks the
stop just as readily as one that was pushed. This hook never runs under Codex.

The per-session record of which ids it has already blocked for lives beside
the database, keyed by `session_id`, next to the existing Stop-side
per-session state files, and is the ONLY thing that stops a re-block (#492).
`stop_hook_active: true` on the Stop payload means "a Stop hook blocked the
prior turn" — it does not say this gate was the one that blocked it, so the
gate does not skip on that flag alone: a message that arrives while a
DIFFERENT Stop hook (a project's own verify-receipt gate, say) is holding the
block chain open still reaches the blocked-id ledger and, finding its id new,
blocks once — the same as any other new id, active flag or not. What the
ledger does prevent is re-blocking for an id it already blocked for in this
session, `stop_hook_active` or not. The gate writes the ledger before it
blocks; if the write fails (a full disk, a read-only directory) it records an
`error` outcome and, on a Stop that is already continuing a block, does not
block, so an unwritable ledger cannot keep the session from stopping.

**Channel-flag warning.** SessionStart also checks, only when
`hosts/claude.json` exists (the channel is configured) and only under Claude
Code, whether the launching `claude` process's own command line shows a
MISTYPED attempt at the channel flag — walking a few levels up the process
ancestry with `ps -o ppid=,command=` to find it. Deliberately narrow: most
Claude Code sessions never opt into channels at all, including on a machine
whose `hosts/claude.json` is left over from a past setup, so warning on every
flag-less session would be noise for exactly that owner. It warns only when
the line shows an INTENT to load the channel that the exact flag does not back
up — the flag word `dangerously-load-development-channels` present but not as
the exact double-hyphenated token (a dash silently autocorrected into an em
dash, `—dangerously-load-development-channels`, is the observed real-world
case), or `server:memesh-channel` present with no exact flag token anywhere on
the line. A command line mentioning neither stays silent. Either mistyped
shape leaves the channel MCP server running while every notification is
dropped, with no other symptom; this is the one line of feedback for that
failure mode. If the launching process cannot be identified at all, this says
nothing — a false warning is worse than a missed one.

## One-time owner-private local-host setup

**Two tiers, easy to conflate.** `memesh agent setup <host>` (below) only
writes a local config file — it registers nothing by itself, and for `claude`
and the managed `codex` runner it must be followed by actually starting that
host (`memesh-host-claude` via [Claude channel runner](#claude-channel-runner),
`memesh-host-codex` via
[Separate: MeMesh-managed Codex app-server runner](#separate-memesh-managed-codex-app-server-runner)).
The [Claude channel runner](#claude-channel-runner) tier additionally requires
Claude's own `--dangerously-load-development-channels` opt-in, since Claude
Channels is an upstream research-preview feature — that step is NOT required
for ordinary Codex plugin sessions (which auto-register per thread with no
setup at all) or for durable, non-live `send`/`fetch` to a stable recipient
(which needs no registration of any kind). If discovery/live delivery isn't
working, first confirm which tier you actually need before debugging the
wrong one.

Native delivery is optional and local to one Unix account. Do this setup once
for the account that owns both the MeMesh database and the active host
sessions; do not place router tokens or host config in a repository, shared
dotfile, or world-readable temp directory.

Each configured host connection attempts to start the packaged router and
retries when its owner-private socket is absent or refused. You can also start
the router yourself when you want to inspect it directly; it creates an
owner-private token and socket beside the active MeMesh database on first
start:

```bash
umask 077
memesh-router
```

The socket path — `MEMESH_ROUTER_SOCKET` if set, otherwise derived from your
MeMesh database location — must fit in the 103-byte limit a Unix domain
socket allows. A long `HOME` or `MEMESH_DIR` can push the default path over
that limit; `memesh-router` then refuses to start, and `memesh doctor` names
the path and its byte count. Set `MEMESH_ROUTER_SOCKET` to a shorter absolute
path, or move `HOME`/`MEMESH_DIR` somewhere shorter, to fix it.

If you start it yourself, leave that process running. In a second terminal,
verify only the installed adapter imports and the live router socket separately:

```bash
MEMESH_DOCTOR_PROBE_MESSAGE_CAPABILITY=1 memesh doctor
MEMESH_DOCTOR_PROBE_MESSAGE_ROUTER=1 memesh doctor
```

The first command proves the installed MCP schema and adapter imports. The
second only proves that the owner-private router socket accepts a connection.
Neither command starts or registers a host, sends a message, proves
`host_accept`, or wakes a stopped session.
In particular, a socket check does not start the host it observes.

The secure host-native router and adapter runtime currently supports macOS and
Linux. Windows remains supported for core MeMesh memory, durable message
storage, and MCP tools, but host-native wakeup fails closed before creating
credentials, configuration, IPC listeners, or managed child processes.

The default local endpoint is versioned with the wire protocol
(`agent-router-v2.sock`). This lets a current router start beside an incompatible
legacy daemon after an upgrade instead of killing it or taking over its live
socket. Existing generated configs that name the former default are normalized
in memory; an explicit custom socket remains exact and reports
`router_version_mismatch` when it exposes the known legacy response shape.

Within one protocol version, the router also reports the MeMesh version it
runs, and each host sends its own when it registers. A router that
started before an upgrade is caught this way instead of silently routing with
the old code:

- A router older than the host, from a release that reports its version,
  refuses that registration with `router_outdated`, stops, and does not start
  again for ten minutes as that older version. The host retries and starts the
  installed router.
- A router from before versions were reported cannot stop itself. The host
  refuses it with `router_outdated` and the instruction to stop it
  (`pkill -f dist/host-runtime/router.js`); the next MeMesh session starts the
  installed one.
- A router newer than the host is used as is.

Create one reusable owner-private config for each managed local host and
principal. The stable principal is the logical recipient. Managed processes
generate a fresh exact session identity. Ordinary Codex plugin sessions instead
register automatically from the Codex thread identity supplied at SessionStart,
using a distinct thread-scoped principal; no thread ID is copied by hand.

```bash
memesh agent setup codex --principal codex-reviewer --workspace "$PWD"
memesh agent setup claude --principal claude-reviewer
```

No `--project` is needed, or read, for routing (#474). Each host derives its
own routing project the same way `message discover`'s automatic project
already does: `claude` from its own process's working directory (the
directory Claude Code was started in), `codex`/`codex-session`/`gemini` from
their `--workspace`. Two hosts started in the same directory — a Claude
session and a Codex thread in one repository, say — therefore land in the
same project automatically; nothing has to be typed or copied between them.
`--project` is still accepted, for a script written before this changed, and
a given value is still written into the config file, but no host reads it
back to decide where it registers.

An optional declaration can be persisted with `--work-summary <text>` (capped
at 200 characters); no default is guessed. `--model <id>` is accepted only so an
older script does not fail: it prints that it is ignored and writes nothing,
because the model is the host's choice per session and MeMesh cannot see it.
For ordinary Codex only, `memesh agent setup codex-session ...` is an optional
workspace-specific stable-principal override, not an activation prerequisite.

### Ordinary active Codex CLI session

An ordinary local Codex session requires the MeMesh Codex plugin to be installed
and enabled so Codex loads the packaged SessionStart hook. Each startup or
resumed thread then registers automatically under the current project with a
thread-scoped principal. Use `message discover` to obtain the exact live session
ID. No manual `agent setup` is required.

The automatic project is a readable repo label plus a 32-hex identity suffix.
For a network Git remote, the suffix covers a password-free locator; otherwise
it covers the native real path. Standard GitHub HTTPS and SSH spellings
converge. Generic SSH locators keep the login and whether the repository path is
absolute or relative to that login's home, preventing distinct accounts or
paths from sharing one scope. This keeps the same repo together across
subdirectories, symlinks, remote-backed clones, and linked worktrees without
letting two unrelated repos named `shared` discover or receive each other's
messages. `memesh briefing --json` in that workspace reports the exact project value.

If one workspace needs a stable named principal across different threads, run
`memesh agent setup codex-session --principal codex-reviewer --workspace "$PWD"`
there and restart Codex. The owner-private config overrides the principal only
when its exact real workspace matches; the project is always the automatic one
above, the same as an ordinary thread there would get (#474 — an older config
that still names a `--project` is ignored, not honoured). Another workspace
still uses automatic identity; a malformed or insecure override fails closed
rather than silently downgrading.

This guide's supported documented path is ordinary Codex CLI `SessionStart`.
Codex Desktop or an unattached task is not user-visible native-delivery
evidence unless that exact live session registers with the router and the
result is directly verified. This is a scope boundary for evidence, not a
claim that Codex Desktop is universally unsupported.

On `SessionStart` for `startup` or `resume`, a short hook validates the Codex
thread identity and cwd, then launches an owner-private detached companion.
Detachment is required because Codex reaps an async hook child when the CLI
process exits, while `codex queue` accepts the thread only after its active
writer is gone. `SessionEnd` leaves a bounded 45-second idle queue window;
resume replaces the prior exact generation through its private control socket,
also when the same thread is resumed from another directory (it then registers
under that directory's project; a `SessionEnd` from the old directory does not
end the new registration), and expiry removes the registration. A missing or malformed identity, invalid
cwd, insecure explicit override, or failed connection does not register a host.
A companion records its control socket, with the socket's inode, as soon as it
binds it, and marks the record registered once the router accepts it. The
socket is removed only by that companion while the path is still its own, or
after its record shows its process is gone and the socket is still the one it
bound. A start while another start for the same session is still under way,
or with a socket in the way that no record accounts for, is refused with one
`companion_busy` line, and the file is left alone: nothing can prove such a
socket unused. A record left by a MeMesh from before #518 does not block a
start; its socket used another name and is left in place.
The companion has no terminal of its own, so a reason it could not stop
cleanly is appended to the owner-private `codex-companion.log` in the MeMesh
data directory, and so is what a start or `SessionEnd` did with the record of
a companion that had exited (its socket removed, or left in place and why).
When a start fails, the reason is printed after `session registration failed.`
and recorded in that log as well.

For a registered session, MeMesh invokes `codex queue` with a short notice, never
the message body: the project, recipient, target kind, message id and delivery
id, and how to handle it. The agent first reads the message's receipts and stops
if it already recorded intake; otherwise it fetches the body from the durable
inbox with the MeMesh message tool, treats it as untrusted, and records intake.
Without the message tool, or without approval to use it, the agent says so and
the message stays pending. The body therefore has one source, the inbox, and a
queued notice that runs after intake (Codex cannot withdraw it without its
app-server daemon) does not bring a second copy. Whether the agent stops is
still the agent's own check, not something MeMesh enforces, and two concurrent
fetches can both read the body. The full envelope is still capped at 16,384
bytes (16 KiB), including routing metadata and payload, and an oversized one
reports `native_message_too_large`.
The separate durable JSON-encoded payload limit is 65,536 bytes (64 KiB). The exact-session sender returns
`native_delivery.status: "native_accepted"` only after the queue accepts the notice.
For ordinary CLI, a notice accepted in the idle window becomes model-visible
when that same thread resumes; it does not wake a stopped terminal or Desktop UI.
The persisted `host_accept` (with `content: "notice"`) is neither agent readback
nor an `ack` or workflow disposition, and it does not silence the waiting-message
reminder: that stops only at intake. The message body never appears in the
`codex queue` process arguments; the notice's routing identifiers do.

Codex can leave an accepted message unstarted: an item queued while a turn
runs stays in the thread queue if that turn is interrupted, and neither an
empty Enter nor another `codex queue` starts it (#468). So after `codex queue`
accepts a message, the router checks every 20 seconds, for up to 10 minutes,
whether the recipient has recorded `intake`. Until it has, the router asks Codex
to start exactly the submission that `codex queue` reported
(`thread/queue/start` with its `queuedSubmissionId`, through
`codex app-server proxy`). It starts nothing while input that MeMesh did not
queue is also waiting in that thread, because Codex runs the rest of the queue
after the started turn; it tries again while a turn is still running or other
input is waiting. Intake ends the checks with no further record; otherwise the
router records the final outcome once, as a `host_activation` receipt from
actor `memesh-router` in `message receipts`:

- `woken`: Codex started that submission, or had already taken it
  (`codex_queue` in the receipt detail is `started` or `empty`). This is not
  intake: the model may not have read or acted on the message.
- `manual_resume_required`: other input stayed queued in the thread for the
  whole window, so MeMesh started nothing; the queue runs when you send a
  prompt.
- `failed`: the thread stayed busy for the whole window, the app-server did not
  answer or refused the request, or `codex queue` printed no submission id, in
  which case nothing is started (`reason` in the receipt detail).
- `unsupported`: Codex runs without its app-server daemon, for example a TUI
  started with `--no-daemon`. A queued MeMesh message can then stay stuck after
  an interrupted turn until you send a prompt.

`memesh doctor` reads the last 7 days of the release's own `host_activation`
receipts and, for each Codex thread, looks at the newest one. It reports a
WARN naming every thread whose newest receipt says `no_daemon`: a message
queued there is still waiting for a prompt, so send that thread one (and
avoid `--no-daemon` next time). Otherwise, including when no release ran in
the window, it reports PASS.

The checks live in the router process; a router restart drops the ones still
pending.

If the target Codex session is stopped, missing, disconnected, or no
longer matches its configured workspace, MeMesh does not start or replace it.
An exact-session send reports `recipient_unavailable`; durable scoped recovery
and receipt history remain available to fetch, cursor recovery, `poll`, or
`memesh message watch` for audit and diagnosis. Exact-session failures are not
automatically replayed through the native channel on a later registration; the
sender must retry deliberately if live delivery is still wanted.

### Separate: MeMesh-managed Codex app-server runner

```bash
memesh-host-codex --config "$HOME/.memesh/hosts/codex.json"
```

This is separate from `codex-session`: it starts a MeMesh-owned `codex
app-server`, creates its own thread through the private Unix/WebSocket control
path, and registers only after that thread is ready. If that Codex ends while
the host is still connecting to the router, the start fails (exit 1) instead of
leaving a registered host. It does not attach to an ordinary Codex session. Message content never appears in MeMesh or Codex
process arguments.

### Claude channel runner

When `memesh doctor` is running from a Claude Code plugin cache, its default
`Claude Channel registration` row inspects only the user-scoped
`mcpServers.memesh-channel` entry in Claude's canonical user config and the
owner-private config file named by its `--config` argument. Missing
registration is a warning: durable MCP/inbox messaging can still work, while
live Channel notification remains inactive until the upstream research-preview
channel is explicitly opted into. A malformed, stale, or insecure target is
also a warning. `CONFIGURED` means only that the declaration and target are
coherent; it does not verify development-channel admission or that an agent
surfaced the notification.

Run the printed `registration_command` once to add `memesh-channel` as a
user-scoped stdio MCP server. Claude owns that process for the session:

```bash
claude mcp add --transport stdio --scope user memesh-channel -- \
  memesh-host-claude --config "$HOME/.memesh/hosts/claude.json"
```

Claude Channels is an upstream research-preview opt-in. Custom channels are
not on Anthropic's approved allowlist, so start every participating Claude
session with the printed launch command and confirm the local-development
warning:

```bash
claude --dangerously-load-development-channels server:memesh-channel
```

Without that flag Claude may initialize the ordinary MCP transport while
silently dropping channel events; a MeMesh `host_accept` then proves only that
the bounded message was written to stdio, not that Claude surfaced it. With the
channel admitted, initialization creates and registers the exact MeMesh
session automatically; EOF, MCP close, or normal signals unregister it.

### Experimental ACP runner (not release-gated)

An internal generic ACP runner remains an experimental adapter surface. No ACP
provider is documented as a supported native-wakeup path here; protocol or
process readiness alone is not proof that a provider accepted a message.
If the agent process of a started ACP runner exits, or the runner fails after
start, `memesh-host-acp` unregisters, prints the cause (the exit code or
signal, or the failure) on stderr and exits 1, also when a SIGINT or SIGTERM
arrives after the agent failed. Closing it with SIGINT or SIGTERM before any
failure exits 0 and is not reported as a failure; if that close itself fails,
it prints why and exits 1.

The managed Codex app-server and Claude channel paths deliver only while their
configured target is active and registered. If it is stopped, missing, disconnected, or replaced, MeMesh keeps the durable message but does not start
the host, recreate the session, or silently redirect an exact-session target.
A later eligible managed-principal registration drains only post-activation
pending work; an exact-session target never moves to a replacement. Manual
cursor reads remain available for audit and diagnostics, not as a requirement
for active Codex-session delivery.

## What Works Today

- MCP, HTTP, and CLI use the same message lifecycle and SQLite system of record.
- `send` creates one canonical message, recipient delivery, and payload-free notification event under an idempotency key. Exact-session success additionally requires native host acceptance; an oversized envelope returns `native_message_too_large`, an unreachable local router returns `router_unreachable`, and other unavailable or rejected sessions return `recipient_unavailable`, with recovery state preserved for all three outcomes. A principal send may name `intended_session`; a session send may set `fallback_to_principal` (#497, above).
- `poll` and `memesh message watch` return only events for the exact project and recipient. They are compatibility and diagnostic paths; the opaque cursor can be persisted and reused after a timeout, dropped hint, duplicate delivery, or process restart.
- `fetch` returns the payload only to the named recipient and matching `target_kind` in the named project. Exact-session messages require `target_kind=session`; polling and fetching do not acknowledge the message.
- `intake`, `ack`, `disposition`, and `activation` are explicit, separate, idempotent receipt facts. `intake` and `disposition` for a delivery with an `intended_session` are refused (`intended_for_other_session`) unless the caller is that session. Inbox/MCP ACK is valid without a host-native acceptance; host-native ACK remains bound to its `host_accept`. `receipts` returns one ordered projection and identifies each underlying fact source. For example, `manual_resume_required` does not imply ACK, acceptance, rejection, cancellation, or completion.
- The `host_accept` fact in `receipts` carries a `delivery_state` observed at read time: whether intake is `recorded` or `pending`, and whether the session it was accepted for is `live`, `not_live`, `unknown` (no registry row names it) or `not_applicable` (plain principal delivery). A session-target or `intended_session` message whose session ended before intake shows `pending` with `not_live`; nobody else is reminded of it and MeMesh does not resend or reroute it, and a MeMesh send cannot wake or resume that session. The sender can resume it in its own host (which may not succeed at once) and read receipts again, or decide to send it again to the principal, knowing the original session may still take the first message if it comes back. `not_live` is an observation, not a permanent end. Nothing is stored for this; older readers see the same stored facts.
- The transport, rather than model-provided payload data, records sender-host provenance.

## Repeatable owner-run live checks

These checks use a built candidate to distinguish message storage or host acceptance from a model-visible reply. They are optional for ordinary use; release requirements and receipt interpretation are in [CONTRIBUTING.md](../../CONTRIBUTING.md#cutting-a-release). An exit code or `host_accept` alone does not prove that the recipient model saw or acted on a message.

```bash
TMPDIR=/private/tmp npm run qa:live-journey -- --core-only --out .qa/core-report.json
MEMESH_CODEX_QA_HOME="$(mktemp -d "$HOME"/.memesh-codex-qa.XXXXXX)"
CODEX_HOME="$MEMESH_CODEX_QA_HOME" codex login
TMPDIR=/private/tmp npm run qa:live-journey -- --host codex --codex-home "$MEMESH_CODEX_QA_HOME" --out .qa/codex-report.json
TMPDIR=/private/tmp npm run qa:live-journey -- --host claude --out .qa/claude-report.json
```

`--core-only` needs no host login and checks memory round trip, SessionStart briefing, quiet-commit capture, Stop capture, and packed upgrade against disposable state. It does not establish Codex or Claude delivery. The host modes require an interactive, authenticated owner environment and write private reports under gitignored `.qa/`. Keep `TMPDIR=/private/tmp` on macOS so the temporary router socket path fits the platform limit. Create the disposable `CODEX_HOME` outside an OS temporary directory: Codex CLI may refuse to start there.

The disposable `CODEX_HOME` contains a real logged-in credential after `codex login`. When the run ends, including after failure, verify its path is the directory just created and remove that directory; do not leave it in your home folder or delete your normal `~/.codex`. The check's MeMesh data directory is temporary and removed on exit unless `--keep` is requested. It does not clean the disposable Codex login for you.

The Codex mode installs the candidate plugin into that disposable home and exercises one real CLI thread through SessionStart, native delivery, resume, and stopped-session fallback. A passing report needs model-visible readback, not only queue acceptance. The Claude mode is interactive: inspect `/mcp` and `/hooks` before confirming isolation, since `--setting-sources ""` does not guarantee installed MeMesh hooks are absent. Stop if you cannot identify an extra MeMesh integration: it could write to your real database. Follow the runner's trusted prompt and wait for the model's intake; a reminder after delivery or transport acceptance alone is not equivalent. Claude print mode (`claude -p`) is not supported by this check.

For a stopped or disconnected exact session, `recipient_unavailable` must be paired with a healthy router discovery and a durable fetch: that error can also mean the sender could not reach the router. Neither case restarts a host or proves the workflow completed. Review the JSON report's `limitations` and visible evidence before relying on either host result.

## Scope identifiers

`project` and `recipient` together key one inbox, and `actor` is derived from
`recipient`, so how those three are spelled is part of the contract rather than
a formatting detail. Two spellings of one name are two inboxes: the recipient
that fetches under one never sees what was sent under the other, and a
`briefing` unread count is computed per spelling.

Every message action canonicalises them to Unicode NFC and trims surrounding
whitespace, on reads as well as writes, so a decomposed spelling reaches the
rows a composed one wrote.

A value spelled as an **absolute filesystem path is refused** — a POSIX path
(`/root`), a Windows drive path (`C:\work`), or a UNC path (`\\host\share`).
Project identity is derived from the working directory and can never take that
shape, so such a value is not an identity MeMesh produced; the error names the
field and a valid value. Callers that previously passed a home directory or a
checkout path where the agent's or project's NAME belonged must pass the name.
Nothing else is rewritten: identifiers are compared exactly, case included, and
an identifier that merely contains a separator (`team/reviewer`) is accepted.
MeMesh does not treat any prefix as a namespace, so `claude-code:reviewer` and
`reviewer` are two different recipients.

The same rule covers every surface that reads or writes that key, not only the
`message` tool: `briefing` counts unfetched deliveries for one exact
(`project`, `recipient`), and `memesh agent setup --principal` writes the
principal half of the identity a host will register under (the project half
is derived automatically — see the setup section above, #474). A path-shaped
value is refused there too, at the moment the config is written, rather than
surfacing later as an error about some other agent's send.

`sender` is not covered by any of this. It is provenance rather than routing —
it keys no inbox, and it keys the send idempotency record — so it is stored
exactly as given, and the transport-bound provenance remains the field to trust.

Rows written before this rule are preserved byte-for-byte. MeMesh reports
path-shaped historical scope through its read-only memory invariant rather than
guessing that `/root`, `/tmp/root`, and `root` name one recipient. Such rows may
remain unreachable through the stricter public API until the owner supplies an
explicit mapping. For a confirmed project mapping, rename both its entity tags
and message scopes with the deliberate owner-run operation:

```bash
memesh kg rename-project --from <old> --to <new>          # dry run
memesh kg rename-project --from <old> --to <new> --apply  # backs up first
```

## Identity and lifecycle

A **principal** is the stable logical recipient. A **session** is one live host connection for that principal. A **generation** changes when that session is replaced. A host that stalled past its lease (a laptop that slept, for example) is not replaced but expired: the router answers one of its requests (a heartbeat, for example) with `stale_generation`, and the host registers again under a new generation, waiting longer each time the router keeps doing so. The replaced host is told so, and stops: it can no longer receive. `memesh-host-claude`, `memesh-host-codex` and `memesh-host-acp` print `replaced by a newer connection for this session; stopping.` on stderr, close and exit 0; the Codex session companion writes that line to `codex-companion.log` in the data directory and exits. A close that fails, or a failure that came first, still exits 1. Stopping the replaced host leaves the newer connection registered. An exact-session target never reroutes. A principal target can deliver only to an eligible active session after its activation checkpoint; it does not replay historical inbox contents into a first session.

Persistence, dispatch attempt, host acceptance, intake, acknowledgement,
workflow disposition, retention, and presence are independent state axes. An
active configured exact Claude session receives a bounded full message without
polling or an inbox fetch; a Codex session receives a notice and reads the body
from the inbox. An oversized full envelope returns
`native_message_too_large`. A stopped, missing, busy beyond its queue limit,
disconnected, or unsupported session returns `recipient_unavailable` and is
not awakened, resumed, or replaced; durable state remains available for audit
and recovery, subject to exact-session and activation-checkpoint rules.

## Bounded storage and audit retention

Message payload growth is observable and owner-controlled. MeMesh never deletes
unresolved, unacknowledged, retryable, or offline-pending messages to satisfy a
limit. Inspect logical payload bytes, protected rows, SQLite reusable pages,
and main/WAL file sizes with an explicit policy cutoff:

```bash
memesh message storage report --cutoff 2026-08-01T00:00:00Z
```

Preview one bounded batch of old terminal payloads; nothing changes without
`--apply`:

```bash
memesh message storage prune --cutoff 2026-08-01T00:00:00Z --batch-size 100
memesh message storage prune --cutoff 2026-08-01T00:00:00Z --batch-size 100 --apply
```

Applied pruning replaces only payload content whose every delivery has an
explicit ACK and a terminal workflow disposition older than the cutoff with a hash-bound
tombstone. Message identity, routing, receipts, ACK, workflow, presence, and
retention audit facts remain queryable. Freed SQLite pages become reusable;
the main database file is a high-watermark and is not promised to shrink.
Full `VACUUM` is never run by a hook or this bounded command.

An owner may set `MEMESH_AGENT_MESSAGE_STORAGE_QUOTA_BYTES` to a non-negative
integer. This is a hard **logical payload** budget, not a whole SQLite file or
disk quota. The canonical send transaction checks it before inserting any
message effect; an over-quota send returns `storage_quota_exceeded` and leaves
no partial message, delivery, event, idempotency, dispatch, or receipt row.
Metadata, indexes, append-only audit facts, reusable pages, and WAL bytes still
consume disk and remain visible in the report; keep separate filesystem
headroom and monitoring. Heartbeats refresh the connection lease in place;
they do not append one audit row every interval. Connected, disconnected, and
superseded transitions remain auditable. There is deliberately no default
quota or automatic retention policy.

## Local and Cloud boundary

This guide describes only one local MeMesh instance: its SQLite durable event
store and same-machine host-native input. Remote and cross-machine transport is
the responsibility of MeMesh Cloud and requires its own verified relay; Cloud
state is not evidence that this local host accepted a native message. Native
acceptance, persistence, or fetch does not promise exactly-once cognition, a
reply, or a stopped-session wake-up.

## What This Is Not Yet

- Not universal host support or stopped-session resume. This document only
  describes the explicitly configured ordinary Codex path and the separate
  managed Codex/Claude paths above.
- Not topic, broadcast, lease/claim, or TTL routing. The current delivery target is one exact recipient.
- Not arbitrary external-user access or cross-machine delivery. A local MeMesh instance is not a public collaboration service.
- Not permission to execute payload content. The receiving host must apply its own policy and required human approval.

## Support Matrix

| Participant | Current path | Status today | Notes |
|---|---|---|---|
| Ordinary Codex CLI | automatic plugin SessionStart registration; optional `codex-session` identity override | bounded full-message native delivery while active | Exact thread identity is discoverable; oversized envelopes return `native_message_too_large`, while stopped or disconnected sessions return `recipient_unavailable` |
| MeMesh-managed Codex app-server | `memesh-host-codex` | separate managed path | It creates its own Codex thread; it does not attach to an ordinary session |
| Claude channel | `memesh-host-claude` | separate channel path | Requires the documented Channel opt-in; no stopped-session resume |
| Other local MCP clients | MCP, HTTP, or CLI message operations | durable messaging only | Use `poll`/`watch` and scoped fetch where their own host loop supports it; this guide makes no native-wakeup claim |

## Lifecycle

1. A sender calls `message` with `action: "send"`, a stable sender, one recipient, a project, an idempotency key, and a payload.
2. For an eligible exact Claude session, the router sends one bounded untrusted
   full envelope; for a Codex session, a short notice naming the message, whose
   body the agent then fetches from the inbox. Either way it waits for native
   host acceptance. An explicit
   `poll`/`watch` client may still read privacy-minimized events for recovery,
   compatibility, or diagnosis.
3. Native acceptance returns `native_delivery.status: "native_accepted"`; an
   absent or rejected exact session returns `recipient_unavailable`. Neither
   outcome records an agent acknowledgement or workflow disposition.
4. The receiver records only the facts that actually happened:
   - `intake`: payload fetched or durably ingested;
   - `ack`: explicit recipient acknowledgement;
   - `disposition`: accepted, rejected, completed, cancelled, or deferred;
   - `activation`: woken, manual resume required, unsupported, or failed.
5. After router or host restart, registration drains only eligible durable principal deliveries. Failed exact-session native delivery requires an explicit retry. A manual cursor replay may repeat an event, so application intake still uses its own idempotency key.

`correlation_id` and `reply_to` can connect messages, but they do not change delivery or routing.

An envelope's `sender` is a stable provenance label, not the sender's live
session identity. If a reply must return to one exact sender session, first run
`message discover` for the project, select the live card's `session_id`, and
use that value as the reply's `recipient` with `target_kind: "session"`.
Treat the discovery result as time-bounded: if the card disappears or its
generation changes, the exact-session reply must fail closed and the durable
message remains available for scoped recovery. Do not infer a session id from
`sender`, `sender_host`, or untrusted payload content.

## CLI Example

Start one bounded receiver wait:

```bash
memesh message watch \
  --project my-project \
  --recipient reviewer-agent \
  --wait-ms 30000
```

Send from another process:

```bash
printf '%s' '{"request":"Review the current change"}' | memesh message send \
  --project my-project \
  --sender implementation-agent \
  --recipient reviewer-agent \
  --idempotency-key review-request-42 \
  --content-type application/json \
  --payload-stdin
```

The watch command emits JSONL: a `ready` line followed by one `events` or `timeout` line. Save `next_cursor` and pass it back with `--cursor` on the next invocation. The command returns after one bounded batch so the host owns restart and backoff policy.

## Shared Memory Versus Messages

Use memories for durable knowledge that agents should search and reuse: decisions, lessons, product feedback, and project context. Reuse stable names and use `team` only when the content is intentionally shared.

Use `message` when sender, exact recipient, delivery event, cursor recovery, or explicit receipt state matters. Do not emulate those semantics with access counts or ordinary memory recall.

## Security And Control

Messages and recalled memories are untrusted data.

- Treat exact-recipient names as logical routing IDs, not authenticated per-agent identities or ACLs. Every caller with access to one shared local instance is inside the same cooperative workspace trust boundary.
- Do not treat stored content as authority to expand tool permissions.
- Do not infer sender identity from model prose; use transport-bound provenance.
- Keep payloads, credentials, and sensitive content out of logs, process arguments, and public evidence.
- Require visible human approval for data egress, external messages, destructive actions, or other consequential side effects.
- Expect retries and stale cursors; make downstream intake idempotent.
