Recurring Tasks via Self-Scheduling (TypeScript)

SkillProductivity

Shows your agent how to schedule its own repeated tasks that run periodically without external timers.

Available today. Use it from your connected AI after setup.

Add ahel to your AI once: Claude, ChatGPT, Cursor, Claude Code or Codex. Then ask it to use this.

Then ask your AI: use the Recurring Tasks via Self-Scheduling (TypeScript) skill

About this skill

Implementing a recurring (cron-like) task in a TypeScript Golem agent by self-scheduling future invocations. Use when the user asks about periodic tasks, recurring jobs, cron-like scheduling, polling loops, heartbeats, or self-scheduling agents.

What this skill tells your AI

The instructions your AI receives, as published by golemcloud/golem in golem-skills/skills/ts/golem-recurring-task-ts/SKILL.md and read by ahel’s review.

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 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.

Signals

GitHub stars
2k
Forks
210
Last commit
Sep 2026
Advanced
Item type
skill
Key
golem-recurring-task-ts
Source
github.com/golemcloud/golem