Notification Channels
Named delivery destinations your agents can notify without approval cards.
You declare channels in omnislash.config.ts; the engine turns them into the
closed destination set of the notification_send system tool. Declaring a channel in
your committed config is your standing approval for every send into it —
that is what keeps the capability hitl:false honest, and what lets headless
heartbeat agents deliver freely.
The model
A channel is a public name + an LLM-facing description. The chat id is private, so it lives in env — resolved at boot from a fixed naming convention, never declared in config:
// omnislash.config.ts
export default defineProject({
name: 'my-brain',
notifications: {
channels: {
reports: {
description: 'Daily sales reports and stock alerts for the owner.',
},
},
},
});
# .env — name derived by convention: NOTIFY_<NAME>_CHAT_ID
NOTIFY_REPORTS_CHAT_ID=-1001234567890
You do not declare NOTIFY_REPORTS_CHAT_ID in your env schema extension —
the engine synthesizes its schema entry from the channel declaration (declaring
it yourself fails the config load). Synthesis buys you everything at once: env
validation catches an unset var at boot, the var shows up in the dashboard env
catalog under Notifications, and a prod deploy without it fails the
pre-deploy env check.
Channel names must match ^[a-z][a-z0-9_]{0,31}$.
Built-in channels
Up to two channels exist without any declaration:
| Channel | Resolves to | Meaning |
|---|---|---|
status | TELEGRAM_STATUS_CHAT_ID | Engine status/ops — deploy notices, health, maintenance results |
owner | first entry of TELEGRAM_ALLOWED_IDS | The primary owner’s DM — personal messages, advice, reminders |
Both — and every declared channel — are sent by the chief’s bot (the bot
whose role is chief.role): a notification is the operation speaking, not a
persona. A brain whose bots all run other roles has no status or owner
channel, and a declared channel fails the boot naming the missing bot.
One exception: owner written by an agent of a persona role — a role a
bot in bots[] runs that is not chief.role — is sent by that role’s own bot,
into its DM with the primary owner. A heartbeat that runs as largo, whose bot
is not the chief’s, greets the owner from Largo’s bot, and the owner’s reply
lands in Largo’s DM, where Largo’s chat agent serves it with the beat’s feature
context attached (reply link). voice_send from a turn with no chat follows
the same rule. status and declared channels always use the chief’s bot: they
are group rooms, and the chief’s bot is the one bot sure to be a member.
owner is the primary owner’s DM — the primary owner is the first id in
TELEGRAM_ALLOWED_IDS, and it alone gets the engine’s defaults: this channel, the
default HITL chat (when TELEGRAM_HITL_CHAT_ID is unset) and restart DMs. Other
owners (later env ids, tier: owner in access.yaml) are not reached by it. It is
never the HITL approvals chat —
TELEGRAM_HITL_CHAT_ID is a separate approvals-only concept and never feeds a
notification destination.
owner needs no declaration because a Telegram private-chat id equals the user
id: the owner ids in TELEGRAM_ALLOWED_IDS are numeric by validation, so the
primary owner’s id doubles as the DM’s chat id. A brain with no Telegram surface
(bots: []) has no owner channel — it drops out of the notification_send enum,
and voice_send’s headless fallback lands on status instead.
Sending
Agents call the notification_send tool. channel is an optional, closed enum
built from your config — your channel descriptions are rendered into the tool
schema, so they are what tells the LLM which channel fits:
notification_send({ channel: 'reports', text: 'Stock for SKU-42 is below 10 units 📦' })
notification_send({ text: 'Dream pass done: 3 memories merged' })
Omitting channel uses the same addressing chain as
context.notify: the calling
turn’s own chat (persona included), and for a turn with no chat — a heartbeat,
a hook, a scheduled headless run — the status channel. So a prompt that says
“report via notification_send to the default channel” needs no channel name.
Omission never widens the destination set: it resolves only to the turn’s own
chat or status. When the turn has no chat and the brain has no status
channel, the call fails and names why.
textrenders as markdown by default (safe degradation to plain text on malformed markup; the engine delivery path handles Telegram entities, so no “plain text only” contortions in prompts). PassparseMode: 'plain'for verbatim text.- Long messages are chunked automatically — no length pre-checks needed.
- Delivery is confirmed, not queued. The tool returns the
messageIdof the message that actually landed; a bad chat id or a kicked bot comes back as a tool failure the agent can see and react to, not as a line in a log it will never read. voice_sendis the voice sibling: no channel argument — it targets the calling conversation, else theownerchannel (statuswhen there is no derivable owner). Which voice it speaks in is config — see voice.file_sendis the document sibling: also no channel argument — it attaches a file to the calling conversation, else tostatus. The file must live in the agent’s own artifacts directory; that containment, not an approval card, is what bounds it — see artifact publishing.aboutsays whose message it is. All three take an optionalabout: { context, entity? }— a feature context and its entity key:notification_send({ text: 'T-481 is answered', about: { context: 'ticket', entity: 'T-481' } }). The engine records it as the message’s origin, so a person’s reply to that message can bring the context back (thereplylink). Withoutabout, a message sent by a run of a feature (its heartbeat, hook or scenario) belongs to that feature, with the run’s entity key if it has one; on such a runabout: { entity: 'F-7' }names the entity alone. A run with no key whose feature’sreplycontexts are all keyed must name one — orabout: { context }to send without — or the send is refused, naming the context and its key (contexts.md § 2.1). A context that does not exist — or names no entity while you pass one — is refused before anything is sent.
This is the only way an agent’s output reaches a chat it is not already
talking in. The engine never re-publishes an agent’s answer into a chat the
agent is not bound to — not for hooks, not for heartbeats, not for scheduled
runs. If a hook
agent should report, say so in the prompt or the skill: “post your report via
notification_send (channel: 'status')”. This is deliberate, and it is what makes a
skill portable: the same disaster-recovery skill is reachable from a Grafana
hook, a scheduled audit and a chat, and it delivers identically from all three.
An engine-side auto-post would fire on some of those paths and not others, so a
skill that also calls notification_send would double-post on exactly one of them.
What the engine does announce on its own is the outcome of a hook fire that
did not cleanly succeed — ⚠️ Hook "<name>" — agent <name> failed, plus the
error and whatever partial output the turn had produced, delivered to the hook’s
surface. A dead hook agent cannot report its own death, so that one is the
engine’s. A clean success is never posted — it only closes the hook’s open
failure message (Notices): a hook
failing on every fire is one message counting up. The notice is built from the
fire’s run record and its agent’s row once the agent’s turn has ended —
however it ended, a stop or a deploy that cut it included — so it arrives even
when the engine restarted while the agent was working. A scenario’s inline
hook reports to the chat its run was started from, else to status. A
scope: chat hook has no outcome notice: its fire ends when the message is in
the conversation.
Another unsolicited message the engine owns is a failed workflow
recovery: ⚠️ Recovery failed — run <slug> plus the error, off the
workflow:recovery.failed event. Same principle, same owner: a remediation agent
that never spawned (attempt cap reached, run row gone, agent creation threw), or
one a worker shutdown cut short, has nobody alive to report its own death, and
without the notice whoever tapped 🛠 Recover waits on “Spawning…”
forever. It lands on the event’s surface — the chat the Recover was tapped in,
or, for automatic recovery, the chat the run itself was started from — and on
your status channel whenever that is null, which covers every dashboard click
and every run the engine started outside a chat. Expect one on a routine redeploy
too: a worker restarting mid-dialogue really does end that recovery, so it is
reported rather than swallowed. Branch on reason, never on the English
error — shutdown_before_dialogue / shutdown_during_dialogue are deploy
noise, every other value (cap_reached, agent_create_failed, run_not_found,
agent_failed, no_retry, …) is a real failure. recoveryCount comes through
as null on exactly the run_not_found legs — there was no run row left to
count from — and an unresolved {{...}} is kept verbatim rather than
blanked, so a hook template reading Recovery attempt {{recoveryCount}}/3 failed reaches the agent with the literal placeholder in it. Guard the field,
or branch on reason, before you interpolate it. The successful legs stay
silent, as the doctrine requires: recovery.started is followed within seconds
by the remediation agent’s own message on the same surface, and
recovery.completed means the retry is already re-rendering the run card. This
notifier lives in the bot process, so a brain running services.bot: false gets
no recovery notice at all.
Those two are not the whole set. The engine also speaks unprompted about state changes no agent is in a position to announce for itself:
🔌 Connection expired: <provider>— an OAuth refresh token was terminally rejected, so every tool behind that credential fails until it is authorized again. Lands onstatus; the ways to fix it are in operations.🔑 Agent credentials rejected: <model>— a CLI harness or API provider refused the credentials an agent runs on, so every step on that model fails while a fallback chain quietly answers on the next one. Lands onstatus, one message per model however many agents trip over it, said again after yournotify.remindAfterHours. For a CLI harness it carries a Sign in button that starts the login right there (you get a link to open), and names the two alternatives — the dashboard terminal andomnislash auth-agents <cli> --forcefrom the brain repo. Its billing twin,💳 Agent billing failure: <model>, means the credentials work and the account cannot pay — the same event, a different fix. See cli agents setup.↻ Model fallback — <agent>— a turn did not run on the model it was asked to run on: the step failed (or its breaker was open and it was skipped without running), and the chain handed the turn to the next one. Names the transition, the reason, and the provider’s own error. This is the one notice that lands on the run’s OWN chat, not onstatus— that is where the answer it explains landed — falling back tostatusonly for a run with no chat of its own (heartbeat, pipeline, dashboard dispatch). On a run’s own chat it is about that agent and transition; onstatusit is about the MODEL, so one outage is one message whichever heartbeat agents hit it, and a rate-limited primary failing every turn folds into that message. Its variant↻ Model chain exhaustedmeans nothing took over and the turn failed with it.🧠 Agent "<name>" context reached …— anotifyAtorcompactAtmark of the role’scontext:policy (contract) was crossed. Lands on the crossing turn’s own surface and nowhere else: a headless turn has nobody to nudge. The auto-compact of a crossedcompactAtdoes not depend on this notice: the agent queues it on itself, chat or no chat.- A boss-run expiry card — the run’s activity summary plus a
+extendkeyboard, onstatus. - A Secretary Mode failure note — the counterpart saw nothing, so the connection owner is told which chat went unanswered, in their control DM. See secretary mode.
Like the recovery notice, all of these ride the bot process. And none of them is an
agent speaking: an agent’s own words still reach a chat it is not already in
through notification_send and nothing else.
Notices: what the engine posts on its own
Every message the engine posts into a chat when nobody there asked for it — the
status card of a run nobody in that chat started (a schedule, an agent, the
dashboard), an eval card, every notice above, the loop’s notices, a Grafana
alert, the external-restart DM — is a notice. It names its subject,
the thing it reports on (hook:<id>, model:<ref>:credentials,
alert:<fingerprint>, eval:heartbeat:<id>, run:<scenario>), and its
level: routine (nothing changed for you — a pass, a clean run, a
restart), news (worth knowing, needs nothing — a change shipped, a model fell
back and the answer still came) or failure (something broke or needs you).
One gate in the bot remembers what each chat has been told:
| The notice | What the chat gets |
|---|---|
| below your level, nothing open on its subject | nothing |
| a subject with nothing open | a new message |
| the same subject in the same state again | the earlier message edited — latest text, ↻ ×N since 12:00 — no new notification |
… and remindAfterHours have passed since its last push | a new message, still counting |
| the subject dropped to a lower level (the hook ran clean, the alert resolved, the grade passed) | the earlier message marked ✅ Resolved 15:00, its buttons dropped; a news notice (a change validated after it regressed) is posted too |
| the subject in a new state at or above your level | a new message |
| a recovery (a hook ran clean, a grade passed, a drift is gone) with nothing open | nothing, at any level |
| the live card of a run still going, while a failure is open on its subject | nothing yet — the run’s outcome then resolves or repeats that message |
Your level — one value, three tiers, the most specific wins per key:
# constitution/MANIFEST.yaml — the whole brain (engine default shown)
notify:
level: news # all | news | failures | none
remindAfterHours: 24 # 0 = never post a persisting failure again
# features/marketing/website-improver.heartbeat.md — one entity
notify:
level: failures
all shows every run card and routine notice (a bare recovery still posts
nothing — all it says is that something open closed); news (the default) what changed;
failures only what broke; none nothing the gate decides on. A scenario’s,
heartbeat’s or hook’s notify: governs the notices about it (its run cards, its
grades, its hook failures); a notice about no entity (a model’s credentials, a
connection, an alert) follows MANIFEST. An agent changes it like any setting —
manifest_update, heartbeat_update / scenario_update / hook_update.
Not gated: a message a person asked for — the card of a run they started in that chat, the fallback notice on their own turn, a recovery they tapped — is posted whatever your level (repeats still fold), and so is everything an agent sends: the level is for the engine’s words, never yours.
What stays quiet is not lost. The run, the grade and the failure are rows
the dashboard and the tools read (run_list, eval_list, health_list), and
the daily brief counts them. notice_list (runtime:read) answers what the
chats were told that is still open — and brief_digest carries the same list
as openNotices.
An agent’s subject. notification_send and context.notify take
subject: a later send with the same subject into the same chat replaces the
earlier message in place (edited, no new notification) until
remindAfterHours, when it is posted afresh. Give it to a report that will be
repeated about one thing — an alert resolver passes the alert’s fingerprint, a
heartbeat that posts “still the same” its own name — and leave it out of a
message that stands on its own. An agent’s subjects are its own: a send can
only replace a message an agent sent, never an engine notice. Nor is it ever an
open notice: nothing marks it resolved, so notice_list and the brief’s
openNotices leave it out — whether the alert itself still fires is its
alert:<fingerprint> row there. When the thing is over, say so with the same
subject: the message is replaced like any other send.
The chat an agent IS in: no tool, no waiting
notification_send is for reaching elsewhere. For an agent a person actually talks
to — a chat’s own chat agent, an agent someone created in a chat, a dashboard chat
(agents.source = 'chat') — its own text is already the delivery channel, and
it delivers one answer per turn:
- The answer is the turn’s last non-empty text block. Everything the agent writes before or between tool calls is narration: it shows on the live status card while the turn runs (newest text wins) and is never delivered as a message. When the turn completes, the answer is sent as a new chat message — so it notifies, and in a group it is threaded under the message that started the turn — and only then is the status card deleted. Attachments the turn produced ride with it. A turn with no text and no attachments delivers nothing.
- A pause does not cut the answer. A turn that pauses on a HITL question or grant request continues after the answer, and still answers once: with the last text it wrote, before or after the pause. A turn that writes its answer, asks for an approval and then writes nothing more still delivers that answer.
- A question carries the text before it. When the agent asks a question, the text it wrote just before calling the question tool is delivered before the question card, to wherever the card goes — the person needs that context to answer. An agent that wrote no text before asking (it only reasoned) has its last reasoning sent instead. In Secretary Mode the card goes to the owner’s DM, and so does that text: the counterpart never sees it. A question asked on a turn no chat message started (a schedule, a heartbeat) arrives as the card alone. A grant request sends nothing ahead of its card.
- Failed, cancelled, steered. A failed turn delivers its answer (if it wrote
one) followed by the ⚠️ failure line and a
[🔄 Retry]button — same audience rules as always: nothing at all for a Secretary-Mode counterpart. A cancelled turn delivers nothing. A message that steers a running turn (interrupt mode, a reply to the status card,/steer <text>) does not lose what that turn already wrote: if the steer cancelled it, its answer so far is delivered, threaded under its own message; if it finished first, its answer is delivered from the journal instead — never twice. - A turn nobody typed is delivered when it ends. A scheduled prompt, an
agent.enqueue, an engine-dispatched turn — those carry no chat dispatch identity, so the live lane does not serve them. Their answer is mirrored into the chat once the turn terminates (silently, headed with its provenance) by the same rule: the last text block of the turn, nothing before it. Boot-time recovery of an answer the bot missed while restarting follows the same rule. - Delivery is not guaranteed and not observable from inside the turn. The
chat delivers only for the agent it currently points at (
/agentscan move that pointer mid-turn), a model reroute restarts the answer from the next model, and long text is split — or, past the platform’s own limits, sent as a file. An agent must not explain “you didn’t get it” with mechanics; it should say the thing again. - The transcript is journalled independently of delivery. Every text block
lands in
conversation_eventsas it is emitted, on the producing side — so “the chat never showed it” and “the agent never wrote it” are separable after the fact, by turn id, rather than a matter of anyone’s recollection.
Why one answer, not every block. It is the contract the CLI harnesses already train their models on — Claude Code treats the final message as the turn’s answer and the text between tool calls as narration — so the prompt and the delivery agree instead of fighting. Delivering every block as its own message made the model’s natural shape (answer → save a note → restate or recap) arrive as two answers or a junk trailing “saved it” message, and no prompt rule reliably stopped it. Mature chat-agent bridges converged on the same split: OpenClaw sends only the final answer as a new message and shows the pre-tool text in a progress draft it deletes at completion.
Every chat agent is told this contract in its system prompt (the
with-chat-delivery section): the final message is what the person reads and
must stand on its own (never “see above”), bookkeeping goes before the answer
because text written after it replaces it, the text before a question is
delivered with the question, and the “you cannot see delivery” rule. No prompt
flag removes it — a persona that turns environment off still gets it: it is
the contract of the channel, not runtime chrome. The section carries a
per-surface half too: the live status card exists on ordinary chats and not in
Secretary Mode, so only the former is told its between-tool text shows there
briefly. Override the file in your own constitution/Agents/system/ if your
surface needs different guidance; keep the mechanics, since they are the
engine’s, not advice.
Sending from your own tool: context.notify
notification_send is the verb the model calls. When the delivery is part of your
own tool’s logic — you send, then write a row; you pick a sticker, then post it
— you do not want a second tool the LLM has to chain. You want the transport.
Every tool body gets it as context.notify:
export const reportNotifyTool = createTool({
name: 'report_notify',
schema: z.object({ text: z.string() }),
// ...
async execute({ text }, context) {
await context.notify({ to: 'reports', body: { text, parseMode: 'markdown' } });
// Only reached once the message landed — so the cooldown row can never
// claim a send that did not happen.
await db.insert(reportMessages).values({ messageText: text });
return success('Sent and logged');
},
});
The request:
| Field | Meaning |
|---|---|
to | A channel name — reserved or declared. Omit it to deliver on the calling turn’s own surface, persona included. A headless turn without to lands on status. |
body | { text, parseMode }. Same rendering rules as notification_send. |
sticker | { fileId } — a native platform sticker. |
replyToMessageId | Thread the message as a reply. Safe when the target is gone: it lands unthreaded instead of failing. |
subject | What the message reports on, when you will report on it again — a later send with the same subject replaces this message in place (Notices). Not with sticker. |
buttons | Link buttons under the text, as rows: { kind: 'url', text, url } opens a link, { kind: 'webApp', text, url } (https) opens a page inside Telegram — only in a person’s private chat with the bot; anywhere else the send is refused rather than delivered without it. Needs body.text. notification_send takes the same buttons. |
about | { context, entity? }, or { entity } keying the one keyed context of the calling turn’s own run — what the message is about, recorded as its origin (see notification_send above). Refused when the context does not resolve, when { entity } finds no keyed context or several, and on a run whose reply reach entries are all keyed and unkeyed when it names nothing. |
Rules worth knowing before you hit them:
- Same addressing as
notification_send.to↔channel, same fallback when omitted. There is no chat-id form in either — see below. - Stickers ride the turn surface only.
file_ids are bot-scoped: an id fetched with one bot’s token is only sendable with that same token. Sostickertogether with an explicittois rejected, and so is a sticker from a headless turn — there is no bot to send it with. Both come back as validation errors, not retried failures. - A rejected request is a permanent error, so a mistake in your call does not spin under retry backoff. A failed delivery is transient — retrying is the right move there.
- The call blocks until delivery. That is the point: it is what lets you write “sent, therefore log it” without compensating logic. Budget for up to a minute in the worst case.
- One payload kind per call.
bodyandstickermay travel together, but they ride one queue job that is retried whole: if the text lands and the sticker then fails, the retry sends the text a second time. Two calls cost nothing and each one fails on its own.
Anything the engine already owns — throttling, FLOOD_WAIT retries, chunking,
markdown degradation, reply threading, which bot speaks — happens behind this
call. A brain that hand-rolls fetch to a platform API is re-implementing all
of it, minus the retries.
Why there is no “send to chat id” form
The capability behind these tools, notification:send, is admissible as
hitl:false only because the destination is never a free-form argument:
either the tool has no destination argument at all (voice_send, file_send),
or it offers a closed enum over destinations you committed to config
(notification_send). A tool that accepts a raw chat id / user id belongs to
messaging:send and requires approval. Consequence: the queue_create_job tool
exposes neither the ui-notifications lane (arbitrary surface) nor the
workflow-execution lane (arbitrary createdBy, which every consumer projects
back to a surface) — either would be a side door around the closed enum.
If you need a new destination: add a channel to config, set its env var, restart. The enum is boot-static, like bot tokens.
context.notify follows the same rule for the same reason — it addresses by
channel name, never by chat id. The gate is on what the model can steer, not
on what your tool body can do: brain code that imports your database is already
fully trusted, so the honest place to draw the line is the destination set, and
that set is your committed config either way.