Skip to content

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 execute and return a ToolResult. Reach for StateKey when the same tool needs to read what an earlier tool call wrote.

  • 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"

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. Use KeyScope::Thread to persist across runs.
  • MergeStrategy::Commutative — safe for concurrent updates. Use Exclusive when only one writer is expected.
  • apply defines how an Update modifies the current Value. Here it increments.

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())
}
}
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(())
}
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(())
}

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 result inside ToolCallDone contains greeting and times_greeted fields.

A tool that:

  1. Declares a JSON Schema for its arguments via descriptor().
  2. Validates arguments before execution via validate_args().
  3. Reads typed state from the snapshot via ctx.state::<K>().
  4. Returns structured JSON via ToolResult::success().

The StateKey trait gives you type-safe, scoped state without raw JSON manipulation.

  • ctx.state::<K>() returns None: 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: the Value type does not round-trip through JSON. Ensure Serialize and Deserialize are derived correctly.
  • ToolError::InvalidArguments not surfaced: validate_args is called before execute by the runtime. If you skip validation, bad input reaches execute and may panic on .unwrap().
  • Scope mismatch: KeyScope::Run state is cleared between runs. If you expect persistence, use KeyScope::Thread.