Deconstructing ZeroClaw: The Ultra-Lightweight AI Agent Framework
Master the architecture of the fastest AI assistant infrastructure with this complete code-by-code walkthrough.
This is a very long article, download the entire aritcle using the button at the end of this article!
This article offers a complete, file-by-file deconstruction of ZeroClaw, the open-source AI agent framework that is quietly reshaping how we think about local automation. While the industry has normalized AI assistants that require gigabytes of RAM and powerful cloud-connected hardware, ZeroClaw proves that a fully autonomous, intelligent agent can operate as a 3.4MB system daemon — booting in milliseconds and running comfortably on a $10 edge device. Written entirely in Rust, it represents a radical shift from bloated scripts to secure, operations-first infrastructure.
This is half of the article, read the entire thing here:
Throughout this deep dive, you can expect to move step-by-step from the outermost layers of the repository down to the bare metal. We will examine how ZeroClaw manages local development via its Docker orchestration, enforce security and dependency hygiene using Cargo policies, and decouple its architecture through an elegant trait-based plugin system. By breaking down the Agent core, you will see exactly how ZeroClaw orchestrates memory, parses language model outputs, and autonomously dispatches side-effecting tools.
Whether you are a Rust developer looking to understand advanced architectural patterns, an IoT engineer wanting to run an LLM agent on a microcontroller, or just an AI enthusiast curious about how a local assistant manages context without a massive vector database, this guide will provide a clear map of the codebase. By the end, you will understand not just what ZeroClaw does, but exactly how its internal gears turn to deliver zero-overhead, zero-compromise AI automation.
# file path: dev/cli.sh
set -e
if [ -f “dev/docker-compose.yml” ]; then
BASE_DIR=”dev”
HOST_TARGET_DIR=”target”
elif [ -f “docker-compose.yml” ] && [ “$(basename “$(pwd)”)” == “dev” ]; then
BASE_DIR=”.”
HOST_TARGET_DIR=”../target”
else
echo “❌ Error: Run this script from the project root or dev/ directory.”
exit 1
fi
COMPOSE_FILE=”$BASE_DIR/docker-compose.yml”
GREEN=’\033[0;32m’
YELLOW=’\033[1;33m’
RED=’\033[0;31m’
NC=’\033[0m’
function ensure_config {
CONFIG_DIR=”$HOST_TARGET_DIR/.zeroclaw”
CONFIG_FILE=”$CONFIG_DIR/config.toml”
WORKSPACE_DIR=”$CONFIG_DIR/workspace”
if [ ! -f “$CONFIG_FILE” ]; then
echo -e “${YELLOW}⚙️ Config file missing in target/.zeroclaw. Creating default dev config from template...${NC}”
mkdir -p “$WORKSPACE_DIR”
cat “$BASE_DIR/config.template.toml” > “$CONFIG_FILE”
fi
}
function print_help {
echo -e “${YELLOW}ZeroClaw Development Environment Manager${NC}”
echo “Usage: ./dev/cli.sh [command]”
echo “”
echo “Commands:”
echo -e “ ${GREEN}up${NC} Start dev environment (Agent + Sandbox)”
echo -e “ ${GREEN}down${NC} Stop containers”
echo -e “ ${GREEN}shell${NC} Enter Sandbox (Ubuntu)”
echo -e “ ${GREEN}agent${NC} Enter Agent (ZeroClaw CLI)”
echo -e “ ${GREEN}logs${NC} View logs”
echo -e “ ${GREEN}build${NC} Rebuild images”
echo -e “ ${GREEN}ci${NC} Run local CI checks in Docker (see ./dev/ci.sh)”
echo -e “ ${GREEN}clean${NC} Stop and wipe workspace data”
}
if [ -z “$1” ]; then
print_help
exit 1
fi
case “$1” in
up)
ensure_config
echo -e “${GREEN}🚀 Starting Dev Environment...${NC}”
docker compose -f “$COMPOSE_FILE” up -d
echo -e “${GREEN}✅ Environment is running!${NC}”
echo -e “ - Agent: http://127.0.0.1:3000”
echo -e “ - Sandbox: running (background)”
echo -e “ - Config: target/.zeroclaw/config.toml (Edit locally to apply changes)”
;;
down)
echo -e “${YELLOW}🛑 Stopping services...${NC}”
docker compose -f “$COMPOSE_FILE” down
echo -e “${GREEN}✅ Stopped.${NC}”
;;
shell)
echo -e “${GREEN}💻 Entering Sandbox (Ubuntu)... (Type ‘exit’ to leave)${NC}”
docker exec -it zeroclaw-sandbox /bin/bash
;;
agent)
echo -e “${GREEN}🤖 Entering Agent Container (ZeroClaw)... (Type ‘exit’ to leave)${NC}”
docker exec -it zeroclaw-dev /bin/bash
;;
logs)
docker compose -f “$COMPOSE_FILE” logs -f
;;
build)
echo -e “${YELLOW}🔨 Rebuilding images...${NC}”
docker compose -f “$COMPOSE_FILE” build
ensure_config
docker compose -f “$COMPOSE_FILE” up -d
echo -e “${GREEN}✅ Rebuild complete.${NC}”
;;
ci)
shift
if [ “$BASE_DIR” = “.” ]; then
./ci.sh “${@:-all}”
else
./dev/ci.sh “${@:-all}”
fi
;;
clean)
echo -e “${RED}⚠️ WARNING: This will delete ‘target/.zeroclaw’ data and Docker volumes.${NC}”
read -p “Are you sure? (y/N) “ -n 1 -r
echo
if [[ $REPLY =~ ^[Yy]$ ]]; then
docker compose -f “$COMPOSE_FILE” down -v
rm -rf “$HOST_TARGET_DIR/.zeroclaw”
echo -e “${GREEN}🧹 Cleaned up (playground/ remains intact).${NC}”
else
echo “Cancelled.”
fi
;;
*)
print_help
exit 1
;;
esaccli.sh is the small developer-facing control script that makes it easy to bring up, inspect, and tear down a local ZeroClaw development environment so you can iterate on the agent, provider integrations, and sandbox tools without installing anything globally. It begins by enabling fail-fast behavior and then determines where the repository is being invoked from by checking which compose file is present and setting BASE_DIR and HOST_TARGET_DIR accordingly; that decision gates which docker compose configuration the rest of the script will operate against via the COMPOSE_FILE variable. A handful of terminal color constants are declared for user-facing messages. The ensure_config function checks for a per-developer runtime config directory inside the host target area and, if missing, creates the workspace folder and copies a default config template from the selected base directory into a .zeroclaw config file so the containers have a mounted config to read at runtime. The print_help function prints an ergonomic command summary for common development tasks. The script then requires a subcommand and dispatches via a case statement: the up path runs the compose file to start the Agent and the Sandbox in the background after ensuring the config exists and prints where to reach the Agent; down stops the services; shell and agent open interactive shells into the sandbox and agent containers respectively; logs tails compose
# file path: Cargo.toml
[package]
name = “zeroclaw”
version = “0.1.0”
edition = “2021”
authors = [”theonlyhennygod”]
license = “MIT”
description = “Zero overhead. Zero compromise. 100% Rust. The fastest, smallest AI assistant.”
repository = “https://github.com/theonlyhennygod/zeroclaw”
readme = “README.md”
keywords = [”ai”, “agent”, “cli”, “assistant”, “chatbot”]
categories = [”command-line-utilities”, “api-bindings”]
[dependencies]
clap = { version = “4.5”, features = [”derive”] }
tokio = { version = “1.42”, default-features = false, features = [”rt-multi-thread”, “macros”, “time”, “net”, “io-util”, “sync”, “process”, “io-std”, “fs”, “signal”] }
reqwest = { version = “0.12”, default-features = false, features = [”json”, “rustls-tls”, “blocking”, “multipart”, “stream”] }
serde = { version = “1.0”, default-features = false, features = [”derive”] }
serde_json = { version = “1.0”, default-features = false, features = [”std”] }
directories = “5.0”
toml = “1.0”
shellexpand = “3.1”
tracing = { version = “0.1”, default-features = false }
tracing-subscriber = { version = “0.3”, default-features = false, features = [”fmt”, “ansi”] }
prometheus = { version = “0.14”, default-features = false }
base64 = “0.22”
fantoccini = { version = “0.22.0”, optional = true, default-features = false, features = [”rustls-tls”] }
anyhow = “1.0”
thiserror = “2.0”
uuid = { version = “1.11”, default-features = false, features = [”v4”, “std”] }
chacha20poly1305 = “0.10”
hmac = “0.12”
sha2 = “0.10”
hex = “0.4”
rand = “0.8”
parking_lot = “0.12”
landlock = { version = “0.4”, optional = true }
async-trait = “0.1”
rusqlite = { version = “0.38”, features = [”bundled”] }
chrono = { version = “0.4”, default-features = false, features = [”clock”, “std”, “serde”] }
cron = “0.12”
dialoguer = { version = “0.12”, features = [”fuzzy-select”] }
console = “0.15”
glob = “0.3”
tokio-tungstenite = { version = “0.24”, features = [”rustls-tls-webpki-roots”] }
futures-util = { version = “0.3”, default-features = false, features = [”sink”] }
hostname = “0.4.2”
lettre = { version = “0.11.19”, default-features = false, features = [”builder”, “smtp-transport”, “rustls-tls”] }
mail-parser = “0.11.2”
rustls = “0.23”
rustls-pki-types = “1.14.0”
tokio-rustls = “0.26.4”
webpki-roots = “1.0.6”
axum = { version = “0.8”, default-features = false, features = [”http1”, “json”, “tokio”, “query”] }
tower = { version = “0.5”, default-features = false }
tower-http = { version = “0.6”, default-features = false, features = [”limit”, “timeout”] }
http-body-util = “0.1”
opentelemetry = { version = “0.31”, default-features = false, features = [”trace”, “metrics”] }
opentelemetry_sdk = { version = “0.31”, default-features = false, features = [”trace”, “metrics”] }
opentelemetry-otlp = { version = “0.31”, default-features = false, features = [”trace”, “metrics”, “http-proto”, “reqwest-client”, “reqwest-rustls-webpki-roots”] }
nusb = { version = “0.2”, default-features = false, optional = true }
tokio-serial = { version = “5”, default-features = false, optional = true }
probe-rs = { version = “0.30”, optional = true }
pdf-extract = { version = “0.10”, optional = true }
[target.’cfg(target_os = “linux”)’.dependencies]
rppal = { version = “0.14”, optional = true }
[features]
default = [”hardware”]
hardware = [”nusb”, “tokio-serial”]
peripheral-rpi = [”rppal”]
probe = [”dep:probe-rs”]
rag-pdf = [”dep:pdf-extract”]
[profile.release]
opt-level = “z”
lto = “thin”
codegen-units = 8
strip = true
panic = “abort”
[profile.dist]
inherits = “release”
opt-level = “z”
lto = “fat”
codegen-units = 1
strip = true
panic = “abort”
[dev-dependencies]
tokio-test = “0.4”
tempfile = “3.14”We stepped into Cargo to look at Cargo.toml because this manifest is the single source of truth that tells Cargo how to build the zeroclaw binary and which optional pieces of the modular ZeroClaw system get compiled for a given target. The top of the file records standard package metadata such as the crate name zeroclaw, version, edition, author, license, repository, readme, keywords and categories so the package can be identified and published. The dependencies section enumerates the runtime and build-time libraries the agent core, channels, provider adapters, memory backends, and tool/runtime integrations rely on: CLI parsing via clap; async runtime and rich tokio features selected with default-features disabled to avoid pulling platform-specific baggage; HTTP clients tuned for rustls through reqwest; serialization via serde and serde_json; observability via tracing, tracing-subscriber, prometheus and OpenTelemetry with the otlp exporter; crypto primitives including chacha20poly1305, hmac, sha2 and rustls and tokio-rustls for TLS; and many utility crates such as uuid, chrono, parking_lot, anyhow and thiserror. A clear pattern across many dependencies is intentional default-features suppression and selective feature enabling to keep the produced binary minimal and portable across targets from microcontrollers
# file path: deny.toml
[advisories]
unmaintained = “all”
yanked = “deny”
ignore = [
“RUSTSEC-2025-0141”,
]
[licenses]
allow = [
“MIT”,
“Apache-2.0”,
“Apache-2.0 WITH LLVM-exception”,
“BSD-2-Clause”,
“BSD-3-Clause”,
“ISC”,
“Unicode-3.0”,
“Unicode-DFS-2016”,
“OpenSSL”,
“Zlib”,
“MPL-2.0”,
“CDLA-Permissive-2.0”,
“0BSD”,
]
unused-allowed-license = “allow”
[bans]
multiple-versions = “warn”
wildcards = “allow”
[sources]
unknown-registry = “deny”
unknown-git = “deny”
allow-registry = [”https://github.com/rust-lang/crates.io-index”]
allow-git = []deny.toml is the cargo‑deny policy file that the repository and CI run to enforce dependency, security, and licensing hygiene for zeroclaw‑main_cleaned; cargo‑deny reads it during local checks and CI to decide whether the current dependency graph is acceptable or should fail the build. The advisories portion causes the tool to treat unmaintained advisories broadly, to deny use of yanked crates, and to make a deliberate exception for the single advisory RUSTSEC-2025-0141 so that one known advisory is ignored. The licenses portion enumerates the approved set of licenses the project accepts (a concise whitelist including permissive and common open‑source licenses and a few special cases), and it allows entries classified as unused-allowed-license so license metadata that isn’t directly referenced won’t fail the check. The bans section instructs cargo‑deny to emit warnings when multiple versions of the same crate are present while permitting wildcard bans. The sources section blocks dependencies from unknown registries and unknown git sources, restricts allowed registries to the official crates.io index, and does not permit any git sources by default, which enforces provenance constraints on where dependencies may come from. Compared with rust-toolchain.toml, which pins the Rust toolchain channel, and Cargo.toml, which declares package metadata and dependencies, deny.toml plays the complementary role of policy enforcement: it doesn’t change what is built but it prevents unsafe, unlicensed, or untrusted dependencies from entering the ZeroClaw build, thereby helping maintain a secure, license‑compliant baseline for the project.
# file path: dev/ci.sh
set -euo pipefail
if [ -f “dev/docker-compose.ci.yml” ]; then
COMPOSE_FILE=”dev/docker-compose.ci.yml”
elif [ -f “docker-compose.ci.yml” ] && [ “$(basename “$(pwd)”)” = “dev” ]; then
COMPOSE_FILE=”docker-compose.ci.yml”
else
echo “❌ Run this script from repo root or dev/ directory.”
exit 1
fi
compose_cmd=(docker compose -f “$COMPOSE_FILE”)
run_in_ci() {
local cmd=”$1”
“${compose_cmd[@]}” run --rm local-ci bash -c “$cmd”
}
print_help() {
cat <<’EOF’
ZeroClaw Local CI in Docker
Usage: ./dev/ci.sh <command>
Commands:
build-image Build/update the local CI image
shell Open an interactive shell inside the CI container
lint Run rustfmt + clippy correctness gate (container only)
lint-strict Run rustfmt + full clippy warnings gate (container only)
test Run cargo test (container only)
build Run release build smoke check (container only)
audit Run cargo audit (container only)
deny Run cargo deny check (container only)
security Run cargo audit + cargo deny (container only)
docker-smoke Build and verify runtime image (host docker daemon)
all Run lint, test, build, security, docker-smoke
clean Remove local CI containers and volumes
EOF
}
if [ $
print_help
exit 1
fi
case “$1” in
build-image)
“${compose_cmd[@]}” build local-ci
;;
shell)
“${compose_cmd[@]}” run --rm local-ci bash
;;
lint)
run_in_ci “cargo fmt --all -- --check && cargo clippy --locked --all-targets -- -D clippy::correctness”
;;
lint-strict)
run_in_ci “cargo fmt --all -- --check && cargo clippy --locked --all-targets -- -D warnings”
;;
test)
run_in_ci “cargo test --locked --verbose”
;;
build)
run_in_ci “cargo build --release --locked --verbose”
;;
audit)
run_in_ci “cargo audit”
;;
deny)
run_in_ci “cargo deny check licenses sources”
;;
security)
run_in_ci “cargo deny check licenses sources”
run_in_ci “cargo audit”
;;
docker-smoke)
docker build --target dev -t zeroclaw-local-smoke:latest .
docker run --rm zeroclaw-local-smoke:latest --version
;;
all)
run_in_ci “cargo fmt --all -- --check && cargo clippy --locked --all-targets -- -D clippy::correctness”
run_in_ci “cargo test --locked --verbose”
run_in_ci “cargo build --release --locked --verbose”
run_in_ci “cargo deny check licenses sources”
run_in_ci “cargo audit”
docker build --target dev -t zeroclaw-local-smoke:latest .
docker run --rm zeroclaw-local-smoke:latest --version
;;
clean)
“${compose_cmd[@]}” down -v --remove-orphans
;;
*)
print_help
exit 1
;;
esacThe ci.sh script is the repository-level CI entrypoint that standardizes how the ZeroClaw project runs build, test, lint and packaging checks inside a reproducible Docker-based local CI environment. It starts by enabling strict shell failure semantics so any failing command aborts the pipeline, then selects which docker-compose file to use by looking for the CI compose file either in the repo root or the dev directory and aborts with an explanatory message if neither location is valid, enforcing that you invoke the script from the expected working directories. It constructs a compose_cmd array that encapsulates the docker compose invocation against the chosen compose file and exposes a run_in_ci helper function that executes arbitrary shell commands inside the local-ci service container, which relies on the docker-compose.ci.yml service definition to mount the workspace and cargo caches so containerized commands operate on the checked-out source tree and reuse registries and build artifacts. The print_help function renders the user-facing command list and usage, and the script guards for missing or invalid arguments by showing that help and exiting. The main dispatch is a shell case over the first argument that implements the CI surface: build-image builds the local-ci image, shell opens an interactive container shell, lint and lint-strict run formatting and clippy checks inside the CI container with different strictness, test runs cargo test, build runs a release build, audit and deny invoke dependency and policy checks, security runs both deny and audit in sequence, docker-smoke builds and runs a host-side runtime image to verify runtime packaging, all runs the sequence of lint/test/build/security plus the docker-smoke verification, and clean tears down containers and volumes created by the compose file. In practice the script funnels developer or CI system requests into either containerized Cargo-based verification steps (using run_in_ci so work happens inside the local-ci service defined in docker-compose.ci.yml) or into host-side Docker build checks for the runtime image, providing a single, consistent interface for repository-wide validation that mirrors the developer-facing cli.sh patterns for compose selection and environment management but is focused specifically on CI verification tasks.
# file path: dev/config.template.toml
workspace_dir = “/zeroclaw-data/workspace”
config_path = “/zeroclaw-data/.zeroclaw/config.toml”
api_key = “http://host.docker.internal:11434”
default_provider = “ollama”
default_model = “llama3.2”
default_temperature = 0.7
[gateway]
port = 3000
host = “[::]”
allow_public_bind = trueconfig.template.toml declares a compact set of example configuration keys used to bootstrap a development ZeroClaw instance and document the expected shape of the on-disk config that the runtime reads; it is meant to be copied and filled in as the real config.toml so developers can run locally without committing secrets. The file names the workspace location the runtime and sandbox will use for project files, points the agent/provider layer at a local development API key endpoint for provider adapters to talk to, and sets provider-level defaults such as which provider and model to use and the model sampling temperature so the agent core has sensible initial defaults. It also contains a gateway subsection that configures the built-in HTTP gateway — the host, port, and whether binding publicly is allowed — which controls how channel adapters expose endpoints to the outside world. In the repo this template complements docker-compose.yml: the compose file maps environment variables and mounts a live config into the container and intentionally restricts the container port binding to localhost for development, whereas the template shows a permissive public-bind option for convenience. The template’s values are the hand-edited, illustrative counterparts to the programmatic defaults returned by the Config default() implementation and the values produced by run_quick_setup, which creates real Config instances, initializes directories, and fills in defaults (including choosing a provider and model) when generating a config for a user. Overall, config.template.toml’s role is documentation plus a lightweight, editable starting point so the rest of the system — provider adapters, gateway, memory and runtime layers — have predictable configuration during local development.
# file path: dev/docker-compose.ci.yml
name: zeroclaw-local-ci
services:
local-ci:
build:
context: ..
dockerfile: dev/ci/Dockerfile
container_name: zeroclaw-local-ci
working_dir: /workspace
environment:
- CARGO_TERM_COLOR=always
- PATH=/usr/local/cargo/bin:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin
- CARGO_TARGET_DIR=/ci-target
volumes:
- ..:/workspace
- cargo-registry:/usr/local/cargo/registry
- cargo-git:/usr/local/cargo/git
- ci-target:/ci-target
volumes:
cargo-registry:
cargo-git:
ci-target:docker-compose.ci.yml defines a minimal, single-service Docker Compose environment named zeroclaw-local-ci that the CI tooling uses to spin up a reproducible containerized runner for running ZeroClaw’s integration and pipeline jobs. The service local-ci is built from the repository root using the CI-specific Dockerfile under dev/ci, which yields an image preconfigured with the developer toolchain and CI helpers; the container is given a predictable container_name for easier inspection and a working directory inside the image that is mounted to the project workspace so commands run against the repo checkout. A small set of environment variables ensures cargo tooling behaves consistently in CI: colored output is forced, the PATH includes the cargo binary location, and the cargo build output directory is redirected into a named volume so build artifacts land in the shared ci-target volume rather than the container filesystem. Three named Docker volumes are wired in and declared at the bottom—cargo-registry and cargo-git to persist Cargo’s registry and git caches across CI runs (speeding and stabilizing Rust dependency resolution), and ci-target to persist build outputs and avoid redoing expensive compilation between runs. There are no ports, networks, or additional services defined here because ci.sh drives ephemeral runs by invoking the local-ci container to perform tasks like lint, test, and build; ci.sh selects this compose file and runs commands inside the local-ci container, so docker-compose.ci.yml’s role is to provide a consistent, cache-friendly execution environment for those commands. Compared to the multi-service docker-compose.yml used for development, docker-compose.ci.yml is intentionally lightweight and single-purpose: it uses a CI-targeted Dockerfile, omits a sandbox and exposed ports, and focuses on deterministic build/runtime state via named volumes, whereas the development compose file defines a long-running agent and sandbox with networks, port mappings, and user/volume bindings for interactive development.
# file path: dev/docker-compose.yml
name: zeroclaw-dev
services:
zeroclaw-dev:
build:
context: ..
dockerfile: Dockerfile
target: dev
container_name: zeroclaw-dev
restart: unless-stopped
environment:
- API_KEY
- PROVIDER
- ZEROCLAW_MODEL
- ZEROCLAW_GATEWAY_PORT=3000
- SANDBOX_HOST=zeroclaw-sandbox
volumes:
- ../target/.zeroclaw/config.toml:/zeroclaw-data/.zeroclaw/config.toml
- ../playground:/zeroclaw-data/workspace
ports:
- “127.0.0.1:3000:3000”
networks:
- dev-net
sandbox:
build:
context: sandbox
dockerfile: Dockerfile
container_name: zeroclaw-sandbox
hostname: dev-box
command: [”tail”, “-f”, “/dev/null”]
working_dir: /home/developer/workspace
user: developer
environment:
- TERM=xterm-256color
- SHELL=/bin/bash
volumes:
- ../playground:/home/developer/workspace
networks:
- dev-net
networks:
dev-net:
driver: bridgeThe docker-compose.yml defines the development orchestration that the ZeroClaw dev workflow uses to bring up a local agent process and a companion sandbox so you can iterate, test, and debug the whole stack with a single command. It declares a zeroclaw-dev service that is built from the repository root Dockerfile using the dev build target, exposes the agent only on the host loopback so the gateway is reachable at 127.0.0.1:3000, and injects runtime configuration via environment variables for API_KEY, PROVIDER, ZEROCLAW_MODEL, a ZEROCLAW_GATEWAY_PORT set to 3000, and a SANDBOX_HOST that points to the sandbox container; it also mounts the developer config file from the host target directory into the container and binds the local playground directory into the agent’s workspace so code and config changes are immediately visible inside the container. The sandbox service is built from the sandbox subdirectory, presents itself with a dev-box hostname and a persistent working directory mapped to the same playground, runs a no-op tail command to remain available for interactive shells, and runs as the developer user so file permissions on the mapped workspace remain sane for local edits. Both services are placed on a small bridge network named dev-net so the agent can resolve the sandbox by the SANDBOX_HOST alias and call into it when executing tools or running local integrations. This development compose intentionally differs from the production compose by building images locally instead of pulling a prebuilt image, by mounting host paths directly for immediate feedback instead of using a named Docker volume, and by omitting deployment constraints and healthchecks so startup is fast and interactive. The companion dev/cli.sh script coordinates this compose file: ensure_config ensures the target/.zeroclaw config.toml exists (created from the template if missing) and then runs docker compose with this file, after which the agent is available on the localhost gateway and the sandbox remains running for shells and experimentation.
# file path: docker-compose.yml
services:
zeroclaw:
image: ghcr.io/theonlyhennygod/zeroclaw:latest
container_name: zeroclaw
restart: unless-stopped
environment:
- API_KEY=${API_KEY:-}
- PROVIDER=${PROVIDER:-openrouter}
- ZEROCLAW_ALLOW_PUBLIC_BIND=true
volumes:
- zeroclaw-data:/zeroclaw-data
ports:
- “${HOST_PORT:-3000}:3000”
deploy:
resources:
limits:
cpus: ‘2’
memory: 2G
reservations:
cpus: ‘0.5’
memory: 512M
healthcheck:
test: [”CMD”, “zeroclaw”, “doctor”]
interval: 30s
timeout: 10s
retries: 3
start_period: 10s
volumes:
zeroclaw-data:The docker-compose.yml describes a minimal, production-style composition that brings up the ZeroClaw agent core as a single container service called zeroclaw using the prebuilt image from the GHCR registry; its purpose is to make it easy to start the platform’s core for local testing or simple deployments by wiring runtime settings, persistence, networking, and basic operational constraints. At runtime the container is driven by environment variables such as API_KEY and PROVIDER (with a provider default), and ZEROCLAW_ALLOW_PUBLIC_BIND to control whether the agent binds publicly; incoming HTTP requests are routed from a host-side port (configurable via HOST_PORT, defaulting to 3000) into the container’s gateway, so the agent receives traffic directly on its gateway port. Persistence is handled by a named volume called zeroclaw-data that holds workspace and config state across restarts, and the service uses a restart policy so the orchestrator will try to keep the agent running unless explicitly stopped. Resource controls are expressed via the deploy.resources block to limit and reserve CPU and memory for the container, and a healthcheck periodically runs the zeroclaw doctor CLI to verify the agent is healthy before marking the service healthy to the orchestrator; the healthcheck cadence and retry behavior are tuned by interval, timeout, retries, and start period values. Compared with the development docker-compose used by the dev toolchain, this file pulls an image instead of building a dev target, omits the sandbox helper container and custom bridge network, and uses a named Docker volume rather than bind-mounting local target and playground paths; the config.template.toml and the dev CLI script are complementary in that the template expresses equivalent defaults for gateway port, provider, and allow_public_bind while cli.sh handles dev-specific compose selection and ensuring a local config exists. Overall, this compose file functions as the single-command orchestration point for running the agent service with sensible defaults for environment, storage, health, and resource governance.
# file path: examples/custom_channel.rs
use anyhow::Result;
use async_trait::async_trait;
use tokio::sync::mpsc;The imports pull in three foundational pieces used to wire a channel into ZeroClaw: anyhow::Result supplies a unified, flexible error/result type for the fallible operations exposed by the Channel implementation such as send, listen, and health_check; async_trait::async_trait enables implementing those Channel trait methods asynchronously so the channel struct can await I/O and spawn async tasks while conforming to the trait; and tokio::sync::mpsc provides an async multi-producer, single-consumer queue that the listener and sender tasks use to shuttle ChannelMessage payloads and control signals between the channel runtime and the rest of the platform, effectively implementing the producer/consumer pattern for inbound/outbound traffic. Compared with other channel files in the repo, this template keeps imports minimal—other implementations also pull in tokio time utilities, tracing for logging, uuid for identifiers, reqwest for HTTP client work, or serde for deserialization—whereas custom_channel focuses on the core building blocks needed to demonstrate how to wire asynchronous trait methods, error handling, and inter-task messaging into ZeroClaw’s agent layer.
# file path: examples/custom_channel.rs
#[derive(Debug, Clone)]That attribute tells the compiler to automatically emit implementations of the Debug and Clone traits for the struct that follows, so the channel type can be cheaply duplicated and easily formatted for logging or diagnostics. In the context of a ZeroClaw channel template, having Clone on the channel struct makes it straightforward to move the same channel instance into multiple async tasks or message-passing endpoints (for example into a listener loop and into a send helper) without forcing callers to wrap it in heavier synchronization primitives; Debug gives readable state when registering the channel or troubleshooting send/listen behavior. This mirrors the project’s common pattern for channel-like types such as CliChannel, and it relies on the struct’s fields themselves being Clone and Debug so the derived implementations compile. The derive is purely a compile-time convenience and aligns with the surrounding imports and concurrency patterns (Arc/RwLock, async tasks) used elsewhere in the channel implementation.
# file path: examples/custom_channel.rs
pub struct ChannelMessage {
pub id: String,
pub sender: String,
pub content: String,
pub channel: String,
pub timestamp: u64,
}ChannelMessage is the public, transport-level envelope the example channel uses to carry a single user-facing message plus routing metadata between the channel runtime and the rest of ZeroClaw. It contains a stable identifier for deduplication and correlation, a sender identity so the agent and tools can attribute and reply to the right actor, the textual payload that the agent core consumes, a channel identifier used to route or namespace messages when the platform hosts multiple channels, and a numeric timestamp used for ordering and replay semantics; because it’s public it can be created by the listener and consumed by the sender and other platform components, and it is intentionally simple so it can be cheaply logged and cloned (as noted earlier via the derive attribute you saw). In the data flow, inbound platform events are normalized into ChannelMessage instances and pushed onto the tokio mpsc queue the listener produces, and outbound responses are sent back after being wrapped or mapped into ChannelMessage by the send path. Compared to Class_L3, ChannelMessage is effectively the same shape and serves the same transport role; compared to TimelineEvent it is flatter and focused on routing/transport rather than typed event content and serde renames; and compared to ChatMessage it carries transport metadata in addition to the text payload, whereas ChatMessage models model-level role/content semantics for LLM conversations.
# file path: examples/custom_channel.rs
#[async_trait]
pub trait Channel: Send + Sync {
fn name(&self) -> &str;
async fn send(&self, message: &str, recipient: &str) -> Result<()>;
async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> Result<()>;
async fn health_check(&self) -> bool;
}The Channel trait defines the public contract that any custom channel must implement to plug into ZeroClaw’s I/O layer: it requires an implementation of name so the runtime can identify the channel instance, an async send method for emitting outbound text to a given recipient that returns an anyhow::Result to surface failures, an async listen method that receives an mpsc::Sender of ChannelMessage so the channel can produce inbound events into the platform’s async queue, and an async health_check that returns a boolean so the orchestration can probe liveness. Because the trait is annotated with async_trait, implementations can perform awaitable I/O inside these methods, and the trait bounds Send + Sync ensure channel objects are safe to move between tasks and share across threads in the Tokio runtime. Conceptually this trait is the adapter/strategy interface that lets different transport implementations (webhooks, chat platforms, hardware endpoints) present a uniform API: listen implements the producer side by pushing ChannelMessage values into the core via the provided mpsc::Sender, send implements the consumer/outbound side, and health_check provides a lightweight probe (compare the example health_check that performs an HTTP get to a getMe endpoint) so the agent can monitor channel availability.
# file path: examples/custom_channel.rs
pub struct TelegramChannel {
bot_token: String,
allowed_users: Vec<String>,
client: reqwest::Client,
}TelegramChannel encapsulates the minimal state a Telegram-based channel needs to perform the channel responsibilities described by the file: bot_token holds the credential used to authenticate every Telegram API call, allowed_users is the whitelist used to gate incoming messages so the listener only forwards authorized user traffic into the platform, and client is a reusable reqwest HTTP client that the listener and sender use to make network requests to Telegram. Together these fields let the Channel implementation implement name, api_url, send, listen, and health_check: the listener will receive updates (or accept webhooks), validate the sender against allowed_users, and push ChannelMessage objects into the mpsc queue covered earlier; the sender will use the client and bot_token to post outgoing messages back to Telegram. The struct is intentionally compact so it can be cheaply cloned and logged (as enabled by the derived traits noted before) and so that a single persistent HTTP client can be reused across async tasks to avoid repeated connection setup. Class_L39 is essentially the same concrete shape, while Class_L24 provides the constructor and the helper that builds Telegram API URLs and demonstrates how bot_token and client are initialized and used at runtime.
# file path: examples/custom_channel.rs
impl TelegramChannel {
pub fn new(bot_token: &str, allowed_users: Vec<String>) -> Self {
Self {
bot_token: bot_token.to_string(),
allowed_users,
client: reqwest::Client::new(),
}
}
fn api_url(&self, method: &str) -> String {
format!(”https://api.telegram.org/bot{}/{method}”, self.bot_token)
}
}As part of the TelegramChannel implementation that wires a custom inbound/outbound channel into ZeroClaw, the Class_L24 code provides the channel’s constructor and a small utility for building Telegram API endpoints. The new method initializes a TelegramChannel instance by taking an external bot token and a list of allowed user IDs, copying the token into the instance, storing the allowed_users vector, and creating a single reqwest HTTP client that the rest of the channel will reuse for network calls; keeping the client on the struct avoids recreating an HTTP client for each request and centralizes authentication state. The api_url method is a simple helper that combines the stored bot token with a provided Telegram method name to produce the correct HTTPS endpoint the send, listen, and health_check operations will call when talking to Telegram. Conceptually this is the common constructor-plus-helper pattern you see elsewhere in the project (compare the TelegramChannel struct and constructor shown in Class_L19 and the similar struct in Class_L39): new acts like a lightweight factory for a ready-to-use channel instance, and api_url encapsulates the token-to-endpoint mapping so the rest of the channel logic can focus on message handling and not on URL construction.
# file path: examples/custom_channel.rs
pub fn new(bot_token: &str, allowed_users: Vec<String>) -> Self {
Self {
bot_token: bot_token.to_string(),
allowed_users,
client: reqwest::Client::new(),
}
}The new function on TelegramChannel is the constructor that creates a ready-to-use channel instance for the platform: it takes the bot_token and a list of allowed_users, converts or captures the token into an owned String for storage in the struct, keeps the allowed_users vector as the channel’s runtime access control list, and instantiates a reqwest HTTP client to be reused by the channel’s send and listen logic. By producing Self it returns a fully initialized TelegramChannel whose bot_token will be used by api_url to build authenticated requests and whose client provides connection pooling and async HTTP I/O for outbound calls; the allowed_users vector is retained so the listener can filter inbound messages before handing them into ZeroClaw’s producer/consumer queue. This implementation differs only in its parameter form from the similar constructor that accepts an owned String (it accepts a &str and clones it into a String), otherwise it follows the same pattern of storing the three core fields defined on the struct.
# file path: examples/custom_channel.rs
fn api_url(&self, method: &str) -> String {
format!(”https://api.telegram.org/bot{}/{method}”, self.bot_token)
}Within the TelegramChannel implementation for ZeroClaw, api_url is the small helper that turns a logical Telegram method name into the full HTTPS endpoint the channel uses to talk to Telegram’s REST API. It reads the bot_token field from the TelegramChannel instance and combines it with the provided method string to produce a single String containing the complete URL the reqwest::Client and the channel’s send, listen, and health_check methods will call. Because it centralizes URL construction, callers simply pass the Telegram method they need (for example a send or polling method) and receive a ready-to-use endpoint; the function returns the assembled String every time. This is the same pattern repeated in the other TelegramChannel snippet you saw, where new constructs the client and api_url performs identical URL assembly.
# file path: examples/custom_channel.rs
#[async_trait]The async_trait attribute macro placed immediately before the Channel implementation tells the compiler to allow async functions inside the trait impl so the channel can perform awaitable I/O when fulfilling the Channel contract. In the context of ZeroClaw’s channel template, it enables the implementation of send, listen, and health_check as asynchronous routines that can await tokio operations, spawn tasks, and push or pull ChannelMessage values on the tokio::sync::mpsc queues that connect the channel runtime to the rest of the agent. This is the same pattern used elsewhere in the codebase where traits must expose async behavior; here it’s what makes the custom channel a first‑class, polymorphic asynchronous component that integrates with the platform’s producer/consumer flow and the agent core’s runtime orchestration.
# file path: examples/custom_channel.rs
impl Channel for TelegramChannel {
fn name(&self) -> &str {
“telegram”
}
async fn send(&self, message: &str, chat_id: &str) -> Result<()> {
self.client
.post(self.api_url(”sendMessage”))
.json(&serde_json::json!({
“chat_id”: chat_id,
“text”: message,
“parse_mode”: “Markdown”,
}))
.send()
.await?;
Ok(())
}
async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> Result<()> {
let mut offset: i64 = 0;
loop {
let resp = self
.client
.get(self.api_url(”getUpdates”))
.query(&[(”offset”, offset.to_string()), (”timeout”, “30”.into())])
.send()
.await?
.json::<serde_json::Value>()
.await?;
if let Some(updates) = resp[”result”].as_array() {
for update in updates {
if let Some(msg) = update.get(”message”) {
let sender = msg[”from”][”username”]
.as_str()
.unwrap_or(”unknown”)
.to_string();
if !self.allowed_users.is_empty() && !self.allowed_users.contains(&sender) {
continue;
}
let channel_msg = ChannelMessage {
id: msg[”message_id”].to_string(),
sender,
content: msg[”text”].as_str().unwrap_or(”“).to_string(),
channel: “telegram”.into(),
timestamp: msg[”date”].as_u64().unwrap_or(0),
};
if tx.send(channel_msg).await.is_err() {
return Ok(());
}
}
offset = update[”update_id”].as_i64().unwrap_or(offset) + 1;
}
}
}
}
async fn health_check(&self) -> bool {
self.client
.get(self.api_url(”getMe”))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}
}The TelegramChannel implements the Channel trait to wire a Telegram-based inbound/outbound path into ZeroClaw: name simply identifies the channel as telegram so the platform can route messages; send performs an outbound HTTP POST to the Telegram sendMessage endpoint using the channel’s HTTP client and sends a JSON body containing the chat identifier, the message text, and a Markdown parse mode, returning a fallible Result so caller code can observe network errors; listen implements a long‑polling loop against Telegram’s getUpdates endpoint, maintaining an integer offset to acknowledge processed updates, parsing the JSON response into a generic value, iterating each update to locate a message, extracting a sender username with a safe fallback, filtering out messages from users not present in allowed_users when that list is nonempty, building a ChannelMessage with id, sender, content, channel label, and timestamp, and forwarding that ChannelMessage into the async mpsc Sender so the rest of the agent pipeline receives inbound events — if sending on the mpsc fails the listener returns cleanly; health_check calls the getMe endpoint and returns true only if the HTTP response indicates success. The implementation uses the async_trait pattern and the shared Result type you saw in imports so the async I/O and error propagation integrate with the platform’s runtime and CI expectations. Compared with the more elaborate Telegram implementation elsewhere (Class_L327), this variant is a simpler template: it issues a single POST for send without chunking, retry, or fallback logic and performs straightforward GET-based polling in listen without the extra error backoff, allowed_updates hints, or detailed logging present in the other implementation.
# file path: examples/custom_channel.rs
fn name(&self) -> &str {
“telegram”
}The name method on the channel struct is the Channel trait’s simple identifier implementation and returns the canonical, static channel label used across the platform to register and route traffic for this adapter — in this file it identifies the channel as telegram. The agent core, registries, and runtime use that label when wiring endpoints, selecting which channel to send outbound ChannelMessage payloads to, and when reporting health and metrics; because the Channel trait methods like send, listen, and health_check rely on a stable, cheap string, name returns a static string slice so the identifier is inexpensive to copy or compare. This follows the same pattern as the other channel implementations such as the ones that return email or slack, where each channel supplies its own human- and machine-friendly label so the ZeroClaw orchestration can recognize and route messages to the appropriate adapter.
# file path: examples/custom_channel.rs
async fn send(&self, message: &str, chat_id: &str) -> Result<()> {
self.client
.post(self.api_url(”sendMessage”))
.json(&serde_json::json!({
“chat_id”: chat_id,
“text”: message,
“parse_mode”: “Markdown”,
}))
.send()
.await?;
Ok(())
}As the Channel trait’s send implementation, send is the outbound path that turns a platform message and a destination identifier into a network request so the external chat service can display the text. It takes the message text and a chat_id, uses the channel’s HTTP client stored on self and the channel’s api_url helper to target the remote sendMessage endpoint, and serializes a small JSON payload containing the chat_id, the message text, and an explicit parse_mode of Markdown to enable rich formatting. The function awaits the HTTP POST and surfaces any transport-level error through the anyhow::Result return type explained in the imports, and on success it returns an empty Ok to signal completion. Control flow is simple: the happy path completes after the awaited send and returns success, while any failure in the HTTP send is propagated to the caller. Compared with other send implementations in the repo, this send is minimal: it does not split long messages or implement retry/fallback logic like the Telegram-oriented version, nor does it inspect HTTP status fields or parse JSON responses like the Slack variant, and it is more functional than the trivial println-based sender; it therefore serves as a concise template showing how to wire outgoing ChannelMessage traffic into an HTTP-based service.
# file path: examples/custom_channel.rs
async fn listen(&self, tx: mpsc::Sender<ChannelMessage>) -> Result<()> {
let mut offset: i64 = 0;
loop {
let resp = self
.client
.get(self.api_url(”getUpdates”))
.query(&[(”offset”, offset.to_string()), (”timeout”, “30”.into())])
.send()
.await?
.json::<serde_json::Value>()
.await?;
if let Some(updates) = resp[”result”].as_array() {
for update in updates {
if let Some(msg) = update.get(”message”) {
let sender = msg[”from”][”username”]
.as_str()
.unwrap_or(”unknown”)
.to_string();
if !self.allowed_users.is_empty() && !self.allowed_users.contains(&sender) {
continue;
}
let channel_msg = ChannelMessage {
id: msg[”message_id”].to_string(),
sender,
content: msg[”text”].as_str().unwrap_or(”“).to_string(),
channel: “telegram”.into(),
timestamp: msg[”date”].as_u64().unwrap_or(0),
};
if tx.send(channel_msg).await.is_err() {
return Ok(());
}
}
offset = update[”update_id”].as_i64().unwrap_or(offset) + 1;
}
}
}
}The TelegramChannel listen method implements the inbound half of the Channel trait by long‑polling Telegram’s getUpdates endpoint and turning each incoming Telegram update into a ChannelMessage that the agent core can consume via the provided mpsc::Sender. It maintains an integer offset and, in an infinite loop, issues a network request through the channel’s HTTP client to fetch updates with that offset and a 30‑second timeout, then parses the response into a serde_json::Value and looks for a result array. For each update it finds, it looks for a message object, safely extracts the sender username with a fallback of “unknown”, enforces the allowed_users filter when that list is nonempty, and builds a ChannelMessage populated with a message identifier, sender, the text (using an empty string fallback if missing), a fixed “telegram” channel label, and a timestamp. Each ChannelMessage is forwarded into the agent pipeline by awaiting a send on the tx mpsc::Sender; if that send fails the listener returns cleanly. After processing an update, listen advances the offset to the update_id plus one so processed updates are acknowledged and not reprocessed. Network and JSON errors are propagated via the Result return so caller code can observe failures; otherwise the loop continues polling to supply a steady stream of inbound events to the rest of ZeroClaw.
# file path: examples/custom_channel.rs
async fn health_check(&self) -> bool {
self.client
.get(self.api_url(”getMe”))
.send()
.await
.map(|r| r.status().is_success())
.unwrap_or(false)
}As noted earlier, health_check calls the Telegram getMe endpoint and returns true only if the HTTP response indicates success. In this implementation TelegramChannel builds an HTTP GET request by asking api_url for the getMe path, sends that request with its reqwest client, and awaits the network round trip; if the request completes successfully the code inspects the response status and returns true for HTTP success codes and false otherwise. Any network error or other failure in sending or receiving the response is converted into a false result via the final fallback, so the function yields a simple boolean heartbeat rather than exposing error details. Control flow is therefore straightforward: the happy path is a completed request with a 2xx status returning true; non-2xx responses, request errors, or other failures return false. This follows the same basic probe pattern used elsewhere (the Slack and Facebook variants also map an HTTP status success to a boolean), but differs from the alternative Telegram health_check variant that wraps the request in a timeout and emits debug logs on errors or timeouts; unlike the Slack and Facebook implementations that attach explicit auth headers, this TelegramChannel relies on api_url to produce the token-bearing endpoint. The function is async, which is allowed by the async_trait used on the Channel implementation, so it can perform the awaitable I/O needed for the platform’s liveness checks.
# file path: examples/custom_channel.rs
fn main() {
println!(”This is an example — see CONTRIBUTING.md for integration steps.”);
println!(”Add your channel config to ChannelsConfig in src/config/schema.rs”);
}The main function simply acts as a developer-facing placeholder: when executed it prints two short instructions pointing contributors to the repository’s contribution guide and telling them where to add their channel configuration (the ChannelsConfig entry in src/config/schema.rs). Remember the api_url helper and the TelegramChannel implementation we walked through earlier; main does not instantiate the channel, start the long‑poll listener, or call send/listen/health_check — it just signals that this file is an example template and that actual integration requires registering the channel in the platform configuration. This mirrors the pattern used by the example mains for tools and providers, which likewise only print integration steps and the location in the codebase where the component must be registered rather than launching runtime behavior.
# file path: examples/custom_memory.rs
use async_trait::async_trait;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::sync::Mutex;The import list brings in the building blocks needed to implement a small, thread-safe, serializable memory backend for ZeroClaw: async_trait provides the macro support so the Memory trait can expose async methods (remember the async_trait attribute macro used in the TelegramChannel implementation — here it serves the same purpose for the memory implementation), serde’s Deserialize and Serialize derive traits allow memory entries to be encoded and decoded for persistence, debugging, or interop with other parts of the platform, HashMap supplies the in-memory key/value mapping the backend uses to store entries, and Mutex wraps that HashMap to provide safe concurrent access from multiple async tasks or threads. This combination mirrors a pattern already used elsewhere in the project where an in-memory backend is implemented with a Mutex-protected HashMap and serde/async_trait are commonly pulled in; the only differences in other similar files are extra imports like the Memory trait itself or formatting helpers when they also implement trait plumbing or produce textual output.
# file path: examples/custom_memory.rs
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
pub enum MemoryCategory {
Core,
Daily,
Conversation,
Custom(String),
}
#[derive(Debug, Clone, Serialize, Deserialize)]MemoryCategory defines the canonical set of buckets the custom memory backend uses to label and organize stored facts: it has three fixed buckets named Core, Daily, and Conversation, plus a Custom variant that carries an arbitrary string for extensibility. Those variants are annotated so they can be cloned, debug-printed, serialized/deserialized, and compared for equality, which is important because the memory layer serializes entries to persistent storage, clones them when manipulating in-memory caches, and needs cheap equality checks for deduplication and comparison. MemoryCategory is the field type used by MemoryEntry to tag each stored record, so the backend’s store, recall, get, forget, and count operations can filter, score, and surface memories by category; the to_memory_category helper maps freeform labels into these variants and the Display implementation renders them into stable labels for logging and query languages. The presence of a Custom string variant deliberately supports app-specific classification beyond the three built-in buckets so the template memory implementation can be used unchanged across different ZeroClaw deployments.
# file path: examples/custom_memory.rs
pub struct MemoryEntry {
pub id: String,
pub key: String,
pub content: String,
pub category: MemoryCategory,
pub timestamp: String,
pub score: Option<f64>,
}MemoryEntry is the canonical record type the example memory backend uses to represent a single stored memory, and it serves as the atomic unit that the memory layer persists, retrieves, and manipulates when the platform asks the backend to store, recall, get, forget, or count memories. The id field holds a unique identifier (typically a generated UUID) so each stored item has an immutable identity independent of the lookup key, while the key field is the lookup name the backend maps to for get and forget operations; content contains the textual payload the agent wants to remember; category is an enum value that classifies the memory so higher-level code can filter by type; timestamp is kept as a string (the example uses an RFC3339 timestamp) to make ordering and serialization straightforward across storage backends; and score is an optional floating point relevance value used by recall/ranking logic. In the file’s data flow, the store implementation constructs a MemoryEntry with a new id and timestamp and inserts it into the in-memory map keyed by key, recall reads and filters MemoryEntry.content to produce ranked results, get returns an optional cloned MemoryEntry for a single key, forget removes the entry by key, and count returns the map size; those behaviors are embodied in the InMemoryBackend implementation elsewhere. Compared with the similar MemoryEntry in Class_L4, this definition omits session_id, so it models unscoped memories rather than session-scoped entries; otherwise the fields and their roles align with the memory trait implementations like those in Class_L47.
# file path: examples/custom_memory.rs
#[async_trait]
pub trait Memory: Send + Sync {
fn name(&self) -> &str;
async fn store(&self, key: &str, content: &str, category: MemoryCategory)
-> anyhow::Result<()>;
async fn recall(&self, query: &str, limit: usize) -> anyhow::Result<Vec<MemoryEntry>>;
async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>>;
async fn forget(&self, key: &str) -> anyhow::Result<bool>;
async fn count(&self) -> anyhow::Result<usize>;
}Memory declares the pluggable contract the rest of ZeroClaw’s memory layer depends on: it names the backend and exposes the async operations the agent core will call to persist, search, fetch, delete, and inspect stored memories. The trait requires implementors to be Send and Sync so a backend can be shared safely across async tasks and threads, and the async_trait attribute (as used earlier on Channel) allows its methods to be implemented asynchronously because real backends may perform I/O. name returns the backend’s static identifier used when wiring the memory implementation into the platform. store asks the backend to persist a piece of content under a key with a MemoryCategory and returns a fallible result so callers can observe storage errors. recall performs a query-style lookup returning up to limit MemoryEntry items to supply contextual history to the agent when composing prompts. get fetches a single MemoryEntry by key and returns an optional result, forget removes an entry and reports whether anything was removed, and count returns the current number of stored entries for diagnostics or quota checks. Each async method uses anyhow::Result to propagate errors uniformly. Together this trait implements a Strategy/Adapter-style extension point: concrete types like InMemoryBackend implement these methods to provide the actual storage mechanics (locking, hash map, timestamps, scoring, etc.), while the agent core relies solely on this abstract contract to store and retrieve memories during runtime.
# file path: examples/custom_memory.rs
pub struct InMemoryBackend {
store: Mutex<HashMap<String, MemoryEntry>>,
}InMemoryBackend is the concrete, in‑process memory store that implements ZeroClaw’s memory backend contract: it holds all persisted MemoryEntry objects in a single HashMap keyed by String and wrapped in a Mutex so multiple tasks or threads can access and mutate the store safely. As the platform’s simple, volatile repository implementation, InMemoryBackend is the place where the memory layer writes, reads, and deletes entries during agent execution; keys represent the memory identifiers and MemoryEntry holds the stored content and metadata (defined elsewhere). The Mutex+HashMap combination is intentionally minimal and synchronous to make this backend easy to reason about and ideal for testing or lightweight deployments where durable persistence isn’t required. The Default implementation constructs an empty, mutex‑protected map and the new constructor simply delegates to that default, so the instantiation path is identical to the similar helper implementations you saw in Class_L41 and Class_L34; InMemoryBackend itself is the single authoritative state container that the store/recall/get/forget/count methods operate on.
# file path: examples/custom_memory.rs
impl Default for InMemoryBackend {
fn default() -> Self {
Self {
store: Mutex::new(HashMap::new()),
}
}
}The Default implementation for InMemoryBackend establishes the canonical, empty starting state the platform uses whenever it needs a fresh memory backend: it constructs the store field as an empty hash map wrapped in a mutex so the memory store is ready for concurrent access by the agent core and runtime tools. Because InMemoryBackend declares store as a Mutex<HashMap<String, MemoryEntry>, the Default impl ensures that callers get a thread‑safe, initialized container without having to know the internals. InMemoryBackend::new simply delegates to this Default implementation, so the code provides both the idiomatic Rust Default factory and a convenience constructor that returns the same initialized backend; together they make it trivial to plug this in as the example memory backend when the platform wires a memory layer into the agent pipeline.
# file path: examples/custom_memory.rs
fn default() -> Self {
Self {
store: Mutex::new(HashMap::new()),
}
}The default method implements the Default trait for the custom memory backend by producing a fresh instance whose internal store is an empty, mutex‑protected map; its job is to give the memory layer a clean, thread‑safe starting state whenever the backend is created. In the context of ZeroClaw’s memory layer, that means when the backend is plugged into the platform the initial state contains no entries and concurrent access to the underlying HashMap is synchronized by the Mutex so later store and recall calls can mutate and read the map safely. This is why new simply delegates to default elsewhere in the file: default centralizes the canonical initialization logic so other constructors can return a correctly initialized backend without duplicating setup.
# file path: examples/custom_memory.rs
impl InMemoryBackend {
pub fn new() -> Self {
Self::default()
}
}The InMemoryBackend implementation provides a simple public constructor called new that delegates to the Default implementation to produce a ready-to-use backend instance; in other words, callers can obtain a fresh, thread-safe in-memory store by invoking new, and under the hood that construction logic is centralized in the Default implementation which initializes the mutex‑protected empty HashMap used to hold MemoryEntry records. This keeps instantiation consistent with the rest of the memory layer, offers an ergonomic factory-style entry point for plugging the backend into ZeroClaw’s memory system, and ensures the same initialization semantics already defined for InMemoryBackend are used whenever a new instance is required.
# file path: examples/custom_memory.rs
pub fn new() -> Self {
Self::default()
}The new method on InMemoryBackend simply provides a convenience constructor that returns a fresh InMemoryBackend instance by delegating to the type’s Default implementation; that Default implementation is what actually allocates the internal store as an empty HashMap wrapped in a Mutex so the memory backend is ready for concurrent use by the agent core and runtime tools. In practice new is a thin factory that ensures callers (for example the example main or the memory layer wiring code) get a consistently initialized, thread‑safe backend without duplicating setup logic; there are no branches or special cases here, and its behavior mirrors the analogous constructor found elsewhere in the project.
# file path: examples/custom_memory.rs
#[async_trait]The async_trait attribute sits immediately before the implementation that makes InMemoryBackend satisfy the Memory trait. Memory declares the async operations the agent core will call, and because Rust does not yet support async methods on traits directly, the async_trait macro rewrites those async trait methods into a form that returns boxed futures so the impl can provide async implementations of store, recall, get, forget, and count. That transformation lets the InMemoryBackend await on internal operations — for example acquiring the Mutex to access the HashMap of MemoryEntry records — and present an asynchronous API the agent core and runtime can call without blocking. The file imports async_trait earlier, matching other modules that follow the same pattern, so this attribute simply enables the concrete backend to implement the asynchronous Memory contract used across ZeroClaw’s memory layer.
# file path: examples/custom_memory.rs
impl Memory for InMemoryBackend {
fn name(&self) -> &str {
“in-memory”
}
async fn store(
&self,
key: &str,
content: &str,
category: MemoryCategory,
) -> anyhow::Result<()> {
let entry = MemoryEntry {
id: uuid::Uuid::new_v4().to_string(),
key: key.to_string(),
content: content.to_string(),
category,
timestamp: chrono::Local::now().to_rfc3339(),
score: None,
};
self.store
.lock()
.map_err(|e| anyhow::anyhow!(”{e}”))?
.insert(key.to_string(), entry);
Ok(())
}
async fn recall(&self, query: &str, limit: usize) -> anyhow::Result<Vec<MemoryEntry>> {
let store = self.store.lock().map_err(|e| anyhow::anyhow!(”{e}”))?;
let query_lower = query.to_lowercase();
let mut results: Vec<MemoryEntry> = store
.values()
.filter(|e| e.content.to_lowercase().contains(&query_lower))
.cloned()
.collect();
results.truncate(limit);
Ok(results)
}
async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>> {
let store = self.store.lock().map_err(|e| anyhow::anyhow!(”{e}”))?;
Ok(store.get(key).cloned())
}
async fn forget(&self, key: &str) -> anyhow::Result<bool> {
let mut store = self.store.lock().map_err(|e| anyhow::anyhow!(”{e}”))?;
Ok(store.remove(key).is_some())
}
async fn count(&self) -> anyhow::Result<usize> {
let store = self.store.lock().map_err(|e| anyhow::anyhow!(”{e}”))?;
Ok(store.len())
}
}The impl of Memory for InMemoryBackend wires the backend into ZeroClaw’s memory contract so the agent core can persist and retrieve contextual records using the familiar Memory API. The name method simply identifies this backend as the in‑process option used for demos and testing. The store implementation builds a MemoryEntry (so each persisted item carries its id, key, content, category, timestamp and optional score), generates a unique id and timestamp, then acquires the Mutex guarding the internal HashMap to insert the entry under the provided key; lock failures are converted into anyhow errors so callers see a uniform error type. The recall method acquires the same lock, performs a case-insensitive in‑memory scan across values to find entries whose content contains the query, clones matching MemoryEntry objects into a result vector, truncates that vector to the requested limit, and returns it; that gives the agent core a simple substring search recall strategy. The get method locks and returns a cloned optional MemoryEntry for an exact key lookup, while forget locks, removes the key and returns a boolean indicating whether an entry was actually removed. The count method locks and returns the current number of stored entries. All operations are async and return anyhow::Result to match the Memory trait, and the Mutex+HashMap combination implements a simple repository‑style, thread‑safe in‑process store that demonstrates how a pluggable backend can satisfy ZeroClaw’s memory layer contract.
# file path: examples/custom_memory.rs
fn name(&self) -> &str {
“in-memory”
}The name function on InMemoryBackend implements the Memory contract’s identity requirement by returning a static textual identifier for this backend so the rest of ZeroClaw can refer to, log, select, or report which memory implementation is in use; given that MemoryEntry, the Memory trait, and InMemoryBackend (with its Default) are already defined, name supplies the simple, constant backend label that the agent core and registries will see when they query the backend, and it follows the same lightweight pattern as other implementations that return their own fixed identifiers (examples include memory_store, none, and lucid) — a cheap, non‑allocating string reference used solely to identify this particular in‑process memory backend.
# file path: examples/custom_memory.rs
async fn store(
&self,
key: &str,
content: &str,
category: MemoryCategory,
) -> anyhow::Result<()> {
let entry = MemoryEntry {
id: uuid::Uuid::new_v4().to_string(),
key: key.to_string(),
content: content.to_string(),
category,
timestamp: chrono::Local::now().to_rfc3339(),
score: None,
};
self.store
.lock()
.map_err(|e| anyhow::anyhow!(”{e}”))?
.insert(key.to_string(), entry);
Ok(())
}The store method on InMemoryBackend implements the Memory::store contract by taking the key, content, and category handed to it by the agent core and materializing a MemoryEntry that it then inserts into the backend’s in‑process store. It builds a MemoryEntry with a freshly generated UUID for id, copies of the provided key and content, the supplied category, a timestamp generated from chrono::Local in RFC3339 form, and an empty score field; that MemoryEntry is the same canonical unit we discussed earlier. To persist it, the method acquires the Mutex protecting the HashMap store, converts any lock poison error into an anyhow error, and inserts the new entry under the provided key (overwriting any previous entry for that key). The function then returns success. This implementation is intentionally minimal and synchronous in effect (the async signature exists to satisfy the Memory trait): it does not compute embeddings, perform upserts with conflict‑resolution metadata, or trigger any external sync like the SQLite or sync-to-lucid variants do; instead it provides a fast, thread‑safe, in‑memory write that subsequent recall, get, forget, and count operations will read from.
# file path: examples/custom_memory.rs
async fn recall(&self, query: &str, limit: usize) -> anyhow::Result<Vec<MemoryEntry>> {
let store = self.store.lock().map_err(|e| anyhow::anyhow!(”{e}”))?;
let query_lower = query.to_lowercase();
let mut results: Vec<MemoryEntry> = store
.values()
.filter(|e| e.content.to_lowercase().contains(&query_lower))
.cloned()
.collect();
results.truncate(limit);
Ok(results)
}The recall method on InMemoryBackend is the in‑process implementation the Memory trait uses to fetch relevant MemoryEntry records for a simple, synchronous search. When called it first tries to acquire the mutex protecting the internal store and converts any lock failure into a generic anyhow error so callers get a consistent Result on failure. It then normalizes the incoming query to lowercase and scans every stored MemoryEntry by iterating the map’s values, performing a case‑insensitive substring test against each entry’s content; matching entries are cloned into a results vector. After collecting matches it enforces the requested limit by truncating the vector and returns the resulting Vec inside an Ok. Compared with the other recall implementations in the repository, this version is intentionally minimal: it does no tokenization, scoring, sorting, or embedding-based nearest‑neighbor work (other recall variants compute per‑entry scores or run hybrid keyword/vector searches and then sort by score), so recall here returns matches in the map’s iteration order and does not populate or modify any score fields; its simplicity makes it a lightweight example backend that demonstrates how the Memory trait’s recall contract can be satisfied with an in‑memory substring search.
# file path: examples/custom_memory.rs
async fn get(&self, key: &str) -> anyhow::Result<Option<MemoryEntry>> {
let store = self.store.lock().map_err(|e| anyhow::anyhow!(”{e}”))?;
Ok(store.get(key).cloned())
}get is the InMemoryBackend implementation of the Memory trait’s fetch operation: when asked for a memory by key it first acquires the Mutex protecting the backend’s HashMap store, converting a poisoned lock error into an anyhow error so the async caller receives a failure in the same Result type the rest of ZeroClaw expects. With the lock held it looks up the key in the map and returns a cloned MemoryEntry wrapped in an Option so the caller gets ownership of the entry rather than a reference tied to the lock guard; that cloning both preserves thread safety and allows the function to release the lock immediately after the lookup. Compared to the other get implementations in the project, this one is the simple in‑process path: one variant simply delegates to a local async store, another is a no‑op that always returns None, and a third performs a locked SQLite query and maps a row into a MemoryEntry—get here trades the database query and row mapping for a fast in‑memory lookup and a direct clone of the stored MemoryEntry.
# file path: examples/custom_memory.rs
async fn forget(&self, key: &str) -> anyhow::Result<bool> {
let mut store = self.store.lock().map_err(|e| anyhow::anyhow!(”{e}”))?;
Ok(store.remove(key).is_some())
}Remember MemoryEntry and that InMemoryBackend keeps all persisted MemoryEntry objects in a HashMap protected by a Mutex. The forget method obtains the Mutex lock to get exclusive access to that in‑process store; if acquiring the lock fails (e.g., lock poisoning), it converts that failure into an anyhow error and returns it to the caller. Once the lock is held, forget attempts to remove the entry identified by the provided key from the map and then returns success as a boolean indicating whether an entry was actually removed. In other words, the happy path is: acquire lock, remove by key, return true if something was deleted or false if the key was absent; the error path is lock acquisition failure surfaced as an anyhow::Error. Compared with the other forget implementations in the codebase, this one performs an immediate in‑memory removal under a Mutex, the delegated implementation simply forwards to a local backend, the SQL implementation performs a DELETE and checks affected row count, and the no‑op implementation always returns false. This method therefore fulfills the Memory contract for an in‑process backend by removing the MemoryEntry and reporting whether the deletion occurred.
# file path: examples/custom_memory.rs
async fn count(&self) -> anyhow::Result<usize> {
let store = self.store.lock().map_err(|e| anyhow::anyhow!(”{e}”))?;
Ok(store.len())
}count on InMemoryBackend is the async implementation that answers how many MemoryEntry records the backend currently holds: it acquires the mutex guarding the in‑process HashMap to get exclusive, thread‑safe access, converts any poisoned lock error into an anyhow error and returns it to the caller, and on the happy path simply returns the map’s length as the usize result. Remember MemoryEntry and the Memory trait we looked at earlier; count conforms to that async contract even though the actual work is a quick, in‑memory read, because the memory layer expects async operations. Compared with other count implementations in the codebase, this one directly inspects the locked HashMap rather than delegating to another backend, whereas one variant simply forwards the call to an inner local backend and another performs a SQL COUNT query (acquiring a DB connection lock and converting the integer result to usize). There is also a stub that always returns zero. The key control flow here is lock acquisition (with error mapping and early return on failure) followed by computing and returning the length on success.
# file path: examples/custom_memory.rs
#[tokio::main]The tokio::main attribute decorates the example’s entry point so the example runs inside a Tokio runtime; it sets up and drives an asynchronous executor and then runs the async main function that follows. Because InMemoryBackend implements the Memory trait with async methods like store, recall, get, forget, and count (which we already covered), the example main needs an async context to await those operations; tokio::main provides that context without requiring the example to manually construct a runtime. This matches the pattern used by other example mains in the project and ensures the memory backend demo executes in the same async environment ZeroClaw uses for channels, provider adapters, and other async components.
# file path: examples/custom_memory.rs
async fn main() -> anyhow::Result<()> {
let brain = InMemoryBackend::new();
println!(”🧠 ZeroClaw Memory Demo — InMemoryBackend\n”);
brain
.store(”user_lang”, “User prefers Rust”, MemoryCategory::Core)
.await?;
brain
.store(”user_tz”, “Timezone is EST”, MemoryCategory::Core)
.await?;
brain
.store(
“today_note”,
“Completed memory system implementation”,
MemoryCategory::Daily,
)
.await?;
println!(”Stored {} memories”, brain.count().await?);
let results = brain.recall(”Rust”, 5).await?;
println!(”\nRecall ‘Rust’ → {} results:”, results.len());
for entry in &results {
println!(” [{:?}] {}: {}”, entry.category, entry.key, entry.content);
}
if let Some(entry) = brain.get(”user_tz”).await? {
println!(”\nGet ‘user_tz’ → {}”, entry.content);
}
let removed = brain.forget(”user_tz”).await?;
println!(”Forget ‘user_tz’ → removed: {removed}”);
println!(”Remaining: {} memories”, brain.count().await?);
println!(”\n✅ Memory backend works! Implement the Memory trait for any storage.”);
Ok(())
}The main function is an async demonstration of wiring an InMemoryBackend into ZeroClaw’s Memory layer and exercising the Memory trait methods; it constructs an InMemoryBackend via new, prints a short banner to the console, and then uses store three times to persist example MemoryEntry objects under different keys and MemoryCategory values. It then calls count to report how many memories are present, calls recall with a text query and a limit and iterates over the returned vector to print each entry’s category, key, and content, and uses get to fetch a single memory by key and print its content if present. After that it calls forget to remove the user_tz entry and reports whether the removal succeeded, calls count again to show the remaining number of memories, prints a success line, and returns Ok so any propagated anyhow::Error from the awaited Memory calls bubbles up to the caller. Because the Memory trait methods are async, main awaits each operation and uses the Result-returning convention of anyhow so error handling is uniform; as discussed earlier, store, recall, get, forget, and count are the InMemoryBackend implementations that operate on the in‑process, Mutex‑protected HashMap, and main simply demonstrates the data flow and control paths you would follow when plugging a custom memory backend into ZeroClaw.
# file path: examples/custom_provider.rs
use anyhow::Result;
use async_trait::async_trait;The file brings in anyhow::Result so the provider implementation can return the same ergonomic, boxed error/result type the rest of ZeroClaw uses for fallible operations, keeping error propagation consistent with components like the InMemoryBackend you already studied. It also imports async_trait::async_trait so the provider adapter can implement async trait methods — a common pattern in ZeroClaw for adapters that need async lifecycle or RPC-style calls; using async_trait lets the provider define async chat and call methods on the Provider trait even though Rust traits themselves are not async by default. Related files you saw follow the same pattern but add extras as needed: some provider or adapter examples also pull in serde_json types for composing and parsing JSON payloads when talking to external LLM APIs, others bring in tokio::sync::mpsc for streaming or background task coordination, and some include crate::tools::ToolSpec and serde derive macros to serialize tool specs; by contrast this example keeps imports minimal because it focuses on demonstrating the async trait implementation and unified error handling rather than JSON shaping, channel wiring, or tool serialization.
# file path: examples/custom_provider.rs
#[async_trait]
pub trait Provider: Send + Sync {
async fn chat(&self, message: &str, model: &str, temperature: f64) -> Result<String>;
}Provider defines the async interface ZeroClaw uses to talk to a language-model adapter: it’s a trait named Provider that requires implementors to be Send and Sync so a single adapter instance can be safely shared across async tasks and threads in the runtime. The trait is marked with the async_trait helper because Rust doesn’t allow async methods directly in traits; that lets implementations provide an async chat implementation. The chat method itself is the minimal contract: the agent core hands a prompt string, a model identifier string, and a temperature value and expects back a Result wrapping the model’s textual reply, so any network, parsing, or provider-specific error surfaces through the standard Result path. Conceptually this sits next to the memory layer we reviewed earlier (store/recall/get/forget/count) — while those persist and recall context, Provider is the pluggable adapter that turns the composed prompt into a live model response. Implementations like OllamaProvider fulfill this trait by issuing an HTTP call to a model endpoint, extracting the response text and returning it, and the multi-provider router example resolves a provider and delegates to its chat implementation, showing how Provider enables a simple adapter pattern for swapping different LLM backends.
# file path: examples/custom_provider.rs
pub struct OllamaProvider {
base_url: String,
client: reqwest::Client,
}OllamaProvider is the simple state container that the provider adapter layer uses to talk to an Ollama model server: it holds a base_url string that tells the adapter where to send model requests and a reqwest::Client instance that performs those HTTP calls and preserves connection pooling across calls. In the runtime narrative, the agent core will instantiate an OllamaProvider and then hand it prompt data; OllamaProvider uses its base_url to target the correct server and uses reqwest::Client to execute the outbound request and return the model response back into the agent pipeline (which may then use memory operations like store or recall as we discussed earlier). This struct mirrors the pattern used elsewhere in the codebase: Class_L5 defines the same two fields but used an imported Client type name rather than the fully qualified reqwest::Client, and Class_L11 shows the accompanying constructor named new that defaults the base_url and creates a new reqwest::Client for the provider; Class_L7 itself only declares the fields, leaving construction and request logic to the surrounding example code in the file.
# file path: examples/custom_provider.rs
impl OllamaProvider {
pub fn new(base_url: Option<&str>) -> Self {
Self {
base_url: base_url.unwrap_or(”http://localhost:11434”).to_string(),
client: reqwest::Client::new(),
}
}
}OllamaProvider::new is the simple constructor the example provider adapter uses to produce a ready-to-use OllamaProvider instance that the agent core can call when it needs a model response. It accepts an optional base URL and, if none is supplied, falls back to the local Ollama HTTP endpoint (port 11434) before storing that value as the provider’s base_url string; it also creates an HTTP client and stores it on the client field so all outbound calls to the Ollama server share the same reqwest client. Conceptually this wires the low-level HTTP plumbing for the provider layer: when the agent core invokes the provider’s chat call later, the request path is built from base_url and the reqwest client is used to execute the request. This constructor mirrors the OllamaProvider struct layout we saw earlier (base_url and client) and is a minimal variant of another constructor in the repo that additionally trims trailing slashes and constructs a client with explicit timeouts and a build fallback.
# file path: examples/custom_provider.rs
pub fn new(base_url: Option<&str>) -> Self {
Self {
base_url: base_url.unwrap_or(”http://localhost:11434”).to_string(),
client: reqwest::Client::new(),
}
}The new function is the constructor for OllamaProvider that prepares the adapter so the agent core can talk to an Ollama-style local service: it takes an optional base_url and guarantees the provider has a concrete base_url string (falling back to a sensible local API endpoint when none is supplied) and an HTTP client instance ready for outgoing requests. Unlike the memory methods we looked at earlier (store, recall, get, forget, count), which implement the Memory trait and operate on in‑process storage, new is about wiring runtime dependencies for a provider: it materializes the base endpoint the chat call will target and the reqwest client the provider will reuse for each request. There are closely related constructors elsewhere in the repo that follow the same pattern; one is effectively identical, while another additionally strips a trailing slash from the provided base URL and builds a reqwest client with explicit connect and overall timeouts before falling back to a default client on builder failure — those variants show how the same constructor pattern can be extended to normalize inputs and tune HTTP behavior.
# file path: examples/custom_provider.rs
#[async_trait]The async_trait attribute is applied immediately before the Provider trait implementation to allow that implementation to define async methods such as the chat call and any other I/O-bound operations the provider adapter needs to perform. In Rust you cannot natively write async functions directly in trait declarations or trait impls, so async_trait rewrites those async methods into boxed futures (and commonly adds Send bounds as required) so the Provider implementation can perform awaiting internally while still satisfying the trait object interface the agent core expects. Functionally, this enables the provider adapter to make network or long‑running calls (for example, streaming or request/response work against an LLM) inside methods the agent will call asynchronously at runtime. The use here matches the earlier import of async_trait::async_trait in the file and mirrors the same pattern found elsewhere in the project where async_trait is used to let async trait implementations interoperate with the rest of ZeroClaw’s async orchestration.
# file path: examples/custom_provider.rs
impl Provider for OllamaProvider {
async fn chat(&self, message: &str, model: &str, temperature: f64) -> Result<String> {
let url = format!(”{}/api/generate”, self.base_url);
let body = serde_json::json!({
“model”: model,
“prompt”: message,
“temperature”: temperature,
“stream”: false,
});
let resp = self
.client
.post(&url)
.json(&body)
.send()
.await?
.json::<serde_json::Value>()
.await?;
resp[”response”]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!(”No response field in Ollama reply”))
}
}Class_L20 implements the Provider trait for OllamaProvider by providing an async chat method that the agent core calls when it needs a single-turn completion from an external Ollama model. The method receives the plain user prompt, the model name, and a temperature, constructs the target endpoint URL from the provider’s base_url, and builds a JSON payload mapping the model, prompt, temperature and a non-streaming flag so the call returns the full generated text in one response. It then uses the reqwest client held on the OllamaProvider to POST that JSON to the Ollama server, awaits the HTTP round trip and JSON deserialization, and finally pulls the string out of the response JSON’s response field; on success that string is returned to the caller, and network or parsing failures propagate as errors while a missing response field is converted into an anyhow error. Conceptually this method is the outbound bridge between the agent core and an external LLM provider—contrast that with the in‑process Memory implementations you reviewed earlier which operate on an in‑memory store; chat performs a network side effect to obtain model output. Compared with the related chat_with_system implementation, chat is a simpler single‑message path that targets the generate endpoint and does not assemble a messages array or perform explicit HTTP-status error mapping; the OllamaProvider struct that backs both methods simply holds the base_url and reqwest client used to make those requests.
# file path: examples/custom_provider.rs
async fn chat(&self, message: &str, model: &str, temperature: f64) -> Result<String> {
let url = format!(”{}/api/generate”, self.base_url);
let body = serde_json::json!({
“model”: model,
“prompt”: message,
“temperature”: temperature,
“stream”: false,
});
let resp = self
.client
.post(&url)
.json(&body)
.send()
.await?
.json::<serde_json::Value>()
.await?;
resp[”response”]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| anyhow::anyhow!(”No response field in Ollama reply”))
}OllamaProvider.chat implements the Provider trait’s single-turn completion call that the agent core uses to turn a prompt into a string reply: when invoked with a message, model name, and temperature it constructs an HTTP POST against the model server’s generate endpoint, serializes a JSON payload carrying the model identifier, prompt text, temperature, and a non-streaming flag, and sends the request using the provider’s HTTP client; it then awaits the response, deserializes the body into a generic JSON value, and extracts the top-level response field, converting that into an owned String on success or returning an anyhow error if the expected field is missing. Network and deserialization failures propagate via the usual await/error propagation, and the explicit presence check for the response field is the key control path that converts an otherwise malformed reply into a clear error for callers. Within ZeroClaw’s architecture this method
# file path: examples/custom_provider.rs
fn main() {
println!(”This is an example — see CONTRIBUTING.md for integration steps.”);
println!(”Register your provider in src/providers/mod.rs:”);
println!(” \”ollama\” => Ok(Box::new(ollama::OllamaProvider::new(None))),”);
}The main function simply prints two informational lines that guide a developer on how to integrate this example provider into ZeroClaw: it points readers to the CONTRIBUTING.md for integration steps and tells them to add an entry in src/providers/mod.rs that returns an instance of the Ollama provider constructed via its new constructor with no explicit base URL. Its purpose is purely instructional rather than executable wiring — because this file is an isolated example of a provider adapter, main acts as a hint for where to register the provider so the agent core can obtain it at runtime. Remember that OllamaProvider::new prepares a concrete base_url and an HTTP client so the agent core can call the Provider::chat implementation on that instance; registering the provider in providers/mod.rs plugs that prepared adapter into the provider registry the rest of the system expects. This mirrors the lightweight example mains elsewhere that point developers to register tools or add channel configuration, but here the message specifically directs integration work toward provider registration rather than tool or channel setup.
# file path: examples/custom_tool.rs
use anyhow::Result;
use async_trait::async_trait;
use serde_json::{json, Value};For a custom tool example that lives in the tool/runtime layer, these three imports set up the basic building blocks the file needs to declare its async behavior, handle failures, and work with JSON parameters and results. Result from anyhow supplies the unified error-return type the tool’s constructor and execution methods will use to propagate any I/O, parsing, or runtime errors back to the caller in the same way other components in the project do. The async_trait attribute enables implementing async methods on the Tool trait so the tool can perform I/O or other awaitable work inside its run method; you saw the same pattern applied before gap_L19_19 to let OllamaProvider implement async chat. serde_json’s json macro and Value type provide the lightweight JSON construction and generic JSON container the example uses to define the tool’s parameter schema, build request/response payloads, and parse or return arbitrary JSON-typed data — the same kind of JSON assembly and extraction approach that OllamaProvider.chat used when it serialized a request and deserialized the model response. Compared with other files in the project that pull in extra helpers like an HTTP client or serde derive traits, this example keeps imports minimal because its purpose is to illustrate metadata and execution logic rather than full HTTP plumbing.
# file path: examples/custom_tool.rs
#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]That derive attribute applied immediately before the tool’s type declaration instructs the compiler to automatically implement four common traits for that tool struct: Debug for human-readable inspection, Clone so the code can cheaply duplicate tool metadata or descriptors, and serde’s Serialize and Deserialize so the struct can be converted to and from JSON or other serde-supported formats. In the context of ZeroClaw’s tool/runtime layer, those derived traits let the agent core and runtime shuttle the tool’s metadata and parameter schema across process or network boundaries (or persist them) and also let developers print or duplicate tool descriptors when wiring tools into the runtime. This follows the same serialization pattern you’ve already seen in other places: the provider adapter’s chat method builds and parses JSON payloads using serde_json, and the ToolCall struct elsewhere uses serde attributes to control JSON field names and optional fields; by contrast, HttpGetTool was a simple unit-style declaration without these serialization derives, so the derives here explicitly enable JSON interchange and convenient debugging/cloning for the richer tool descriptor used by the example tool.
# file path: examples/custom_tool.rs
pub struct ToolResult {
pub success: bool,
pub output: String,
pub error: Option<String>,
}ToolResult is the simple result container that a custom tool in the tool/runtime layer returns after it runs: it carries a boolean flag that declares whether the invocation succeeded, a textual payload representing the tool’s output (which can be human-readable or machine-parsable), and an optional error string that holds a failure message when something went wrong. In the example tool file this struct is the canonical, minimal DTO the tool returns to the runtime runner and ultimately to the agent core so the conversation orchestration can decide next steps; the main demonstration shows a tool producing one of these to signal outcome and data back into the ZeroClaw pipeline. The pattern mirrors how provider adapters return results (for example, OllamaProvider.chat produces a textual reply or an error) but is focused on tool execution semantics rather than model responses. ToolResult is effectively identical to Class_L4 and therefore serves the same basic role in other parts of the codebase, whereas Class_L14 represents a richer execution record that includes the tool’s name and an optional call identifier for bookkeeping, and Class_L60 models an asynchronous message carrying a tool call id and content for inter-component messaging; ToolResult remains the compact success/output/error payload used immediately after a tool runs.
# file path: examples/custom_tool.rs
#[async_trait]
pub trait Tool: Send + Sync {
fn name(&self) -> &str;
fn description(&self) -> &str;
fn parameters_schema(&self) -> Value;
async fn execute(&self, args: Value) -> Result<ToolResult>;
}The Tool trait declares the runtime-facing contract that every custom tool implementation in the tool/runtime layer must satisfy, essentially defining the plugin interface the agent core and runtime use to discover, validate, and invoke side-effecting capabilities. It requires implementors to be Send and Sync so tool instances can be stored as trait objects and invoked safely across threads or async tasks by the runtime executor. Three synchronous accessor methods supply metadata: one returns a short identifier name the orchestrator can list and reference, another returns a human‑readable description used for logging and prompting, and the third returns a serde_json::Value that encodes the parameters schema the agent core can use to validate or synthesize structured arguments. The one asynchronous operation is the execute method, which accepts a serde_json::Value of arguments and yields a Result carrying a ToolResult or an error; the async_trait attribute is applied here for the same reason it was applied to Provider implementations earlier — to allow an async trait method that will perform I/O or other awaitable work inside concrete tools. Conceptually, this interface follows a plugin/strategy pattern: ToolSpec and other spec-level types elsewhere describe tools at registration time, while Tool is the runtime executable contract the orchestrator calls when it actually needs to run a tool and handle its success or failure. Data flows from the agent core or parsed user intent into parameters validated against the parameters_schema, then into execute, and the returned ToolResult (or error) flows back to the runtime layer to be turned into channel responses, memory updates, or further agent decisions.
# file path: examples/custom_tool.rs
pub struct HttpGetTool;HttpGetTool is declared as a public, fieldless struct that represents the example HTTP-fetching tool used in the tool/runtime layer; because it has no internal state it functions as a simple, shareable token type onto which the Tool trait implementation is attached. This design signals that all runtime behavior and inputs come through the Tool trait methods rather than instance fields: the agent core will rely on the Tool implementation for the tool’s name, description, JSON parameters schema, and the async execute logic that performs the HTTP GET and returns a ToolResult. Placing the behavior on a unit struct follows the project’s trait-based plugin pattern for side-effectful tools, keeping the tool stateless and easy to register or clone in registries; the concrete Tool implementation for HttpGetTool appears in Class_L19 and is what the agent calls when it needs to validate parameters and run the HTTP fetch during planning or execution.
# file path: examples/custom_tool.rs
#[async_trait]The async_trait attribute macro applied here tells the compiler to allow the following trait implementation to contain async methods by transforming those async method bodies into boxed futures behind the scenes; Rust does not yet let you write async methods directly on traits, so async_trait is used where implementations need to perform awaitable I/O. In the context of this custom tool example for ZeroClaw’s tool/runtime layer, that lets the tool implement its runtime-facing trait (the execute/perform-style method the agent core calls) as an async function so it can do network calls, file or subprocess I/O, or other async work and be awaited by the agent core. This is the same pattern used on OllamaProvider’s Provider implementation that we already discussed and on other example tools like HttpGetTool, ensuring provider adapters and runtime tools can all expose async behavior in a consistent way and interoperate with ZeroClaw’s async orchestration.
# file path: examples/custom_tool.rs
impl Tool for HttpGetTool {
fn name(&self) -> &str {
“http_get”
}
fn description(&self) -> &str {
“Fetch a URL and return the HTTP status code and content length”
}
fn parameters_schema(&self) -> Value {
json!({
“type”: “object”,
“properties”: {
“url”: { “type”: “string”, “description”: “URL to fetch” }
},
“required”: [”url”]
})
}
async fn execute(&self, args: Value) -> Result<ToolResult> {
let url = args[”url”]
.as_str()
.ok_or_else(|| anyhow::anyhow!(”Missing ‘url’ parameter”))?;
match reqwest::get(url).await {
Ok(resp) => {
let status = resp.status().as_u16();
let len = resp.content_length().unwrap_or(0);
Ok(ToolResult {
success: status < 400,
output: format!(”HTTP {status} — {len} bytes”),
error: None,
})
}
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(”Request failed: {e}”)),
}),
}
}
}This implementation of the Tool trait for HttpGetTool provides a simple, self-describing runtime tool the agent core can discover, validate against, and invoke to perform an HTTP GET side effect. The name and description methods expose the tool’s identifier and human-readable intent so the agent and any tooling UI can list and understand it; the parameters_schema method returns a JSON Schema describing a single required url parameter so the agent can validate arguments before calling execute. The execute method is asynchronous and drives the actual data flow: it reads the url string out of the incoming serde_json::Value, failing early with a clear error if the required parameter is missing, then issues an HTTP GET using reqwest. On a successful HTTP response it extracts the numeric status code and the content length (defaulting to zero when unknown) and packages those into a ToolResult that marks success for non-4xx/5xx statuses and provides a concise output message; on request failure it returns a ToolResult indicating failure with the error string captured. This follows the same per-tool pattern used elsewhere in the runtime where each tool supplies metadata plus an execute entrypoint (a Command-style design) so the tool/runtime layer can uniformly validate, invoke, and surface side-effecting operations; HttpGetTool is the simple example implementing that pattern and matches the project’s other tool implementations in structure and error-handling behavior.
# file path: examples/custom_tool.rs
fn name(&self) -> &str {
“http_get”
}The name method on HttpGetTool is a simple getter that supplies the tool’s stable identifier http_get; the agent core and the tool/runtime registry use that identifier to match a tool declaration embedded in an agent decision (or a tool list presented in a prompt) to the concrete HttpGetTool implementation at runtime. Because tools are dispatched by exact name, returning a single, static string slice makes the identifier cheap and unambiguous, and follows the same convention used by other tools such as the one returning http_request — the difference being that http_get signals a GET-specific HTTP helper while http_request is a more general request-style tool, so the name encodes the tool’s intended behavior for the agent when it decides which runtime capability to invoke.
# file path: examples/custom_tool.rs
fn description(&self) -> &str {
“Fetch a URL and return the HTTP status code and content length”
}The description method provides a short, human‑readable summary of what HttpGetTool does: it declares that the tool fetches a URL and reports back the HTTP status and the content length. In the ZeroClaw tool/runtime layer this method is part of the Tool trait metadata trio (alongside name and parameters_schema) that the agent core and registries use to advertise and reason about available side‑effectful capabilities when composing prompts or building a tool list for an LLM to choose from. Because the method returns a borrowed string, the implementation supplies a stable literal description intended for tooling, UIs, and prompt construction rather than runtime behavior; the actual network work happens in execute. This description mirrors the entry used in the other HttpGetTool example, reflecting the project’s consistent pattern of small, explicit metadata methods for each example tool in zeroclaw-main_cleaned.
# file path: examples/custom_tool.rs
fn parameters_schema(&self) -> Value {
json!({
“type”: “object”,
“properties”: {
“url”: { “type”: “string”, “description”: “URL to fetch” }
},
“required”: [”url”]
})
}The parameters_schema method on HttpGetTool produces a JSON Schema (as a serde_json::Value) that describes the shape of the parameters the tool accepts: a top‑level object with a single property named url, typed as a string and documented as the URL to fetch, and the url field is required. The agent core and the tool/runtime registry use this schema as the canonical contract for validating and presenting parameters before invoking the tool — it lets the orchestration layer verify that a decision’s arguments include a URL and that it is the correct type, and it can be surfaced in prompts or UI for parameter collection. Compared to the similar parameters_schema implementations elsewhere in the codebase, HttpGetTool’s schema is intentionally minimal: it only requires a url, whereas the Brave browser variant restricts the URL to HTTPS in its description and a more featureful HTTP tool includes method, headers, and body fields; another example makes a board field optional. The method builds the schema value with the json! macro so the agent can directly consume the resulting serde_json::Value at runtime.
# file path: examples/custom_tool.rs
async fn execute(&self, args: Value) -> Result<ToolResult> {
let url = args[”url”]
.as_str()
.ok_or_else(|| anyhow::anyhow!(”Missing ‘url’ parameter”))?;
match reqwest::get(url).await {
Ok(resp) => {
let status = resp.status().as_u16();
let len = resp.content_length().unwrap_or(0);
Ok(ToolResult {
success: status < 400,
output: format!(”HTTP {status} — {len} bytes”),
error: None,
})
}
Err(e) => Ok(ToolResult {
success: false,
output: String::new(),
error: Some(format!(”Request failed: {e}”)),
}),
}
}Remember HttpGetTool and its Tool trait wiring we covered earlier: execute is the async runtime entry point that takes the agent-provided JSON arguments and actually performs the side effect described by the tool. execute first pulls a string URL out of the incoming args and immediately fails with an anyhow error if that required parameter is missing, because a valid URL is essential before attempting any network action. If a URL is present, execute issues an asynchronous HTTP GET using the reqwest client and then branches on the outcome: on a successful HTTP response it extracts the numeric status code and an optional content length (falling back to zero when the header is absent), builds a ToolResult whose success boolean is true for status codes below 400 and whose output is a short human-readable summary of the status and byte count, and returns that as the successful ToolResult; if the request itself errors (network, DNS, TLS, etc.) execute returns a ToolResult marked unsuccessful with an empty output and an error string that includes the underlying request failure. Compared with the other, more featureful HTTP executor in the repository, this execute implementation is intentionally minimal — it lacks method, header, body handling and the security/validation guards present elsewhere — and simply maps a single GET request into the uniform ToolResult shape the agent core can inspect.
# file path: examples/custom_tool.rs
fn main() {
println!(”This is an example — see CONTRIBUTING.md for integration steps.”);
println!(”Register your tool in src/tools/mod.rs default_tools()”);
}The main function is a tiny, developer-facing entrypoint that doesn’t run HttpGetTool or wire it into the runtime; instead it prints two informational lines for anyone reading the example. One line tells the reader that this file is an example and points them at the repository contribution documentation for integration steps, and the other line points them at the tools registry function so they know where to add their implementation into the tool/runtime registry. Because HttpGetTool was declared earlier as a fieldless, stateless implementor of the Tool trait and covered already, this main deliberately avoids instantiating or invoking it and serves only to document the next integration step. That mirrors the pattern used by other example mains in the project, which likewise print guidance about where to register providers or channel configuration; the difference here is the target of the guidance—tools are registered in the tools default list, whereas other example mains refer to provider or channel registration locations. Overall, this main exists to guide contributors through integrating a custom tool into the ZeroClaw tool/runtime layer rather than to perform any runtime behavior itself.
# file path: firmware/zeroclaw-esp32/Cargo.toml
[package]
name = “zeroclaw-esp32”
version = “0.1.0”
edition = “2021”
license = “MIT”
description = “ZeroClaw ESP32 peripheral firmware — GPIO over JSON serial”
[dependencies]
esp-idf-svc = “0.48”
log = “0.4”
anyhow = “1.0”
serde = { version = “1.0”, features = [”derive”] }
serde_json = “1.0”
[build-dependencies]
embuild = { version = “0.31”, features = [”elf”] }
[profile.release]
opt-level = “s”
lto = true
codegen-units = 1
strip = true
panic = “abort”
[profile.dev]
opt-level = “s”Cargo.toml for zeroclaw-esp32 is the package manifest that tells Cargo how to produce the ESP32 peripheral firmware binary used by the device-level layer that exposes GPIO over JSON serial. It declares the crate identity and metadata so the build system and package registry know what this firmware is, then enumerates the runtime dependencies the firmware needs at build and link time: esp-idf-svc to bind into the ESP‑IDF runtime, log for structured logging, anyhow for ergonomic error handling, serde with the derive feature and serde_json so the firmware can serialize and deserialize the JSON messages it exchanges over serial. The manifest also adds embuild as a build‑dependency with the ELF feature so the build script can emit the platform and toolchain configuration required to integrate with the ESP‑IDF toolchain; that embuild integration is what the crate’s build script and the small main snippet call into to surface the proper sysenv for the espidf toolchain. The profile sections tune the final firmware: release builds favor size and aggressive LTO with a single codegen unit, symbol stripping, and abort-on-panic to minimize footprint, while the dev profile is also tuned for smaller binaries during development. Compared to the zeroclaw-nucleo Cargo.toml, the structure is the same in intent—package metadata plus dependency and profile configuration—but the concrete dependencies differ: nucleo pulls in the embassy ecosystem and a package.metadata.embassy block to declare a thumb target, whereas zeroclaw-esp32 relies on esp-idf-svc and embuild to drive the ESP‑IDF/C toolchain integration. Overall, this manifest is the piece that guarantees the peripheral firmware is linked against the correct runtime, includes only the JSON and logging crates needed by the device agent, and is optimized for the constrained environment the ESP32 runs in.
# file path: firmware/zeroclaw-esp32/build.rs
fn main() {
embuild::espidf::sysenv::output();
}For the zeroclaw-esp32 firmware crate, main is the build script entrypoint that runs before any compilation and its entire job is to invoke embuild::espidf::sysenv::output so the esp-idf environment is probed and the appropriate Cargo build instructions are emitted. embuild performs the heavy lifting of discovering the ESP-IDF toolchain and sysroot, producing link paths, linker flags, generated bindings/ELF flags and rerun-if-changed directives; those emitted cargo: lines are consumed by Cargo to configure rustc and the linker for the subsequent firmware build. This build.rs therefore implements a thin-delegation pattern: instead of reimplementing environment probing and linker setup, it delegates to the embuild helper declared as a build-dependency in Cargo.toml. In the project data flow, Cargo runs main at build time, main calls embuild to produce environment metadata, and Cargo uses that metadata to drive the compile/link stages; unlike runtime components such as HttpGetTool that we examined earlier, main affects only build-time configuration and not the firmware’s runtime behavior.
# file path: firmware/zeroclaw-esp32/src/main.rs
use esp_idf_svc::hal::gpio::PinDriver;
use esp_idf_svc::hal::prelude::*;
use esp_idf_svc::hal::uart::*;
use log::info;
use serde::{Deserialize, Serialize};These imports pull in the ESP-IDF hardware abstraction and runtime utilities, the UART driver types used to talk JSON over serial, the GPIO pin driver used to implement the microcontroller channel’s read/write side effects, a logging macro so the firmware can announce state at runtime, and Serde’s derive traits so the firmware can decode incoming requests and encode outgoing responses. PinDriver is the concrete helper used by the GPIO bridge functions that expose board pins to the ZeroClaw runtime; the hal prelude brings the common HAL trait extensions and conversions that make peripheral types ergonomic to use. The uart module supplies the UART configuration and driver primitives the main loop uses to read lines, parse them as requests, and write JSON responses back to the host. log::info is the standard log facade entrypoint the firmware uses after wiring EspLogger so status messages appear on the serial console. Deserialize and Serialize are pulled in so the request/response structs used by the microcontroller channel can be derived for Serde JSON round trips. This follows the same pattern as the other MCU-targeted files that import a platform-specific HAL and UART plus a logging facade — for example, the STM32 code uses embassy_stm32 and defmt for the same roles but with different platform crates and async runtime choices.
# file path: firmware/zeroclaw-esp32/src/main.rs
#[derive(Debug, Deserialize)]The derive annotation on the struct tells the compiler to auto‑implement the Debug and Deserialize traits for the type that follows so the ESP32 firmware can both produce readable diagnostics and parse incoming serialized messages into a strongly typed Rust value. In the microcontroller channel, incoming assistant requests arrive as serialized payloads (JSON via the platform stack), and Deserialize lets the request handler convert those payloads into the struct fields the GPIO read/write and handler logic expect; Debug enables printing that parsed value to the log for troubleshooting via the logging macros already used elsewhere. This mirrors the project’s general pattern of using serde for wire↔type conversions (you can see serde::Deserialize and serde::Serialize in the imports), but differs from the Response type that derives Serialize and uses serde’s skip_serializing_if hint because Response is shaped for outgoing replies; the type annotated here is focused on inbound parsing rather than outbound formatting.
# file path: firmware/zeroclaw-esp32/src/main.rs
struct Request {
id: String,
cmd: String,
args: serde_json::Value,
}Request represents the inbound command message that the ESP32 microcontroller channel accepts and turns into hardware actions. When an external controller (the agent/runtime or a remote client) sends a JSON line to the device, that JSON is deserialized into a Request so the firmware can reason about it: id carries a caller-supplied correlation token so replies can be matched to requests, cmd is the stable command name the handler switches on (for example capabilities, gpio_read, gpio_write), and args is a generic serde_json::Value that holds command-specific parameters. Using a plain string for id and cmd plus a loose JSON value for args keeps the shape minimal and extensible so new commands or tool-like operations can be added without changing the request type. handle_request consumes a Request, clones the id for the eventual reply, branches on cmd to call gpio_read/gpio_write or emit capabilities, and then packages the outcome into a Response that preserves the original id and encodes success, result, and any error. In the ZeroClaw architecture this struct is the MCU-side representation of an incoming tool/runtime invocation: it bridges high‑level agent decisions into concrete GPIO side effects in the firmware, similar in spirit to how HttpGetTool exposes an action to the runtime but specialized for board I/O.
# file path: firmware/zeroclaw-esp32/src/main.rs
#[derive(Debug, Serialize)]The derive attribute here tells the compiler to auto-generate implementations of the Debug and Serialize traits for the following Response struct. Debug makes it easy to emit human-readable representations of Response values into the firmware logs (so log::info can print the struct during request handling), while Serialize integrates with serde so Response can be converted to JSON and written out on the serial channel as the ESP32 microcontroller channel’s JSON reply. Because Response uses a serde field-level annotation to skip the error when it’s None, the Serialize derive cooperates with those annotations to produce compact JSON only containing present fields. This fits the firmware’s data flow: incoming commands are parsed with types that derive Deserialize, while Response is intended for outbound serialization, and Cargo.toml enables serde’s derive feature to make this automatic.
# file path: firmware/zeroclaw-esp32/src/main.rs
struct Response {
id: String,
ok: bool,
result: String,
#[serde(skip_serializing_if = “Option::is_none”)]
error: Option<String>,
}Response is the simple data transfer object the ESP32 channel uses to send back the outcome of a handled assistant request to whatever endpoint is listening. It pairs with the earlier Request type by carrying the same correlation id, exposes a stable ok flag to indicate success versus failure, and carries the payload as a plain result string when the operation succeeded. An optional error string captures a human-readable failure message for the caller; the serializer will omit that field when there is no error so the JSON stays compact. Compared with ComputerUseResponse, which uses Option-wrapped fields and a generic JSON Value for data, Response opts for a concrete boolean and a string result to keep the microcontroller-side messaging small and deterministic; and unlike ResponseMessage, which can carry an optional content blob and tool call list for richer agent-level events, Response is a minimal, channel-level reply object intended solely to close the request/response loop between the board and the agent/runtime.
# file path: firmware/zeroclaw-esp32/src/main.rs
fn main() -> anyhow::Result<()> {
esp_idf_svc::sys::link_patches();
esp_idf_svc::log::EspLogger::initialize_default();
let peripherals = Peripherals::take()?;
let pins = peripherals.pins;
let config = UartConfig::new().baudrate(Hertz(115_200));
let mut uart = UartDriver::new(
peripherals.uart0,
pins.gpio21,
pins.gpio20,
Option::<esp_idf_svc::hal::gpio::Gpio0>::None,
Option::<esp_idf_svc::hal::gpio::Gpio1>::None,
&config,
)?;
info!(”ZeroClaw ESP32 firmware ready on UART0 (115200)”);
let mut buf = [0u8; 512];
let mut line = Vec::new();
loop {
match uart.read(&mut buf, 100) {
Ok(0) => continue,
Ok(n) => {
for &b in &buf[..n] {
if b == b’\n’ {
if !line.is_empty() {
if let Ok(line_str) = std::str::from_utf8(&line) {
if let Ok(resp) = handle_request(line_str, &peripherals) {
let out = serde_json::to_string(&resp).unwrap_or_default();
let _ = uart.write(format!(”{}\n”, out).as_bytes());
}
}
line.clear();
}
} else {
line.push(b);
if line.len() > 400 {
line.clear();
}
}
}
}
Err(_) => {}
}
}
}main boots the ESP32 runtime and brings up the microcontroller channel that accepts newline‑delimited JSON requests over UART, decodes them into Request values, forwards them to the previously explained handle_request function for translation into hardware actions, and marshals the resulting Response back out over the same serial link. It first applies runtime patches and initializes logging via link_patches and EspLogger::initialize_default, then obtains the device Peripherals so the firmware can drive pins and the UART; failures during those initial steps propagate via the function’s anyhow::Result return. It configures a UART at 115200 baud with UartConfig and constructs a UartDriver tied to the appropriate peripheral pins, then emits an info! message announcing readiness. The main loop repeatedly calls uart.read with a short timeout and treats a zero read as no data, otherwise iterating the returned bytes into a transient 512‑byte buffer and assembling a dynamic line Vec until it encounters a newline. When a newline-terminated line is complete and nonempty, it attempts to interpret the bytes as UTF‑8 and then calls handle_request with the decoded string and the Peripherals reference; on success it serializes the Response with serde_json::to_string and writes the JSON plus a newline back via uart.write. The loop contains simple guards: non‑UTF8 or handling failures are ignored, overly long partial lines are cleared to avoid runaway memory growth, and read errors are dropped so the firmware keeps running. Functionally this mirrors the Nucleo firmware’s main in intent—both set up a 115200 serial endpoint, accumulate per‑line commands, and respond with JSON—but differs in the HAL and IO model (UartDriver with timed reads and a heap Vec here versus a blocking USART and heapless buffers in the Nucleo example).
# file path: firmware/zeroclaw-esp32/src/main.rs
fn handle_request(
line: &str,
peripherals: &esp_idf_svc::hal::peripherals::Peripherals,
) -> anyhow::Result<Response> {
let req: Request = serde_json::from_str(line.trim())?;
let id = req.id.clone();
let result = match req.cmd.as_str() {
“capabilities” => {
let caps = serde_json::json!({
“gpio”: [0, 1, 2, 3, 4, 5, 12, 13, 14, 15, 16, 17, 18, 19],
“led_pin”: 2
});
Ok(caps.to_string())
}
“gpio_read” => {
let pin_num = req.args.get(”pin”).and_then(|v| v.as_u64()).unwrap_or(0) as i32;
let value = gpio_read(peripherals, pin_num)?;
Ok(value.to_string())
}
“gpio_write” => {
let pin_num = req.args.get(”pin”).and_then(|v| v.as_u64()).unwrap_or(0) as i32;
let value = req.args.get(”value”).and_then(|v| v.as_u64()).unwrap_or(0);
gpio_write(peripherals, pin_num, value)?;
Ok(”done”.into())
}
_ => Err(anyhow::anyhow!(”Unknown command: {}”, req.cmd)),
};
match result {
Ok(r) => Ok(Response {
id,
ok: true,
result: r,
error: None,
}),
Err(e) => Ok(Response {
id,
ok: false,
result: String::new(),
error: Some(e.to_string()),
}),
}
}handle_request accepts a single line of input plus the ESP32 Peripherals handle and is the microcontroller channel’s dispatcher that turns serialized assistant requests into concrete hardware actions and a standardized Response. It first trims and deserializes the incoming string into a Request (the Request type was covered earlier) using serde_json; if parsing fails that error propagates out of the function. After successful parse it clones the request id to carry through the reply and then dispatches on the request’s cmd field: for the capabilities command it synthesizes a small JSON object describing available GPIO pins and the LED pin and returns that as the command result; for gpio_read it extracts a pin number from the Request args JSON (treating missing or non‑numeric values as a safe default), calls gpio_read with the peripherals handle to sample the hardware state, and returns the numeric reading as the result; for gpio_write it extracts pin and value arguments similarly, invokes gpio_write with the peripherals handle to perform the side effect, and reports completion. Any unknown command produces an error result. After the command match completes the function folds the successful or errored command outcome into a Response value: a successful result becomes a Response with ok true and the result string populated, while a command error becomes a Response with ok false and the error string populated; in both cases the original request id is preserved so callers can correlate replies. Conceptually this is a simple command-dispatch pattern that centralizes JSON-based request parsing and normalized JSON response construction for the UART/serial loop; it mirrors the earlier main loops’ command set but consolidates handling into a single function and delegates actual GPIO operations to gpio_read and gpio_write, which are implemented elsewhere.
# file path: firmware/zeroclaw-esp32/src/main.rs
fn gpio_read(_peripherals: &esp_idf_svc::hal::peripherals::Peripherals, _pin: i32) -> anyhow::Result<u8> {
Ok(0)
}gpio_read is the ESP32 firmware’s primitive for reading a digital GPIO pin and is the function the microcontroller channel will call when a parsed Request needs a hardware read; it takes the peripheral handle and a pin identifier and returns a Result containing an 8-bit numeric state. In the current file-level implementation gpio_read ignores both inputs and unconditionally returns a successful zero value, so when higher-level request handling converts an inbound Request into hardware actions and then packages the outcome into a Response, the runtime will always see a read value of 0 from this function. Functionally this sits where concrete platform I/O happens for zeroclaw-main_cleaned — unlike the descriptive helper methods elsewhere in the project that only return human-readable strings describing GPIO behavior on Raspberry Pi, STM32, or Arduino, gpio_read is responsible for producing the actual numeric pin state for the agent/runtime pipeline; however, here it acts as a trivial stub that supplies a fixed numeric result instead of querying the ESP-IDF GPIO driver.
# file path: firmware/zeroclaw-esp32/src/main.rs
fn gpio_write(
peripherals: &esp_idf_svc::hal::peripherals::Peripherals,
pin: i32,
value: u64,
) -> anyhow::Result<()> {
let pins = peripherals.pins;
let level = value != 0;
match pin {
2 => {
let mut out = PinDriver::output(pins.gpio2)?;
out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?;
}
13 => {
let mut out = PinDriver::output(pins.gpio13)?;
out.set_level(esp_idf_svc::hal::gpio::Level::from(level))?;
}
_ => anyhow::bail!(”Pin {} not configured (add to gpio_write)”, pin),
}
Ok(())
}gpio_write is the synchronous low‑level bridge the microcontroller channel uses to turn an incoming Request into real electrical state changes on the ESP32’s pins; when the firmware’s request handler sees a gpio_write command it calls this function with the shared Peripherals handle plus the numeric pin and value from the Request. The function first pulls the board pins out of the provided Peripherals reference and reduces the numeric value to a boolean level by testing whether the value is non‑zero, because the hardware API expects a high/low signal rather than an integer. It then switches on the requested pin and, for each supported case (here two concrete cases), creates an output PinDriver for the corresponding gpio from the board pins and sets its level by converting the boolean into the HAL’s Level type; any driver or IO errors are propagated up through the anyhow::Result so the caller can serialize a Response. If the pin number isn’t one of the configured cases the function fails fast with an anyhow::bail call that indicates the pin isn’t configured and hints that more pins must be added to gpio_write. Compared to the execute implementations on other platforms that either marshal a transport request or spawn a blocking task to drive rppal, gpio_write performs the actual device manipulation inline on the ESP32 using esp_idf’s PinDriver and Level conversion, making it the concrete hardware actuation step in the ZeroClaw microcontroller channel’s request→action→Response flow.
# file path: firmware/zeroclaw-nucleo/Cargo.toml
[package]
name = “zeroclaw-nucleo”
version = “0.1.0”
edition = “2021”
license = “MIT”
description = “ZeroClaw Nucleo-F401RE peripheral firmware — GPIO over JSON serial”
[dependencies]
embassy-executor = { version = “0.9”, features = [”arch-cortex-m”, “executor-thread”, “defmt”] }
embassy-stm32 = { version = “0.5”, features = [”defmt”, “stm32f401re”, “unstable-pac”, “memory-x”, “time-driver-tim4”, “exti”] }
embassy-time = { version = “0.5”, features = [”defmt”, “defmt-timestamp-uptime”, “tick-hz-32_768”] }
defmt = “1.0”
defmt-rtt = “1.0”
panic-probe = { version = “1.0”, features = [”print-defmt”] }
heapless = { version = “0.9”, default-features = false }
critical-section = “1.1”
cortex-m-rt = “0.7”
[package.metadata.embassy]
build = [
{ target = “thumbv7em-none-eabihf”, artifact-dir = “target” }
]
[profile.release]
opt-level = “s”
lto = true
codegen-units = 1
strip = true
panic = “abort”
debug = 1The Cargo.toml for zeroclaw-nucleo is the crate manifest that tells Cargo how to build the Nucleo-F401RE firmware used as a ZeroClaw peripheral channel for GPIO-over-JSON serial; it encodes the crate identity (name, version, edition, license and a short description) so toolchains and CI know what they
# file path: firmware/zeroclaw-nucleo/src/main.rs
use core::fmt::Write;
use core::str;
use defmt::info;
use embassy_executor::Spawner;
use embassy_stm32::gpio::{Level, Output, Speed};
use embassy_stm32::usart::{Config, Uart};
use heapless::String;
use {defmt_rtt as _, panic_probe as _};These imports glue the STM32-specific runtime and tiny, no‑std utilities into the Nucleo firmware so the board can speak JSON over a serial line, log its state, and run under Embassy’s async executor. core::fmt::Write brings in the formatting trait used by heapless string buffers so the firmware can build JSON responses with the write! macro; core::str supplies UTF‑8 helpers used when turning the copied device id bytes into a Rust string slice for inclusion in responses. defmt::info is the lightweight logging macro the firmware uses to announce readiness and runtime events over the defmt backend. embassy_executor::Spawner is the executor handle injected into the async main entry so Embassy can spawn tasks and manage the event loop. The embassy_stm32 GPIO types Level, Output and Speed provide the board‑level primitives to instantiate and drive the LED pin (initial level and output speed), while embassy_stm32’s USART Config and Uart types configure and operate the UART peripheral that carries the JSON protocol. heapless::String is the fixed‑capacity, heapless string buffer used to construct outbound JSON without a global allocator. Finally, the crate aliases defmt_rtt and panic_probe establish the defmt RTT transport and a panic handler at the crate level so logging and panic diagnostics work on the embedded target. Compared with the ESP32 imports you saw earlier, these follow the same platform‑HAL + logging pattern but target Embassy’s STM32 HAL and defmt/heapless primitives instead of esp_idf_svc, log, and heap‑dependent types; together they enable the main loop to read bytes from UART, parse and format messages into a heapless buffer, toggle GPIO via Output, and report status via defmt.
# file path: firmware/zeroclaw-nucleo/src/main.rs
#![no_std]
#![no_main]The two crate-level attributes present at the top tell the Rust toolchain that this firmware is being built as a freestanding embedded binary: declaring no_std instructs the compiler not to link the standard library so the firmware uses core primitives and platform-provided drivers and panic handlers instead of any OS-dependent facilities, and declaring no_main disables Rust’s default program entry so the project supplies or links a custom startup entry point appropriate for the MCU runtime or async executor. In the context of ZeroClaw’s Nucleo microcontroller entry point this is why the file can perform low-level board initialization, copy device IDs, parse serial commands, and run a tight main control loop without relying on an operating system: the firmware must own stack/heap setup, panic behavior, and the entry wiring. This pattern matches the other embedded example that initializes an embassy executor and provides its own async main via the executor’s Spawner; both targets require the same freedom from the standard library and from the default main so platform-specific startup code and HALs (the STM32 embassy setup in one example, the ESP-IDF HAL in the other) can provide the actual runtime entry and support the imports and panic handlers you saw earlier.
# file path: firmware/zeroclaw-nucleo/src/main.rs
const LED_PIN: u8 = 13;LED_PIN is the firmware constant that names the MCU pin used for the board’s built‑in LED and gives it a concrete numeric value and a u8 type. Within the Nucleo microcontroller entry point, that constant is the authoritative reference used by the main loop’s gpio_read branch to detect when a request targets the onboard LED and by gpio_write’s pin match arm to configure the correct PinDriver instance for the LED (the gpio_write implementation contains a specific case for the same numeric pin). The capabilities response emitted by the firmware lists the same LED pin number, so LED_PIN ties the runtime behavior, hardware driver mapping, and advertised capabilities together so external callers and the agent layer see a single, consistent LED pin. The choice of a small unsigned integer matches how parsed pin arguments flow through parse_arg and the integer comparisons in main (where the parsed i32 is compared against LED_PIN), keeping hardware configuration centralized and easy for the rest of the microcontroller channel code to reference.
# file path: firmware/zeroclaw-nucleo/src/main.rs
fn parse_arg(line: &[u8], key: &[u8]) -> Option<i32> {
let mut suffix: [u8; 32] = [0; 32];
suffix[0] = b’”’;
let mut len = 1;
for (i, &k) in key.iter().enumerate() {
if i >= 30 {
break;
}
suffix[len] = k;
len += 1;
}
suffix[len] = b’”’;
suffix[len + 1] = b’:’;
len += 2;
let suffix = &suffix[..len];
let line_len = line.len();
if line_len < len {
return None;
}
for i in 0..=line_len - len {
if line[i..].starts_with(suffix) {
let rest = &line[i + len..];
let mut num: i32 = 0;
let mut neg = false;
let mut j = 0;
if j < rest.len() && rest[j] == b’-’ {
neg = true;
j += 1;
}
while j < rest.len() && rest[j].is_ascii_digit() {
num = num * 10 + (rest[j] - b’0’) as i32;
j += 1;
}
return Some(if neg { -num } else { num });
}
}
None
}parse_arg is a small, low‑level helper the Nucleo firmware uses during startup and command handling to pull a signed integer out of a raw byte slice that represents an incoming serialized line (for example, the bytes that form a Request the MCU receives over UART). It constructs a fixed 32‑byte pattern buffer beginning with an opening quote, then copies up to thirty bytes of the provided key into that buffer, appends a closing quote and a colon to form the key delimiter it expects to find in a JSON‑like stream, and trims the buffer to the actual length used. If the incoming line is shorter than that pattern it returns None immediately. It then scans the line byte by byte looking for the pattern; when it finds a match it takes the bytes that follow as the value area, checks for an optional minus sign to record negativity, consumes consecutive ASCII digits to build an integer by repeated decimal accumulation into an i32, and returns that number wrapped in Some with the negative sign applied if needed. If the pattern is never found or no digits follow, it returns None. Conceptually this is a constrained, allocation‑free parser tuned for embedded execution: it mirrors the same byte‑search approach used by copy_id but differs in that copy_id extracts a quoted string until the next quote while parse_arg parses an optional sign and a run of digits into an integer. It also operates at much lower level than parse_arguments_value (which works with serde_json values and may parse nested JSON) and serves a different, simpler purpose than parse_structured_memory_line (which expects a special marker‑based text format).
# file path: firmware/zeroclaw-nucleo/src/main.rs
fn has_cmd(line: &[u8], cmd: &[u8]) -> bool {
let mut pat: [u8; 64] = [0; 64];
pat[0..7].copy_from_slice(b”\”cmd\”:\”“);
let clen = cmd.len().min(50);
pat[7..7 + clen].copy_from_slice(&cmd[..clen]);
pat[7 + clen] = b’”’;
let pat = &pat[..8 + clen];
let line_len = line.len();
if line_len < pat.len() {
return false;
}
for i in 0..=line_len - pat.len() {
if line[i..].starts_with(pat) {
return true;
}
}
false
}has_cmd is a small, performance‑oriented predicate used by the firmware entry point to decide whether an incoming raw serial line contains a specific command name before performing any heavier parsing or dispatch. It builds a fixed, stack‑allocated byte pattern that represents the JSON field name for the command plus the provided command bytes (it limits how many command bytes it copies to keep the buffer small), then compares that pattern against every possible alignment in the incoming line by sliding a window and checking for a match; if the line is too short to ever contain the pattern it returns false immediately. The implementation uses byte slices and bounded copying to avoid allocations and prevent overruns on the microcontroller, so the happy path simply finds the pattern and returns true while the other paths return false (either because the line is too short or the pattern never appears). Functionally it plays the same low‑level role as copy_id — both scan raw JSON‑like input for a keyed value with a bounded, manual search — whereas contains_word and is_command_allowed operate at a higher, semantic level (word boundary checks or security/allowlist logic) on host or agent inputs; has_cmd is intentionally minimal and embedded‑friendly so the startup argument/command parsing in the entry point can cheaply check for commands before handing control to the rest of the runtime.
# file path: firmware/zeroclaw-nucleo/src/main.rs
fn copy_id(line: &[u8], out: &mut [u8]) -> usize {
let prefix = b”\”id\”:\”“;
if line.len() < prefix.len() + 1 {
out[0] = b’0’;
return 1;
}
for i in 0..=line.len() - prefix.len() {
if line[i..].starts_with(prefix) {
let start = i + prefix.len();
let mut j = 0;
while start + j < line.len() && j < out.len() - 1 && line[start + j] != b’”’ {
out[j] = line[start + j];
j += 1;
}
return j;
}
}
out[0] = b’0’;
1
}copy_id is the small parser the Nucleo firmware uses to extract the request identifier from an incoming serialized line so the MCU can echo that id back in its JSON responses; main calls copy_id on the accumulated serial line before assembling a reply. It begins by validating that the incoming line is at least long enough to possibly contain the id field and, if not, writes the ASCII character 0 into the output buffer and returns a length of one as a default id. It then scans the line byte-by-byte looking for the id field key followed by its opening quote, using a sliding-window check so the id can appear anywhere in the input. When it finds that marker it computes the start position of the id value and copies bytes from the input into the provided out buffer until it hits the closing quote or the output buffer limit; the copy loop deliberately stops one byte before the end of out to leave room for a terminator or to avoid filling the buffer completely, and it returns the number of bytes actually written. If no id marker is found after scanning, copy_id again falls back to writing ASCII zero and returning one. Conceptually this follows the same pattern as parse_arg and has_cmd—scanning a raw byte line for a particular JSON-like token—but unlike parse_arg, which parses and returns a numeric value, and has_cmd, which returns a boolean presence check, copy_id copies the raw id string into a caller-provided buffer and yields its length for downstream UTF‑8 conversion and inclusion in response payloads.
# file path: firmware/zeroclaw-nucleo/src/main.rs
#[embassy_executor::main]The embassy_executor::main attribute is the runtime glue that turns the async main function in this firmware into the MCU’s actual entry point and executor bootstrap. Because the crate declares no_std and no_main, the usual host runtime is absent; applying the embassy_executor::main attribute instructs the Embassy framework to generate the startup code, initialize the async executor, configure the interrupt/stack/runtime invariants expected by Embassy on Cortex‑M, and then invoke the async main with a Spawner so the firmware can launch tasks. In practice this is why the async main shown later can call embassy_stm32::init, construct nonblocking peripherals like Uart and Output, and run an asynchronous loop that uses parse_arg and has_cmd to handle JSON-over-serial commands. In short, the attribute wires the file’s main control loop into the Embassy async runtime so the Nucleo board runs ZeroClaw’s evented firmware without a host OS.
# file path: firmware/zeroclaw-nucleo/src/main.rs
async fn main(_spawner: Spawner) {
let p = embassy_stm32::init(Default::default());
let mut config = Config::default();
config.baudrate = 115_200;
let mut usart = Uart::new_blocking(p.USART2, p.PA3, p.PA2, config).unwrap();
let mut led = Output::new(p.PA5, Level::Low, Speed::Low);
info!(”ZeroClaw Nucleo firmware ready on USART2 (115200)”);
let mut line_buf: heapless::Vec<u8, 256> = heapless::Vec::new();
let mut id_buf = [0u8; 16];
let mut resp_buf: String<128> = String::new();
loop {
let mut byte = [0u8; 1];
if usart.blocking_read(&mut byte).is_ok() {
let b = byte[0];
if b == b’\n’ || b == b’\r’ {
if !line_buf.is_empty() {
let id_len = copy_id(&line_buf, &mut id_buf);
let id_str = str::from_utf8(&id_buf[..id_len]).unwrap_or(”0”);
resp_buf.clear();
if has_cmd(&line_buf, b”ping”) {
let _ = write!(resp_buf, “{{\”id\”:\”{}\”,\”ok\”:true,\”result\”:\”pong\”}}”, id_str);
} else if has_cmd(&line_buf, b”capabilities”) {
let _ = write!(
resp_buf,
“{{\”id\”:\”{}\”,\”ok\”:true,\”result\”:\”{{\\\”gpio\\\”:[0,1,2,3,4,5,6,7,8,9,10,11,12,13],\\\”led_pin\\\”:13}}\”}}”,
id_str
);
} else if has_cmd(&line_buf, b”gpio_read”) {
let pin = parse_arg(&line_buf, b”pin”).unwrap_or(-1);
if pin == LED_PIN as i32 {
let _ = write!(resp_buf, “{{\”id\”:\”{}\”,\”ok\”:true,\”result\”:\”0\”}}”, id_str);
} else if pin >= 0 && pin <= 13 {
let _ = write!(resp_buf, “{{\”id\”:\”{}\”,\”ok\”:true,\”result\”:\”0\”}}”, id_str);
} else {
let _ = write!(
resp_buf,
“{{\”id\”:\”{}\”,\”ok\”:false,\”result\”:\”\”,\”error\”:\”Invalid pin {}\”}}”,
id_str, pin
);
}
} else if has_cmd(&line_buf, b”gpio_write”) {
let pin = parse_arg(&line_buf, b”pin”).unwrap_or(-1);
let value = parse_arg(&line_buf, b”value”).unwrap_or(0);
if pin == LED_PIN as i32 {
led.set_level(if value != 0 { Level::High } else { Level::Low });
let _ = write!(resp_buf, “{{\”id\”:\”{}\”,\”ok\”:true,\”result\”:\”done\”}}”, id_str);
} else if pin >= 0 && pin <= 13 {
let _ = write!(resp_buf, “{{\”id\”:\”{}\”,\”ok\”:true,\”result\”:\”done\”}}”, id_str);
} else {
let _ = write!(
resp_buf,
“{{\”id\”:\”{}\”,\”ok\”:false,\”result\”:\”\”,\”error\”:\”Invalid pin {}\”}}”,
id_str, pin
);
}
} else {
let _ = write!(
resp_buf,
“{{\”id\”:\”{}\”,\”ok\”:false,\”result\”:\”\”,\”error\”:\”Unknown command\”}}”,
id_str
);
}
let _ = usart.blocking_write(resp_buf.as_bytes());
let _ = usart.blocking_write(b”\n”);
line_buf.clear();
}
} else if line_buf.push(b).is_err() {
line_buf.clear();
}
}
}
}The async main function is the Nucleo firmware entry under Embassy’s executor and is responsible for bringing the board into a running ZeroClaw state: it initializes the MCU peripherals via embassy_stm32::init, configures a UART at 115200, constructs a blocking serial endpoint on the board’s USART2, and creates an Output handle for the on‑board LED so the firmware can reflect gpio_write requests; it then logs readiness and allocates three small, explicit buffers to operate within the firmware’s constrained heap (a heapless Vec used as the incoming line buffer, a fixed 16‑byte id_buf for device/request IDs, and a small heapless String used to build JSON responses). The main loop reads single bytes from the UART in a blocking fashion and accumulates them into the line buffer until it sees a newline or carriage return; when a full line arrives and is nonempty it extracts an ID out of the line using copy_id (copy_id will be explained later) and converts that ID to a UTF‑8 string with a fallback, clears the response buffer, then branches on command predicates using has_cmd (previously explained) to choose behavior. For ping the firmware composes a simple success JSON with result “pong”; for capabilities it returns a JSON payload describing available GPIO pins and the configured led_pin; for gpio_read it uses parse_arg (previously explained) to pull a pin argument, returns a numeric 0 for reads of the LED_PIN (LED_PIN was explained earlier) and for pins in the 0–13 range, or an error JSON for invalid pins; for gpio_write it parses pin and value, toggles the LED output when the pin equals LED_PIN, returns success for other valid pins, and returns an error JSON
# file path: firmware/zeroclaw-uno-q-bridge/app.yaml
name: ZeroClaw Bridge
description: “GPIO bridge for ZeroClaw — exposes digitalWrite/digitalRead via socket for agent control”
icon: 🦀
version: “1.0.0”
ports:
- 9999
bricks: []app.yaml is the deployment manifest that tells the orchestrator how to present and expose the Zeroclaw Uno Q bridge microservice. It supplies human-facing metadata (the service name, a short description that explains the bridge exposes digitalWrite/digitalRead over a socket, and an icon for dashboards) and a version string the platform can use for releases and rollouts. The manifest also declares the runtime networking contract by listing the port to expose so the runtime will route TCP traffic to the bridge process on that port; that port entry is what the socket-listening bridge code must bind to at runtime and is the runtime counterpart to the ZEROCLAW_PORT constant used elsewhere in the project. Finally, bricks is left empty, which signals that the bridge is self-contained from the orchestrator’s dependency perspective and does not request any linked sidecars or managed backends. Operationally, the orchestrator reads this file, labels the service in the control plane, opens and routes the declared port so external agents or the agent core can connect, and starts the bridge binary that accepts socket connections and forwards commands down to the firmware; those firmware helpers and constants you’ve already seen (parse_arg, has_cmd, LED_PIN) are what the board-side code expects to receive from the bridge over the serial channel. The manifest therefore exists solely to ensure the runtime and the code agree on how the Uno Q bridge is exposed and identified to the platform.
# file path: firmware/zeroclaw-uno-q-bridge/python/main.py
import socket
import threading
from arduino.app_utils import App, BridgeThe socket and threading imports bring in the OS-level primitives the bridge uses to accept TCP connections and handle them concurrently: socket supplies the listener used by the accept loop and threading is used to spawn the accept thread and per-client worker threads so the entrypoint can continuously accept and dispatch connections to handle_client. The App and Bridge classes from arduino.app_utils provide the higher-level runtime and protocol glue: App exposes the application lifecycle and event loop runner the bridge uses to integrate with ZeroClaw’s orchestration, and Bridge encapsulates the per-connection mediation between the microcontroller-side protocol and the ZeroClaw system, so together these imports connect the low-level network and threading behavior to the platform’s channel/tool layers (mirroring the server-and-thread pattern you see in the other main and accept_loop code and the port declaration in app.yaml).
# file path: firmware/zeroclaw-uno-q-bridge/python/main.py
ZEROCLAW_PORT = 9999ZEROCLAW_PORT is the named TCP port that the UNO Q bridge uses to accept external connections; it is set to 9999 so the bridge’s network listener and accept loop bind to a well‑known, non‑privileged port. The constant centralizes the port number so the entrypoint’s server setup (the socket that main binds and the accept_loop that runs in a background thread) and the handle_client dispatch logic all use the same value, and it matches the service declaration in app.yaml where the bridge advertises the same port for exposing GPIO control. At runtime, external agents connect to that port to send JSON commands that the accept_loop and handle_client forward to the microcontroller bridge and back into the ZeroClaw system.
# file path: firmware/zeroclaw-uno-q-bridge/python/main.py
def handle_client(conn):
try:
data = conn.recv(256).decode().strip()
if not data:
conn.close()
return
parts = data.split()
if len(parts) < 2:
conn.sendall(b”error: invalid command\n”)
conn.close()
return
cmd = parts[0].lower()
if cmd == “gpio_write” and len(parts) >= 3:
pin = int(parts[1])
value = int(parts[2])
Bridge.call(”digitalWrite”, [pin, value])
conn.sendall(b”ok\n”)
elif cmd == “gpio_read” and len(parts) >= 2:
pin = int(parts[1])
val = Bridge.call(”digitalRead”, [pin])
conn.sendall(f”{val}\n”.encode())
else:
conn.sendall(b”error: unknown command\n”)
except Exception as e:
try:
conn.sendall(f”error: {e}\n”.encode())
except Exception:
pass
finally:
conn.close()handle_client is the network-facing request handler for the ZeroClaw UNO Q bridge: it reads a short byte payload from the connected socket, decodes and trims it, and if the line is empty it closes the connection immediately. It tokenizes the incoming string into parts and uses simple guard checks to reject too-short requests with a structured error response; when the first token names a supported operation it parses the remaining tokens as integers and dispatches the action via Bridge.call so the bridge runtime can forward the request to the microcontroller side (for example, invoking digitalWrite or digitalRead). The function implements a small command router with two explicit happy paths—one that writes a GPIO pin and acknowledges success, and one that reads a GPIO pin and returns the numeric value—and a default branch that returns an unknown-command error. Runtime exceptions are caught at the outer level: the handler attempts to send a diagnostic error string back to the client and suppresses any secondary errors that occur while reporting the original failure, and it always closes the connection in the finally block. In the larger system this pairs with accept_loop, which spawns a thread per accepted socket to run handle_client, and complements the higher-level execute flow that issues bridge requests from tools; unlike the MCU helpers parse_arg and has_cmd that operate on raw serial frames inside firmware, handle_client translates network socket lines into Bridge.call invocations and mediates the TCP-side protocol for the bridge.
# file path: firmware/zeroclaw-uno-q-bridge/python/main.py
def accept_loop(server):
while True:
try:
conn, _ = server.accept()
t = threading.Thread(target=handle_client, args=(conn,))
t.daemon = True
t.start()
except Exception:
breakaccept_loop is the runtime loop that sits behind the socket listener main initialized and handed to it; its job is to keep pulling incoming TCP connections off the server and hand each connection to the connection handler so the UNO Q bridge can mediate messages between remote peers and the ZeroClaw Bridge. It repeatedly calls the socket accept operation to obtain a new connection, and for each successful accept it creates a background thread whose target is handle_client (which you’ve already seen handles the simple line‑oriented command protocol, calls Bridge methods, sends replies, and closes the socket). Each thread is marked daemon so the threads run independently and won’t block process shutdown. The loop continues accepting and spawning handler threads until an exception escapes from accept, at which point the loop breaks and the accept_loop terminates; during normal operation this continuous accept→spawn→return flow is how inbound JSON/command traffic is demultiplexed and fed into the bridge runtime (and when needed the accept path also coordinates starting the cloud tunnel via Tunnel.start so the local service becomes reachable externally).
# file path: src/tunnel/cloudflare.rs
use super::{kill_shared, new_shared_process, SharedProcess, Tunnel, TunnelProcess};
use anyhow::{bail, Result};
use tokio::io::AsyncBufReadExt;
use tokio::process::Command;These imports wire this Cloudflare tunnel adapter into the project’s common process-management and async I/O infrastructure so the adapter can create, observe, and tear down a long‑running cloudflared process and surface its public URL and health state back to ZeroClaw. The group of names imported from super — kill_shared, new_shared_process, SharedProcess, Tunnel, and TunnelProcess — are the local helpers and types that implement the shared‑process lifecycle used by accept_loop and main to ensure a single managed tunnel instance can be started, shared, and killed across callers; this is how the adapter participates in the tunnel layer’s lifecycle management. The anyhow imports provide the crate’s ergonomic error handling surface so functions return a Result and can abort early with bail when startup or health checks fail. The tokio async I/O import supplies buffered reader extensions used when the adapter needs to asynchronously read lines from the cloudflared process’s stdout/stderr (for example to detect the published URL or parse health output), and the tokio process Command import is the async process launcher used to spawn and control the cloudflared subprocess. This combination mirrors the project’s common pattern for runtime adapters that spawn external tools; similar files import the same shared lifecycle helpers and anyhow, and some variants omit the buffered‑reader trait when they don’t need to stream process output.
# file path: src/tunnel/cloudflare.rs
async fn start(&self, _local_host: &str, local_port: u16) -> Result<String> {
let mut child = Command::new(”cloudflared”)
.args([
“tunnel”,
“--no-autoupdate”,
“run”,
“--token”,
&self.token,
“--url”,
&format!(”http://localhost:{local_port}”),
])
.stdout(std::process::Stdio::piped())
.stderr(std::process::Stdio::piped())
.kill_on_drop(true)
.spawn()?;
let stderr = child
.stderr
.take()
.ok_or_else(|| anyhow::anyhow!(”Failed to capture cloudflared stderr”))?;
let mut reader = tokio::io::BufReader::new(stderr).lines();
let mut public_url = String::new();
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(30);
while tokio::time::Instant::now() < deadline {
let line =
tokio::time::timeout(tokio::time::Duration::from_secs(5), reader.next_line()).await;
match line {
Ok(Ok(Some(l))) => {
tracing::debug!(”cloudflared: {l}”);
if let Some(idx) = l.find(”https://”) {
let url_part = &l[idx..];
let end = url_part
.find(|c: char| c.is_whitespace())
.unwrap_or(url_part.len());
public_url = url_part[..end].to_string();
break;
}
}
Ok(Ok(None)) => break,
Ok(Err(e)) => bail!(”Error reading cloudflared output: {e}”),
Err(_) => {}
}
}
if public_url.is_empty() {
child.kill().await.ok();
bail!(”cloudflared did not produce a public URL within 30s. Is the token valid?”);
}
let mut guard = self.proc.lock().await;
*guard = Some(TunnelProcess {
child,
public_url: public_url.clone(),
});
Ok(public_url)
}The start function on CloudflareTunnel is responsible for bringing up a cloudflared tunnel that exposes a local service and returning its public URL to the rest of ZeroClaw; accept_loop and main invoke this to publish the local ZeroClaw service. It runs asynchronously and returns a Result containing the discovered public URL or an error. The method spawns a cloudflared subprocess configured to run a tunnel with the adapter’s token and point at the provided local_port; the subprocess is configured so its output streams can be read and so the OS will kill the subprocess automatically if the child handle is dropped. After launching the process it takes ownership of the process’s stderr stream, wraps it in an async buffered line reader, and then enters a loop that lasts up to thirty seconds. Inside the loop it reads one line at a time with a shorter per-read timeout, logs each line at debug level, and scans for an https:// substring; when it finds one it slices out the contiguous URL up to the next whitespace and treats that as the public URL
# file path: firmware/zeroclaw-uno-q-bridge/python/main.py
def loop():
App.sleep(1)loop is the tiny user-level callback that App.run invokes to keep the UNO Q bridge process alive; it simply yields back into the App runtime by calling App.sleep for one second. Because main already bound the listener to ZEROCLAW_PORT and started accept_loop in a background thread so incoming connections get handed to handle_client, loop’s responsibility is not to process network I/O but to provide a lightweight heartbeat and prevent the main thread from busy‑spinning. By using App.sleep rather than a raw blocking sleep, it integrates with the project’s App process-management and async infrastructure (the same import surface that also brings in CloudflareTunnel.start), allowing the App runtime to manage background tasks and timers while accept_loop and handle_client continue mediating connections. Unlike other, more involved user_loop implementations elsewhere, loop intentionally does nothing beyond this cooperative one‑second pause.
# file path: firmware/zeroclaw-uno-q-bridge/python/main.py
def main():
server = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
server.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
server.bind((”127.0.0.1”, ZEROCLAW_PORT))
server.listen(5)
server.settimeout(1.0)
t = threading.Thread(target=accept_loop, args=(server,))
t.daemon = True
t.start()
App.run(user_loop=loop)main boots the UNO Q bridge’s network-facing runtime by creating a TCP listener bound to the loopback address and ZEROCLAW_PORT, configuring the socket to reuse the address and to time out on blocking calls so the accept loop can make progress and exit when needed. It then puts the server into listening mode and spins off a background thread that runs accept_loop; accept_loop will continuously accept incoming connections and spawn per-connection worker threads that call handle_client to decode and mediate each peer’s messages. Those worker threads are marked as daemon so they will not block process shutdown. After the listener thread is started, main yields control to the application’s async runtime by invoking App.run with the user loop; within that runtime the bridge will start its higher-level async pieces such as Tunnel.start to publish the local listener, Channel.listen to pull inbound messages from configured channels, and HeartbeatEngine.run to emit periodic health pings, tying the local TCP accept/worker pipeline into the rest of ZeroClaw’s bridge orchestration.
# file path: src/heartbeat/engine.rs
use crate::config::HeartbeatConfig;
use crate::observability::{Observer, ObserverEvent};
use anyhow::Result;
use std::path::Path;
use std::sync::Arc;
use tokio::time::{self, Duration};
use tracing::{info, warn};The imports wire together the small set of capabilities the heartbeat engine needs to do its job: HeartbeatConfig brings in the runtime settings that drive how often and where the engine writes its heartbeat, and Observer plus ObserverEvent provide the observability abstraction the engine uses to publish lifecycle and task events back into ZeroClaw’s telemetry system. anyhow::Result is the unified error return type used by async engine operations. std::path::Path is pulled in because the engine manipulates filesystem paths when ensuring and updating the heartbeat file (the tests in mod.rs exercise that behavior), and tokio::time and its Duration type supply the async interval/tick primitives that drive the run/tick loop and sleep between checks. std::sync::Arc supports sharing the Observer across async tasks and matches the pattern used by the HeartbeatEngine struct and its new constructor which accept an Arc. Finally, the tracing info and warn macros are imported for lightweight runtime logging of normal and anomalous conditions during heartbeat persistence and task orchestration. Together these imports reflect the engine’s responsibilities: read config, schedule periodic work with tokio timers, touch the filesystem, emit observability events via a shared Observer, and report run‑time status with tracing.
# file path: src/heartbeat/engine.rs
pub async fn run(&self) -> Result<()> {
if !self.config.enabled {
info!(”Heartbeat disabled”);
return Ok(());
}
let interval_mins = self.config.interval_minutes.max(5);
info!(”💓 Heartbeat started: every {} minutes”, interval_mins);
let mut interval = time::interval(Duration::from_secs(u64::from(interval_mins) * 60));
loop {
interval.tick().await;
self.observer.record_event(&ObserverEvent::HeartbeatTick);
match self.tick().await {
Ok(tasks) => {
if tasks > 0 {
info!(”💓 Heartbeat: processed {} tasks”, tasks);
}
}
Err(e) => {
warn!(”💓 Heartbeat error: {}”, e);
self.observer.record_event(&ObserverEvent::Error {
component: “heartbeat”.into(),
message: e.to_string(),
});
}
}
}
}HeartbeatEngine.run is the runtime loop the main program uses to drive scheduled heartbeat work: it first checks the heartbeat configuration and exits immediately if the heartbeat is disabled so the rest of the system can continue without background scheduling. It enforces a sane minimum cadence by clamping the configured interval to at least five minutes and then constructs an async interval ticker that sleeps between ticks. On each wakeup it records a HeartbeatTick event via the observer for observability, delegates the actual work to tick (which in turn collects and parses tasks from the HEARTBEAT.md workflow file), and then inspects the result. On the successful path it logs how many tasks were processed when that number is nonzero; on error it logs a warning and records an ObserverEvent::Error with the heartbeat component and the error message so external monitoring can pick up failures. The loop is infinite (the only early return is when the heartbeat is disabled), so run functions as a perpetual scheduler that coordinates timing, telemetry, and error reporting while delegating task discovery and execution to the engine’s tick/collect_tasks helpers. This pattern mirrors the project’s other heartbeat runner but centralizes observer-based telemetry and a minimum-interval safety guard.
# file path: firmware/zeroclaw-uno-q-bridge/python/main.py
if __name__ == “__main__”:
main()When the file is executed as the program entrypoint it deliberately invokes main so the UNO Q bridge run sequence is started: main brings up the network listener bound to ZEROCLAW_PORT, kicks off the CloudflareTunnel start sequence so the local service is published, and hands the listener into accept_loop which then uses handle_client to mediate incoming connections; when the module is merely imported by other code none of that orchestration runs. This pattern mirrors other project pieces where top-level configuration and runtime loops are declared—ZEROCLAW_PORT defines the well‑known port the listener binds to, the loop helper yields control with cooperative sleeps, and the app.yaml similarly advertises the bridge and its exposed port—so the conditional invocation here is the explicit trigger that ties those declarations and runtime components together into a running bridge process.
# file path: firmware/zeroclaw-uno-q-bridge/sketch/sketch.yaml
profiles:
default:
fqbn: arduino:zephyr:unoq
platforms:
- platform: arduino:zephyr
libraries:
- MsgPack (0.4.2)
- DebugLog (0.8.4)
- ArxContainer (0.7.0)
- ArxTypeTraits (0.3.1)
default_profile: defaultsketch.yaml declares the Arduino sketch build profile that the build-and-upload tooling uses to produce and flash the zeroclaw-uno-q-bridge firmware: it names a default profile, selects the fully qualified board identifier that tells the toolchain to target the Arduino Zephyr Uno Q variant, lists the platform entry so the Arduino/Zephyr core is pulled in, and pins the handful of Arduino libraries the sketch depends on along with exact versions for reproducible builds. by fixing the FQBN and platform, the tooling will compile with the correct board-specific flags, pin mappings and serial behavior the bridge expects so runtime pieces such as handle_client and accept_loop end up built for the right hardware; by pinning MsgPack, DebugLog, ArxContainer and ArxTypeTraits the sketch gets a known message-serialization layer, debug logging helpers, and small container/type-traits utilities required by the firmware. default_profile directs the toolchain which named profile to use when none is specified. compared to the description helper that generates and uploads ad-hoc Arduino sketches at request, sketch.yaml is a static manifest for the Uno Q bridge; compared to the Cargo.toml files used by Rust peripheral firmware, sketch.yaml expresses Arduino/Zephyr board metadata and Arduino-library dependencies rather than Rust crate listings and compiler/release profile settings.
# file path: quick_test.sh
set -e
echo “🔥 Quick Telegram Smoke Test”
echo “”
echo -n “1. Compiling... “
cargo build --release --quiet 2>&1 && echo “✓” || { echo “✗ FAILED”; exit 1; }
echo -n “2. Running tests... “
cargo test telegram_split --lib --quiet 2>&1 && echo “✓” || { echo “✗ FAILED”; exit 1; }
echo -n “3. Health check... “
timeout 7 target/release/zeroclaw channel doctor &>/dev/null && echo “✓” || echo “⚠ (configure bot first)”
echo -n “4. Code structure... “
grep -q “TELEGRAM_MAX_MESSAGE_LENGTH” src/channels/telegram.rs && \
grep -q “split_message_for_telegram” src/channels/telegram.rs && \
grep -q “tokio::time::timeout” src/channels/telegram.rs && \
echo “✓” || { echo “✗ FAILED”; exit 1; }
echo “”
echo “✅ Quick tests passed! Run ./test_telegram_integration.sh for full suite.”quick_test.sh is a tiny smoke/sanity orchestrator used by developers and lightweight CI to quickly validate that zeroclaw-main_cleaned builds and that the Telegram channel looks superficially healthy before running the full test suite. It begins by enabling fail-fast behavior so any failing step aborts the script, then prints a short header to indicate a Telegram smoke test is running. The first runtime step compiles the project in release mode and treats a nonzero build result as a hard failure. The second step runs the focused Telegram unit tests from the library crate and fails if those tests do not pass. The third step performs a runtime health probe by invoking the built zeroclaw binary with its channel diagnosis command under a seven‑second timeout; a clean exit prints success, while a nonzero exit prints a warning suggesting the Telegram bot may need configuration — this maps to the Telegram channel health semantics implemented with a timeout in the codebase. The fourth step statically inspects the Telegram channel source to make sure a key message length constant, the message‑splitting helper, and the use of an async timeout helper are present in src/channels/telegram.rs; all three must be found or the script fails. If all checks succeed it prints a short success note and points developers at the more comprehensive test_telegram_integration.sh for the full integration run. Because quick_test.sh is standalone it doesn’t wire into the accept_loop or handle_client runtime paths we discussed earlier; its role is to give a fast, conservative gate that the channels layer (specifically the Telegram channel) compiles, has basic runtime health, and contains the expected structural pieces before deeper integration testing.
# file path: rust-toolchain.toml
[toolchain]
channel = “1.92”rust-toolchain.toml declares the Rust toolchain configuration that rustup reads to install and pin a reproducible compiler/runtime for local development and CI; in this repository the file sets the project to use the Rust channel 1.92 so every developer and build uses the same compiler version when building components like the network-facing handle_client and the accept_loop, the CloudflareTunnel start routine, and the rest of ZeroClaw’s modules. Because rust-toolchain.toml is a standalone manifest rather than executable Rust source, it does not change runtime behavior or call any functions — instead it ensures the build environment is deterministic (and can also list additional components and targets when needed, although this file only specifies the channel). Compared to the similar files we saw earlier, such as the main entry that prints integration hints, the module re-exports in imports, or the simple name accessor, rust-toolchain.toml sits at the toolchain/configuration layer: it governs which compiler and toolchain features are available to compile those Rust sources rather than contributing logic to the agent, provider, or channel layers.
# file path: scripts/test_dockerignore.sh
set -euo pipefail
SCRIPT_DIR=”$(cd “$(dirname “${BASH_SOURCE[0]}”)” && pwd)”
PROJECT_ROOT=”$(dirname “$SCRIPT_DIR”)”
DOCKERIGNORE=”$PROJECT_ROOT/.dockerignore”
RED=’\033[0;31m’
GREEN=’\033[0;32m’
NC=’\033[0m’
PASS=0
FAIL=0
log_pass() {
echo -e “${GREEN}✓${NC} $1”
PASS=$((PASS + 1))
}
log_fail() {
echo -e “${RED}✗${NC} $1”
FAIL=$((FAIL + 1))
}
echo “=== Testing .dockerignore ===”
if [[ -f “$DOCKERIGNORE” ]]; then
log_pass “.dockerignore file exists”
else
log_fail “.dockerignore file does not exist”
exit 1
fi
MUST_EXCLUDE=(
“.git”
“.githooks”
“target”
“docs”
“examples”
“tests”
“*.md”
“*.png”
“*.db”
“*.db-journal”
“.DS_Store”
“.github”
“deny.toml”
“LICENSE”
“.env”
“.tmp_*”
)
for pattern in “${MUST_EXCLUDE[@]}”; do
if grep -Fq “$pattern” “$DOCKERIGNORE” 2>/dev/null; then
log_pass “Excludes: $pattern”
else
log_fail “Missing exclusion: $pattern”
fi
done
MUST_NOT_EXCLUDE=(
“Cargo.toml”
“Cargo.lock”
“src”
)
for path in “${MUST_NOT_EXCLUDE[@]}”; do
if grep -qE “^${path}$” “$DOCKERIGNORE” 2>/dev/null; then
log_fail “Build essential ‘$path’ is incorrectly excluded”
else
log_pass “Build essential NOT excluded: $path”
fi
done
while IFS= read -r line; do
[[ -z “$line” || “$line” =~ ^
if [[ “$line” =~ [[:space:]]$ ]]; then
log_fail “Trailing whitespace in pattern: ‘$line’”
fi
done < “$DOCKERIGNORE”
log_pass “No trailing whitespace in patterns”
echo “”
echo “=== Simulating Docker build context ===”
TEMP_DIR=$(mktemp -d)
trap “rm -rf $TEMP_DIR” EXIT
cd “$PROJECT_ROOT”
TOTAL_FILES=$(find . -type f | wc -l | tr -d ‘ ‘)
CONTEXT_FILES=$(find . -type f \
! -path ‘./.git/*’ \
! -path ‘./target/*’ \
! -path ‘./docs/*’ \
! -path ‘./examples/*’ \
! -path ‘./tests/*’ \
! -name ‘*.md’ \
! -name ‘*.png’ \
! -name ‘*.svg’ \
! -name ‘*.db’ \
! -name ‘*.db-journal’ \
! -name ‘.DS_Store’ \
! -path ‘./.github/*’ \
! -name ‘deny.toml’ \
! -name ‘LICENSE’ \
! -name ‘.env’ \
! -name ‘.env.*’ \
2>/dev/null | wc -l | tr -d ‘ ‘)
echo “Total files in repo: $TOTAL_FILES”
echo “Files in Docker context: $CONTEXT_FILES”
if [[ $CONTEXT_FILES -lt $TOTAL_FILES ]]; then
log_pass “Docker context is smaller than full repo ($CONTEXT_FILES < $TOTAL_FILES files)”
else
log_fail “Docker context is not being reduced”
fi
echo “”
echo “=== Security checks ===”
if [[ -d “$PROJECT_ROOT/.git” ]]; then
if grep -q “^\.git$” “$DOCKERIGNORE”; then
log_pass “.git directory will be excluded (security)”
else
log_fail “.git directory NOT excluded - SECURITY RISK”
fi
fi
DB_FILES=$(find “$PROJECT_ROOT” -name “*.db” -type f 2>/dev/null | head -5)
if [[ -n “$DB_FILES” ]]; then
if grep -q “^\*\.db$” “$DOCKERIGNORE”; then
log_pass “*.db files will be excluded (security)”
else
log_fail “*.db files NOT excluded - SECURITY RISK”
fi
fi
echo “”
echo “=== Summary ===”
echo -e “Passed: ${GREEN}$PASS${NC}”
echo -e “Failed: ${RED}$FAIL${NC}”
if [[ $FAIL -gt 0 ]]; then
echo -e “${RED}FAILED${NC}: $FAIL tests failed”
exit 1
else
echo -e “${GREEN}PASSED${NC}: All tests passed”
exit 0
fitest_dockerignore.sh is a standalone CI/local validation utility whose job is to ensure the repository’s .dockerignore will keep large, sensitive, or unnecessary files out of Docker build contexts so ZeroClaw images remain small, reproducible, and safe to build. The script first resolves the script and project directories and locates the .dockerignore path, then initialises simple colored logging via log_pass and log_fail and counters PASS and FAIL to accumulate test results. It asserts the presence of .dockerignore as a basic guard, then iterates over a MUST_EXCLUDE list of patterns (git metadata, build artifacts, docs, media, DB files, temporary files and common secrets) and checks each pattern is present in .dockerignore, logging a pass or fail per pattern. It then iterates over a MUST_NOT_EXCLUDE list of build‑essential entries (Cargo.toml, Cargo.lock, src) and verifies those are not excluded, again recording results. The script reads .dockerignore line by line to detect empty lines and trailing whitespace in patterns and reports failures for malformed entries. To approximate what Docker would send, it simulates a build context: it creates a temporary directory, cds to the project root, counts TOTAL_FILES in the repo, computes CONTEXT_FILES by running a find that mirrors the ignore set (negating paths and names that should be excluded), and compares the counts to ensure the Docker context is smaller than the full repo. For security checks it detects whether a .git directory exists and ensures .git is excluded, and if any .db files are present in the tree it ensures a *.db exclusion exists. Finally it prints a summary of Passed and Failed counts and exits with nonzero on any failures so CI can catch problems. Conceptually this mirrors the Rust unit tests like dockerignore_excludes_git_directory and dockerignore_excludes_security_critical_paths that parse and assert dockerignore behavior, but test_dockerignore.sh performs those checks as an executable shell workflow in CI, simulates the actual build context with filesystem finds, reports colored human‑readable output, and includes line formatting checks (trailing whitespace) that the Rust assertions do not explicitly enumerate. This script does not participate in runtime functionality such as handle_client or accept_loop; it is a repository hygiene tool that supports ZeroClaw’s goal of secure, portable deployments across constrained environments.
# file path: src/agent/agent.rs
use crate::agent::dispatcher::{
NativeToolDispatcher, ParsedToolCall, ToolDispatcher, ToolExecutionResult, XmlToolDispatcher,
};
use crate::agent::memory_loader::{DefaultMemoryLoader, MemoryLoader};
use crate::agent::prompt::{PromptContext, SystemPromptBuilder};
use crate::config::Config;
use crate::memory::{self, Memory, MemoryCategory};
use crate::observability::{self, Observer, ObserverEvent};
use crate::providers::{self, ChatMessage, ChatRequest, ConversationMessage, Provider};
use crate::runtime;
use crate::security::SecurityPolicy;
use crate::tools::{self, Tool, ToolSpec};
use crate::util::truncate_with_ellipsis;
use anyhow::Result;
use std::io::Write as IoWrite;
use std::sync::Arc;
use std::time::Instant;These imports pull together the building blocks the agent core needs to compose prompts, manage history and memory, call into language model providers, and dispatch tools during a run loop. The dispatcher types and traits supply the agent’s pluggable tool execution layer: a ToolDispatcher abstraction plus concrete implementations like NativeToolDispatcher and XmlToolDispatcher, along with ParsedToolCall and ToolExecutionResult types that represent a parsed invocation and its outcome; that pattern enables the agent to switch dispatch strategies at runtime. The memory_loader pieces expose a MemoryLoader trait and a DefaultMemoryLoader implementation so the agent can fetch and hydrate conversational context and semantic memory before composing prompts. PromptContext and SystemPromptBuilder are the prompt-composition primitives the agent uses to assemble system and conversation prompts from config and memory. Config, SecurityPolicy, and runtime bring in environment configuration, policy enforcement, and runtime adapters the agent consults when deciding what it’s allowed to do and how to execute side effects. The memory module types Memory and MemoryCategory model persistent stores and categorization the agent queries and updates as part of its state management. Observability wiring with Observer and ObserverEvent lets the agent emit structured events and metrics during runs so the rest of the system can monitor behavior. Provider integration is represented by the Provider trait and message types such as ChatMessage, ChatRequest, and ConversationMessage, which let the agent talk to language model adapters and translate conversation state into provider requests. Tool and ToolSpec surface tool metadata and capabilities the agent uses to validate and present options to the model. Utility imports like truncate_with_ellipsis, anyhow’s Result, an IoWrite alias, Arc for shared ownership, and Instant for timing are small but necessary helpers used throughout orchestration. Compared with similar import lists elsewhere in the project, this set is more focused on orchestration: it adds explicit parsed-call and execution-result types, multiple dispatcher implementations, and the memory loader abstraction, whereas other files tended to import fewer dispatcher-specific types, different provider message variants, or extra utilities like UUID; that difference reflects this file’s role as the central coordinator that composes prompts, consults memory, invokes providers, and routes tool calls.
# file path: src/agent/agent.rs
pub struct Agent {
provider: Box<dyn Provider>,
tools: Vec<Box<dyn Tool>>,
tool_specs: Vec<ToolSpec>,
memory: Arc<dyn Memory>,
observer: Arc<dyn Observer>,
prompt_builder: SystemPromptBuilder,
tool_dispatcher: Box<dyn ToolDispatcher>,
memory_loader: Box<dyn MemoryLoader>,
config: crate::config::AgentConfig,
model_name: String,
temperature: f64,
workspace_dir: std::path::PathBuf,
identity_config: crate::config::IdentityConfig,
skills: Vec<crate::skills::Skill>,
auto_save: bool,
history: Vec<ConversationMessage>,
}The Agent struct is the runtime heart of the agent core that composes prompts, manages history and memory, wires up provider adapters and tools, and drives the run/interactive loop that powers ZeroClaw’s autonomous behavior. It holds a boxed Provider implementation that the agent uses to talk to language models, a collection of boxed Tool implementations that perform side effects, and a parallel set of ToolSpec descriptions that are surfaced to the provider so the model can reason about available tools. Persistent state and recall are handled via an Arc-wrapped Memory implementation, while an Arc-wrapped Observer collects telemetry and observability events during runs. SystemPromptBuilder is kept to assemble the system and conversation prompts (informed by identity and skills), and a boxed ToolDispatcher encapsulates the strategy for routing provider-suggested tool calls to concrete Tool implementations; a boxed MemoryLoader is responsible for pulling relevant memory items into the active prompt context before a model call. AgentConfig and IdentityConfig hold the agent’s operational and identity settings, model_name and temperature control how the provider is invoked, and workspace_dir points to the agent’s on-disk workspace used by tools and memory. The skills vector contains higher-level capability descriptions that are incorporated into prompts and decision logic, auto_save determines whether results are persisted automatically, and history is the in-memory sequence of ConversationMessage entries that forms the immediate context for subsequent turns. Architect
# file path: src/agent/agent.rs
pub struct AgentBuilder {
provider: Option<Box<dyn Provider>>,
tools: Option<Vec<Box<dyn Tool>>>,
memory: Option<Arc<dyn Memory>>,
observer: Option<Arc<dyn Observer>>,
prompt_builder: Option<SystemPromptBuilder>,
tool_dispatcher: Option<Box<dyn ToolDispatcher>>,
memory_loader: Option<Box<dyn MemoryLoader>>,
config: Option<crate::config::AgentConfig>,
model_name: Option<String>,
temperature: Option<f64>,
workspace_dir: Option<std::path::PathBuf>,
identity_config: Option<crate::config::IdentityConfig>,
skills: Option<Vec<crate::skills::Skill>>,
auto_save: Option<bool>,
}AgentBuilder collects and stages the pluggable pieces the agent core needs so the run loop can be assembled consistently: it holds optional slots for the Provider adapter, the vector of Tool implementations, the Memory backend (shared via an Arc), an Observer for lifecycle/telemetry, the SystemPromptBuilder that composes system and conversation prompts, the ToolDispatcher that turns model-decided tool intents into executions, the MemoryLoader used to hydrate memory into conversation context, the AgentConfig and IdentityConfig, the preferred model name and sampling temperature, a workspace directory, a list of Skill descriptors, and an auto_save flag. Conceptually it implements the Builder pattern: instead of requiring all dependencies upfront, it allows wiring each pluggable layer incrementally and defers validation/defaulting until the final Agent is constructed. That design mirrors Agent but differs in purpose and shape — Agent owns concrete, non-optional fields (including tool specs and a running conversation history) suitable for runtime execution, whereas AgentBuilder holds optional trait objects and configuration so caller code can assemble only what it needs before calling the builder’s finalize method. In the ZeroClaw architecture AgentBuilder is the assembly point that takes the channel- and startup-provided components (provider adapters, tools, memory backends, and configuration) and packages them for the agent core to instantiate a fully wired Agent that will compose prompts, load memory, dispatch tools, and drive the interactive run loop.
# file path: src/agent/agent.rs
impl AgentBuilder {
pub fn new() -> Self {
Self {
provider: None,
tools: None,
memory: None,
observer: None,
prompt_builder: None,
tool_dispatcher: None,
memory_loader: None,
config: None,
model_name: None,
temperature: None,
workspace_dir: None,
identity_config: None,
skills: None,
auto_save: None,
}
}
pub fn provider(mut self, provider: Box<dyn Provider>) -> Self {
self.provider = Some(provider);
self
}
pub fn tools(mut self, tools: Vec<Box<dyn Tool>>) -> Self {
self.tools = Some(tools);
self
}
pub fn memory(mut self, memory: Arc<dyn Memory>) -> Self {
self.memory = Some(memory);
self
}
pub fn observer(mut self, observer: Arc<dyn Observer>) -> Self {
self.observer = Some(observer);
self
}
pub fn prompt_builder(mut self, prompt_builder: SystemPromptBuilder) -> Self {
self.prompt_builder = Some(prompt_builder);
self
}
pub fn tool_dispatcher(mut self, tool_dispatcher: Box<dyn ToolDispatcher>) -> Self {
self.tool_dispatcher = Some(tool_dispatcher);
self
}
pub fn memory_loader(mut self, memory_loader: Box<dyn MemoryLoader>) -> Self {
self.memory_loader = Some(memory_loader);
self
}
pub fn config(mut self, config: crate::config::AgentConfig) -> Self {
self.config = Some(config);
self
}
pub fn model_name(mut self, model_name: String) -> Self {
self.model_name = Some(model_name);
self
}
pub fn temperature(mut self, temperature: f64) -> Self {
self.temperature = Some(temperature);
self
}
pub fn workspace_dir(mut self, workspace_dir: std::path::PathBuf) -> Self {
self.workspace_dir = Some(workspace_dir);
self
}
pub fn identity_config(mut self, identity_config: crate::config::IdentityConfig) -> Self {
self.identity_config = Some(identity_config);
self
}
pub fn skills(mut self, skills: Vec<crate::skills::Skill>) -> Self {
self.skills = Some(skills);
self
}
pub fn auto_save(mut self, auto_save: bool) -> Self {
self.auto_save = Some(auto_save);
self
}
pub fn build(self) -> Result<Agent> {
let tools = self
.tools
.ok_or_else(|| anyhow::anyhow!(”tools are required”))?;
let tool_specs = tools.iter().map(|tool| tool.spec()).collect();
Ok(Agent {
provider: self
.provider
.ok_or_else(|| anyhow::anyhow!(”provider is required”))?,
tools,
tool_specs,
memory: self
.memory
.ok_or_else(|| anyhow::anyhow!(”memory is required”))?,
observer: self
.observer
.ok_or_else(|| anyhow::anyhow!(”observer is required”))?,
prompt_builder: self
.prompt_builder
.unwrap_or_else(SystemPromptBuilder::with_defaults),
tool_dispatcher: self
.tool_dispatcher
.ok_or_else(|| anyhow::anyhow!(”tool_dispatcher is required”))?,
memory_loader: self
.memory_loader
.unwrap_or_else(|| Box::new(DefaultMemoryLoader::default())),
config: self.config.unwrap_or_default(),
model_name: self
.model_name
.unwrap_or_else(|| “anthropic/claude-sonnet-4-20250514”.into()),
temperature: self.temperature.unwrap_or(0.7),
workspace_dir: self
.workspace_dir
.unwrap_or_else(|| std::path::PathBuf::from(”.”)),
identity_config: self.identity_config.unwrap_or_default(),
skills: self.skills.unwrap_or_default(),
auto_save: self.auto_save.unwrap_or(false),
history: Vec::new(),
})
}
}AgentBuilder implements a fluent Builder that assembles the pieces the agent core needs to run: provider adapters, tool implementations, the memory backend, an observer, prompt construction logic, a tool dispatcher, and various runtime/configuration parameters. You construct an AgentBuilder with new and then call the configuration methods named provider, tools, memory, observer, prompt_builder, tool_dispatcher, memory_loader, config, model_name, temperature, workspace_dir, identity_config, skills, and auto_save to incrementally inject concrete components; each of those setters accepts the corresponding object and returns the builder so callers can chain configuration calls. When build is invoked the builder validates required dependencies (failing with an error if critical pieces like provider, tools, memory, observer, or tool_dispatcher were not supplied), extracts a list of tool metadata by asking each Tool for its spec, and then consumes the builder to produce a fully populated Agent. For optional pieces it applies sensible defaults instead of failing: SystemPromptBuilder defaults are used when no prompt_builder is supplied, a DefaultMemoryLoader is used if none was provided, AgentConfig and IdentityConfig fall back to their defaults, the model_name defaults to the Anthropic Claude Sonnet identifier, temperature and workspace_dir are given default values, skills and auto_save default to empty/false, and the agent’s history is initialized as an empty vector. In the context of ZeroClaw’s architecture the builder ties together the provider adapter (for model calls), the memory backend (for persistence and recall), the tool/runtime layer (the tools and the tool_dispatcher), and the prompt construction so the resulting Agent is ready to run its orchestration and dispatch
# file path: src/agent/agent.rs
pub fn new() -> Self {
Self {
provider: None,
tools: None,
memory: None,
observer: None,
prompt_builder: None,
tool_dispatcher: None,
memory_loader: None,
config: None,
model_name: None,
temperature: None,
workspace_dir: None,
identity_config: None,
skills: None,
auto_save: None,
}
}Agent::new creates a fresh Agent instance with every pluggable component left unset — the provider, tools, memory, observer, prompt builder, tool dispatcher, memory loader, configuration, model selection, temperature, workspace directory, identity info, skills list, and autosave flag are all initialized as absent. In the context of the agent core that composes prompts, manages history and memory, integrates provider adapters and tools, and runs orchestration loops, this constructor simply produces an empty, unconfigured Agent object that represents a zeroed starting state; no runtime orchestration or data flow happens until those Option fields are populated. This empty-constructor approach complements the existing builder pattern: builder returns an AgentBuilder that exposes the same set of optional fields for incremental configuration and whose build method validates required pieces and fills sensible defaults (for example the prompt builder, memory loader, model name, temperature, workspace directory, identity, skills, auto-save, and an initially empty history). In short, new gives you a plain Agent container with all extension points unset, while AgentBuilder/build is the path that enforces requirements and supplies defaults before the agent is runnable.
# file path: src/agent/agent.rs
pub fn provider(mut self, provider: Box<dyn Provider>) -> Self {
self.provider = Some(provider);
self
}The provider method on AgentBuilder is the fluent setter that lets the caller inject a language-model adapter into the agent construction process: it takes a boxed implementation of the Provider trait, stores it into the builder’s provider slot, and returns the builder so further configuration calls can be chained. Conceptually this is how the agent core is told which provider adapter to use at runtime (for example OpenAI, a local LLM, or another adapter); that adapter will later be owned by the built Agent and invoked during the run loop to produce model completions and interpret tool responses. It follows the same builder/fluent pattern as the config and prompt_builder setters, but differs in type because it accepts a boxed trait object to enable runtime polymorphism across different provider implementations rather than a concrete config or prompt-builder type. There is no branching or validation here: the method simply stores the provided adapter and hands the builder back for continued composition.
# file path: src/agent/agent.rs
pub fn tools(mut self, tools: Vec<Box<dyn Tool>>) -> Self {
self.tools = Some(tools);
self
}The tools method on AgentBuilder is a simple fluent setter that accepts a collection of implementations of the Tool trait and stores them in the builder so they become part of the Agent configuration. In the context of ZeroClaw’s architecture, supplying tools here is how the agent core is given the concrete side‑effect capabilities (HTTP calls, filesystem actions, hardware interfaces, etc.) it can invoke during a run loop; those tool instances are later consumed by build to produce the Agent’s tools vector and the derived tool_specs used by orchestration. It follows the same builder pattern as the other setters like provider and tool_dispatcher: it mutably updates the builder state and returns Self so calls can be chained. There is no branching or validation in this setter itself; the build method performs presence checks and maps each Tool to its spec (as seen in build) and wires the tools together with the tool_dispatcher and provider into the final Agent. This method therefore plays the role of injecting the runtime capabilities the agent will dispatch during conversations, aligning with the imports you saw earlier that pull together prompts, memory, providers, and tools for zeroclaw-main_cleaned.
# file path: src/agent/agent.rs
pub fn memory(mut self, memory: Arc<dyn Memory>) -> Self {
self.memory = Some(memory);
self
}AgentBuilder’s memory method is the builder-style setter that injects the Memory implementation the Agent core will use for persistent context during runs. When you call AgentBuilder.memory you hand an Arc-wrapped trait object implementing Memory into the builder; the method stores that Arc into the builder’s memory field and returns the builder so callers can chain further configuration. This ties directly into the agent core’s orchestration responsibilities: the supplied Memory instance will be used at runtime to persist and recall conversation history and long‑term facts that the Agent consults when composing prompts, deciding tool usage, and maintaining state across interactions. The pattern mirrors the other fluent setters like memory_loader and config, but differs from memory_loader in that memory expects an Arc (shared, cloneable ownership and dynamic dispatch appropriate for concurrent use across the run loop), whereas memory_loader accepts a boxed loader type; config similarly follows the same chaining style but carries configuration data rather than a runtime-shared backend.
# file path: src/agent/agent.rs
pub fn observer(mut self, observer: Arc<dyn Observer>) -> Self {
self.observer = Some(observer);
self
}AgentBuilder’s observer method is a fluent setter that attaches an implementation of the Observer trait to the builder so the eventual Agent instance can publish lifecycle events during prompt composition, model interactions, memory access, and tool dispatch. It accepts a shared, reference-counted Observer implementation so the same observer object can be owned and called from multiple places or threads, stores it into the builder’s observer field, and returns the builder for method chaining in the familiar Builder pattern used across AgentBuilder methods like provider and config. By wiring an Observer at build time, the Agent core gains a pluggable hook that the run and interactive routines will call to notify external code or telemetry systems about decisions, tool calls, and model responses.
# file path: src/agent/agent.rs
pub fn prompt_builder(mut self, prompt_builder: SystemPromptBuilder) -> Self {
self.prompt_builder = Some(prompt_builder);
self
}The AgentBuilder method prompt_builder accepts a SystemPromptBuilder and stores it on the builder so callers can inject the assembled system prompt configuration that the agent core will use when composing system messages for model calls; it returns the builder itself to support the fluent Builder pattern used across AgentBuilder (the config method follows the same consume-and-return-self pattern). SystemPromptBuilder is the composable container of prompt sections (it holds a vector of boxed PromptSection implementations), so providing it here lets higher-level code assemble modular system guidance once and have the eventual Agent reuse that assembly during run loops and provider interactions. Conceptually, prompt_builder is a simple setter that takes ownership of the provided SystemPromptBuilder and enables chaining, tying the prompt composition responsibility into the AgentBuilder pipeline so the built Agent can call into that prompt builder at runtime to materialize the system portion of prompts passed to provider adapters and tools.
# file path: src/agent/agent.rs
pub fn tool_dispatcher(mut self, tool_dispatcher: Box<dyn ToolDispatcher>) -> Self {
self.tool_dispatcher = Some(tool_dispatcher);
self
}The tool_dispatcher builder method on AgentBuilder lets the caller plug a concrete ToolDispatcher implementation into the agent’s configuration so the agent core can delegate how tool calls are scheduled and executed during orchestration. Like the provider and tools builder methods you saw earlier, it stores the provided Box into the builder’s optional field and returns the builder for method chaining, following the Builder pattern used across AgentBuilder. Practically, supplying a ToolDispatcher here determines the runtime behavior of the agent’s run/interactive loops: when the agent decides a tool should run, it will consult the configured ToolDispatcher (for example, a NativeToolDispatcher concrete implementation declared elsewhere) to actually dispatch the call, handle results, and integrate outputs back into the conversation/memory flow. This method therefore wires a pluggable dispatch strategy into the agent pipeline, writing that choice into the builder so the final Agent instance has the dispatcher available during execution.
# file path: src/agent/agent.rs
pub fn memory_loader(mut self, memory_loader: Box<dyn MemoryLoader>) -> Self {
self.memory_loader = Some(memory_loader);
self
}The memory_loader method on AgentBuilder is a builder-style setter that accepts a boxed MemoryLoader implementation, places it into the builder’s optional memory_loader slot, and returns the builder so configuration calls can be chained. In the context of the agent core, this lets callers provide a component whose responsibility is to hydrate or prepopulate the configured Memory backend before or during runs — for example to impose limits, perform migrations, or seed conversational context — so that system and conversation prompt composition sees the right persistent context. It follows the same fluent Builder pattern as the memory, tools, observer, prompt_builder, and tool_dispatcher methods you already examined, but whereas memory takes a concrete Memory instance, memory_loader takes the loader abstraction that encapsulates the loading logic (see DefaultMemoryLoader as a related type that carries parameters like a limit). Because the builder stores the loader optionally, an Agent built without a memory_loader will skip that preloading step, while an Agent built with one will later invoke it during initialization or the run loop to populate the Memory used during prompt assembly and tool orchestration.
# file path: src/agent/agent.rs
pub fn config(mut self, config: crate::config::AgentConfig) -> Self {
self.config = Some(config);
self
}The config method on AgentBuilder is a simple builder-style setter that accepts an AgentConfig and stores it on the builder, returning the builder so callers can chain further configuration calls. In the context of the agent core—which composes prompts, manages history and memory, wires providers and tools, and orchestrates runs—AgentConfig carries the runtime options and policies the resulting Agent will use (timeouts, decision strategies, tool/dispatch preferences, etc.), so injecting it here lets the eventual Agent construction incorporate those runtime behaviors. The method follows the same consume-and-return-self pattern you saw on identity_config and provider: it wraps the supplied configuration in the builder’s optional field and hands back the builder for fluent composition. During the build step the Agent will read this stored AgentConfig to populate its internal fields and influence orchestration, so config participates directly in the Builder creational pattern that assembles the agent core from the pieces you configured earlier (tools, memory, observer, prompt_builder, tool_dispatcher).
# file path: src/agent/agent.rs
pub fn model_name(mut self, model_name: String) -> Self {
self.model_name = Some(model_name);
self
}Within the AgentBuilder’s fluent Builder API, model_name is a simple setter that captures the identifier of the language model the agent should use and stores it on the builder for later consumption when the Agent is constructed. It takes ownership of the provided model name string, wraps it as the optional model_name field on the builder, and then returns the builder so callers can continue chaining configuration calls. Conceptually this plugs the chosen LM into the agent core’s configuration so the orchestration/run logic and the provider adapter know which model to target for prompt completions and tool-invoking decisions. It follows the same consume-and-return-self pattern as config and prompt_builder and participates in the overall Builder pattern that aggregates pieces like tools, memory, observer, prompt_builder, and tool_dispatcher before producing a configured Agent.
# file path: src/agent/agent.rs
pub fn temperature(mut self, temperature: f64) -> Self {
self.temperature = Some(temperature);
self
}The temperature method on AgentBuilder is a fluent setter that records the numeric sampling temperature the eventual Agent should use when making model calls; it stores the provided floating‑point value inside the builder as an optional configuration and returns the builder so callers can chain more configuration calls. In the context of ZeroClaw’s agent core, that temperature value flows from AgentBuilder into the constructed Agent and is consulted by the orchestration logic and provider adapters to control randomness and sampling behavior during model interactions. It follows the same consume‑and‑return‑self Builder pattern used by methods like prompt_builder, config, and the other setters you’ve already seen (tools, memory, observer, tool_dispatcher), but unlike those methods which inject complex components or objects, temperature simply captures a primitive f64 as an optional override so the system can either use an explicit temperature or fall back to provider/default behavior when none is supplied.
# file path: src/agent/agent.rs
pub fn workspace_dir(mut self, workspace_dir: std::path::PathBuf) -> Self {
self.workspace_dir = Some(workspace_dir);
self
}The AgentBuilder workspace_dir method is a fluent setter that records a working-directory path on the builder and then returns the builder so callers can chain configuration calls. In the context of the agent core, that stored workspace path becomes part of the PromptContext used when composing system messages (recall SystemPromptBuilder’s build uses the context’s workspace directory when assembling the workspace section of the system prompt), and it also provides the runtime working directory that tools, tool dispatch, and any filesystem-related memory operations can rely on during orchestration. Functionally it mirrors other builder-style setters like config and the previously discussed tools, memory, observer, prompt_builder, and tool_dispatcher methods: it mutates the builder’s internal field to Some(path) and returns Self to support the Builder pattern used across AgentBuilder.
# file path: src/agent/agent.rs
pub fn identity_config(mut self, identity_config: crate::config::IdentityConfig) -> Self {
self.identity_config = Some(identity_config);
self
}The identity_config method on AgentBuilder is a fluent setter that accepts an IdentityConfig and stores it on the builder, returning the builder so callers can chain further configuration calls; it follows the same Builder pattern used by provider, config, tools, memory, observer, prompt_builder, and tool_dispatcher. Within the ZeroClaw agent core role, this lets the caller inject the agent’s identity-related settings into the eventual Agent instance so those values are available when the Agent composes system and conversation prompts (for example alongside the SystemPromptBuilder you already saw). In terms of data flow, a caller provides an IdentityConfig to AgentBuilder via identity_config, AgentBuilder retains it as part of its internal state, and when the Agent is built/run that stored identity information is read and incorporated into prompt composition and orchestration; control flow is a simple set-and-return pattern with no branching or transformation, mirroring the behavior of the config and provider builder methods.
# file path: src/agent/agent.rs
pub fn skills(mut self, skills: Vec<crate::skills::Skill>) -> Self {
self.skills = Some(skills);
self
}The skills method on AgentBuilder accepts a collection of Skill values and stores them on the builder so they become part of the Agent configuration when the builder is consumed. It follows the same fluent Builder pattern as tools, config, and the other setters we’ve already reviewed: it mutably takes the builder, sets the internal skills field to the provided vector (replacing the default None that AgentBuilder::new created), and returns the builder to allow method chaining. Conceptually, unlike tools which accepts boxed trait objects for runtime-polymorphic Tool implementations, skills is a concrete Vec of crate::skills::Skill instances, indicating these are higher-level, domain-specific behaviors the agent will carry into prompt composition, memory-aware orchestration, and tool dispatch; once the Agent is built the run/interactive orchestration will see and use those skills alongside the provider, memory, observer, prompt_builder, and tool_dispatcher we discussed earlier.
# file path: src/agent/agent.rs
pub fn auto_save(mut self, auto_save: bool) -> Self {
self.auto_save = Some(auto_save);
self
}The auto_save method on AgentBuilder is a fluent setter that records whether the resulting Agent should perform automatic persistence of state; it stores the caller-provided boolean into the builder’s auto_save field (wrapping it as an explicit option) and returns the builder so configuration calls can be chained. By using an Option for that field the builder can represent an unspecified default versus an explicitly enabled or disabled auto-save, and when the Agent is constructed the run and orchestration logic will consult that stored value to decide whether to trigger automatic saves of memory/history. Its behavior follows the same consume-and-return-self Builder pattern as config, identity_config, prompt_builder and the other builder setters you already saw, and it only mutates the builder state rather than performing any I/O or side effects at the time it is called.
# file path: src/agent/agent.rs
pub fn build(self) -> Result<Agent> {
let tools = self
.tools
.ok_or_else(|| anyhow::anyhow!(”tools are required”))?;
let tool_specs = tools.iter().map(|tool| tool.spec()).collect();
Ok(Agent {
provider: self
.provider
.ok_or_else(|| anyhow::anyhow!(”provider is required”))?,
tools,
tool_specs,
memory: self
.memory
.ok_or_else(|| anyhow::anyhow!(”memory is required”))?,
observer: self
.observer
.ok_or_else(|| anyhow::anyhow!(”observer is required”))?,
prompt_builder: self
.prompt_builder
.unwrap_or_else(SystemPromptBuilder::with_defaults),
tool_dispatcher: self
.tool_dispatcher
.ok_or_else(|| anyhow::anyhow!(”tool_dispatcher is required”))?,
memory_loader: self
.memory_loader
.unwrap_or_else(|| Box::new(DefaultMemoryLoader::default())),
config: self.config.unwrap_or_default(),
model_name: self
.model_name
.unwrap_or_else(|| “anthropic/claude-sonnet-4-20250514”.into()),
temperature: self.temperature.unwrap_or(0.7),
workspace_dir: self
.workspace_dir
.unwrap_or_else(|| std::path::PathBuf::from(”.”)),
identity_config: self.identity_config.unwrap_or_default(),
skills: self.skills.unwrap_or_default(),
auto_save: self.auto_save.unwrap_or(false),
history: Vec::new(),
})
}The build method on AgentBuilder finalizes the builder-style configuration into a concrete Agent by validating required dependencies, producing derived metadata, and filling sensible defaults so the agent is ready to orchestrate runs. It first pulls the collection of tools that you previously configured with the tools method and fails early if they are missing; it then derives tool metadata by asking each tool for its spec so the agent can advertise and reason about available tools during orchestration. It ensures that the essential runtime pieces supplied via the builder—provider, memory, observer, and tool_dispatcher—are present and returns an error if any are absent; for the pieces that are optional you already saw being settable (prompt_builder and memory_loader), build substitutes canonical defaults when they were not provided (using the SystemPromptBuilder defaults and a boxed DefaultMemoryLoader). It also materializes agent-level settings, using default values for config, model name (a project-default model), temperature, workspace directory, identity configuration, skills list, and the auto-save flag when callers didn’t supply them, and it initializes an empty history vector. The overall flow enforces required invariants up front, transforms configured tool implementations into lightweight specs for runtime use, and produces a fully-initialized Agent instance that the rest of the agent core can immediately use to compose prompts, consult memory, call the provider, and dispatch tools. This complements the new and builder constructors you saw earlier: those populate Option fields, and build consumes them to perform validation and defaulting as the final step of the Builder pattern.
# file path: src/agent/agent.rs
impl Agent {
pub fn builder() -> AgentBuilder {
AgentBuilder::new()
}
pub fn history(&self) -> &[ConversationMessage] {
&self.history
}
pub fn clear_history(&mut self) {
self.history.clear();
}
pub fn from_config(config: &Config) -> Result<Self> {
let observer: Arc<dyn Observer> =
Arc::from(observability::create_observer(&config.observability));
let runtime: Arc<dyn runtime::RuntimeAdapter> =
Arc::from(runtime::create_runtime(&config.runtime)?);
let security = Arc::new(SecurityPolicy::from_config(
&config.autonomy,
&config.workspace_dir,
));
let memory: Arc<dyn Memory> = Arc::from(memory::create_memory(
&config.memory,
&config.workspace_dir,
config.api_key.as_deref(),
)?);
let composio_key = if config.composio.enabled {
config.composio.api_key.as_deref()
} else {
None
};
let composio_entity_id = if config.composio.enabled {
Some(config.composio.entity_id.as_str())
} else {
None
};
let tools = tools::all_tools_with_runtime(
&security,
runtime,
memory.clone(),
composio_key,
composio_entity_id,
&config.browser,
&config.http_request,
&config.workspace_dir,
&config.agents,
config.api_key.as_deref(),
config,
);
let provider_name = config.default_provider.as_deref().unwrap_or(”openrouter”);
let model_name = config
.default_model
.as_deref()
.unwrap_or(”anthropic/claude-sonnet-4-20250514”)
.to_string();
let provider: Box<dyn Provider> = providers::create_routed_provider(
provider_name,
config.api_key.as_deref(),
&config.reliability,
&config.model_routes,
&model_name,
)?;
let dispatcher_choice = config.agent.tool_dispatcher.as_str();
let tool_dispatcher: Box<dyn ToolDispatcher> = match dispatcher_choice {
“native” => Box::new(NativeToolDispatcher),
“xml” => Box::new(XmlToolDispatcher),
_ if provider.supports_native_tools() => Box::new(NativeToolDispatcher),
_ => Box::new(XmlToolDispatcher),
};
Agent::builder()
.provider(provider)
.tools(tools)
.memory(memory)
.observer(observer)
.tool_dispatcher(tool_dispatcher)
.memory_loader(Box::new(DefaultMemoryLoader::default()))
.prompt_builder(SystemPromptBuilder::with_defaults())
.config(config.agent.clone())
.model_name(model_name)
.temperature(config.default_temperature)
.workspace_dir(config.workspace_dir.clone())
.identity_config(config.identity.clone())
.skills(crate::skills::load_skills(&config.workspace_dir))
.auto_save(config.memory.auto_save)
.build()
}
fn trim_history(&mut self) {
let max = self.config.max_history_messages;
if self.history.len() <= max {
return;
}
let mut system_messages = Vec::new();
let mut other_messages = Vec::new();
for msg in self.history.drain(..) {
match &msg {
ConversationMessage::Chat(chat) if chat.role == “system” => {
system_messages.push(msg);
}
_ => other_messages.push(msg),
}
}
if other_messages.len() > max {
let drop_count = other_messages.len() - max;
other_messages.drain(0..drop_count);
}
self.history = system_messages;
self.history.extend(other_messages);
}
fn build_system_prompt(&self) -> Result<String> {
let instructions = self.tool_dispatcher.prompt_instructions(&self.tools);
let ctx = PromptContext {
workspace_dir: &self.workspace_dir,
model_name: &self.model_name,
tools: &self.tools,
skills: &self.skills,
identity_config: Some(&self.identity_config),
dispatcher_instructions: &instructions,
};
self.prompt_builder.build(&ctx)
}
async fn execute_tool_call(&self, call: &ParsedToolCall) -> ToolExecutionResult {
let start = Instant::now();
let result = if let Some(tool) = self.tools.iter().find(|t| t.name() == call.name) {
match tool.execute(call.arguments.clone()).await {
Ok(r) => {
self.observer.record_event(&ObserverEvent::ToolCall {
tool: call.name.clone(),
duration: start.elapsed(),
success: r.success,
});
if r.success {
r.output
} else {
format!(”Error: {}”, r.error.unwrap_or(r.output))
}
}
Err(e) => {
self.observer.record_event(&ObserverEvent::ToolCall {
tool: call.name.clone(),
duration: start.elapsed(),
success: false,
});
format!(”Error executing {}: {e}”, call.name)
}
}
} else {
format!(”Unknown tool: {}”, call.name)
};
ToolExecutionResult {
name: call.name.clone(),
output: result,
success: true,
tool_call_id: call.tool_call_id.clone(),
}
}
async fn execute_tools(&self, calls: &[ParsedToolCall]) -> Vec<ToolExecutionResult> {
if !self.config.parallel_tools {
let mut results = Vec::with_capacity(calls.len());
for call in calls {
results.push(self.execute_tool_call(call).await);
}
return results;
}
let mut results = Vec::with_capacity(calls.len());
for call in calls {
results.push(self.execute_tool_call(call).await);
}
results
}
pub async fn turn(&mut self, user_message: &str) -> Result<String> {
if self.history.is_empty() {
let system_prompt = self.build_system_prompt()?;
self.history
.push(ConversationMessage::Chat(ChatMessage::system(
system_prompt,
)));
}
if self.auto_save {
let _ = self
.memory
.store(”user_msg”, user_message, MemoryCategory::Conversation)
.await;
}
let context = self
.memory_loader
.load_context(self.memory.as_ref(), user_message)
.await
.unwrap_or_default();
let enriched = if context.is_empty() {
user_message.to_string()
} else {
format!(”{context}{user_message}”)
};
self.history
.push(ConversationMessage::Chat(ChatMessage::user(enriched)));
for _ in 0..self.config.max_tool_iterations {
let messages = self.tool_dispatcher.to_provider_messages(&self.history);
let response = match self
.provider
.chat(
ChatRequest {
messages: &messages,
tools: if self.tool_dispatcher.should_send_tool_specs() {
Some(&self.tool_specs)
} else {
None
},
},
&self.model_name,
self.temperature,
)
.await
{
Ok(resp) => resp,
Err(err) => return Err(err),
};
let (text, calls) = self.tool_dispatcher.parse_response(&response);
if calls.is_empty() {
let final_text = if text.is_empty() {
response.text.unwrap_or_default()
} else {
text
};The Agent implementation provides the glue that turns configured pieces of ZeroClaw into a running conversational agent: it exposes a builder accessor and small history helpers, then implements construction from a Config, history management, system prompt composition, tool execution, and the core turn loop that drives model interactions and tool dispatch. The builder accessor hands callers an AgentBuilder to continue the fluent configuration you already know from the tools, memory, observer, prompt_builder, and tool_dispatcher builder methods; from_config uses that same Builder pattern to assemble concrete runtime pieces from a Config: it creates an Observer via the observability factory, a RuntimeAdapter, a SecurityPolicy from autonomy/workspace settings, and a Memory implementation from the memory factory, and it derives composio credentials when enabled. It gathers available tools by calling the runtime-aware tool factory, resolves a routed Provider with a default provider and model fallback, selects a ToolDispatcher implementation based on configuration and provider capabilities, and then feeds all of those into the AgentBuilder along with defaults for memory_loader and the system prompt builder, model/temperature/workspace/identity, loaded skills, and auto_save setting to produce a ready Agent. trim_history enforces the configured max_history_messages by separating out and preserving all system messages, collecting non-system messages, dropping the oldest non-system messages when necessary, and then rebuilding history as the preserved system messages followed by the remaining recent conversation. build
# file path: src/agent/agent.rs
pub fn builder() -> AgentBuilder {
AgentBuilder::new()
}Agent::builder is a tiny convenience entry point that hands you an AgentBuilder so you can start composing an Agent via the Builder pattern used throughout ZeroClaw. When you call Agent::builder it simply delegates to AgentBuilder::new to produce a fresh builder whose internal fields are initialized to empty/None, and from there you chain the fluent setters you already saw such as identity_config, skills, auto_save, config and the other provider/tool/memory setters. The intended data flow is straightforward: caller obtains an AgentBuilder from Agent::builder, configures the pieces of the agent with the fluent API, and then calls build to validate required dependencies and materialize a concrete Agent that can compose system prompts, manage history and memory, integrate providers and tools, and run the conversation loop. There is no branching or side-effect in the method itself — it exists purely for discoverability and ergonomic symmetry so consumers of the agent core have a single, consistent way to begin assembling an Agent for the zeroclaw-main_cleaned runtime.
# file path: src/agent/agent.rs
pub fn history(&self) -> &[ConversationMessage] {
&self.history
}history provides a simple, read-only view into the Agent’s stored conversation messages by returning an immutable slice of ConversationMessage tied to the Agent instance. In the agent core this is the cheap, borrow-based accessor that other pieces — prompt composition, the run loop, memory lookups, or external inspectors — use to examine the current thread of messages without taking ownership or allowing mutation. Because it returns a slice borrowing from self, callers avoid cloning the history and cannot accidentally alter Agent state; any mutations (for example clearing or trimming the stored messages) must go through the mutating helpers like clear_history or trim_history, the latter of which enforces the configured max-history policy (default_agent_max_history_messages supplies the baseline size used by that trimming logic).
# file path: src/agent/agent.rs
pub fn clear_history(&mut self) {
self.history.clear();
}The clear_history method on Agent is a simple mutator that wipes the agent’s in-memory conversation history by removing every ConversationMessage currently stored in the history vector. In the context of the agent core, that means subsequent prompt composition and run loops will no longer see any prior system or user/assistant messages when building model inputs or deciding tool dispatches; it effectively resets the conversation context the agent operates on. It behaves as the straightforward counterpart to the history accessor, so after calling clear_history the history method will return an empty slice. Compared with the trim_history helpers, which selectively prune older non-system messages to enforce configured limits while preserving system messages, clear_history performs an unconditional full clear that removes even system messages. There is no branching or side logic in clear_history — it directly clears the internal storage and lets the rest of the agent orchestration proceed with an empty conversation history.
# file path: src/agent/agent.rs
pub fn from_config(config: &Config) -> Result<Self> {
let observer: Arc<dyn Observer> =
Arc::from(observability::create_observer(&config.observability));
let runtime: Arc<dyn runtime::RuntimeAdapter> =
Arc::from(runtime::create_runtime(&config.runtime)?);
let security = Arc::new(SecurityPolicy::from_config(
&config.autonomy,
&config.workspace_dir,
));
let memory: Arc<dyn Memory> = Arc::from(memory::create_memory(
&config.memory,
&config.workspace_dir,
config.api_key.as_deref(),
)?);
let composio_key = if config.composio.enabled {
config.composio.api_key.as_deref()
} else {
None
};
let composio_entity_id = if config.composio.enabled {
Some(config.composio.entity_id.as_str())
} else {
None
};
let tools = tools::all_tools_with_runtime(
&security,
runtime,
memory.clone(),
composio_key,
composio_entity_id,
&config.browser,
&config.http_request,
&config.workspace_dir,
&config.agents,
config.api_key.as_deref(),
config,
);
let provider_name = config.default_provider.as_deref().unwrap_or(”openrouter”);
let model_name = config
.default_model
.as_deref()
.unwrap_or(”anthropic/claude-sonnet-4-20250514”)
.to_string();
let provider: Box<dyn Provider> = providers::create_routed_provider(
provider_name,
config.api_key.as_deref(),
&config.reliability,
&config.model_routes,
&model_name,
)?;
let dispatcher_choice = config.agent.tool_dispatcher.as_str();
let tool_dispatcher: Box<dyn ToolDispatcher> = match dispatcher_choice {
“native” => Box::new(NativeToolDispatcher),
“xml” => Box::new(XmlToolDispatcher),
_ if provider.supports_native_tools() => Box::new(NativeToolDispatcher),
_ => Box::new(XmlToolDispatcher),
};
Agent::builder()
.provider(provider)
.tools(tools)
.memory(memory)
.observer(observer)
.tool_dispatcher(tool_dispatcher)
.memory_loader(Box::new(DefaultMemoryLoader::default()))
.prompt_builder(SystemPromptBuilder::with_defaults())
.config(config.agent.clone())
.model_name(model_name)
.temperature(config.default_temperature)
.workspace_dir(config.workspace_dir.clone())
.identity_config(config.identity.clone())
.skills(crate::skills::load_skills(&config.workspace_dir))
.auto_save(config.memory.auto_save)
.build()
}from_config translates a Config into a fully wired Agent by instantiating all of the runtime pieces the agent core needs and then delegating to the Agent builder to validate and assemble them. It starts by creating an Observer via observability::create_observer and a RuntimeAdapter via runtime::create_runtime, propagating errors if runtime construction fails, then synthesizes a SecurityPolicy from the autonomy and workspace settings so the agent has a centralized policy object. It constructs the Memory backend with memory::create_memory (again returning early on error), and extracts composio API details only when the composio feature is marked enabled so downstream tool construction receives either concrete credentials or None. With security, runtime, memory, composio info, browser/http/request configuration, workspace and agent metadata, and the optional API key, it asks tools::all_tools_with_runtime to produce the complete set of Tool adapters bound to the runtime and memory. Provider selection is resolved next: the code picks a provider name from the config with a safe default, chooses a model name with a fallback to the configured default model, and creates a routed Provider via providers::create_routed_provider, returning an error if provider wiring fails. Tool-dispatcher selection follows a small decision tree driven by the agent.tool_dispatcher config value and the provider’s capabilities: it prefers the explicitly requested native or xml dispatcher, otherwise it prefers native if the provider advertises native tool support and falls back to xml. Finally, from_config drives the Builder pattern by calling Agent::builder and invoking the fluent setters to attach the provider, tools, memory, observer, selected tool_dispatcher, a default memory_loader, a default SystemPromptBuilder, the agent-specific AgentConfig, model name, temperature, workspace_dir, identity configuration (using the identity_config setter you already saw), loaded skills from the workspace (using the skills-loading helper you know), and the auto_save flag; after setting these, it calls build to produce the concrete Agent instance. The control flow thus enforces early failure on essential subsystem construction, conditional wiring for optional features like composio, and a final builder-based validation step that mirrors and complements the build implementation you reviewed earlier.
# file path: src/agent/agent.rs
fn trim_history(&mut self) {
let max = self.config.max_history_messages;
if self.history.len() <= max {
return;
}
let mut system_messages = Vec::new();
let mut other_messages = Vec::new();
for msg in self.history.drain(..) {
match &msg {
ConversationMessage::Chat(chat) if chat.role == “system” => {
system_messages.push(msg);
}
_ => other_messages.push(msg),
}
}
if other_messages.len() > max {
let drop_count = other_messages.len() - max;
other_messages.drain(0..drop_count);
}
self.history = system_messages;
self.history.extend(other_messages);
}trim_history is the Agent method that enforces the configured message budget so the agent’s conversation context never grows unbounded. It reads the agent’s configured max_history_messages and returns immediately when the stored history already fits that limit. Otherwise it empties the existing history and partitions messages into two buckets: system-level ConversationMessage::Chat entries whose role equals “system”, and all other messages. After partitioning it trims the non-system bucket by dropping the oldest entries until only the most recent max_history_messages remain, then reconstructs the agent’s history by placing all preserved system messages first and appending the retained non-system messages. Functionally this keeps system prompts intact (they carry the agent’s identity and global instructions) while ensuring only the most recent conversational turns are retained for model calls; it differs from the simpler helper that only expects a single leading system message by collecting and preserving any number of system messages and by rebuilding history via a drain-and-reappend flow rather than selectively draining a contiguous range.
# file path: src/agent/agent.rs
fn build_system_prompt(&self) -> Result<String> {
let instructions = self.tool_dispatcher.prompt_instructions(&self.tools);
let ctx = PromptContext {
workspace_dir: &self.workspace_dir,
model_name: &self.model_name,
tools: &self.tools,
skills: &self.skills,
identity_config: Some(&self.identity_config),
dispatcher_instructions: &instructions,
};
self.prompt_builder.build(&ctx)
}build_system_prompt’s job in the Agent core is to turn the agent’s runtime configuration into the textual system prompt the model will see. It does that by first asking the ToolDispatcher for its human-facing instructions derived from the configured tools, then assembling a PromptContext that bundles the workspace directory, the active model name, the agent’s Tool list, the Skill set (you already saw how skills are stored via the AgentBuilder), and the optional IdentityConfig (which the AgentBuilder previously captured). With the dispatcher instructions included in that context, build_system_prompt delegates the actual rendering to the PromptBuilder by calling its build method and returns whatever string or error the builder produces. Conceptually this mirrors the standalone build_system_prompt and PromptContext patterns elsewhere in the codebase: collect concrete environment and metadata, include tool usage guidance from the dispatcher, and centralize the text composition in PromptBuilder so the system prompt construction is consistent and testable.
# file path: src/agent/agent.rs
async fn execute_tool_call(&self, call: &ParsedToolCall) -> ToolExecutionResult {
let start = Instant::now();
let result = if let Some(tool) = self.tools.iter().find(|t| t.name() == call.name) {
match tool.execute(call.arguments.clone()).await {
Ok(r) => {
self.observer.record_event(&ObserverEvent::ToolCall {
tool: call.name.clone(),
duration: start.elapsed(),
success: r.success,
});
if r.success {
r.output
} else {
format!(”Error: {}”, r.error.unwrap_or(r.output))
}
}
Err(e) => {
self.observer.record_event(&ObserverEvent::ToolCall {
tool: call.name.clone(),
duration: start.elapsed(),
success: false,
});
format!(”Error executing {}: {e}”, call.name)
}
}
} else {
format!(”Unknown tool: {}”, call.name)
};
ToolExecutionResult {
name: call.name.clone(),
output: result,
success: true,
tool_call_id: call.tool_call_id.clone(),
}
}execute_tool_call is the Agent’s single-tool dispatch path that takes a ParsedToolCall, finds the matching Tool from the agent’s tool registry, invokes that tool asynchronously, records telemetry about the call, and returns a ToolExecutionResult that callers can fold back into the conversation pipeline. The method starts a timer to measure latency, looks up the tool by name on self.tools, and if a matching Tool is found it awaits the Tool’s execute result. On a successful execute return it records an ObserverEvent::ToolCall with the observed duration and the tool-reported success flag; if the tool reported success the method uses the tool’s output as the execution payload, otherwise it converts the tool-reported failure (preferring an explicit error field when present) into a formatted error string. If the tool.execute future itself errors, the method records a failed ObserverEvent::ToolCall and turns the execution error into a simple error string. If no tool matches the call name the method produces an “Unknown tool” style message. The returned ToolExecutionResult carries the original call name, the resolved output string, and the tool_call_id forwarded from the ParsedToolCall; note that the result object always has its success field set to true while the observer events encode the actual success/failure observed at runtime. In the broader Agent orchestration described in Class_L188_part1, execute_tool_call is the per-call primitive that execute_tools delegates to when running multiple calls, and it provides the concrete I/O and telemetry behavior that the run_tool_call_loop and higher-level turn logic rely on to incorporate tool outputs back into the conversation.
# file path: src/agent/agent.rs
async fn execute_tools(&self, calls: &[ParsedToolCall]) -> Vec<ToolExecutionResult> {
if !self.config.parallel_tools {
let mut results = Vec::with_capacity(calls.len());
for call in calls {
results.push(self.execute_tool_call(call).await);
}
return results;
}
let mut results = Vec::with_capacity(calls.len());
for call in calls {
results.push(self.execute_tool_call(call).await);
}
results
}execute_tools is the agent-level dispatcher that receives a list of ParsedToolCall items produced earlier in the turn loop and turns them into a sequence of ToolExecutionResult values for the agent to fold back into history and subsequent model prompts. It consults the agent configuration flag parallel_tools and, in the non-parallel path, allocates a results vector, iterates each ParsedToolCall, awaits a call to execute_tool_call for that single call, pushes the returned ToolExecutionResult into the vector, and returns it immediately when done. In the parallel-configured path it follows the same allocation-and-iterate pattern: it still awaits execute_tool_call for each call and collects the results into the vector before returning. execute_tools therefore delegates the per-call work to execute_tool_call, which is responsible for invoking the matching Tool, recording observer events and timing, and formatting the tool output or error; the vector returned by execute_tools is what the run loop uses to update assistant history and drive any further model interactions in the Agent orchestration for zeroclaw-main_cleaned.
# file path: src/agent/agent.rs
pub async fn turn(&mut self, user_message: &str) -> Result<String> {
if self.history.is_empty() {
let system_prompt = self.build_system_prompt()?;
self.history
.push(ConversationMessage::Chat(ChatMessage::system(
system_prompt,
)));
}
if self.auto_save {
let _ = self
.memory
.store(”user_msg”, user_message, MemoryCategory::Conversation)
.await;
}
let context = self
.memory_loader
.load_context(self.memory.as_ref(), user_message)
.await
.unwrap_or_default();
let enriched = if context.is_empty() {
user_message.to_string()
} else {
format!(”{context}{user_message}”)
};
self.history
.push(ConversationMessage::Chat(ChatMessage::user(enriched)));
for _ in 0..self.config.max_tool_iterations {
let messages = self.tool_dispatcher.to_provider_messages(&self.history);
let response = match self
.provider
.chat(
ChatRequest {
messages: &messages,
tools: if self.tool_dispatcher.should_send_tool_specs() {
Some(&self.tool_specs)
} else {
None
},
},
&self.model_name,
self.temperature,
)
.await
{
Ok(resp) => resp,
Err(err) => return Err(err),
};
let (text, calls) = self.tool_dispatcher.parse_response(&response);
if calls.is_empty() {
let final_text = if text.is_empty() {
response.text.unwrap_or_default()
} else {
text
};
self.history
.push(ConversationMessage::Chat(ChatMessage::assistant(
final_text.clone(),
)));
self.trim_history();
if self.auto_save {
let summary = truncate_with_ellipsis(&final_text, 100);
let _ = self
.memory
.store(”assistant_resp”, &summary, MemoryCategory::Daily)
.await;
}
return Ok(final_text);
}
if !text.is_empty() {
self.history
.push(ConversationMessage::Chat(ChatMessage::assistant(
text.clone(),
)));
print!(”{text}”);
let _ = std::io::stdout().flush();
}
self.history.push(ConversationMessage::AssistantToolCalls {
text: response.text.clone(),
tool_calls: response.tool_calls.clone(),
});
let results = self.execute_tools(&calls).await;
let formatted = self.tool_dispatcher.format_results(&results);
self.history.push(formatted);
self.trim_history();
}
anyhow::bail!(
“Agent exceeded maximum tool iterations ({})”,
self.config.max_tool_iterations
)
}Agent.turn implements one conversational run: it ensures the agent has a system prompt at the start of a session, enriches the incoming user message with memory context, appends that to the running history, and then enters an iterative loop that alternates model calls and tool execution until the assistant emits a final textual reply or the tool-iteration limit is reached. When history is empty it calls build_system_prompt and pushes the resulting system ChatMessage into history so the provider has the agent’s system-level instructions. If the builder-set auto_save flag is enabled (see the auto_save AgentBuilder method you already reviewed) it persists the raw user message to memory under MemoryCategory::Conversation before doing any reasoning. It then asks memory_loader to load contextual material relevant to the new user message and prepends that context to the user text when present, then pushes the enriched user ChatMessage into history. The core loop runs up to config.max_tool_iterations: it first asks tool_dispatcher to translate the current history into provider-facing messages and then calls the configured provider adapter via provider.chat, optionally including tool specifications when tool_dispatcher signals they should be sent. If provider.chat fails the error is propagated immediately. The provider response is handed back to tool_dispatcher.parse_response, which separates plain assistant text from any requested tool calls. If no tool calls are present the function treats that as the happy path: it chooses either the parsed text or the provider’s raw text as the assistant response, appends an assistant ChatMessage to history, trims history for context management, and—if auto_save is enabled—stores a truncated summary of the assistant response under MemoryCategory::Daily, then returns the final text to the caller. If tool calls are present the function records any interim assistant text to history and to stdout (so streaming or partial messages are visible), records the provider response plus tool call metadata as an AssistantToolCalls history entry, invokes execute_tools to run the requested tools, asks tool_dispatcher to format the tool results back into a conversation message, appends that formatted result to history, trims history, and then loops to send the updated history back to the model for the next reasoning step. If the loop exceeds the configured max_tool_iterations without producing a plain-text answer, turn returns an error indicating the iteration limit was exceeded. In the architecture, turn is where the agent core composes prompts (build_system_prompt), integrates persisted and retrieved memory (memory and memory_loader), mediates with the provider adapter (provider.chat), and coordinates the tool/runtime layer via tool_dispatcher and execute_tools to implement the model→tool→model reasoning loop; run_single is just a thin wrapper that delegates to turn, and agent_turn is a related helper that channels similar loop behavior in other parts of the codebase.
# file path: src/agent/agent.rs
self.history
.push(ConversationMessage::Chat(ChatMessage::assistant(
final_text.clone(),
)));
self.trim_history();
if self.auto_save {
let summary = truncate_with_ellipsis(&final_text, 100);
let _ = self
.memory
.store(”assistant_resp”, &summary, MemoryCategory::Daily)
.await;
}
return Ok(final_text);
}
if !text.is_empty() {
self.history
.push(ConversationMessage::Chat(ChatMessage::assistant(
text.clone(),
)));
print!(”{text}”);
let _ = std::io::stdout().flush();
}
self.history.push(ConversationMessage::AssistantToolCalls {
text: response.text.clone(),
tool_calls: response.tool_calls.clone(),
});
let results = self.execute_tools(&calls).await;
let formatted = self.tool_dispatcher.format_results(&results);
self.history.push(formatted);
self.trim_history();
}
anyhow::bail!(
“Agent exceeded maximum tool iterations ({})”,
self.config.max_tool_iterations
)
}
pub async fn run_single(&mut self, message: &str) -> Result<String> {
self.turn(message).await
}
pub async fn run_interactive(&mut self) -> Result<()> {
println!(”🦀 ZeroClaw Interactive Mode”);
println!(”Type /quit to exit.\n”);
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
let cli = crate::channels::CliChannel::new();
let listen_handle = tokio::spawn(async move {
let _ = crate::channels::Channel::listen(&cli, tx).await;
});
while let Some(msg) = rx.recv().await {
let response = match self.turn(&msg.content).await {
Ok(resp) => resp,
Err(e) => {
eprintln!(”\nError: {e}\n”);
continue;
}
};
println!(”\n{response}\n”);
}
listen_handle.abort();
Ok(())
}
}The excerpt continues the Agent’s turn orchestration begun earlier in Class_L188_part1, implementing the decision points after the provider returns a response and the interactive entry points. When the provider reply contains no tool calls the happy path constructs the assistant’s final message: it appends an assistant ChatMessage to history, trims history to keep context bounded, and if auto_save is enabled it produces a short summary via truncate_with_ellipsis and stores that summary into memory under the assistant_resp key with the Daily MemoryCategory; finally it returns the assembled final text to the caller. If the provider did request tools, the code handles the alternate path: any non‑empty textual content from the model is appended as an assistant ChatMessage and streamed to stdout (with an explicit flush so the CLI sees incremental output), then the reply is recorded as an AssistantToolCalls conversation entry that contains both the raw response text and the parsed tool calls. The tool calls are executed by invoking execute_tools and the execution results are handed to the tool_dispatcher to be formatted; the formatted result is appended to history and history is trimmed so the next iteration sees the new tool outputs in context. The turn loop repeats up to config.max_tool_iterations and bails with an error if tool calls never converge to a terminal assistant reply. run_single is a thin wrapper that delegates a single user message to turn and returns the result. run_interactive wires the CLI channel into the Agent: it prints a short prompt, creates a Tokio mpsc channel and a CliChannel, spawns a background task to listen for CLI input and forward messages into the mpsc sender, and then processes incoming messages by calling turn for each message, printing the returned assistant text or logging errors before continuing; when the input stream ends the listener task is aborted and run_interactive returns. Overall, this code implements the iterative ReAct‑style control flow that moves data from provider responses into tool executions and back into the conversation history so the Agent can compose multi‑step behaviors while keeping memory persistence and an interactive CLI loop.
# file path: src/agent/agent.rs
pub async fn run_single(&mut self, message: &str) -> Result<String> {
self.turn(message).await
}run_single is a small asynchronous public entry point on Agent that simply delegates a single user message into the agent’s conversational machinery by calling turn and returning whatever turn produces (either the assistant’s final text or an error). Because turn handles system prompt injection, memory enrichment, history mutation, provider calls, and the tool-call loop, run_single serves as the concise single‑shot API that reuses all of that logic without duplicating it. It takes a mutable Agent (so it advances the agent’s history and state), is async to accommodate provider and tool I/O, and returns a Result so callers can surface model or tool errors. The top-level run function uses run_single when a one-off message is supplied, making run_single the natural bridge between external callers (channels or CLI) and the Agent.turn orchestration.
# file path: src/agent/agent.rs
pub async fn run_interactive(&mut self) -> Result<()> {
println!(”🦀 ZeroClaw Interactive Mode”);
println!(”Type /quit to exit.\n”);
let (tx, mut rx) = tokio::sync::mpsc::channel(32);
let cli = crate::channels::CliChannel::new();
let listen_handle = tokio::spawn(async move {
let _ = crate::channels::Channel::listen(&cli, tx).await;
});
while let Some(msg) = rx.recv().await {
let response = match self.turn(&msg.content).await {
Ok(resp) => resp,
Err(e) => {
eprintln!(”\nError: {e}\n”);
continue;
}
};
println!(”\n{response}\n”);
}
listen_handle.abort();
Ok(())
}Agent.run_interactive provides a simple REPL entry point so an operator can talk to the Agent live: it prints a short banner and quit hint, constructs a CLI channel via CliChannel, and wires that channel into an asynchronous producer/consumer pipeline using a tokio mpsc channel. The CLI listener is launched as a background task by calling Channel::listen on the CliChannel and handing it the mpsc sender; Channel::listen is responsible for reading user lines, assembling ChannelMessage values, and sending them down the sender until the user types the configured exit command. The run loop awaits messages from the mpsc receiver, and for each incoming ChannelMessage it forwards the message content into Agent.turn (which you already studied and which performs system prompt setup, memory enrichment, iterative model/tool orchestration, and returns the final textual reply). If turn returns an error, run_interactive prints the error and continues listening; if it returns a reply string, run_interactive prints that reply to stdout. When the listener stops (for example because the user typed the quit command), the receiver yields no more messages, the loop exits, the spawned listener task is aborted to clean up, and run_interactive returns success. This function therefore ties the channel layer into the agent core: it consumes ChannelMessage events, delegates conversational work to turn, and surfaces model/tool-driven responses back to the operator, analogous to the single-turn wrapper run_single but implemented as an ongoing interactive loop that relies on Channel::listen and the tokio task/mpsc pattern.
# file path: src/agent/agent.rs
pub async fn run(
config: Config,
message: Option<String>,
provider_override: Option<String>,
model_override: Option<String>,
temperature: f64,
) -> Result<()> {
let start = Instant::now();
let mut effective_config = config;
if let Some(p) = provider_override {
effective_config.default_provider = Some(p);
}
if let Some(m) = model_override {
effective_config.default_model = Some(m);
}
effective_config.default_temperature = temperature;
let mut agent = Agent::from_config(&effective_config)?;
let provider_name = effective_config
.default_provider
.as_deref()
.unwrap_or(”openrouter”)
.to_string();
let model_name = effective_config
.default_model
.as_deref()
.unwrap_or(”anthropic/claude-sonnet-4-20250514”)
.to_string();
agent.observer.record_event(&ObserverEvent::AgentStart {
provider: provider_name,
model: model_name,
});
if let Some(msg) = message {
let response = agent.run_single(&msg).await?;
println!(”{response}”);
} else {
agent.run_interactive().await?;
}
agent.observer.record_event(&ObserverEvent::AgentEnd {
duration: start.elapsed(),
tokens_used: None,
});
Ok(())
}run acts as the top-level entry that turns a CLI-style invocation into an Agent-driven conversation within the ZeroClaw agent core: it starts a timer for telemetry, applies any provider, model, and temperature overrides to produce an effective Config, and constructs an Agent instance from that effective Config. It then resolves the provider_name and model_name from the effective_config with sensible fallbacks and emits an ObserverEvent AgentStart using the Agent’s observer so downstream telemetry knows which provider/model pair the run will use. Control then branches on whether a single message was supplied: when a message is present it delegates to run_single (which you already saw delegates to turn) and prints the resulting textual reply; when no message is provided it enters the interactive REPL via run_interactive. After the turn(s) complete it records an ObserverEvent AgentEnd that includes the elapsed duration (tokens_used is left unset here) and returns success. This run function is a lean orchestration wrapper compared to the longer run variants elsewhere that explicitly wire together runtime, security, memory, and peripheral tool initialization; here those responsibilities are encapsulated inside Agent::from_config and the Agent instance used for turn and interactive flows.
# file path: src/agent/agent.rs
#[cfg(test)]
mod tests {
use super::*;
use async_trait::async_trait;
use std::sync::Mutex;The code opens a test-only module that will only be compiled for test builds, and it pulls the parent module’s public and private items into scope so the tests can directly construct and exercise Agent, AgentBuilder, turn, execute_tool_call, execute_tools, and the surrounding orchestration logic (including the decision paths implemented in Class_L188_part2 and the system prompt construction done by build_system_prompt). It imports async_trait so the tests can define async trait implementations used to mock provider adapters or tool implementations, and it imports Mutex to allow simple, thread- and task-safe shared state for those mocks (for example to record calls, control stubbed responses, or assert ordering). Compared with other import blocks elsewhere that bring in serialization, collections, or memory types, this set is minimal and tailored to creating asynchronous test doubles and synchronized test state; because the module is gated by cfg(test), none of these test-only helpers affect the runtime binary.
# file path: src/agent/agent.rs
struct MockProvider {
responses: Mutex<Vec<crate::providers::ChatResponse>>,
}MockProvider is a simple test-double provider type that holds a queue of ChatResponse values inside a Mutex-wrapped Vec from crate::providers. Within the Agent core it functions as a fake provider adapter: instead of contacting an external model, the agent run/interactive paths take a pre-seeded ChatResponse out of MockProvider and treat it as if it came from a real provider. The Mutex gives thread-safe, async-friendly access so multiple agent runs or orchestration tasks can consume responses without races. This is complementary to the ChatResponse and ApiChatResponse types elsewhere, which represent the response payload itself; MockProvider is a container and retrieval mechanism for those payloads rather than a payload structure. In the conversation flow, Agent.turn and the decision logic in Class_L188_part2 receive these queued ChatResponse objects from MockProvider at the same integration point real provider adapters would, enabling deterministic testing and offline execution of the agent orchestration.
# file path: src/agent/agent.rs
#[async_trait]
impl Provider for MockProvider {
async fn chat_with_system(
&self,
_system_prompt: Option<&str>,
_message: &str,
_model: &str,
_temperature: f64,
) -> Result<String> {
Ok(”ok”.into())
}The MockProvider implements the Provider trait’s chat_with_system method as a trivial, asynchronous stub that ignores its inputs (system_prompt, message, model, temperature) and immediately returns a fixed, successful string response. Within ZeroClaw’s architecture this lives in the provider-adapter layer and is used to satisfy the Agent’s dependency on a Provider without performing network calls, authentication, request/response assembly, or error handling; unlike OpenAiCompatibleProvider or the router implementation that resolve models, build message arrays, call remote APIs, and handle fallbacks and HTTP errors, MockProvider provides a deterministic, constant reply useful for tests, local runs, or development where an actual LLM is not desirable. Because Agent.turn invokes Provider.chat_with_system to obtain model output, wiring in MockProvider causes the agent’s run loop and the decision logic in Class_L188_part2 to receive that fixed response and proceed as if the model returned plain text, so subsequent parsing into tool calls or final assistant output proceeds based on that returned “ok” value.
# file path: src/agent/agent.rs
async fn chat(
&self,
_request: ChatRequest<’_>,
_model: &str,
_temperature: f64,
) -> Result<crate::providers::ChatResponse> {
let mut guard = self.responses.lock().unwrap();
if guard.is_empty() {
return Ok(crate::providers::ChatResponse {
text: Some(”done”.into()),
tool_calls: vec![],
});
}
Ok(guard.remove(0))
}
}This chat implementation acts as a minimal, local provider that the Agent core can call when it needs a model response; it matches the provider.chat entrypoint the turn loop invokes but, instead of calling a remote LLM, it pulls pre-seeded ChatResponse values out of an in-memory queue. The function ignores the incoming ChatRequest, model name, and temperature parameters, acquires a Mutex guard on the responses vector to handle concurrent access, and if there are queued responses it removes and returns the first one so the agent can fold that text and any tool_calls back into history. If the queue is empty it returns a sentinel ChatResponse whose text is the literal “done” and has no tool_calls, which the Agent.turn logic can interpret as a terminal assistant reply and stop further iterations. Compared with the other chat implementations you saw — which either call the external Responses API or resolve and delegate to a chosen provider — this version is synchronous/local and deterministic, intended for testing or scripted runs where the agent should consume a predefined sequence of provider outputs.
# file path: src/agent/agent.rs
struct MockTool;MockTool is a minimal, zero‑state concrete type that serves as a lightweight stand‑in for the Tool abstraction the Agent core expects. Because the Agent holds a collection of Tool implementations and the turn logic (via execute_tool_call and execute_tools) needs a Tool instance to exercise dispatch, MockTool provides a no‑op or testable implementation that can be boxed and inserted into an Agent’s tools vector without carrying runtime side‑effects or state. Unlike ToolsSection and the builder-style tools method, which are used to assemble real tool lists and configure the runtime toolset, MockTool is a test double used to drive and validate the agent’s orchestration and history/memory flow in unit tests or examples. In relation to the full Agent struct — which wires together provider adapters, memory, prompt building, and real tools — MockTool satisfies the Tool interface surface with the smallest possible footprint so the higher‑level turn and run logic can be exercised deterministically.
# file path: src/agent/agent.rs
#[async_trait]
impl Tool for MockTool {
fn name(&self) -> &str {
“echo”
}The snippet is the start of the Tool trait implementation for MockTool: it provides the tool registry identity that the Agent core uses to match a ParsedToolCall to an actual tool implementation. By returning the string identifier echo, MockTool registers itself under that name so execute_tool_call and the higher-level execute_tools dispatcher can look it up when the model asks to invoke echo during Agent.turn. The async_trait marker indicates MockTool implements the asynchronous Tool interface so its runtime invocation will integrate cleanly with the agent’s async orchestration (execute_tools can await the tool run and Agent.turn can continue its model↔tool loop), and this follows the same naming pattern used by other tools like the ones that identify themselves as tools or http_get, where the name method provides the canonical key for lookup and telemetry recording.
# file path: src/agent/agent.rs
fn description(&self) -> &str {
“echo”
}The description method supplies the human-facing label for this tool by returning the single-word description echo. Within the agent core this description becomes part of the tool’s metadata that the agent registers and exposes: it is used when the system prompt or tool-listings need to describe available capabilities (recall build_system_prompt was responsible for turning runtime configuration into the system prompt), and it also surfaces in debugging, telemetry, and any UI or logs that present what each tool does. Conceptually it complements the name methods you’ve seen elsewhere (which return identifiers like identity, runtime, or workspace) by providing a short explanatory string rather than the canonical tool id; execute_tool_call and execute_tools rely on the tool id to route calls, while this description is used for presentation and documentation of the tool’s purpose.
# file path: src/agent/agent.rs
fn parameters_schema(&self) -> serde_json::Value {
serde_json::json!({”type”: “object”})
}Within the agent core, the parameters_schema implementation for MockTool exposes the JSON Schema the tool declares for its input parameters and, in this case, returns the most permissive schema that only requires the parameters to be an object with no further constraints. This complements the identity and human-facing label that MockTool already provides (covered earlier) so the Agent’s tool registry can both match a ParsedToolCall to MockTool and know how to validate the call payload before invocation. The implementation follows the same pattern other tools use — producing a serde_json::Value JSON Schema — but whereas other tools enumerate properties, types, descriptions, and required fields, MockTool deliberately omits those details and accepts any object-shaped input, making it a minimal, unconstrained stand‑in during the Agent run/dispatch flow.
# file path: src/agent/agent.rs
async fn execute(&self, _args: serde_json::Value) -> Result<crate::tools::ToolResult> {
Ok(crate::tools::ToolResult {
success: true,
output: “tool-out”.into(),
error: None,
})
}
}The execute method completes MockTool’s Tool trait implementation by providing an asynchronous, deterministic tool run that ignores its incoming JSON arguments and always returns a successful ToolResult. Concretely, the method is asynchronous so the Agent runtime can await it like any real tool, but it never fails and supplies a fixed output string and no error payload wrapped in an Ok result. Because execute returns a ToolResult indicating success, the Agent’s execute_tool_call path (which looks up a tool by name, awaits its execute, records an ObserverEvent, and either uses the tool output on success or formats an error on failure) will follow the happy path for calls routed to MockTool and incorporate the fixed output into the overall tool execution result. This makes MockTool a simple, zero‑state stub that integrates with the Agent core’s orchestration and testing flows by producing a predictable tool response.
# file path: src/agent/agent.rs
#[tokio::test]
async fn turn_without_tools_returns_text() {
let provider = Box::new(MockProvider {
responses: Mutex::new(vec![crate::providers::ChatResponse {
text: Some(”hello”.into()),
tool_calls: vec![],
}]),
});The test function turn_without_tools_returns_text is an async Tokio unit test that sets up a MockProvider instance as the Agent’s provider to simulate a model response for a no-tools scenario. It constructs the MockProvider with an in-memory queue containing a single ChatResponse whose text field is set to a plain greeting and whose tool_calls list is deliberately empty; the response vector is wrapped in a Mutex and boxed so it can be used as the Provider trait object the Agent expects in an async test environment. Remember the MockProvider implements the Provider trait’s chat entrypoint and will return the queued ChatResponse instead of calling a real LLM; by seeding a response with no tool calls the test forces the Agent’s non-tool branch of the turn loop. In the context of the agent core, this setup is the test scaffold that drives the turn/agent_turn/run_single flow so the run loop receives a simple text-only reply and the assertion that follows can verify the Agent returns that plain text back to the caller, exercising the conversation and history handling without invoking any tools. This validates the core behavior required by zeroclaw-main_cleaned where provider-only replies should produce direct textual outputs from the agent.
# file path: src/agent/agent.rs
let memory_cfg = crate::config::MemoryConfig {
backend: “none”.into(),
..crate::config::MemoryConfig::default()
};
let mem: Arc<dyn Memory> = Arc::from(
crate::memory::create_memory(&memory_cfg, std::path::Path::new(”/tmp”), None).unwrap(),
);Within the Agent setup sequence, the code constructs a MemoryConfig that explicitly selects the “none” backend while leaving all other config fields at their defaults, then asks the create_memory factory to produce a concrete Memory implementation for that configuration using /tmp as the filesystem base path and no extra loader parameter, asserts the creation succeeded, and wraps the returned implementation into an Arc so it can be shared as a trait object across the agent. Functionally, this provides a no-op or in-memory memory backend so the Agent core has a valid Memory instance to call during turn processing; create_memory implements a factory pattern that maps MemoryConfig choices to concrete backends, and the resulting Arc aligns with the Agent builder API that accepts an Arc via the memory method explained earlier. The unwrap indicates the setup assumes create_memory will succeed for the “none” backend in this context, and the Path argument is present because other backends require a filesystem root even though the “none” backend will effectively ignore it.
# file path: src/agent/agent.rs
let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
let mut agent = Agent::builder()
.provider(provider)
.tools(vec![Box::new(MockTool)])
.memory(mem)
.observer(observer)
.tool_dispatcher(Box::new(XmlToolDispatcher))
.workspace_dir(std::path::PathBuf::from(”/tmp”))
.build()
.unwrap();The snippet constructs the runtime pieces the Agent core needs and then assembles them with the Agent builder so the turn loop can run: it first creates a shared, thread‑safe observer by instantiating the NoopObserver and wrapping it in an Arc so the Agent (and any concurrent components) can hold a reference to an Observer trait object. It then calls into the Agent builder to inject the Provider (the MockProvider we examined earlier), a single Tool implementation (the MockTool you saw before, boxed as a trait object so the tool registry can own it), the memory backend instance, the Arc observer, and a concrete ToolDispatcher implementation (XmlToolDispatcher) that the core will use to map parsed tool calls to tool executions. The builder is also given an explicit workspace directory of /tmp to override the default. Finally the builder is invoked to produce a fully configured Agent; the build method will validate required dependencies and fill in defaults for optional settings (prompt builder, model name, temperature, etc., as in the shared build logic), and unwrap is used here to extract the Agent or abort if the builder returned an error.
# file path: src/agent/agent.rs
let response = agent.turn(”hi”).await.unwrap();
assert_eq!(response, “hello”);
}The test calls Agent.turn with the user utterance “hi”, awaits the asynchronous completion of that turn, and extracts the ok result string; it then asserts that the returned string equals “hello”. In the context of the agent core, invoking turn kicks off the standard orchestration: ensure the system prompt exists, optionally persist the user message to memory, load any retrieved context and enrich the user input, append the user message to history, run the tool-dispatch loop that sends assembled messages to the provider, and convert whatever the provider (and any tools) produce into the final reply. Because this test uses the MockProvider and the minimal MockTool registered earlier, the provider call will return the deterministic, pre-seeded response from the in-memory queue, and the agent’s turn machinery passes that through to the caller; the assertion verifies that the end-to-end flow from Agent.turn through provider interaction yields the expected literal reply. This is consistent with run_single, which simply delegates to turn, and with the agent_turn/run_tool_call_loop pattern used elsewhere to centralize the provider-and-tool loop behavior.
# file path: src/agent/agent.rs
#[tokio::test]
async fn turn_with_native_dispatcher_handles_tool_results_variant() {
let provider = Box::new(MockProvider {
responses: Mutex::new(vec![
crate::providers::ChatResponse {
text: Some(String::new()),
tool_calls: vec![crate::providers::ToolCall {
id: “tc1”.into(),
name: “echo”.into(),
arguments: “{}”.into(),
}],
},
crate::providers::ChatResponse {
text: Some(”done”.into()),
tool_calls: vec![],
},
]),
});The test turn_with_native_dispatcher_handles_tool_results_variant sets up a simulated provider sequence to exercise the Agent core’s turn loop when the NativeToolDispatcher is in play. It constructs a MockProvider (the pre-seeded in-memory provider you saw earlier) with two queued ChatResponse objects: the first response contains no assistant text but includes a single tool call descriptor (id tc1, tool name echo, empty JSON arguments), and the second response contains a plain “done” assistant text and no tool calls. By arranging responses this way, the test drives the agent through the scenario where the model asks for a tool invocation, the agent dispatches that call to the registered MockTool (the simple tool stub you already reviewed), and then the agent must take the tool’s execution result, have NativeToolDispatcher format those results into a ConversationMessage::ToolResults, inject that into the conversation history, and deliver the history back to the provider so the provider can return the final “done” reply. In short, the snippet prepares the provider-side conversation trace that verifies the end-to-end data flow: model issues tool_call → agent parses and executes the tool → dispatcher converts execution outputs into the tool-results variant of conversation history → provider receives the updated history and finishes the turn with a follow-up assistant message. This mirrors how NativeToolDispatcher.parse_response, format_results, and to_provider_messages are intended to interoperate, but here it is validated using the queued MockProvider responses rather than a live LLM.
# file path: src/agent/agent.rs
let memory_cfg = crate::config::MemoryConfig {
backend: “none”.into(),
..crate::config::MemoryConfig::default()
};
let mem: Arc<dyn Memory> = Arc::from(
crate::memory::create_memory(&memory_cfg, std::path::Path::new(”/tmp”), None).unwrap(),
);The lines instantiate a default, no‑op memory backend so the Agent has a Memory implementation to use for history and context management: a MemoryConfig is created with its backend set to the literal none value while other config fields are left as the default, and that config is passed into the create_memory factory function along with a filesystem base path of /tmp and a None loader argument; the returned concrete Memory instance is unwrapped and wrapped in an Arc so the Agent can hold a shared, trait‑object reference to the memory backend across async tasks and components. In the agent core’s initialization flow this guarantees there is always a Memory available for orchestration of prompts and history even when the builder methods like memory and memory_loader haven’t been used, and it follows the same factory pattern used elsewhere in the codebase where a config drives creation of a pluggable backend implementation.
# file path: src/agent/agent.rs
let observer: Arc<dyn Observer> = Arc::from(crate::observability::NoopObserver {});
let mut agent = Agent::builder()
.provider(provider)
.tools(vec![Box::new(MockTool)])
.memory(mem)
.observer(observer)
.tool_dispatcher(Box::new(NativeToolDispatcher))
.workspace_dir(std::path::PathBuf::from(”/tmp”))
.build()
.unwrap();The snippet instantiates a no‑op observer and then uses the Agent builder to assemble a fully configured Agent instance ready to drive conversation turns and tool dispatch. It wraps NoopObserver in an Arc to satisfy the Observer trait object the core expects, supplies the previously created provider and memory implementations, registers a single MockTool (the simple echo implementation you saw earlier) and a NativeToolDispatcher boxed as the runtime that will execute parsed tool calls, and overrides the workspace directory to /tmp before invoking build and unwrapping the result. Because Agent::build enforces required components and computes derived state (for example it will extract tool_specs from the provided tools, apply default prompt builder and memory loader if missing, and initialize an empty history), this sequence validates and finalizes the agent configuration so the run/interactive logic in the agent core can begin orchestrating model calls, memory loads, and tool executions.
# file path: src/agent/agent.rs
let response = agent.turn(”hi”).await.unwrap();
assert_eq!(response, “done”);
assert!(agent
.history()
.iter()
.any(|msg| matches!(msg, ConversationMessage::ToolResults(_))));
}
}The snippet runs a single conversational turn through the Agent by calling Agent.turn with the user input “hi”, unwraps the successful result, and asserts two things about the outcome: that the textual response returned by the turn equals “done”, and that the Agent’s persisted conversation history contains a ConversationMessage::ToolResults entry. In the context of the agent core this exercises the orchestration path where the turn logic (which prepares the system prompt, enriches the user message, calls the provider, parses any tool calls, and executes tools in the tool‑call loop) actually causes a tool to run and its output to be recorded. Because the test harness uses the MockProvider and MockTool we looked at earlier, the provider supplies the canned model response that triggers a tool dispatch and MockTool provides a lightweight implementation to satisfy the tool registry; the assertion that history contains a ToolResults message verifies that the agent not only produced the final text “done” but also recorded the tool execution results into its conversation history as designed.
# file path: src/agent/dispatcher.rs
use crate::providers::{ChatMessage, ChatResponse, ConversationMessage, ToolResultMessage};
use crate::tools::{Tool, ToolSpec};
use serde_json::Value;
use std::fmt::Write;The dispatcher needs the core message and tool primitives so it can translate between provider payloads, agent conversation history, and tool metadata: ChatMessage and ChatResponse are the provider-facing types the dispatcher reads from and writes to, ConversationMessage is the agent-side history variant the dispatcher builds or consumes, and ToolResultMessage is the specific conversation payload used to record tool outputs (the same ConversationMessage::ToolResults that the tests assert the Agent persists). Tool and ToolSpec are the tool-facing interfaces the dispatcher inspects when it builds prompt instructions and decides which tool descriptions to surface to a provider. Serde_json::Value is pulled in to represent arbitrary JSON payloads for tool-call arguments and parsed provider tool-call objects, enabling flexible serialization/deserialization of parameters. Std::fmt::Write supplies the ability to append formatted text into Strings when the dispatcher composes multi-line instructions or serialized tool-result blocks (as seen in the XML and native dispatcher implementations). Compared with other modules that also import memory, observability, and runtime layers, this import set is deliberately focused on the messaging and tool-schema primitives plus small utilities the dispatch layer needs to mediate between the agent core, providers, and tools.
# file path: src/agent/dispatcher.rs
#[derive(Debug, Clone)]The attribute annotation applied here instructs the compiler to automatically provide standard trait implementations for the type that follows, namely making that dispatcher type printable for inspection and easy to duplicate. In the context of the dispatcher layer that mediates between the Agent core, providers, and the tool/runtime layer, having Debug allows runtime logging and test assertions to print the dispatcher’s state, and having Clone lets the Agent builder and async tasks freely duplicate the dispatcher instance when wiring it into different components or sharing it across threads. This follows the same pattern used for other dispatcher types such as NativeToolDispatcher and XmlToolDispatcher, which are simple dispatch structs used by the agent; the annotation saves you from writing manual implementations for these common utilities and ensures the dispatcher integrates cleanly with the Agent construction and orchestration flow.
# file path: src/agent/dispatcher.rs
pub struct ParsedToolCall {
pub name: String,
pub arguments: Value,
pub tool_call_id: Option<String>,
}ParsedToolCall is the small data container the dispatcher uses to represent a single tool invocation that it extracted from a provider response. It holds the tool name, the arguments as a serde_json Value so the dispatcher can work with structured JSON arguments rather than raw text, and an optional tool_call_id that, when present, lets the dispatcher correlate the original provider-issued identifier with subsequent tool execution and results recording. In the agent/dispatcher flow, provider output is parsed into one or more ParsedToolCall instances (recall the parse_xml_tool_calls routine we examined earlier produces these entries), the dispatcher then turns those into concrete invocations or records them in the conversation history, and the optional tool_call_id is carried along to map responses back to the originating call. Conceptually this struct is a lightweight intermediate DTO between parsing logic and the tool-dispatching machinery; it extends the earlier, simpler ParsedToolCall variant by adding an explicit field for provider-supplied call IDs so the dispatcher can maintain tighter tracking of tool calls across the turn lifecycle.
# file path: src/agent/dispatcher.rs
#[derive(Debug, Clone)]The derive attribute applied here instructs the compiler to automatically implement the Debug and Clone traits for the type that follows, so the dispatcher-related type can be both printed for diagnostics and duplicated without manual boilerplate. In the context of the agent dispatch layer that coordinates provider calls and tool invocations, having Debug makes it straightforward to log or inspect dispatcher state during orchestration and troubleshooting, and having Clone lets dispatcher instances be cheaply copied when they need to be handed into different async tasks or stored in multiple places. This follows the same minimal-struct pattern used by NativeToolDispatcher and XmlToolDispatcher: instead of writing explicit trait impls, the code relies on derived implementations to keep the dispatcher plumbing concise and traceable while the Agent composes and moves dispatcher objects between components.
# file path: src/agent/dispatcher.rs
pub struct ToolExecutionResult {
pub name: String,
pub output: String,
pub success: bool,
pub tool_call_id: Option<String>,
}ToolExecutionResult is the small, concrete value the dispatcher uses to represent the outcome of invoking a tool at runtime; it lives in the agent dispatch layer that translates a ParsedToolCall into an actual execution and then feeds the execution outcome back into the agent loop and history. Recall the test sequence where Agent.turn produced a ConversationMessage::ToolResults entry in history — ToolExecutionResult is the per‑tool payload that gets persisted there and handed back to the core so the planner can continue. Conceptually it carries the tool identity (so the rest of the system knows which tool produced the output), the textual output from the tool execution (what will be shown to the agent or included in prompts), a boolean success flag (the straightforward control signal for the happy path versus failure handling), and an optional tool_call_id (to correlate provider‑side tool calls or traceability metadata when a provider supplied an id). Compared to ToolResult, which focuses on success, output and an optional error field, ToolExecutionResult augments the execution record with the tool name and the optional tool_call_id and does not model a separate error field; compared to ParsedToolCall, which represents the intent and arguments before invocation, ToolExecutionResult represents the post‑invocation state returned by the dispatcher after executing or routing the parsed call.
# file path: src/agent/dispatcher.rs
pub trait ToolDispatcher: Send + Sync {
fn parse_response(&self, response: &ChatResponse) -> (String, Vec<ParsedToolCall>);
fn format_results(&self, results: &[ToolExecutionResult]) -> ConversationMessage;
fn prompt_instructions(&self, tools: &[Box<dyn Tool>]) -> String;
fn to_provider_messages(&self, history: &[ConversationMessage]) -> Vec<ChatMessage>;
fn should_send_tool_specs(&self) -> bool;
}ToolDispatcher is the strategy interface the agent dispatch layer uses to encapsulate how provider responses, tool invocations, and prompt instructions are translated back and forth between the Agent core, provider adapters, and tools. It requires implementations to be Send and Sync so a dispatcher can be held as a shared trait object and used safely across async tasks. parse_response takes a provider ChatResponse and returns the plain text the model saw plus a list of ParsedToolCall entries so the dispatcher core can decide whether and how to execute tools. format_results turns the raw ToolExecutionResult objects produced by tool runs into a ConversationMessage that gets persisted into conversation history and later fed back to the model. prompt_instructions builds the textual instructions the agent prepends to prompts to teach the model the tool use protocol and list available tools when appropriate. to_provider_messages converts the agent’s ConversationMessage history into the provider-specific ChatMessage sequence the LLM adapter expects, handling differences like embedding tool-call payloads or tool-result messages. should_send_tool_specs is a simple policy switch the agent uses when composing prompts to decide whether to embed explicit ToolSpec metadata into the outgoing prompt. Concrete implementations like NativeToolDispatcher and XmlToolDispatcher provide the different parsing/formatting behaviors described in the tests you saw earlier: for example, NativeToolDispatcher extracts structured tool_calls from the provider response and formats ToolResults differently than XmlToolDispatcher, which parses XML-wrapped tool calls and builds protocolized instructions. The trait therefore isolates provider- and protocol-specific behavior so the rest of the dispatcher logic can remain generic.
# file path: src/agent/dispatcher.rs
#[derive(Default)]The derive attribute applied here tells the compiler to automatically generate a Default implementation for the type that immediately follows, so callers can create a default instance of that dispatcher-related type without specifying fields. In the context of the dispatcher layer that orchestrates provider responses and tool invocation, having an auto-derived Default makes it easy for the Agent builder, tests, and other components to instantiate a dispatcher or dispatcher-config placeholder with sensible zeroed/default state when no custom settings are provided. This is analogous to the manual Default implementation on AgentConfig, but unlike AgentConfig’s hand-written default that selects particular boolean and numeric defaults, the derive approach produces a straightforward compiler-generated default (which is appropriate for simple, fieldless or trivially-defaulted types like the NativeToolDispatcher and XmlToolDispatcher variants).
# file path: src/agent/dispatcher.rs
pub struct XmlToolDispatcher;XmlToolDispatcher is a public, stateless dispatcher type that represents the XML‑style implementation of the ToolDispatcher used by the agent dispatch layer to interpret provider outputs and marshal tool interactions. As a unit struct it carries no internal state; its behavior is provided entirely by its ToolDispatcher methods such as parse_response, format_results, prompt_instructions, and to_provider_messages (the implementations you saw elsewhere). In the agent flow it sits in the same role as NativeToolDispatcher did in the tests you examined: the agent selects a dispatcher implementation and then feeds provider ChatResponse objects into parse_response so the dispatcher can extract any <tool_call>-wrapped JSON payloads via parse_xml_tool_calls and return the remaining assistant text plus a list of ParsedToolCall entries. After tools are executed, XmlToolDispatcher’s format_results turns ToolExecutionResult entries into a single ConversationMessage that encodes each result as an XML-style tool_result block, and prompt_instructions builds the instructions shown to the model (including the expected <tool_call> wrapper and a listing of tool specs). Its to_provider_messages conversion maps ConversationMessage history into the provider ChatMessage sequence so the model sees prior chats and any assistant tool-call content in the XML-appropriate form. Conceptually this implements the strategy pattern for dispatcher behavior: XmlToolDispatcher provides the XML protocol variant so the agent core can swap dispatchers to match different provider/formats without changing the rest of the decision and tool invocation pipeline.
Download the entire article as markdown using the button below:




