This document covers custom tool integration for API clients (e.g., Home Assistant, Open WebUI).
For CLI local actions (file operations, code execution), see local-actions.md.
Custom tool support is experimental. Tool calls can fail because of:
- Too many tools: Lumo gets confused when the client provides many tools or (very) long instructions.
- Misrouted calls: Lumo routes custom tools through its native pipeline, which fails server-side. lumo-tamer bounces these back, but this adds latency and isn't always reliable.
- Wrong tool/arguments: Lumo sets the wrong tool name or arguments.
- Detection failures: JSON code blocks are not properly detected or parsed.
This requires trial and error. Experiment with server.instructions settings to improve results.
Privacy note: When Lumo misroutes a tool call, the tool name and arguments are sent to Proton's servers via the native tool pipeline. This data may be processed unencrypted server-side and could appear in Proton's logs. If your tools handle sensitive data, be aware of this risk. Tool results are not affected: they flow through the normal message pipeline with encryption.
-
Enable custom tools in
config.yaml:server: customTools: enabled: true
-
Configure your API client (Home Assistant, Open WebUI, etc.) to use tools as normal.
-
lumo-tamer intercepts Lumo's responses, detects tool calls, and returns them in OpenAI format for your client to execute.
server:
customTools:
# Enable detection of JSON tool calls in Lumo's responses
enabled: true
# Prefix added to custom tool names to distinguish from Lumo's native tools.
# Applied to tool definitions sent to Lumo, stripped from tool calls returned to client.
# Set to "" to disable prefixing.
prefix: "user:"The instructions sent to Lumo are assembled from a template:
server:
instructions:
# Template for assembling instructions.
# Uses Handlebars-like syntax: {{varName}}, {{#if varName}}...{{/if}}
# Available variables:
# - tools: JSON-stringified tool definitions (truthy when tools provided)
# - clientInstructions: system/developer message from request
# - forTools: the forTools block below (pre-interpolated with {{prefix}})
# - fallback: the fallback block below
# - prefix: tool prefix from customTools.prefix
template: |
{{#if tools}}
{{forTools}}
{{/if}}
{{#if clientInstructions}}
{{clientInstructions}}
{{else}}
{{fallback}}
{{/if}}
{{#if tools}}
Below are all the custom tools you can use. Remember, all tools prefixed with `{{prefix}}` are custom tools and must be called by outputting the JSON to the user.
{{tools}}
{{/if}}
# Fallback instructions when no system/developer message is provided
fallback: |
Always answer in plain text. Don't use tables, quote blocks, lists, etc. Be concise.
# Instructions prepended when tools are provided in the request.
# Can use {{prefix}} variable.
forTools: |
=== CUSTOM TOOL PROTOCOL ===
The tools below are CUSTOM tools, prefixed with `{{prefix}}`.
IMPORTANT: Custom tools are NOT part of your built-in tool system.
You MUST call them by outputting JSON as text in a code block to the user, like this:
```json
{"name": "{{prefix}}example_tool", "arguments": {"param": "value"}}
```
DO NOT try to call custom tools through your internal tool mechanism, it will fail with error:true.
DO NOT remove the `{{prefix}}` prefix when calling these tools.
The user's system will execute them and return results.
=== END PROTOCOL ===
# Bounce instruction sent when Lumo routes a custom tool through its native pipeline.
forToolBounce: |
You tried to call a custom tool using your built-in tool system, but custom tools must be called by outputting JSON text within a code block. Please output the tool call as JSON, like this:Clean up client instructions that might confuse Lumo:
server:
instructions:
# Search/replace patterns applied to client instructions (case-insensitive regex).
# Useful for removing or rewriting phrases that may confuse Lumo about tool calling.
# Each entry: { pattern: "regex", replacement: "text" } - omit replacement to strip.
replacePatterns:
- pattern: "(?<=(?:(?:native|custom|internal|external)\\s)?)(?=tool)"
replacement: "custom "Tool calls not detected
- Ensure
customTools.enabled: true - Check that Lumo is outputting valid JSON in code fences
- Review
instructions.forTools- Lumo may need clearer instructions
Wrong tool names
- Check
customTools.prefix- it's added to definitions and stripped from responses - If prefix is causing issues, set to
""to disable
Lumo says "I don't have access to that tool"
- This is a misrouted call being bounced - should resolve automatically
- If persistent, check logs for bounce failures
Lumo has built-in tools executed server-side by Proton:
| Tool | Description |
|---|---|
proton_info |
Proton product information (always enabled) |
web_search |
Web search via Proton's backend |
weather |
Weather data |
stock |
Stock prices |
cryptocurrency |
Cryptocurrency prices |
Enable/disable external native tools:
server:
# Enable Lumo's native web_search tool (and other external tools)
enableWebSearch: trueNative and custom tools work together: native tools execute server-side, custom tools are detected client-side.
- Tool definitions are prefixed with
customTools.prefix(e.g.,get_weatherbecomesuser:get_weather) - Instructions are assembled from
instructions.templatewith tool definitions as JSON - Instructions are prepended to a user message as
[Project instructions: ...]instructions.injectInto: "first"(default): inject into first user message (less token usage in multi-turn)instructions.injectInto: "last": inject into last user message each request (matches WebClient)
- Lumo outputs tool calls as JSON in code fences:
If Lumo misroutes the tool call through its native pipeline, lumo-tamer bounces it, after which Lumo will output JSON (hopefully). See Misrouted Tool Calls.
I'll check the weather for you. ```json {"name": "user:get_weather", "arguments": {"city": "Paris"}} ``` - lumo-tamer detects and extracts tool calls, strips the prefix, and returns in OpenAI format
- Your client executes the tool and sends results back
{
"choices": [{
"message": {
"role": "assistant",
"content": "I'll check the weather for you.",
"tool_calls": [{
"id": "call_abc123",
"type": "function",
"function": {
"name": "get_weather",
"arguments": "{\"city\": \"Paris\"}"
}
}]
}
}]
}Sometimes Lumo routes a custom tool through its native SSE pipeline instead of outputting JSON text. This always fails because Proton's backend doesn't know the tool.
- lumo-tamer detects the misrouted call (tool name not in
KNOWN_NATIVE_TOOLS) - Suppresses Lumo's error response ("I don't have access to that tool...")
- Bounces the call back with
instructions.forToolBounce - Lumo re-outputs the tool call as JSON text
- Normal detection extracts the tool call
This is transparent to API clients - the bounce happens internally.
| File | Purpose |
|---|---|
src/api/instructions.ts |
Instruction template assembly |
src/api/routes/responses/tool-processor.ts |
StreamingToolDetector for streaming detection |
src/api/tool-parser.ts |
Non-streaming tool call extraction |
src/lumo-client/client.ts |
Misrouted tool bounce logic |