Skip to content

Proactive with a budget

A field review can find three check-ins that each look reasonable in isolation and still reveal a bad product day. One thread has an unfinished task. Another has a useful update. A third contains an unkept promise. Sending all three before lunch makes the assistant feel needy.

The defect sits above thread relevance. The system never accounted for the user's total interruption load. Budget proactive behavior globally, then apply quiet hours, spacing, per-thread cooldowns, triage, and unanswered-message checks. A valid run may conclude that nothing should be sent.

Proactivity is a permission to interrupt, not a requirement to produce text.

Concept map

text
timer tick
  -> global daily budget
  -> allowed local hours
  -> minimum spacing since last attempt
  -> eligible recent threads
  -> per-thread cooldown
  -> no pending user work
  -> no unanswered prior check-in
  -> cheap triage picks zero or one candidate
  -> isolated turn writes message or NOTHING_TO_ADD
  -> store attempt outcome
  -> push only if triage marked important

Every gate removes work before an expensive agent turn. The attempt record makes the next decision depend on what the system already tried.

Start with an interruption budget

A daily cap provides the simplest reliable control. Two unsolicited messages per local day is a reasonable initial default for a personal assistant. Zero should disable the feature without code changes.

Count attempts, not only delivered messages, when enforcing spacing and triage rate. Otherwise a stream of silent or failed attempts can keep spending compute. Count delivered messages for the user-facing daily budget, and store enough state to distinguish the two.

The budget belongs to the assistant, not each thread. Five active threads must still share the same daily allowance.

Quiet hours are local

Proactive checks should run only inside configured local hours such as 09:00 through 21:00. The assistant timezone must be explicit. Server UTC and browser locale are poor substitutes because the worker may run elsewhere and the browser may be closed.

Quiet hours prevent the check from starting. They should not queue a burst for the opening minute. The next normal poll can reconsider candidates under the usual budget.

A minimum gap, such as three hours, handles the case where two useful items appear during allowed hours. The second may still matter, but not immediately after the first.

Candidate selection should be boring

Start with recent normal topic threads, perhaps those updated in the last seven days. Exclude:

  • private threads
  • threads with pending outbox messages
  • threads where the user has not answered the last proactive check-in
  • threads contacted within the per-thread cooldown
  • stale threads with no open task, promise, question, or new information

A 48-hour per-thread cooldown prevents one active project from consuming every slot. The user can still reopen the thread manually.

The database query should produce a small candidate set. A cheap triage model then chooses at most one thread and gives a bounded reason. Running the main model over every recent thread would turn a restraint feature into a large recurring bill.

Silence needs a protocol value

The isolated turn should have an exact sentinel such as NOTHING_TO_ADD. Normalize surrounding whitespace, record a silent outcome, remove orphaned operation rows if appropriate, and insert no assistant message.

ts
type NudgeResult =
  | {status: "none"}
  | {status: "silent"; threadId: string}
  | {status: "sent"; threadId: string; important: boolean}
  | {status: "error"; threadId?: string; code: string};

async function runCandidate(candidate: Candidate): Promise<NudgeResult> {
  const reply = (await runIsolatedTurn(candidate)).trim();
  if (reply === "NOTHING_TO_ADD") {
    return {status: "silent", threadId: candidate.threadId};
  }

  const messageId = await insertProactiveReply(candidate.threadId, reply);
  if (candidate.important) await sendPush(messageId);
  return {
    status: "sent",
    threadId: candidate.threadId,
    important: candidate.important,
  };
}

The sentinel is not elegant prose. It is a machine contract that protects the user from filler. Test it.

Triage and generation have different jobs

Triage asks whether interruption is justified and which thread wins. Generation asks what useful message to send. Combining them makes it harder to enforce one candidate and harder to measure why the system acted.

The triage reason should come from an allowlisted set, such as a due todo, an unkept promise without a formal intent, an unanswered question that blocks progress, or new relevant information. Open-ended reasons make reporting noisy and can rationalize almost any interruption.

Rate-limit triage itself, perhaps to once per hour. Most ten-minute poll ticks should end after deterministic checks.

Push is a separate decision. A message can appear as unread in the product without waking the device. Send push only when triage marked the candidate important. Intent reminders follow their own delivery contract because the user explicitly requested them.

Terms

  • Proactive message is an assistant reply without a new user message or due explicit intent.
  • Budget is the maximum unsolicited delivery count in a local day.
  • Quiet hours are times when proactive evaluation cannot produce a message.
  • Spacing is the minimum time between assistant-wide attempts or deliveries.
  • Cooldown is a per-thread exclusion period.
  • Triage is the cheap selection step before generation.
  • Silent result is a completed run that intentionally creates no message.
  • Unanswered check-in is a proactive message after which the user has not replied.

Naming an unanswered check-in matters. Without that state, the assistant can nag because each poll sees the same unresolved task.

Decisions and rejected alternatives

Persist every attempt with a bounded status such as none, silent, sent, or error. Chat history cannot reveal silent runs or failed attempts, yet both consume budget and matter during debugging.

Use a cheap model for triage and the normal chat path for the selected thread. Triage is a small classification problem. The final message benefits from full context and normal tools.

Rely on unread state for ordinary check-ins and reserve push for important ones. Treating every proactive message as a notification would make the budget feel larger than it is.

The alternatives fail for different reasons:

  • A cron per conversation ignores the global interruption budget.
  • One model call over the full account costs more as usage grows.
  • A mandatory daily briefing teaches the model to manufacture updates.

Failure modes

  • Budget is enforced per thread instead of per user.
  • The worker uses UTC for quiet hours.
  • Silent attempts do not count toward triage rate limits.
  • A prior unanswered check-in does not block another.
  • Pending user messages race with a proactive turn.
  • The same thread is selected every day.
  • Triage returns an unbounded free-text reason.
  • NOTHING_TO_ADD appears as a chat bubble.
  • Every nudge triggers push.
  • Private or stale threads enter the candidate set.
  • Errors retry every poll tick without backoff.

Field checklist

Once the assistant knows when to stay quiet, the ordinary chat loop must show the same attention to what the user actually sent and whether a reply was seen. Continue with Chat that feels attentive. The privacy gates that remove private threads from this pipeline are in Private means private.

Built from field notes on durable software systems.