/AYjnt/
All examples
schedulingcronui

Recurring tasks agent

Agent that wakes itself on a fixed cadence via `scheduleEvery()` and mutates its own state. Classic background-worker pattern on a single Durable Object. Ships with a live bar-chart UI.

What you'll learn
  • How `scheduleEvery(seconds, callback)` differs from one-shot `schedule()`
  • Why you must cancel the old schedule before calling scheduleEvery again
  • How recurring state updates fan out to any connected UI automatically
01 step

Start from the blank scaffold (with UI)

Same starter as the blank template but with React, react-dom and matching @types preinstalled so your agent.ts can have an app.tsx next to it. The default project is a Counter agent — we'll replace it with the example's agent in the next steps.

~/my-agent-app
my-app/ (--with-ui scaffold)
agent.ts
app.tsx
package.json
tsconfig.json
02 step

Replace counter/ with heartbeat/

The --with-ui scaffold gave us react + react-dom + typed tsconfig already. All that's left is swap the agent.

~/my-agent-app
03 step

agents/heartbeat/agent.ts — the tick loop

`this.scheduleEvery(seconds, "tick")` fires `this.tick()` on a fixed cadence. Returns a `Schedule` whose id you persist if you want to cancel it later. Calling scheduleEvery twice without cancelling the first leaves both running — always `stopTicking()` first.

agents/heartbeat/agent.ts ts
import { Agent } from "agents";
import type { GeneratedEnv } from "@ayjnt/env";

type Tick = { at: number; n: number; load: number };
type State = {
  intervalSeconds: number;
  ticks: Tick[];
  scheduleId: string | null;
};

export default class HeartbeatAgent extends Agent<GeneratedEnv, State> {
  override initialState: State = { intervalSeconds: 0, ticks: [], scheduleId: null };

  /** Recurring callback — fires every intervalSeconds once started. */
  async tick(): Promise<void> {
    const last = this.state.ticks[0]?.n ?? 0;
    const tick = { at: Date.now(), n: last + 1, load: Math.round(Math.random() * 1000) / 10 };
    this.setState({ ...this.state, ticks: [tick, ...this.state.ticks].slice(0, 50) });
  }

  private async stopTicking() {
    if (this.state.scheduleId) await this.cancelSchedule(this.state.scheduleId).catch(() => {});
    this.setState({ ...this.state, intervalSeconds: 0, scheduleId: null });
  }

  override async onRequest(request: Request): Promise<Response> {
    if (request.method === "POST") {
      const { intervalSeconds = 5, stop } = await request.json() as { intervalSeconds?: number; stop?: boolean };
      if (stop) { await this.stopTicking(); return Response.json({ ok: true, running: false }); }

      await this.stopTicking();   // ← mandatory before starting a new schedule
      const s = await this.scheduleEvery(intervalSeconds, "tick");
      this.setState({ ...this.state, intervalSeconds, scheduleId: s.id });
      return Response.json({ ok: true, running: true, intervalSeconds });
    }
    return Response.json({ instance: this.name, ...this.state });
  }
}
04 step

agents/heartbeat/app.tsx — live bar chart

`useAgent()` subscribes to state. Every tick → setState → CF_AGENT_STATE message → React re-render. No polling, no SSE. Two buttons POST to the same URL to start/stop the loop.

agents/heartbeat/app.tsx tsx
import { useAgent } from "@ayjnt/heartbeat";

export default function Heartbeat() {
  const agent = useAgent();
  const ticks = agent.state?.ticks ?? [];
  const running = (agent.state?.intervalSeconds ?? 0) > 0;

  const post = (body: object) =>
    fetch(window.location.pathname, { method: "POST", body: JSON.stringify(body) });

  const max = Math.max(100, ...ticks.map((t) => t.load));

  return (
    <main>
      <h1>heartbeat — {agent.name}</h1>
      <button disabled={running} onClick={() => post({ intervalSeconds: 2 })}>start 2s</button>
      <button disabled={!running} onClick={() => post({ stop: true })}>stop</button>
      <div style={{ display: "flex", alignItems: "end", height: 120 }}>
        {ticks.slice().reverse().map((t) => (
          <div key={t.n} style={{ flex: 1, background: "#3b82f6", height: `${(t.load/max)*100}%` }} />
        ))}
      </div>
    </main>
  );
}
05 step

Run + start ticking

Open the URL in the browser. Click start; bars start dropping in every 2 seconds. Close the tab — the agent keeps ticking because the alarm is persistent, not tied to any websocket.

~/my-agent-app
06 step

What it looks like

Each vertical bar is one tick. New ticks come in on the left; the chart scrolls right as the window fills.

heartbeat — live bar chart result
  heartbeat — demo
  instance: demo · status: ticking every 2s
  [start 2s] [start 5s] [stop]

  load history (9 ticks)
  ┌──────────────────────────────────────────┐
  │        ▄                                 │
  │   █    █   ▆                             │
  │   █ ▇  █   █    █                        │
  │   █ █  █ ▆ █ ▃  █ ▂                      │
  │   █ █ ▅█ █ █ █▄ █ █  █                   │
  └──────────────────────────────────────────┘

  15:42:18  #9  load: 74.3%
  15:42:16  #8  load: 52.1%
  15:42:14  #7  load: 88.0%
  15:42:12  #6  load: 31.4%
  …
07 step

Deploy

`ayjnt deploy` checks your git tree is clean + synced with origin, regenerates the wrangler config from scratch, then shells out to `wrangler deploy`. The committed migrations.json file is the source of truth for what's in production.

~/my-agent-app