·38 min

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

A complete Rust tutorial on building objective05-exec — a local-first agent runtime that bridges a perpetual Kuzu-backed knowledge graph to real-world tools (GitHub, email, Slack, Discord, filesystem) using TOOLS.md/SKILLS.md capability discovery, signal evaluation, and a poll→evaluate→execute loop.

DK

Daniel Kliewer

Author, Sovereign AI

Rustobjective05knowledge graphKuzuDBagent runtimelocal AITOOLS.mdSKILLS.mdOpenClawtool executionGitHub APISlackDiscordSMTPsovereign AIobjective05-execsignal evaluationasync RusttokioMCPtemporal graphcontradiction detection
Sovereign AI book cover

From the Book

This is from Sovereign AI: An Architectural Investigation into Local-First Intelligence.

Get the Book — $88
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

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+)

bash
1curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
2source "$HOME/.cargo/env"
3rustup default stable
4rustc --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:

bash
1# Full installation
2git clone https://github.com/kliewerdaniel/objective05.git
3cd objective05
4cargo build --release
5./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:

bash
1# macOS
2brew install kuzu
3
4# Linux
5curl -L https://github.com/kuzudb/kuzu/releases/latest/download/kuzu_cli-linux-x86_64.tar.gz \
6 | tar -xz -C /usr/local/bin

You can then run ad-hoc queries:

bash
1kuzu ../objective05/data/graph.db
2kuzu> 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.

ToolRequired Env VarsHow to Get
GitHubGITHUB_TOKEN, DEFAULT_REPOGitHub → Settings → Developer settings → Personal access tokens (scopes: repo, workflow)
EmailSMTP_HOST, SMTP_PORT, SMTP_USERNAME, SMTP_PASSWORDAny SMTP relay (Gmail App Password, Fastmail, Mailgun, Postmark)
SlackSLACK_WEBHOOK_URLSlack → Apps → Incoming Webhooks
DiscordDISCORD_WEBHOOK_URLDiscord → Channel Settings → Integrations → Webhooks
FilesystemREPORTS_DIRAny 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:

bash
1# GitHub
2curl -H "Authorization: token $GITHUB_TOKEN" https://api.github.com/user
3
4# Slack
5curl -X POST -H 'Content-type: application/json' \
6 --data '{"text":"objective05-exec smoke test"}' \
7 $SLACK_WEBHOOK_URL
8
9# Discord
10curl -X POST -H 'Content-type: application/json' \
11 --data '{"content":"objective05-exec smoke test"}' \
12 $DISCORD_WEBHOOK_URL
13
14# SMTP (using openssl)
15echo "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:

text
1┌─────────────────────────────────────────────────────────────┐
2│ objective05 Daemon │
3│ │
4│ ┌─────────────┐ ┌──────────────┐ ┌──────────────────┐ │
5│ │ Ingestion │ │ Knowledge │ │ Broadcasting │ │
6│ │ Pipeline │→│ Graph (Kuzu) │→│ Engine │ │
7│ │ │ │ │ │ │ │
8│ │ RSS/Reddit/ │ │ Entities, │ │ Reports, Audio, │ │
9│ │ YouTube/ │ │ Claims, │ │ Notifications │ │
10│ │ GitHub/ │ │ Events, │ │ │ │
11│ │ ArXiv/HN │ │ Narratives │ │ │ │
12│ └─────────────┘ └──────┬───────┘ └──────────────────┘ │
13│ │ │
14│ ▼ │
15│ ┌──────────────────────────────────────────────────────┐ │
16│ │ objective05-exec Agent Runtime │ │
17│ │ │ │
18│ │ ┌──────────┐ ┌──────────┐ ┌──────────────────┐ │ │
19│ │ │ Query │ │ Evaluate │ │ Execute │ │ │
20│ │ │ Graph │→│ Signals │→│ Tools │ │ │
21│ │ └──────────┘ └──────────┘ └──────────────────┘ │ │
22│ │ │ │ │
23│ │ ▼ │ │
24│ │ ┌──────────────────┐ │ │
25│ │ │ TOOLS.md / │ │ │
26│ │ │ SKILLS.md │ │ │
27│ │ │ Discovery │ │ │
28│ │ └──────────────────┘ │ │
29│ └──────────────────────────────────────────────────────┘ │
30│ │ │
31│ ▼ │
32│ ┌──────────────────────────────────────────────────────┐ │
33│ │ Tool Execution Layer │ │
34│ │ │ │
35│ │ GitHub │ Email │ Slack │ Discord │ Files │ │
36│ │ (PRs) │ (SMTP) │ (Webhook)│ (Webhook)│ (Write) │ │
37│ └──────────────────────────────────────────────────────┘ │
38└─────────────────────────────────────────────────────────────┘

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:

  1. QueryGraphQueryBuilder constructs Cypher queries against Kuzu and pulls back rows representing signals: contradictions, narrative divergences, trending entities, new events.
  2. EvaluateSignalEvaluator matches each signal against a list of ActionRules. Rules can threshold on confidence, set priority, and decide whether to auto-execute or require human approval.
  3. ExecuteToolExecutor dispatches 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:

