TypeScriptADK-TS

Custom Agents

Build agents with completely custom orchestration logic by inheriting from BaseAgent

Custom agents provide ultimate flexibility in ADK-TS for orchestrating sophisticated workflows with conditional branching, dynamic routing, and state-dependent decisions. Think of them as the master conductors for complex, multi-path processes where the next step depends on the results of previous steps.

Unlike simple workflow patterns (SequentialAgent, ParallelAgent, LoopAgent), custom agents handle complex decision trees, conditional flows, and dynamic routing based on content analysis, user preferences, or business rules. They allow you to define arbitrary orchestration logic by inheriting directly from BaseAgent and implementing your own control flow.

Advanced Feature

Custom agents require deep understanding of ADK-TS fundamentals. Master LLM agents and workflow agents before implementing custom orchestration logic.

Implementing Custom Logic

The core of any custom agent is the runAsyncImpl method where you define unique behavior:

Signature: protected async *runAsyncImpl(ctx: InvocationContext): AsyncGenerator<Event, void, unknown>

  • AsyncGenerator: Must be an async * function that yields events
  • InvocationContext: Provides access to session state, user context, and services
  • Event Yielding: Forward events from sub-agents or create custom progress updates

Core Concepts

1. Sub-Agent Initialization

Store sub-agents as instance properties and initialize them in the constructor:

import { BaseAgent, LlmAgent } from "@iqai/adk";

export class CustomWorkflowAgent extends BaseAgent {
  private processor: LlmAgent;
  private validator: LlmAgent;

  constructor() {
    super({ name: "custom_workflow_agent" });

    this.processor = new LlmAgent({
      name: "content_processor_agent",
      model: "gemini-2.5-flash",
      description:
        "Processes user input to extract and format key information.",
      instruction: "Process the input: {user_input}",
      outputKey: "processed_content",
    });

    this.validator = new LlmAgent({
      name: "content_validator_agent",
      model: "gemini-2.5-flash",
      description:
        "Validates the processed content and determines its validity status.",
      instruction: "Validate: {processed_content}. Return 'valid' or 'invalid'",
      outputKey: "validation_result",
    });
  }
}

2. State Management

Read and write session state within runAsyncImpl to manage data flow:

protected async *runAsyncImpl(
  ctx: InvocationContext
): AsyncGenerator<Event, void, unknown> {
  // Read state with defaults
  const complexity = ctx.session.state.get("complexity", "standard");

  // Make decisions based on state
  if (complexity === "high") {
    yield new Event({
      author: this.name,
      content: { parts: [{ text: "⚙️ Using advanced processing..." }] },
    });
  }

  // Set state for downstream agents
  ctx.session.state.set("processing_stage", "validation");

  // Use EventActions for auditable state updates
  yield new Event({
    author: this.name,
    content: { parts: [{ text: "✅ Processing started" }] },
    actions: new EventActions({
      stateDelta: { processing_started: true },
    }),
  });
}

3. Custom Control Flow

Implement conditional logic and retries within runAsyncImpl:

protected async *runAsyncImpl(
  ctx: InvocationContext
): AsyncGenerator<Event, void, unknown> {
  // Call sub-agents and forward their events
  for await (const event of this.processor.runAsync(ctx)) {
    yield event;
  }
  for await (const event of this.validator.runAsync(ctx)) {
    yield event;
  }
  // Check validation result and implement retry logic
  const validationResult = ctx.session.state.get("validation_result");
  if (validationResult === "invalid") {
    yield new Event({
      author: this.name,
      content: { parts: [{ text: "❌ Validation failed, retrying..." }] },
    });
    // Retry logic - call processor again
    for await (const event of this.processor.runAsync(ctx)) {
      yield event;
    }
  }
}

4. Template Variables

Sub-agents access session state dynamically through template variables in their instructions:

this.processor = new LlmAgent({
  name: "content_processor_agent",
  model: "gemini-2.5-flash",
  description: "Processes user input to extract and format key information.",
  instruction: "Process the input: {user_input}", // Using template variable
  outputKey: "processed_content",
});

this.validator = new LlmAgent({
  name: "content_validator_agent",
  model: "gemini-2.5-flash",
  description:
    "Validates the processed content and determines its validity status.",
  instruction: "Validate: {processed_content}. Return 'valid' or 'invalid'", // Using template variable
  outputKey: "validation_result",
});

Each sub-agent receives values like {user_input} and {processed_content} from session state automatically.

