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.
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
asktools) - 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
providersmap; per-instanceprovider: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
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 elwoodCreate 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 fileA 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: falseprovider: 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-servercommand with--jinjato enable tool use over the Anthropic endpoint. Elwood is tool-driven, so instances without--jinjacannot call tools. - Set
thinking.enable: false— local servers do not return thesignaturefield that Elwood's response parser requires on thinking blocks. - Set
cache.enable: false—cache_controlis 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
--mmprojprojector), settool_result_images: hoistedon the provider — llama.cpp's Anthropic endpoint silently drops image blocks insidetool_resultcontent, 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.
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.
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.
| 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 |
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- Additional messaging platforms (Matrix, Discord, etc.)
- Voice message support
- Semantic memory search (vector embeddings)
- Web UI for debugging/administration
MIT