229 lines
12 KiB
Markdown
229 lines
12 KiB
Markdown
---
|
||
name: notify
|
||
description: 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.
|
||
trigger_words:
|
||
- 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`
|
||
|
||
```bash
|
||
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:
|
||
|
||
```bash
|
||
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`
|
||
|
||
```bash
|
||
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).
|
||
|
||
```bash
|
||
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.
|