Files

12 KiB
Raw Permalink Blame History

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
notify me
ping me
let me know when
ask me
clarifying questions

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, default http://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; wait behaves 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."
  • --url is 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).
  • --type picks the icon, colour and Android channel: deploy code build git service ai music media research logs dns auth mail success error warn backup. --channel / --importance / --vibration override a single push.
  • --final marks the task's closing push: the hub stamps the session finished (what the terminal's DONE line 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 deploy push with a public --url auto-attaches that site's /og-image.png when it answers 200 (lab hosts are skipped — the phone can't validate their certs; HA_NOTIFY_NO_OG=1 disables 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 how commit-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-cost drops 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 exits 0 the moment a button is tapped; 3 when 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 --background the 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, inline style="" 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 a srcdoc iframe with sandbox="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": 320 pins it instead). The PWA's theme tokens are injected, so hsl(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 resolve var() — 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"
  • wait prints one JSON line keyed by field key on submit (exit 0); cancelled ⇒ 3; --timeout N reached ⇒ 4 (the form stays pending — notify cancel <id> it or keep waiting).
  • multiselect answers are arrays of option values; checkbox a boolean; file answers [{name, repoPath, url}] (the upload lands under services/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 plan skill embeds under ## Questions.

Notes

  • notify cost prints the cost line alone; --dry-run on send / ask / form prints 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_SPOKEN as 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 — ask and form are its replacement.