AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified Apache-2.0 Self-run

Resonate Recursive Fan Out Pattern Typescript

skill-resonatehq-resonate-skills-resonate-recursive-fan-out-pattern-typescript · by resonatehq

Implement recursive fan-out patterns for parallel workflow execution. Use when breaking down complex work into independent subtasks that execute recursively with automatic deduplication and load balancing.

— No reviews yet
0 installs
32 views
0.0% view→install

Install

$ agentstack add skill-resonatehq-resonate-skills-resonate-recursive-fan-out-pattern-typescript

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • ✓ Prompt-injection patterns
  • ✓ Secret / credential exfiltration
  • ✓ Dangerous shell & filesystem operations
  • ✓ Untrusted network calls
  • ✓ Known-malicious package signatures

What it can access

  • ✓ Network access No
  • ✓ Filesystem access No
  • ✓ Shell / process execution No
  • ✓ Environment & secrets No
  • ✓ Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-resonatehq-resonate-skills-resonate-recursive-fan-out-pattern-typescript)

Reliability & compatibility

✓ Security review passed
0 installs to date
— no reviews yet
● 2mo ago

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of Resonate Recursive Fan Out Pattern Typescript? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Resonate Recursive Fan-Out Pattern (TypeScript)

Overview

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.

Mental Model

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 promise

Pattern 1: Detached Fan-Out (Fire-and-Forget)

Use when: You want to spawn independent work without waiting for results.

Basic Pattern

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 ...
  });
}

With Automatic Deduplication

function* scrapeUser(
  ctx: Context,
  userId: string,
  depth: number
): Generator {
  // 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.

Pagination with Fan-Out

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);
  }
}

Pattern 2: Parallel RPC Fan-Out (Collect Results)

Use when: You need to gather and combine results from parallel subtasks.

Basic Pattern

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;
}

Real-World Example: Recursive Research Agent

interface ResearchMessage {
  role: "system" | "user" | "assistant" | "tool";
  content: string;
  tool_calls?: Array;
}

function* research(
  ctx: Context,
  topic: string,
  depth: number
): Generator {
  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;
}

Pattern 3: Bounded Fan-Out (Rate Limiting)

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 };
}

Pattern 4: Dynamic Fan-Out (Decision-Based)

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;
}

Deduplication Strategy

Why deduplication matters:

  • Same user can appear in multiple follower lists
  • Same URL can be linked from multiple pages
  • Same subtask can be triggered from different paths

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 twice

Promise 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 ID

Load Balancing

Automatic 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 capacity

Target specific worker group:

yield* ctx.beginRpc(
  processItem,
  item,
  ctx.options({
    target: "poll://any@workers"  // Any worker in "workers" group
  })
);

Common Patterns

Pattern: Map-Reduce

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;
}

Pattern: Tree Traversal

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 })
    );
  }
}

Pattern: Retry with Fan-Out

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

Performance Considerations

Detached vs RPC:

  • ctx.detached(): Fire-and-forget, parent doesn't wait
  • ctx.beginRpc(): Parent waits for results, blocks until all complete

When to use detached:

  • Independent tasks (scraping, notifications, logging)
  • No need for results in parent
  • Maximum parallelism

When to use RPC:

  • Need results to continue
  • Aggregation/reduction operations
  • Coordinated execution

Memory: Thousands of parallel workflows are fine. Resonate handles state externally.

Common Pitfalls

1. Missing Deduplication ID

// ❌ 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 })
  );
}

2. Blocking on Detached Workflows

// ❌ 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;

3. Non-Deterministic Promise IDs

// ❌ 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}` })
);

Decision Tree

Use recursive fan-out when:

  • Breaking work into parallel subtasks
  • Recursive data structures (trees, graphs)
  • Unknown depth (crawling, scraping)
  • Natural deduplication via IDs

Use detached when:

  • Fire-and-forget execution
  • Independent side effects
  • Don't need results

Use beginRpc when:

  • Need to collect and combine results
  • Parallel map-reduce operations
  • Coordinated execution

Summary

Recursive fan-out patterns enable:

  • Massive parallelism across distributed workers
  • Automatic deduplication via promise IDs
  • Recursive tree/graph traversal
  • Load-balanced execution
  • Both fire-and-forget and result-gathering modes

Core recipe: Spawn children with ctx.detached() or ctx.beginRpc() → Use deterministic IDs for deduplication → Let Resonate handle distribution and recovery

Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

Install and usage instructions live in the source repository linked above.

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.