Proactivity
@graphorin/proactive is the layer that lets a personal assistant act on a schedule instead of only answering. It ships two deliberately separate primitives - a heartbeat and cron-leg tasks - both emitting the same typed escalation ladder, both riding the durable trigger scheduler, and both built around a cheap-run cost posture.
Single-process by design: schedules persist in the SQLite trigger store, which is single-process. Run one proactive process per store.
Heartbeat vs cron-leg
The two primitives answer different questions and are deliberately not merged:
| Heartbeat | Cron-leg task | |
|---|---|---|
| Question | "Is anything worth telling the user right now?" | "Run this specific job on this schedule." |
| Agenda | A live checklist() the bot recomputes per beat | A fixed prompt per task |
| Empty agenda | Skips before any model call | Always fires |
| All-quiet reply | Sentinel-suppressed (HEARTBEAT_OK) | A completed fire delivers its text |
| Outcome rungs | notify only | The full ladder, capped by the task's grant |
| Session | Isolated per beat (configurable) | Always fresh per fire |
Heartbeat
import { createHeartbeat } from '@graphorin/proactive';
const heartbeat = createHeartbeat({
agent: heartbeatAgent, // a DEDICATED cheap agent (scaffold: 'minimal')
scheduler,
schedule: { every: 30 * 60 * 1000, jitterMs: 60_000 },
checklist: async () => (await listDueReminders()).join('\n') || null,
sentinel: 'HEARTBEAT_OK',
profile: {
provider: cheapModel, // fail-closed per-beat model pin
budgetUsd: 0.05, // per-beat run budget (C5)
isolatedSession: true, // fresh session per beat (default)
},
activeHours: { from: '08:00', to: '23:00', timezone: 'Europe/Kyiv' },
runGate: () => interactiveAgent.isBusy(),
onOutcome: async (o) =>
gateway.deliver({ identity: owner, text: o.text }),
});
await heartbeat.start();Semantics, in evaluation order per fire:
- Active hours - outside the window the beat skips (
inactive-hours). The window is a daily wall-clock range in an IANA timezone (default UTC - deliberately not the server's local zone);from > tocrosses midnight. - Busy deferral - when the gate reports busy, the beat defers and retries every
deferMs(default 30s), giving up with a WARN aftermaxDefers(default 10) consecutive deferrals. The default gate isagent.isBusy()on the heartbeat's own agent; pointrunGateat the interactive runner so a beat never talks over a live conversation - an internal mutex could only see runs the heartbeat itself started. - Checklist -
null/ empty / whitespace skips before any model call. An empty agenda must cost nothing. - The run - executes on the beat profile: fresh session id (or a real session when a
SessionManageris supplied), the pinned provider, and the per-beat run budget withonExceed: 'stop'(a budget-cut beat is counted as a failure, never delivered as a finding). - Sentinel suppression - every occurrence of the sentinel is stripped; when what remains is shorter than
minOutcomeLength(default 8), nothing is delivered (sentinel/below-min-lengthskips). A real finding becomes anotifyoutcome.
heartbeat.status() surfaces counters (beats, outcomes, failures, defers, per-reason skips) for health wiring, and heartbeat.beat() fires one beat manually.
Cron-leg tasks
import { createProactiveCronTask } from '@graphorin/proactive';
const nightly = createProactiveCronTask({
id: 'nightly-review',
agent: taskAgent, // DEDICATED agent; toolset curated by construction
scheduler,
schedule: { cron: '0 3 * * *', timezone: 'Europe/Kyiv', jitterMs: 120_000 },
prompt: 'Review yesterday and draft the morning brief.',
provider: cheapModel, // REQUIRED fail-closed pin
grant: 'review',
budget: { maxCostUsd: 0.1 },
onOutcome: async (o) =>
gateway.deliver(outcomeToDelivery(o, owner)),
});
await nightly.start();Every fire creates a fresh session and runs on a required, fail-closed model pin: the run resolves to exactly the task's provider, winning over prepareStep overrides and the whole preference ladder, and the agent-level fallback chain is never consulted - a 03:00 fire must not silently escalate to a more expensive model because the cheap one rate-limited.
No recursive scheduling. A proactive run must not register triggers or schedules. The primary enforcement is by construction - dedicate an agent whose toolset simply has no scheduling tools - and schedulingToolNames adds a deterministic creation-time check: any listed name reachable from the task's agent registry throws ProactiveConfigError unless allowRecursiveScheduling: true grants it explicitly. (E1's deny-by-name vocabulary will add a third layer later.)
The escalation ladder
Every fire ends in exactly one rung of the typed ProactiveOutcome union from @graphorin/core - notify < question < review < act (the frozen PROACTIVE_OUTCOME_LADDER):
notify- fire-and-forget delivery. The default and the floor.question- the task needs user input; the run parked on a read-only gated tool.review- the task proposes an action and parked on a writer gated tool (needsApproval); nothing has happened yet.act- side effects already happened inside the run, on the task's own authority.
A task declares its maximum rung once: grant (default 'notify'). The grant maps onto existing deterministic machinery rather than new enforcement code:
| Grant | Mechanism |
|---|---|
notify / question | The fire runs capability: 'read-only' - writer tools are never advertised and the executor blocks fabricated writer calls. Acting is impossible by construction. |
review | Full capability; writer tools carry needsApproval (bot config), so proposals park on the existing durable HITL instead of executing. |
act | Full capability, plus the fail-closed config gate below. |
A run that escalates above its grant (e.g. a notify task parks on a gated tool) is settled fail-closed: every pending approval is auto-denied, nothing is delivered, and the fire reports escalationBlocked.
The act grant is gated on the ingest gate
grant: 'act' requires evidence that the memory ingest gate is active: pass the memory facade and the runner checks memory.ingestGate !== null (createMemory({ ingestGate })). Without it, task creation throws ProactiveConfigError (act-requires-ingest-gate). The rule exists because an auto-acting task writes its own consequences back into memory - exactly the loop the B3 gate breaks for guardrail-blocked turns; see the security guide. Enable act only after the ladder has run on notify-only for a while.
Routing outcomes
outcomeToDelivery(outcome, identity) shapes any outcome into a channel gateway delivery payload: notify / act become plain text; question / review carry the HITL question block (prompt, keyboard options, and the opaque resolve ref) that a messenger renders as buttons.
Two ref families ride the same callback-data slot:
run:<runId>:<toolCallId>(serializeApprovalRef/parseApprovalRef) - an agent run parked on approvals. The messenger resolves it throughPOST /v1/runs/:runId/resumewith{ approvals: [{ toolCallId, granted }] }- the endpoint re-enters the real agent loop (see the server reference).wf:<workflowId>:<threadId>:<name>(serializeAwakeableReffrom@graphorin/workflow) - a task parked inside a durable workflow (workflowAwakeableOutcomebuilds these). Resolved through the existingPOST /v1/workflows/:id/resume; the workflow timer daemon keeps ticking parked threads - the proactive layer composes with it, never re-hosts it.
For the agent family, bridge parked fires into the server so REST can find them: pass suspendedRuns: server.runs to the task and register the dedicated agent under registryAgentId (default proactive-<taskId>) in server.agents. Avoid : inside registry agent ids - it is the scope-segment separator in agents:invoke:<id>.
Budgets
Proactive spend is bounded at three layers:
- Per-fire run budget (C5) -
profile.budgetUsd/budget.maxCostUsd(+maxTokens) pass through to the agent's run-level budget withonExceed: 'stop'; sub-agent usage counts. The cost leg needs USD-priced usage (pricing middleware);maxTokensworks everywhere. - Fail-closed model pin - the fire cannot silently escalate to a pricier model through fallback.
- Scheduler harness (C4) - the interval floor, declaration cap, deterministic jitter and auto-expiry bound how often anything fires at all; heartbeat and cron schedules pass
jitterMs/expiresAtstraight through.
Cost posture
Pair the runners with scaffold: 'minimal' on the dedicated agents: instructions-only prompt, defer-loaded tools, no plan recitation. A beat that finds nothing then costs one short model call - and an empty checklist costs zero.
Limitations
- Single-process - the SQLite trigger store supports one scheduler process; run one proactive host per store.
- In-process resume -
POST /v1/runs/:runId/resumeresumes suspensions retained by the current server process (including bridged proactive fires). After a restart, resume library-side from the agent's ownCheckpointStore(agent.run(savedState, { directive })). - One run per instance - dedicate agent instances to the heartbeat and to each task;
Agent.isBusy()and the busy gate exist precisely because instances are single-run.