Real-World Example: Content Quality Agent

A complete custom agent that processes content through validation, quality checks, and conditional refinement with retry logic.

import {
  BaseAgent,
  LlmAgent,
  LoopAgent,
  InvocationContext,
  Event,
  EventActions,
} from "@iqai/adk";

export class ContentQualityAgent extends BaseAgent {
  private processor: LlmAgent;
  private validator: LlmAgent;
  private critic: LlmAgent;
  private reviser: LlmAgent;
  private qualityChecker: LlmAgent;
  private critiqueLoop: LoopAgent;

  constructor() {
    super({ name: "content_quality_agent" });

    this.processor = new LlmAgent({
      name: "content_processor_agent",
      model: "gemini-2.5-flash",
      description: "Processes and formats user content for quality enhancement",
      instruction: "Process and format: {user_input}",
      outputKey: "processed_content",
    });

    this.validator = new LlmAgent({
      name: "content_validator_agent",
      model: "gemini-2.5-flash",
      description: "Validates the processed content for required standards",
      instruction: "Validate: {processed_content}. Return 'valid' or 'invalid'",
      outputKey: "validation_result",
    });

    this.critic = new LlmAgent({
      name: "content_critic_agent",
      model: "gemini-2.5-flash",
      description: "Critiques the processed content and suggests improvements",
      instruction: "Critique: {processed_content}. Suggest improvements.",
      outputKey: "critique",
    });

    this.reviser = new LlmAgent({
      name: "content_reviser_agent",
      model: "gemini-2.5-flash",
      description:
        "Revises the processed content based on critique suggestions",
      instruction: "Revise: {processed_content} based on: {critique}",
      outputKey: "processed_content",
    });

    this.qualityChecker = new LlmAgent({
      name: "quality_checker_agent",
      model: "gemini-2.5-flash",
      description: "Checks the quality score of the processed content",
      instruction:
        "Rate quality 1-10: {processed_content}. Output only number.",
      outputKey: "quality_score",
    });

    this.critiqueLoop = new LoopAgent({
      name: "critique_loop_agent",
      description:
        "Iteratively critiques and revises content to enhance quality",
      maxIterations: 2,
      subAgents: [this.critic, this.reviser],
    });
  }

  protected async *runAsyncImpl(
    ctx: InvocationContext
  ): AsyncGenerator<Event, void, unknown> {
    const maxRetries = 2;
    let retryCount = 0;

    // Read user preferences from state
    const complexity = ctx.session.state.get("content_complexity", "standard");
    const qualityThreshold = ctx.session.state.get("quality_threshold", 7);

    while (retryCount <= maxRetries) {
      // Step 1: Conditional processing based on complexity
      if (complexity === "high") {
        yield new Event({
          author: this.name,
          content: { parts: [{ text: "⚙️ Using advanced processing..." }] },
        });
      }

      // Step 2: Process content
      for await (const event of this.processor.runAsync(ctx)) {
        yield event;
      }

      // Step 3: Validate processed content
      for await (const event of this.validator.runAsync(ctx)) {
        yield event;
      }

      const validationResult = ctx.session.state.get("validation_result");

      if (validationResult === "invalid") {
        retryCount++;
        if (retryCount <= maxRetries) {
          yield new Event({
            author: this.name,
            content: {
              parts: [
                {
                  text: `❌ Validation failed. Retry ${retryCount}/${maxRetries}`,
                },
              ],
            },
          });
          continue;
        } else {
          yield new Event({
            author: this.name,
            content: { parts: [{ text: "⚠️ Max retries reached" }] },
          });
          return;
        }
      }

      // Step 4: Quality enhancement loop
      for await (const event of this.critiqueLoop.runAsync(ctx)) {
        yield event;
      }

      // Step 5: Final quality check
      for await (const event of this.qualityChecker.runAsync(ctx)) {
        yield event;
      }

      const qualityScore = parseInt(
        ctx.session.state.get("quality_score", "0"),
        10
      );

      // Step 6: Decision based on quality
      if (qualityScore >= qualityThreshold) {
        yield new Event({
          author: this.name,
          content: {
            parts: [{ text: `✅ Quality approved! Score: ${qualityScore}` }],
          },
          actions: new EventActions({
            stateDelta: {
              processing_complete: true,
              final_quality_score: qualityScore,
            },
          }),
        });
        return;
      } else {
        retryCount++;
        if (retryCount <= maxRetries) {
          yield new Event({
            author: this.name,
            content: {
              parts: [
                {
                  text: `🔄 Quality below threshold. Regenerating (${retryCount}/${maxRetries})...`,
                },
              ],
            },
          });
        } else {
          yield new Event({
            author: this.name,
            content: {
              parts: [{ text: "⚠️ Max regeneration attempts reached" }],
            },
          });
          return;
        }
      }
    }
  }
}

