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 the workspace memory state,
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 memory 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 (work
unit ids, SHAs, signal ids) but no proof of issuer. A receiving agent cannot
tell a legitimate orchestrator directive from a plausible imitation, so a
correctly cautious agent must refuse both. 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.
Any such change fails verification as receipt_context_mismatch. The snapshot
is bound as its own protected payload, separate from the receipt’s policy
digest, so it is never reported as receipt_policy_unrecognized: that code
means the receipt was minted under an issuer policy this CLI version does not
recognise.
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 work unit’s claim of record (Tier A: the work unit file, 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 directive-receipt journal.
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) presents a Tier-C grant:
a signed, TTL-bound, single-use lfdg2 token that an operator signed with an
Ed25519 private key held outside every agent session. Every verifier checks it
against the operator’s public trust file (see
Reviewer grant trust file),
so no verifying process holds a minting secret. The grant reaches the process through the host, never through a command-line flag or a file.
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. A grant is refused when it is
scoped to a different WU, has already been used, has expired, or belongs to a role session that has
since been revoked or replaced. 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.
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 configured for the workspace.
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 from an
addressee. For a signal with explicit recipients, only a reply from one of
those recipient identities resolves it; another session’s reply remains
visible as thread activity but never clears that identity’s pending
acknowledgement. For an unaddressed/role-only broadcast, any resolved reply
clears it, since resolving a role-scoped request is delegated authority.
When a thread contains a nested signal with its own requires_ack: true, reply
directly to that signal’s signal_id. The reply receipt resolves the direct
parent’s obligation and leaves other independently acknowledgement-required
signals pending. A reply to a direct parent without its own requires_ack
keeps the established thread-root fallback for ordinary nested replies. No
ancestor traversal or wire-schema change is involved.
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.
mem:converged --thread <id> remains a thread-level summary of root recipient
decisions. It does not inspect reply_to or each independently acknowledgement-
required child’s receipt, so convergence does not prove that every nested
request has been answered; use --pending-ack for the per-signal obligations.
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
the workspace signal-hook state directory. 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 the local signal store, 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 signal store. 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.
Availability requests under peer consent
Section titled “Availability requests under peer consent”The supported reference receiver also accepts the closed
a2a.peer-availability.request.v1 signal for availability and
meeting_proposal requests. It resolves the sender from the authenticated
transport and reads consent stored by the receiving workspace for that peer.
The receiver-selected consent scope binds the member, calendar, connection,
permitted time range, expiry, allowed request kinds, and a separate usage cap
for each kind. A sender cannot choose or widen those resource bindings in its
request.
Requests contain only a request ID, kind, time window, and duration. The
receiver returns free/busy blocks clipped to that window and at most three
proposed slots of the requested duration. The result omits event titles,
attendees, and other calendar details. meeting_proposal returns candidate
times only; consent neither creates a calendar booking nor satisfies a
separate effect approval. Remote
results carry the E3 remote-signal provenance label so later tool decisions
can apply the appropriate restrictions.
Requests with no grant, an expired grant, a disallowed kind or time window, an
exhausted per-kind cap, a reused request ID with changed request contents, or
an ungranted receiver resource are rejected with a typed REJECT before
calendar access. The active receiver run must also be able to persist the E3
owner notice; a missing or
failed notice binding fails closed. Refusals from the same peer are coalesced
to one owner notice per configured notice window.
This behavior is proven for the SDK-supported non-Cloud reference composition of the consent-gated port and the agent-runtime availability handler. The existing hosted control-plane relay carries cross-workspace signals, but this reference proof does not establish hosted-production availability handling; deployments should not assume a hosted receiver applies these consent and calendar rules unless that receiver explicitly composes them.
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.