How to Build a Programmatic Agent Using `handleSteps` Generators in FreeBuff

FreeBuff agents use generator functions in the handleSteps property to execute step-by-step workflows, yielding tool calls and log chunks that the runtime processes sequentially until completion or step limit.

The FreeBuff SDK enables fully programmable agents through a template-based system. An agent definition exports a handleSteps generator that the SDK serializes, transmits to the runtime, and executes step-by-step. Each yielded value becomes an instruction—whether a tool invocation, progress log, or control signal—that drives the agent's behavior programmatically.

Understanding the handleSteps Execution Flow

Building a programmatic agent using handleSteps generators involves seven distinct phases, each handled by specific components in the FreeBuff source code.

Phase 1: Define the Agent Template

The agent creator exports an AgentDefinition object containing the handleSteps generator function. This definition resides in the agents package and establishes the agent's identity and behavior.

// agents/types/agent-definition.ts
export interface AgentDefinition {
  name: string;
  description: string;
  handleSteps?: () => Generator<HandleStepsYieldValue, any, any>;
}

Phase 2: Serialize for Transport

When agents are loaded, the SDK converts the generator function to a string for serialization while preserving a live reference for local execution. In sdk/src/agents/load-agents.ts, the loader performs:

agentDefinition.handleSteps = fn.toString();
const handleStepsFn = fn; // Live reference preserved

This dual representation enables both remote runtime execution and local development workflows.

Phase 3: Initialize the Session

The client creates a run session with step-limit protection. In sdk/src/client.ts, the run method seeds stepsRemaining from maxAgentSteps (defaulting to 200):

const runOptions = {
  maxAgentSteps: options.maxAgentSteps ?? 200,
  // ... session configuration
};

Phase 4: Execute Programmatic Steps

The runtime deserializes and drives the generator. The core driver in packages/agent-runtime/src/run-programmatic-step.ts uses globalEval to reconstruct the generator function, then repeatedly invokes next() until exhaustion or step limit:

const generator = globalEval(agentDefinition.handleSteps)();
let result = generator.next();

while (!result.done && stepsRemaining > 0) {
  const yieldedValue = result.value;
  // Process yield...
  result = generator.next(toolResult);
  stepsRemaining--;
}

Phase 5: Validate and Route Yields

Each yielded value passes through HandleStepsYieldValueSchema validation. Tool requests route to the Tool Executor; results feed back into the generator via next():

// packages/agent-runtime/src/util/parse-tool-calls-from-text.ts
const validated = HandleStepsYieldValueSchema.parse(yieldedValue);
if (validated.tool) {
  const toolResult = await toolExecutor.execute(validated.tool, validated.args);
  result = generator.next(toolResult);
}

Phase 6: Stream Progress Logs

When the generator yields handleStepsLogChunk, the runtime forwards it to the client's callback for UI rendering:

yield { handleStepsLogChunk: 'Processing file...' };
// Runtime delivers to: options.handleStepsLogChunk?.(msg)

Phase 7: Return Final Output

Generator completion propagates the return value as the agent's final result, terminating the run session.

Creating a handleSteps Generator Agent

Follow this implementation pattern to build your own programmatic agent using handleSteps generators.

Step 1: Structure Your Generator Function

The handleSteps property must be a generator function (function*) yielding objects matching the HandleStepsYieldValueSchema. Valid yield types include:

  • Tool calls: { tool: string, args: object }
  • Log chunks: { handleStepsLogChunk: string }
  • Error signals: { error: string }
function* workflowGenerator() {
  // Emit progress visible to users
  yield { handleStepsLogChunk: 'Initializing analysis...' };
  
  // Execute a tool and capture the result
  const fileContent = yield {
    tool: 'readFile',
    args: { path: 'src/config.ts' }
  };
  
  // Use the result in subsequent logic
  yield { handleStepsLogChunk: `Read ${fileContent.length} bytes` };
  
  // Return final output
  return { success: true, data: fileContent };
}

Step 2: Export the Complete Definition

Create your agent file in the agents/ directory with full type safety:

// agents/src/my-analyzer.ts
import type { AgentDefinition } from '@codebuff/common/types/agent-template';

export const fileAnalyzer: AgentDefinition = {
  name: 'file-analyzer',
  description: 'Reads and analyzes source files programmatically',
  
  handleSteps: function* () {
    // Log: operation start
    yield { handleStepsLogChunk: '🔍 Starting file analysis' };
    
    // Tool: read package.json for context
    const packageJson = yield {
      tool: 'readFile',
      args: { path: 'package.json' }
    };
    
    // Log: intermediate progress
    const pkg = JSON.parse(packageJson);
    yield {
      handleStepsLogChunk: `📦 Project: ${pkg.name}@${pkg.version}`
    };
    
    // Tool: list source files
    const sourceFiles = yield {
      tool: 'listDirectory',
      args: { path: 'src', recursive: false }
    };
    
    // Log: completion status
    yield {
      handleStepsLogChunk: `✅ Found ${sourceFiles.length} files in src/`
    };
    
    // Return structured result
    return {
      project: pkg.name,
      filesAnalyzed: sourceFiles.length,
      fileList: sourceFiles
    };
  }
};

Step 3: Register and Export

Ensure your agent is discoverable by adding it to the package index:

// agents/src/index.ts
export { fileAnalyzer } from './my-analyzer';

Step 4: Execute with the SDK Client

Invoke your agent through the runtime using the client.run API:

// examples/run-analyzer.ts
import { client } from '@codebuff/sdk';
import { fileAnalyzer } from '@codebuff/agents';

