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:212scanKeyword 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:172The 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 onSIGINT/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.sleepas 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.runper 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
analyzeStorycalls. On restart Resonate replaysscanKeyword;searchHackerNewsand every completedanalyzeStoryreturn 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
notifyFindingsreturns. On restartnotifyFindingsre-runs. Slack delivery is at-least-once — the README is explicit that duplicate notifications are possible because the webhook POST is wrapped in a singlectx.run.src/agent.ts:115-154, READMELimits. - Worker crashes during the inter-round sleep.
ctx.sleepis 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/catchis inside the for-loop inmonitorHackerNews, 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.
seenIdsrebuilds via deterministic replay of completedscanKeywordcalls — the promise store contains everyScanResultever returned, includingnewlyAnalyzed[], 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.sleepinstead ofsetTimeout). - 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
- Example repo: https://github.com/resonatehq-examples/example-hackernews-research-agent-ts
- Resonate TypeScript SDK: https://github.com/resonatehq/resonate-sdk-ts
- SDK primitives used:
Resonateclass,setDependency,register,stop— https://github.com/resonatehq/resonate-sdk-ts/blob/main/src/index.tsContext(ctx.run,ctx.sleep,ctx.getDependency) — https://github.com/resonatehq/resonate-sdk-ts/blob/main/src/context.ts
- Resonate server: https://github.com/resonatehq/resonate
- Resonate documentation: https://docs.resonatehq.io