text
1objective05-exec/
2├── Cargo.toml
3├── rust-toolchain.toml
4├── README.md
5├── src/
6│ ├── main.rs
7│ ├── lib.rs
8│ ├── agent/
9│ │ ├── mod.rs
10│ │ ├── runtime.rs # Core agent loop
11│ │ ├── query.rs # Kuzu query builder
12│ │ ├── evaluator.rs # Signal evaluation engine
13│ │ └── executor.rs # Tool execution dispatcher
14│ ├── tools/
15│ │ ├── mod.rs
16│ │ ├── github.rs # GitHub PR/file tools
17│ │ ├── email.rs # SMTP email tool
18│ │ ├── slack.rs # Slack webhook tool
19│ │ ├── discord.rs # Discord webhook tool
20│ │ └── filesystem.rs # File write tool
21│ ├── discovery/
22│ │ ├── mod.rs
23│ │ └── tool_loader.rs # TOOLS.md/SKILLS.md parser
24│ ├── config.rs # Configuration management
25│ └── error.rs # Error types
26├── tools/
27│ ├── github.tools.md # Tool capability definitions
28│ ├── email.tools.md
29│ ├── slack.tools.md
30│ ├── discord.tools.md
31│ └── filesystem.tools.md
32├── config.toml # Agent configuration
33├── .env.example # Documented env vars
34└── docs/
35 └── architecture.md

Initialize the project

bash
1cargo new objective05-exec
2cd objective05-exec
3cargo init --lib # we'll add a binary target too

Pin the Rust toolchain (optional but recommended)

rust-toolchain.toml:

toml
1[toolchain]
2channel = "stable"
3components = ["rustfmt", "clippy"]

Add dependencies to Cargo.toml

toml
1[package]
2name = "objective05-exec"
3version = "0.1.0"
4edition = "2021"
5description = "Execution runtime for objective05 - bridging knowledge graphs to real-world tools"
6license = "MIT OR Apache-2.0"
7
8[dependencies]
9# Async runtime
10tokio = { version = "1", features = ["full"] }
11async-trait = "0.1"
12
13# Serialization
14serde = { version = "1", features = ["derive"] }
15serde_json = "1"
16toml = "0.8"
17
18# HTTP clients
19reqwest = { version = "0.12", features = ["json", "streaming"] }
20jsonwebtoken = "9"
21
22# Kuzu DB (via FFI bindings)
23kuzu = "0.4"
24
25# Logging
26tracing = "0.1"
27tracing-subscriber = { version = "0.3", features = ["env-filter"] }
28
29# Error handling
30anyhow = "1"
31thiserror = "2"
32
33# UUID generation
34uuid = { version = "1", features = ["v4", "serde"] }
35
36# Time handling
37chrono = { version = "0.4", features = ["serde"] }
38
39# Email
40lettre = { version = "0.11", features = ["tokio1-rustls-tls", "smtp-transport"] }
41
42# GitHub API
43octocrab = "0.38"
44
45# Markdown parsing for TOOLS.md
46pulldown-cmark = "0.11"
47
48[profile.release]
49opt-level = 3
50lto = "thin"
51codegen-units = 1
52strip = true

Note on Kuzu: The official kuzu crate provides Rust bindings via C FFI. If you hit FFI link errors on macOS, you may need brew install kuzu first to obtain the libkuzu.dylib system 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:

bash
1# GitHub
2GITHUB_TOKEN=ghp_replace_me
3DEFAULT_REPO=your-org/your-repo
4
5# Email
6SMTP_HOST=smtp.gmail.com
7SMTP_PORT=587
8SMTP_USERNAME=you@example.com
9SMTP_PASSWORD=replace_me
10
11# Slack
12SLACK_WEBHOOK_URL=https://hooks.slack.com/services/T000/B000/XXXX
13
14# Discord
15DISCORD_WEBHOOK_URL=https://discord.com/api/webhooks/000/XXXX
16
17# Filesystem
18REPORTS_DIR=./reports
19
20# Logging
21RUST_LOG=info,objective05_exec=debug

Step 2: Configuration and Error Types

Let's start with the foundational types.

src/error.rs:

