4 min readResonate HQJust published

Long-running Hacker News research agent in TypeScript on Resonate

How an infinite monitoring loop, an LLM call per story, and an in-memory deduplication set survive process restarts without an external state store.

Resonate brand card on a dark background with a teal spectrum wave at the bottom and the post headline in white Sansation.

A long-running agent that scans an external API, calls an LLM per result, and sends notifications has three independent failure surfaces; a crash mid-scan in a naive implementation re-analyzes stories, double-fires notifications, or drops results. Resonate turns the entire scan into a durable generator workflow where every yield* ctx.run(...) is a checkpoint, yield* ctx.sleep(...) is a durable timer, and the deduplication set is rebuilt from the promise store via replay. The example shows a TypeScript agent that monitors Hacker News for configured keywords, ranks stories with gpt-4o-mini, optionally posts to Slack, and resumes a partially-completed scan or an in-progress sleep without losing work.

The shape of the solution

The orchestrating function is an infinite loop. It owns the in-memory seenIds set, sleeps durably between rounds, and delegates each per-keyword scan to a separate durable function.

export function* monitorHackerNews(
  ctx: Context
): Generator<any, void, any> {
  const config = ctx.getDependency<AgentConfig>('config')!;
 
  // ...startup logs elided
 
  const seenIds = new Set<string>();
 
  while (true) {
    for (const keyword of config.keywords) {
      try {
        const result = yield* scanKeyword(ctx, keyword, Array.from(seenIds));
        for (const a of result.newlyAnalyzed) seenIds.add(a.storyId);
      } catch (error) {
        console.error(`❌ Error scanning "${keyword}":`, error);
      }
    }
 
    yield* ctx.sleep(config.scanIntervalMs);
  }
}
// from example-hackernews-research-agent-ts/src/agent.ts:212

scanKeyword is also independently invocable from the CLI — it accepts seenIds: string[] = [] so a one-off call can pass just the keyword.

export function* scanKeyword(
  ctx: Context,
  keyword: string,
  seenIds: string[] = []
): Generator<any, ScanResult, any> {
  const config = ctx.getDependency<AgentConfig>('config')!;
 
  const stories = yield* searchHackerNews(ctx, keyword);
  const seen = new Set(seenIds);
  const newStories = stories.filter(s => !seen.has(s.objectID));
 
  const newlyAnalyzed: StoryAnalysis[] = [];
  for (const story of newStories) {
    const analysis = yield* analyzeStory(ctx, story, keyword);
    newlyAnalyzed.push(analysis);
  }
 
  const interesting = newlyAnalyzed.filter(
    a => a.isInteresting && a.relevanceScore >= config.relevanceThreshold
  );
 
  yield* notifyFindings(ctx, interesting, keyword, config.slackWebhook);
 
  return { keyword, storiesFound: stories.length, newlyAnalyzed };
}
// from example-hackernews-research-agent-ts/src/agent.ts:172

The durable primitives in play

  • resonate.register(scanKeyword) / resonate.register(monitorHackerNews) — registers the two generators as durable, RPC-invokable functions. src/index.ts:32-33.
  • resonate.setDependency('openai', openai) / resonate.setDependency('config', config) — injects the unserializable OpenAI client and the config object into the durable world; keeps the generators portable across workers. src/index.ts:29-30.
  • ctx.getDependency<OpenAI>('openai') / ctx.getDependency<AgentConfig>('config') — resolves dependencies inside a generator. src/agent.ts:51, 177, 215.
  • yield* ctx.run(async () => { ... }) — single durable checkpoint. The async body runs once; on replay the cached return value is yielded back without re-executing. Used for the HN fetch (src/agent.ts:26-38), each LLM call (src/agent.ts:50-102), and the notify step (src/agent.ts:115-154).
  • yield* searchHackerNews(...) / yield* analyzeStory(...) / yield* notifyFindings(...) — generator composition. The inner generators are themselves durable; the outer one yields each completed step as a checkpointed unit. src/agent.ts:179, 185, 193.
  • yield* ctx.sleep(config.scanIntervalMs) — durable timer. A worker restart during the sleep resumes the remaining sleep duration; it does not trigger an immediate redundant scan. src/agent.ts:234.
  • resonate.stop() — clean worker shutdown on SIGINT/SIGTERM. src/index.ts:45.

