Skip to content

Repository files navigation

Elwood

A self-hosted personal AI assistant that runs as a system service, communicates via messaging platforms, and executes tools on your behalf.

Note: This is an experimental learning project, not intended for production use. It was built as an exercise in Haskell and AI agent architecture.

Overview

Elwood is inspired by OpenClaw but designed to be minimal, auditable, and tightly integrated with NixOS. The name comes from Claude Shannon's middle name.

Key features:

  • Telegram integration — Chat with your assistant from anywhere
  • Webhook endpoints — Trigger agent actions from external systems (Home Assistant, n8n, etc.)
  • Tool execution — Run commands, read/write files
  • MCP support — Extend capabilities with Model Context Protocol servers
  • Persistent memory — Cross-session knowledge store
  • Scheduled tasks — Recurring cron jobs via systemd timers that call webhooks; plus one-shot self-wakeups the agent can schedule itself via schedule_callback (persisted to disk, fired in-process)
  • Tool approval flow — Approve sensitive operations via inline keyboard (Telegram only; webhook-triggered runs deny ask tools)
  • Image support — Send photos and Claude can see them (auto-resized to save tokens); the agent can also view images on disk via view_image (workspace-relative or absolute paths) and perceive image-typed MCP tool results
  • Extended thinking — Configurable reasoning budget for complex tasks
  • Task delegation — Spawn sub-agents with isolated context for tool-heavy tasks
  • Context compaction — Automatic summarization for long conversations
  • Configurable providers — Mix Claude with local models (llama-swap / llama.cpp) via a providers map; per-instance provider: routing
  • Server-side tool search — On-demand tool discovery via Anthropic's tool search with deferred loading
  • Typing indicator — Shows "typing..." in Telegram while the agent works
  • Tool-use notifications — Opt-in status messages for each tool call, with full call arguments in a tap-to-expand quote. Default off; enable globally or per-chat via tool_use_messages, or toggle a chat at runtime with /tools
  • Cost tracking — Approximate API cost metric via model-aware pricing
  • Prometheus metrics — Token usage, API requests, tool calls, and conversation gauges
  • NixOS module — Multi-agent support with systemd hardening

Architecture

Architecture

Building

Elwood is written in Haskell and uses Nix for reproducible builds.

# Enter development shell
nix develop

# Build
cabal build

# Run tests
cabal test

# Run (requires config.yaml and environment variables)
cabal run elwood

Configuration

Create a config.yaml file (see config.yaml.example for all options):

state_dir: /var/lib/assistant
workspace: /var/lib/assistant/workspace

channels:
  telegram:
    - id: 123456789
      session: main
      # tool_use_messages: true   # optional per-chat override of the global default

agent:
  model: claude-sonnet-4-20250514
  thinking:
    enable: false  # set to true to enable thinking
    mode:
      adaptive: {}  # or fixed: { budget_tokens: 4096 }
  max_tokens: 16384
  # cache:
  #   enable: true  # set to false to disable prompt caching
  #   ttl: 5m       # "5m" (default) or "1h"
  system_prompt:
    - type: workspace_file
      path: SOUL.md
  permissions:
    default_policy: allow  # allow | ask | deny
    approval_timeout_seconds: 120
    tool_policies:
      run_command: ask
    dangerous_patterns:
      - "\\brm\\b"
      - "\\bsudo\\b"
    safe_patterns:
      - "^rm -i\\b"

# delegate:                      # sub-agent defaults for delegate_task
#   agent:
#     description: General-purpose sub-agent
#     model: claude-haiku-4-20250414
#     max_iterations: 10
#   extra_agents:                # named presets for delegate_task 'agent' parameter
#     fast:
#       description: Quick Haiku responses
#       model: claude-haiku-4-20250414
#       thinking: { enable: false }

compaction:
  token_threshold: 50000
  model: claude-3-5-haiku-20241022
  # strategy: { keep_turns: 10 }  # or { keep_fraction: 0.25 }

webhook:
  enabled: true
  port: 8080
  secret: "your-webhook-secret"
  endpoints:
    - name: doorbell
      prompt:
        - type: text
          content: |
            Motion detected at front door at {{.timestamp}}.
            Please describe what you see.
      delivery_target:
        type: telegram_broadcast

# NOTE: npx works for local dev but not in NixOS sandboxed services.
# See the NixOS Deployment section for nix-packaged MCP servers.
mcp_servers:
  filesystem:
    command: npx
    args:
      - "-y"
      - "@modelcontextprotocol/server-filesystem"
      - "/path/to/docs"