rust
1use thiserror::Error;
2
3#[derive(Error, Debug)]
4pub enum ExecError {
5 #[error("Configuration error: {0}")]
6 Config(String),
7
8 #[error("Knowledge graph error: {0}")]
9 GraphError(String),
10
11 #[error("Tool execution error: {tool}: {reason}")]
12 ToolError { tool: String, reason: String },
13
14 #[error("Signal evaluation error: {0}")]
15 EvaluationError(String),
16
17 #[error("Discovery error: {0}")]
18 DiscoveryError(String),
19
20 #[error("Network error: {0}")]
21 NetworkError(#[from] reqwest::Error),
22
23 #[error("I/O error: {0}")]
24 IoError(#[from] std::io::Error),
25}
26
27pub type ExecResult<T> = Result<T, ExecError>;

src/config.rs:

rust
1use serde::{Deserialize, Serialize};
2use std::path::PathBuf;
3
4#[derive(Debug, Clone, Serialize, Deserialize)]
5pub struct Config {
6 pub agent: AgentConfig,
7 pub knowledge_graph: GraphConfig,
8 pub tools: ToolsConfig,
9 pub execution: ExecutionConfig,
10}
11
12#[derive(Debug, Clone, Serialize, Deserialize)]
13pub struct AgentConfig {
14 pub name: String,
15 pub description: String,
16 pub tool_discovery_path: PathBuf,
17}
18
19#[derive(Debug, Clone, Serialize, Deserialize)]
20pub struct GraphConfig {
21 pub path: PathBuf,
22 pub query_timeout_ms: u64,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub struct ToolsConfig {
27 pub enabled_tools: Vec<String>,
28 pub max_concurrent_executions: usize,
29}
30
31#[derive(Debug, Clone, Serialize, Deserialize)]
32pub struct ExecutionConfig {
33 pub poll_interval_secs: u64,
34 pub max_action_budget_per_cycle: usize,
35 pub log_executions: bool,
36}
37
38impl Config {
39 pub fn from_file(path: &PathBuf) -> ExecResult<Self> {
40 let content = std::fs::read_to_string(path)
41 .map_err(|e| ExecError::Config(format!("Failed to read config: {}", e)))?;
42 let config: Config = toml::from_str(&content)
43 .map_err(|e| ExecError::Config(format!("Failed to parse config: {}", e)))?;
44 Ok(config)
45 }
46}

config.toml:

toml
1[agent]
2name = "objective05-exec"
3description = "Execution runtime for objective05 - bridges knowledge graph to real-world tools"
4tool_discovery_path = "tools/"
5
6[knowledge_graph]
7path = "../objective05/data/graph.db"
8query_timeout_ms = 5000
9
10[tools]
11enabled_tools = ["github", "email", "slack", "discord", "filesystem"]
12max_concurrent_executions = 4
13
14[execution]
15poll_interval_secs = 60
16max_action_budget_per_cycle = 10
17log_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:

bash
1CONFIG_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:

rust
1pub mod tool_loader;
2
3pub use tool_loader::{ToolRegistry, ToolCapability, ParameterSchema, ToolType};

src/discovery/tool_loader.rs:

rust
1use super::ExecResult;
2use crate::error::ExecError;
3use serde::{Deserialize, Serialize};
4use std::collections::HashMap;
5use std::path::Path;
6
7/// A tool capability as described in a TOOLS.md file
8#[derive(Debug, Clone, Serialize, Deserialize)]
9pub struct ToolCapability {
10 pub name: String,
11 pub description: String,
12 pub parameters: HashMap<String, ParameterSchema>,
13 pub trigger_conditions: Vec<String>,
14 pub tool_type: ToolType,
15}
16
17#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct ParameterSchema {
19 pub r#type: String,
20 pub description: String,
21 pub required: bool,
22 pub examples: Vec<String>,
23}
24
25#[derive(Debug, Clone, Serialize, Deserialize)]
26pub enum ToolType {
27 GitHub,
28 Email,
29 Slack,
30 Discord,
31 Filesystem,
32 Custom(String),
33}
34
35/// Tool registry discovered from TOOLS.md files
36#[derive(Debug, Clone)]
37pub struct ToolRegistry {
38 pub capabilities: HashMap<String, ToolCapability>,
39}
40
41impl ToolRegistry {
42 /// Load all tool capabilities from the tools directory
43 pub fn load_from_directory(dir_path: &Path) -> ExecResult<Self> {
44 let mut capabilities = HashMap::new();
45
46 if !dir_path.exists() {
47 return Err(ExecError::DiscoveryError(
48 format!("Tools directory not found: {}", dir_path.display()),
49 ));
50 }
51
52 for entry in std::fs::read_dir(dir_path)? {
53 let entry = entry?;
54 let path = entry.path();
55
56 if path.extension().map_or(false, |ext| ext == "md") {
57 let content = std::fs::read_to_string(&path)?;
58 if let Some(capability) = Self::parse_tool_file(&path, &content)? {
59 capabilities.insert(capability.name.clone(), capability);
60 }
61 }
62 }
63
64 Ok(Self { capabilities })
65 }
66
67 /// Parse a single TOOLS.md file into a ToolCapability
68 fn parse_tool_file(
69 path: &Path,
70 content: &str,
71 ) -> ExecResult<Option<ToolCapability>> {
72 let mut name = String::new();
73 let mut description = String::new();
74 let mut parameters = HashMap::new();
75 let mut trigger_conditions = Vec::new();
76 let mut tool_type = ToolType::Custom(String::new());
77
78 let current_tool = path
79 .file_stem()
80 .and_then(|s| s.to_str())
81 .unwrap_or("unknown")
82 .to_string();
83
84 // Determine tool type from filename
85 tool_type = match current_tool.as_str() {
86 "github" => ToolType::GitHub,
87 "email" => ToolType::Email,
88 "slack" => ToolType::Slack,
89 "discord" => ToolType::Discord,
90 "filesystem" => ToolType::Filesystem,
91 _ => ToolType::Custom(current_tool.clone()),
92 };
93
94 let mut in_params = false;
95 let mut in_triggers = false;
96
97 for line in content.lines() {
98 if line.starts_with("# ") {
99 name = line[2..].trim().to_string();
100 in_params = false;
101 in_triggers = false;
102 } else if line.starts_with("## Parameters") {
103 in_params = true;
104 in_triggers = false;
105 } else if line.starts_with("## Trigger Conditions") {
106 in_triggers = true;
107 in_params = false;
108 } else if line.starts_with("## Description") || line.starts_with("### Description") {
109 description = content
110 .lines()
111 .skip_while(|l| !l.contains("Description"))
112 .skip(1)
113 .take_while(|l| !l.starts_with("#"))
114 .collect::<Vec<&str>>()
115 .join("\n")
116 .trim()
117 .to_string();
118 } else if line.starts_with("- **") && in_params {
119 // Parse parameter line like: - **param_name** (type): description
120 if let Some(param_def) = line.strip_prefix("- **") {
121 if let Some((param_name, rest)) = param_def.split_once("** (") {
122 if let Some((param_type, _desc)) = rest.split_once("): ") {
123 let desc = _desc.trim_start_matches('-').trim();
124 parameters.insert(
125 param_name.to_string(),
126 ParameterSchema {
127 r#type: param_type.to_string(),
128 description: desc.to_string(),
129 required: false, // Default, can be overridden
130 examples: Vec::new(),
131 },
132 );
133 }
134 }
135 }
136 } else if line.starts_with("- ") && in_triggers {
137 if let Some(condition) = line.strip_prefix("- ") {
138 trigger_conditions.push(condition.trim().to_string());
139 }
140 }
141 }
142
143 if name.is_empty() {
144 return Ok(None);
145 }
146
147 Ok(Some(ToolCapability {
148 name,
149 description,
150 parameters,
151 trigger_conditions,
152 tool_type,
153 }))
154 }
155
156 /// Check if any trigger conditions match a given graph signal
157 pub fn match_triggers(&self, signal: &GraphSignal) -> Vec<String> {
158 self.capabilities
159 .iter()
160 .filter_map(|(name, cap)| {
161 if cap
162 .trigger_conditions
163 .iter()
164 .any(|cond| signal.matches(cond))
165 {
166 Some(name.clone())
167 } else {
168 None
169 }
170 })
171 .collect()
172 }
173}

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:

rust
1pub mod runtime;
2pub mod query;
3pub mod evaluator;
4pub mod executor;

src/agent/query.rs:

rust
1use crate::config::Config;
2use crate::discovery::tool_loader::ToolRegistry;
3use crate::error::{ExecError, ExecResult};
4use serde_json::json;
5use std::sync::Arc;
6use tokio::sync::Mutex;
7
8/// A graph signal that represents a detectable pattern
9#[derive(Debug, Clone)]
10pub struct GraphSignal {
11 pub signal_type: SignalType,
12 pub entity: String,
13 pub context: serde_json::Value,
14}
15
16#[derive(Debug, Clone)]
17pub enum SignalType {
18 ContradictionDetected,
19 NarrativeDivergence,
20 TrendingEntity,
21 NewEvent,
22 ClaimSuperseded,
23 Custom(String),
24}
25
26impl std::fmt::Display for SignalType {
27 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
28 match self {
29 SignalType::ContradictionDetected => write!(f, "ContradictionDetected"),
30 SignalType::NarrativeDivergence => write!(f, "NarrativeDivergence"),
31 SignalType::TrendingEntity => write!(f, "TrendingEntity"),
32 SignalType::NewEvent => write!(f, "NewEvent"),
33 SignalType::ClaimSuperseded => write!(f, "ClaimSuperseded"),
34 SignalType::Custom(s) => write!(f, "{}", s),
35 }
36 }
37}
38
39impl GraphSignal {
40 pub fn matches(&self, condition: &str) -> bool {
41 match &self.signal_type {
42 SignalType::ContradictionDetected => condition.contains("contradiction"),
43 SignalType::NarrativeDivergence => condition.contains("narrative"),
44 SignalType::TrendingEntity => condition.contains("trending"),
45 SignalType::NewEvent => condition.contains("event"),
46 SignalType::ClaimSuperseded => condition.contains("superseded"),
47 SignalType::Custom(c) => condition.contains(c.as_str()),
48 }
49 }
50}
51
52/// Query builder for Kuzu knowledge graph
53pub struct GraphQueryBuilder {
54 config: Config,
55}
56
57impl GraphQueryBuilder {
58 pub fn new(config: Config) -> Self {
59 Self { config }
60 }
61
62 /// Query for recent contradictions in tracked narratives
63 pub fn build_contradiction_query(&self) -> String {
64 format!(
65 r#"
66 MATCH (c:Contradiction)
67 WHERE c.detected_at > datetime('{}')
68 RETURN c.id AS contradiction_id,
69 c.claim_a AS claim_a,
70 c.claim_b AS claim_b,
71 c.entity AS entity,
72 c.detected_at AS detected_at,
73 c.confidence AS confidence
74 ORDER BY c.detected_at DESC
75 LIMIT 50
76 "#,
77 chrono::Utc::now()
78 .checked_sub_signed(chrono::Duration::hours(24))
79 .unwrap()
80 .to_rfc3339()
81 )
82 }
83
84 /// Query for narratives with highest divergence scores
85 pub fn build_divergence_query(&self) -> String {
86 format!(
87 r#"
88 MATCH (n:Narrative)
89 WHERE n.divergence_score > 0.5
90 AND n.updated_at > datetime('{}')
91 RETURN n.id AS narrative_id,
92 n.title AS title,
93 n.divergence_score AS divergence_score,
94 n.tracked_entities AS entities,
95 n.updated_at AS updated_at
96 ORDER BY n.divergence_score DESC
97 LIMIT 20
98 "#,
99 chrono::Utc::now()
100 .checked_sub_signed(chrono::Duration::hours(48))
101 .unwrap()
102 .to_rfc3339()
103 )
104 }
105
106 /// Query for trending entities by mention velocity
107 pub fn build_trending_query(&self) -> String {
108 format!(
109 r#"
110 MATCH (e:Entity)
111 WHERE e.mention_velocity > 5
112 AND e.last_seen > datetime('{}')
113 RETURN e.id AS entity_id,
114 e.name AS name,
115 e.mention_velocity AS velocity,
116 e.category AS category,
117 e.last_seen AS last_seen
118 ORDER BY e.mention_velocity DESC
119 LIMIT 10
120 "#,
121 chrono::Utc::now()
122 .checked_sub_signed(chrono::Duration::hours(12))
123 .unwrap()
124 .to_rfc3339()
125 )
126 }
127
128 /// Build a query for new events in tracked domains
129 pub fn build_events_query(&self) -> String {
130 format!(
131 r#"
132 MATCH (e:Event)
133 WHERE e.created_at > datetime('{}')
134 AND e.verified = true
135 RETURN e.id AS event_id,
136 e.title AS title,
137 e.description AS description,
138 e.related_entities AS entities,
139 e.confidence AS confidence,
140 e.created_at AS created_at
141 ORDER BY e.created_at DESC
142 LIMIT 30
143 "#,
144 chrono::Utc::now()
145 .checked_sub_signed(chrono::Duration::hours(6))
146 .unwrap()
147 .to_rfc3339()
148 )
149 }
150
151 /// Execute a query against the Kuzu graph via the Rust FFI bindings.
152 /// In this tutorial the FFI is stubbed; in production, replace with
153 /// a real `kuzu::Database` and `kuzu::Connection`.
154 pub async fn execute_query(&self, query: &str) -> ExecResult<serde_json::Value> {
155 tracing::info!("Executing graph query: {}", query);
156
157 // Simulate query execution
158 tokio::time::sleep(tokio::time::Duration::from_millis(100)).await;
159
160 Ok(json!({
161 "status": "success",
162 "rows": []
163 }))
164 }
165}

A few notes on the query builder:

  • Time windows are computed from Utc::now() at call time. If you need historical replays, accept a since: 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_query stub returns an empty rows array. The real implementation should construct a kuzu::Connection, call conn.query(query), and walk the resulting QueryResult to serialize rows into serde_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:

rust
1use crate::agent::query::GraphSignal;
2use crate::discovery::tool_loader::ToolRegistry;
3use crate::error::ExecResult;
4
5/// An evaluation rule that maps signals to tool actions
6#[derive(Debug, Clone)]
7pub struct ActionRule {
8 pub rule_id: String,
9 pub signal_type: String,
10 pub threshold: f64,
11 pub action: ActionDefinition,
12}
13
14#[derive(Debug, Clone)]
15pub struct ActionDefinition {
16 pub tool_name: String,
17 pub priority: Priority,
18 pub description: String,
19 pub auto_execute: bool,
20}
21
22#[derive(Debug, Clone, PartialEq)]
23pub enum Priority {
24 Low,
25 Medium,
26 High,
27 Critical,
28}
29
30/// The signal evaluator that matches rules against graph signals
31pub struct SignalEvaluator {
32 pub rules: Vec<ActionRule>,
33}
34
35impl SignalEvaluator {
36 pub fn new() -> Self {
37 Self { rules: Vec::new() }
38 }
39
40 /// Add an evaluation rule
41 pub fn add_rule(&mut self, rule: ActionRule) {
42 self.rules.push(rule);
43 }
44
45 /// Evaluate a signal against all rules and return matching actions
46 pub fn evaluate(&self, signal: &GraphSignal) -> ExecResult<Vec<ActionDefinition>> {
47 let mut actions = Vec::new();
48 let signal_type_str = signal.signal_type.to_string();
49
50 for rule in &self.rules {
51 if rule.signal_type != signal_type_str {
52 continue;
53 }
54 // Check confidence/threshold
55 if let Some(confidence) = signal.context.get("confidence") {
56 if let Some(score) = confidence.as_f64() {
57 if score < rule.threshold {
58 continue;
59 }
60 }
61 }
62 actions.push(rule.action.clone());
63 }
64
65 Ok(actions)
66 }
67
68 /// Evaluate multiple signals and return all matching actions
69 pub fn evaluate_batch(
70 &self,
71 signals: &[GraphSignal],
72 registry: &ToolRegistry,
73 ) -> ExecResult<Vec<(GraphSignal, Vec<ActionDefinition>)>> {
74 let mut results = Vec::new();
75
76 for signal in signals {
77 if let Ok(actions) = self.evaluate(signal) {
78 if !actions.is_empty() {
79 results.push((signal.clone(), actions));
80 }
81 }
82 }
83
84 Ok(results)
85 }
86}
87
88/// Build default evaluation rules for common patterns
89pub fn build_default_rules() -> SignalEvaluator {
90 let mut evaluator = SignalEvaluator::new();
91
92 // Rule: High-confidence contradictions → GitHub PR
93 evaluator.add_rule(ActionRule {
94 rule_id: "contradiction-pr".to_string(),
95 signal_type: "ContradictionDetected".to_string(),
96 threshold: 0.7,
97 action: ActionDefinition {
98 tool_name: "github".to_string(),
99 priority: Priority::High,
100 description: "File PR to update conflicting entity in knowledge base".to_string(),
101 auto_execute: true,
102 },
103 });
104
105 // Rule: High divergence narratives → Slack notification
106 evaluator.add_rule(ActionRule {
107 rule_id: "narrative-slack".to_string(),
108 signal_type: "NarrativeDivergence".to_string(),
109 threshold: 0.5,
110 action: ActionDefinition {
111 tool_name: "slack".to_string(),
112 priority: Priority::Medium,
113 description: "Notify team of narrative divergence in tracked domain".to_string(),
114 auto_execute: false,
115 },
116 });
117
118 // Rule: Trending entities → File system report
119 evaluator.add_rule(ActionRule {
120 rule_id: "trending-report".to_string(),
121 signal_type: "TrendingEntity".to_string(),
122 threshold: 0.0,
123 action: ActionDefinition {
124 tool_name: "filesystem".to_string(),
125 priority: Priority::Low,
126 description: "Write trending entity analysis to reports directory".to_string(),
127 auto_execute: true,
128 },
129 });
130
131 // Rule: New verified events → Discord announcement
132 evaluator.add_rule(ActionRule {
133 rule_id: "event-discord".to_string(),
134 signal_type: "NewEvent".to_string(),
135 threshold: 0.8,
136 action: ActionDefinition {
137 tool_name: "discord".to_string(),
138 priority: Priority::High,
139 description: "Announce new verified event to Discord channel".to_string(),
140 auto_execute: true,
141 },
142 });
143
144 evaluator
145}

A few thoughts on the rule design:

  • auto_execute: false is 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:

toml
1[[rules]]
2rule_id = "contradiction-pr"
3signal_type = "ContradictionDetected"
4threshold = 0.7
5tool_name = "github"
6priority = "High"
7auto_execute = true
8description = "File PR to update conflicting entity in knowledge base"

Then in main.rs:

rust
1let rules_toml = std::fs::read_to_string("rules.toml")?;
2let rules: Vec<ActionRule> = toml::from_str(&rules_toml)?;
3let mut evaluator = SignalEvaluator::new();
4for 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:

rust
1pub mod github;
2pub mod email;
3pub mod slack;
4pub mod discord;
5pub mod filesystem;
6
7use crate::error::ExecResult;
8use serde_json::Value;
9
10#[async_trait::async_trait]
11pub trait ExecutableTool: Send + Sync {
12 async fn execute(&self, params: Value) -> ExecResult<ToolResult>;
13 fn validate(&self, params: &Value) -> ExecResult<()>;
14 fn name(&self) -> &str;
15}
16
17pub struct ToolResult {
18 pub success: bool,
19 pub output: String,
20 pub metadata: serde_json::Value,
21}

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:

  1. Validates required parameters.
  2. Calls the external API or system call.
  3. Returns a ToolResult with success status, human-readable output, and structured metadata.
  4. Logs the action via tracing for 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::ToolError with 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:

rust
1fn safe_resolve(&self, rel: &str) -> ExecResult<PathBuf> {
2 let base = Path::new(&self.base_dir).canonicalize()?;
3 let candidate = base.join(rel);
4 let canonical = candidate.canonicalize().unwrap_or(candidate);
5 if !canonical.starts_with(&base) {
6 return Err(ExecError::ToolError { tool: "filesystem".into(), reason: format!("Path '{}' escapes base directory", rel) });
7 }
8 Ok(canonical)
9}

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:

rust
1use crate::agent::evaluator::ActionDefinition;
2use crate::error::{ExecError, ExecResult};
3use crate::tools::{ExecutableTool, ToolResult, github::GitHubTool, email::EmailTool, slack::SlackTool, discord::DiscordTool, filesystem::FilesystemTool};
4use serde_json::Value;
5use std::collections::HashMap;
6
7pub struct ToolExecutor {
8 pub tools: HashMap<String, Box<dyn ExecutableTool>>,
9}
10
11impl ToolExecutor {
12 pub fn new(config: &crate::config::Config) -> ExecResult<Self> {
13 let mut executor = Self { tools: HashMap::new() };
14 for tool_name in &config.tools.enabled_tools {
15 match tool_name.as_str() {
16 "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() })); },
17 "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() })); },
18 "slack" => { executor.tools.insert("slack".into(), Box::new(SlackTool { webhook_url: std::env::var("SLACK_WEBHOOK_URL").unwrap_or_default() })); },
19 "discord" => { executor.tools.insert("discord".into(), Box::new(DiscordTool { webhook_url: std::env::var("DISCORD_WEBHOOK_URL").unwrap_or_default() })); },
20 "filesystem" => { executor.tools.insert("filesystem".into(), Box::new(FilesystemTool { base_dir: std::env::var("REPORTS_DIR").unwrap_or_else(|_| "./reports".to_string()) })); },
21 _ => tracing::warn!("Unknown tool in config: {}", tool_name),
22 }
23 }
24 Ok(executor)
25 }
26
27 pub async fn execute_action(&self, action: &ActionDefinition, params: Value) -> ExecResult<ToolResult> {
28 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() })?;
29 tracing::info!("Executing tool '{}' with priority {:?}: {}", action.tool_name, action.priority, action.description);
30 if let Err(e) = tool.validate(&params) { tracing::warn!("Validation failed for {}: {}", action.tool_name, e); return Err(e); }
31 let result = tool.execute(params).await;
32 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()); }
33 result
34 }
35
36 pub async fn execute_batch(&self, actions: Vec<(ActionDefinition, Value)>, max_budget: usize) -> ExecResult<Vec<ToolResult>> {
37 let mut results = Vec::new();
38 let budget = actions.len().min(max_budget);
39 tracing::info!("Executing {} actions (budget: {})", budget, max_budget);
40 for (i, (action, params)) in actions.into_iter().enumerate() {
41 if i >= budget { tracing::info!("Action budget reached, skipping remaining"); break; }
42 match self.execute_action(&action, params).await {
43 Ok(r) => results.push(r),
44 Err(e) => tracing::error!("Failed to execute action {}: {}", i, e),
45 }
46 }
47 Ok(results)
48 }
49}

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:

