Skip to Content
How-To GuidesTypeScriptRecurring Tasks via Self-Scheduling (TypeScript)

Recurring Tasks via Self-Scheduling (TypeScript)

Overview

A Golem agent can act as its own scheduler by scheduling one of its own methods to run again at the end of each invocation. This creates a durable, crash-resilient recurring task — if the agent restarts, the scheduled invocation is still pending and will fire at the designated time.

Because a method handler’s this is bound to the agent’s state (not to its other methods), factor the self-scheduling logic into a small module-level helper that uses the definition’s RPC client and calls .schedule().

Basic Pattern

The agent schedules its own poll method to run again after a delay:

import { z } from 'zod'; import { defineAgent, method } from '@golemcloud/golem-ts-sdk'; export const PollerAgent = defineAgent({ name: 'PollerAgent', id: { name: z.string() }, methods: { start: method({ input: {}, returns: z.void() }), poll: method({ input: {}, returns: z.void() }), }, }); // Self-scheduling helper: enqueue this agent's own `poll` to run after a delay. function scheduleNext(name: string, delaySecs: bigint): void { const nowSecs = BigInt(Math.floor(Date.now() / 1000)); PollerAgent.client.get({ name }).poll.schedule({ seconds: nowSecs + delaySecs, nanoseconds: 0 }); } export const PollerAgentImpl = PollerAgent.implement({ init: ({ id }) => ({ name: id.name }), methods: { start() { // Kick off the loop by enqueueing the first poll. scheduleNext(this.name, 0n); }, poll() { // 1. Do the recurring work. doWork(); // 2. Schedule the next run (60 seconds from now). scheduleNext(this.name, 60n); }, }, });

Exponential Backoff

Increase the delay on repeated failures, reset on success. Keep the counters in the agent’s state:

export const PollerAgentImpl = PollerAgent.implement({ init: ({ id }) => ({ name: id.name, consecutiveFailures: 0, baseIntervalSecs: 60n, maxIntervalSecs: 3600n, }), methods: { poll() { const success = tryWork(); let delay: bigint; if (success) { this.consecutiveFailures = 0; delay = this.baseIntervalSecs; } else { this.consecutiveFailures++; const exp = Math.min(this.consecutiveFailures, 6); delay = this.baseIntervalSecs * BigInt(2 ** exp); if (delay > this.maxIntervalSecs) delay = this.maxIntervalSecs; } scheduleNext(this.name, delay); }, }, });

Cancellation

.schedule() returns a CancellationToken. Keep it when the pending invocation must be canceled immediately, and call .cancel() before its scheduled time. For a recurring loop, also keep a boolean flag in state so a poll that has already started exits without rescheduling:

import type { CancellationToken } from '@golemcloud/golem-ts-sdk'; export const PollerAgentImpl = PollerAgent.implement({ init: ({ id }) => ({ name: id.name, cancelled: false, pending: undefined as CancellationToken | undefined, }), methods: { poll() { if (this.cancelled) return; // stop the loop doWork(); const nowSecs = BigInt(Math.floor(Date.now() / 1000)); this.pending = PollerAgent.client.get({ name: this.name }).poll.schedule({ seconds: nowSecs + 60n, nanoseconds: 0, }); }, cancel() { this.cancelled = true; this.pending?.cancel(); this.pending = undefined; }, }, });

Do not include pending in a typed snapshot schema: it is a live host resource, not ordinary serializable state. The boolean flag remains the durable source of truth across snapshots and restarts.

Cancellation from the CLI

If you scheduled the invocation from the CLI with an explicit idempotency key, cancel the pending invocation by key:

# Schedule with a known idempotency key golem agent invoke --trigger --schedule-at 2026-03-15T10:30:00Z -i 'poll-next' 'PollerAgent("my-poller")' poll # Cancel the pending invocation golem agent cancel-invocation 'PollerAgent("my-poller")' 'poll-next'

Common Use Cases

Periodic Polling

Check an external API or queue for new work at regular intervals:

poll() { const items = fetchPendingItems(); for (const item of items) { process(item); } scheduleNext(this.name, 60n); }

Periodic Cleanup

Remove expired data or stale resources on a schedule:

cleanup() { this.entries = this.entries.filter((e) => !e.isExpired()); scheduleNext(this.name, 3600n); // run hourly }

Heartbeat / Keep-Alive

Periodically notify an external service that the agent is alive:

heartbeat() { sendHeartbeat(this.serviceUrl); scheduleNext(this.name, 30n); // every 30s }

Helper for Scheduling Self

Keep the scheduling logic in one module-level helper so every method stays clean. PollerAgent.client is the typed RPC factory for this same agent type; addressing it by the agent’s own id record targets this instance:

function scheduleNext(name: string, delaySecs: bigint): void { const nowSecs = BigInt(Math.floor(Date.now() / 1000)); PollerAgent.client.get({ name }).poll.schedule({ seconds: nowSecs + delaySecs, nanoseconds: 0 }); }

Key Points

  • The agent is durable — if it crashes, the pending scheduled invocation still fires and the agent recovers
  • Invocations are sequential — no concurrent executions of poll on the same agent
  • Each .schedule() call is a fire-and-forget enqueue; the current invocation completes immediately
  • Use a state flag or generation counter to stop the loop gracefully
  • Keep the scheduled method idempotent — it may be retried on recovery

Recovery & Oplog Growth

Each scheduled tick (heartbeat, poll, cleanup) appends entries to the agent’s oplog. For long-running or high-frequency recurring tasks, the oplog grows unboundedly, and recovery on crash will replay the full history — which becomes slow over time.

You cannot opt out of oplog writes for a durable agent. The fix is snapshot-based recovery: enable periodic snapshotting so recovery starts from the latest snapshot instead of replaying every prior tick. Set snapshotting on defineAgent — snapshotting: { everyNInvocations: N } or snapshotting: { periodicSeconds: N } — and, for state the default JSON path can’t represent, supply custom snapshot: { save, load } on .implement(...). See golem-custom-snapshot-ts.