Skip to content

Waking entities

Entities in DARIX are driven by wakes. A wake is a single handler invocation triggered by something outside the handler: a new message, a child finishing, a change in an observed stream, a cron firing, a scheduled send delivering. Between wakes the entity is idle — no process, no memory, no running handler.

Everything you do to make an entity respond to something — ctx.spawn(..., { wake }), ctx.observe(..., { wake }), ctx.send(), upsertCronSchedule() — is ultimately a way to produce a wake.

The mental model

external event  ─►  wake entry (persisted)  ─►  handler invocation  ─►  WakeEvent passed to handler
  1. External event. A message arrives, a child transitions, a watched collection changes, a cron fires.
  2. Wake entry is persisted to the entity's stream. This is the durability guarantee — wakes survive process restarts, network blips, and crashes. A wake that was written will eventually be delivered to a handler.
  3. Handler is invoked. The runtime picks up the wake, loads the entity's state, and calls your handler with a WakeEvent describing what triggered this invocation.
  4. Handler runs. You read ctx.events, inspect wake, configure the agent, emit new events. When the handler returns (or calls ctx.sleep()), the entity goes idle until the next wake.

This means handlers are re-entrant: the same handler function is called fresh on every wake. Use ctx.firstWake for one-time initialization, and ctx.db.actions / ctx.db.collections to carry state across wakes.

What produces a wake

There are five things that can wake an entity:

1. An incoming message

Any external /send (via the CLI, HTTP, or another entity's ctx.send()) appends a message_received event to the entity's stream, which wakes the handler:

ts
ctx.send("/assistant/peer", { text: "hello" })

The receiving handler sees wake.type === "message_received" and finds the payload on wake.payload.

2. A spawned child

Pass wake when spawning a child to control when the parent wakes:

ts
const child = await ctx.spawn("worker", "analysis-1", args, {
  initialMessage: "begin",
  wake: { on: "runFinished", includeResponse: true },
})

See the full catalog of Wake values in WakeEvent.

3. An observed entity

ctx.observe() subscribes to another entity's stream without spawning it. Pair it with a wake option to re-invoke this handler when the observed stream changes:

ts
import { entity } from "@durable-streams/darix-runtime"

await ctx.observe(entity(someEntityUrl), {
  wake: { on: "change", collections: ["status"], debounceMs: 250 },
})

The entity() helper wraps a raw URL string into the correct observe target type.

4. Shared state

observe(db(...)) connects to a shared-state stream and, with wake, re-wakes the connecting entity when its collections change:

ts
await ctx.observe(db("board-1", schema), {
  wake: { on: "change", collections: ["findings"] },
})

5. A schedule

Cron schedules and future-send schedules both wake their target entity when they fire. Schedules are created via ctx.darixTools (the built-in upsert_cron_schedule / upsert_future_send_schedule tools) and live on the entity's manifest — so they survive restarts and can be updated or cancelled idempotently.

Reading a WakeEvent

Your handler signature is:

ts
handler(ctx: HandlerContext, wake: WakeEvent) => void | Promise<void>

The minimum useful pattern is to branch on wake.type:

ts
async handler(ctx, wake) {
  if (wake.type === "message_received") {
    // external input - reply, dispatch, etc.
    ctx.useAgent({ ... })
    await ctx.agent.run()
    return
  }

  // everything else (child finished, change, cron, timeout) arrives as type "wake".
  // Inspect wake.payload for the specific sub-kind.
  ctx.sleep()
}

Two wake types reach handlers directly:

  • "message_received" — an external message was delivered to this entity's inbox.
  • "wake" — a synthesised wake for anything else (child finished, collection change, cron, scheduled send, timeout). The specifics are on wake.payload.

For the full payload shape (changes[], finished_child, other_children, timeout), see the wake-type catalog in the reference.

Coalescing and idempotency

Multiple external events that arrive while an entity is busy (or between acks) are coalesced into a single wake. The runtime guarantees that:

  • A wake covers a contiguous range of offsets in the source stream (wake.fromOffset..wake.toOffset).
  • wake.eventCount tells you how many new events this wake represents.
  • Handlers must be safe to re-run with the same input — at-least-once delivery. Use ctx.firstWake and idempotent writes to collections rather than side effects on each wake.

If you need to deduplicate explicitly, key your writes by something stable (the child's entity URL, the message's producer/epoch/seq headers, etc.) and let the collection's primary key do the dedup.

Debounce and timeouts on change wakes

{ on: 'change' } has two knobs worth understanding:

  • debounceMs — if set, rapid-fire changes are batched; the wake fires debounceMs after the last change.
  • timeoutMs — if set, the wake fires after this interval even if nothing changed. Useful for heartbeat-style handlers that need to periodically check state without requiring external events.

Both are optional. If neither is set, every change produces a wake.

Sleeping between wakes

When the handler finishes (or calls ctx.sleep()), the entity returns to idle. The runtime persists the ack offset so the next wake starts from the right place. You don't have to — and shouldn't — hold resources across wakes.

See also