What the SDK handles vs. what you write

The generators in agent.ts are written as straight-line code: fetch, filter, loop, analyze, notify, sleep, repeat. There is no checkpoint-management code, no idempotency-key generation, no "have I done this step before?" branch, no resume-from-offset logic, no external state store. The promise store IS the state store.

What the SDK does on its own:

  • Persists the result of every yield* ctx.run(...) to the Resonate server.
  • On worker crash / restart, replays the generator from the top and returns the cached value for every completed step until it reaches the first incomplete one.
  • Treats ctx.sleep as a durable timer that survives process death — the sleep is resumed, not restarted.
  • Keeps dependencies (the OpenAI client, the config) out of the durable promise payloads, so the workflow state stays serializable.

What you write:

  • The generator body. Plain control flow — for, while, try/catch, filter, Set.
  • One ctx.run per side-effect you want to be a checkpoint (HN fetch, one LLM call, one notify).
  • A dependency injection step at startup for anything unserializable (OpenAI client, config object).
  • Whatever business logic decides what counts as "interesting" (here: isInteresting && relevanceScore >= threshold).

The deduplication is the cleanest illustration: seenIds is a regular in-memory Set. It is never written to disk. On recovery, Resonate replays the outer generator; each prior scanKeyword call returns its cached newlyAnalyzed; the for (const a of result.newlyAnalyzed) seenIds.add(a.storyId) line re-runs in the same order; seenIds rebuilds itself deterministically before execution catches up to the live edge.

Failure modes covered

  • Worker crashes mid-scan, between two analyzeStory calls. On restart Resonate replays scanKeyword; searchHackerNews and every completed analyzeStory return cached results; only the unfinished and not-yet-started stories actually hit the LLM. src/agent.ts:179-187.
  • Worker crashes after the LLM analyses complete but before notifyFindings returns. On restart notifyFindings re-runs. Slack delivery is at-least-once — the README is explicit that duplicate notifications are possible because the webhook POST is wrapped in a single ctx.run. src/agent.ts:115-154, README Limits.
  • Worker crashes during the inter-round sleep. ctx.sleep is durable; on restart the remaining duration resumes rather than scheduling an immediate redundant scan. src/agent.ts:234.
  • A single keyword's scan throws — HN timeout, OpenAI rate-limit, JSON parse error. The try/catch is inside the for-loop in monitorHackerNews, so a failure in one keyword's scan logs and continues to the next keyword and to the next round. src/agent.ts:226-231.
  • Cold restart with no in-memory state. seenIds rebuilds via deterministic replay of completed scanKeyword calls — the promise store contains every ScanResult ever returned, including newlyAnalyzed[], and replay walks them in original order. README :19-20, :96.

Not covered (called out so an agent reader does not assume coverage): unbounded seenIds growth — every story ID from every round is retained in memory, and replay cost grows with the log. The README flags this and suggests bounding the window or snapshotting externally for production use. README :100-102.

When to reach for this pattern

  • If you have a continuous monitoring loop that does external IO per iteration and you need crash-resume without a separate database to track progress.
  • If each iteration consists of an external fetch, a fan-out of LLM calls, and a notification — and you want each step independently checkpointed rather than the whole round being one atomic unit.
  • If you want a setInterval-shaped loop that survives process death (ctx.sleep instead of setTimeout).
  • If your "have I seen this before?" set can be reconstructed from prior workflow outputs and you would rather not run a state store next to the worker.
  • If the same scan function needs to be both invocable from a continuous loop and independently invocable as a one-off RPC call.

Sources