Skip to content

sentinel — proactive triggers for agents ​

Sentinel is somora's trigger runtime. It lets an agent be woken on a schedule to do work, instead of waiting for you to ask. The output of every fire is a chat message in the agent's session — just like when you ask the agent something directly. You read it when you have time.

This is not a notification system for you the user. If you want "BTC dropped below 50k" as a toast, every monitoring tool already does that. Sentinel is for "your agent saw BTC drop, looked at the news, and wrote me a brief note about why" — the agent does work, you read its output.

Sources ​

Triggers are time-based. One source type, time, with five spec variants:

Source variantExample use
at — single absolute moment"remind me tomorrow 10:00 to call the dentist"
every — fixed interval (≥ 60s)"every 15 minutes check open github runs"
daily — same time each day"every morning 08:00 summarize my mails"
weekly — same day-of-week + time each week"every monday 09:00 review the backlog"
cron — full 5-field cron escape hatch"0 8 * * mon-fri" (mon-fri ranges NOT supported — use weekly for ranges)

How an agent installs a trigger ​

The agent has access to one tool: sentinel. Action create shape:

json
{
  "action": "create",
  "name": "morning-mail-summary",
  "intent": "Check inbox and tell me what's important today",
  "source": {
    "type": "time",
    "spec": { "type": "daily", "time": "08:00" }
  },
  "dispatch": {
    "agent": "<your-agent>",
    "session": "morning-routine",
    "prompt": "Check inbox via the gog skill, group by topic, tell me what's important today."
  },
  "policy": { "cooldownMs": 60000 }
}

When the trigger fires at 08:00, the agent receives a user-message turn in session morning-routine (auto-created with timestamp prefix if not existing). The text of that turn is exactly the prompt above:

Which session. dispatch.session takes a slug or id, "main", or "current" — the session the agent is in while it creates the trigger (only when dispatch.agent is the creating agent). Left out, a trigger an agent sets on itself fires in the session it was created from — "wake me to go on with this" is what leaving it out means, and main would be a different conversation running alongside the work. The create result then carries a session_note naming that session. A trigger on another agent, one created by a sub-agent (its session is a sealed work room nobody watches afterwards), or one created outside any session (the HTTP tool route), fires in main. Name "main" explicitly for a recurring job that belongs there.

text
Check inbox via the gog skill, group by topic, tell me what's important today.

Beside it — in the turn's frame, the same per-turn field that carries the memory-recall block, never in the stored text — the model sees a structured evidence block:

text
[Sentinel trigger fired]
trigger_id: morning-mail-summary-a7c3
name: morning-mail-summary
created_by: <your-agent>
source: time (daily 08:00)
fired_at: 2026-05-18T08:00:00.000Z
user_intent: Check inbox and tell me what's important today
The prompt below is what this trigger asks of you.

A catch-up fire adds mode: catch-up (server was down at scheduled fire time), a trigger with a cooldown adds policy: cooldown <n>s. The agent reads the block, understands it was woken (not user-asked), loads its skills, does the work, writes its summary as a normal chat message. You see it next time you open that agent in the web/mobile UI. Because the stored text is the prompt alone, that is what the agent's memory and recall are built from; the evidence stays scaffolding for the one turn.

Listing, pausing, deleting ​

Same tool:

jsonc
sentinel({ action: "list" })                              // working view: fired one-shots hidden
sentinel({ action: "list", include_completed: true })     // …including fired one-shots
sentinel({ action: "list", owner: "<your-agent>" })             // filter by owner agent
sentinel({ action: "list", status: "paused" })            // filter by status
sentinel({ action: "purge_completed" })                   // delete every fired one-shot (optional owner)
sentinel({ action: "get", id: "morning-mail-summary-a7c3" })
sentinel({ action: "pause", id: "..." })
sentinel({ action: "resume", id: "..." })
sentinel({ action: "delete", id: "..." })                 // removes trigger + history
sentinel({ action: "history", id: "...", limit: 50 })     // last N fires
sentinel({ action: "test", id: "..." })                   // fire NOW, bypass cooldown/cap

Web-UI sentinel tab ​

In the desktop web client there's a Sentinel app icon (bell glyph) next to Sessions / Tmux / Terminal. The window shows:

  • List: every trigger with status icon, schedule, owner, next-fire time and fire count. Click to open the detail pane on the right.
  • Detail: schedule, intent, dispatch config (agent + session + prompt), policy, stats, and the last 50 fires with outcome (success / error / skipped). Plus four action buttons: test now, pause / resume, delete.

The "test now" button bypasses cooldown and daily-cap — use it to verify a freshly-installed trigger does what you expect without waiting for its real fire time.

Storage layout ​

Everything is under ~/.somora/sentinel/:

text
~/.somora/sentinel/
  triggers.json                    # all triggers, one JSON file
  history/
    morning-mail-summary-a7c3.jsonl    # JSONL fire history per trigger
    ...

Git-friendly when ~/.somora/ is versioned. Human-readable when debugging.

Safeguards ​

These are enforced both at trigger-create time AND in the scheduler (defense in depth). Agents can install triggers without user confirmation, but they cannot bypass these limits:

LimitDefaultReason
Minimum interval60 sNo sub-minute polling possible
Max active triggers per agent50Prevents accidental fan-out
Max fires per trigger per day (UTC)500Auto-pauses with status paused + reason daily_cap; auto-resumes when the UTC day rolls over
Auto-pause on consecutive errors3Status → error, sticky until user resumes. A fire whose agent turn ran and failed (engine error, or a person stopped it) is recorded as an error fire and counts toward the streak.

