12 KiB
name, description, trigger_words
| name | description | trigger_words | ||||||
|---|---|---|---|---|---|---|---|---|
| notify | Reach Gabriel from an agent session through the ai-agent hub — a push notification to his phone (notify send), a 2-3-button choice he taps (notify ask), or a structured form he fills in the viewer (notify form) — and collect the answer without blocking the turn (notify wait). Works identically on the lab host and on a remote worker. Use when work is finished, when the user wants to be pinged, or when you need a decision or details from him while he may be away from the terminal. |
|
notify — push · ask · form, through the hub
One CLI, cli/notify.py in the ai-agent repo, is how a session talks to
Gabriel. Every call goes to the ai-agent hub, the notification source of
truth: it records the event, links it to this conversation, forwards it to
Home Assistant (phone push + the desk phone), and takes the answer back.
| command | what Gabriel gets | you get back |
|---|---|---|
notify send [TITLE] MESSAGE |
a push (+ the desk phone reads it) | nothing — fire and forget |
notify ask "Q?" "A" "B" ["C"] |
a push with 2–3 tappable buttons | the chosen label |
notify form --title … --fields … |
a form inline in the conversation | the answers as JSON |
notify wait <id> |
— | the answer of an ask_… / frm_… |
notify cancel <frm_id> |
the form is dismissed | — |
The old names still work and mean exactly the same thing: notify-done =
notify send, notify-ask = notify ask, ask-form = notify form
(with its own wait / cancel). Pick whichever the task's guidelines name.
Where it runs
The CLI is stdlib Python, and the sidecar puts cli/ on every run's PATH, so
it is available wherever the hub launched the session:
- On the lab host it POSTs to the hub directly (
AI_AGENT_URL, defaulthttp://127.0.0.1:8096). - On a remote worker (a paired Mac) the sidecar exports
AI_AGENT_OUTBOX_URL: the call is queued in that sidecar's outbox and the hub — which polls every paired worker every ~2 s — collects it, records it, forwards it to the phone and pushes the answer back. The worker never calls the hub. Latency is a couple of seconds;waitbehaves the same.
Ids (ntf_…, ask_…, frm_…) are minted by the CLI, so the id printed on a
worker is the id the hub, the phone and the viewer use.
1. Push — notify send
notify send "Deploy finished ✅" # title defaults to "Claude Code"
notify send "homelab" "Traefik reloaded, all services up ✅" --type service
notify send "gabvdl deployed ✅" "live now" --type deploy --url https://www.dev.gabvdl.xyz
notify send "Task done ✅" "PR opened for review" --type git --final \
--say "La PR est ouverte, tout est prêt pour la relecture."
--urlis optional. Omitted, the hub links this conversation in the viewer (it resolves the session's transcript itself — worker sessions included). Pass a more relevant page when there is one (a deployed site, a dashboard, a kanban ticket).--typepicks the icon, colour and Android channel:deploycodebuildgitserviceaimusicmediaresearchlogsdnsauthmailsuccesserrorwarnbackup.--channel/--importance/--vibrationoverride a single push.--finalmarks the task's closing push: the hub stamps the session finished (what the terminal'sDONEline means). Use it on the last notification of a task, not on progress pings.--say/--spoken— one short, factual, hopeful French sentence the desk phone reads aloud instead of the screen text (Pocket-TTS, no emotion markup: the tone is in the wording).--image <url|/local/file>shows a picture. A--type deploypush with a public--urlauto-attaches that site's/og-image.pngwhen it answers 200 (lab hosts are skipped — the phone can't validate their certs;HA_NOTIFY_NO_OG=1disables it).--action-cmd "Title::shell command"adds a button; tapping it runs the command (a detached waiter long-polls the hub for up to 6 h). This is howcommit-project's Merge to main button works.- The cost line (
Claude Code · $1.57 · 78k tokens · 8m 38s) is appended automatically from this session's transcript;--no-costdrops it.
2. Choice — notify ask
For a decision Gabriel can make with one tap. The first arg is one sentence
that names every option; the rest are the button labels (2 or 3, short
verbs: Rebuild, Skip, Cancel — Android shows at most 3).
Never block the turn. Send with --background, arm a Monitor on the wait,
end the turn; the answer wakes you:
notify ask --background \
"Where should I deploy the build: staging, production, or cancel?" \
"Staging" "Production" "Cancel" --type deploy --timeout 900
# → ASK_ID=ask_4f2c91ab77de
Monitor: command = "notify wait ask_4f2c91ab77de", persistent = true,
description = "waiting for the deploy-target choice"
notify wait <ask_id>prints the chosen label and exits0the moment a button is tapped;3when the ask expires (--timeout, default 180 s — give a backgrounded ask a generous one).- While pending, the conversation sits in the viewer's Waiting for feedback section, answerable from the card as well as from the phone.
- When the answer lands, TaskStop the monitor if still armed. If you stop waiting, TaskStop it too — the ask expires on its own.
- Without
--backgroundthe call waits inline and prints the label — fine in a shell script, wrong in an agent turn. - Only for questions that genuinely block the work while Gabriel is plausibly away. For anything richer than 2–3 buttons, use a form.
3. Form — notify form
notify form --title "Project setup" --description "A few details before I scaffold." \
--type ai --fields '[
{"type":"markdown","label":"Context","text":"I found **two** candidate stacks."},
{"key":"name","label":"Project name","type":"text","required":true},
{"key":"stack","label":"Stack","type":"select","required":true,
"options":["Vite + React","Next.js","Astro"]},
{"key":"features","label":"Features","type":"multiselect",
"options":[{"value":"auth","label":"Auth"},{"value":"db","label":"Database"}]},
{"key":"deploy","label":"Deploy after scaffold","type":"checkbox"},
{"key":"polish","label":"Polish level","type":"slider","min":1,"max":5,"default":3},
{"key":"logo","label":"Logo","type":"file","accept":"image/*"},
{"key":"deadline","label":"Deadline","type":"date"}
]'
# → FORM_ID=frm_… + the focus URL; exits immediately
Field types: text textarea number select multiselect radio
checkbox slider file date, plus two display blocks: markdown
({"type":"markdown","text":"…"}) and html (below), each with an optional
label heading. Options are strings or {value,label}. Description, help,
labels and options render markdown. --fields-file <path> or JSON on stdin
for big specs.
"Other…" on choice fields. A required select / radio /
multiselect automatically gets an Other… choice with a free-text input, so
a mandatory question whose options miss the real answer stays answerable;
"other": true|false forces it on an optional field or removes it. The typed
text comes back as the value (a string outside the options; in a
multiselect array, one extra free string) — so read such answers as "one of my
options, or Gabriel's own words".
html blocks — visualisations, schemas, images, videos
{"type":"html","label":"…","html":"<…>"} puts raw markup in the form's flow:
a chart, an architecture schema, a before/after screenshot, a video clip, a
table you styled yourself. Two rendering paths, picked per block:
- Inline (default) — sanitized (DOMPurify) and rendered in the PWA's own
DOM, inheriting its theme:
<img>,<video controls>,<audio>, inline<svg>, tables, inlinestyle=""all work. No<script>, no<style>sheet, no<iframe>, no event handlers (stripped). - Sandboxed —
"sandbox": true, or automatically when the markup carries a<script>,<style>,<link>or<iframe>: the block becomes asrcdociframe withsandbox="allow-scripts"(opaque origin: no access to the PWA, its cookies or storage). Scripts run, so Chart.js / mermaid / three.js / d3 visualisations work; libraries may be loaded only from cdn.jsdelivr.net, cdnjs.cloudflare.com, unpkg.com or esm.sh (CSP; fonts from Google Fonts) — anything else must be inline. The iframe sizes itself to its content ("height": 320pins it instead). The PWA's theme tokens are injected, sohsl(var(--primary)),var(--muted-foreground),var(--code-string)… match the viewer in CSS/SVG, and<html class="dark">is set in dark mode. Canvas libraries (Chart.js, three.js) can't resolvevar()— give them literal colours, or read a token first:getComputedStyle(document.documentElement).getPropertyValue('--primary')(an HSL triplet, e.g.24 95% 53%). A full<html>document is accepted as is.
Local files (a screenshot you just took, a rendered diagram, a clip) go in
with --asset <file> (repeatable) and are referenced as
{{asset:<basename>}} anywhere in the spec — the hub stores them like
composer uploads and substitutes the serve URL. Works unchanged from a Mac
worker (the file travels base64 inside the payload; 20 MB per file).
notify form --title "Which layout?" --type ai \
--asset .ai/artefacts/$CLAUDE_SESSION_ID/before.png \
--fields '[
{"type":"html","label":"Current page",
"html":"<img src=\"{{asset:before.png}}\" style=\"border-radius:8px\">"},
{"type":"html","label":"Weekly cost","sandbox":true,"height":260,
"html":"<canvas id=c></canvas><script src=\"https://cdn.jsdelivr.net/npm/chart.js\"></script><script>new Chart(c,{type:\"bar\",data:{labels:[\"Mon\",\"Tue\"],datasets:[{data:[3,5],backgroundColor:\"hsl(24 95% 53%)\"}]}})</script>"},
{"key":"layout","label":"Layout","type":"radio","required":true,
"options":["Sidebar","Tabs"]}
]'
Blocks are display-only (never answered, skipped in the recap) and capped at
512 KB each. Keep HTML out of markdown blocks and the description — they
render markdown only.
The hub pushes the "📋 title" notification itself, deep-linked to the form
(?form=<id> opens it full-screen in the PWA). Then, as for an ask:
Monitor: command = "notify wait frm_XXXX", persistent = true,
description = "waiting for form answers"
waitprints one JSON line keyed by field key on submit (exit0); cancelled ⇒3;--timeout Nreached ⇒4(the form stays pending —notify cancel <id>it or keep waiting).multiselectanswers are arrays of option values;checkboxa boolean;fileanswers[{name, repoPath, url}](the upload lands underservices/ai-agent/data/uploads/).- Keep working on everything that doesn't depend on the answers; fold them in when they land. A stale pending form sits in Waiting for feedback forever, so cancel what you give up on.
- The same field spec is what the
planskill embeds under## Questions.
Notes
notify costprints the cost line alone;--dry-runonsend/ask/formprints the exact payload instead of sending.- Env:
AI_AGENT_URL(hub),AI_AGENT_OUTBOX_URL+AI_AGENT_OUTBOX_TOKEN(set by a worker's sidecar),CLAUDE_SESSION_ID(set by the sidecar for every run; the lab's shims resolve it otherwise),HA_NOTIFY_TYPE/HA_NOTIFY_URL/HA_NOTIFY_IMAGE/HA_NOTIFY_SPOKENas flag defaults. - There is no direct Home-Assistant fallback any more: if the hub is down the call fails loudly (exit 1) rather than pushing twice later.
- Claude Code's interactive AskUserQuestion is disabled for spawned sessions —
askandformare its replacement.