Who owns your agent’s brain?
Introduction
A while back I sat in a room while a very confident vendor explained that our whole engineering workflow should route through their API. Every prompt, every file scan, every “just check the logs for me” — all of it, out over the wire to a server we didn’t own, billed per thousand tokens, gated behind a rate limit written by someone whose incentives were not ours.
Nobody in that room asked the question I’ve been asking my whole career, the one I keep coming back to in The Rise of the Solo Dev Army: who actually controls this thing, and what happens to us when the control isn’t ours? Because here’s the pattern I’ve watched play out a dozen times. The business falls in love with a capability. It gets wired into everything. And the architectural cost — the dependency, the latency, the fact that your tool stops working the moment someone else’s dashboard turns red — stays invisible right up until the day it isn’t. Same story as technical debt: silent, compounding, then suddenly a rewrite.
So I did the unfashionable thing. I stopped pointing my agent at a cloud
endpoint and built one that lives entirely on my own hardware. It’s called
ai_tools — a local agent
platform written in Rust (Actix-Web) with an Astro + Svelte frontend,
driving local GGUF models through llama.cpp. This is a field report on how
it’s built, why “local” turns out to be an architectural decision and not just
a privacy nicety, and how I taught it to actually touch my Linux box safely.
The bottleneck nobody puts in the architecture diagram
Let me be precise about why cloud-bound agents hit a wall, because “privacy” is only the headline and it’s not even the most interesting part.
- Privacy and security boundaries. You cannot pipe internal infrastructure details, private keys, local file structures, or proprietary source into a third-party API without lighting up every compliance red flag in the building. Not “shouldn’t” — cannot, if you work anywhere serious.
- Latency and rate limits. An agentic loop — model decides to call a tool, reads the result, decides the next step — pays an HTTP round trip on every hop. Multiply a few hundred milliseconds across a multi-step loop and the thing feels like it’s thinking through molasses.
- Cost anxiety on high-frequency work. Polling, file scanning, background monitoring: exactly the boring, repetitive jobs an agent is best at are the ones that turn into a token meter you nervously watch tick upward.
- No native OS context. A cloud model operates in a vacuum. It has no idea what’s eating your RAM, which ports are open, or where that config file is buried in your filesystem. It can talk about your machine; it cannot see it.
The business only ever watches one dial — velocity — and cloud APIs are sold as pure velocity. The costs above are the code smell you don’t notice until the codebase can’t move.
Run the same loop locally and in the cloud — watch the round trips stack up.
I’ve made this argument before in a different costume. Clean architecture, I wrote once, is not decoration — bad structure shows up as CPU, RAM, and server bills, and eventually as a client walking away over the running costs. Renting a remote brain for every keystroke is the same mistake at a new altitude. And if you’ve read Beware of the Hype Train, you already know my reflex: when the entire industry is shouting “bigger, more parameters, more cloud,” that’s exactly the moment to ask what problem you’re actually trying to solve.
The ai_tools platform
The philosophy is boring on purpose: high performance, zero external dependencies for inference, strict type safety.
Instead of reaching for a heavy Python framework, the runtime is pure Rust. Rust
handles concurrent tool execution, WebSocket streaming, SQLite persistence, and
the llama.cpp server lifecycle with a memory footprint that lets the whole
platform get out of the way of the model — which is the only thing that should
be eating your GPU.
Three pieces do the real work:
llama.cppengine control.ai_toolsis a full control plane overllama.cpp. It scans your GGUF cache (~/.cache/llama.cpp/), lets you tune threads, context size, flash attention, and GPU-layer offload, and babysits thellama-serverprocess for you. You own the model and the knobs.- Two-tier memory. SQLite stores complete conversation turns, active context, and tool-call histories, durably. A local ChromaDB instance — the same kind of embeddings-on-your-own-hardware setup I keep arguing for — handles semantic retrieval and long-term recall. Nothing leaves the box.
- A dynamic tool registry. Tools register themselves into a central
ToolRegistry. The agent presents the available function definitions to the model and executes the calls it decides to make.
That last point is where an agent stops being a chatbot.
The toolbelt
An agent without tools is a very expensive autocomplete. Capabilities in
ai_tools are organized into modules:
| Tool category | Registered capabilities |
| Database | chromadb vector search & collection query |
| Development | github repository & issue search |
| Financial & web | Crypto prices, currency conversion, stock data, Google Books, uptime monitor |
| Utility & workspace | Gmail (read/send), Google Docs, Drive, Sheets, Calendar, Tasks, YouTube, Weather, and a Human-in-the-Loop ask_human |
| System (Linux) | Native OS control: search_file, open_folder, list_top_processes, grep_search, system_status, network_ports |
Google APIs and vector search are useful. But the moment the whole thing clicked — the moment a local agent stopped being a novelty and became something I actually reach for at 2pm on a Tuesday — was when I let it touch the operating system.
Giving an agent hands (without giving it a knife)
There are two ways to give an agent system access, and one of them is a mistake.
- Unconstrained shell. Hand the model
bashand let it run whatever it dreams up. Fast to build. Also one hallucinatedrm -rfor one malformed string away from wrecking your environment. This is the “we’ll think about safety later” of agent design, and later never comes. - Structured native wrappers. Expose discrete, safe commands with typed parameters, hard timeouts, and truncated output. More work up front. Sleeps at night.
Try to make the agent run something reckless under each model.
I chose the second. Not because I’m cautious by temperament, but because I’ve spent enough years watching “move fast” become “explain to the client why prod is down” that I flinch at unbounded blast radius. Constraints are the feature here.
Every tool implements one contract:
1use async_trait::async_trait;2use anyhow::Result;3use serde_json::Value;4use crate::api::agent::core::types::{ToolCall, ToolCallResult, ToolMetadata};56#[async_trait]7pub trait AgentTool: Send + Sync {8 fn metadata(&self) -> &ToolMetadata;9 fn get_function_definition(&self) -> Value;10 async fn execute(&self, tool_call: &ToolCall) -> Result<ToolCallResult>;11 fn is_available(&self) -> bool { true }12}
The system tool exposes six actions behind an enum-backed JSON schema, so the model can only ever pick from a known menu:
search_file—findfiles by pattern under a directory.open_folder—xdg-opena folder in the desktop file manager.list_top_processes—ps -eo pid,ppid,cmd,%mem,%cpu --sort=-%cpu, sliced to the top 15 in Rust, not by shelling out tohead.grep_search—grep -rnthrough text files.system_status—df -handfree -mfor instant disk and RAM numbers.network_ports—ss -tulnfor listening TCP/UDP sockets.
Here’s the shape of it (src/backend/src/api/agent/tools/utility/system.rs):
1use crate::api::agent::core::types::{ToolCall, ToolCallResult, ToolType};2use crate::api::agent::tools::framework::agent_tool::{AgentTool, ToolCategory, ToolMetadata};3use anyhow::Result;4use async_trait::async_trait;5use serde_json::json;6use std::time::Duration;7use tokio::process::Command;8use tokio::time::timeout;910pub struct SystemCommandTool {11 metadata: ToolMetadata,12}1314#[async_trait]15impl AgentTool for SystemCommandTool {16 fn metadata(&self) -> &ToolMetadata {17 &self.metadata18 }1920 fn get_function_definition(&self) -> serde_json::Value {21 json!({22 "name": "system_command",23 "description": "Execute safe, everyday Linux system commands.",24 "parameters": {25 "type": "object",26 "properties": {27 "command": {28 "type": "string",29 "description": "The command to run.",30 "enum": [31 "search_file", "open_folder", "list_top_processes",32 "grep_search", "system_status", "network_ports"33 ]34 },35 "path": { "type": "string", "description": "Target directory." },36 "query": { "type": "string", "description": "File-name or grep pattern." }37 },38 "required": ["command"]39 }40 })41 }4243 async fn execute(&self, tool_call: &ToolCall) -> Result<ToolCallResult> {44 let args: serde_json::Value =45 serde_json::from_str(&tool_call.function.arguments).unwrap_or(json!({}));4647 let command = args.get("command").and_then(|v| v.as_str()).unwrap_or("");48 let path = args.get("path").and_then(|v| v.as_str()).unwrap_or(".");49 let query = args.get("query").and_then(|v| v.as_str()).unwrap_or("");5051 let timeout_duration = Duration::from_secs(30);5253 let output = match command {54 "list_top_processes" => {55 let mut cmd = Command::new("ps");56 cmd.args(["-eo", "pid,ppid,cmd,%mem,%cpu", "--sort=-%cpu"]);57 let res = process_output(timeout(timeout_duration, cmd.output()).await);58 // Safe slice in Rust memory — no shell pipeline to `head`59 res.lines().take(16).collect::<Vec<&str>>().join("\n")60 }61 "network_ports" => {62 let mut cmd = Command::new("ss");63 cmd.arg("-tuln");64 process_output(timeout(timeout_duration, cmd.output()).await)65 }66 // ...search_file, open_folder, grep_search, system_status67 _ => format!("Unknown command: {}", command),68 };6970 Ok(ToolCallResult {71 tool_name: self.metadata.name.clone(),72 result: output,73 tool_call_id: Some(tool_call.id.clone()),74 })75 }76}
The three decisions that matter
Skim past the boilerplate and there are exactly three choices doing the heavy lifting, and each one is really a decision about failure:
| Decision | What it prevents |
tokio::process::Command (non-blocking) | A shelling-out tool never stalls the Actix event loop or the WebSocket stream to other connected clients. One user’s find doesn’t freeze everyone else’s session. |
tokio::time::timeout on every call | A command that hangs — say, searching an unmounted network share — gets cancelled at 30s instead of deadlocking the whole agentic turn. |
| No shell pipes; args passed to binaries directly | Nothing goes through sh -c. Arguments are handed straight to find, ps, ss, grep. Shell injection isn’t mitigated, it’s absent. |
This is the constructive friction I keep banging on about, just moved into the type system. In a team, the raised eyebrow at code review is what stops the reckless one-liner. When you’re a solo dev with an AI army — the exact scenario I worried about in the Solo Dev Army piece — that friction has to come from somewhere. Here it comes from an enum, a timeout, and a refusal to concatenate strings into a shell. The agent is fast because it’s local; it’s trustworthy because the guardrails are compiled in, not prompted in.
What this actually feels like
Pair an open-weight model — Qwen 2.5 14B/32B, Llama 3.1 8B, a DeepSeek R1 distill — with local tool execution on Apple Silicon or a dedicated GPU, and the developer experience changes character:
- Sub-second feedback. An agent checking memory or finding a file answers in tens of milliseconds. The only real latency left is token generation, which is your silicon’s problem to solve, not a queue in someone else’s data center.
- Zero token-cost anxiety. Let it run background loops all day. There is no billing dashboard climbing in the corner of your eye.
Let both agents idle in a background loop.
- Actual autonomy. “My machine’s running hot — find the top process and
open the project directory with the log file.” The agent runs
list_top_processes, spots the runaway PID, callssearch_file, andxdg-opens the folder on my desktop. Seconds. No wire.
None of these models is the biggest on the leaderboard, and that’s the point. Same lesson I keep relearning: the boring, right-sized tool that runs where you need it beats the giant that lives somewhere you can’t reach. Efficiency is the moat now, not parameter count.
Final thoughts
Local models stopped being the budget alternative to cloud APIs a while ago.
Bolt one to a compiled, high-concurrency language like Rust and a local runtime
like llama.cpp, and you get something the API-first crowd can’t ship you at
any price: a sovereign workstation agent. Fast because there’s no wire.
Private because nothing leaves the box. Trustworthy because the safety is in the
types, not in a hopeful system prompt.
The business will keep watching the velocity dial, and the vendors will keep selling more parameters. That’s fine — let them. The quiet, unglamorous move is to own the thing that runs on your own machine, structure its powers deliberately, and stop renting a brain by the token. That’s not a step down from the frontier. It’s just refusing to build your whole workflow on a foundation you don’t control.
_If you want to kick the tires on
ai_tools or add a native tool of
your own to the registry, check out the
ai_tools GitHub repository, clone
it, and give cargo run