Set required environment variables:

export TELEGRAM_BOT_TOKEN="your-bot-token"
export ANTHROPIC_API_KEY="your-api-key"  # required if using the anthropic provider
export WEBHOOK_SECRET="your-webhook-secret"   # optional, overrides config file

Local models / providers

A top-level providers map lets each model instance (main agent, compaction, delegate sub-agents, per-chat and webhook-endpoint overrides) target a different LLM endpoint. The built-in anthropic provider (https://api.anthropic.com, keyed by ANTHROPIC_API_KEY) is always available; ANTHROPIC_API_KEY is required only when the anthropic provider is actually used.

The wire format Elwood speaks is the Anthropic Messages API (POST /v1/messages). llama-swap fronts llama.cpp models and proxies the same API, so local models work with no translation layer.

Example — route compaction to a local llama-swap instance while keeping the main agent on Claude:

providers:
  local:
    base_url: "http://satori:8080"  # llama-swap endpoint
    tool_result_images: hoisted     # llama.cpp drops images inside tool results

agent:
  model: claude-sonnet-4-20250514   # uses built-in anthropic provider

compaction:
  model: gemma-4-12B                # model name as configured in llama-swap
  provider: local

delegate:
  extra_agents:
    local:
      model: gemma-4-12B
      provider: local
      tools: [run_command]   # avoid eager-loading the full tool catalog
      tool_search: false
      thinking:
        enable: false
      cache:
        enable: false

provider: is available alongside model: at every model instance (main agent:, compaction:, delegate.agent:, delegate.extra_agents.*, per-chat overrides, and per-webhook-endpoint overrides). Omitting provider: defaults to anthropic.

Operator notes for local model instances:

  • Start each llama-server command with --jinja to enable tool use over the Anthropic endpoint. Elwood is tool-driven, so instances without --jinja cannot call tools.
  • Set thinking.enable: false — local servers do not return the signature field that Elwood's response parser requires on thinking blocks.
  • Set cache.enable: falsecache_control is an Anthropic-API-specific feature and is meaningless to llama.cpp.
  • Do not enable tool_search — it injects a server-side BM25 tool that local servers cannot handle.
  • Restrict the toolset with tools: [run_command] (or whatever the task needs). Local backends load every tool schema eagerly — the full catalog (~85K tokens) overflows a 64K window — so ship only the tools the sub-agent actually uses.
  • For multimodal models (e.g. Gemma with an --mmproj projector), set tool_result_images: hoisted on the provider — llama.cpp's Anthropic endpoint silently drops image blocks inside tool_result content, so Elwood re-sends them as user-message image blocks the model can actually see.
  • Cost metrics (elwood_cost_dollars, agent-daily-cost) will read approximately zero for local model instances; no Anthropic pricing applies.

Workspace Files

The system prompt is assembled from a configurable list of inputs under agent.system_prompt. Each input is either a workspace_file (read from the workspace directory) or inline text. When omitted, it defaults to [{type: workspace_file, path: SOUL.md}].

Place workspace files in your workspace directory (e.g. SOUL.md for personality and behavioral guidelines).

Webhook and cron job prompts use the same input format. Webhook text inputs support {{.field}} template placeholders for dynamic content from the JSON payload. Cron jobs automatically include {{.time}} (ISO 8601), {{.trigger}}, and {{.cron}} in the payload.

NixOS Deployment

Add the flake to your NixOS configuration:

{
  inputs.elwood.url = "github:mcwitt/elwood";
  inputs.mcp-servers.url = "github:nix-community/mcp-servers-nix";

  outputs = { self, nixpkgs, elwood, mcp-servers, ... }: {
    nixosConfigurations.myhost = nixpkgs.lib.nixosSystem {
      modules = [
        elwood.nixosModules.default
        ({ pkgs, system, ... }: {
          services.assistant.agents.elwood = {
            enable = true;
            channels.telegram = [ { id = 123456789; session = "main"; } ];
            environmentFile = "/run/secrets/elwood-env";
            workspace = {
              path = "/var/lib/assistant/elwood/workspace";
              files."SOUL.md".text = ''You are Elwood, a personal AI assistant.'';
              files."USER.md" = {
                path = ./USER.md;
                mutable = true;  # agent can modify this file
              };
            };

            agent = {
              systemPrompt = [
                {
                  type = "workspace_file";
                  path = "SOUL.md";
                }
                {
                  type = "text";
                  content = ''<additional content, not editable by the agent>'';
                }
              ];
              permissions = {
                dangerousPatterns = [ "\\brm\\b" "\\bsudo\\b" ];
                safePatterns = [ "^rm -i\\b" ];
                defaultPolicy = "ask";  # Telegram prompts; webhooks deny (use "allow" for webhook-safe tools)
              };
            };

            # environmentFile should contain WEBHOOK_SECRET (and TELEGRAM_BOT_TOKEN, ANTHROPIC_API_KEY)
            webhook = {
              enable = true;
              port = 8080;
              endpoints."doorbell" = {
                prompt = [ { type = "text"; content = "Motion detected at {{.timestamp}}"; } ];
                deliveryTarget = { type = "telegram_broadcast"; };
              };
            };

            # Cron jobs are systemd timers that POST to auto-generated webhook endpoints
            cronJobs.heartbeat = {
              prompt = [ { type = "text"; content = "Check system health. Reply HEARTBEAT_OK if all is well."; } ];
              schedule = "*-*-* *:00/30";  # every 30 minutes
              session = "123456789";       # share conversation with Telegram chat
              deliveryTarget = { type = "telegram"; chatIds = [ 123456789 ]; };
              suppressIfContains = "HEARTBEAT_OK";
            };

            cronJobs.daily-summary = {
              prompt = [ { type = "text"; content = "The current time is {{.time}}. Generate my daily summary."; } ];
              # Available template variables:
              #   {{.time}}    - ISO 8601 timestamp (e.g., 2026-02-27T08:00:00-05:00)
              #   {{.trigger}} - always "systemd-timer" for cron jobs
              #   {{.cron}}    - the cron job name (e.g., "daily-summary")
              schedule = "*-*-* 08:00";
              deliveryTarget = { type = "telegram_broadcast"; };  # broadcast (default)
              # session = null (default); each run is isolated
            };

            mcpServers.filesystem = {
              command = "${mcp-servers.packages.${system}.filesystem}/bin/mcp-server-filesystem";
              args = [ "/var/lib/assistant/elwood/workspace" ];
            };

          };

          # Run a second agent with different config
          services.assistant.agents.career-coach = {
            enable = true;
            channels.telegram = [ { id = 123456789; } ];
            environmentFile = "/run/secrets/career-coach-env";
            agent.model = "claude-sonnet-4-20250514";
          };
        })
      ];
    };
  };
}

Each agent runs as a separate systemd service (assistant-<name>.service) with hardening (restricted capabilities, protected system paths, etc.). Cron jobs create systemd timers that trigger auto-generated webhook endpoints.

Built-in Tools

Tool Description
run_command Execute shell commands (with permission checks)
save_memory Persist knowledge across sessions
search_memory Search saved memories
view_image View an image file (png/jpg/gif/webp) so the model can see its content
queue_attachment Queue files to send as Telegram attachments
delegate_task Spawn a sub-agent with isolated context for multi-step tasks
schedule_callback Schedule a one-shot wakeup at an absolute time; the woken turn resumes the current conversation and is delivered to the current chat
list_callbacks List pending scheduled callbacks
cancel_callback Cancel a pending scheduled callback by id

Monitoring

When the webhook server is enabled, a Prometheus-compatible metrics endpoint is available at /metrics. No authentication is required for this endpoint.

Available metrics:

Metric Type Labels Description
elwood_input_tokens_total counter model, source Input tokens consumed
elwood_output_tokens_total counter model, source Output tokens consumed
elwood_cache_read_tokens_total counter model, source Cache read tokens
elwood_cache_creation_tokens_total counter model, source Cache creation tokens
elwood_estimated_input_tokens_total counter model, source, type, tool Estimated input tokens by content type
elwood_api_requests_total counter model, source, stop_reason API requests made
elwood_tool_calls_total counter tool Tool invocations
elwood_compactions_total counter Conversation compactions
elwood_conversation_messages gauge session Messages per conversation
elwood_conversation_estimated_tokens gauge session Estimated tokens per conversation
elwood_cost_dollars counter model, source Approximate cumulative API cost in USD
elwood_tools_registered gauge Number of registered tools
elwood_mcp_servers_active gauge Number of active MCP servers
elwood_uptime_seconds gauge Time since process start

Example Prometheus scrape config:

scrape_configs:
  - job_name: elwood
    static_configs:
      - targets: ['localhost:8080']
    metrics_path: /metrics

Future Ideas

  • Additional messaging platforms (Matrix, Discord, etc.)
  • Voice message support
  • Semantic memory search (vector embeddings)
  • Web UI for debugging/administration

License

MIT

About

Self-hosted personal AI assistant with Telegram integration

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages