Skip to main content

AI & Agents

11 min read

Agent Orchestrator

Orchestrate AI agents with guardrails, approvals, and budget control.

The orchestrator is LLM-agnostic – provide any runner function that accepts an agent and input, and Directive handles safety, approvals, and state tracking. Works with OpenAI, Anthropic, Ollama, or your own backend.


Setup

The createAgentOrchestrator function wraps your agent run function with Directive's constraint engine, adding guardrails, approval workflows, and observability:

import {
  createAgentOrchestrator,
  createPIIGuardrail,
} from '@directive-run/ai';
import type { AgentLike, AgentRunner } from '@directive-run/ai';

// Describe what the agent does and which model it uses
const agent: AgentLike = {
  name: 'assistant',
  instructions: 'You are a helpful assistant.',
  model: 'gpt-4',
};

// Wrap your LLM SDK in a standard runner function
const runner: AgentRunner = async (agent, input, options) => {
  const result = await myLLMCall(agent, input, options);

  return result;
};

// Wire the runner into an orchestrator with safety and state tracking
const orchestrator = createAgentOrchestrator({
  runner,
  autoApproveToolCalls: true,
});

Running an Agent

Run an agent through the orchestrator. All guardrails, approval checks, and state tracking happen automatically:

const result = await orchestrator.run<string>(agent, 'What is WebAssembly?');

// Inspect what the agent returned
console.log(result.output);       // The agent's response
console.log(result.totalTokens);  // Token usage
console.log(result.messages);     // Full conversation
console.log(result.toolCalls);    // Any tools called

The orchestrator tracks state internally. Check it anytime:

// Read live orchestrator state between runs
console.log(orchestrator.facts.agent.status);       // 'idle' | 'running' | 'paused' | 'completed' | 'error'
console.log(orchestrator.facts.agent.tokenUsage);   // Cumulative tokens across all runs
console.log(orchestrator.facts.agent.turnCount);     // Total message count
console.log(orchestrator.facts.conversation);        // Full conversation history

Guardrails

Validate inputs, outputs, and tool calls before they execute. See Guardrails & Safety for the full API, built-in guardrails, and streaming constraints.

import {
  createAgentOrchestrator,
  createPIIGuardrail,
  createToolGuardrail,
} from '@directive-run/ai';

const orchestrator = createAgentOrchestrator({
  runner,
  autoApproveToolCalls: true,
  guardrails: {
    // Scrub personal data from user input before it reaches the agent
    input: [createPIIGuardrail({ redact: true })],

    // Prevent the agent from calling dangerous tools
    toolCall: [createToolGuardrail({ denylist: ['shell', 'eval'] })],
  },
});

Approval Workflow

Require human approval before tool calls execute. When the agent wants to call a tool, the orchestrator pauses the run, fires your callback, and waits for you to call approve() or reject() before continuing.

Here's the flow:

  1. Agent run hits a tool call
  2. Orchestrator pauses and fires onApprovalRequest with a request object
  3. Your code forwards that request to a human (UI, Slack, email, etc.)
  4. The human decides – your code calls orchestrator.approve(id) or orchestrator.reject(id)
  5. The agent run resumes (or fails if rejected/timed out)

Express API Example

A common pattern is exposing approval over a REST API. The orchestrator fires the callback, you store the pending request, and a separate endpoint handles the human's decision:

import express from 'express';

const app = express();
app.use(express.json());

const orchestrator = createAgentOrchestrator({
  runner,
  autoApproveToolCalls: false,
  approvalTimeoutMs: 60000,

  // Step 1: Orchestrator pauses here and fires this callback
  onApprovalRequest: (request) => {
    // request.id    – unique ID for this approval (pass it back to approve/reject)
    // request.agentName – which agent wants to act
    // request.description – human-readable summary of the tool call
    // request.data – the raw tool call payload

    // Step 2: Push to your frontend via WebSocket, SSE, polling, etc.
    broadcastToAdminDashboard({
      requestId: request.id,
      agent: request.agentName,
      action: request.description,
      details: request.data,
    });
  },
});

// Step 3: Human clicks "Approve" or "Reject" in the dashboard, which hits this endpoint
app.post('/api/approvals/:requestId', (req, res) => {
  const { requestId } = req.params;
  const { approved, reason } = req.body;

  if (approved) {
    orchestrator.approve(requestId);      // Unpauses the agent run
  } else {
    orchestrator.reject(requestId, reason ?? 'Denied by reviewer');
  }

  res.json({ ok: true });
});

React UI Example

In a frontend app, use the orchestrator's reactive state to render pending approvals and wire the buttons directly:

