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.