Skip to content
mcwittPublic

About

Self-hosted personal AI assistant with Telegram integration

Resources

Stars

1 star

Watchers

0 watching

Forks

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; on Fable-class models, thinking.mode.adaptive.display: updates also returns the model's between-tool-call narration so it is delivered mid-turn
  • Mid-turn messages — Text the model writes between tool calls is delivered as it happens, and the send_message tool delivers content verbatim before the turn finishes
  • 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.

For a delegate_task call, pass tools as a JSON array such as ["run_command"], or omit it to retain the selected agent preset. The string "all" explicitly overrides the preset with the full available catalog; it can substantially increase prompt processing time. Configured tool_search is preserved, but requires support from the provider. Startup logs, background-task admission, and timeout errors report the tool count, upfront schema count, and approximate initial input-token count. These estimates help select a smaller catalog; they are not provider token counts or context-limit checks.

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: false — cache_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
send_message Send a message to the user mid-turn, verbatim, before the turn finishes; a failed send is reported to the model, and the tool is absent on turns with no chat to deliver to
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

Core model and behavioral contracts

Elwood separates a pure turn model (Elwood.Claude.Turn) from IO handles. A model reply becomes either a terminal outcome or a validated, nonempty batch of tool calls. Traversing a batch creates paired calls and results in order. The concurrent Applicative interpreter runs each batch together while preserving result order. Named sessions admit turns in FIFO order; different sessions and explicitly delegated tasks can also run concurrently. Configuration overrides compose as a right-biased monoid before being resolved.

The following contracts govern execution:

  • Tools requested in one model reply execute concurrently; results retain the declared call order. Dependent operations require separate model replies.
  • Integer settings reject fractions and overflow. Permission regular expressions compile during configuration loading, and malformed tool-name arrays are rejected.
  • Memory keys are accepted unchanged or rejected when invalid.
  • Background delegates cannot use queue_attachment or emit turn-local progress notifications; return artifact paths in the task result. Synchronous delegates retain those capabilities.
  • Completed delegate results can be read repeatedly. Retention starts at completion; an execution deadline is independent of retention. /stop affects the active turn and its delegates, and /clear waits its turn in the session.
  • Truncated replies remain distinct from completed replies. Malformed tool continuations are errors. Failed or incomplete compaction leaves the original conversation intact.
  • A thinking block is only sent back after the exact prefix it was produced under: the same system prompt, tools, model and earlier messages. When pruning, compaction, a changed system prompt or tool set, a model switch or a restart changes that prefix, the session's next turn drops all earlier thinking blocks (a removal from the start of the history, which the API's prefix check accepts) and never sends them again; compaction keeps no thinking. If the API still reports a prefix mismatch, the request is retried once with prefix_mismatch_behavior: "drop_block" and a warning is logged.
  • Webhook suppression controls delivery without changing the destination captured by scheduled callbacks. Explicit send_message remains immediate.

Conversation files live under conversations/v2/, using collision-free UTF-8 encodings split into filesystem-safe path components. The store also accepts flat files under conversations/: it checks the embedded sessionId before copying a conversation to its encoded path and leaves the source file intact. A filename collision produces an error; resolve the conflicting identifier explicitly from a backup.

Persistence uses atomic file replacement and serialized publication; it does not promise power-loss durability. A due callback is removed from persistent pending work before dispatch, so a process crash between those steps can lose that callback. External effects are not claimed to be exactly-once.

The test suite specifies protocol, persistence, cancellation, process-lifetime and adapter behavior. See the design and behavioral contracts for the core model and resource guarantees.

About

Self-hosted personal AI assistant with Telegram integration

Resources

Stars

1 star

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages