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:

ChannelResolves toMeaning
statusTELEGRAM_STATUS_CHAT_IDEngine status/ops — deploy notices, health, maintenance results
ownerfirst entry of TELEGRAM_ALLOWED_IDSThe 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.

  • text renders 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). Pass parseMode: 'plain' for verbatim text.
  • Long messages are chunked automatically — no length pre-checks needed.
  • Delivery is confirmed, not queued. The tool returns the messageId of 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_send is the voice sibling: no channel argument — it targets the calling conversation, else the owner channel (status when there is no derivable owner). Which voice it speaks in is config — see voice.
  • file_send is the document sibling: also no channel argument — it attaches a file to the calling conversation, else to status. The file must live in the agent’s own artifacts directory; that containment, not an approval card, is what bounds it — see artifact publishing.
  • about says whose message it is. All three take an optional about: { 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 (the reply link). Without about, 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 run about: { entity: 'F-7' } names the entity alone. A run with no key whose feature’s reply contexts are all keyed must name one — or about: { 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 on status; 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 on status, one message per model however many agents trip over it, said again after your notify.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 and omnislash auth-agents <cli> --force from 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 on status — that is where the answer it explains landed — falling back to status only 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; on status it 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 exhausted means nothing took over and the turn failed with it.
  • 🧠 Agent "<name>" context reached … — a notifyAt or compactAt mark of the role’s context: 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 crossed compactAt does 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 +extend keyboard, on status.
  • 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 noticeWhat the chat gets
below your level, nothing open on its subjectnothing
a subject with nothing opena new message
the same subject in the same state againthe earlier message edited — latest text, ↻ ×N since 12:00 — no new notification
… and remindAfterHours have passed since its last pusha 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 levela new message
a recovery (a hook ran clean, a grade passed, a drift is gone) with nothing opennothing, at any level
the live card of a run still going, while a failure is open on its subjectnothing 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 (/agents can 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_events as 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:

FieldMeaning
toA 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.
replyToMessageIdThread the message as a reply. Safe when the target is gone: it lands unthreaded instead of failing.
subjectWhat 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.
buttonsLink 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. So sticker together with an explicit to is 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. body and sticker may 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.

Screens from Sixty Seconds Studio, a fictional demo studio, running on the real dashboard.