Writing an Agent
This guide walks through writing a complete agent binary in Rust using the libagent SDK.
Project Setup
cargo new --bin my-agent
cd my-agent
Add to Cargo.toml:
[dependencies]
libagent = { path = "../../crates/libagent" }
tokio = { version = "1", features = ["full"] }
serde_json = "1"
tracing = "0.1"
tracing-subscriber = "0.3"
Minimal Agent
// src/main.rs use libagent::agent::Agent; use libagent::types::LifecycleState; use serde_json::json; #[tokio::main] async fn main() -> Result<(), Box<dyn std::error::Error>> { tracing_subscriber::fmt::init(); let mut agent = Agent::from_env().await?; tracing::info!(agent_id = %agent.id, "Agent started"); agent.transition(LifecycleState::Plan).await?; agent.transition(LifecycleState::Act).await?; let result = agent.invoke_tool("echo", json!({"message": "hello world"})).await?; tracing::info!("Tool result: {}", result); agent.transition(LifecycleState::Observe).await?; agent.observe("echoed", "hello world echoed successfully", vec![]).await?; agent.transition(LifecycleState::Terminate).await?; Ok(()) }
The Manifest
Create manifest.yaml alongside your binary:
apiVersion: scarab/v1
kind: AgentManifest
metadata:
name: my-agent
version: 1.0.0
spec:
trust_level: sandboxed
capabilities:
- tool.invoke:echo
- obs.append
lifecycle:
restart_policy: never
timeout_secs: 60
command: target/debug/my-agent
Spawning
cargo build
ash spawn manifest.yaml
Reading Task and Model
If the manifest declares spec.task and spec.model, read them from environment:
#![allow(unused)] fn main() { let task = std::env::var("SCARAB_TASK").unwrap_or_default(); let model = std::env::var("SCARAB_MODEL") .unwrap_or_else(|_| "anthropic/claude-opus-4-6".to_string()); tracing::info!("Task: {}", task); tracing::info!("Model: {}", model); }
Error Recovery Pattern
#![allow(unused)] fn main() { loop { agent.transition(LifecycleState::Act).await?; match agent.invoke_tool("web.search", json!({"query": task})).await { Ok(results) => { agent.transition(LifecycleState::Observe).await?; agent.observe("searched", &format!("{} results", results["results"].as_array().map(|a| a.len()).unwrap_or(0)), vec![]).await?; break; } Err(e) => { tracing::warn!("Search failed: {}, retrying", e); agent.transition(LifecycleState::Plan).await?; tokio::time::sleep(std::time::Duration::from_secs(2)).await; } } } }
Graceful Shutdown
Always call transition(Terminate) before exiting. If the binary exits without transitioning, agentd will detect the process exit and mark the agent as terminated with an error.
#![allow(unused)] fn main() { // At the end of main, or in a signal handler agent.transition(LifecycleState::Terminate).await?; }
Testing Agents Locally
For unit tests that don't need a live agentd, mock the socket:
#![allow(unused)] fn main() { #[tokio::test] async fn test_agent_from_env_missing() { std::env::remove_var("SCARAB_AGENT_ID"); assert!(Agent::from_env().await.is_err()); } }
For integration tests, spin up agentd in a test fixture and use a temp socket path.