Skip to main content

What’s an Agent?

An agent is a reusable unit of work with:
  • Inputs: Parameters it accepts
  • Operation: What it does (code, think, http, storage, etc.)
  • Outputs: Data it returns
Agents are automatically discovered from the agents/ directory at build time (v1.12+) and can be used across multiple ensembles.

Explore the Template Agent

Your project already includes a working agent! Let’s explore agents/examples/hello/:
agents/examples/hello/
├── agent.yaml      # Agent configuration
└── index.ts        # Agent implementation

agent.yaml

name: hello
operation: code
description: Simple greeting function

schema:
  input:
    name: string
    style: string?
  output:
    message: string
This declares:
  • Operation type: code (runs JavaScript/TypeScript)
  • Input schema: Accepts name (required) and style (optional)
  • Output schema: Returns a message string

index.ts

import type { AgentExecutionContext } from '@ensemble-edge/conductor';

export default function hello({ input }: AgentExecutionContext) {
  const { name, style } = input as { name: string; style?: string };

  const styles = {
    formal: `Good day, ${name}. It is a pleasure to make your acquaintance.`,
    casual: `Hey ${name}! What's up?`,
    enthusiastic: `OMG ${name}! SO EXCITED TO MEET YOU!!!`
  };

  const message = style && style in styles
    ? styles[style as keyof typeof styles]
    : `Hello, ${name}! Welcome to Conductor.`;

  return { message };
}
Note the signature: Agents use AgentExecutionContext which provides:
  • input - Your agent’s parameters
  • env - Cloudflare bindings (KV, D1, AI, etc.)
  • ctx - ExecutionContext (waitUntil, etc.)
This signature works everywhere: direct calls, ensembles, and tests.

Understanding Operation Types

Agents use different operations based on what they need to do:

operation: code

When to use: Run custom TypeScript/JavaScript logic Requires: Function implementation in index.ts API keys needed: ❌ No
name: greeter
operation: code
description: Custom greeting logic

schema:
  input:
    name: string
  output:
    greeting: string

operation: think

When to use: Call LLM models for reasoning, analysis, generation Requires: Provider and model configuration API keys needed: ✅ Yes (OpenAI, Anthropic) or Cloudflare Workers AI
name: analyzer
operation: think
description: Analyze text with AI

config:
  provider: anthropic
  model: claude-3-5-sonnet-20241022
  prompt: |
    Analyze this text: ${input.text}
    Provide key insights.

schema:
  input:
    text: string
  output:
    analysis: string

operation: http

When to use: Make HTTP requests to external APIs Requires: URL and method configuration API keys needed: Depends on API
name: fetcher
operation: http
description: Fetch data from API

config:
  url: https://api.example.com/data
  method: GET
  headers:
    Authorization: Bearer ${env.API_KEY}

schema:
  output:
    data: object

operation: data

When to use: Query databases (KV, D1, R2) Requires: Database binding in wrangler.toml API keys needed: ❌ No (uses Cloudflare bindings)
name: db-query
operation: data
description: Query D1 database

config:
  backend: d1
  binding: DB
  operation: query
  sql: SELECT * FROM users WHERE id = ?
  params:
    - ${input.user_id}

schema:
  input:
    user_id: string
  output:
    user: object
See all operations

Critical: Agent Signatures for Ensembles

All agents MUST use the AgentExecutionContext signature to work in ensembles!

The Correct Pattern ✅

import type { AgentExecutionContext } from '@ensemble-edge/conductor';

interface MyAgentInput {
  param1: string;
  param2: number;
}

interface MyAgentOutput {
  result: string;
}

export default function myAgent({ input, env, ctx }: AgentExecutionContext): MyAgentOutput {
  // Destructure your parameters from input
  const { param1, param2 } = input as MyAgentInput;

  // Your logic here
  const result = `Processed: ${param1} with ${param2}`;

  return { result };
}

Why This Signature?

When called through an ensemble, Conductor wraps your parameters:
// Ensemble passes:
{
  input: { param1: 'hello', param2: 42 },  // Your parameters
  env: { /* Cloudflare bindings */ },       // KV, D1, AI, etc.
  ctx: { /* ExecutionContext */ }           // waitUntil, etc.
}
Benefits:
  • ✅ Works in ensembles (orchestrated workflows)
  • ✅ Works with direct calls
  • ✅ Works in tests
  • ✅ Access to Cloudflare bindings (env)
  • ✅ Access to ExecutionContext (ctx)