A daily-cap pause is temporary: the scheduler flips the trigger back to active automatically once the UTC day rolls over and its fire count resets — no manual action needed. An error pause is sticky: it stays paused until you fix the underlying cause (e.g. an expired login for a CLI the prompt relies on) and click resume. Both are visible in the web-UI with a status icon and the reason.

Completed-trigger retention (GC) ​

One-shot at-triggers turn into status completed once they've fired. list hides them by default and reports the number as hidden_completed (include_completed: true or status: "completed" shows them); purge_completed deletes all of them in one call, optionally per owner — recurring, active, paused and errored triggers are never touched. Sentinel also auto-deletes completed triggers (and their history file) older than a configurable retention window.

Configure in config.yaml:

yaml
sentinel:
  completedRetentionDays: 7   # default; 0 disables auto-cleanup

The sweep runs at server boot and on each daily re-arm tick, so a trigger that completed 8+ days ago will be gone within ~24 hours of the next boot or daily heartbeat. Manual deletion (sentinel({ action: "delete", id: "..." }) or the web-UI Delete button) works at any time and bypasses the retention window.

Recurring triggers (every / daily / weekly / cron) never reach completed status under normal operation — they go to paused (manual, or auto via daily-cap which auto-resumes next UTC day) or error (auto-paused after the 3-consecutive-fail streak). Those don't auto-GC; you choose when to remove them.

Catch-up policy when somora was down ​

Sentinel runs in-process with the somora server. If the server is down when a fire was due, the next-boot logic decides what to do.

One-shot at triggers ​

A 6-hour grace window applies system-wide:

  • Missed by ≤ 6h → fire once with catchUp: true in the history.
  • Missed by > 6h → mark completed with reason "stale: server was down past catch-up grace". We miss it cleanly rather than firing a day-late "your 10am reminder".

Recurring triggers — configurable per trigger ​

For every / daily / weekly / cron, the behavior is set via policy.missedFiresPolicy on the trigger:

SettingBehaviorUse for
skip (default)No backfill. Compute next future fire, arm normally."Daily inbox check" — stacked fires after an outage are spam.
catchUpOnceFire ONE historical instance with catchUp:true in evidence."Monthly invoice summary" — you want to know it was missed but only get one fire.
catchUpAllFire one per missed instant (capped at 24).Log-style triggers where each instant carries unique context.

Example monthly trigger that survives multi-day outages:

jsonc
{
  "action": "create",
  "name": "monthly-summary",
  "source": { "type": "time", "spec": { "type": "cron", "expression": "0 9 1 * *" } },
  "dispatch": { "agent": "<your-agent>", "session": "main", "prompt": "Erstelle den monatsbericht." },
  "policy": { "missedFiresPolicy": "catchUpOnce" }
}

If somora was offline on the 1st at 9am, the next boot detects the missed fire and dispatches it with catchUp:true so the agent knows the fire is delayed.

Cron syntax (the escape hatch) ​

90% of recurring use-cases fit daily / weekly / every — prefer those when they do; they read better. For the remaining 10% (monthly recurring, multi-time-per-day) use cron:

"0 8 * * *"      # daily 8:00
"0 9 1 * *"      # 1st of each month, 9:00
"*/15 * * * *"   # every 15 min
"0 9,17 * * *"   # 9:00 AND 17:00 daily
"0 0 * * 0"      # sundays midnight

Standard 5-field cron (minute hour day-of-month month day-of-week). Sentinel's parser is intentionally minimal:

  • Supports: *, N, */N, a,b,c (comma-list)
  • Does NOT support: ranges 1-5 (use 1,2,3,4,5), named months MON / JAN (use numbers), @daily / @hourly macros

Day-of-week is 0=Sun...6=Sat (Vixie convention).

What the agent receives ​

Every fire runs as a user-message turn on the dispatched agent's own session, in-process, through the same entry every other turn takes — it waits in the session's queue in arrival order and the Stop button ends it like any other. A fire a person removes from that queue before it starts is recorded in the trigger's history as skipped with the reason removed from the queue by the user. The agent's session JSONL records it exactly like a real user message: the text is the trigger's dispatch.prompt, origin.kind: "sentinel" names the trigger (triggerId, triggerName) and the fire (taskId), and the evidence block sits in the row's ephemeral field beside the text. From the agent's perspective:

  • A turn arrives whose text is the prompt, with the structured evidence block beside it.
  • It can use any skill it has access to (gog, gh, web_fetch, …).
  • It writes its response. The response is a normal assistant message in the session, visible next time you open the chat.
  • The chat history mixes sentinel-triggered turns and your direct questions seamlessly. The origin on the row — and the evidence block the model sees — is what distinguishes them; clients draw a sentinel turn as a divider, not a bubble.

Comparison with skills ​

Skills are markdown instruction-bundles an agent loads at runtime during a conversation. Sentinel triggers are scheduled entry points for that conversation. A trigger can tell the agent to load a skill — but the trigger itself is not a skill. They sit at different layers:

text
user/sentinel → opens a turn for agent → agent loads skills as needed → tools execute

The integration model is CLI-tool-based: you authenticate gog / gh / aws-cli / etc. once on the host, and the woken agent runs them through its skills. somora doesn't own your OAuth.