What Makes This Custom:

  • State-driven decisions: Routes based on complexity and quality thresholds
  • Retry logic with limits: Handles validation failures with bounded retries
  • Mixed orchestration: Combines LoopAgent with custom conditional flow
  • Quality gates: Multi-stage validation before approval
  • Auditable state updates: Uses EventActions for transparency

Real-World Use Cases

📝 Multi-Perspective Content Analysis Sentiment + Topics + Keywords + Priority Assessment (all simultaneously)

🔍 Comprehensive Research Web Search + Academic Papers + News + Social Media (parallel data gathering)

📊 Financial Analysis Technical Analysis + Fundamental Analysis + News Sentiment + Risk Assessment

🛠️ Quality Assurance Grammar Check + Fact Verification + Style Review + Compliance Check

🌍 Multi-Language Processing Translation + Sentiment + Cultural Context + Localization (per language)

📊 Data Validation Format Check + Business Rules + Data Quality + Duplicate Detection

When to Use Custom Agents

Use custom agents when standard workflow patterns don't meet your needs:

  • Conditional Logic: Execute different sub-agents based on runtime conditions or results
  • Dynamic Agent Selection: Choose sub-agents dynamically based on evaluation or analysis
  • Complex State Management: Implement intricate state logic beyond simple sequential passing
  • External Integrations: Incorporate API calls, databases, or custom libraries in orchestration
  • Unique Workflow Patterns: Implement logic that doesn't fit sequential, parallel, or loop structures
  • Advanced Error Handling: Custom retry logic, fallback strategies, and recovery mechanisms

Common Custom Agent Patterns

Routing Agent Pattern

Dynamically route requests to specialized sub-agents based on runtime analysis. This pattern evaluates input complexity, user tier, or content characteristics to select the optimal processing path.

Key Features:

  • Input complexity analysis
  • Multi-tier processing logic
  • Dynamic agent selection based on multiple criteria
  • Conditional branching without nested if-else chains

Use Cases: Customer support ticket routing, content processing tiers, complexity-based workflows, resource optimization

import { BaseAgent, LlmAgent, InvocationContext, Event } from "@iqai/adk";

export class IntelligentRouterAgent extends BaseAgent {
  private basicProcessor: LlmAgent;
  private advancedProcessor: LlmAgent;
  private expertProcessor: LlmAgent;

  constructor() {
    super({ name: "intelligent_router_agent" });

    this.basicProcessor = new LlmAgent({
      name: "basic_processor_agent",
      model: "gemini-2.5-flash",
      description: "Handles basic processing tasks",
      instruction: "Handle with basic processing: {user_input}",
      outputKey: "result",
    });

    this.advancedProcessor = new LlmAgent({
      name: "advanced_processor_agent",
      model: "gemini-2.5-flash",
      description: "Handles advanced processing tasks",
      instruction: "Apply advanced analysis: {user_input}",
      outputKey: "result",
    });

    this.expertProcessor = new LlmAgent({
      name: "expert_processor_agent",
      model: "gemini-2.5-flash",
      description: "Handles expert-level processing tasks",
      instruction: "Provide expert-level analysis: {user_input}",
      outputKey: "result",
    });
  }

  protected async *runAsyncImpl(
    ctx: InvocationContext
  ): AsyncGenerator<Event, void, unknown> {
    // Analyze input to determine routing
    const complexity = this.analyzeComplexity(
      ctx.session.state.get("user_input", "")
    );
    const userTier = ctx.session.state.get("user_tier", "standard");

    yield new Event({
      author: this.name,
      content: {
        parts: [
          {
            text: `📊 Analyzing: Complexity ${complexity.toFixed(
              2
            )}, Tier ${userTier}`,
          },
        ],
      },
    });

    // Route based on multiple criteria
    let selectedAgent;
    let processingLevel;

    if (complexity > 0.8 && userTier === "premium") {
      selectedAgent = this.expertProcessor;
      processingLevel = "expert";
    } else if (complexity > 0.5 || userTier === "professional") {
      selectedAgent = this.advancedProcessor;
      processingLevel = "advanced";
    } else {
      selectedAgent = this.basicProcessor;
      processingLevel = "basic";
    }

    yield new Event({
      author: this.name,
      content: {
        parts: [{ text: `🎯 Routing to ${processingLevel} processor` }],
      },
    });

    // Execute selected agent
    for await (const event of selectedAgent.runAsync(ctx)) {
      yield event;
    }
  }