Wrong Pattern (Don’t Do This) ❌

// ❌ DOESN'T WORK IN ENSEMBLES
export default function myAgent({ param1, param2 }: MyInput) {
  return { result: param1 + param2 };
}
This only works for direct function calls, but fails in ensembles because the parameters are wrapped in input.

Using env and ctx

The signature gives you access to powerful features:
export default async function myAgent({ input, env, ctx }: AgentExecutionContext) {
  const { query } = input as { query: string };

  // Access KV storage
  const cached = await env.KV.get(query);
  if (cached) return JSON.parse(cached);

  // Use AI binding
  const result = await env.AI.run('@cf/meta/llama-3.1-8b-instruct', {
    prompt: query
  });

  // Schedule background work
  ctx.waitUntil(env.KV.put(query, JSON.stringify(result)));

  return { result };
}
Quick Rule: Always use AgentExecutionContext signature. It’s the only pattern that works everywhere!

Test the Hello Agent

The template includes working tests. Let’s look at tests/basic.test.ts:
import { describe, it, expect } from 'vitest';
import { Executor, MemberLoader } from '@ensemble-edge/conductor';
import { stringify } from 'yaml';
import helloWorldYAML from '../ensembles/hello-world.yaml';
import greetConfig from '../agents/examples/hello/agent.yaml';
import greetFunction from '../agents/hello';

describe('Hello Agent Test', () => {
  it('should execute successfully', async () => {
    // Setup with proper ExecutionContext mock
    const env = {} as Env;
    const ctx = {
      waitUntil: (promise: Promise<any>) => promise,
      passThroughOnException: () => {}
    } as ExecutionContext;

    const executor = new Executor({ env, ctx });
    const loader = new MemberLoader({ env, ctx });

    // Register hello agent
    const greetMember = loader.registerAgent(greetConfig, greetFunction);
    executor.registerAgent(greetMember);

    // Execute the ensemble
    const result = await executor.executeFromYAML(
      stringify(helloWorldYAML),
      { name: 'World' }
    );

    // Verify result
    expect(result.success).toBe(true);
    expect(result.value.output.greeting).toContain('Hello');
  });
});
Run it:
pnpm test
All tests should pass! ✅

Create Your First Custom Agent

Now that you understand how agents work, let’s create a new one.

Step 1: Create Agent Directory

mkdir -p agents/user/greeter

Step 2: Define the Agent

Create agents/user/greeter/agent.yaml:
name: greeter
operation: code
description: Generates personalized greetings with different styles

schema:
  input:
    name: string
    time_of_day: string?
    language: string?
  output:
    greeting: string
    timestamp: number

Step 3: Implement the Agent

Create agents/user/greeter/index.ts:
import type { AgentExecutionContext } from '@ensemble-edge/conductor';

export default function greeter({ input }: AgentExecutionContext) {
  const {
    name,
    time_of_day = 'day',
    language = 'en'
  } = input as {
    name: string;
    time_of_day?: string;
    language?: string;
  };

  const greetings: Record<string, Record<string, string>> = {
    en: {
      morning: `Good morning, ${name}!`,
      afternoon: `Good afternoon, ${name}!`,
      evening: `Good evening, ${name}!`,
      day: `Hello, ${name}!`
    },
    es: {
      morning: `Buenos días, ${name}!`,
      afternoon: `Buenas tardes, ${name}!`,
      evening: `Buenas noches, ${name}!`,
      day: `Hola, ${name}!`
    },
    fr: {
      morning: `Bonjour, ${name}!`,
      afternoon: `Bon après-midi, ${name}!`,
      evening: `Bonsoir, ${name}!`,
      day: `Bonjour, ${name}!`
    }
  };

  const langGreetings = greetings[language] || greetings.en;
  const greeting = langGreetings[time_of_day] || langGreetings.day;

  return {
    greeting,
    timestamp: Date.now()
  };
}

Step 4: Rebuild

Agents are auto-discovered at build time:
pnpm run build

Step 5: Use Your Agent

Create ensembles/greeting-workflow.yaml:
name: greeting-workflow
description: Generate personalized greetings

trigger:
  - type: cli
    command: greet

agents:
  - name: greet
    operation: code
    config:
      handler: greeter  # Reference your new agent

flow:
  - agent: greet

output:
  greeting: ${greet.output.greeting}
  timestamp: ${greet.output.timestamp}

Step 6: Test It

