Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

Library Mode

ironflow-core works on its own, without the API server, a worker or a database. Add it to any binary and call the operations directly: shell commands, HTTP requests and AI agents, with timeouts, budgets and cost tracking. Nothing is persisted and there is no dashboard; for that, use the full platform described in the rest of this book.

cargo add ironflow-core tokio --features tokio/full
use ironflow_core::prelude::*;

#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
    let provider = ClaudeCodeProvider::new();

    // Run a shell command
    let files = Shell::new("ls -la src/").await?;

    // Feed the output into an agent
    let review = Agent::new()
        .prompt(&format!("Review these source files:\n{}", files.stdout()))
        .model(Model::SONNET)
        .max_budget_usd(0.10)
        .run(&provider)
        .await?;

    println!("{}", review.text());
    Ok(())
}

Shell

#![allow(unused)]
fn main() {
use ironflow_core::prelude::*;
use std::time::Duration;

async fn example() -> Result<(), OperationError> {
let output = Shell::new("cargo test")
    .dir("/path/to/project")
    .timeout(Duration::from_secs(120))
    .env("RUST_LOG", "debug")
    .await?;

println!("stdout: {}", output.stdout());
println!("exit code: {}", output.exit_code());
Ok(())
}
}

Shell::new hands its string to sh -c. When the command carries data you do not control, use Shell::exec(program, &[args]), which passes each argument as is.

Http

A non-2xx status is not an error: check is_success().

#![allow(unused)]
fn main() {
use ironflow_core::prelude::*;
use std::time::Duration;

async fn example() -> Result<(), OperationError> {
let output = Http::post("https://httpbin.org/post")
    .header("Authorization", "Bearer token123")
    .json(serde_json::json!({"key": "value"}))
    .timeout(Duration::from_secs(30))
    .await?;

println!("status: {}, body: {}", output.status(), output.body());
Ok(())
}
}

Agent

Derive JsonSchema on a type and the provider is constrained to return it.

#![allow(unused)]
fn main() {
use ironflow_core::prelude::*;

#[derive(Deserialize, JsonSchema)]
struct Review {
    score: u8,
    summary: String,
}

async fn example() -> Result<(), OperationError> {
let provider = ClaudeCodeProvider::new();

let result = Agent::new()
    .system_prompt("You are a senior Rust reviewer.")
    .prompt("Review the codebase")
    .model(Model::OPUS)
    .allowed_tools(&["Read", "Grep"])
    .max_turns(5)
    .max_budget_usd(0.50)
    .output::<Review>()
    .run(&provider)
    .await?;

let review: Review = result.json().expect("schema-validated output");
println!("Score: {}/10 - {}", review.score, review.summary);
println!("Cost: ${:.4}", result.cost_usd().unwrap_or(0.0));
Ok(())
}
}

Agent::timeout(Duration::from_secs(300)) bounds the call independently of the provider, automatic retries included: past it run fails with AgentError::Timeout and the provider stops its process or pod. Without it, the provider timeout applies.

Other providers (OpenAI, Gemini, Mistral, remote Claude Code) are listed in Agent Providers.

Session resume

#![allow(unused)]
fn main() {
use ironflow_core::prelude::*;

async fn example() -> Result<(), OperationError> {
let provider = ClaudeCodeProvider::new();

let first = Agent::new()
    .prompt("Analyze the src/ directory")
    .max_budget_usd(0.10)
    .run(&provider)
    .await?;

let session = first.session_id().expect("provider returned session ID");

let followup = Agent::new()
    .prompt("Now suggest improvements")
    .resume(session)
    .max_budget_usd(0.10)
    .run(&provider)
    .await?;
Ok(())
}
}

Parallel execution

tokio::try_join! when the number of operations is known at compile time:

#![allow(unused)]
fn main() {
use ironflow_core::prelude::*;

async fn example() -> Result<(), OperationError> {
let (files, status) = tokio::try_join!(
    Shell::new("ls -la"),
    Shell::new("git status"),
)?;
Ok(())
}
}

try_join_all when it is decided at run time, try_join_all_limited to cap concurrency:

#![allow(unused)]
fn main() {
use ironflow_core::prelude::*;

async fn example() -> Result<(), OperationError> {
let provider = ClaudeCodeProvider::new();
let prompts = vec!["Summarize file A", "Summarize file B", "Summarize file C"];

let results = try_join_all_limited(
    prompts.iter().map(|p| {
        Agent::new()
            .prompt(p)
            .model(Model::HAIKU)
            .max_budget_usd(0.10)
            .run(&provider)
    }),
    2, // at most 2 agent calls at a time
).await?;
Ok(())
}
}

Tracking cost and duration

WorkflowTracker adds up cost, tokens and duration across operations:

#![allow(unused)]
fn main() {
use ironflow_core::prelude::*;

async fn example() -> Result<(), OperationError> {
let provider = ClaudeCodeProvider::new();
let mut tracker = WorkflowTracker::new("deploy-pipeline");

let files = Shell::new("ls -la").await?;
tracker.record_shell("list-files", &files);

let review = Agent::new()
    .prompt("Review the project")
    .max_budget_usd(0.10)
    .run(&provider)
    .await?;
tracker.record_agent("code-review", &review);

tracker.summary(); // structured log via tracing
println!("Total cost: ${:.4}", tracker.total_cost_usd());
println!("Steps: {}", tracker.step_count());
Ok(())
}
}

Dry-run mode

#![allow(unused)]
fn main() {
use ironflow_core::prelude::*;

async fn example() -> Result<(), OperationError> {
// Global: every operation skips execution
set_dry_run(true);
let output = Shell::new("rm -rf /").await?; // not executed
assert_eq!(output.stdout(), "");

// Per operation, overrides the global setting
set_dry_run(false);
let output = Shell::new("echo hello").dry_run(true).await?;
assert_eq!(output.stdout(), "");
Ok(())
}
}

Record/replay testing

RecordReplayProvider wraps any provider and stores its responses as JSON fixtures, keyed by a hash of the prompt, system prompt and schema. Tests then replay them without spending tokens. The random nonce of a Prompt untrusted block is masked before hashing, so it does not change the fixture.

#![allow(unused)]
fn main() {
use ironflow_core::prelude::*;

async fn example() -> Result<(), OperationError> {
// Record mode when IRONFLOW_RECORD=1, replay otherwise
let provider = RecordReplayProvider::new(ClaudeCodeProvider::new(), "tests/fixtures");

// Or force replay, ignoring the env var
let provider = RecordReplayProvider::replay(ClaudeCodeProvider::new(), "tests/fixtures");

let result = Agent::new()
    .prompt("Explain ownership in Rust")
    .max_budget_usd(0.10)
    .run(&provider)
    .await?;
Ok(())
}
}

To test a full workflow handler, see Testing Workflows.