Proactive CAESAR¶
By default CAESAR is reactive — it answers /v1/chat requests and
dashboard messages. Proactive mode lets the brain start runs on
its own and reach your phone via ntfy.sh. Three
trigger sources, all living in the same triggers.yaml:
- Schedules (cron-like) — wake the brain at a fixed time. The canonical example is a morning brief: every weekday at 7am, summarise your calendar + weather + overnight HA state and push a notification.
- HA events (v1.6+) — wake the brain when Home Assistant fires an event. Motion at 11pm. Water leak. Garage door open longer than usual.
- Webhooks (v1.7+) — wake the brain when any external system
POSTs JSON to CAESAR. n8n, IFTTT, GitHub repos, custom shell
scripts. Each trigger gets its own bearer token;
caesar initgenerates one per trigger.
All three share the same brain entry and audit-log under the same
proactive-<trigger_id>- decision-id prefix. The matcher is
deliberately coarse — wake the brain on "motion happens" or
"a POST arrived", let the brain prompt decide whether to do
anything about it.
ADR-0030, ADR-0031, and ADR-0032 cover the underlying design.
What's in scope¶
After this page you'll have:
- A
triggers.yamldeclaring when CAESAR wakes itself up. - A configured
notifyLegion worker so the brain can reach you. - A policy entry allowing the
notifytool. - An audit log row for every proactive fire —
trigger.scheduled/trigger.subscribed,trigger.fired,trigger.completed, plus any tools the brain called.
Proactivity is opt-in at every layer:
CAESAR_PROACTIVE__TRIGGERS_PATHunset → no scheduler, no HA driver.enabled: falseon a trigger → loaded but never fires.notifynot onallowed_tools→ the brain can't push to your phone even if a trigger fires.
So the failure mode of "I configured this wrong" is silence, not a runaway alert at 3am.
Step 1 — Decide on a topic¶
Pick (or sign up for) an ntfy topic. The public server at
https://ntfy.sh is fine for personal use; self-host if you'd rather
keep notifications inside your homelab.
- Public ntfy.sh: pick a long, random topic name. Public topics have no auth — anyone who guesses the name can subscribe. A 20+ character random string is the practical fix.
- Self-hosted: see ntfy docs.
Once running, set
base_urlto your server and (optionally)tokento a per-CAESAR bearer token.
Install the ntfy iOS or Android app and subscribe to your topic; that's how the notifications reach you.
Step 2 — Configure notify in .env¶
# .env
CAESAR_TOOLS__NOTIFY__TOPIC=caesar-<your-long-random-string>
# Optional — defaults to https://ntfy.sh:
# CAESAR_TOOLS__NOTIFY__BASE_URL=https://ntfy.example.com
# Optional — only needed for self-hosted with auth:
# CAESAR_TOOLS__NOTIFY__TOKEN=tk_...
Then add notify to the in-process worker list (most operators run
the brain and the notify worker on the same box):
caesar init already lists notify under allowed_tools in the
generated policy.yaml, so the brain is allowed to call it as soon
as the worker is running.
Step 3 — Author triggers.yaml¶
caesar init writes a starter file with three disabled examples —
one per source type:
version: 1
triggers:
# Schedule (cron) source — fires at a fixed time.
- id: morning_brief
enabled: false # flip to true to arm
cron: "0 7 * * 1-5" # 7am, weekdays
timezone: "America/Los_Angeles" # your IANA timezone
max_runtime_seconds: 120
prompt: |
It's 7am on a weekday. Summarise today's calendar (if
calendar_read is configured), the weather (if web_search
is configured), and any overnight HA state changes worth
flagging. Then send a 2-3 sentence brief via `notify`.
# HA-event source — fires on a Home Assistant event.
- id: late_office_motion
enabled: false
event_type: state_changed
entity_id: binary_sensor.office_motion
to: "on"
time_window: "22:00-06:00"
timezone: "America/Los_Angeles"
cooldown_seconds: 600
prompt: |
Motion in the office at this hour is unusual. Check whether
anyone's home (state of person.* entities) and send a one-liner
via notify with what you see.
# Webhook source — external systems POST to /v1/hook/<trigger_id>
# with the bearer token. caesar init mints a fresh 48-char token.
- id: external_event
enabled: false
bearer_token: "<48-char-random-from-caesar-init>"
cooldown_seconds: 30
prompt: |
An external event arrived. Summarise what happened in 1-2
sentences via notify, or stay quiet ("nothing to report")
if it doesn't look worth flagging.
Schedule field reference¶
| Field | Required | Notes |
|---|---|---|
id |
yes | Snake-case identifier. Used in trigger_id on audit rows; pick something greppable. |
enabled |
no (default true) |
false keeps the trigger loaded but unarmed. |
cron |
yes | Standard 5-field cron (min hr dom mon dow). crontab.guru is great for sanity-checking. |
timezone |
no (default UTC) |
Any IANA name. DST is handled by croniter; daylight-saving boundaries don't double-fire. |
max_runtime_seconds |
no (default 300, max 3600) |
Hard cap on one fire's brain run. |
prompt |
yes | What the brain sees as the user message. |
HA-event field reference¶
| Field | Required | Notes |
|---|---|---|
id |
yes | Same as schedule — snake-case identifier. |
enabled |
no (default true) |
Same. |
event_type |
yes | The HA event to subscribe to. state_changed is the common case; others (zwave_node_alive, automation_triggered, custom events) work too. |
entity_id |
no | Constrains state_changed events to one entity (domain.entity format). Ignored for non-state events. |
to |
no | Constrains state_changed events to a specific new-state value (e.g. "on", "home", "unavailable"). Ignored for non-state events. |
time_window |
no | "HH:MM-HH:MM" in 24h time. Inclusive start, exclusive end. Cross-midnight allowed ("22:00-06:00" means 10pm–6am). |
timezone |
no (default UTC) |
IANA name. Used only for time_window evaluation. |
cooldown_seconds |
no (default None) |
After firing, suppress matching events for N seconds. Coalesced suppressions land in one trigger.suppressed audit row. |
max_runtime_seconds |
no (default 300, max 3600) |
Same cap as schedule triggers. |
prompt |
yes | What the brain sees. |
Webhook field reference¶
| Field | Required | Notes |
|---|---|---|
id |
yes | Same as the others. Used as the URL path: POST /v1/hook/{id}. |
enabled |
no (default true) |
Same. |
bearer_token |
yes | ≥32 characters. caesar init generates a fresh 48-char token via secrets.token_urlsafe(36). Stored as a SecretStr — never logged. |
cooldown_seconds |
no (default None) |
Same suppression semantics as HA events. Inside the cooldown window, repeat POSTs return 429 and coalesce into one trigger.suppressed audit row. |
max_runtime_seconds |
no (default 300, max 3600) |
Same cap as the others. |
prompt |
yes | What the brain sees. The POST body is appended as "Event body:\n<JSON-formatted or raw text>". |
Flat vs nested form¶
Both shapes load identically; pick whichever reads better:
# Flat (recommended for the common case)
triggers:
- id: hourly_ping
cron: "0 * * * *"
prompt: tell me one fact
# Nested (forward-compat with v1.7's webhook source)
triggers:
- id: hourly_ping
prompt: tell me one fact
source:
kind: schedule
cron: "0 * * * *"
timezone: UTC
Step 4 — Point Praetor at the file¶
caesar init already writes this line:
When this variable is unset, neither the scheduler nor the HA-event driver is constructed and CAESAR behaves exactly like v1.4 — reactive only.
Step 5 — Arm a trigger and restart¶
Edit triggers.yaml, flip enabled: true on the trigger you want,
restart Praetor. At startup you'll see one audit row per armed
trigger:
event_type: trigger.scheduled event_type: trigger.subscribed
payload: payload:
trigger_id: morning_brief trigger_id: late_office_motion
cron: "0 7 * * 1-5" event_type: state_changed
timezone: America/Los_Angeles entity_id: binary_sensor.office_motion
next_fire_at: "2026-05-19T14:00:00+00:00" to: "on"
time_window: "22:00-06:00"
cooldown_seconds: 600
When the trigger fires, the scheduler/driver:
- Writes
trigger.fired(trigger_id,prompt, …). - Runs
trigger.promptthrough the brain graph, with aproactive=truesystem-prompt bias that pushes the LLM toward summarise-and-notify, don't act on the house. - Each tool the brain calls (
notify,calculator,web_search,calendar_read, …) goes through the policy engine and writes its normaltool.called/tool.deniedaudit rows. - Writes
trigger.completedon success, ortrigger.timeout/trigger.erroron failure.
Every audit row carries a decision_id prefixed
proactive-<trigger_id>-, so filtering the dashboard timeline to
proactive activity is one substring match away.
Reacting to HA events¶
This is the heart of v1.6. Two design decisions are worth knowing because they shape how you write triggers:
The matcher is coarse on purpose¶
Each triggers.yaml entry can constrain on one entity and one
new-state value. You cannot AND multiple conditions in the matcher,
and you cannot write boolean expressions. This is deliberate
(ADR-0031 §3): CAESAR's value is the brain, and the brain has full
HA-state access. Put the cheap part of the filter in YAML (event
type, entity, time window) and the smart part in the prompt:
- id: garage_left_open
event_type: state_changed
entity_id: binary_sensor.garage_door
to: "on" # door opened
cooldown_seconds: 1800 # don't spam within 30 minutes
prompt: |
The garage door just opened. Look up the state of:
- person.* (who's home)
- sun.sun (is it night?)
- alarm_control_panel.home (is the alarm armed?)
If it's the middle of the night with nobody home and the
alarm is armed, alert me with the time and what you found.
Otherwise stay quiet — reply "nothing to report".
The brain has access to the HA bridge via the call_service /
state tools, so multi-condition logic ("door open AND it's night
AND nobody home AND alarm armed") lives in the prompt where it
gets fresh state every fire — not in stale YAML that can't see
what changed since the last reload.
Cooldown is per-trigger and in-memory¶
cooldown_seconds: 600 means "after this trigger fires, ignore
matching events for 10 minutes". The 11 motion events your sensor
emits while someone walks across the room get coalesced into one
trigger.suppressed audit row carrying count: 11. The next
allowed fire flushes that row.
The cooldown clock lives in memory — a Praetor restart resets every cooldown. That's fine for the common case (operators rarely restart in the middle of an alert burst), but it does mean a triggered alert can theoretically refire within seconds of a restart. If you care about that gap, raise it in an issue.
What happens during HA reconnects¶
CAESAR keeps one shared WebSocket subscription to HA and reconnects
with exponential backoff if it drops (ADR-0031 §2). Events emitted
during the disconnect are not replayed. This is a deliberate
correctness gap: HA's WS API doesn't expose a stable cursor, and
emulating replay via the REST history endpoint is fragile. If
you need durable delivery for a security-sensitive trigger (water
leak, smoke detector), write the automation in HA itself — that's
HA's job, not CAESAR's.
A small set of audit events tracks the WS lifecycle:
ha.subscription.opened— first successful connect.ha.subscription.reconnected— every subsequent connect, with risingconnect_count.ha.subscription.closed— on stop or unrecoverable error.
If your HA triggers stop firing, those rows are the first place to look.
Worked examples¶
The morning-brief example covers schedules. For HA events:
Motion in office between 10pm and 6am. Coarse matcher; brain prompt checks whether anyone's home before alerting:
- id: late_office_motion
event_type: state_changed
entity_id: binary_sensor.office_motion
to: "on"
time_window: "22:00-06:00"
timezone: "America/Los_Angeles"
cooldown_seconds: 600
prompt: |
Motion in the office during quiet hours. Look up the state of
person.* entities — if anyone's home, stay quiet ("nothing to
report"). Otherwise send a brief alert via `notify` saying
when it happened and where.
Water leak detected. No cooldown — every leak event matters:
- id: water_leak
event_type: state_changed
entity_id: binary_sensor.basement_leak
to: "on"
prompt: |
Water leak detected in the basement. Send an URGENT notify
(priority: 5) with the sensor name, the time, and any nearby
sensor state that might be relevant.
ZWave node went offline. Non-state event; only event_type
matches:
- id: zwave_node_offline
event_type: zwave_js_node_status
cooldown_seconds: 3600 # one alert per hour max
prompt: |
A ZWave node changed status. Summarise which one and what
happened in one sentence via `notify`. If the new status is
"alive" after a "dead", say "back online" — those are noise.
Reacting to webhooks¶
The third source (v1.7+) lets any external system fire the brain by POSTing JSON. The wire shape:
POST /v1/hook/{trigger_id}
Authorization: Bearer <bearer_token from triggers.yaml>
Content-Type: application/json
<free-form JSON body>
Response codes:
- 202 Accepted — auth + trigger-id check passed; brain fires in a background task. The HTTP response returns immediately so a slow LLM run can't time out the sender.
- 401 Unauthorized — missing, malformed, or wrong
Authorizationheader. Audited aswebhook.unauthorized— without ever logging the supplied bearer. - 404 Not Found —
trigger_iddoesn't match any armed webhook trigger. Same response for "no webhook triggers configured at all" so probing operators get a stable contract. - 413 Request Entity Too Large — body exceeded 64 KiB. The audit-log clamp (SR-008) is 16 KiB anyway; rejecting at the edge keeps a wayward sender from filling Praetor's memory.
- 429 Too Many Requests — only when
cooldown_secondsis set and the trigger is currently in cooldown. The body is dropped; suppressions coalesce into onetrigger.suppressedrow with a count, flushed on the next allowed fire.
Auth: per-trigger bearer token¶
Each webhook trigger has its own opaque bearer token. caesar init
generates one per trigger using secrets.token_urlsafe(36) — 288
bits of entropy, well above any realistic guessing threat. Tokens
are stored as Pydantic SecretStr so they don't leak into structlog
output or stack traces.
Verification uses hmac.compare_digest (constant-time) so an
attacker can't probe the token byte-by-byte via timing.
No HMAC body signing in v1.7. Bearer over HTTPS defends the realistic homelab threats (random scanners, hostile peers, casual forgery). HMAC's marginal benefit (defends a leaked token from replay if the sender signed a different body) isn't worth the per-sender format quirks. ADR-0032 documents the deferral; v1.8 may add HMAC if a specific sender forces it.
Body in the prompt¶
The body is appended to the brain's user message as:
JSON bodies get sorted-key, indented formatting so the prompt is readable. Non-JSON bodies pass through as UTF-8 text. The brain has the full body (up to 64 KiB) and decides what to do with it. There is no JSON-path filter, no templating syntax — the matcher stays coarse and the brain prompt does the work:
prompt: |
A GitHub event arrived. If it isn't a PR being opened against
the main branch, reply "nothing to report". Otherwise summarise
via notify.
Network exposure¶
By default CAESAR binds to loopback only (SR-001). Webhooks from outside your box don't reach it until you expose CAESAR through one of:
- Tailscale Funnel — easiest for personal use; auth is your
tailnet; the public URL has the bearer in the path. Set
CAESAR_SERVER__HOST=0.0.0.0and let Funnel front it. - Cloudflare Tunnel — TLS terminates at Cloudflare's edge; authentication is your bearer.
- Reverse proxy on your LAN — nginx / Caddy / Traefik. Add rate-limiting and IP allow-lists here; CAESAR doesn't do global rate-limiting itself.
- Direct port-forward — works but ill-advised; no edge layer to mitigate scanners.
Whichever you pick, the bearer token in triggers.yaml is the auth
boundary. Don't reuse one bearer across multiple senders; let
caesar init mint one per trigger.
Worked examples¶
n8n calendar invite. n8n posts the event JSON to CAESAR; the brain summarises and notifies:
- id: n8n_calendar_invite
bearer_token: "wht_<48-char-random>"
cooldown_seconds: 30
prompt: |
A calendar invite arrived via n8n. The body has the event
details. Summarise time, attendees, and (if you can tell)
whether it looks worth flagging. Then notify with a one-liner.
Stay quiet if it's routine.
n8n setup: HTTP Request node → POST https://caesar.example/v1/hook/n8n_calendar_invite
→ Add header Authorization: Bearer wht_... → send the event body.
GitHub PR opened. GitHub posts the full repo event payload; the prompt does the filter:
- id: github_repo_event
bearer_token: "wht_<48-char-random>"
prompt: |
A GitHub webhook arrived for one of my repos. If this is a PR
being opened against `main`, summarise the title and author
via notify. If it's anything else (push, issue, comment,
review, label), reply "nothing to report" and don't fire
notify.
GitHub's webhook UI: target URL + Authorization: Bearer … is
NOT supported via the standard webhook delivery (GitHub uses HMAC
or no auth). Use a small relay (Cloudflare Worker, n8n) that
verifies GitHub's HMAC, then forwards to CAESAR with the bearer
header. This is the deliberate v1.7 trade-off; HMAC support lands
in v1.8 if anyone wires the demand.
Custom shell script. A cron job posts a sensor reading every hour; CAESAR decides whether it's anomalous:
curl -sS -X POST https://caesar.example/v1/hook/sensor_reading \
-H "Authorization: Bearer wht_<random>" \
-H "Content-Type: application/json" \
-d '{"sensor": "garage_co", "ppm": 19, "ts": "2026-05-18T10:00:00Z"}'
- id: sensor_reading
bearer_token: "wht_<48-char-random>"
cooldown_seconds: 3600 # one alert per hour max
prompt: |
A custom sensor reading arrived. Check: is the value above the
sensor's safe threshold? (Garage CO above 30ppm is unsafe.)
If unsafe, notify URGENT. Otherwise stay quiet.
Reliability vs HA events¶
Webhook senders own retry. n8n, GitHub, and most homelab tools retry on 5xx and timeout. That makes webhooks the right source for delivery-critical events like water-leak detectors or smoke alarms — much better than HA events, which CAESAR drops during the WS reconnect window (ADR-0031 §7). If you've got both options for the same sensor, prefer the webhook path for the security-critical ones.
How proactive runs differ from /v1/chat¶
Same brain graph, same policy engine, same audit log. Only:
- System-prompt bias. A second preamble between the SR-004 safety preamble and your operator prompt tells the LLM "you're running proactively — bias toward summarise-and-notify, don't touch HA unless this prompt explicitly asks". To make a trigger act, write the prompt as an instruction.
- Decision-id prefix. Reactive runs use a bare UUID; proactive
runs use
proactive-<trigger_id>-<rand>. Grep accordingly.
Disabling proactivity entirely¶
Several flavours, applied to whichever sources you care about:
- Pause everything: comment out
CAESAR_PROACTIVE__TRIGGERS_PATHin.envand restart. None of the drivers construct; the/v1/hook/*route still answers 404 (stable contract). - Pause one trigger: set
enabled: falseon that trigger. Praetor still reads the file at startup but the trigger isn't armed. - Pause only HA triggers: leave HA unconfigured (omit
CAESAR_HA__URL/CAESAR_HA__TOKEN). The HA driver isn't constructed without an HA bridge; schedules and webhooks keep working. - Pause only webhook triggers: set
enabled: falseon all webhook triggers. The route stays mounted but every POST returns 404 +webhook.unknown_trigger.
There's no live "stop the driver" endpoint — edit the file and restart. Hot-reload may land in a future release if demand appears.
Migrating from v1.5¶
v1.6 renames the file and matching env var. The old names still work for one release with a deprecation warning:
| v1.5 (deprecated) | v1.6 (canonical) |
|---|---|
schedules.yaml |
triggers.yaml |
CAESAR_PROACTIVE__SCHEDULES_PATH |
CAESAR_PROACTIVE__TRIGGERS_PATH |
Top-level YAML key schedules: |
Top-level YAML key triggers: |
To migrate: rename the file, rename the env var, change the top-level
YAML key from schedules: to triggers:. The trigger entries
themselves don't change.
If you only rename some of them (or none), Praetor logs a deprecation warning at startup and keeps working. The v1.7 release drops the fallback — by then you should be on the new names.
Failure modes¶
| Symptom | Likely cause |
|---|---|
No trigger.scheduled / trigger.subscribed rows at startup |
CAESAR_PROACTIVE__TRIGGERS_PATH unset, or every trigger has enabled: false. |
| HA triggers loaded but never fire | HA bridge not configured (CAESAR_HA__URL / CAESAR_HA__TOKEN missing). Check for ha.subscription.opened audit rows; absence means the driver wasn't constructed. |
| Webhook POSTs all get 404 | Either trigger_id is wrong in the URL, or the webhook trigger has enabled: false, or CAESAR_PROACTIVE__TRIGGERS_PATH isn't set. Check for trigger.subscribed rows with source_kind: webhook at startup. |
| Webhook POSTs get 401 | Wrong or missing Authorization: Bearer … header. Check webhook.unauthorized audit rows (these never carry the supplied token). |
| Webhook POSTs get 413 | Body exceeded 64 KiB. Trim the payload at the sender or split into multiple events. |
| Webhook POSTs get 429 | The trigger is in cooldown_seconds after a recent fire. Either raise the cooldown to suppress less, lower to suppress more, or remove it. |
| Webhook fires but reaches no one externally | notify topic not configured, or operator isn't subscribed in the ntfy app. Test by inspecting notify.called audit rows. |
| Trigger fires but brain doesn't notify | notify worker not running (check CAESAR_LEGION__INPROCESS_WORKERS) or notify missing from allowed_tools. |
trigger.error with "ntfy returned HTTP 4xx" |
CAESAR_TOOLS__NOTIFY__TOPIC wrong, or self-hosted server requires a token. |
| Notifications arrive but with no body | The LLM emitted an empty message. Tighten the prompt — "Summarise X in 2-3 sentences; if there's nothing to say, reply 'nothing to report'." |
| Notification spam from motion sensor | Raise cooldown_seconds on the trigger. v1.6 has no global rate limit. |
| HA triggers stop firing after a network blip | Check the audit log for ha.subscription.reconnected rows; the WS likely came back. If not, check ha.subscription.opened was emitted at startup — without it, the driver never connected. |
| DST surprise | croniter respects the trigger's IANA zone, so 7am local stays 7am local across the spring/fall flip. If you set timezone: UTC and live somewhere with DST, set it to your local zone instead. |
What's next¶
- Add a tool the brain can call during proactive runs. See Add your own tool.
- Switch LLM providers per task — v1.1's task routing lets you point proactive runs at a cheaper local model. See Picking a model.
- Run the brain on one box, workers on another — the scheduler
and HA driver are single-instance per Praetor, but the
notifyworker isn't bound to them. See Run a worker on another box.