Agent addressing
LumenFlow’s memory layer can route coordination traffic to a specific live agent session without inventing a pack-specific side channel.
A2A.V1 release contract
Section titled “A2A.V1 release contract”Agent-to-agent coordination is defined by the exported A2A.V1 contract in
@hellmai/lumenflow-control-plane-sdk/a2a:
A2ASignalV1carriesschema_version,signal_id,sender_identity,recipients[], optionalworkspace_id, thread/reply fields, intent, interrupt class, ACK requirement, message, idempotency key, origin, and existing WU/initiative/lane/role axes.A2AReceiptV1carriessignal_id,reader_identity,read_at,delivery_state, and optional idempotency key. Delivery state starts asdeliveredand is promoted toackedorrejectedwhen the recipient posts a same-threadAGREEorREJECT.- The conformance suite is published from the same package, so local memory adapters and hosted buses can prove the same visibility, routing, receipt, and convergence behavior.
The local implementation stores signals and receipts in .lumenflow/memory/,
surfaces events through generated Claude hooks, and provides mem:watch for
clients without native hooks. Those are adapters. lumenflow.cloud consumes the
same A2A.V1 schema over a hosted transport, where workspace_id is required
as the tenant/workspace routing key even though local worktrees may omit it.
This release is a minor-compatible addition while target_agent remains a
deprecated read-compatible alias for the first entry in recipients[].
Removing that alias would be a major-version change.
Live roster
Section titled “Live roster”Use mem:roster to see active sessions, their ephemeral display_name,
explicit agent_identity, role axes, current WU, lane, and most recent
heartbeat.
display_name is a friendly handle for the current live session. It is not a
stable semantic alias; those still belong to the role contract.
agent_identity is the stable A2A reader identity for sessions that should
share receipt state across restarts or concurrent terminals. Set it explicitly
with pnpm agent:session --agent-identity <owner>:<role>:<purpose> or
LUMENFLOW_AGENT_IDENTITY. LumenFlow does not infer this value from PID,
hostname, or process metadata; when it is absent, receipts use session_id.
Directed signals
Section titled “Directed signals”Send directed coordination signals by session_id, display_name, or
agent_identity. --to accepts a comma-separated recipient list:
The CLI resolves live session IDs and display names to agent_identity when one
is present. Signals persist canonical A2A recipients[]; target_agent remains
only as the deprecated single-recipient compatibility alias. Broadcast signals
without recipients continue to render for every session.
Threads and intents
Section titled “Threads and intents”Use A2A thread and intent fields when a signal needs a machine-readable decision, not just a status note:
Directed root signals get a generated thread_id and default to
interrupt_class: priority. Broadcasts default to interrupt_class: advisory.
Replies inherit the parent thread unless --thread is supplied explicitly,
including when the parent itself has no thread_id of its own — the reply
then inherits the parent’s own signal id as the thread identity, and a
--thread <parent-id> query returns the parent plus every reply as one
chain (see Session-scoped inboxes).
Valid intents are INFO, PROPOSE, COUNTER, AGREE, and REJECT.
mem:converged --thread <id> exits successfully only when every recipient on
the thread root has replied with AGREE; REJECT replies are reported
separately and keep the thread unconverged.
The local .lumenflow/memory/*.jsonl files are an adapter over the exported
A2ASignalV1 and A2AReceiptV1 contract. Hosted transports should preserve the
same fields and state transitions while replacing storage and delivery.
Authenticated directives
Section titled “Authenticated directives”A plain mem:signal message is always advisory text: useful anchors (WU
ids, SHAs, signal ids) but no proof of issuer. A receiving agent cannot
tell a legitimate orchestrator directive from a plausible forgery, so a
correctly-suspicious agent must refuse both — and did, twice, in the
incident that motivated this feature (WU-3911). For directives that need
to be provably authoritative rather than merely plausible, mint a receipt:
--directive-class accepts only wake, pause, resume, redirect, or
advisory. The body must be the exact canonical sentence shown above. The WU
and closed class carry all actionable semantics; arbitrary prose is never
authenticated, so paraphrased concealment, reporting suppression, and
enforcement bypass requests fail closed without a brittle phrase list.
Authenticated directive v1 requires at least one resolved --to recipient. A
directive to N recipients mints N independent single-use receipts, one per
recipient, so a broadcast is consumable once per addressee rather than
once in total: one addressee verifying (and consuming) their own receipt never
touches any other addressee’s receipt. Receipts expire after 30 minutes by
default (--expires-in-minutes, 1-240) and bind the WU, canonical body, that
one recipient’s own single-element audience, the WHOLE stored recipient list
(so replacing or widening it fails closed too), and an authority snapshot.
Verification authenticates the caller’s own identity, checks it against the
stored audience, and only then resolves and consumes that reader’s own
receipt; a non-addressee (or anyone missing from the audience) gets a distinct
directive_recipient_unauthorized refusal that never touches any addressee’s
receipt.
The authority snapshot binds the WU’s session_id, assigned_to, status,
lane, and initiative — never a hash of the whole WU spec file. An
ordinary wu:edit that leaves those five fields alone (acceptance criteria,
notes, code_paths, and so on) does not invalidate a live directive; a change
to the claiming session_id (handoff, recovery) or assigned_to
(reassignment) does, exactly like a released or completed claim already did.
Public session ids are routing data, not credentials. The harness must inject
each process’s LUMENFLOW_AGENT_SESSION plus a separately generated 256-bit
proof before session enrollment. The proof’s wire format is
lfsp1.<base64url of 32 random bytes>, produced by generateAgentSessionProof()
from @hellmai/lumenflow-packs-agent-runtime; no session has a proof binding until a
harness injects one. LumenFlow stores only a domain-separated digest in
gitignored 0700/0600 private session storage and never accepts the raw
proof in a CLI argument or prints it. Sessions without an enrolled proof fail
closed for authenticated directives.
A harness has two duties here. It must mint a distinct proof per session,
and it must strip LUMENFLOW_AGENT_SESSION_PROOF from every spawned child
environment — session creation reads the ambient environment, so a
sub-agent that inherits its parent’s proof inherits its parent’s directive
authority.
Read the verdict precisely. AUTHENTICATED means all of the following, and
nothing more:
- the closed action and WU scope were minted for this one recipient;
- the minting caller presented the host-injected proof for the session that currently holds this WU’s claim of record (Tier A: the WU YAML, the event projection, and a non-completed canonical session record must agree — done, released, missing-session, and takeover states fail closed); and
- the receipt bytes are present in this checkout’s private
.lumenflow/state/packs/software-delivery/coordination/directive-receipts/journal.
That last point is the honest limit of the guarantee. Nothing derived from the
issuer’s private proof is carried in the receipt, so journal-write access is
issuer authority. A verdict is trustworthy against text-only injection and
against any actor that cannot write that directory (it is created 0700 with
0600 files). It is not trustworthy against a peer process running as the
same local user: such a peer can hand-build and journal a receipt naming any
session, and it will verify. Same-uid isolation is an OS/host responsibility;
closing it inside LumenFlow needs the external authority described below. A
test pins this behaviour so it cannot change silently.
The issuer is always a session; the human assignee is shown separately and is never the issuer.
Bare role-session registration is routing/liveness state, not directive authority: the registering caller selects its own role and scope. Initiative-, lane-, and WU-scoped role sessions are all denied directive authority unless they also present a Tier-C grant (below).
Tier C: directing an agent whose WU you don’t hold
Section titled “Tier C: directing an agent whose WU you don’t hold”An orchestrator that does not hold the target WU’s own claim — an
initiative- or lane-scoped orchestrator directing a sub-agent on a different
WU, or a peer in another harness — cannot use Tier A. It can still mint the
exact same --authenticate command, unchanged, when its role session
(agent:session --initiative/--lane, WU-3914) presents a Tier-C grant:
a signed, TTL-bound, single-use token from the host or control-plane identity
adapter (@hellmai/lumenflow-control-plane-sdk, the same
LUMENFLOW_CONTROL_PLANE_SIGNING_KEY secret that adapter already uses to sign
its own remote-transport identity tokens). The grant travels through a
host-injected LUMENFLOW_DIRECTIVE_GRANT environment variable — never a CLI
flag, argv value, or file — exactly like the existing session-proof channel.
There is no new command or flag: mint resolves Tier A vs. Tier C purely from
whether the presenting session already holds the WU’s own claim.
A grant names one role session (by its own registered scope_key and
lifecycle_role, so a session presenting someone else’s grant is refused) and
exactly one WU — never a standing issuance capability. Five negative paths
fail closed with an attributed refusal, each machine-verified: the grant is
scoped to a different WU (scope mismatch), the identical token is presented a
second time (replay), its TTL has passed (expiry), the role session has since
been revoked (revocation), or a second process has since taken over the role
session’s own resource lease (takeover). Authority is re-evaluated at
verification time for Tier C exactly as it already is for Tier A — a grant
only proves authority was legitimate at issuance, never that it still holds
now.
Unlike the Tier-A same-uid limit above, a same-uid peer that can write this checkout’s private journals cannot FORGE a valid Tier-C verification without also holding the external signing key: the reader independently re-checks the grant’s HMAC signature under its own copy of that key at verify time, not merely a same-uid-writable consumption record. Two tests pin this: a reader missing the key cannot verify a genuine Tier-C directive, and a same-uid forger with full journal-write access but no genuine grant cannot produce a token whose signature validates.
That forgery boundary is not the same as full closure. The grant-consumption
journal stores the raw signed token so it can be re-verified later, so a
same-uid process that can read that journal before a live grant’s TTL
expires holds a genuine bearer credential and can act as the issuer for as
long as the token remains unexpired and unconsumed — no key needed, only
read access to a 0600 file owned by the same OS user. The journal is now
shared across every checkout of one repo, so REPLAY of an already-consumed
token from a different worktree is refused; observation-then-reuse of a
still-live, not-yet-consumed token is not. 0700/0600 constrain other OS
users on a shared POSIX host, not another process running as the same OS
user, and are not enforced at all on a filesystem that ignores POSIX
permission bits.
See the internal architecture note for the complete negative-path list, the
narrowed same-uid argument, and its scope (the Tier-A claim-of-record path is
unchanged and still an OS/host same-uid-isolation responsibility):
docs/05-building-block-view/authenticated-agent-coordination.md.
mem:inbox has no authenticated-directive flag and never consumes a receipt
while rendering. Read the addressed signal there, then run the explicit
mem:signal --verify-directive command before acting.
Session-scoped inboxes
Section titled “Session-scoped inboxes”Read only the messages for one live session with --for:
This view still includes broadcast traffic, so an agent sees both direct messages and lane-wide coordination.
--thread <id> returns the full proposal/ack chain, not just unread
messages — it implies unreadOnly: false so an already-read parent still
appears alongside new replies. Plain (non-threaded) polling keeps its
unread-only default.
--watch’s live-delivery output suppresses the current session’s own
outbound signals by default, so an orchestrator watching for peer traffic
does not see its own just-sent messages echoed back. Pass --include-self
to see them for diagnostics; when anything is suppressed, a one-line count
is printed so the omission stays visible.
Role-addressed signals and the pending-ack view
Section titled “Role-addressed signals and the pending-ack view”mem:signal --target-role <descriptor> stamps an intended-recipient role
(for example orchestrator or implementer/general) onto a signal;
--initiative INIT-XXX scopes a signal to an initiative and is a valid
scope on its own, without --wu or --lane. mem:inbox renders role=
and target_role= on every signal that carries them, in both the default
and --thread listings, and shows the initiative in the scope bracket.
The Inbox Snapshot embedded in wu:brief uses that same projection, so
claim-time context shows the same role, target-role, initiative,
requires_ack, and pending_ack metadata instead of dropping addressing
information at the handoff boundary.
Filter by role or initiative:
--target-role is an exact, case-sensitive match on the stamped
descriptor with no hierarchy: --target-role orchestrator/prime does not
match a signal stamped orchestrator, and vice versa. Signals with no
target_role are not returned by it, and an empty descriptor is rejected.
Initiative ids follow the canonical shape (INIT-001, INIT-MCP-FULL,
INIT-INPATIENT-SAFETY).
Answer the question “which acknowledgements do I owe” with the pending-ack view:
The view lists only signals that still await an AGREE/REJECT and
are addressed to the reader: by identity (--to recipients), or — only
when the signal names no recipient at all — by role (target_role). A
signal sent --to sess-alice --target-role orchestrator is owed by alice
alone, not by every orchestrator. The reader’s roles come from its session
record — the lifecycle role, the composite
<lifecycle_role>/<specialty_profile> descriptor, and for role sessions
only a verified, lease-live, non-revoked actual_role. A role session’s
requested_role records launch intent only and grants no reader authority.
Roles may instead come from an explicit --target-role, which replaces the
derived roles; --for plus
--target-role is a union of identity-addressed and role-addressed
signals. A session whose derived descriptor is only orchestrator is not
reached by target_role: orchestrator/prime. Unaddressed broadcasts are
excluded even when they set --requires-ack, as are directed signals for
another session, role-addressed signals for another role, and the
reader’s own outbound requests. Read state does not matter here: a signal
you have read but not answered is still pending. --pending-ack cannot be
combined with --watch. --ack records a read receipt only. A pending
acknowledgement is cleared by an AGREE/REJECT reply on the same
thread from an addressee: for a signal with explicit recipients, only
a reply from one of those recipient identities resolves it; a reply from
any other session is still visible as thread activity but never clears
another identity’s pending acknowledgement. For an unaddressed/role-only
broadcast, any resolved reply clears it, since resolving a role-scoped
request is delegated authority. The view is a pure function of the
signal/receipt ledger, so running a plain mem:inbox read in between never
changes the answer: --pending-ack, then mem:inbox, then --pending-ack
again return identical results.
Hook delivery
Section titled “Hook delivery”Claude Code projects with LumenFlow enforcement hooks enabled install
.claude/hooks/signal-received.sh and a PostToolUse settings entry. When
mem:signal --to ... appends an addressed A2ASignalV1 signal for an active
Claude session, LumenFlow writes a per-session pending event under
.lumenflow/state/signal-hooks/. The Claude hook drains that queue at the next
tool boundary and surfaces a structured signal:received prompt prefix.
requires_ack: true events include explicit AGREE and REJECT reply commands
on the same thread. The wu:prep directed-signal gate remains the enforcement
backstop if a session ignores or misses the hook payload.
Clients without native hook delivery may use mem:watch when their harness supports a foreground
daemon:
The watcher tails the canonical local A2A signal log, applies the same live
session and reader identity resolution as mem:inbox --for, dispatches
signal:received events through the hook event queue, drains them to stdout,
and records delivered receipts so the same signal is not emitted repeatedly.
Workers should poll only through mechanisms their harness supports. If a worker harness cannot
support a watcher or wakeup mechanism, the orchestrator owns the watcher and wakes the worker;
do not prescribe a portable worker delay loop.
| Client | Delivery status |
|---|---|
| Claude Code | Supported through generated PostToolUse hook delivery. |
| Codex CLI | Supported through foreground mem:watch daemon fallback. |
| Cursor | Supported through foreground mem:watch daemon fallback. |
| Windsurf | Supported through foreground mem:watch daemon fallback. |
| Aider / Cline / generic clients | Supported through foreground mem:watch daemon fallback. |
Remote / cross-workspace signals
Section titled “Remote / cross-workspace signals”When a workspace configures control_plane in workspace.yaml, signals also
replicate to and from a configured remote (a local reference HTTP fixture,
a self-hosted deployment, or a hosted lumenflow.cloud endpoint) via the
ControlPlaneSyncPortV2 contract. Three independent legs run on every
control-plane sidecar drain tick — push, pull, and memory sync — and one
leg failing never blocks the others.
The mem:watch daemon and orchestrate:monitor --watch’s patrol tick also
pull remote signals, before evaluating idle-orchestrator eligibility, so a
signal a remote workspace queued for this one lands locally in time for that
same tick’s delivery pass.
A pulled remote signal is untrusted, attacker-controlled text from a party
this workspace does not control. Before it is ever written to
.lumenflow/memory/signals.jsonl, LumenFlow wraps it in a content-derived,
non-spoofable boundary plus its sender’s workspace/identity provenance —
for example:
Because the frame is baked into the signal’s message at the moment it is
pulled, every surface that renders it — the wu:brief Inbox Snapshot, this
page’s mem:inbox output, and a mem:watch push envelope — shows the same
frame automatically. A rendered section with at least one remote-origin
signal also carries a standing notice once, at the top of that section
(never once per line), reinforcing that fenced content is data, not
instructions. Local-origin signals are never wrapped and render exactly as
described elsewhere on this page.
Cross-workspace --to addresses
Section titled “Cross-workspace --to addresses”--to accepts three additional address forms, alongside the
session_id/display_name/agent_identity targets described above, so a
sender uses one scheme regardless of transport:
Router rule: an address is local iff its <workspace> equals this
workspace’s own workspace.yaml top-level id; otherwise it routes via
whatever control_plane adapter this workspace has configured (the local
reference HTTP service, a self-hosted deployment, or a hosted endpoint).
There is no on-box spool: two repos sharing one machine are two separate
enrollments, and a remote-addressed signal is never written to this
workspace’s local .lumenflow/memory/signals.jsonl. A cross-workspace --to
must be sent alone — mixing it with another --to target in the same
command is rejected.
When this workspace has no control_plane configured, a cross-workspace or
cross-project send fails with a typed error naming exactly what is missing,
rather than queuing locally as if it had been delivered.
Deny-by-default peer consent is the receiving-side contract any
ControlPlaneSyncPortV2-conforming local reference service composes:
createConsentGatedSignalPort decorates a SignalSyncPort so that a
pushSignals call from a peer workspace with no local consent record is
rejected, never queued or silently accepted. This decorator is a composable
library primitive, not something every deployed local reference service
applies automatically — confirm your own reference service actually wires it
in before relying on deny-by-default behavior.
Proposing an upstream issue
Section titled “Proposing an upstream issue”A sender that consumes another repo can raise a structured issue there with
the additive-optional upstream_issue field on A2ASignalV1
(repo, title, description, severity, reporter_workspace_id,
reporter_org_id, optional evidence_refs[] and suggested_labels[]).
Carrying upstream_issue requires intent: PROPOSE and requires_ack: true on the same signal — raising one is always an explicit,
single-message sender action; nothing in LumenFlow auto-emits one on any
lifecycle event. The UpstreamIssue type is exported from
@hellmai/lumenflow-control-plane-sdk/a2a for both originating and
receiving consumers.
Prep gate
Section titled “Prep gate”wu:prep fails before gates when the current reader has unread directed signals
on the WU or its initiative. The failure lists signal IDs, sender, intent, and
ACK requirement when present. Continue only after reading and responding, or use
an audited override:
Roster-aware context
Section titled “Roster-aware context”mem:context renders in strict priority order: unread Current Signals
scoped to --wu and/or --initiative first (mandatory, never truncated),
then the Coordination Roster, then WU/summary/discovery/project memory
content. A live signal for the WU or initiative in scope always survives
truncation; the roster and general memory are what get trimmed under a
tight --max-size.
The roster auto-composes from the delegation registry plus only
lease-live sessions (an expired or revoked session lease never enters the
default view — see mem:roster’s liveness model), and is capped at a
deterministic entry/byte ceiling independent of --max-size. Use
--no-roster only when a test or fixture needs stable, non-live output.
The Current Signals section excludes the local session’s own outbound
signals by default (the same self-echo suppression as mem:inbox --watch);
pass --include-self to see them. Output reports per-section
included/omitted counts and, when anything was dropped, a truncation reason
naming which section lost content.
Display-name pool
Section titled “Display-name pool”Friendly names come from .lumenflow/agents/display-name-pool.yaml. A name is
reserved when agent:session starts and returned to the pool when the session
ends, so long-running orchestration stays readable without conflating session
identity with role ownership.
Stable reader identity
Section titled “Stable reader identity”Two live sessions may intentionally use the same agent_identity. That is the
workstation reuse pattern: a restarted reviewer or a second terminal can treat
signals already read by the first session as read without sharing the ephemeral
session_id.