First Tool
Implement one tool that reads typed state from ToolCallContext during execution.
Tools are the foundational, code-only layer of an Awaken agent — everything
else (model, agent, skill) is config that wires tools into runtime behavior.
First Agent already shows a stateless tool
defined inline; this tutorial extends that pattern with typed StateKeys so
the tool can persist values across calls within a run.
State is optional. Many tools (API calls, search, shell commands) don’t need state — just implement
executeand return aToolResult. Reach forStateKeywhen the same tool needs to read what an earlier tool call wrote.
Prerequisites
Section titled “Prerequisites”- Familiarity with First Agent (it introduces the runtime + a stateless tool — this tutorial builds on the same scaffold).
[dependencies]awaken = { git = "https://github.com/AwakenWorks/awaken" }tokio = { version = "1", features = ["full"] }async-trait = "0.1"serde = { version = "1", features = ["derive"] }serde_json = "1"1. Define a StateKey
Section titled “1. Define a StateKey”A StateKey describes one named slot in the state map. It declares the value type, how updates are applied, and the lifetime scope.
use awaken::{KeyScope, MergeStrategy, StateKey};
/// Tracks how many times the greeting tool has been called.struct GreetCount;
impl StateKey for GreetCount { const KEY: &'static str = "greet_count"; const MERGE: MergeStrategy = MergeStrategy::Commutative; const SCOPE: KeyScope = KeyScope::Run;
type Value = u32; type Update = u32;
fn apply(value: &mut Self::Value, update: Self::Update) { *value += update; }}Key choices:
KeyScope::Run— state resets at the start of each run. UseKeyScope::Threadto persist across runs.MergeStrategy::Commutative— safe for concurrent updates. UseExclusivewhen only one writer is expected.applydefines how anUpdatemodifies the currentValue. Here it increments.
2. Implement the Tool
Section titled “2. Implement the Tool”The tool reads the current count via ctx.state::<GreetCount>() and returns a personalized greeting.
use async_trait::async_trait;use serde_json::{json, Value};use awaken::{KeyScope, MergeStrategy, StateKey};use awaken::contract::tool::{Tool, ToolDescriptor, ToolResult, ToolOutput, ToolError, ToolCallContext};
/// Tracks how many times the greeting tool has been called.struct GreetCount;
impl StateKey for GreetCount { const KEY: &'static str = "greet_count"; const MERGE: MergeStrategy = MergeStrategy::Commutative; const SCOPE: KeyScope = KeyScope::Run;
type Value = u32; type Update = u32;
fn apply(value: &mut Self::Value, update: Self::Update) { *value += update; }}
struct GreetTool;
#[async_trait]impl Tool for GreetTool { fn descriptor(&self) -> ToolDescriptor { ToolDescriptor::new("greet", "Greet", "Greet a user by name") .with_parameters(json!({ "type": "object", "properties": { "name": { "type": "string", "description": "Name to greet" } }, "required": ["name"] })) }
fn validate_args(&self, args: &Value) -> Result<(), ToolError> { args["name"] .as_str() .filter(|s| !s.is_empty()) .ok_or_else(|| ToolError::InvalidArguments("name is required".into()))?; Ok(()) }
async fn execute( &self, args: Value, ctx: &ToolCallContext, ) -> Result<ToolOutput, ToolError> { let name = args["name"].as_str().unwrap_or("world");
// Read state -- returns None if the key has not been set yet. let count = ctx.state::<GreetCount>().copied().unwrap_or(0);
Ok(ToolResult::success("greet", json!({ "greeting": format!("Hello, {}!", name), "times_greeted": count, })).into()) }}3. Register the Tool
Section titled “3. Register the Tool”use std::sync::Arc;use async_trait::async_trait;use serde_json::{json, Value};use awaken::{KeyScope, MergeStrategy, StateKey};use awaken::contract::tool::{Tool, ToolDescriptor, ToolResult, ToolOutput, ToolError, ToolCallContext};use awaken::engine::GenaiExecutor;use awaken::registry_spec::ModelSpec;use awaken::registry_spec::AgentSpec;use awaken::AgentRuntimeBuilder;
struct GreetCount;
impl StateKey for GreetCount { const KEY: &'static str = "greet_count"; const MERGE: MergeStrategy = MergeStrategy::Commutative; const SCOPE: KeyScope = KeyScope::Run;
type Value = u32; type Update = u32;
fn apply(value: &mut Self::Value, update: Self::Update) { *value += update; }}
struct GreetTool;
#[async_trait]impl Tool for GreetTool { fn descriptor(&self) -> ToolDescriptor { ToolDescriptor::new("greet", "Greet", "Greet a user by name") }
async fn execute(&self, _args: Value, _ctx: &ToolCallContext) -> Result<ToolOutput, ToolError> { Ok(ToolResult::success("greet", json!({})).into()) }}
fn main() -> Result<(), Box<dyn std::error::Error>> { let agent_spec = AgentSpec::new("assistant") .with_model_id("gpt-4o-mini") .with_system_prompt("You are a helpful assistant. Use the greet tool when asked.") .with_max_rounds(5);
let runtime = AgentRuntimeBuilder::new() .with_agent_spec(agent_spec) .with_tool("greet", Arc::new(GreetTool)) .with_provider("openai", Arc::new(GenaiExecutor::new())) .with_model(ModelSpec::new("gpt-4o-mini", "openai", "gpt-4o-mini")) .build()?;
let _runtime = runtime; Ok(())}4. Run
Section titled “4. Run”use std::sync::Arc;use async_trait::async_trait;use serde_json::{json, Value};use awaken::{KeyScope, MergeStrategy, StateKey};use awaken::contract::message::Message;use awaken::contract::event_sink::VecEventSink;use awaken::contract::tool::{Tool, ToolDescriptor, ToolResult, ToolOutput, ToolError, ToolCallContext};use awaken::engine::GenaiExecutor;use awaken::registry_spec::ModelSpec;use awaken::registry_spec::AgentSpec;use awaken::AgentRuntimeBuilder;use awaken::RunActivation;
struct GreetCount;
impl StateKey for GreetCount { const KEY: &'static str = "greet_count"; const MERGE: MergeStrategy = MergeStrategy::Commutative; const SCOPE: KeyScope = KeyScope::Run;
type Value = u32; type Update = u32;
fn apply(value: &mut Self::Value, update: Self::Update) { *value += update; }}
struct GreetTool;
#[async_trait]impl Tool for GreetTool { fn descriptor(&self) -> ToolDescriptor { ToolDescriptor::new("greet", "Greet", "Greet a user by name") }
async fn execute(&self, _args: Value, _ctx: &ToolCallContext) -> Result<ToolOutput, ToolError> { Ok(ToolResult::success("greet", json!({})).into()) }}
#[tokio::main]async fn main() -> Result<(), Box<dyn std::error::Error>> { let runtime = AgentRuntimeBuilder::new() .with_agent_spec(AgentSpec::new("assistant").with_model_id("gpt-4o-mini")) .with_tool("greet", Arc::new(GreetTool)) .with_provider("openai", Arc::new(GenaiExecutor::new())) .with_model(ModelSpec::new("gpt-4o-mini", "openai", "gpt-4o-mini")) .build()?;
let request = RunActivation::new( "thread-1", vec![Message::user("Greet Alice")], ) .with_agent_id("assistant");
// This tutorial captures events because the next step verifies that the tool ran. // Use runtime.run_to_completion(request) when you only need the final result. let sink = Arc::new(VecEventSink::new()); runtime.run(request, sink.clone()).await?; Ok(())}5. Verify
Section titled “5. Verify”Check the collected events for a ToolCallDone event with name == "greet":
use awaken::contract::event::AgentEvent;
let events = sink.take();let tool_done = events.iter().any(|e| matches!( e, AgentEvent::ToolCallDone { id: _, message_id: _, result: _, outcome: _ }));println!("tool_call_done_seen: {}", tool_done);Expected:
tool_call_done_seen: true- The
resultinsideToolCallDonecontainsgreetingandtimes_greetedfields.
What You Created
Section titled “What You Created”A tool that:
- Declares a JSON Schema for its arguments via
descriptor(). - Validates arguments before execution via
validate_args(). - Reads typed state from the snapshot via
ctx.state::<K>(). - Returns structured JSON via
ToolResult::success().
The StateKey trait gives you type-safe, scoped state without raw JSON manipulation.
Which Doc To Read Next
Section titled “Which Doc To Read Next”- understand the full tool lifecycle: Tool Trait
- add plugins that manage state across runs: Add a Plugin
- learn about state scoping rules: State Keys
Common Errors
Section titled “Common Errors”ctx.state::<K>()returnsNone: the state key has not been written yet in this run. Use.unwrap_or_default()or.copied().unwrap_or(0)for numeric defaults.StateError::KeyEncode/StateError::KeyDecode: theValuetype does not round-trip through JSON. EnsureSerializeandDeserializeare derived correctly.ToolError::InvalidArgumentsnot surfaced:validate_argsis called beforeexecuteby the runtime. If you skip validation, bad input reachesexecuteand may panic on.unwrap().- Scope mismatch:
KeyScope::Runstate is cleared between runs. If you expect persistence, useKeyScope::Thread.