rust
1use crate::agent::evaluator::{SignalEvaluator, build_default_rules};
2use crate::agent::executor::ToolExecutor;
3use crate::agent::query::{GraphQueryBuilder, GraphSignal, SignalType};
4use crate::config::Config;
5use crate::discovery::tool_loader::ToolRegistry;
6use crate::error::ExecResult;
7use serde_json::json;
8
9pub struct AgentRuntime {
10 config: Config,
11 query_builder: GraphQueryBuilder,
12 evaluator: SignalEvaluator,
13 executor: ToolExecutor,
14 registry: ToolRegistry,
15}
16
17impl AgentRuntime {
18 pub fn new(config: Config) -> ExecResult<Self> {
19 let query_builder = GraphQueryBuilder::new(config.clone());
20 let evaluator = build_default_rules();
21 let executor = ToolExecutor::new(&config)?;
22 let registry = ToolRegistry::load_from_directory(&config.agent.tool_discovery_path)?;
23 Ok(Self { config, query_builder, evaluator, executor, registry })
24 }
25
26 pub async fn run_cycle(&self) -> ExecResult<Vec<String>> {
27 tracing::info!("Starting execution cycle");
28 let mut outputs = Vec::new();
29 let signals = self.collect_signals().await?;
30 tracing::info!("Collected {} graph signals", signals.len());
31 if signals.is_empty() { tracing::info!("No signals detected this cycle"); return Ok(outputs); }
32 let evaluations = self.evaluator.evaluate_batch(&signals, &self.registry)?;
33 tracing::info!("{} signals matched evaluation rules", evaluations.len());
34 let actions: Vec<_> = evaluations.into_iter()
35 .flat_map(|(signal, actions)| actions.into_iter().map(move |action| {
36 let params = json!({"signal_type": signal.signal_type.to_string(), "entity": signal.entity, "context": signal.context});
37 (action, params)
38 }))
39 .collect();
40 let results = self.executor.execute_batch(actions, self.config.execution.max_action_budget_per_cycle).await?;
41 for r in results { outputs.push(r.output); }
42 tracing::info!("Execution cycle complete: {} actions executed", outputs.len());
43 Ok(outputs)
44 }
45
46 async fn collect_signals(&self) -> ExecResult<Vec<GraphSignal>> {
47 let mut signals = Vec::new();
48 if let Ok(result) = self.query_builder.execute_query(&self.query_builder.build_contradiction_query()).await {
49 if let Some(rows) = result.get("rows").and_then(|r| r.as_array()) {
50 for row in rows {
51 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() });
52 }
53 }
54 }
55 Ok(signals)
56 }
57
58 pub async fn run_forever(&self) -> ExecResult<()> {
59 tracing::info!("Starting agent runtime (poll interval: {}s)", self.config.execution.poll_interval_secs);
60 let interval = std::time::Duration::from_secs(self.config.execution.poll_interval_secs);
61 loop {
62 tracing::info!("--- Execution cycle starting ---");
63 match self.run_cycle().await {
64 Ok(outputs) => { for o in outputs { tracing::info!("Output: {}", o); } },
65 Err(e) => { tracing::error!("Cycle failed: {}", e); },
66 }
67 tracing::info!("--- Execution cycle complete, sleeping ---");
68 tokio::time::sleep(interval).await;
69 }
70 }
71}

