Skip to content
Guide Reference Download app

@claxedo/wakes

@claxedo/wakes resumes an idle agent session from an out-of-band trigger — a durable wake fired by time, an external event, or an authorized approval. No resident process required, and no durable-execution engine: durable state is a database’s job and durable timers are the platform’s job, and this package is the thin, tested logic connecting them — claims, leases, lanes, and sinks.

Terminal window
npm install @claxedo/wakes
import { createWakes, createScheduler, createNodeWakeDriver } from "@claxedo/wakes"
import { SqliteWakeStore } from "@claxedo/wakes/sqlite" // node-only subpath
const driver = createNodeWakeDriver()
const wakes = createWakes({
store: new SqliteWakeStore({ path: "wakes.db" }),
driver, // push (optional)
spawnTurn: async (sessionId, result) => host.resumeSession(sessionId, result),
sinks: { // other firing behaviors
my_job: async (wake, result) => host.runJob(JSON.parse(wake.intentJson)),
},
authorize: async (actor, workspaceId) => host.canApprove(actor, workspaceId),
computeNextRun: (cron, after) => parseCron(cron, after), // only if you use cron
})
driver.bind(wakes)
// three trigger types — durations are compile-checked `ms` strings
await wakes.schedule({ workspaceId, sessionId, in: "3d", intent }) // or at: Date | epoch-ms
await wakes.watch({ workspaceId, sessionId, eventKey: "ci:pass:x", intent, expiresIn: "7d" })
const { token } = await wakes.requestApproval({ workspaceId, sessionId, prompt, expiresIn: "1d" })
// fire sources
createScheduler(wakes).start() // the polling backstop (guarantee)
// + the driver fires due-now wakes instantly // the push path (speed)
await wakes.deliverEvent("ci:pass:x", payload) // 'on_event' — host webhook ingress
await wakes.resolve(token, answer, actor) // 'on_approval' — inbound handler
// turn-side: make an irreversible external effect at-most-once across re-runs
await wakes.once(sessionId, "open-pr:branch-x", () => host.openPr(branch))

@claxedo/wakes/sqlite is a deliberately separate subpath — the root entry (@claxedo/wakes) stays edge-runtime-safe (no better-sqlite3) so it can be imported from a Cloudflare Worker or other edge runtime that only wires the engine plus a non-SQLite store.

A wake is one durable row:

fieldmeaning
triggerTypeWHEN it fires: at (time/cron), on_event (delivered key), on_approval (human answer via token)
kindWHAT firing does: selects a registered sink (default session_turn)
serialKeyWHICH lane: same-key wakes never fire concurrently; null = no lane
intentJsonthe payload handed to the sink
statepending → firing → fired (or expired / cancelled)
leaseUntil, idempotencyKeycrash-recovery and create-dedup bookkeeping

Every transition out of pending is a guarded compare-and-swap — the single serialization point. Whoever wins the CAS fires; everyone else backs off. A crash mid-fire leaves the row in firing with a lease; when the lease lapses, any runner reclaims and re-drives it (the result payload was persisted at claim time, so an approval’s answer survives the crash). Firing is at-least-once; once() receipts and idempotency keys make effects at-most-once where it matters.

  • Store (WakeStore, all-async) — owns the two hard operations: the CAS and claimDue (the atomic “grab due wakes respecting lanes”). Because claims are atomic in the database, any number of runners can race safely: duplicates waste a read, never double-fire. Ships: SqliteWakeStore (@claxedo/wakes/sqlite).
  • Sinks — plain in-process functions registered at createWakes time, keyed by the kind string stored on the row. No code in the database, no RPC — a wake created last week fires with this week’s reviewed implementation. An unregistered kind fails before any side effect, leaving the row lease-reclaimable.
  • Lanes (serialKey) — claimDue never claims a key that already has a firing row and takes at most one wake per key per batch (earliest first), so same-key ordering is a property of the data layer, not of any process.
  • Driver (WakeDriver) — nudge({serialKey, fireAt}), a lossy hint, never load-bearing. Ships createNodeWakeDriver: in-memory per-lane promise chains that drain each lane until empty.
  • Scheduler — the guarantee. createScheduler is recover-on-boot plus a non-overlapping runDue() interval; slow, dumb, cannot miss.

Timing semantics (read this before trusting the clock)

Section titled “Timing semantics (read this before trusting the clock)”
  • Never early, close-to-on-time normally, late-but-never-lost worst case. This is reliable scheduling, not hard-realtime.
  • Recurring wakes compute the next occurrence from the wake’s own scheduled time, never from wall-clock-at-fire, so slack never drifts the grid; the next-occurrence insert is idempotency-keyed so replays can’t double-book.
  • Downtime replays missed recurring occurrences one by one on recovery (no skip-to-latest mode). Expiring wakes that lapsed during downtime fire their expired: true notification instead of the normal result.
  • A stopped Node process fires nothing until it’s back — the scheduler lives in the process; rows wait safely on disk. “Fires while my laptop is closed” requires a hosted runner by definition.

getWakeToolDefinitions() and handleWakeToolCall() (from the package root) are JSON-schema tool metadata and a pure, host-agnostic dispatcher — not an MCP server. A host registers these onto whatever tool surface its sessions already use and supplies per-turn context (sessionId, workspaceId, actor); the agent never passes sessionId/workspaceId itself, so a tool call can only create or cancel wakes for the session it runs in. The four tools are schedule_followup, watch, request_approval, and cancel_wake. Approval tokens stay server-side — handed to the host via onApprovalRequested, never exposed to the agent.

Local / self-host NodeHosted Cloudflare Worker
Store@claxedo/wakes/sqliteConvexWakeStore (host-owned, in claxedo-server)
PushcreateNodeWakeDriverWakeLane Durable Object
BackstopcreateScheduler (1s tick)Cron Trigger (15 min)
Sinkssession_turn (agent tools)workgraph_settle
Down =fires on next boot (recover + catch-up)platform alarms/cron; no process of ours needs to stay alive

WorkGraph settlement keeps its own truth (lease/epoch-fenced outbox rows in Convex) and uses wakes only as the doorbell: per command burst, one dirty-flag wake per tenant (kind: workgraph_settle, serialKey = tenant, fireAt: now). Firing runs the tenant-scoped reconcile; unsettled results schedule a durable retry wake on the same lane. Burst coalescing there is state-aware — it skips creating a wake when a pending settle wake already holds the lane (createLaneWakeIfIdle in convex/wakes.ts) rather than relying on engine idempotency keys.

See the README and the code-grounded architecture doc on GitHub for the full source map, lifecycle transition table, claim SQL, and extension recipes.

WorkGraph

The first hosted consumer — wakes as the settlement doorbell.