function ApprovalPanel({ orchestrator }) {
  const approval = useFact(orchestrator.system, '__approval');

  return (
    <div>
      {approval?.pending?.map((req) => (
        <div key={req.id}>
          <p><strong>{req.agentName}</strong> wants to: {req.description}</p>
          <pre>{JSON.stringify(req.data, null, 2)}</pre>
          <button onClick={() => orchestrator.approve(req.id)}>Approve</button>
          <button onClick={() => orchestrator.reject(req.id, 'Denied')}>Reject</button>
        </div>
      ))}
    </div>
  );
}

Querying Approval State

Check pending approvals anytime:

const pending = orchestrator.facts.approval.pending;    // Requests waiting for a decision
const approved = orchestrator.facts.approval.approved;  // Requests that were approved
const rejected = orchestrator.facts.approval.rejected;  // Requests that were rejected

Budget Control

Set a token budget that automatically pauses agents when exceeded:

const orchestrator = createAgentOrchestrator({
  runner,
  autoApproveToolCalls: true,
  maxTokenBudget: 10000,             // Agent auto-pauses when this limit is hit
});

await orchestrator.run(agent, 'Summarize this document...');

// Track cumulative spend after each run
console.log(orchestrator.facts.agent.tokenUsage);  // e.g., 3500
console.log(orchestrator.facts.agent.status);       // 'completed'

// After many runs, once the budget is exhausted:
// orchestrator.facts.agent.status === 'paused'

For more granular cost control, use custom constraints:

const orchestrator = createAgentOrchestrator({
  runner,
  autoApproveToolCalls: true,

  // Fire a warning when token usage crosses 5,000
  constraints: {
    costWarning: {
      priority: 100,
      when: (facts) => facts.agent.tokenUsage > 5000,
      require: { type: 'COST_WARNING' },
    },
  },

  // Handle the warning by logging (could also notify, throttle, etc.)
  resolvers: {
    costWarning: {
      requirement: 'COST_WARNING',
      resolve: async (req, context) => {
        console.warn('Token usage high:', context.facts.agent.tokenUsage);
      },
    },
  },
});

Constraints & Resolvers

Add custom orchestrator-level constraints that react to agent state. Constraints evaluate after each run and trigger resolvers when conditions are met:

// Type the output generic so facts.agent.output is typed
interface MyOutput { confidence?: number }

const orchestrator = createAgentOrchestrator<{}, MyOutput>({
  runner,
  autoApproveToolCalls: true,

  constraints: {
    // Escalate to an expert when the agent is not confident enough
    escalateToExpert: {
      when: (facts) => (facts.agent.output?.confidence ?? 1) < 0.7,
      require: (facts) => ({
        type: 'RUN_EXPERT',
        query: facts.agent.input,
      }),
      priority: 50,
    },
  },

  resolvers: {
    // Spin up a more capable agent with the same question
    runExpert: {
      requirement: 'RUN_EXPERT',
      resolve: async (req, context) => {
        const expertAgent: AgentLike = {
          name: 'expert',
          instructions: 'You are a domain expert. Provide detailed, accurate answers.',
          model: 'gpt-4',
        };
        const result = await context.runAgent(expertAgent, req.query);
      },
    },
  },
});

Resolvers receive a context with facts (the combined orchestrator state), runAgent (to run additional agents), and signal (for cancellation).

Constraint Helpers

Use constraint() and when() for ergonomic constraint construction:

import { constraint, when } from '@directive-run/ai';

interface MyFacts { confidence: number; errors: number }

const orchestrator = createAgentOrchestrator<MyFacts>({
  runner,
  autoApproveToolCalls: true,

  constraints: {
    // Fluent builder – chain .when().require().priority().build()
    escalate: constraint<MyFacts>()
      .when((f) => f.confidence < 0.7)
      .require({ type: 'ESCALATE' })
      .priority(50)
      .build(),

    // Quick shorthand – one-liner that returns a constraint directly
    pause: when<MyFacts>((f) => f.errors > 3)
      .require({ type: 'PAUSE' }),

    // Shorthand with explicit priority for conflict resolution
    halt: when<MyFacts>((f) => f.errors > 10)
      .require({ type: 'HALT' })
      .withPriority(100),
  },
});

Both produce plain OrchestratorConstraint objects – zero runtime overhead, just ergonomic sugar. The when() shorthand returns a constraint directly (no .build() needed), and .withPriority() returns a new constraint with the priority set.

For the full list of all builder utilities (including core module helpers like constraintFactory and typedConstraint), see the Glossary: Builders & Helpers.


Lifecycle Hooks

Observe agent runs, guardrail checks, and retries without modifying behavior:

