Herald Output Formatting

The Herald system provides pluggable output formatters for Paladin and Battalion execution results. A Herald transforms a PaladinResult or BattalionResult into a human-readable or machine-readable string — JSON, Markdown, or ASCII table.

The Herald trait is defined in crates/paladin-core/src/platform/container/herald.rs. Adapters are in src/infrastructure/adapters/herald/.


Table of Contents

  1. Overview
  2. Available Heralds
  3. Herald Trait
  4. Attaching to a Paladin
  5. Attaching to a Battalion Service
  6. Custom Herald Implementation
  7. Streaming Output
  8. Error Handling

Overview

HeraldImportBest For
JsonHeraldpaladin::infrastructure::adapters::herald::JsonHeraldAPIs, logging, programmatic consumption
MarkdownHeraldpaladin::infrastructure::adapters::herald::MarkdownHeraldTerminal display, reports, documentation
TableHeraldpaladin::infrastructure::adapters::herald::TableHeraldTabular terminal output, log files

Available Heralds

JsonHerald

Serialises PaladinResult and BattalionResult to JSON.

use paladin::infrastructure::adapters::herald::JsonHerald;
use paladin::infrastructure::adapters::herald::json_herald::JsonHeraldConfig;

// Default: pretty = true, include_metadata = true
let herald = JsonHerald::new();

// Compact JSON without metadata
let herald = JsonHerald::with_config(JsonHeraldConfig {
    pretty: false,
    include_metadata: false,
});

let json_str = herald.format_paladin_result(&result)?;
// "usage" is the full TokenUsage split, serialized as a stable six-key object:
// {"output": "...", "usage": {"prompt_tokens": 100, "completion_tokens": 50,
//   "total_tokens": 150, "cache_read_tokens": null, "cache_write_tokens": null,
//   "reasoning_tokens": null}, "execution_time_ms": 1230, ...}

MarkdownHerald

Formats results with Markdown headings, status badges, and code blocks. Supports ANSI colour codes for terminal output.

use paladin::infrastructure::adapters::herald::MarkdownHerald;
use paladin::infrastructure::adapters::herald::markdown_herald::MarkdownHeraldConfig;

// Default: auto-detects terminal colour support
let herald = MarkdownHerald::new();

// Custom: force no colours, H1 headings
let herald = MarkdownHerald::with_config(MarkdownHeraldConfig {
    include_colors: false,
    heading_level: 1,
});

TableHerald

Renders results as ASCII tables using comfy-table.

use paladin::infrastructure::adapters::herald::TableHerald;
use paladin::infrastructure::adapters::herald::table_herald::TableHeraldConfig;

// Default configuration
let herald = TableHerald::default();

// Custom: 80-char column width, rounded borders
let herald = TableHerald::new(TableHeraldConfig {
    max_column_width: 80,
    border_style: "rounded".to_string(),
});

Herald Trait

The Herald trait has seven methods, not three — format_stream_chunk returns Result<Option<String>, HeraldError> where None means "buffering, not ready to emit yet", not an error:

pub trait Herald: Send + Sync {
    /// Format a completed Paladin result
    fn format_paladin_result(&self, result: &PaladinResult) -> Result<String, HeraldError>;

    /// Format a completed Battalion result
    fn format_battalion_result(&self, result: &BattalionResult) -> Result<String, HeraldError>;

    /// Format a streaming chunk. `Ok(None)` means "buffering -- not ready to emit yet".
    fn format_stream_chunk(&self, chunk: &StreamChunk) -> Result<Option<String>, HeraldError>;

    /// Finalize streaming output with metadata (tokens, timing) once the stream completes.
    fn finalize_stream(&self, metadata: &ExecutionMetadata) -> Result<String, HeraldError>;

    /// Format an error for display. Infallible -- never returns Err.
    fn format_error(&self, error: &PaladinError) -> String;

    /// Formatter identifier, e.g. "json", "markdown", "table".
    fn name(&self) -> &str;

    /// MIME type of the formatted output, e.g. "application/json".
    fn mime_type(&self) -> &str;
}

Attaching to a Paladin

