Tool System

Sub-agent architecture with Home Assistant integration

How It Works

Muninn uses a sub-agent architecture for tool execution. The main LLM sees only one tool definition (agent), keeping the conversation context lean. When the LLM needs external data or actions, it delegates to a sub-agent that has access to all available tools.

Two-Layer Tool Flow

1
Main LLM receives user message + single agent tool definition (~150 tokens)
2
LLM decides to delegate: agent({"task": "Get weather for Moncton today"})
3
Sub-agent runs in ephemeral context with all inner tools (weather, home, calculate, etc.)
4
Sub-agent calls the appropriate tool, gets results, returns a concise answer
5
Main LLM receives the agent's answer and formulates the final reply to the user

Why Sub-Agent?

  • 70% token savings — ~150 tokens for one tool def vs ~500+ for six
  • Scales infinitely — adding tools doesn't grow the main context
  • Clean separation — sub-agent context is ephemeral, thrown away after each call
  • The main loop caps at 8 rounds, the sub-agent at 25. The last round strips tool definitions to force a text reply. A loop-detector trip with rounds left now injects a "change tack" nudge and keeps the tools (course-correct, not kill); a garbled/empty final reply is re-asked up to 4× before any fallback.

Tool Trait

#[async_trait]
pub trait Tool: Send + Sync {
    fn name(&self) -> &str;
    fn definition(&self) -> ToolDefinition;
    async fn execute(&self, arguments: &str) -> Result<String>;
}

Inner tools are registered in main.rs and passed to the AgentTool, which wraps them for the sub-agent. The main LLM's ToolRegistry only contains the agent.

Built-in Tools

weather

Current conditions and daily forecast from Environment Canada via Home Assistant.

PropertyValue
BackendHA REST API (/api/states/weather.moncton_forecast + /api/services/weather/get_forecasts)
Parameters{"days": 3} (1-7, default 3)
Response time~11ms (local network)
OutputCurrent temp/conditions/wind/humidity + daily forecast with highs/lows/precip

home_query / home_action

Smart home via Home Assistant's conversation API. Split into a read tool (home_query: "is the basement light on?") and a write tool (home_action: "turn off the lights") so the role filter can gate writes independently. Natural language in, action out.

PropertyValue
BackendHA /api/conversation/process
Parameters{"command": "turn off the kitchen lights"}
Response time~100-300ms
CapabilitiesLights, locks, sensors, climate, media, covers — anything HA exposes

HA's intent system handles entity resolution — Muninn doesn't need to know entity IDs.

Example

User: "turn off the basement lights"
Main LLM → agent({"task": "Turn off the basement lights"})
Sub-agent → home({"command": "turn off the basement lights"})
HA → "Turned off the lights"
Reply: "Done, Oz. The basement lights are now off."

calculate

Python expression evaluator for math, unit conversions, date calculations.

PropertyValue
BackendPython 3 subprocess via stdin (sandboxed with env_clear)
Parameters{"expression": "2**10 + 3.14"}
Timeout10 seconds (configurable)
Importsmath, decimal, fractions, statistics, datetime, itertools
Output cap4,000 characters

web_search

Web search via self-hosted SearXNG routed through Tor proxies.

PropertyValue
BackendSearXNG on .46:8080
Parameters{"query": "search terms"}
Max resultsConfigurable (default: 5)
OutputNumbered list: title, URL, snippet (200 chars)

music

Play and control music/audio via Music Assistant (through Home Assistant). Use this — not home_action — for anything involving playing, queuing, pausing, skipping, favoriting, or volume of music.