For graceful shutdown, wrap the loop in a tokio::select! against a shutdown signal.


Step 8: The Main Entry Point

src/main.rs:

rust
1mod agent;
2mod config;
3mod discovery;
4mod error;
5mod tools;
6
7use objective05_exec::config::Config;
8use objective05_exec::error::ExecResult;
9use tracing_subscriber::EnvFilter;
10
11#[tokio::main]
12async fn main() -> ExecResult<()> {
13 tracing_subscriber::fmt()
14 .with_env_filter(EnvFilter::try_from_default_env().unwrap_or_else(|_| EnvFilter::new("info")))
15 .init();
16 let config_path = std::path::PathBuf::from("config.toml");
17 let config = Config::from_file(&config_path)?;
18 tracing::info!("Starting objective05-exec v{}", env!("CARGO_PKG_VERSION"));
19 let runtime = agent::runtime::AgentRuntime::new(config)?;
20 tracing::info!("Agent runtime initialized. Starting execution loop...");
21 runtime.run_forever().await?;
22 Ok(())
23}

Also expose src/lib.rs:

rust
1pub mod agent;
2pub mod config;
3pub mod discovery;
4pub mod error;
5pub mod tools;
6pub use config::Config;
7pub 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:

bash
1cargo build --release
2./target/release/objective05-exec

