Quickstart
A complete, copy-pasteable agent: a personal-finance budgeting assistant that calls two tools to look up the user's spending and budget, then advises in plain text. It exercises the tool-calling loop end-to-end against OpenAI.
1. Add the dependency
[dependencies]
paigasus-helikon = { version = "0.3", features = ["openai", "macros"] }
tokio = { version = "1", features = ["macros", "rt-multi-thread"] }
anyhow = "1"
serde = { version = "1", features = ["derive"] }
schemars = "1"
The openai feature pulls in paigasus_helikon::openai::OpenAiModel; the macros feature pulls in the #[tool] attribute and the tools! macro. paigasus_helikon::core is always available.
2. Set your API key
The OpenAI adapter reads the key from the environment.
export OPENAI_API_KEY=sk-...
3. Write main.rs
Each #[tool] function takes a &ToolContext<()> and a single Deserialize + JsonSchema argument struct, and returns Result<T, ToolError> where T is Serialize + JsonSchema. The doc comment on the function becomes the tool description the model sees; the doc comments on the argument fields become the JSON-Schema field descriptions.
use paigasus_helikon::core::{ Agent, AgentInput, LlmAgent, RunContext, RunResultStreaming, ToolContext, ToolError, }; use paigasus_helikon::openai::OpenAiModel; use paigasus_helikon::{tool, tools}; #[derive(serde::Deserialize, schemars::JsonSchema)] struct LookupSpendingArgs { /// Spending category, e.g. "Dining". category: String, /// Month in YYYY-MM form. month: String, } #[derive(serde::Serialize, schemars::JsonSchema)] struct LookupSpendingOut { /// Total spent in the category this month, in dollars. total: f64, /// Number of transactions. count: u32, } /// Look up the user's total spending and transaction count for a category in a month. #[tool] async fn lookup_spending( _ctx: &ToolContext<()>, args: LookupSpendingArgs, ) -> Result<LookupSpendingOut, ToolError> { // Read `month` so the field is not flagged unused; the canned ledger ignores it. let _ = args.month; let out = match args.category.to_lowercase().as_str() { "dining" => LookupSpendingOut { total: 312.40, count: 18, }, "groceries" => LookupSpendingOut { total: 540.10, count: 9, }, _ => LookupSpendingOut { total: 0.0, count: 0, }, }; Ok(out) } #[derive(serde::Deserialize, schemars::JsonSchema)] struct BudgetStatusArgs { /// Spending category, e.g. "Dining". category: String, } #[derive(serde::Serialize, schemars::JsonSchema)] struct BudgetStatusOut { /// Monthly budget for the category, in dollars. budget: f64, /// Amount spent so far, in dollars. spent: f64, /// Remaining budget (negative = over budget). remaining: f64, } /// Look up the user's monthly budget, amount spent, and remaining balance for a category. #[tool] async fn budget_status( _ctx: &ToolContext<()>, args: BudgetStatusArgs, ) -> Result<BudgetStatusOut, ToolError> { let out = match args.category.to_lowercase().as_str() { "dining" => BudgetStatusOut { budget: 250.0, spent: 312.40, remaining: -62.40, }, "groceries" => BudgetStatusOut { budget: 600.0, spent: 540.10, remaining: 59.90, }, _ => BudgetStatusOut { budget: 0.0, spent: 0.0, remaining: 0.0, }, }; Ok(out) } #[tokio::main] async fn main() -> anyhow::Result<()> { let model = OpenAiModel::chat("gpt-5-mini").build()?; // ← provider-specific line let agent = LlmAgent::builder::<()>() .name("budget-assistant") .model(model) .instructions( "You are a budgeting assistant. Use the tools to look up the user's spending \ and budget for the relevant category, then tell them whether they are on \ track and suggest one concrete action.", ) .tools(tools![lookup_spending, budget_status]) .build(); let ctx: RunContext<()> = RunContext::ephemeral(()); let input = AgentInput::from_user_text("How am I doing on my dining budget this month?"); let stream = agent.run(ctx, input).await?; let result = RunResultStreaming::new(stream).collect().await?; println!("{}", result.final_output); Ok(()) }
What each piece does:
OpenAiModel::chat("gpt-5-mini").build()?constructs the model adapter. The id is an example — swap it for any available model if the API rejects it.LlmAgent::builder::<()>()opens the agent builder; the()type parameter is the per-run context state (here, the unit type — no shared state)..name,.model,.instructions, and.tools(tools![...])configure it;.build()finalizes.RunContext::ephemeral(())is the one-liner for the all-defaults case: an in-memoryMemorySession, an emptyHookRegistry, a defaultTracerHandle, and a freshCancellationToken. Use consuming setters (.with_session(...),.with_hooks(...),.with_tracer(...),.with_cancel(...)) to override individual parts.AgentInput::from_user_text(...)builds the initial user turn.agent.run(ctx, input).await?returns an event stream;RunResultStreaming::new(stream).collect().await?drains it to a final result.result.final_outputis the agent's plain-text answer.
4. Run it
OPENAI_API_KEY=sk-... cargo run --features openai,macros
The same code ships as a runnable example in the workspace:
OPENAI_API_KEY=sk-... cargo run -p paigasus-helikon \
--features openai,macros --example budget_assistant_openai
Switching providers
The provider lives entirely in the one model-construction line. To run the identical agent against Anthropic, enable the anthropic feature and swap:
#![allow(unused)] fn main() { use paigasus_helikon::anthropic::AnthropicModel; let model = AnthropicModel::messages("claude-sonnet-4-6").build()?; }
Everything downstream — the #[tool] functions, the builder, RunContext, the stream collection — is unchanged.
Next steps
- The agent loop — how
rundrives model calls, tool dispatch, and turn assembly. - Tools — the
#[tool]attribute, thetools!macro, andToolContextin depth.