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 invocation cancel '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
pollon 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.