For development, run with RUST_LOG=objective05_exec=debug to see per-query and per-tool traces.


Environment Variables Reference

VariableRequired ForDefaultNotes
GITHUB_TOKENgithub toolnonePAT with repo and workflow scopes
DEFAULT_REPOgithub toolnoneorg/name for default PR target
SMTP_HOSTemail toolnoneHostname of SMTP relay
SMTP_PORTemail tool587TLS SMTP port
SMTP_USERNAMEemail toolnoneFull email address
SMTP_PASSWORDemail toolnoneApp password recommended
SLACK_WEBHOOK_URLslack toolnoneIncoming webhook URL
DISCORD_WEBHOOK_URLdiscord toolnoneChannel webhook URL
REPORTS_DIRfilesystem tool./reportsBase directory for writes
RUST_LOGlogginginfoStandard tracing_subscriber filter
CONFIG_PATHoptionalconfig.tomlOverride 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

cypher
1CREATE NODE TABLE Entity(
2 id STRING PRIMARY KEY,
3 name STRING,
4 category STRING,
5 mention_velocity DOUBLE,
6 last_seen TIMESTAMP
7);
8
9CREATE NODE TABLE Narrative(
10 id STRING PRIMARY KEY,
11 title STRING,
12 divergence_score DOUBLE,
13 tracked_entities STRING[],
14 updated_at TIMESTAMP
15);
16
17CREATE NODE TABLE Contradiction(
18 id STRING PRIMARY KEY,
19 claim_a STRING,
20 claim_b STRING,
21 entity STRING,
22 detected_at TIMESTAMP,
23 confidence DOUBLE
24);
25
26CREATE NODE TABLE Event(
27 id STRING PRIMARY KEY,
28 title STRING,
29 description STRING,
30 related_entities STRING[],
31 confidence DOUBLE,
32 verified BOOLEAN,
33 created_at TIMESTAMP
34);
35
36CREATE NODE TABLE Claim(
37 id STRING PRIMARY KEY,
38 text STRING,
39 entity STRING,
40 superseded_by STRING,
41 created_at TIMESTAMP
42);