Create tests/greeter.test.ts:
import { describe, it, expect } from 'vitest';
import { Executor, MemberLoader } from '@ensemble-edge/conductor';
import { stringify } from 'yaml';
import greetingWorkflow from '../ensembles/greeting-workflow.yaml';
import greeterConfig from '../agents/user/greeter/agent.yaml';
import greeterFunction from '../agents/user/greeter';

describe('Greeter Agent', () => {
  it('should generate greetings in different languages', async () => {
    const env = {} as Env;
    const ctx = {
      waitUntil: (promise: Promise<any>) => promise,
      passThroughOnException: () => {}
    } as ExecutionContext;

    const executor = new Executor({ env, ctx });
    const loader = new MemberLoader({ env, ctx });

    const greeterMember = loader.registerAgent(greeterConfig, greeterFunction);
    executor.registerAgent(greeterMember);

    const result = await executor.executeFromYAML(
      stringify(greetingWorkflow),
      { name: 'Alice', time_of_day: 'morning', language: 'es' }
    );

    expect(result.success).toBe(true);
    expect(result.value.output.greeting).toBe('Buenos días, Alice!');
  });
});
Run: pnpm test

Agent with AI (operation: think)

Let’s create an agent that uses AI for more complex logic.

Create AI Analyzer Agent

Create agents/user/analyzer/agent.yaml:
name: analyzer
operation: think
description: Analyzes text sentiment and extracts key themes

config:
  provider: anthropic
  model: claude-3-5-sonnet-20241022
  prompt: |
    Analyze the following text:

    ${input.text}

    Provide a JSON response with:
    - sentiment: positive, negative, or neutral
    - confidence: 0-1 score
    - themes: array of key themes
    - summary: one sentence summary

    Return only valid JSON.

schema:
  input:
    text: string
  output:
    sentiment: string
    confidence: number
    themes: array
    summary: string
Note: This requires an Anthropic API key in your environment.

Use the Analyzer

Create ensembles/analyze-text.yaml:
name: analyze-text
description: Analyze text with AI

trigger:
  - type: cli
    command: analyze

agents:
  - name: analyze
    operation: think
    config:
      provider: anthropic
      model: claude-3-5-sonnet-20241022
      prompt: |
        Analyze: ${input.text}

flow:
  - agent: analyze

output:
  analysis: ${analyze.output}

Auto-Discovery (v1.12+)

Zero-Config Agent Loading: Agents in the agents/ directory are automatically discovered at build time and registered with your application.

How It Works

  1. Build-Time Discovery: Vite plugins scan agents/**/*.yaml during build
  2. Virtual Modules: Agent configs and handlers are bundled into virtual:conductor-agents
  3. Runtime Registration: MemberLoader.autoDiscover() loads all discovered agents automatically

Creating a New Agent

Just create the files - no imports or registration needed:
  1. Create the directory: agents/user/my-agent/
  2. Add agent.yaml (required)
  3. Add index.ts (optional, for operation: code)
  4. Rebuild: pnpm run build
  5. Done! Your agent is now available at /api/v1/execute/agent/{name}

Using Auto-Discovered Agents

With the auto-discovery API (recommended):
// src/index.ts
import { createAutoDiscoveryAPI } from '@ensemble-edge/conductor/api'
import { agents } from 'virtual:conductor-agents'
import { ensembles } from 'virtual:conductor-ensembles'

export default createAutoDiscoveryAPI({
  agents,  // All agents auto-discovered
  ensembles,  // All ensembles auto-discovered
  autoDiscover: true,
})
That’s it! No manual imports. No registration code. Just create YAML files.

Verification

List all discovered agents:
# After build
curl http://localhost:8787/api/v1/agents

# Returns:
{
  "agents": [
    { "name": "hello", "operation": "code" },
    { "name": "greeter", "operation": "code" },
    { "name": "analyzer", "operation": "think" }
  ]
}

Testing with Manual Registration

Note: In unit tests, you can still use manual registration for clarity:
import { MemberLoader } from '@ensemble-edge/conductor'
import greetConfig from '../agents/user/greeter/agent.yaml'
import greetFunction from '../agents/user/greeter'

const loader = new MemberLoader({ env, ctx })
loader.registerAgent(greetConfig, greetFunction)
This is fine for tests! Manual registration is supported alongside auto-discovery.

Migration from v1.11

If you have existing manual registration code in your entry point: Before (v1.11):
import greetConfig from './agents/user/greet/agent.yaml'
import greetFunction from './agents/user/greet/index.ts'
// ... 50 more imports ...