  private analyzeComplexity(input: string): number {
    const factors = {
      length: Math.min(input.length / 1000, 1),
      technicalTerms:
        (input.match(/\b(API|database|algorithm|framework)\b/gi) || []).length /
        10,
      questionComplexity: (input.match(/\?/g) || []).length / 5,
    };

    return Math.min(
      (factors.length + factors.technicalTerms + factors.questionComplexity) /
        3,
      1
    );
  }
}

Retry with Backoff Pattern

Implement sophisticated retry logic with exponential backoff, different retry strategies per failure type, and graceful fallback when all retries are exhausted. This pattern goes beyond basic retries by adding timing strategies and fallback agents.

Key Features:

  • Exponential backoff with configurable timing
  • Attempt tracking and limits
  • Fallback strategy when primary fails
  • Graceful degradation

Use Cases: External API integration, unreliable services, quality assurance with fallback options, resilient processing

import { BaseAgent, LlmAgent, InvocationContext, Event } from "@iqai/adk";

export class ResilientProcessorAgent extends BaseAgent {
  private primaryAgent: LlmAgent;
  private fallbackAgent: LlmAgent;

  constructor() {
    super({ name: "resilient_processor_agent" });

    this.primaryAgent = new LlmAgent({
      name: "primary_processor_agent",
      model: "gemini-2.5-flash",
      description: "Primary processor with high accuracy",
      instruction: "Process with high accuracy: {user_input}",
      outputKey: "result",
    });

    this.fallbackAgent = new LlmAgent({
      name: "fallback_processor_agent",
      model: "gemini-2.5-flash",
      description: "Fallback processor with robust handling",
      instruction: "Process with fallback method: {user_input}",
      outputKey: "result",
    });
  }

  protected async *runAsyncImpl(
    ctx: InvocationContext
  ): AsyncGenerator<Event, void, unknown> {
    const maxRetries = 3;
    let attempt = 0;
    let backoffMs = 1000; // Start with 1 second

    while (attempt < maxRetries) {
      try {
        yield new Event({
          author: this.name,
          content: {
            parts: [{ text: `🔄 Attempt ${attempt + 1}/${maxRetries}` }],
          },
        });

        // Try primary processing
        for await (const event of this.primaryAgent.runAsync(ctx)) {
          yield event;
        }

        // Success - exit retry loop
        yield new Event({
          author: this.name,
          content: { parts: [{ text: "✅ Processing successful" }] },
        });
        return;
      } catch (error) {
        attempt++;
        const errorMessage = (error as Error).message;

        yield new Event({
          author: this.name,
          content: {
            parts: [{ text: `❌ Attempt ${attempt} failed: ${errorMessage}` }],
          },
        });

        if (attempt < maxRetries) {
          yield new Event({
            author: this.name,
            content: {
              parts: [{ text: `⏳ Retrying in ${backoffMs}ms...` }],
            },
          });

          await new Promise((resolve) => setTimeout(resolve, backoffMs));
          backoffMs *= 2; // Exponential backoff
        }
      }
    }

    // All retries failed - use fallback strategy
    yield new Event({
      author: this.name,
      content: {
        parts: [{ text: "🔄 All retries exhausted, using fallback processor" }],
      },
    });

    for await (const event of this.fallbackAgent.runAsync(ctx)) {
      yield event;
    }
  }
}

State Machine Pattern

Implement workflows with explicit state transitions, validation rules, and conditional branching based on current state. This pattern defines valid transitions between states and enforces workflow rules throughout execution.

Key Features:

  • Explicit state transition validation
  • State-to-state mapping with allowed transitions
  • Loop-back capabilities for retries
  • Clear workflow visualization

Use Cases: Approval workflows, multi-stage processes, complex business logic with validation, document processing pipelines

import { BaseAgent, InvocationContext, Event } from "@iqai/adk";

type WorkflowState =
  | "initialized"
  | "analyzing"
  | "processing"
  | "validating"
  | "completed"
  | "failed";