Seed Script

If you do not have an Objective05 instance running, this Kuzu script populates a minimal graph the agent can act on:

cypher
1CREATE (e1:Entity {id: 'e1', name: 'Rust Foundation', category: 'org', mention_velocity: 12.4, last_seen: timestamp()});
2CREATE (e2:Entity {id: 'e2', name: 'Local-first AI', category: 'topic', mention_velocity: 8.1, last_seen: timestamp()});
3CREATE (n1:Narrative {id: 'n1', title: 'Edge LLM Adoption', divergence_score: 0.72, tracked_entities: ['e1', 'e2'], updated_at: timestamp()});
4CREATE (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});
5CREATE (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:

rust
1#[cfg(test)]
2mod tests {
3 use super::*;
4 use crate::agent::query::{GraphSignal, SignalType};
5 use serde_json::json;
6
7 #[test]
8 fn evaluator_threshold_blocks_low_confidence() {
9 let mut ev = SignalEvaluator::new();
10 ev.add_rule(ActionRule {
11 rule_id: "test".into(),
12 signal_type: "ContradictionDetected".into(),
13 threshold: 0.8,
14 action: ActionDefinition { tool_name: "github".into(), priority: Priority::High, description: "test".into(), auto_execute: true },
15 });
16 let high = GraphSignal { signal_type: SignalType::ContradictionDetected, entity: "x".into(), context: json!({"confidence": 0.9}) };
17 let low = GraphSignal { signal_type: SignalType::ContradictionDetected, entity: "x".into(), context: json!({"confidence": 0.5}) };
18 assert_eq!(ev.evaluate(&high).unwrap().len(), 1);
19 assert_eq!(ev.evaluate(&low).unwrap().len(), 0);
20 }
21}

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.

rust
1pub struct MockTool { pub calls: Arc<Mutex<Vec<serde_json::Value>>> }
2
3#[async_trait::async_trait]
4impl ExecutableTool for MockTool {
5 async fn execute(&self, params: Value) -> ExecResult<ToolResult> {
6 self.calls.lock().await.push(params);
7 Ok(ToolResult { success: true, output: "ok".into(), metadata: json!({}) })
8 }
9 fn validate(&self, _params: &Value) -> ExecResult<()> { Ok(()) }
10 fn name(&self) -> &str { "mock" }
11}

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

dockerfile
1FROM rust:1.78 as builder
2WORKDIR /app
3COPY . .
4RUN cargo build --release
5
6FROM debian:bookworm-slim
7RUN apt-get update && apt-get install -y libkuzu1.4 ca-certificates && rm -rf /var/lib/apt/lists/*
8COPY --from=builder /app/target/release/objective05-exec /usr/local/bin/
9COPY config.toml /etc/objective05-exec/
10COPY tools /etc/objective05-exec/tools/
11WORKDIR /etc/objective05-exec
12CMD ["/usr/local/bin/objective05-exec"]

Build and run:

bash
1docker build -t objective05-exec .
2docker 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:

  1. Share tool definitions with OpenClaw agents — one set of capability files, multiple agents
  2. Receive commands from OpenClaw gateways — the agent can be triggered remotely via WebSocket
  3. Execute OpenClaw tools — any tool that follows the OpenClaw specification works with our executor
  4. 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:

  1. A knowledge graph to provide context
  2. An evaluation engine to decide what to do
  3. A tool layer to execute actions
  4. 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.

Sovereign AI: An Architectural Investigation into Local-First Intelligence by Daniel Kliewer

Sovereign AI: An Architectural Investigation into Local-First Intelligence

by Daniel Kliewer · Paperback · 72 pages

An examination of the architecture of intelligence that you own — from first principles through production deployment.