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:

  1. Signal scan — six independent detectors run in parallel (Redis + SQLite reads only, no LLM). Any detector may return a Signal with a strength in [0, 1].
  2. Aggregate — the detected signals collapse into a single Trigger. Priority picks the trigger type (e.g. an urgent signal always wins over a check_in), and a weighted sum gives the aggregate strength.
  3. 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.
  4. 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.
  5. Deep evaluation — enqueues a proactive-eval job. 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.
  6. Broadcast delivery — the message is sent to every enabled channel adapter in parallel. There is no "best channel" heuristic; the cross-channel philosophy applies.
  7. Bookkeeping — after at least one channel delivers successfully: start per-trigger cooldown, increment daily counter, record one proactive_events row per delivered channel, store the message in the SQLite messages table with source: "proactive". On total delivery failure nothing is recorded — the next tick can retry.
One LLM call per firing, not two

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.

SignalData sourceFires whenMaps 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.

TypePriorityMeaningDefault cooldown
urgent1Something time-sensitive (deadline, appointment).30 min
deliverable2A scheduled task / reminder produced output the user hasn't seen.2 h
insight3A meaningful pattern around known entities worth mentioning.8 h
check_in4Ambient 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:

text
// 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 phaseactive (<5 min since last user msg) blocks; post_task (5–30 min) is the optimal window; idle (30 min–2 h) and deep_idle (≥2 h) both allow.
  • MutePOST /api/proactive/mute sets profile.mutedUntil. Respected unconditionally until it expires.
  • Quiet hours — if quietHours.enabled and the current hour (in quietHours.timezone) is inside the window, non-urgent triggers are dropped. urgent triggers bypass when quietHours.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:signals with TTL equal to suggestedDelayMinutes, 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_events row 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).

KeyTypeDefaultDescription
enabledbooleantrueMaster switch. When false, the cron is removed and no signals run.
modelstring""Model id for the single eval+generate call. Empty string falls through to orchestrator then global model selection (see Models doc).
monitorCronPatternstring"*/5 * * * *"Cron for the signal scan. Five-field syntax.
signals.idleThresholdMinutesnumber120Minimum silence across any channel to produce an idle signal.
signals.idleMaxDaysnumber7Ceiling on silence age; beyond this the idle signal no longer fires (avoids re-engaging stale channels forever).
signals.timeSensitiveLeadMinutesnumber60Lead time used when a deadline is mentioned in memory.
signals.returnFromAbsenceHoursnumber24Absence length required to count as a "return".
engagement.baseThresholdnumber0.6Informational baseline; the effective gate is the adaptive threshold.
engagement.responseWindowMinutesnumber60How 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 / .maxnumber0.3 / 0.9Bounds for the adaptive threshold. Cold start uses the midpoint.
rateLimits.cooldownSeconds.urgentnumber1800Per-trigger cooldown — urgent, 30 min.
rateLimits.cooldownSeconds.deliverablenumber7200Deliverable, 2 h.
rateLimits.cooldownSeconds.insightnumber28800Insight, 8 h.
rateLimits.cooldownSeconds.check_innumber43200Check-in, 12 h.
rateLimits.maxPerDaynumber5Daily cap for non-urgent triggers. Resets at midnight in the configured timezone.
rateLimits.maxUrgentPerDaynumber10Separate daily cap for urgent triggers.
quietHours.enabledbooleantrueWhether the quiet-hours window suppresses non-urgent triggers.
quietHours.startnumber22Start hour (0–23), in timezone.
quietHours.endnumber8End hour (0–23), exclusive. Windows that cross midnight (e.g. 22→8) are handled.
quietHours.timezonestring"UTC"IANA timezone for quiet hour / daily-cap math.
quietHours.urgentOverridebooleantrueAllow urgent triggers to bypass the quiet-hours window.
triggerWeights.{urgent, deliverable, insight, check_in}number1.0 / 0.9 / 0.5 / 0.3Per-trigger weight used when summing signal strengths into the aggregate that is compared against the adaptive threshold.
json
// 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.

MethodPathPurpose
GET/api/proactive/statusReturns enabled, live cooldown expiries, current daily counter, and the engagement profile (totalSent / totalEngaged / mutedUntil / stylePreference).
GET/api/proactive/profileFull profile snapshot (activity pattern, average response time, style preference, last activity timestamps).
PATCH/api/proactive/profileUpdate stylePreference (minimal / balanced / proactive).
POST/api/proactive/muteMute proactive messages for minutes (body: {minutes: number}).
POST/api/proactive/unmuteClear the mute immediately.
GET/api/proactive/historyRecent engagement events with outcome, response time, sentiment. Takes ?limit=.
POST/api/proactive/triggerManual 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 messageMeaningFix
Proactive engagement disabledenabled: false.Toggle it on in the Engagement page.
Proactive scan: no signals detectedAll 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 thresholdSignals 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 goodWorkflow 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 cooldownPer-trigger cooldown still active from a previous send.Wait it out or lower rateLimits.cooldownSeconds.<type>.
Deep eval: daily cap reachedDaily counter exceeded maxPerDay / maxUrgentPerDay.Wait for midnight reset in quietHours.timezone or raise the cap.
Deep eval: no channel adapters availableNo 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 channelAdapters exist but every sendToChannel call threw.Check each channel's credentials and that the channel is still connected.
Proactive message deliveredEverything 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.

Proactive messages consume tokens

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.