resonate-recursive-fan-out-pattern-typescript — independently scanned and version-tracked by SaferSkills.
SaferSkills independently audited resonate-recursive-fan-out-pattern-typescript (Agent Skill) and scored it 100/100 (green). The audit ran 55 deterministic rules across Security, Supply Chain, Maintenance, Transparency, and Community; it found 0 high-severity and 0 lower-severity findings. The full rule-by-rule trace and per-finding evidence are below. Free, methodology-open.
Findings & checks · 0 flagged
Every scanned point with the score it earned and what moved between them.
First recorded scan — no prior version to compare against.
The primary manifest — the file an agent reads to learn what this artifact does.
The recursive fan-out pattern enables workflows to spawn multiple child workflows in parallel, either collecting their results or letting them execute independently. Resonate provides two primitives for this: ctx.detached() for fire-and-forget execution and ctx.beginRpc() for parallel execution with result gathering.
Core principle: Break complex work into parallel subtasks that recursively break down further, with automatic deduplication via promise IDs and transparent load balancing across workers.
Parent Workflow
│
├─────────┬─────────┬─────────┐
│ │ │ │
Child 1 Child 2 Child 3 Child 4
│ │ │
├──┬──┐ ├──┐ └──┬──┐
G1 G2 G3 G4 G5 G6 G7
Fan-out: Parent spawns multiple children
Recursive: Children spawn grandchildren
Parallel: All execute concurrently
Deduplication: Same ID => same promiseUse when: You want to spawn independent work without waiting for results.
import { Context } from "@resonatehq/sdk";
function* parentWorkflow(ctx: Context, items: string[]) {
// Spawn detached workflows for each item
for (const item of items) {
yield* ctx.detached(
processItem,
item,
ctx.options({ id: `process/${item}` })
);
}
// Parent completes immediately, children continue independently
return { spawned: items.length };
}
function* processItem(ctx: Context, item: string) {
// Process the item
yield* ctx.run(async () => {
console.log(`Processing ${item}`);
// ... actual work ...
});
}function* scrapeUser(
ctx: Context,
userId: string,
depth: number
): Generator<any, void, any> {
// Fetch user data
const user = yield* ctx.run(fetchUserData, userId);
console.log(`Scraped: ${user.handle}`);
// Recursively scrape followers
if (depth > 0) {
const followers = yield* ctx.run(getFollowers, userId);
for (const follower of followers) {
// CRITICAL: Use follower ID as promise ID for deduplication
// If same follower appears in multiple lists, only one scrape happens
yield* ctx.detached(
scrapeUser,
follower.id,
depth - 1,
ctx.options({ id: follower.id })
);
}
}
}Deduplication guarantee: Multiple calls with same ID resolve to the same promise. A user appearing in 100 follower lists is scraped exactly once.
function* scrapeWithPagination(
ctx: Context,
userId: string,
depth: number
) {
const user = yield* ctx.run(fetchUser, userId);
if (depth > 0) {
let cursor: string | undefined = undefined;
// Paginate through followers
do {
const page = yield* ctx.run(getFollowersPage, userId, cursor);
// Spawn detached scrape for each follower on this page
for (const follower of page.followers) {
yield* ctx.detached(
scrapeWithPagination,
follower.id,
depth - 1,
ctx.options({ id: follower.id })
);
}
cursor = page.cursor;
} while (cursor !== undefined);
}
}Use when: You need to gather and combine results from parallel subtasks.
function* parallelProcessing(ctx: Context, items: string[]) {
const handles = [];
// Spawn parallel RPCs
for (const item of items) {
const handle = yield* ctx.beginRpc(
processItem,
item
);
handles.push(handle);
}
// Collect all results
const results = [];
for (const handle of handles) {
const result = yield* handle;
results.push(result);
}
return { results };
}
function* processItem(ctx: Context, item: string) {
const result = yield* ctx.run(async () => {
// Process and return result
return `processed: ${item}`;
});
return result;
}interface ResearchMessage {
role: "system" | "user" | "assistant" | "tool";
content: string;
tool_calls?: Array<{
id: string;
function: { name: string; arguments: string };
}>;
}
function* research(
ctx: Context,
topic: string,
depth: number
): Generator<any, string, any> {
const messages: ResearchMessage[] = [
{ role: "system", content: SYSTEM_PROMPT },
{ role: "user", content: `Research ${topic}` }
];
while (true) {
// Prompt LLM (only allow tool access if depth > 0)
const message = yield* ctx.run(promptLLM, messages, depth > 0);
messages.push(message);
// If LLM wants to research subtopics, spawn parallel research
if (message.tool_calls) {
const handles = [];
// Spawn detached research for each subtopic
for (const tool_call of message.tool_calls) {
const tool_name = tool_call.function.name;
const tool_args = JSON.parse(tool_call.function.arguments);
if (tool_name === "research") {
// Recursively research subtopic with reduced depth
const handle = yield* ctx.beginRpc(
research,
tool_args.topic,
depth - 1
);
handles.push([tool_call, handle]);
}
}
// Collect all subtopic research results
for (const [tool_call, handle] of handles) {
const result = yield* handle;
messages.push({
role: "tool",
tool_call_id: tool_call.id,
content: result
});
}
} else {
// No more tool calls, return final summary
return message.content || "";
}
}
}
const SYSTEM_PROMPT = `
You are a recursive research agent.
When given a broad topic, break it down into 2-3 subtopics and call the "research" tool for each.
If the topic is specific, summarize it directly without calling the tool.
`;
async function promptLLM(
_ctx: Context,
messages: ResearchMessage[],
hasToolAccess: boolean
) {
const completion = await openai.chat.completions.create({
model: "gpt-4o",
messages: messages,
tools: TOOLS,
tool_choice: hasToolAccess ? "auto" : "none"
});
return completion.choices[0]?.message;
}Use when: You need to limit parallelism to avoid overwhelming external systems.
function* boundedFanOut(
ctx: Context,
items: string[],
batchSize: number
) {
const results = [];
// Process in batches
for (let i = 0; i < items.length; i += batchSize) {
const batch = items.slice(i, i + batchSize);
const handles = [];
// Spawn up to batchSize parallel RPCs
for (const item of batch) {
const handle = yield* ctx.beginRpc(processItem, item);
handles.push(handle);
}
// Wait for this batch to complete before starting next
for (const handle of handles) {
const result = yield* handle;
results.push(result);
}
}
return { results };
}Use when: Children determine whether to spawn grandchildren.
function* dynamicFanOut(
ctx: Context,
url: string,
maxDepth: number,
currentDepth: number = 0
) {
// Fetch page
const page = yield* ctx.run(fetchPage, url);
// Process this page
yield* ctx.run(processPage, page);
// Decide whether to crawl deeper
if (currentDepth < maxDepth && shouldCrawlDeeper(page)) {
const links = extractLinks(page);
// Fan out to linked pages
for (const link of links) {
yield* ctx.detached(
dynamicFanOut,
link,
maxDepth,
currentDepth + 1,
ctx.options({ id: link }) // Deduplicate by URL
);
}
}
}
function shouldCrawlDeeper(page: any): boolean {
// Custom logic: only crawl if page matches criteria
return page.isRelevant && page.linkCount < 100;
}Why deduplication matters:
How Resonate deduplicates:
// First call: Creates new promise with ID "user/123"
yield* ctx.detached(scrape, "user/123", ctx.options({ id: "user/123" }));
// Second call (from different branch): Resolves to SAME promise
yield* ctx.detached(scrape, "user/123", ctx.options({ id: "user/123" }));
// Result: "user/123" is scraped exactly once, not twicePromise ID strategies:
// ✅ GOOD - Natural unique identifier
ctx.options({ id: user.id })
ctx.options({ id: url })
ctx.options({ id: `task/${taskId}` })
// ✅ GOOD - Deterministic composite key
ctx.options({ id: `${parentId}/${childId}` })
// ❌ BAD - Non-deterministic (random on replay)
ctx.options({ id: `${Date.now()}` })
ctx.options({ id: `${Math.random()}` })
// ✅ BEST - Auto-generated (if no deduplication needed)
yield* ctx.detached(work) // Resonate generates deterministic IDAutomatic distribution:
// Define worker group
const resonate = new Resonate({
url: "http://localhost:8001",
group: "workers"
});
// Register functions
resonate.register("processItem", processItem);
// Start multiple worker instances
// Worker 1: npm start
// Worker 2: npm start
// Worker 3: npm start
// Detached/RPC calls automatically distribute across available workers
yield* ctx.detached(processItem, item); // Routed to worker with capacityTarget specific worker group:
yield* ctx.beginRpc(
processItem,
item,
ctx.options({
target: "poll://any@workers" // Any worker in "workers" group
})
);function* mapReduce(ctx: Context, inputs: string[]) {
const handles = [];
// Map phase: spawn parallel work
for (const input of inputs) {
const handle = yield* ctx.beginRpc(mapFunction, input);
handles.push(handle);
}
// Collect mapped results
const mapped = [];
for (const handle of handles) {
const result = yield* handle;
mapped.push(result);
}
// Reduce phase: combine results
const reduced = yield* ctx.run(reduceFunction, mapped);
return reduced;
}function* traverseTree(ctx: Context, nodeId: string) {
// Process current node
const node = yield* ctx.run(fetchNode, nodeId);
yield* ctx.run(processNode, node);
// Recursively process children
for (const childId of node.children) {
yield* ctx.detached(
traverseTree,
childId,
ctx.options({ id: childId })
);
}
}function* retryableFanOut(
ctx: Context,
items: string[],
maxAttempts: number = 3
) {
const handles = [];
for (const item of items) {
const handle = yield* ctx.beginRpc(
retryableTask,
item,
maxAttempts
);
handles.push([item, handle]);
}
const results = [];
const failures = [];
for (const [item, handle] of handles) {
try {
const result = yield* handle;
results.push({ item, result });
} catch (error) {
failures.push({ item, error });
}
}
return { results, failures };
}
function* retryableTask(ctx: Context, item: string, maxAttempts: number) {
for (let attempt = 1; attempt <= maxAttempts; attempt++) {
try {
return yield* ctx.run(processItem, item);
} catch (error) {
if (attempt === maxAttempts) throw error;
yield* ctx.sleep(1000 * attempt); // Exponential backoff
}
}
}Detached vs RPC:
ctx.detached(): Fire-and-forget, parent doesn't waitctx.beginRpc(): Parent waits for results, blocks until all completeWhen to use detached:
When to use RPC:
Memory: Thousands of parallel workflows are fine. Resonate handles state externally.
// ❌ WRONG - Creates duplicate work
for (const follower of followers) {
yield* ctx.detached(scrape, follower.id); // No ID specified!
}
// ✅ CORRECT - Deduplicates by follower ID
for (const follower of followers) {
yield* ctx.detached(
scrape,
follower.id,
ctx.options({ id: follower.id })
);
}// ❌ WRONG - Can't await detached workflow
const handle = yield* ctx.detached(work);
const result = yield* handle; // Error: detached workflows don't return handles
// ✅ CORRECT - Use beginRpc if you need results
const handle = yield* ctx.beginRpc(work);
const result = yield* handle;// ❌ WRONG - Different ID on replay
yield* ctx.detached(
work,
data,
ctx.options({ id: `task-${Date.now()}` })
);
// ✅ CORRECT - Deterministic ID
yield* ctx.detached(
work,
data,
ctx.options({ id: `task-${data.id}` })
);Use recursive fan-out when:
Use detached when:
Use beginRpc when:
Recursive fan-out patterns enable:
Core recipe: Spawn children with ctx.detached() or ctx.beginRpc() → Use deterministic IDs for deduplication → Let Resonate handle distribution and recovery
~30 seconds. Free. No account. Every finding cites a rule and a line of evidence.