Agent ↔ Orchestrator Bridge

Paladin agents and Battalion workflows interact bidirectionally:

  • An agent can trigger orchestration — schedule a job, enqueue an item, fire an event, or send a notification — through a narrow, policy-guarded port.
  • A workflow can invoke an agent — run a single Paladin or a whole Battalion as a step and feed its output back into the workflow.

This guide covers both directions, how to configure the bridge safely, and four end-to-end recipes. It builds on the Orchestration and Content Processing guides.

Every example targets the current v0.5.0 workspace. The substantive examples are real, compiled code pulled from the paladin-doc-examples crate via mdBook {{#include}} (one illustrative fragment is rust,ignore). API forms are verified against crates/paladin-ports/src/output/orchestrator_port.rs, paladin_executor_port.rs, battalion_port.rs, and the concrete OrchestratorBridgeAdapter in src/application/services/orchestration/.


Table of Contents

  1. Agents Triggering Orchestration
  2. Orchestration Invoking Agents
  3. Configuring the Bridge
  4. Use-Case Recipes
  5. See Also

Agents Triggering Orchestration

The seam is OrchestratorPort (crates/paladin-ports/src/output/orchestrator_port.rs). It exposes exactly four actions, mirrored by the BridgeAction enum:

BridgeActionOrchestratorPort methodRequest typeReturns
ScheduleJobschedule_jobScheduleJobRequestUuid
QueueItemqueue_itemQueueItemRequestUuid
FireEventfire_eventFireEventRequestEventDispatchResult
SendNotificationsend_notificationSendNotificationRequestUuid

The concrete adapter, OrchestratorBridgeAdapter, wraps an Arc<Orchestrator> and a BridgePolicy. It enforces the policy before performing any underlying call, so an agent can never exceed the actions or per-execution caps it was granted.

sequenceDiagram
    participant Agent as Paladin agent (tool call)
    participant Bridge as OrchestratorBridgeAdapter
    participant Policy as BridgePolicy
    participant Orch as Orchestrator

    Agent->>Bridge: fire_event(FireEventRequest)
    Bridge->>Policy: is_allowed(FireEvent)?
    Policy-->>Bridge: true
    Bridge->>Policy: cap_for(FireEvent)
    Policy-->>Bridge: 3
    Bridge->>Orch: dispatch event (within cap)
    Orch-->>Bridge: EventDispatchResult
    Bridge-->>Agent: Ok(EventDispatchResult)

Tool-based invocation from an agent loop

Expose the bridge to a Paladin as a tool. When the agent decides to act, the tool implementation calls the relevant OrchestratorPort method. The agent never touches the Orchestrator directly — only the policy-guarded port.

#![allow(unused)]
fn main() {
use paladin_ports::output::orchestrator_port::{
    BridgeAction, BridgePolicy, FireEventRequest, OrchestratorBridgeError, OrchestratorPort,
};

/// An agent fires a domain event through the policy-guarded bridge.
pub async fn agent_triggers_orchestration() -> Result<(), Box<dyn std::error::Error>> {
    // Grant ONLY the actions this agent should perform, with explicit caps.
    let mut allowed = HashSet::new();
    allowed.insert(BridgeAction::FireEvent);
    let policy = BridgePolicy::new(allowed, 0, 0, 5, 0); // up to 5 events, nothing else

    // In production this is an `OrchestratorBridgeAdapter`; here a mock stands in.
    let bridge: Arc<dyn OrchestratorPort> = mock_orchestrator();
    let _ = &policy; // the real adapter is constructed as `::new(orchestrator, policy)`

    match bridge
        .fire_event(FireEventRequest {
            event_type: "critical_finding".to_string(),
            payload: serde_json::json!({ "severity": "high" }),
            source: "security-agent".to_string(),
        })
        .await
    {
        Ok(result) => println!("fired; {} trigger(s) matched", result.triggered_count),
        Err(OrchestratorBridgeError::ActionNotAllowed(_)) => {
            eprintln!("policy forbids this action")
        }
        Err(OrchestratorBridgeError::QuotaExceeded { .. }) => {
            eprintln!("per-execution cap reached")
        }
        Err(e) => return Err(e.into()),
    }
    Ok(())
}
}

OrchestratorBridgeError distinguishes ActionNotAllowed (the policy doesn't grant the action) from QuotaExceeded (the per-execution cap is reached), so an agent can react sensibly instead of failing opaquely.


Orchestration Invoking Agents

The reverse direction uses the executor ports:

  • PaladinExecutorPort (paladin_executor_port.rs) — run a single Paladin: async fn execute(&self, paladin: &Paladin, input: &str) -> Result<PaladinResult, PaladinError>.
  • BattalionPort (battalion_port.rs) — run/monitor a whole Battalion by id: execute(battalion_id) -> BattalionResult, plus status and cancel.

A workflow step builds the input string (passing context from earlier steps), calls the executor, and reads the result back out.

sequenceDiagram
    participant WF as Workflow step
    participant Exec as PaladinExecutorPort
    participant Paladin as Paladin agent

    WF->>Exec: execute(&paladin, input_with_context)
    Exec->>Paladin: run agent loop
    Paladin-->>Exec: PaladinResult { output, token_count, ... }
    Exec-->>WF: Ok(PaladinResult)
    Note over WF: feed result.output into the next step
#![allow(unused)]
fn main() {
use paladin_core::platform::container::paladin::Paladin;
use paladin_ports::output::paladin_executor_port::PaladinExecutorPort;

/// A workflow step runs a single Paladin, passing context via the input string.
pub async fn orchestration_invokes_agent(
    analyst: &Paladin,
) -> Result<(), Box<dyn std::error::Error>> {
    let executor: Arc<dyn PaladinExecutorPort> = mock_executor();

    let upstream = "Q3 revenue rose 12% QoQ; churn fell to 2.1%.";
    let input = format!("Summarize the key risks given this context:\n{upstream}");

    let result = executor.execute(analyst, &input).await?;

    println!("agent said: {}", result.output);
    println!(
        "tokens: {}, stop reason: {:?}",
        result.token_count, result.stop_reason
    );
    Ok(())
}
}

PaladinResult carries output, token_count, execution_time_ms, loop_count, and stop_reason — everything the workflow needs to decide what to do next. To invoke a whole Battalion instead of a single agent, use BattalionPort::execute(battalion_id) and read the BattalionResult (see Orchestration → Configuration Reference).


Configuring the Bridge

Bridge behavior is configured programmatically through BridgePolicy — there is no dedicated config.yml bridge section in v0.5.0. A policy is two things: the set of allowed actions, and a per-execution cap for each action.

#![allow(unused)]
fn main() {
/// Build least-privilege and default bridge policies.
pub fn configure_bridge() {
    use paladin_ports::output::orchestrator_port::{BridgeAction, BridgePolicy};

    // Explicit, least-privilege: allow scheduling + notifications only,
    // with caps of (jobs=2, queue=0, events=0, notifications=5).
    let mut allowed = HashSet::new();
    allowed.insert(BridgeAction::ScheduleJob);
    allowed.insert(BridgeAction::SendNotification);
    let policy = BridgePolicy::new(allowed, 2, 0, 0, 5);

    // Builder-style: start from caps and add actions.
    let policy = BridgePolicy::new(HashSet::new(), 1, 1, 1, 1)
        .allow(BridgeAction::FireEvent)
        .allow(BridgeAction::QueueItem);

    // Conservative-but-usable default: all four actions, cap 3 each.
    let policy = BridgePolicy::default();
}
}

The three forms shown are: an explicit least-privilege policy, the builder-style .allow(..), and the conservative-but-usable Default (all four actions, cap 3 each). Prefer an explicit least-privilege policy for agents you don't fully trust.

Tip: because the adapter enforces the policy before every call, tightening a policy is a safe, local change — you don't have to audit the agent's prompt to constrain what it can do.


Use-Case Recipes

1. News monitoring pipeline with AI analysis

NewsApiFetcher → AI summarization (LlmContentAnalyzer) → notification via the bridge.

#![allow(unused)]
fn main() {
use paladin_ports::output::orchestrator_port::SendNotificationRequest;

/// Recipe: notify the result of an AI summary through the bridge.
pub async fn recipe_news_notification(
    bridge: &Arc<dyn OrchestratorPort>,
    summary: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    bridge
        .send_notification(SendNotificationRequest {
            channel: "email".to_string(),
            recipient: "ops@example.com".to_string(),
            subject: "Daily news digest".to_string(),
            body: summary.to_string(),
        })
        .await?;
    Ok(())
}
}

See Content Processing for the ingestion/analysis half and Orchestration → Job Scheduling to run this on a cron.

2. Research workflow

A web/HTTP tool gathers sources, a Paladin synthesizes them, and a Formation assembles the final report.

// 1. Agent gathers sources via an HTTP tool (Arsenal), producing notes.
// 2. Synthesis Paladin run as a workflow step:
let synthesis = executor.execute(&synthesizer, &collected_notes).await?;
// 3. Formation assembles intro → body → conclusion from the synthesis.
let report = formation_service.execute(&report_formation, &synthesis.output).await?;

3. Scheduled batch enrichment (job queue)

A recurring job enqueues items; a worker drains the queue and runs each through a Paladin.

#![allow(unused)]
fn main() {
use paladin_core::platform::container::schedule::Schedule;
use paladin_ports::output::orchestrator_port::{QueueItemRequest, ScheduleJobRequest};

/// Recipe: schedule a recurring batch job and enqueue an item.
pub async fn recipe_scheduled_batch(
    bridge: &Arc<dyn OrchestratorPort>,
    content_id: &str,
) -> Result<(), Box<dyn std::error::Error>> {
    bridge
        .schedule_job(ScheduleJobRequest {
            name: "nightly-enrichment".to_string(),
            description: "Enrich the day's content with AI tags".to_string(),
            schedule: Schedule::Daily(2, 0), // 02:00 daily
        })
        .await?;

    bridge
        .queue_item(QueueItemRequest {
            queue_name: "enrichment".to_string(),
            payload: serde_json::json!({ "content_id": content_id }),
        })
        .await?;
    Ok(())
}
}

4. Trigger-initiated agent run

An agent fires a domain event; a registered Trigger matches it and initiates a Paladin run — fully event-driven, no polling.

#![allow(unused)]
fn main() {
/// Recipe: an agent fires an event that a Trigger turns into a Paladin run.
pub async fn recipe_trigger_initiated(
    bridge: &Arc<dyn OrchestratorPort>,
) -> Result<(), Box<dyn std::error::Error>> {
    let dispatch = bridge
        .fire_event(FireEventRequest {
            event_type: "anomaly_detected".to_string(),
            payload: serde_json::json!({ "metric": "latency_p99", "value": 920 }),
            source: "monitor-agent".to_string(),
        })
        .await?;

    println!("{} trigger(s) initiated", dispatch.triggered_count);
    Ok(())
}
}

See Also

  • Orchestration — the Battalion patterns, job scheduler, and trigger system the bridge drives.
  • Content Processing — the ingestion/analysis pipeline used in recipes 1 and 3.
  • Paladin Agents — building the agents on both sides of the bridge.
  • Crate Map — where OrchestratorPort and the executor ports live.