Notification Channel Model
Current channel surface
A channel is the filename stem in .notification/<channel>.json; for example,
system.json projects to attention.system and mcp.telegram.json to
attention["mcp.telegram"]. Ordinary channel files are current mirrors. Daemon is a logical aggregate from Store-owned .notification/daemon/<id>.json mini-files; sibling daemon.json is only a non-authoritative compatibility report. Use check and producer tools, not direct edits.
Delivered metadata can be historical, so never treat an old attention snapshot as
canonical producer state.
The effective allowlist is the static built-ins + mcp.* + hook channels
registered by this agent's workdir. It is per-agent, not process-global.
Unknown JSON names, non-JSON entries, invalid stems, and kernel-private dotfiles
are ignored. A present but unregistered channel emits one deduplicated
notification_hook warn-and-flag event with ref_id: blocked_channel:<channel> until registration.
Built-ins include email, system, nudge, post-molt, tool_loop_guard,
bash, cron, molt, goal, daemon, and delay-alarm. Runtime/configuration nudges use nudge; its System owner routes kernel_version to update checks. source_drift stays local, never release-migration routing. See ../../../system-manual/reference/runtime-update-checks/SKILL.md.
Payload and producer ownership
A producer writes the current channel as an envelope such as:
{
"header": "1 system notification",
"icon": "🔔",
"priority": "normal",
"published_at": "2026-06-10T00:00:00Z",
"instructions": "Optional producer-owned handling guidance.",
"data": {"events": []}
}
instructions is inside the payload, not a channel. Read it before choosing a
verb: the producer knows whether the file is disposable output, a mirror over
canonical state, a coalesced event summary, or protected source of truth. A
notification is never cleared by delivery and never mutates producer state: it must
not mark mail read, change a goal, consume an MCP queue, or touch any other
producer-owned record. A producer with canonical state should teach its owner verb
in instructions; hook registration only widens the allowlist. External
writers use atomic sibling-temp replacement so readers never see partial JSON.
Delivery and voluntary check
check returns a placeholder; the turn loop stamps the complete current mirrors onto
that same result (a deliberate read, not a replay). IDLE/ASLEEP wake delivery uses
the same shape. Automatic delivery is one-shot and shares one delivered identity
between the ACTIVE tool-result path and the IDLE/ASLEEP synthesized pair: a
known event/message is attached once: system/daemon use event IDs, email uses
mail IDs, and Telegram/WeChat/Feishu/WhatsApp use message or per-update IDs plus
material source content. New/materially changed records are delivered without
replaying the other records in their aggregate. Derived current flags, relative
ages, cursors, headers and counts alone do not create an event. Required reply
references may accompany a new message as context, not new instructions. Generic
ID-less hook channels remain versioned snapshots, not a global exactly-once promise.
Automatic IM seed context uses the native bounded preview window (Telegram 20,
WeChat/Feishu 10); older context in oversize legacy mirrors is not recorded as
having been delivered. A deliberate check can reread the complete mirror.
Identity is committed only after successful delivery from the exact observation;
blocked, unstable, no-carrier or failed delivery stays pending. The ledger survives
ordinary molt/rebuild/resync in the same agent process, not Agent/process restart:
then surviving mirrors can be delivered again. Nothing is persisted for crash
exactly-once. Earlier delivered messages remain usable task records, subject to
newer instructions and producer safeguards; older runtime state is not current.
Fingerprints and the live holder belong to kernel sync, not manual or this handler.
Consumer delay and expiry
delay is a consumer filter, never a producer operation. One allowed target is
recorded in private .notification/.delay_state.json; the target file keeps
receiving updates. A nonzero request replaces the prior live delay, and seconds: 0 cancels only the matching target and makes it visible again. The finite cap is
read live from LINGTAI_NOTIFICATION_DELAY_MAX_SECONDS; blank, non-numeric,
non-positive, or non-finite values fall back to 600.
For ordinary targets, coherent snapshots, voluntary checks, and wake delivery
omit only the delayed target. A daemon delay masks attention only: its payload,
byte version, and bounded summary remain readable, while daemon:delayed=1
prevents daemon arrivals from moving the wake fingerprint. Other channels,
including registered hooks, continue to wake. delay-alarm cannot itself be
delayed.
Expiry or recovery stops filtering and publishes one high-priority latest-only
delay-alarm mirror with target, requested/actual duration, byte-level
changed/no-change, and only conservative producer-reported or retained-event
statistics. State is atomically replaced under the established Store mutation
lock; a stable request id makes timer/restart races overwrite one alarm rather
than duplicate it. Malformed or unreadable delay state fails open to target
visibility, never silence.
Hooks and allowlist lifecycle
- Write a watcher that atomically publishes the envelope to an allowlisted
.notification/<channel>.jsonpath. - Before starting that watcher, register it with
notification(action='add', input={...}), supplyingname,channel,source,description,how_to_modify, andhow_to_cancel;versiondefaults to1.0.0, andinstructionsis optional. - The first publish is delivered once automatically (
checkrereads it); follow the producer instruction through the owning tool.
list preserves registry order. edit revalidates channel uniqueness; a null-
only edit is a no-op. drop revokes the channel but never stops its process.
Corrupt or unreadable .notification/hooks.json gives list, add, drop, and
edit a bounded load-failed result. Built-in channels and Store-reserved stems
hooks and large_result_acks cannot be registered.
Block cap and settings
notification.max_chars is one shared live cap (10000, clamped to 2048..10000)
for persistent and attention lanes. Metadata files are not channels. Resolution is environment
LINGTAI_NOTIFICATION_MAX_CHARS, then valid System-v2
notification_max_chars, then default. At or under cap, serialization is
byte-identical. Above cap, persistent output spills to
logs/notification-overflow-<ts>.json; attention output uses the
content-addressed notification-attention-overflow-<digest8>.json (with a
collision suffix). Both model copies compact while preserving routing ids and marking shortened fields truthfully where the producer defines a truncation flag. Only the persistent lane protects the IM current/new message (is_current): it fully compacts, then fully stubs, older context in the same block before the current message's own content is ever touched; the attention lane has no per-message is_current protection. If an id-only copy still cannot fit, the marker-only envelope may omit its long path (path_omitted=true) while retaining the exact basename in spill_file. A block-level overflow marker alone is not a reason to reread the whole block — open the spill (or, on spill_failed, call the producer tool) only for required content absent from all available current copies, including omitted/id-only records. A complete usable alternate/raw copy needs no reread even if a shorter preview's flag is true; a false or missing flag is not proof of completeness. This is context-size control, not
access or delivery accounting.
The SHOW row reports the same effective clamped value and never writes, refreshes,
adds init.json, or creates a settings file. The notification footprint also
includes kernel-owned large_result_acks.json, hooks.json, and the private
delay state. Inspect read-only; never delete the directory or bulk-remove files,
because producers own their state and delivery identity lives in the kernel.