PropertyValue
BackendHA + Music Assistant integration
Parameters{"action": "play", "query": "...", "mode": "favorites|discover|radio|genre", "player": "kitchen", "with": "gym", "volume": 0.4, "direction": "up", "enqueue": "play|add|next"}
Actionsplay, pause/resume/stop/next/previous, volume (set/nudge/mute), shuffle, favorite, dislike (skip + remember), transfer, join/unjoin (synced multi-room), status (track + volume). "pause everything" stops all players.
Play modesfavorites (default "play some music" = favorites+random), discover (random library minus favorites — "surprise me"), radio (similar-tracks station — "more like this / X radio"), genre (playlist search — "play some jazz / 90s music"), or a specific named song/artist/album/playlist
Player defaultRoom word resolves via config map → HA area → device registry → fuzzy name; else the speaker's own room, else whatever's currently playing. music_default_player as final fallback.
Configmusic_assistant_config_entry_id enables search/library/genre; [tools.music_rooms] pins per-room defaults
ReferenceFull docs: docs/MUSIC.md

Page fetch (via MCP)

The native web_fetch tool was removed. Page fetch / screenshot / structured-extract now come from the muninn-fetch HTTP MCP server (configured under [[tools.mcp_servers]], admin-gated). Use web_search to find URLs, then the MCP fetch tools to retrieve their content.

camera

Snapshot from any Frigate camera via Home Assistant, with AI vision description.

PropertyValue
BackendHA camera proxy (/api/camera_proxy/camera.<name>) + the GPU server vision LLM
Parameters{"camera": "basement_pixel", "question": "optional"}
Response time~10s (45ms snapshot + ~8s vision)
Sensor dataFrigate motion, person occupancy, person count, last recognized face
OutputVision description + sensor context. Snapshot image uploaded to Matrix room

Available cameras: front_door, basement_pixel, driveway_camera, garage_ptz, garage_pano, sunroom_pixel_7, kitchen_tablet, gym_camera, octoprint_camera.

imagine

AI image generation via ComfyUI with Flux 2 Klein 9B. Supports text-to-image and image-to-image (style transfer from camera snapshots).

PropertyValue
BackendComfyUI on .21:8188 (RTX 3090)
ModelFlux 2 Klein 9B — GGUF Q8 (txt2img), fp8 safetensors (img2img edit)
Parameters{"prompt": "...", "source_image": "camera:name", "steps": 25}
Generation time~40-90s depending on mode and model loading

Async Generation

Image generation runs asynchronously — the user gets an immediate reply and a placeholder image while ComfyUI works. When the image is ready, the placeholder is replaced with the real image. The user can continue chatting during generation.

Image-to-Image

Uses Flux Klein's native edit architecture with TextEncodeQwenImageEdit + ReferenceLatent. The Qwen 3 8B encoder understands both the prompt and source image natively. Source images are fetched from HA cameras or URLs.

shell

Bash execution behind a deterministic multi-gate pipeline (self-service control, protected hosts, obfuscation, remote-exec allowlist, unsafe-file-write/heredoc guard, fast-reject, then the LLM safety judge). See the Security page for the full gate order; the two key steps are summarized below.

PropertyValue
Parameters{"command": "df -h"}
Timeout30 seconds (configurable)
Configshell_enabled = false by default (opt-in)

Safety System

1
Fast-reject: Hardcoded blocklist catches rm -rf, sudo, dd, fork bombs, shutdown, etc.
2
Safety judge: LLM evaluates risk: safe, caution, or dangerous. Dangerous commands are blocked.

email

Read, search, and send email over one or more configured mailboxes ([tools.email], default gateway a Mailcow server). Stdlib-style stack — imap + mail-parser + lettre, run inside spawn_blocking. Admin-only.

PropertyValue
BackendIMAP (read/search/flags/move) + SMTP (send); per-account TLS (ssl/starttls)
Actionsaccounts, list (folder, unread-only), read (by uid, non-destructive PEEK), search (IMAP TEXT), send (to/cc/bcc), reply (threaded In-Reply-To/References, reply-all), mark_read/mark_unread, archive, delete (Trash or permanent), folders, extract_events (scan mail for appointments → confirm-first calendar proposals)
extract_eventsPer-email LLM pass (date-grounded, mail treated as untrusted) yields structured proposals; never writes the calendar — the agent confirms, then calls calendar add. A dedup ledger (email_calendar_extractions, Message-ID keyed) skips already-processed mail; rescan overrides
Multi-accountOne [[tools.email.accounts]] block per mailbox; a call names account or falls back to the default
NotesSent copies are IMAP-appended best-effort; account hosts come from trusted config (no SSRF surface)