const loader = new MemberLoader({ env, ctx })
loader.registerAgent(greetConfig, greetFunction)
// ... 50 more registrations ...
After (v1.12):
import { createAutoDiscoveryAPI } from '@ensemble-edge/conductor/api'
import { agents } from 'virtual:conductor-agents'
import { ensembles } from 'virtual:conductor-ensembles'

export default createAutoDiscoveryAPI({
  agents,
  ensembles,
  autoDiscover: true,
})
Saves 400+ lines of boilerplate! See the Auto-Discovery guide for complete details.

Agent Patterns

Pattern 1: Simple Code Agent

Pure logic, no external dependencies:
// agents/user/calculator/index.ts
import type { AgentExecutionContext } from '@ensemble-edge/conductor';

export default function calculator({ input }: AgentExecutionContext) {
  const { operation, a, b } = input as {
    operation: string;
    a: number;
    b: number;
  };

  const ops: Record<string, number> = {
    add: a + b,
    subtract: a - b,
    multiply: a * b,
    divide: b !== 0 ? a / b : 0
  };

  return {
    result: ops[operation] || 0
  };
}

Pattern 2: HTTP Data Fetcher

Fetch from external APIs:
name: weather-fetcher
operation: http
description: Fetch weather data

config:
  url: https://api.weather.com/current?city=${input.city}
  method: GET
  headers:
    Authorization: Bearer ${env.WEATHER_API_KEY}
  cache:
    ttl: 1800  # Cache for 30 minutes

schema:
  input:
    city: string
  output:
    temperature: number
    conditions: string

Pattern 3: Database Query

Query Cloudflare D1:
name: user-lookup
operation: data
description: Look up user by email

config:
  backend: d1
  binding: DB
  operation: query
  sql: |
    SELECT id, name, email, created_at
    FROM users
    WHERE email = ?
    LIMIT 1
  params:
    - ${input.email}

schema:
  input:
    email: string
  output:
    user: object?

Pattern 4: AI with Custom Logic

Combine AI with code:
name: smart-responder
operation: think
description: Generate contextual responses

config:
  provider: anthropic
  model: claude-3-5-sonnet-20241022
  prompt: |
    User message: ${input.message}
    User history: ${input.history}

    Generate a helpful response that:
    1. Acknowledges their message
    2. References their history
    3. Provides actionable next steps

    Keep it under 50 words.

schema:
  input:
    message: string
    history: array
  output:
    response: string

Best Practices

1. Keep Agents Focused

Each agent should do ONE thing well:
  • user-validator - Validates user data
  • user-handler - Validates, stores, sends email, logs (too much!)

2. Use Descriptive Names

  • email-sender, pdf-extractor, sentiment-analyzer
  • helper, utils, processor

3. Document Inputs/Outputs

Always define schemas:
schema:
  input:
    user_id: string  # UUID of the user
    include_metadata: boolean  # Whether to include extra fields
  output:
    user: object  # User record with all fields
    metadata: object?  # Additional metadata if requested

4. Handle Errors Gracefully

import type { AgentExecutionContext } from '@ensemble-edge/conductor';

export default function myAgent({ input }: AgentExecutionContext) {
  const { data } = input as { data: string };

  try {
    // Your logic
    const result = processData(data);
    return { success: true, result };
  } catch (error) {
    return {
      success: false,
      error: error instanceof Error ? error.message : 'Unknown error'
    };
  }
}

5. Use Caching for HTTP Agents

config:
  url: https://api.example.com/data
  cache:
    ttl: 3600  # Cache for 1 hour
Reduces API calls and improves performance!

6. Test Your Agents

Always write tests for custom agents:
describe('My Agent', () => {
  it('should handle valid input', async () => {
    // Test success case
  });

  it('should handle invalid input', async () => {
    // Test error case
  });

  it('should respect timeout', async () => {
    // Test performance
  });
});

Troubleshooting

Problem: Created a new agent but it’s not availableFix: Rebuild to trigger auto-discovery:
pnpm run build
Agents are discovered at build time, not runtime.
Problem: TypeError: this.ctx.waitUntil is not a functionFix: Use proper ExecutionContext mock:
const ctx = {
  waitUntil: (promise: Promise<any>) => promise,
  passThroughOnException: () => {}
} as ExecutionContext;
Problem: operation: think fails with authentication errorFix: Add API key to wrangler.toml or environment:
[vars]
ANTHROPIC_API_KEY = "sk-ant-..."
Or use Cloudflare Workers AI (no key needed):
config:
  provider: cloudflare
  model: '@cf/meta/llama-3.1-8b-instruct'
