Skip to main content
TaskContext<S> is the single interface your handlers interact with. Every handler — onEvent, onAlarm, onError, and onClientGetState — receives a TaskContext<S> as its first argument, where S is the task’s state type.

Interface

interface TaskContext<S> {
  // State
  recall(): Effect<S | null, TaskError>
  save(state: S): Effect<void, TaskError>
  update(fn: (s: S) => S): Effect<void, TaskError>

  // Scheduling
  scheduleIn(delay: Duration.Input): Effect<void, TaskError>
  scheduleAt(time: Date | number): Effect<void, TaskError>
  cancelSchedule(): Effect<void, TaskError>
  nextAlarm(): Effect<number | null, TaskError>

  // Lifecycle
  purge(): Effect<never, PurgeSignal>

  // Identity
  readonly id: string
  readonly name: string
}

State methods

recall
() => Effect<S | null, TaskError>
Reads the current persisted state from Durable Object storage. Returns null if no state has been saved yet (i.e., save() has never been called for this instance).recall() always returns the raw stored state regardless of any onClientGetState hook — the hook only runs for external getState() calls.
onAlarm: (ctx) =>
  Effect.gen(function* () {
    const state = yield* ctx.recall()
    const count = state?.count ?? 0
    yield* ctx.save({ count: count + 1 })
  })
save
(state: S) => Effect<void, TaskError>
Writes a new state value to Durable Object storage, replacing any previous value. The state is encoded using the task’s state schema before being stored.
onEvent: (ctx, _event) =>
  Effect.gen(function* () {
    yield* ctx.save({ count: 0 })
  })
update
(fn: (s: S) => S) => Effect<void, TaskError>
Atomic read-modify-write. Reads the current state, applies fn to it, then saves the result. If no state exists yet, fn receives the current state as null — handle this case in your transform function.
fn receives S, not S | null. Use recall() followed by save() if you need to handle the null case explicitly.
onEvent: (ctx, event) =>
  Effect.gen(function* () {
    yield* ctx.update((s) => ({ ...s, count: s.count + event.amount }))
  })

Scheduling methods

scheduleIn
(delay: Duration.Input) => Effect<void, TaskError>
Schedules an alarm relative to now. When the alarm fires, onAlarm is called.Duration.Input accepts Effect Duration values and plain strings in natural language format:
InputMeaning
"5 seconds"5 seconds from now
"1 minute"1 minute from now
"2 hours"2 hours from now
Duration.seconds(30)30 seconds from now
{ millis: 500 }500 milliseconds from now
Calling scheduleIn() while an alarm is already pending replaces the existing alarm.
onEvent: (ctx, _event) =>
  Effect.gen(function* () {
    yield* ctx.save({ count: 0 })
    yield* ctx.scheduleIn("2 seconds")
  })
scheduleAt
(time: Date | number) => Effect<void, TaskError>
Schedules an alarm at an absolute point in time. Accepts a Date object or a Unix timestamp in milliseconds.
onEvent: (ctx, event) =>
  Effect.gen(function* () {
    const fireAt = new Date(event.scheduledTime)
    yield* ctx.scheduleAt(fireAt)
  })
cancelSchedule
() => Effect<void, TaskError>
Cancels the pending alarm, if any. If no alarm is scheduled this is a no-op.
onEvent: (ctx, event) =>
  Effect.gen(function* () {
    if (event._tag === "Cancel") {
      yield* ctx.cancelSchedule()
    }
  })
nextAlarm
() => Effect<number | null, TaskError>
Returns the scheduled alarm time as a Unix timestamp in milliseconds, or null if no alarm is pending.
onEvent: (ctx, _event) =>
  Effect.gen(function* () {
    const next = yield* ctx.nextAlarm()
    if (next !== null) {
      console.log("Alarm fires at", new Date(next).toISOString())
    }
  })

Lifecycle methods

purge
() => Effect<never, PurgeSignal>
Deletes all persisted state and cancels any scheduled alarm, then terminates the current handler by failing with a PurgeSignal. The task instance is effectively reset — the next send() call will start fresh.The return type Effect<never, PurgeSignal> means this effect never produces a value: it always terminates the handler. You do not need to return after yield* ctx.purge().
PurgeSignal is an internal control-flow type. Do not catch it in your handlers — let it propagate naturally.
onAlarm: (ctx) =>
  Effect.gen(function* () {
    const state = yield* ctx.recall()
    const count = (state?.count ?? 0) + 1
    yield* ctx.save({ count })

    if (count >= 10) {
      yield* ctx.purge() // nothing after this runs
    }

    yield* ctx.scheduleIn("2 seconds")
  })

Identity properties

id
string
The instance ID that was passed to send() or getState(). Use this to correlate logs, emit events keyed to the instance, or build derived storage keys.
onEvent: (ctx, event) =>
  Effect.gen(function* () {
    console.log(`[${ctx.name}/${ctx.id}] handling event`, event)
  })
name
string
The task name — the key used in the definitions record passed to createTasks().
const { TasksDO, tasks } = createTasks({ counter, emailer })
// ctx.name === "counter" inside the counter task's handlers
// ctx.name === "emailer" inside the emailer task's handlers

onClientGetState and recall()

The onClientGetState hook and ctx.recall() serve different purposes:
ctx.recall()onClientGetState
Who calls itYour handler codeExternal client via getState()
What it returnsRaw persisted stateTransformed state (what the client sees)
When it runsDuring onEvent, onAlarm, onErrorOnly during external getState() requests
Use onClientGetState to redact sensitive fields or compute derived values without affecting internal handler logic:
const myTask = Task.define({
  state: Schema.Struct({
    count: Schema.Number,
    internalToken: Schema.String,
  }),
  event: MyEvent,

  onAlarm: (ctx) =>
    Effect.gen(function* () {
      const state = yield* ctx.recall()
      // recall() returns full state — internalToken is accessible here
      console.log(state?.internalToken) // "secret-123"
    }),

  onClientGetState: (_ctx, state) =>
    Effect.gen(function* () {
      if (state === null) return null
      return { ...state, internalToken: "[redacted]" }
    }),
})

TaskError type

All TaskContext methods (except purge) can fail with TaskError:
class TaskError extends Data.TaggedError("TaskError")<{
  readonly message: string
  readonly cause?: unknown
}> {}
TaskError represents failures in the underlying storage or alarm infrastructure (e.g., a Durable Object storage write failure). In normal operation you will not encounter it — use Effect.orDie or handle it explicitly if needed.

Build docs developers (and LLMs) love