calendar

Appointments with timed reminders Muninn delivers to the conversation — "remember I have a dentist appointment Tuesday at 3" → stored, then reminded in time. Admin-only.

PropertyValue
BackendLocal SQLite = source of truth (schema v27); best-effort CalDAV push to SOGo so events show in phone/Element
Actionsadd (natural-language when, optional location/notes/duration/reminders/visibility), list, remove
RemindersDefault 1 day + 1 hour before (configurable, per-event overridable); a 60s reminder worker posts to the originating Matrix thread
Shared calendarOne calendar serves all users; each event records who added it + public/private visibility (private events list only to their creator)
RecurringiCal RRULE via the rrule crate, anchored in America/Moncton (wall-clock survives DST); "every Tuesday at 3pm", "every weekday at 9am", "monthly on the 1st". The worker rolls a 60-day horizon to materialize per-occurrence reminders; remove cancels the whole series

contacts

CardDAV name→email/phone lookup over the SOGo address book, so "email a contact" resolves an address instead of typing one. Admin-only.

PropertyValue
BackendRaw CardDAV over reqwest (one addressbook-query REPORT pulls every vCard inline) + a hand-rolled vCard parser (FN/EMAIL/TEL/ORG, structured-N fallback, entity decode) — no CardDAV/vCard crate
Actionssearch (name/email/org — the resolution path), list, add (name + email/phone → PUT a vCard 3.0)
NotesFiltering is client-side (a personal book is small → one round-trip); host from trusted config (no SSRF surface). The agent resolves a name here, then calls email send

youtube

Summarize a YouTube video from its transcript. admin/adult.

PropertyValue
Backendyt-dlp (host binary, invoked by argv — never a shell string) pulls auto-captions as VTT; a hand-rolled VTT cleaner strips timing/tags and collapses rolling-duplicate lines → transcript → LLM summary
OutputTL;DR + key points + notable moments; URL is host-validated to YouTube (no open-fetch SSRF). 90s subprocess budget; graceful no-captions / not-found / timeout errors
DependencyRequires yt-dlp on the host ([tools.youtube] binary, default resolved via PATH)

research

Deep research in one call → a cited markdown brief. admin/adult. Slow by nature, so it's meant to run backgrounded (prefix !bg, or just say "research…" which auto-backgrounds).

PropertyValue
LoopLLM decomposes the question into sub-questions → each searched on SearXNG → top unique URLs fetched concurrently and reduced to text → synthesis pass writes the report; a deterministic ## Sources list is appended so [n] citations always resolve
Bounds≤10 sources, 24k synthesis chars, 8s/fetch — caps wall-clock + cost regardless of how the planner expands the topic
SSRF guardOnly public http(s) URLs fetched; loopback/private/link-local/.local hosts rejected, so a poisoned search result can't pivot the fetcher into the LAN

Status Reactions

During tool execution or image processing, Muninn reacts to the user's message with an emoji. The reaction is removed when processing finishes.

ActionEmoji
incoming image (vision)👁️
agent🤖
web_search🔍
page fetch (muninn-fetch MCP: fetch_markdown/html/full)🌐
shell💻
calculate🧮
camera📷
imagine🎨
Other⚙️

Adding a New Tool

1. Create src/tools/yourname.rs implementing the Tool trait

2. Add pub mod yourname; to tools/mod.rs

3. Register as an inner tool in main.rs (NOT in the main registry):

inner_tools.register(Box::new(
    tools::yourname::YourTool::new(/* config */),
));

4. The sub-agent system prompt automatically lists all registered inner tools

5. Update the agent's description in tools/agent.rs if the new capability should be mentioned (so the main LLM knows to delegate)