use paladin::application::services::paladin::paladin_builder::PaladinBuilder;
use paladin::infrastructure::adapters::herald::JsonHerald;
use paladin_core::platform::container::herald::Herald;
use std::sync::Arc;

let herald: Arc<dyn Herald> = Arc::new(JsonHerald::new());

let paladin = PaladinBuilder::new(llm_port)
    .system_prompt("You are an API assistant.")
    .with_herald(herald)
    .build()
    .await?;

let result = paladin.execute("List all Rust 2024 edition features").await?;
// result.output is already formatted as JSON
println!("{}", result.output);

Attaching to a Battalion Service

Formation, Phalanx, and other services accept a Herald via .with_herald():

use paladin_battalion::phalanx_service::PhalanxExecutionService;
use paladin::infrastructure::adapters::herald::MarkdownHerald;
use std::sync::Arc;

let service = PhalanxExecutionService::new(paladin_port)
    .with_herald(Arc::new(MarkdownHerald::new()));

let result = service.execute(&phalanx, "Analyse this dataset").await?;
println!("{}", result.output);

Custom Herald Implementation

Implement the Herald trait — all seven methods — to create a bespoke formatter:

use paladin_core::platform::container::herald::{
    BattalionResult, ExecutionMetadata, Herald, HeraldError, PaladinError, PaladinResult,
    StreamChunk,
};

/// A bespoke CSV formatter implementing the full seven-method `Herald` trait — the
/// same seven methods `output-formatting.md` documents, not the three-method stale
/// shape this page previously showed.
pub struct CsvHerald;

/// Escape a field for inclusion in a comma-separated row. This bespoke example
/// deliberately keeps escaping minimal (commas only, no quoting/newline handling) --
/// it is not RFC 4180-complete -- but applies it consistently across every method
/// below so no field can silently break row alignment.
fn csv_escape(field: &str) -> String {
    field.replace(',', ";")
}

impl Herald for CsvHerald {
    fn format_paladin_result(&self, result: &PaladinResult) -> Result<String, HeraldError> {
        Ok(format!(
            "{},{},{},{}\n",
            csv_escape(&result.output),
            result.usage.total_tokens,
            result.execution_time_ms,
            csv_escape(&format!("{:?}", result.stop_reason)),
        ))
    }

    fn format_battalion_result(&self, result: &BattalionResult) -> Result<String, HeraldError> {
        Ok(format!("{}\n", csv_escape(&result.final_output)))
    }

    fn format_stream_chunk(&self, chunk: &StreamChunk) -> Result<Option<String>, HeraldError> {
        Ok(Some(chunk.content.clone()))
    }

    fn finalize_stream(&self, metadata: &ExecutionMetadata) -> Result<String, HeraldError> {
        Ok(format!(
            "# total_tokens={},duration_ms={}\n",
            metadata.token_usage.total_tokens,
            csv_escape(&format!("{:?}", metadata.duration_ms)),
        ))
    }

    fn format_error(&self, error: &PaladinError) -> String {
        format!("error,{}\n", csv_escape(&error.to_string()))
    }

    fn name(&self) -> &str {
        "csv"
    }

    fn mime_type(&self) -> &str {
        "text/csv"
    }
}

Streaming Output

Use format_stream_chunk() during execute_stream():

use paladin_ports::output::paladin_port::PaladinStreamChunk;
use paladin_core::platform::container::herald::Herald;

let mut stream = paladin.execute_stream("Generate a long report").await?;
let herald = MarkdownHerald::new();

while let Some(chunk_result) = stream.recv().await {
    match chunk_result {
        Ok(chunk) => {
            // chunk.text is raw text; wrap in a StreamChunk for the Herald
            if let Some(formatted) = herald.format_stream_chunk(&chunk.into())? {
                print!("{}", formatted);
            }
            if chunk.is_final { break; }
        }
        Err(e) => eprintln!("Stream error: {}", e),
    }
}

Error Handling

HeraldError variants from paladin_core::platform::container::herald_error:

VariantCauseRecovery
SerializationError(String)JSON serialisation failureCheck result data for non-serialisable fields
FormatError(String)Internal formatter errorReport as bug; fallback to to_string()
InvalidInput(String)Unexpected input shapeValidate result before formatting