Triggers¶
A trigger is the unit of "wake up and do something" in CraftBot: a small durable record saying react to this, at this time, for this session. Every reaction the agent ever has (your messages, schedules, task continuations, memory processing, proactive heartbeats) starts as a trigger. Understanding triggers tells you what your agent will still do after a crash, a reboot, or a week of your laptop being closed.
Overview¶
The trigger system has two layers:
- A priority queue in memory decides order: the trigger with the earliest
fire_attimestamp runs first. Ties break bypriority(lower number wins). A trigger withfire_atin the future simply sleeps in the queue until its time comes. The queue holds at most one trigger per session. A newer trigger for the same session supersedes the queued one. - A SQLite ledger on disk provides durability: every trigger is written to the store before it is enqueued, claimed when the agent loop picks it up, and settled when the turn finishes. A crash at any point between those steps means re-delivery on the next boot, not loss.
One trigger in, one turn of the agent loop out.
Implementation files
The trigger dataclass is agent_core/core/trigger.py. The durable front door is TriggerService in app/triggers/service.py. The ledger is app/triggers/store.py (a triggers table inside sessions.db under the app data directory). The typed sources are app/triggers/sources.py.
What creates triggers¶
Every producer declares a typed source, so you can always tell why the agent woke up:
| Source | Fired when |
|---|---|
user_message |
You send a message, in the browser or from a connected platform (priority 3, fires immediately) |
scheduled / scheduled_once / scheduled_immediate |
The scheduler fires a recurring schedule, a one-time schedule, or a run-right-now task |
task_continuation |
A task turn finished and queued its next step (priority 5 for simple tasks, 7 for complex) |
resume |
A task restored at boot is re-woken to continue |
restart_notice |
First turn after a restart; delivers the consolidated "I was restarted" chat message |
limit_reached |
A task hit its action/token budget and is parked awaiting your Continue/Abort choice |
memory |
The memory-processing job is due (see Memory) |
proactive_heartbeat / proactive_planner |
Proactive mode sweeps due recurring tasks or runs a day/week/month planner |
onboarding |
The post-install onboarding interview |
skill_workflow |
A skill-driven workflow step |
living_ui_dev / living_ui_crash_fix / living_ui_import |
Living UI build, crash-repair, and import work |
Lower priority numbers win a tie, so your messages (3) preempt task continuations (5–7), which preempt scheduled background work (default 50).
Anatomy¶
Each trigger carries:
| Field | Purpose |
|---|---|
fire_at |
Unix timestamp when it becomes eligible to run |
priority |
Tie-breaker within the same fire_at; lower = sooner |
next_action_description |
Human-readable statement of what to do; this is what the LLM reads |
payload |
Context: the user message, platform, flags |
session_id |
Which session/task it belongs to |
waiting_for_reply |
True when the trigger exists only to keep a paused task alive |
source |
Typed origin from the table above |
id |
Row id in the durable store |
Lifecycle and durability¶
The path from "something happened" to "the agent reacted" has an explicit state machine:
| Stage | Store state | What it means |
|---|---|---|
emit() |
PENDING |
Written to SQLite first, then enqueued. From this point a crash loses nothing. |
next() |
CLAIMED |
The consumer picked it up and is running the turn. |
ack() |
DONE |
The turn completed. |
nack() |
PENDING again, or DEAD |
The turn raised: retried with exponential backoff (30s, 60s, 120s… capped at 1 hour), dead-lettered after 5 attempts. |
Three additional behaviors:
- Parking protects your messages. An incoming chat message is durably parked in the store before the session-routing LLM call runs. If CraftBot crashes mid-routing, the parked row is re-delivered as a fresh session at next boot. The message you typed is never lost. Once routed, the parked copy is settled.
- Dedup keys prevent double-fires. Work whose identity predates the trigger (a schedule occurrence, a boot-time task resume) carries a dedup key. Inserting the same work twice is a database-level no-op, so a crash retry can't run your 9am schedule twice.
- Dead letters are announced. A trigger that exhausts its retries doesn't vanish: the agent posts a chat message ("A background task trigger failed repeatedly and was parked…") so you know work stopped and can ask it to retry.
What happens at boot¶
Every start-up runs rehydrate() before anything else:
- Orphaned
CLAIMEDrows (in flight when the process died) go back toPENDING. - All
PENDINGrows are loaded into the queue. This is how scheduled work, waiting tasks, and unrouted messages survive restarts. - Rows more than 24 hours past due are dropped as stale (settled, not fired). A week-old "check the news at 8am" shouldn't fire seven times on Monday.
- Rows more than 2 minutes overdue get a catch-up note appended to their description.
The catch-up note is what you observe as sensible behavior after downtime. The agent is told the trigger was due some time ago while CraftBot was offline and to use judgment: carry it out if it's only slightly late and still relevant, confirm with you first if it's significantly late or the action is irreversible (sending an email, posting a message), or skip it if it no longer matters.
Rehydration also garbage-collects settled rows older than 7 days, and runs before restored tasks re-emit their resume triggers so the dedup index catches duplicates.
Observable behavior¶
| You see | Why |
|---|---|
| The agent replies instantly while a task runs in the background | Your user_message trigger (priority 3) interleaves ahead of the task's continuations |
| A scheduled task fires normally seconds after you reboot | The pending trigger rehydrated from the store |
| "This was due 3 hours ago, still want me to send it?" | The catch-up note on an overdue rehydrated trigger |
| An "I was restarted" summary message after boot | The restart_notice trigger |
| "A background task trigger failed repeatedly and was parked…" | Dead-letter after 5 failed attempts |
| A task silently waits days for your answer | Its waiting_for_reply trigger re-arms itself in 3-hour hops, no LLM involved |
To watch trigger activity directly, grep the logs:
[TriggerService] Rehydrated 3 pending trigger(s) from previous run
[CONSUMER] Trigger consumer started
[TRIGGER] Creating new trigger for session: 4f2c1a
Limits¶
The durability policy is fixed, not user-configurable. The values are deliberate:
| Constant | Value |
|---|---|
| Retry attempts before dead-letter | 5 |
| Retry backoff | 30s doubling, capped at 1 hour |
| Catch-up note threshold | 2 minutes overdue |
| Stale drop threshold | 24 hours overdue |
| Settled-row garbage collection | 7 days |
Note that nack() retries cover consumer-level failures only. Errors inside a turn are handled by the agent loop itself and rarely consume the retry budget.
Next¶
- Agent loop: what a claimed trigger actually runs
- Task sessions: how a
user_messagetrigger finds the right task - Scheduling: the main producer of future-dated triggers
- Proactive mode: heartbeats and planners as trigger sources