export class WorkflowStateMachineAgent extends BaseAgent {
  private readonly stateTransitions: Record<WorkflowState, WorkflowState[]> = {
    initialized: ["analyzing", "failed"],
    analyzing: ["processing", "failed"],
    processing: ["validating", "analyzing", "failed"],
    validating: ["completed", "processing", "failed"],
    completed: [],
    failed: ["initialized"],
  };

  constructor() {
    super({ name: "workflow_state_machine_agent" });
  }

  protected async *runAsyncImpl(
    ctx: InvocationContext
  ): AsyncGenerator<Event, void, unknown> {
    let currentState: WorkflowState = ctx.session.state.get(
      "workflow_state",
      "initialized"
    );

    while (currentState !== "completed" && currentState !== "failed") {
      yield new Event({
        author: this.name,
        content: { parts: [{ text: `🔄 State: ${currentState}` }] },
      });

      const nextState = await this.executeStateLogic(currentState, ctx);

      // Validate state transition
      if (!this.isValidTransition(currentState, nextState)) {
        yield new Event({
          author: this.name,
          content: {
            parts: [
              {
                text: `❌ Invalid transition: ${currentState} → ${nextState}`,
              },
            ],
          },
        });
        currentState = "failed";
        break;
      }

      // Update state and continue
      yield new Event({
        author: this.name,
        content: {
          parts: [{ text: `➡️ Transition: ${currentState} → ${nextState}` }],
        },
      });

      currentState = nextState;
      ctx.session.state.set("workflow_state", currentState);
      ctx.session.state.set("state_transition_time", new Date().toISOString());
    }

    yield new Event({
      author: this.name,
      content: { parts: [{ text: `🏁 Final state: ${currentState}` }] },
    });
  }

  private async executeStateLogic(
    state: WorkflowState,
    ctx: InvocationContext
  ): Promise<WorkflowState> {
    switch (state) {
      case "initialized":
        return this.canStartAnalysis(ctx) ? "analyzing" : "failed";
      case "analyzing":
        return await this.performAnalysis(ctx);
      case "processing":
        return await this.performProcessing(ctx);
      case "validating":
        return await this.performValidation(ctx);
      default:
        return "failed";
    }
  }

  private isValidTransition(from: WorkflowState, to: WorkflowState): boolean {
    return this.stateTransitions[from].includes(to);
  }

  private canStartAnalysis(ctx: InvocationContext): boolean {
    return !!ctx.session.state.get("user_input", "");
  }

  private async performAnalysis(
    ctx: InvocationContext
  ): Promise<WorkflowState> {
    // Analysis logic here
    return "processing";
  }

  private async performProcessing(
    ctx: InvocationContext
  ): Promise<WorkflowState> {
    // Processing logic here
    const processingSuccess = Math.random() > 0.3; // 70% success rate
    return processingSuccess ? "validating" : "analyzing";
  }

  private async performValidation(
    ctx: InvocationContext
  ): Promise<WorkflowState> {
    // Validation logic here
    const validationSuccess = Math.random() > 0.2; // 80% success rate
    return validationSuccess ? "completed" : "processing";
  }
}

Best Practices

Design Principles

Single Responsibility: Design each custom agent with a focused, specific orchestration purpose. A content workflow agent should handle content generation and validation, while a routing agent should focus on request analysis and delegation. This focused approach makes agents easier to test, debug, and reuse across different workflows.

Clear State Contracts: Use descriptive, unique state keys (user_preferences, analysis_results, validation_status) and document what data flows between agents. This prevents conflicts and makes debugging much easier when agents don't receive expected data.

Predictable Logic: Make your conditional logic easy to understand and test. Use clear variable names, document decision points, and structure your code so that execution paths are obvious. Avoid deeply nested conditions that obscure the flow.

Efficient Execution: Skip unnecessary work and provide meaningful progress feedback. Check preconditions early, exit fast when possible, and yield progress events during long operations to keep users informed.

Communication Patterns

Progress Events: Provide regular feedback during long-running operations. Users need to understand what's happening, especially for complex workflows that may take time to complete.

State Updates: Use EventActions to update session state for better auditability and transparency. This creates a clear audit trail of state changes throughout the workflow.

Development Tips

Start Simple: Begin with sequential workflows before moving to complex conditional logic. Add decision points gradually as you understand the agent interactions and state flow requirements.

Test Isolation: Test individual agents independently before testing the complete custom workflow. Mock external dependencies and sub-agents during unit testing to isolate orchestration logic.

State Debugging: Use clear state key names and log state transitions to make debugging easier when agents don't receive expected data or make wrong decisions.

How is this guide?