const orchestrator = createAgentOrchestrator({
  runner,
  autoApproveToolCalls: true,

  hooks: {
    // Log when an agent begins processing
    onAgentStart: ({ agentName, input, timestamp }) => {
      console.log(`[${agentName}] Starting at ${timestamp}`);
    },

    // Track performance after each successful run
    onAgentComplete: ({ agentName, tokenUsage, durationMs }) => {
      console.log(`[${agentName}] Done: ${tokenUsage} tokens in ${durationMs}ms`);
    },

    // Surface errors with timing context
    onAgentError: ({ agentName, error, durationMs }) => {
      console.error(`[${agentName}] Failed after ${durationMs}ms:`, error.message);
    },

    // Alert when a guardrail blocks content
    onGuardrailCheck: ({ guardrailName, guardrailType, passed, reason }) => {
      if (!passed) {
        console.warn(`Guardrail ${guardrailName} (${guardrailType}) blocked: ${reason}`);
      }
    },

    // Monitor automatic retries
    onAgentRetry: ({ agentName, attempt, error, delayMs }) => {
      console.log(`[${agentName}] Retry #${attempt} in ${delayMs}ms: ${error.message}`);
    },
  },
});

Retries

Configure automatic retries with backoff for transient failures:

const orchestrator = createAgentOrchestrator({
  runner,
  autoApproveToolCalls: true,

  agentRetry: {
    attempts: 3,                  // Try up to 3 times before giving up
    backoff: 'exponential',       // 'exponential' | 'linear' | 'fixed'
    baseDelayMs: 1000,            // First retry waits 1s
    maxDelayMs: 30000,            // Cap delay at 30s

    // Only retry rate limits and server errors
    isRetryable: (error) => {
      return error.message.includes('429') || error.message.includes('500');
    },

    onRetry: (attempt, error, delayMs) => {
      console.log(`Retry ${attempt} in ${delayMs}ms: ${error.message}`);
    },
  },
});

For HTTP-status-aware retry with provider fallback and cost budget guards, see Resilience & Routing. The withRetry wrapper respects Retry-After headers on 429 responses and uses exponential backoff with jitter on 503, while withFallback chains multiple providers for automatic failover.


Pause & Resume

Manually control agent execution:

// Pause all agent activity (e.g., user clicked "stop")
orchestrator.pause();
console.log(orchestrator.facts.agent.status); // 'paused'

// Resume from where the agent left off
orchestrator.resume();
console.log(orchestrator.facts.agent.status); // 'running' or 'idle'

// Wipe conversation, token usage, and approvals for a fresh session
orchestrator.reset();

// Release resources when the component or process shuts down
orchestrator.dispose();

Builder Pattern

For complex configurations, use the fluent builder:

import {
  createOrchestratorBuilder,
  createPIIGuardrail,
  createToolGuardrail,
  createOutputTypeGuardrail,
} from '@directive-run/ai';

const orchestrator = createOrchestratorBuilder()
  // Escalate when token usage gets high
  .withConstraint('escalate', {
    when: (facts) => facts.agent.tokenUsage > 5000,
    require: { type: 'ESCALATE' },
  })
  .withResolver('escalate', {
    requirement: 'ESCALATE',
    resolve: async (req, context) => {
      console.warn('Token budget escalation:', context.facts.agent.tokenUsage);
    },
  })

  // Layer on guardrails for input, tools, and output
  .withInputGuardrail('pii', createPIIGuardrail({ redact: true }))
  .withToolCallGuardrail('tools', createToolGuardrail({ denylist: ['shell'] }))
  .withOutputGuardrail('type', createOutputTypeGuardrail({ type: 'string' }))

  // Set budget and enable debug logging
  .withBudget(10000)
  .withDebug()

  // Finalize with the runner
  .build({
    runner,
    autoApproveToolCalls: true,
  });

Framework Integration

The orchestrator exposes a .system property – a standard Directive system – so all framework hooks work out of the box. The bridge keys are __agent, __approval, __conversation, and __toolCalls.

React

import { useAgentOrchestrator, useFact, useSelector, useWatch, useInspect } from '@directive-run/react';

