objective05-exec: Giving Your Local Intelligence System Hands — A Rust Tutorial on Bridging a Knowledge Graph to Real-World Tool Execution

objective05-exec: Giving Your Local Intelligence System Hands
How to bridge a perpetual knowledge graph to real-world tool execution — a Rust tutorial
June 8, 2026 · Daniel Kliewer
GitHub: kliewerdaniel/objective05
Table of Contents
- Introduction
- The Landscape: What Everyone Else Is Building
- The Gap
- Prerequisites and Environment Setup
- Architecture Overview
- Step 1: Project Structure
- Step 2: Configuration and Error Types
- Step 3: The Tool Discovery System
- Step 4: The Graph Query Builder
- Step 5: The Signal Evaluator
- Step 6: The Tool Execution Layer
- Step 7: The Core Agent Runtime
- Step 8: The Main Entry Point
- Step 9: TOOLS.md Files
- Step 10: Building and Running
- Environment Variables Reference
- The Kuzu Schema This Agent Expects
- Testing the Agent
- Deployment Patterns
- Integration with OpenClaw
- Troubleshooting
- Beyond the MVP
- Why This Matters
- Conclusion
Introduction
There are two fundamental modes of intelligence: understanding and acting.
Most AI systems do one or the other. Chatbots understand — they process your input, generate a response, and forget everything when the session ends. Dashboards act — they display charts, trigger alerts, send emails — but they have no memory of what happened yesterday. The product design choices that lead here are predictable: when the model is the product, you build stateless interfaces. When the dashboard is the product, you build passive displays.
I built Objective05 to solve the understanding problem. It's a local-first intelligence system written in Rust that continuously ingests information from the web, extracts entities and claims, detects contradictions and narrative drift, maintains a temporal knowledge graph backed by Kuzu DB, and generates written reports and audio broadcasts. It listens. It thinks. It remembers.
But for months now, I've been asking a different question: what does it do with what it knows?
The answer matters more than you might think. Because the biggest gap in the AI landscape right now isn't between better models and worse models. It's between systems that understand deeply and systems that can actually do something about it.
In this post, I'm going to walk through building objective05-exec — the execution runtime that bridges Objective05's knowledge graph to real-world tools. By the end, you'll have a Rust-based agent that can:
- Query the Kuzu knowledge graph for context
- Evaluate whether an action is warranted based on detected patterns
- Execute real tasks: file GitHub PRs, send emails, update spreadsheets, post to Slack/Discord, write files to disk
- Discover available tools through a TOOLS.md/SKILLS.md interface (matching the OpenClaw model)
- Run on consumer hardware, fully local, fully sovereign
This is not a cloud agent. This is not a chatbot wrapper. This is a local-first agent runtime that connects deep understanding to real-world action.
The Landscape: What Everyone Else Is Building
Before we dive into the code, let's look at what the big players launched in the last few months. Three products define the current moment:
Microsoft Scout (built on OpenClaw)
Scout is an "always-on autonomous agent" built on the OpenClaw framework. It integrates with Microsoft 365, executes tasks across cloud and desktop, and operates with enterprise-grade security. The key feature: it doesn't wait to be asked. It monitors your calendar, drafts documents, schedules meetings, and acts across your work tools autonomously.
OpenClaw — Scout's base — is itself worth studying. It's a self-hosted, multi-channel agent gateway written in Node.js, MIT licensed, that runs on consumer hardware. It supports persistent memory across sessions, multi-agent routing, tool execution, and capability discovery via TOOLS.md/SKILLS.md files. It connects to Slack, Teams, WhatsApp, Discord, Telegram, and more. It's the scaffolding that turned "chatbots that respond" into "agents that act."
Google Gemini Spark
Spark is Google's always-on agent running on dedicated GCP VMs. It monitors Gmail, Calendar, Docs, and Sheets. Its strength: task planning and structuring, collaborative teams, repeatable workflows, and autonomous background execution. It drafts documents, makes purchases, and runs workflows without user prompting.
Anthropic Orbit
Orbit is Anthropic's proactive agent that synthesizes data from Gmail, Slack, GitHub, Calendar, Google Drive, and Figma to generate personalized daily briefings. Discovered as a hidden toggle in Claude's settings in May 2026, it represents a shift from reactive chat to proactive awareness.
The Gap
Look at these three products and you'll see a pattern. They're all cloud agents with tool execution. They can act — draft a doc, send an email, file a PR — but their understanding is shallow. They have no persistent knowledge graph. No temporal reasoning. No contradiction detection. No narrative tracking. They connect to your work tools, yes, but they don't understand them the way Objective05 understands the web.
Meanwhile, Objective05 has deep local understanding — a temporal knowledge graph that tracks entities, claims, events, and contradictions over time — but no way to act on that understanding. It can detect that a narrative is diverging in the GitHub ecosystem, but it can't file a PR to address it. It can spot a contradiction between two ArXiv papers on the same topic, but it can't draft a response. It can identify a trending pattern across Hacker News, but it can't post a summary to Slack.
The gap is clear: Objective05 has the brain. It needs hands.
Prerequisites and Environment Setup
Before writing any code, you need a working Rust toolchain and a few external services configured. The agent is designed to fail soft when credentials are missing — it will log warnings and continue with whatever tools are available — but a clean install goes faster with everything in place.
1. Install Rust (stable, 1.78+)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
rustup default stable
rustc --version # should report 1.78 or newer
2. Clone the Objective05 Repository (Graph Source)
objective05-exec is a consumer of the Objective05 knowledge graph. You can either run a full Objective05 installation or stub one out:
# Full installation
git clone https://github.com/kliewerdaniel/objective05.git
cd objective05
cargo build --release
./target/release/objective05 # starts ingestion; writes to ./data/graph.db
If you only want to experiment with the execution runtime, you can use a stub Kuzu database with the schema described in The Kuzu Schema This Agent Expects.
3. Install Kuzu CLI (Optional but Useful)
The Kuzu CLI lets you inspect the graph directly:
# macOS
brew install kuzu
# Linux
curl -L https://github.com/kuzudb/kuzu/releases/latest/download/kuzu_cli-linux-x86_64.tar.gz \
| tar -xz -C /usr/local/bin
You can then run ad-hoc queries:
kuzu ../objective05/data/graph.db
kuzu> MATCH (n:Narrative) RETURN n LIMIT 5;
4. Acquire Tool Credentials
The default toolset requires at minimum a GitHub personal access token. The rest are optional and can be enabled selectively in config.toml.
| Tool | Required Env Vars | How to Get |
|---|---|---|
| GitHub | GITHUB_TOKEN, DEFAULT_REPO |
GitHub → Settings → Developer settings → Personal access tokens (scopes: repo, workflow) |
SMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORD |
Any SMTP relay (Gmail App Password, Fastmail, Mailgun, Postmark) | |
| Slack | SLACK_WEBHOOK_URL |
Slack → Apps → Incoming Webhooks |
| Discord | DISCORD_WEBHOOK_URL |
Discord → Channel Settings → Integrations → Webhooks |
| Filesystem | REPORTS_DIR |
Any local path you can write to (defaults to ./reports) |
The agent does not require all five tools to run. You can enable only github and filesystem and disable the rest.
5. Verify Connectivity
Before launching the agent, sanity-check each external dependency:
# GitHub
curl -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/user
# Slack
curl -X POST -H 'Content-type: application/json' \
--data '{"text":"objective05-exec smoke test"}' \
$SLACK_WEBHOOK_URL
# Discord
curl -X POST -H 'Content-type: application/json' \
--data '{"content":"objective05-exec smoke test"}' \
$DISCORD_WEBHOOK_URL
# SMTP (using openssl)
echo "Subject: smoke test" | openssl s_client -connect $SMTP_HOST:$SMTP_PORT -crlf -starttls smtp
If any of these fail, fix them before starting the agent so its logs aren't drowned in connection errors.
Architecture Overview
Here's the architecture we're building:
┌─────────────────────────────────────────────────────────────┐
│ objective05 Daemon │
│ │
│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
│ │ Ingestion │ │ Knowledge │ │ Broadcasting │ │
│ │ Pipeline │→│ Graph (Kuzu) │→│ Engine │ │
│ │ │ │ │ │ │ │
│ │ RSS/Reddit/ │ │ Entities, │ │ Reports, Audio, │ │
│ │ YouTube/ │ │ Claims, │ │ Notifications │ │
│ │ GitHub/ │ │ Events, │ │ │ │
│ │ ArXiv/HN │ │ Narratives │ │ │ │
│ └─────────────┘ └──────┬───────┘ └──────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ objective05-exec Agent Runtime │ │
│ │ │ │
│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
│ │ │ Query │ │ Evaluate │ │ Execute │ │ │
│ │ │ Graph │→│ Signals │→│ Tools │ │ │
│ │ └──────────┘ └──────────┘ └──────────────────┘ │ │
│ │ │ │ │
│ │ ▼ │ │
│ │ ┌──────────────────┐ │ │
│ │ │ TOOLS.md / │ │ │
│ │ │ SKILLS.md │ │ │
│ │ │ Discovery │ │ │
│ │ └──────────────────┘ │ │
│ └──────────────────────────────────────────────────────┘ │
│ │ │
│ ▼ │
│ ┌──────────────────────────────────────────────────────┐ │
│ │ Tool Execution Layer │ │
│ │ │ │
│ │ GitHub │ Email │ Slack │ Discord │ Files │ │
│ │ (PRs) │ (SMTP) │ (Webhook)│ (Webhook)│ (Write) │ │
│ └──────────────────────────────────────────────────────┘ │
└─────────────────────────────────────────────────────────────┘
The agent runtime sits between the knowledge graph and the tools. It queries the graph for context, evaluates whether an action is warranted based on detected patterns, and executes the appropriate tool. Tools are discovered through TOOLS.md/SKILLS.md files — the same pattern OpenClaw uses, which means our agent can interoperate with the OpenClaw ecosystem.
The Three-Phase Loop
Every cycle the runtime runs goes through the same three phases:
- Query —
GraphQueryBuilderconstructs Cypher queries against Kuzu and pulls back rows representing signals: contradictions, narrative divergences, trending entities, new events. - Evaluate —
SignalEvaluatormatches each signal against a list ofActionRules. Rules can threshold on confidence, set priority, and decide whether to auto-execute or require human approval. - Execute —
ToolExecutordispatches the resulting actions to the appropriate tool, respecting a per-cycle budget and logging all outcomes.
This is intentionally not an LLM-driven loop. The intelligence comes from the graph, the rules come from you, and the LLM is reserved for the parts that actually need language understanding (drafting PR bodies, summarizing divergences). The deterministic loop is what makes the system debuggable, auditable, and cheap to run.
Step 1: Project Structure
Let's start with the directory layout:
objective05-exec/
├── Cargo.toml
├── rust-toolchain.toml
├── README.md
├── src/
│ ├── main.rs
│ ├── lib.rs
│ ├── agent/
│ │ ├── mod.rs
│ │ ├── runtime.rs # Core agent loop
│ │ ├── query.rs # Kuzu query builder
│ │ ├── evaluator.rs # Signal evaluation engine
│ │ └── executor.rs # Tool execution dispatcher
│ ├── tools/
│ │ ├── mod.rs
│ │ ├── github.rs # GitHub PR/file tools
│ │ ├── email.rs # SMTP email tool
│ │ ├── slack.rs # Slack webhook tool
│ │ ├── discord.rs # Discord webhook tool
│ │ └── filesystem.rs # File write tool
│ ├── discovery/
│ │ ├── mod.rs
│ │ └── tool_loader.rs # TOOLS.md/SKILLS.md parser
│ ├── config.rs # Configuration management
│ └── error.rs # Error types
├── tools/
│ ├── github.tools.md # Tool capability definitions
│ ├── email.tools.md
│ ├── slack.tools.md
│ ├── discord.tools.md
│ └── filesystem.tools.md
├── config.toml # Agent configuration
├── .env.example # Documented env vars
└── docs/
└── architecture.md
Initialize the project
cargo new objective05-exec
cd objective05-exec
cargo init --lib # we'll add a binary target too
Pin the Rust toolchain (optional but recommended)
rust-toolchain.toml:
[toolchain]
channel = "stable"
components = ["rustfmt", "clippy"]
Add dependencies to Cargo.toml
[package]
name = "objective05-exec"
version = "0.1.0"
edition = "2021"
description = "Execution runtime for objective05 - bridging knowledge graphs to real-world tools"
license = "MIT OR Apache-2.0"
[dependencies]
# Async runtime
tokio = { version = "1", features = ["full"] }
async-trait = "0.1"
# Serialization
serde = { version = "1", features = ["derive"] }
serde_json = "1"
toml = "0.8"
# HTTP clients
reqwest = { version = "0.12", features = ["json", "streaming"] }
jsonwebtoken = "9"
# Kuzu DB (via FFI bindings)
kuzu = "0.4"
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Error handling
anyhow = "1"
thiserror = "2"
# UUID generation
uuid = { version = "1", features = ["v4", "serde"] }
# Time handling
chrono = { version = "0.4", features = ["serde"] }
# Email
lettre = { version = "0.11", features = ["tokio1-rustls-tls", "smtp-transport"] }
# GitHub API
octocrab = "0.38"
# Markdown parsing for TOOLS.md
pulldown-cmark = "0.11"
[profile.release]
opt-level = 3
lto = "thin"
codegen-units = 1
strip = true
Note on Kuzu: The official
kuzucrate provides Rust bindings via C FFI. If you hit FFI link errors on macOS, you may needbrew install kuzufirst to obtain thelibkuzu.dylibsystem library. On Linux, the crate vendors the static library; on Windows, ensure the Visual C++ runtime is installed.
.env.example
A documented template for the environment variables the agent reads at startup:
# GitHub
GITHUB_TOKEN=ghp_replace_me
DEFAULT_REPO=your-org/your-repo
# Email
SMTP_HOST=smtp.gmail.com
SMTP_PORT=587
SMTP_USERNAME=you@example.com
SMTP_PASSWORD=replace_me
# Slack
SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T000/B000/XXXX
# Discord
DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/000/XXXX
# Filesystem
REPORTS_DIR=./reports
# Logging
RUST_LOG=info,objective05_exec=debug
Step 2: Configuration and Error Types
Let's start with the foundational types.
src/error.rs:
use thiserror::Error;
#[derive(Error, Debug)]
pub enum ExecError {
#[error("Configuration error: {0}")]
Config(String),
#[error("Knowledge graph error: {0}")]
GraphError(String),
#[error("Tool execution error: {tool}: {reason}")]
ToolError { tool: String, reason: String },
#[error("Signal evaluation error: {0}")]
EvaluationError(String),
#[error("Discovery error: {0}")]
DiscoveryError(String),
#[error("Network error: {0}")]
NetworkError(#[from] reqwest::Error),
#[error("I/O error: {0}")]
IoError(#[from] std::io::Error),
}
pub type ExecResult<T> = Result<T, ExecError>;
src/config.rs:
use serde::{Deserialize, Serialize};
use std::path::PathBuf;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Config {
pub agent: AgentConfig,
pub knowledge_graph: GraphConfig,
pub tools: ToolsConfig,
pub execution: ExecutionConfig,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AgentConfig {
pub name: String,
pub description: String,
pub tool_discovery_path: PathBuf,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct GraphConfig {
pub path: PathBuf,
pub query_timeout_ms: u64,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolsConfig {
pub enabled_tools: Vec<String>,
pub max_concurrent_executions: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ExecutionConfig {
pub poll_interval_secs: u64,
pub max_action_budget_per_cycle: usize,
pub log_executions: bool,
}
impl Config {
pub fn from_file(path: &PathBuf) -> ExecResult<Self> {
let content = std::fs::read_to_string(path)
.map_err(|e| ExecError::Config(format!("Failed to read config: {}", e)))?;
let config: Config = toml::from_str(&content)
.map_err(|e| ExecError::Config(format!("Failed to parse config: {}", e)))?;
Ok(config)
}
}
config.toml:
[agent]
name = "objective05-exec"
description = "Execution runtime for objective05 - bridges knowledge graph to real-world tools"
tool_discovery_path = "tools/"
[knowledge_graph]
path = "../objective05/data/graph.db"
query_timeout_ms = 5000
[tools]
enabled_tools = ["github", "email", "slack", "discord", "filesystem"]
max_concurrent_executions = 4
[execution]
poll_interval_secs = 60
max_action_budget_per_cycle = 10
log_executions = true
A note on configuration: every field is required. If you want to make something optional (for example, the tool discovery path when running with no markdown tool files), wrap it in Option<PathBuf> and add #[serde(default)]. The defaults defined here assume a single-tenant, single-host deployment; multi-agent routing would extend Config with a routing section listing per-signal-type target agents.
Loading Config from Environment
The current Config::from_file reads from disk. For containerized deployments you may want to read specific fields from environment variables. The simplest pattern is to override the config path:
CONFIG_PATH=/etc/objective05-exec/config.toml ./target/release/objective05-exec
For full 12-factor compliance, swap Config::from_file for an envy-based deserializer that reads the same struct from process environment.
Step 3: The Tool Discovery System
This is where we borrow OpenClaw's cleverest design pattern: TOOLS.md/SKILLS.md capability discovery. Instead of hardcoding available tools, the agent reads markdown files that describe what tools exist, what parameters they accept, and when they should be used. This means tools can be added without recompiling — just drop a new .tools.md file into the tools directory.
src/discovery/mod.rs:
pub mod tool_loader;
pub use tool_loader::{ToolRegistry, ToolCapability, ParameterSchema, ToolType};
src/discovery/tool_loader.rs:
use super::ExecResult;
use crate::error::ExecError;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
use std::path::Path;
/// A tool capability as described in a TOOLS.md file
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ToolCapability {
pub name: String,
pub description: String,
pub parameters: HashMap<String, ParameterSchema>,
pub trigger_conditions: Vec<String>,
pub tool_type: ToolType,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ParameterSchema {
pub r#type: String,
pub description: String,
pub required: bool,
pub examples: Vec<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub enum ToolType {
GitHub,
Email,
Slack,
Discord,
Filesystem,
Custom(String),
}
/// Tool registry discovered from TOOLS.md files
#[derive(Debug, Clone)]
pub struct ToolRegistry {
pub capabilities: HashMap<String, ToolCapability>,
}
impl ToolRegistry {
/// Load all tool capabilities from the tools directory
pub fn load_from_directory(dir_path: &Path) -> ExecResult<Self> {
let mut capabilities = HashMap::new();
if !dir_path.exists() {
return Err(ExecError::DiscoveryError(
format!("Tools directory not found: {}", dir_path.display()),
));
}
for entry in std::fs::read_dir(dir_path)? {
let entry = entry?;
let path = entry.path();
if path.extension().map_or(false, |ext| ext == "md") {
let content = std::fs::read_to_string(&path)?;
if let Some(capability) = Self::parse_tool_file(&path, &content)? {
capabilities.insert(capability.name.clone(), capability);
}
}
}
Ok(Self { capabilities })
}
/// Parse a single TOOLS.md file into a ToolCapability
fn parse_tool_file(
path: &Path,
content: &str,
) -> ExecResult<Option<ToolCapability>> {
let mut name = String::new();
let mut description = String::new();
let mut parameters = HashMap::new();
let mut trigger_conditions = Vec::new();
let mut tool_type = ToolType::Custom(String::new());
let current_tool = path
.file_stem()
.and_then(|s| s.to_str())
.unwrap_or("unknown")
.to_string();
// Determine tool type from filename
tool_type = match current_tool.as_str() {
"github" => ToolType::GitHub,
"email" => ToolType::Email,
"slack" => ToolType::Slack,
"discord" => ToolType::Discord,
"filesystem" => ToolType::Filesystem,
_ => ToolType::Custom(current_tool.clone()),
};
let mut in_params = false;
let mut in_triggers = false;
for line in content.lines() {
if line.starts_with("# ") {
name = line[2..].trim().to_string();
in_params = false;
in_triggers = false;
} else if line.starts_with("## Parameters") {
in_params = true;
in_triggers = false;
} else if line.starts_with("## Trigger Conditions") {
in_triggers = true;
in_params = false;
} else if line.starts_with("## Description") || line.starts_with("### Description") {
description = content
.lines()
.skip_while(|l| !l.contains("Description"))
.skip(1)
.take_while(|l| !l.starts_with("#"))
.collect::<Vec<&str>>()
.join("\n")
.trim()
.to_string();
} else if line.starts_with("- **") && in_params {
// Parse parameter line like: - **param_name** (type): description
if let Some(param_def) = line.strip_prefix("- **") {
if let Some((param_name, rest)) = param_def.split_once("** (") {
if let Some((param_type, _desc)) = rest.split_once("): ") {
let desc = _desc.trim_start_matches('-').trim();
parameters.insert(
param_name.to_string(),
ParameterSchema {
r#type: param_type.to_string(),
description: desc.to_string(),
required: false, // Default, can be overridden
examples: Vec::new(),
},
);
}
}
}
} else if line.starts_with("- ") && in_triggers {
if let Some(condition) = line.strip_prefix("- ") {
trigger_conditions.push(condition.trim().to_string());
}
}
}
if name.is_empty() {
return Ok(None);
}
Ok(Some(ToolCapability {
name,
description,
parameters,
trigger_conditions,
tool_type,
}))
}
/// Check if any trigger conditions match a given graph signal
pub fn match_triggers(&self, signal: &GraphSignal) -> Vec<String> {
self.capabilities
.iter()
.filter_map(|(name, cap)| {
if cap
.trigger_conditions
.iter()
.any(|cond| signal.matches(cond))
{
Some(name.clone())
} else {
None
}
})
.collect()
}
}
A small but important detail: the parser is intentionally tolerant. A TOOLS.md with no ## Parameters section still loads — the resulting ToolCapability just has an empty parameters map. This means you can write a TOOLS.md that documents a tool's intent without committing to a typed schema, useful for early prototyping.
The match_triggers method does a substring match between signal-type strings and trigger-condition text. For higher precision, you can replace it with a real expression evaluator (see Beyond the MVP).
Step 4: The Graph Query Builder
Now we need a way to query the Kuzu knowledge graph for context. This is where Objective05's temporal graph comes in — we're not just asking "what's true?" We're asking "what changed?", "what contradicts?", "what's trending?"
src/agent/mod.rs:
pub mod runtime;
pub mod query;
pub mod evaluator;
pub mod executor;
src/agent/query.rs:
use crate::config::Config;
use crate::discovery::tool_loader::ToolRegistry;
use crate::error::{ExecError, ExecResult};
use serde_json::json;
use std::sync::Arc;
use tokio::sync::Mutex;
/// A graph signal that represents a detectable pattern
#[derive(Debug, Clone)]
pub struct GraphSignal {
pub signal_type: SignalType,
pub entity: String,
pub context: serde_json::Value,
}
#[derive(Debug, Clone)]
pub enum SignalType {
ContradictionDetected,
NarrativeDivergence,
TrendingEntity,
NewEvent,
ClaimSuperseded,
Custom(String),
}
impl std::fmt::Display for SignalType {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
SignalType::ContradictionDetected => write!(f, "ContradictionDetected"),
SignalType::NarrativeDivergence => write!(f, "NarrativeDivergence"),
SignalType::TrendingEntity => write!(f, "TrendingEntity"),
SignalType::NewEvent => write!(f, "NewEvent"),
SignalType::ClaimSuperseded => write!(f, "ClaimSuperseded"),
SignalType::Custom(s) => write!(f, "{}", s),
}
}
}
impl GraphSignal {
pub fn matches(&self, condition: &str) -> bool {
match &self.signal_type {
SignalType::ContradictionDetected => condition.contains("contradiction"),
SignalType::NarrativeDivergence => condition.contains("narrative"),
SignalType::TrendingEntity => condition.contains("trending"),
SignalType::NewEvent => condition.contains("event"),
SignalType::ClaimSuperseded => condition.contains("superseded"),
SignalType::Custom(c) => condition.contains(c.as_str()),
}
}
}
/// Query builder for Kuzu knowledge graph
pub struct GraphQueryBuilder {
config: Config,
}
impl GraphQueryBuilder {
pub fn new(config: Config) -> Self {
Self { config }
}
/// Query for recent contradictions in tracked narratives
pub fn build_contradiction_query(&self) -> String {
format!(
r#"
MATCH (c:Contradiction)
WHERE c.detected_at > datetime('{}')
RETURN c.id AS contradiction_id,
c.claim_a AS claim_a,
c.claim_b AS claim_b,
c.entity AS entity,
c.detected_at AS detected_at,
c.confidence AS confidence
ORDER BY c.detected_at DESC
LIMIT 50
"#,
chrono::Utc::now()
.checked_sub_signed(chrono::Duration::hours(24))
.unwrap()
.to_rfc3339()
)
}
/// Query for narratives with highest divergence scores
pub fn build_divergence_query(&self) -> String {
format!(
r#"
MATCH (n:Narrative)
WHERE n.divergence_score > 0.5
AND n.updated_at > datetime('{}')
RETURN n.id AS narrative_id,
n.title AS title,
n.divergence_score AS divergence_score,
n.tracked_entities AS entities,
n.updated_at AS updated_at
ORDER BY n.divergence_score DESC
LIMIT 20
"#,
chrono::Utc::now()
.checked_sub_signed(chrono::Duration::hours(48))
.unwrap()
.to_rfc3339()
)
}
/// Query for trending entities by mention velocity
pub fn build_trending_query(&self) -> String {
format!(
r#"
MATCH (e:Entity)
WHERE e.mention_velocity > 5
AND e.last_seen > datetime('{}')
RETURN e.id AS entity_id,
e.name AS name,
e.mention_velocity AS velocity,
e.category AS category,
e.last_seen AS last_seen
ORDER BY e.mention_velocity DESC
LIMIT 10
"#,
chrono::Utc::now()
.checked_sub_signed(chrono::Duration::hours(12))
.unwrap()
.to_rfc3339()
)
}
/// Build a query for new events in tracked domains
pub fn build_events_query(&self) -> String {
format!(
r#"
MATCH (e:Event)
WHERE e.created_at > datetime('{}')
AND e.verified = true
RETURN e.id AS event_id,
e.title AS title,
e.description AS description,
e.related_entities AS entities,
e.confidence AS confidence,
e.created_at AS created_at
ORDER BY e.created_at DESC
LIMIT 30
"#,
chrono::Utc::now()
.checked_sub_signed(chrono::Duration::hours(6))
.unwrap()
.to_rfc3339()
)
}
/// Execute a query against the Kuzu graph via the Rust FFI bindings.
/// In this tutorial the FFI is stubbed; in production, replace with
/// a real `kuzu::Database` and `kuzu::Connection`.
pub async fn execute_query(&self, query: &str) -> ExecResult<serde_json::Value> {
tracing::info!("Executing graph query: {}", query);
// Simulate query execution
tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
Ok(json!({
"status": "success",
"rows": []
}))
}
}
A few notes on the query builder:
- Time windows are computed from
Utc::now()at call time. If you need historical replays, accept asince: DateTime<Utc>parameter on each builder and pass it from the runtime. - Limits (
LIMIT 50,LIMIT 20, etc.) are conservative defaults. They exist to prevent runaway result sets if the graph is dense. Tune them based on your action budget. - The
execute_querystub returns an emptyrowsarray. The real implementation should construct akuzu::Connection, callconn.query(query), and walk the resultingQueryResultto serialize rows intoserde_json::Value. Treat the stub as a typed boundary: anything that passes through it must serialize cleanly.
Step 5: The Signal Evaluator
The evaluator is the decision engine. It takes raw graph signals and determines which tools should be triggered. This is where the "thinking" happens — not with an LLM, but with rule-based evaluation against the knowledge graph.
src/agent/evaluator.rs:
use crate::agent::query::GraphSignal;
use crate::discovery::tool_loader::ToolRegistry;
use crate::error::ExecResult;
/// An evaluation rule that maps signals to tool actions
#[derive(Debug, Clone)]
pub struct ActionRule {
pub rule_id: String,
pub signal_type: String,
pub threshold: f64,
pub action: ActionDefinition,
}
#[derive(Debug, Clone)]
pub struct ActionDefinition {
pub tool_name: String,
pub priority: Priority,
pub description: String,
pub auto_execute: bool,
}
#[derive(Debug, Clone, PartialEq)]
pub enum Priority {
Low,
Medium,
High,
Critical,
}
/// The signal evaluator that matches rules against graph signals
pub struct SignalEvaluator {
pub rules: Vec<ActionRule>,
}
impl SignalEvaluator {
pub fn new() -> Self {
Self { rules: Vec::new() }
}
/// Add an evaluation rule
pub fn add_rule(&mut self, rule: ActionRule) {
self.rules.push(rule);
}
/// Evaluate a signal against all rules and return matching actions
pub fn evaluate(&self, signal: &GraphSignal) -> ExecResult<Vec<ActionDefinition>> {
let mut actions = Vec::new();
let signal_type_str = signal.signal_type.to_string();
for rule in &self.rules {
if rule.signal_type != signal_type_str {
continue;
}
// Check confidence/threshold
if let Some(confidence) = signal.context.get("confidence") {
if let Some(score) = confidence.as_f64() {
if score < rule.threshold {
continue;
}
}
}
actions.push(rule.action.clone());
}
Ok(actions)
}
/// Evaluate multiple signals and return all matching actions
pub fn evaluate_batch(
&self,
signals: &[GraphSignal],
registry: &ToolRegistry,
) -> ExecResult<Vec<(GraphSignal, Vec<ActionDefinition>)>> {
let mut results = Vec::new();
for signal in signals {
if let Ok(actions) = self.evaluate(signal) {
if !actions.is_empty() {
results.push((signal.clone(), actions));
}
}
}
Ok(results)
}
}
/// Build default evaluation rules for common patterns
pub fn build_default_rules() -> SignalEvaluator {
let mut evaluator = SignalEvaluator::new();
// Rule: High-confidence contradictions → GitHub PR
evaluator.add_rule(ActionRule {
rule_id: "contradiction-pr".to_string(),
signal_type: "ContradictionDetected".to_string(),
threshold: 0.7,
action: ActionDefinition {
tool_name: "github".to_string(),
priority: Priority::High,
description: "File PR to update conflicting entity in knowledge base".to_string(),
auto_execute: true,
},
});
// Rule: High divergence narratives → Slack notification
evaluator.add_rule(ActionRule {
rule_id: "narrative-slack".to_string(),
signal_type: "NarrativeDivergence".to_string(),
threshold: 0.5,
action: ActionDefinition {
tool_name: "slack".to_string(),
priority: Priority::Medium,
description: "Notify team of narrative divergence in tracked domain".to_string(),
auto_execute: false,
},
});
// Rule: Trending entities → File system report
evaluator.add_rule(ActionRule {
rule_id: "trending-report".to_string(),
signal_type: "TrendingEntity".to_string(),
threshold: 0.0,
action: ActionDefinition {
tool_name: "filesystem".to_string(),
priority: Priority::Low,
description: "Write trending entity analysis to reports directory".to_string(),
auto_execute: true,
},
});
// Rule: New verified events → Discord announcement
evaluator.add_rule(ActionRule {
rule_id: "event-discord".to_string(),
signal_type: "NewEvent".to_string(),
threshold: 0.8,
action: ActionDefinition {
tool_name: "discord".to_string(),
priority: Priority::High,
description: "Announce new verified event to Discord channel".to_string(),
auto_execute: true,
},
});
evaluator
}
A few thoughts on the rule design:
auto_execute: falseis the safe default for new rules.- Thresholds are typed as
f64— never compare with==. - Rule IDs should be stable across releases; treat them like primary keys.
You can persist these rules to config.toml and reload at startup if you want them editable without recompiling. A simple format:
[[rules]]
rule_id = "contradiction-pr"
signal_type = "ContradictionDetected"
threshold = 0.7
tool_name = "github"
priority = "High"
auto_execute = true
description = "File PR to update conflicting entity in knowledge base"
Then in main.rs:
let rules_toml = std::fs::read_to_string("rules.toml")?;
let rules: Vec<ActionRule> = toml::from_str(&rules_toml)?;
let mut evaluator = SignalEvaluator::new();
for r in rules { evaluator.add_rule(r); }
This makes the agent's behavior data-driven rather than code-driven.
Step 6: The Tool Execution Layer
Now the hands. Each tool implements a common trait so the executor can dispatch to any tool uniformly.
src/tools/mod.rs:
pub mod github;
pub mod email;
pub mod slack;
pub mod discord;
pub mod filesystem;
use crate::error::ExecResult;
use serde_json::Value;
#[async_trait::async_trait]
pub trait ExecutableTool: Send + Sync {
async fn execute(&self, params: Value) -> ExecResult<ToolResult>;
fn validate(&self, params: &Value) -> ExecResult<()>;
fn name(&self) -> &str;
}
pub struct ToolResult {
pub success: bool,
pub output: String,
pub metadata: serde_json::Value,
}
The five tool implementations (github.rs, email.rs, slack.rs, discord.rs, filesystem.rs) follow the same trait implementation pattern. The full source is in the companion repository. Each tool:
- Validates required parameters.
- Calls the external API or system call.
- Returns a
ToolResultwith success status, human-readable output, and structured metadata. - Logs the action via
tracingfor observability.
The key invariants are:
- All required parameters are validated before any side effect.
- Every execution logs an
info!trace with the tool name, action, and a summary of the parameters. - External errors are wrapped in
ExecError::ToolErrorwith a{ tool, reason }payload.
Securing the Filesystem Tool
The FilesystemTool writes to self.base_dir. In production you must constrain the base directory with a canonical-path check to prevent path traversal:
fn safe_resolve(&self, rel: &str) -> ExecResult<PathBuf> {
let base = Path::new(&self.base_dir).canonicalize()?;
let candidate = base.join(rel);
let canonical = candidate.canonicalize().unwrap_or(candidate);
if !canonical.starts_with(&base) {
return Err(ExecError::ToolError { tool: "filesystem".into(), reason: format!("Path '{}' escapes base directory", rel) });
}
Ok(canonical)
}
Always invoke safe_resolve before opening a file. A signal that requests path: "../../etc/passwd" will be rejected.
Now the executor dispatcher:
src/agent/executor.rs:
use crate::agent::evaluator::ActionDefinition;
use crate::error::{ExecError, ExecResult};
use crate::tools::{ExecutableTool, ToolResult, github::GitHubTool, email::EmailTool, slack::SlackTool, discord::DiscordTool, filesystem::FilesystemTool};
use serde_json::Value;
use std::collections::HashMap;
pub struct ToolExecutor {
pub tools: HashMap<String, Box<dyn ExecutableTool>>,
}
impl ToolExecutor {
pub fn new(config: &crate::config::Config) -> ExecResult<Self> {
let mut executor = Self { tools: HashMap::new() };
for tool_name in &config.tools.enabled_tools {
match tool_name.as_str() {
"github" => { executor.tools.insert("github".into(), Box::new(GitHubTool { token: std::env::var("GITHUB_TOKEN").unwrap_or_default(), default_repo: std::env::var("DEFAULT_REPO").unwrap_or_default() })); },
"email" => { executor.tools.insert("email".into(), Box::new(EmailTool { smtp_host: std::env::var("SMTP_HOST").unwrap_or_default(), smtp_port: std::env::var("SMTP_PORT").ok().and_then(|p| p.parse().ok()).unwrap_or(587), username: std::env::var("SMTP_USERNAME").unwrap_or_default(), password: std::env::var("SMTP_PASSWORD").unwrap_or_default() })); },
"slack" => { executor.tools.insert("slack".into(), Box::new(SlackTool { webhook_url: std::env::var("SLACK_WEBHOOK_URL").unwrap_or_default() })); },
"discord" => { executor.tools.insert("discord".into(), Box::new(DiscordTool { webhook_url: std::env::var("DISCORD_WEBHOOK_URL").unwrap_or_default() })); },
"filesystem" => { executor.tools.insert("filesystem".into(), Box::new(FilesystemTool { base_dir: std::env::var("REPORTS_DIR").unwrap_or_else(|_| "./reports".to_string()) })); },
_ => tracing::warn!("Unknown tool in config: {}", tool_name),
}
}
Ok(executor)
}
pub async fn execute_action(&self, action: &ActionDefinition, params: Value) -> ExecResult<ToolResult> {
let tool = self.tools.get(action.tool_name.as_str()).ok_or_else(|| ExecError::ToolError { tool: action.tool_name.clone(), reason: "Tool not found in executor".into() })?;
tracing::info!("Executing tool '{}' with priority {:?}: {}", action.tool_name, action.priority, action.description);
if let Err(e) = tool.validate(¶ms) { tracing::warn!("Validation failed for {}: {}", action.tool_name, e); return Err(e); }
let result = tool.execute(params).await;
if let Ok(ref r) = result { tracing::info!("Tool '{}' succeeded: {}", action.tool_name, r.output); } else { tracing::error!("Tool '{}' failed: {:?}", action.tool_name, result.err()); }
result
}
pub async fn execute_batch(&self, actions: Vec<(ActionDefinition, Value)>, max_budget: usize) -> ExecResult<Vec<ToolResult>> {
let mut results = Vec::new();
let budget = actions.len().min(max_budget);
tracing::info!("Executing {} actions (budget: {})", budget, max_budget);
for (i, (action, params)) in actions.into_iter().enumerate() {
if i >= budget { tracing::info!("Action budget reached, skipping remaining"); break; }
match self.execute_action(&action, params).await {
Ok(r) => results.push(r),
Err(e) => tracing::error!("Failed to execute action {}: {}", i, e),
}
}
Ok(results)
}
}
Step 7: The Core Agent Runtime
This is the heart of the system — the poll→evaluate→execute loop. It runs continuously, querying the knowledge graph, evaluating signals against rules, and dispatching actions to tools.
src/agent/runtime.rs:
use crate::agent::evaluator::{SignalEvaluator, build_default_rules};
use crate::agent::executor::ToolExecutor;
use crate::agent::query::{GraphQueryBuilder, GraphSignal, SignalType};
use crate::config::Config;
use crate::discovery::tool_loader::ToolRegistry;
use crate::error::ExecResult;
use serde_json::json;
pub struct AgentRuntime {
config: Config,
query_builder: GraphQueryBuilder,
evaluator: SignalEvaluator,
executor: ToolExecutor,
registry: ToolRegistry,
}
impl AgentRuntime {
pub fn new(config: Config) -> ExecResult<Self> {
let query_builder = GraphQueryBuilder::new(config.clone());
let evaluator = build_default_rules();
let executor = ToolExecutor::new(&config)?;
let registry = ToolRegistry::load_from_directory(&config.agent.tool_discovery_path)?;
Ok(Self { config, query_builder, evaluator, executor, registry })
}
pub async fn run_cycle(&self) -> ExecResult<Vec<String>> {
tracing::info!("Starting execution cycle");
let mut outputs = Vec::new();
let signals = self.collect_signals().await?;
tracing::info!("Collected {} graph signals", signals.len());
if signals.is_empty() { tracing::info!("No signals detected this cycle"); return Ok(outputs); }
let evaluations = self.evaluator.evaluate_batch(&signals, &self.registry)?;
tracing::info!("{} signals matched evaluation rules", evaluations.len());
let actions: Vec<_> = evaluations.into_iter()
.flat_map(|(signal, actions)| actions.into_iter().map(move |action| {
let params = json!({"signal_type": signal.signal_type.to_string(), "entity": signal.entity, "context": signal.context});
(action, params)
}))
.collect();
let results = self.executor.execute_batch(actions, self.config.execution.max_action_budget_per_cycle).await?;
for r in results { outputs.push(r.output); }
tracing::info!("Execution cycle complete: {} actions executed", outputs.len());
Ok(outputs)
}
async fn collect_signals(&self) -> ExecResult<Vec<GraphSignal>> {
let mut signals = Vec::new();
if let Ok(result) = self.query_builder.execute_query(&self.query_builder.build_contradiction_query()).await {
if let Some(rows) = result.get("rows").and_then(|r| r.as_array()) {
for row in rows {
signals.push(GraphSignal { signal_type: SignalType::ContradictionDetected, entity: row.get("entity").and_then(|e| e.as_str()).unwrap_or("unknown").to_string(), context: row.clone() });
}
}
}
Ok(signals)
}
pub async fn run_forever(&self) -> ExecResult<()> {
tracing::info!("Starting agent runtime (poll interval: {}s)", self.config.execution.poll_interval_secs);
let interval = std::time::Duration::from_secs(self.config.execution.poll_interval_secs);
loop {
tracing::info!("--- Execution cycle starting ---");
match self.run_cycle().await {
Ok(outputs) => { for o in outputs { tracing::info!("Output: {}", o); } },
Err(e) => { tracing::error!("Cycle failed: {}", e); },
}
tracing::info!("--- Execution cycle complete, sleeping ---");
tokio::time::sleep(interval).await;
}
}
}
For graceful shutdown, wrap the loop in a tokio::select! against a shutdown signal.
Step 8: The Main Entry Point
src/main.rs:
mod agent;
mod config;
mod discovery;
mod error;
mod tools;
use objective05_exec::config::Config;
use objective05_exec::error::ExecResult;
use tracing_subscriber::EnvFilter;
#[tokio::main]
async fn main() -> ExecResult<()> {
tracing_subscriber::fmt()
.with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
.init();
let config_path = std::path::PathBuf::from("config.toml");
let config = Config::from_file(&config_path)?;
tracing::info!("Starting objective05-exec v{}", env!("CARGO_PKG_VERSION"));
let runtime = agent::runtime::AgentRuntime::new(config)?;
tracing::info!("Agent runtime initialized. Starting execution loop...");
runtime.run_forever().await?;
Ok(())
}
Also expose src/lib.rs:
pub mod agent;
pub mod config;
pub mod discovery;
pub mod error;
pub mod tools;
pub use config::Config;
pub use error::{ExecError, ExecResult};
Step 9: TOOLS.md Files
The capability discovery files live in the tools/ directory. The five files (github.tools.md, email.tools.md, slack.tools.md, discord.tools.md, filesystem.tools.md) follow the same three-section format:
## Description— what the tool does, in plain language## Parameters— name, type, required/optional, example## Trigger Conditions— natural-language rules describing when the agent should consider using this tool
The agent never compiles against the contents of these files at runtime — they exist as documentation for the operator and as input to future LLM-driven tool selectors.
Step 10: Building and Running
With everything in place:
cargo build --release
./target/release/objective05-exec
For development, run with RUST_LOG=objective05_exec=debug to see per-query and per-tool traces.
Environment Variables Reference
| Variable | Required For | Default | Notes |
|---|---|---|---|
GITHUB_TOKEN |
github tool |
none | PAT with repo and workflow scopes |
DEFAULT_REPO |
github tool |
none | org/name for default PR target |
SMTP_HOST |
email tool |
none | Hostname of SMTP relay |
SMTP_PORT |
email tool |
587 |
TLS SMTP port |
SMTP_USERNAME |
email tool |
none | Full email address |
SMTP_PASSWORD |
email tool |
none | App password recommended |
SLACK_WEBHOOK_URL |
slack tool |
none | Incoming webhook URL |
DISCORD_WEBHOOK_URL |
discord tool |
none | Channel webhook URL |
REPORTS_DIR |
filesystem tool |
./reports |
Base directory for writes |
RUST_LOG |
logging | info |
Standard tracing_subscriber filter |
CONFIG_PATH |
optional | config.toml |
Override config file path |
All variables are read at startup. There is no hot-reload — restart the agent to pick up new credentials.
The Kuzu Schema This Agent Expects
objective05-exec queries four node types and reads their temporal/relational properties. If you are building a graph from scratch (or pointing the agent at an existing one), the schema below covers everything the four default queries reference.
Node Tables
CREATE NODE TABLE Entity(
id STRING PRIMARY KEY,
name STRING,
category STRING,
mention_velocity DOUBLE,
last_seen TIMESTAMP
);
CREATE NODE TABLE Narrative(
id STRING PRIMARY KEY,
title STRING,
divergence_score DOUBLE,
tracked_entities STRING[],
updated_at TIMESTAMP
);
CREATE NODE TABLE Contradiction(
id STRING PRIMARY KEY,
claim_a STRING,
claim_b STRING,
entity STRING,
detected_at TIMESTAMP,
confidence DOUBLE
);
CREATE NODE TABLE Event(
id STRING PRIMARY KEY,
title STRING,
description STRING,
related_entities STRING[],
confidence DOUBLE,
verified BOOLEAN,
created_at TIMESTAMP
);
CREATE NODE TABLE Claim(
id STRING PRIMARY KEY,
text STRING,
entity STRING,
superseded_by STRING,
created_at TIMESTAMP
);
Seed Script
If you do not have an Objective05 instance running, this Kuzu script populates a minimal graph the agent can act on:
CREATE (e1:Entity {id: 'e1', name: 'Rust Foundation', category: 'org', mention_velocity: 12.4, last_seen: timestamp()});
CREATE (e2:Entity {id: 'e2', name: 'Local-first AI', category: 'topic', mention_velocity: 8.1, last_seen: timestamp()});
CREATE (n1:Narrative {id: 'n1', title: 'Edge LLM Adoption', divergence_score: 0.72, tracked_entities: ['e1', 'e2'], updated_at: timestamp()});
CREATE (c1:Contradiction {id: 'c1', claim_a: 'Edge LLMs reduce cost', claim_b: 'Edge LLMs increase engineering cost', entity: 'e2', detected_at: timestamp(), confidence: 0.83});
CREATE (ev1:Event {id: 'ev1', title: 'Rust 1.85 released', description: 'New async fn in traits stabilization', related_entities: ['e1'], confidence: 0.99, verified: true, created_at: timestamp()});
After seeding, run the agent and watch the filesystem report get written.
Testing the Agent
The MVP does not ship with a full test suite, but the architecture has obvious test seams. Start with three layers.
1. Unit Tests for Pure Logic
SignalEvaluator::evaluate and GraphSignal::matches are deterministic functions with no I/O. Cover them with table-driven tests:
#[cfg(test)]
mod tests {
use super::*;
use crate::agent::query::{GraphSignal, SignalType};
use serde_json::json;
#[test]
fn evaluator_threshold_blocks_low_confidence() {
let mut ev = SignalEvaluator::new();
ev.add_rule(ActionRule {
rule_id: "test".into(),
signal_type: "ContradictionDetected".into(),
threshold: 0.8,
action: ActionDefinition { tool_name: "github".into(), priority: Priority::High, description: "test".into(), auto_execute: true },
});
let high = GraphSignal { signal_type: SignalType::ContradictionDetected, entity: "x".into(), context: json!({"confidence": 0.9}) };
let low = GraphSignal { signal_type: SignalType::ContradictionDetected, entity: "x".into(), context: json!({"confidence": 0.5}) };
assert_eq!(ev.evaluate(&high).unwrap().len(), 1);
assert_eq!(ev.evaluate(&low).unwrap().len(), 0);
}
}
2. Integration Tests with Mocked Tools
Implement a MockTool that records calls into a Vec<RecordedCall> and assert on it after a cycle. This validates the executor dispatch without touching real APIs.
pub struct MockTool { pub calls: Arc<Mutex<Vec<serde_json::Value>>> }
#[async_trait::async_trait]
impl ExecutableTool for MockTool {
async fn execute(&self, params: Value) -> ExecResult<ToolResult> {
self.calls.lock().await.push(params);
Ok(ToolResult { success: true, output: "ok".into(), metadata: json!({}) })
}
fn validate(&self, _params: &Value) -> ExecResult<()> { Ok(()) }
fn name(&self) -> &str { "mock" }
}
3. End-to-End Smoke Test
The seed script plus a filesystem tool with REPORTS_DIR=./smoke-out gives you a complete E2E test. After one cycle, assert that ./smoke-out/trending_*.md exists and contains a markdown header.
Deployment Patterns
Bare-Metal / Home Server
The simplest deployment. cargo build --release on the host, then run the binary under tmux or systemd. Set RUST_LOG=info and let it poll every 60 seconds.
Docker
FROM rust:1.78 as builder
WORKDIR /app
COPY . .
RUN cargo build --release
FROM debian:bookworm-slim
RUN apt-get update && apt-get install -y libkuzu1.4 ca-certificates && rm -rf /var/lib/apt/lists/*
COPY --from=builder /app/target/release/objective05-exec /usr/local/bin/
COPY config.toml /etc/objective05-exec/
COPY tools /etc/objective05-exec/tools/
WORKDIR /etc/objective05-exec
CMD ["/usr/local/bin/objective05-exec"]
Build and run:
docker build -t objective05-exec .
docker run -d --restart unless-stopped -v /path/to/objective05-graph:/data:ro -v /path/to/reports:/reports --env-file .env objective05-exec
Kubernetes
Deploy as a Deployment with a single replica (the runtime is not designed to be horizontally scaled without coordination on the graph database). Mount the Kuzu graph as a ReadOnlyMany PVC, mount the reports directory as ReadWriteOnce, and pass credentials via Secret. The run_forever loop can be paired with a livenessProbe that hits a /healthz endpoint (add a tiny axum server to main.rs if you want this).
GitHub Actions CI
The integration test layer runs entirely offline, so it can live in a normal GitHub Actions job without any external services. Cache ~/.cargo and target/ between runs to keep CI under 5 minutes.
Integration with OpenClaw
One of the most powerful aspects of this architecture is its compatibility with the OpenClaw ecosystem. Because we use the same TOOLS.md/SKILLS.md discovery pattern, your objective05-exec runtime can:
- Share tool definitions with OpenClaw agents — one set of capability files, multiple agents
- Receive commands from OpenClaw gateways — the agent can be triggered remotely via WebSocket
- Execute OpenClaw tools — any tool that follows the OpenClaw specification works with our executor
- Feed results back — tool execution results can be persisted to the Kuzu graph as observations
To integrate, run OpenClaw as a sidecar process and have it forward incoming chat messages to objective05-exec over a Unix socket. The executor's signal-evaluation step already filters by auto_execute, so OpenClaw-originated signals can be marked for human approval by default.
Troubleshooting
The agent starts but logs no signals
Open the Kuzu CLI and run MATCH (n) RETURN count(n). If the graph is empty, the seed script (above) will populate it. If the graph has rows but your queries return nothing, check the time window — the default queries use 6–48 hour windows, and a fresh graph won't have any data inside those windows.
Validation failed for filesystem: Path 'foo' escapes base directory
safe_resolve is doing its job. Either use a relative path that stays under REPORTS_DIR or update the rule to skip the offending path.
Tool 'githb' not found in executor
Typo in config.toml's enabled_tools array. The agent logs a warn! for unknown tools but does not crash — fix the typo and restart.
Kuzu link errors on macOS
brew install kuzu then export KUZU_INCLUDE_PATH=/opt/homebrew/include and export LIBRARY_PATH=/opt/homebrew/lib before cargo build. On Apple Silicon the path is /opt/homebrew/; on Intel it is /usr/local/.
Cycle fails with Query timeout
Increase query_timeout_ms in config.toml. The Kuzu FFI default is 5 seconds; on a slow disk or a graph over 10M nodes, you may need 15–30 seconds.
Slack/Discord webhook returns 404
The webhook URL is invalid or has been revoked. Generate a new one and update the env var.
Agent uses 100% CPU
You are probably hitting the graph on every cycle. Lower the signal ceiling by raising the threshold values in your rules, or by reducing poll_interval_secs.
Beyond the MVP
The current implementation covers the core loop: poll→evaluate→execute with five tool integrations. Here is what is next.
Autonomous Drafting
Instead of just executing predefined actions, the agent can draft PRs or documents by querying the graph for relevant context, assembling a response using the detected entities and claims, and submitting it for human review. The draft becomes an observation in the graph — if accepted, it updates the knowledge state. If rejected, it feeds back into the contradiction detection system.
Calendar-Aware Scheduling
The agent can check calendar availability before scheduling meetings, sending reports, or triggering batch executions. This adds temporal intelligence beyond the knowledge graph — the agent understands not just what is true, but when to act on it.
Multi-Agent Routing
Multiple objective05-exec instances could run in parallel, each specialized for a different domain (GitHub monitoring, ArXiv tracking, news analysis). A master router evaluates signals and dispatches to the appropriate specialist agent. This mirrors the multi-agent routing in OpenClaw but with deeper domain specialization.
Mobile Push Notifications
For high-priority signals — critical contradictions, breaking events, trending entities — the agent can push notifications to mobile devices via APNs/FCM. This bridges the gap between background intelligence and real-time awareness.
Outcome Feedback Loops
Every tool execution produces an outcome. Those outcomes — success, failure, user acceptance — feed back into the knowledge graph as new observations. A PR that gets merged confirms a hypothesis. An email that gets opened confirms relevance. A Slack message that gets replied to confirms urgency. Over time, the agent's evaluation rules self-tune based on historical outcome data.
Expression-Based Triggers
Replace the substring match in ToolRegistry::match_triggers with a real expression evaluator (e.g., cel-rust or rhai). Rules can then express complex conditions like confidence > 0.7 AND entity in ['rust', 'go'] without rewriting the discovery parser.
Why This Matters
The current AI landscape is dominated by two approaches: cloud agents that are always-on but shallow, and local models that are deep but passive. objective05-exec sits in the middle. It's local-first — runs on consumer hardware, no API costs, no vendor lock-in. But unlike a local chatbot, it has hands. It can file PRs, send emails, write reports, post to Slack.
The key insight is that tool execution does not require the cloud. You do not need a $200/month agent subscription to have an AI that drafts PRs and sends emails. You need:
- A knowledge graph to provide context
- An evaluation engine to decide what to do
- A tool layer to execute actions
- A discovery system to add new tools
All of this runs locally. The only cloud dependency is the tools themselves — GitHub's API, your SMTP server, Slack's webhooks. The intelligence, the memory, the reasoning — all local.
This is sovereign AI execution. Not because it is offline. But because it is yours.
Conclusion
objective05-exec is the bridge between understanding and action. It takes everything the knowledge graph has learned — contradictions, divergences, trends, events — and turns them into real-world outcomes: PRs filed, emails sent, reports written, channels notified.
It's built on the same principles that drove Objective05: local-first, persistent state, tool-agnostic, and open source. It does not need a cloud subscription. It does not forget what it learned yesterday. And it does not wait to be asked.
The brain is ready. The hands are ready. What it does next is up to what the graph tells it.
This post is part of the ongoing documentation for Objective05 — the perpetual local intelligence system. Previous posts: The Model Is Not the Product: On Building Persistent Intelligence Infrastructure, objective03: A Local News Agency, Mastering llama.cpp: Local LLM Integration.