async function analyzeProject() {
  const result = await client.run({
    agents: [fileAnalyzer],
    maxAgentSteps: 15,                    // Prevent runaway execution
    handleStepsLogChunk: (msg: string) => {
      console.log(`[${new Date().toISOString()}] ${msg}`);
    }
  });
  
  console.log('Analysis complete:', result);
  return result;
}

analyzeProject().catch(console.error);

Working with Tool Results and State

The handleSteps generator maintains state across yields. Tool results passed through generator.next() become available for subsequent logic:

handleSteps: function* () {
  // First tool call
  const userQuery = yield { tool: 'getUserInput', args: {} };
  
  // Use result in second tool call
  const searchResults = yield {
    tool: 'webSearch',
    args: { query: userQuery }
  };
  
  // Chain results into final processing
  const summary = yield {
    tool: 'summarize',
    args: { text: searchResults.join('\n') }
  };
  
  return { answer: summary };
}

This synchronous-looking code executes asynchronously, with the runtime managing all promise resolution and error handling between steps.

Advanced handleSteps Patterns

Nested Agent Delegation

Spawn sub-agents within a generator using loopAgentSteps, as implemented in the built-in researcher agents:

handleSteps: function* () {
  const researchTask = yield {
    tool: 'spawnAgent',
    args: {
      agentName: 'deep-researcher',
      inputs: { topic: 'quantum computing' }
    }
  };
  
  // Process sub-agent output
  yield {
    handleStepsLogChunk: `Research produced ${researchTask.pages} pages`
  };
  
  return { synthesized: researchTask.findings };
}

Note that spawnAgent sets fromHandleSteps: false internally to prevent infinite recursion cycles.

Error Recovery Strategies

Tool failures yield error objects that generators can handle gracefully:

handleSteps: function* () {
  yield { handleStepsLogChunk: 'Attempting file read' };
  
  const result = yield {
    tool: 'readFile',
    args: { path: 'maybe-missing.txt' }
  };
  
  // Check for error structure
  if (result && typeof result === 'object' && 'error' in result) {
    yield { handleStepsLogChunk: `Fallback: ${result.error}` };
    
    // Attempt recovery
    const fallback = yield {
      tool: 'readFile',
      args: { path: 'backup.txt' }
    };
    
    return { data: fallback, source: 'backup' };
  }
  
  return { data: result, source: 'primary' };
}

Step Budget Management

Monitor remaining steps for long-running workflows:

handleSteps: function* () {
  const maxIterations = 5;
  
  for (let i = 0; i < maxIterations; i++) {
    yield { handleStepsLogChunk: `Iteration ${i + 1}/${maxIterations}` };
    
    const progress = yield { tool: 'processBatch', args: { batchId: i } };
    
    if (progress.complete) {
      yield { handleStepsLogChunk: 'Early termination: goal achieved' };
      break;
    }
  }
  
  return { iterations: i + 1 };
}

The runtime enforces the global maxAgentSteps ceiling regardless of loop structures.

Key Source Files Reference

File Purpose
packages/agent-runtime/src/run-programmatic-step.ts Core driver: deserializes generators, iterates yields, manages step counting
sdk/src/agents/load-agents.ts Serializes handleSteps functions for runtime transport
sdk/src/client.ts Public API: client.run with maxAgentSteps and logging options
agents/types/agent-definition.ts TypeScript interfaces for AgentDefinition and yield schemas
packages/agent-runtime/src/tools/tool-executor.ts Executes tool calls yielded from generators
packages/agent-runtime/src/util/parse-tool-calls-from-text.ts Validates and parses HandleStepsYieldValueSchema

Summary

  • FreeBuff programmatic agents center on the handleSteps generator pattern defined in AgentDefinition
  • Generator functions (function*) yield tool calls, log chunks, and control signals that the runtime processes sequentially
  • Serialization in load-agents.ts enables both local development and remote runtime execution
  • Execution in run-programmatic-step.ts handles deserialization, validation, tool execution, and step limiting
  • State management flows through generator.next() calls, with tool results feeding back into subsequent yields
  • Safety defaults include 200-step limits via maxAgentSteps and schema validation on all yields

Frequently Asked Questions

What yield values are valid in a handleSteps generator?

Valid yields must conform to HandleStepsYieldValueSchema. Common patterns include { tool: 'name', args: {} } for tool execution, { handleStepsLogChunk: 'message' } for progress streaming, and { error: 'description' } for error signaling. The runtime validates each yield and routes it appropriately.

How does the runtime handle generator state across async tool calls?

The runtime maintains the generator instance in run-programmatic-step.ts, calling next() with resolved tool results. This creates the illusion of synchronous execution while the runtime manages underlying promises. Generator-local variables persist across yields, enabling stateful workflows.

Can I use handleSteps with TypeScript async generators?

No—handleSteps specifically expects synchronous generators (function*, not async function*). The asynchronous handling occurs in the runtime layer, not within the generator itself. Attempting to use async function* will fail serialization in load-agents.ts and break the execution model.

What happens when maxAgentSteps is exceeded?

The runtime forcibly terminates generator execution. In run-programmatic-step.ts, the loop condition checks stepsRemaining > 0 before each next() call. Upon exhaustion, the run ends with an incomplete status and partial results. Always set conservative limits and monitor handleStepsLogChunk for progress visibility.

Have a question about this repo?

These articles cover the highlights, but your codebase questions are specific. Give your agent direct access to the source. Share this with your agent to get started:

Share the following with your agent to get started:
curl -s "https://instagit.com/install.md"

Works with
Claude Codex Cursor VS Code OpenClaw Any MCP Client

Maintain an open-source project? Get it listed too →