function AgentPanel() {
  // Initialize the orchestrator as a React hook (auto-disposes on unmount)
  const orchestrator = useAgentOrchestrator({
    runner,
    autoApproveToolCalls: true,
  });
  const { system } = orchestrator;

  // Subscribe to individual bridge keys – re-renders when they change
  const agent = useFact(system, '__agent');
  const conversation = useFact(system, '__conversation');
  const approval = useFact(system, '__approval');

  // Derive a summary object – only re-renders when derived values change
  const summary = useSelector(system, (state) => ({
    status: state.__agent?.status,
    tokens: state.__agent?.tokenUsage,
    pending: state.__approval?.pending?.length ?? 0,
  }));

  // Check whether the orchestrator has finished all pending work
  const { isSettled } = useInspect(system);

  // Fire a side-effect when the agent finishes (does not cause re-render)
  useWatch(system, 'fact', '__agent', (next, prev) => {
    if (prev?.status === 'running' && next?.status === 'completed') {
      console.log('Agent finished:', next.output);
    }
  });

  return (
    <div>
      <p>Status: {agent?.status}</p>
      <p>Tokens: {agent?.tokenUsage}</p>
      <p>Messages: {conversation?.length ?? 0}</p>
      <p>Pending approvals: {approval?.pending?.length ?? 0}</p>
      <p>{isSettled ? 'Idle' : 'Working...'}</p>
    </div>
  );
}

Vue

<script setup>
import { createAgentOrchestrator } from '@directive-run/ai';
import { useFact, useSelector, useInspect } from '@directive-run/vue';
import { onUnmounted } from 'vue';

const orchestrator = createAgentOrchestrator({ runner, autoApproveToolCalls: true });
onUnmounted(() => orchestrator.dispose()); // Clean up on component teardown

// Reactive refs that update when the orchestrator state changes
const agent = useFact(orchestrator.system, '__agent');
const conversation = useFact(orchestrator.system, '__conversation');
const { isSettled } = useInspect(orchestrator.system);
</script>

<template>
  <p>Status: {{ agent?.status }}</p>
  <p>Tokens: {{ agent?.tokenUsage }}</p>
  <p>Messages: {{ conversation?.length ?? 0 }}</p>
  <p>{{ isSettled ? 'Idle' : 'Working...' }}</p>
</template>

Svelte

<script>
import { createAgentOrchestrator } from '@directive-run/ai';
import { useFact, useInspect } from '@directive-run/svelte';
import { onDestroy } from 'svelte';

const orchestrator = createAgentOrchestrator({ runner, autoApproveToolCalls: true });
onDestroy(() => orchestrator.dispose());

// Svelte stores – use $store syntax in the template for reactivity
const agent = useFact(orchestrator.system, '__agent');
const conversation = useFact(orchestrator.system, '__conversation');
const inspect = useInspect(orchestrator.system);
</script>

<p>Status: {$agent?.status}</p>
<p>Tokens: {$agent?.tokenUsage}</p>
<p>Messages: {$conversation?.length ?? 0}</p>
<p>{$inspect.isSettled ? 'Idle' : 'Working...'}</p>

Solid

import { createAgentOrchestrator } from '@directive-run/ai';
import { useFact, useInspect } from '@directive-run/solid';
import { onCleanup } from 'solid-js';

function AgentPanel() {
  const orchestrator = createAgentOrchestrator({ runner, autoApproveToolCalls: true });
  onCleanup(() => orchestrator.dispose());

  // Solid signals – call agent() and conversation() to read current values
  const agent = useFact(orchestrator.system, '__agent');
  const conversation = useFact(orchestrator.system, '__conversation');
  const inspect = useInspect(orchestrator.system);

  return (
    <div>
      <p>Status: {agent()?.status}</p>
      <p>Tokens: {agent()?.tokenUsage}</p>
      <p>Messages: {conversation()?.length ?? 0}</p>
      <p>{inspect().isSettled ? 'Idle' : 'Working...'}</p>
    </div>
  );
}

Lit

import { LitElement, html } from 'lit';
import { createAgentOrchestrator } from '@directive-run/ai';
import { FactController, InspectController } from '@directive-run/lit';

class AgentPanel extends LitElement {
  private orchestrator = createAgentOrchestrator({ runner, autoApproveToolCalls: true });

  // Reactive controllers – trigger re-render when their values change
  private agent = new FactController(this, this.orchestrator.system, '__agent');
  private conversation = new FactController(this, this.orchestrator.system, '__conversation');
  private inspect = new InspectController(this, this.orchestrator.system);

  disconnectedCallback() {
    super.disconnectedCallback();
    this.orchestrator.dispose();
  }

  render() {
    return html`
      <p>Status: ${this.agent.value?.status}</p>
      <p>Tokens: ${this.agent.value?.tokenUsage}</p>
      <p>Messages: ${this.conversation.value?.length ?? 0}</p>
      <p>${this.inspect.value?.isSettled ? 'Idle' : 'Working...'}</p>
    `;
  }
}

Next Steps

Previous
Resilience & Routing

We care about your data. We'll never share your email.

Powered by Directive. This signup uses a Directive module with facts, derivations, constraints, and resolvers – zero useState, zero useEffect. Read how it works

Directive - Constraint-Driven State Management for TypeScript