Problem: Agent works when called directly but fails in ensemblesCause: Agent not using AgentExecutionContext signatureFix: Update agent signature:
import type { AgentExecutionContext } from '@ensemble-edge/conductor';

export default function myAgent({ input, env, ctx }: AgentExecutionContext) {
  const { params } = input as MyInput;
  // Your logic...
}
See Agent Signatures section above.
Problem: Want to use an operation that doesn’t existFix: Use operation: code and implement in TypeScript:
import type { AgentExecutionContext } from '@ensemble-edge/conductor';

export default async function myAgent({ input, env, ctx }: AgentExecutionContext) {
  const { data } = input as { data: string };
  // Your custom logic here
  const result = await customOperation(data);
  return { result };
}
Code operations can do anything TypeScript can do!

TypeScript Agent Handlers

Every agent with operation: code needs a TypeScript handler. Here’s everything you need to know about writing effective handlers.

Handler Structure

agents/user/my-agent/
├── agent.yaml      # Agent configuration (declares operation: code)
└── index.ts        # TypeScript handler implementation

The AgentExecutionContext

All handlers receive the same context object:
import type { AgentExecutionContext } from '@ensemble-edge/conductor'

interface AgentExecutionContext {
  input: Record<string, unknown>  // Input parameters
  env: ConductorEnv               // Cloudflare bindings (KV, D1, AI, etc.)
  ctx: ExecutionContext           // Cloudflare execution context
}

Handler Patterns

Simple Synchronous Handler:
import type { AgentExecutionContext } from '@ensemble-edge/conductor'

export default function greet({ input }: AgentExecutionContext) {
  const { name } = input as { name: string }
  return { message: `Hello, ${name}!` }
}
Async Handler with External APIs:
import type { AgentExecutionContext } from '@ensemble-edge/conductor'

export default async function fetchData({ input, env }: AgentExecutionContext) {
  const { url } = input as { url: string }

  const response = await fetch(url, {
    headers: { 'Authorization': `Bearer ${env.API_KEY}` }
  })

  const data = await response.json()
  return { data, status: response.status }
}
Handler with Cloudflare Bindings:
import type { AgentExecutionContext } from '@ensemble-edge/conductor'

export default async function cacheData({ input, env }: AgentExecutionContext) {
  const { key, value } = input as { key: string; value: string }

  // Use KV binding
  await env.CACHE.put(key, value, { expirationTtl: 3600 })

  // Use D1 database
  const result = await env.DB.prepare(
    'INSERT INTO cache_log (key, timestamp) VALUES (?, ?)'
  ).bind(key, Date.now()).run()

  return { cached: true, dbResult: result }
}

Using Agents in TypeScript Ensembles

Once you have YAML agents with TypeScript handlers, you can reference them in TypeScript ensembles:
// ensembles/data-pipeline.ts
import { createEnsemble, step } from '@ensemble-edge/conductor'

const dataPipeline = createEnsemble('data-pipeline')
  .trigger({ type: 'cli', command: 'pipeline' })
  .addStep(
    step('fetch')
      .agent('fetcher')  // References agents/user/fetcher/agent.yaml
      .input({ url: '${input.sourceUrl}' })
  )
  .addStep(
    step('process')
      .agent('data-processor')  // References agents/user/data-processor/agent.yaml
      .input({ data: '${fetch.output.data}' })
  )
  .addStep(
    step('store')
      .agent('cache-writer')
      .input({
        key: '${input.cacheKey}',
        value: '${process.output.result}'
      })
  )
  .build()

export default dataPipeline

Validating Agents

Validate your agent configurations:
# Validate a single agent
ensemble conductor validate agents/user/my-agent/agent.yaml

# Validate all agents recursively
ensemble conductor validate agents/ -r

Next Steps

Advanced: Versioning with Edgit (Optional)

If you want component-level versioning, you can use Edgit:
# Add agent to Edgit
edgit components add my-agent agents/user/my-agent/ agent

# Tag a version
edgit tag create my-agent v1.0.0

# Reference versioned agent
agents:
  - name: processor
    agent: [email protected]  # Specific version via Edgit
Note: Edgit is optional. Standard git version control works great!
Learn more about Edgit