Engagement
ScalyClaw can reach out first when something actionable happens. The proactive system turns ambient signals — pending task results, deadlines in memory, an idle conversation, a user returning from absence — into at most one message per scan, broadcast to every enabled channel. Everything runs in the background via the scalyclaw-internal queue, evaluated by a single LLM call per firing.
Lifecycle
Each tick of the cron pattern runs this pipeline:
- Signal scan — six independent detectors run in parallel (Redis + SQLite reads only, no LLM). Any detector may return a
Signalwith a strength in[0, 1]. - Aggregate — the detected signals collapse into a single
Trigger. Priority picks the trigger type (e.g. anurgentsignal always wins over acheck_in), and a weighted sum gives the aggregate strength. - Adaptive threshold — the trigger must exceed a self-tuning threshold. During cold start (fewer than 5 proactive messages ever sent) the threshold is the midpoint of
adaptiveRange; afterwards it shifts down when the user engages with proactive messages and up when they ignore them. - Timing gate — checks workflow phase (active/post-task/idle/deep-idle), mute state, quiet hours (with
urgentOverride), and low-activity hour throttling. Non-urgent signals are buffered and retried later. - Deep evaluation — enqueues a
proactive-evaljob. The job re-detects signals (rate-limit + daily-cap recheck), assembles context, and runs one LLM call that either decides to stay quiet or returns a ready-to-send message. - Broadcast delivery — the message is sent to every enabled channel adapter in parallel. There is no "best channel" heuristic; the cross-channel philosophy applies.
- Bookkeeping — after at least one channel delivers successfully: start per-trigger cooldown, increment daily counter, record one
proactive_eventsrow per delivered channel, store the message in the SQLitemessagestable withsource: "proactive". On total delivery failure nothing is recorded — the next tick can retry.
Earlier versions used a two-stage evaluate-then-generate pipeline that paid for two LLM calls per scan even when the second step declined to produce a message. The current engine merges both stages into one call that returns either {engage: false, reasoning} or {engage: true, triggerType, message}. This halves the token cost and the per-firing latency.
Signals
Six detectors produce signals. Any of them can be the sole reason for a firing.
| Signal | Data source | Fires when | Maps to trigger |
|---|---|---|---|
idle |
Redis scalyclaw:activity:* keys |
Any channel has been silent between idleThresholdMinutes and idleMaxDays. Strength scales linearly from 0.3 (just past threshold) to 1.0 (approaching the max). |
check_in |
pending_deliverable |
SQLite messages table |
Assistant messages tagged metadata.source ∈ {task, recurrent-task, reminder, recurrent-reminder} created after lastProactiveAt (or in the last 24h on cold start). Strength = 1.0 — pending deliverables always matter. |
deliverable |
time_sensitive |
FTS5 query over memory_fts |
Memories matching deadline / due / meeting / appointment / expires / schedule with importance ≥ 5 and no TTL, or TTL in the future. Strength = max(importance) / 10. |
urgent |
entity_trigger |
memory_entities + memory_entity_mentions |
Memories updated in the last 24h mention at least one of the top-5 most-referenced entities. Strength scales with mention count. | insight |
user_pattern |
Profile activity histogram | Current hour is above the user's 24-hour activity average AND the user hasn't been active for 30 min. Requires ≥10 activity samples accumulated. Strength = 0.3. | check_in |
return_from_absence |
Profile + activity keys | User active in the last 5 min after an absence exceeding returnFromAbsenceHours. Strength = 0.8. |
check_in |
Trigger types
Signals collapse into one of four trigger types. The highest-priority type wins when multiple signals fire at once. Each type has its own cooldown.
| Type | Priority | Meaning | Default cooldown |
|---|---|---|---|
urgent | 1 | Something time-sensitive (deadline, appointment). | 30 min |
deliverable | 2 | A scheduled task / reminder produced output the user hasn't seen. | 2 h |
insight | 3 | A meaningful pattern around known entities worth mentioning. | 8 h |
check_in | 4 | Ambient idle / return-from-absence / activity-pattern nudges. | 12 h |
Adaptive threshold
The aggregate trigger strength is gated by an adaptive threshold that self-tunes to how the user reacts:
// profile.totalSent < 5 → cold start, use midpoint threshold = (adaptiveRange.min + adaptiveRange.max) / 2 // otherwise, tune with engagement rate engagementRate = profile.totalEngaged / profile.totalSent threshold = adaptiveRange.max - engagementRate * (adaptiveRange.max - adaptiveRange.min)
High engagement → lower threshold → more proactive. Ignored messages → higher threshold → less proactive. Defaults: adaptiveRange.min = 0.3, adaptiveRange.max = 0.9, so a fresh install starts at 0.6.
Timing
Even when a trigger passes the threshold, the timing gate may still defer or suppress delivery.
- Workflow phase —
active(<5 min since last user msg) blocks;post_task(5–30 min) is the optimal window;idle(30 min–2 h) anddeep_idle(≥2 h) both allow. - Mute —
POST /api/proactive/mutesetsprofile.mutedUntil. Respected unconditionally until it expires. - Quiet hours — if
quietHours.enabledand the current hour (inquietHours.timezone) is inside the window, non-urgent triggers are dropped.urgenttriggers bypass whenquietHours.urgentOverride: true. - Low-activity hour — if the current hour sits below 30% of the user's average activity (and there is enough data to judge), non-urgent triggers are delayed 30 min.
- Signal buffering — when timing defers delivery, the detected signals are buffered under Redis key
scalyclaw:proactive:signalswith TTL equal tosuggestedDelayMinutes, so the next tick can pick them up cheaply.
Channel delivery
Proactive messages are broadcast to every enabled channel adapter in parallel. There is no "primary" or "best" channel — the philosophy is cross-channel: if you enabled Telegram, Discord, and Slack, a proactive message lands on all three. The gateway adapter (used by the dashboard chat UI) also receives it.
Delivery uses Promise.allSettled, so one adapter failing never blocks the others. After the cycle:
- If at least one channel accepted the message, bookkeeping runs exactly once — cooldown is set, daily counter is incremented, and one
proactive_eventsrow is inserted per delivered channel (so per-channel user response tracking works: a response on Discord resolves only that channel's event). - If every channel failed, nothing is recorded — the next tick is free to retry.
Config reference
Every field below is stored in Redis at scalyclaw:config under proactive and is editable from the dashboard Engagement page (hot-reload, no restart).
| Key | Type | Default | Description |
|---|---|---|---|
enabled | boolean | true | Master switch. When false, the cron is removed and no signals run. |
model | string | "" | Model id for the single eval+generate call. Empty string falls through to orchestrator then global model selection (see Models doc). |
monitorCronPattern | string | "*/5 * * * *" | Cron for the signal scan. Five-field syntax. |
signals.idleThresholdMinutes | number | 120 | Minimum silence across any channel to produce an idle signal. |
signals.idleMaxDays | number | 7 | Ceiling on silence age; beyond this the idle signal no longer fires (avoids re-engaging stale channels forever). |
signals.timeSensitiveLeadMinutes | number | 60 | Lead time used when a deadline is mentioned in memory. |
signals.returnFromAbsenceHours | number | 24 | Absence length required to count as a "return". |
engagement.baseThreshold | number | 0.6 | Informational baseline; the effective gate is the adaptive threshold. |
engagement.responseWindowMinutes | number | 60 | How long a delivered proactive message stays "pending" waiting for the user's response before it's auto-resolved as a false alarm. |
engagement.adaptiveRange.min / .max | number | 0.3 / 0.9 | Bounds for the adaptive threshold. Cold start uses the midpoint. |
rateLimits.cooldownSeconds.urgent | number | 1800 | Per-trigger cooldown — urgent, 30 min. |
rateLimits.cooldownSeconds.deliverable | number | 7200 | Deliverable, 2 h. |
rateLimits.cooldownSeconds.insight | number | 28800 | Insight, 8 h. |
rateLimits.cooldownSeconds.check_in | number | 43200 | Check-in, 12 h. |
rateLimits.maxPerDay | number | 5 | Daily cap for non-urgent triggers. Resets at midnight in the configured timezone. |
rateLimits.maxUrgentPerDay | number | 10 | Separate daily cap for urgent triggers. |
quietHours.enabled | boolean | true | Whether the quiet-hours window suppresses non-urgent triggers. |
quietHours.start | number | 22 | Start hour (0–23), in timezone. |
quietHours.end | number | 8 | End hour (0–23), exclusive. Windows that cross midnight (e.g. 22→8) are handled. |
quietHours.timezone | string | "UTC" | IANA timezone for quiet hour / daily-cap math. |
quietHours.urgentOverride | boolean | true | Allow urgent triggers to bypass the quiet-hours window. |
triggerWeights.{urgent, deliverable, insight, check_in} | number | 1.0 / 0.9 / 0.5 / 0.3 | Per-trigger weight used when summing signal strengths into the aggregate that is compared against the adaptive threshold. |
// Full proactive config under scalyclaw:config { "proactive": { "enabled": true, "model": "", "monitorCronPattern": "*/5 * * * *", "signals": { "idleThresholdMinutes": 120, "idleMaxDays": 7, "timeSensitiveLeadMinutes": 60, "returnFromAbsenceHours": 24 }, "engagement": { "baseThreshold": 0.6, "responseWindowMinutes": 60, "adaptiveRange": { "min": 0.3, "max": 0.9 } }, "rateLimits": { "cooldownSeconds": { "urgent": 1800, "deliverable": 7200, "insight": 28800, "check_in": 43200 }, "maxPerDay": 5, "maxUrgentPerDay": 10 }, "quietHours": { "enabled": true, "start": 22, "end": 8, "timezone": "UTC", "urgentOverride": true }, "triggerWeights": { "urgent": 1.0, "deliverable": 0.9, "insight": 0.5, "check_in": 0.3 } } }
Admin API
The dashboard Engagement page is backed by these endpoints. Raw curl is fine too.
| Method | Path | Purpose |
|---|---|---|
GET | /api/proactive/status | Returns enabled, live cooldown expiries, current daily counter, and the engagement profile (totalSent / totalEngaged / mutedUntil / stylePreference). |
GET | /api/proactive/profile | Full profile snapshot (activity pattern, average response time, style preference, last activity timestamps). |
PATCH | /api/proactive/profile | Update stylePreference (minimal / balanced / proactive). |
POST | /api/proactive/mute | Mute proactive messages for minutes (body: {minutes: number}). |
POST | /api/proactive/unmute | Clear the mute immediately. |
GET | /api/proactive/history | Recent engagement events with outcome, response time, sentiment. Takes ?limit=. |
POST | /api/proactive/trigger | Manual smoke test. Runs signal detection and — if any fire — a full deep evaluation + broadcast. Returns the delivered + failed channel lists. |
Why isn't it firing?
When proactive messages aren't arriving, work through the log lines the engine emits during a scan:
| Log message | Meaning | Fix |
|---|---|---|
Proactive engagement disabled | enabled: false. | Toggle it on in the Engagement page. |
Proactive scan: no signals detected | All six detectors returned null. Fresh install with no activity keys, no pending deliverables, no memories with temporal keywords, no recent entity activity. | Send a message on any channel to seed activity, or create a scheduled task that produces a deliverable. |
Proactive scan: below adaptive threshold | Signals fired but the aggregate strength didn't clear the threshold. | Lower adaptiveRange.min/max, raise weights, or wait for more engagement history (first 5 sends use the midpoint). |
Proactive scan: timing not good | Workflow phase is active, user is muted, or quiet hours are active. | Check mutedUntil, the quiet-hours window, and when the last user message arrived. |
Deep eval: on cooldown | Per-trigger cooldown still active from a previous send. | Wait it out or lower rateLimits.cooldownSeconds.<type>. |
Deep eval: daily cap reached | Daily counter exceeded maxPerDay / maxUrgentPerDay. | Wait for midnight reset in quietHours.timezone or raise the cap. |
Deep eval: no channel adapters available | No channels registered (not in config, or all failed to connect). | Enable at least one channel in config.channels and confirm it connected at startup. |
Proactive delivery failed on every channel | Adapters exist but every sendToChannel call threw. | Check each channel's credentials and that the channel is still connected. |
Proactive message delivered | Everything worked — message is on the wire. | — |
The quickest way to force the path is POST /api/proactive/trigger (or the "Trigger now" button on the dashboard). It shares the exact same pipeline as the cron scan, so whatever blocks the button blocks the cron too.
Each firing runs one LLM call against the model resolved from proactive.model. A 5-minute cron plus aggressive signals can easily produce hundreds of calls per day — watch the Usage page and tune monitorCronPattern, cooldowns, and daily caps accordingly.