Tools System

Tools extend Chalie’s capabilities by allowing sandboxed execution of external code. Tools are containerized, versioned, and can be triggered on-demand or on a schedule.

Overview

The tools system provides:

Architecture

Components

Tool Registry Service

Tool Container Service

Tool Config Service

Tool Relevance Service

REST API (backend/api/tools.py)

Creating a Tool

Each tool is a subdirectory in backend/tools/ with three required files:

Tool Contract (Formalized JSON Interface)

All tools implement a unified contract: base64-encoded JSON in → JSON out.

Input Payload (from framework)

The framework sends this to the tool container as a base64-encoded JSON string (CMD arg):

{
  "params": {
    "query": "user's search query",
    "location": "optional param value"
  },
  "settings": {
    "api_key": "abc123",
    "endpoint": "https://api.example.com"
  },
  "telemetry": {
    "lat": 35.8762,
    "lon": 14.5366,
    "city": "Valletta",
    "country": "Malta",
    "time": "2026-02-20T16:54:01Z",
    "locale": "en-MT",
    "language": "en-US"
  }
}

Output Format (from tool)

The tool must write this JSON to stdout (and only stdout):

{
  "text": "Human-readable result text. Optional.",
  "html": "<div style=\"...\">Inline HTML card. Optional.</div>",
  "title": "Dynamic card title (optional, overrides manifest title)",
  "error": "Error message string (if operation failed)"
}

1. manifest.json

Required fields:

{
  "name": "tool_name",
  "description": "Human-readable description for Chalie to understand what this tool does",
  "version": "1.0.0",
  "category": "search|calculation|memory|integration|utility|context|research|communication",

  "trigger": {
    "type": "on_demand|cron|webhook"
  },

  "parameters": {
    "param_name": {
      "type": "string|integer|float|boolean",
      "description": "What this parameter does",
      "required": true,
      "default": null
    }
  },

  "returns": {
    "text": { "type": "string" },
    "html": { "type": "string" }
  },

  "output": {
    "synthesize": true,
    "ephemeral": false,
    "card": {
      "enabled": true,
      "title": "Card Title",
      "accent_color": "#4a90d4",
      "background_color": "rgba(74, 144, 212, 0.10)"
    }
  }
}

Trigger Types:

Output Configuration:

The output section controls how the tool’s result is displayed:

{
  "output": {
    "synthesize": true,
    "ephemeral": false,
    "card": {
      "enabled": true,
      "title": "Weather in ",
      "accent_color": "#4a90d4",
      "background_color": "rgba(74, 144, 212, 0.10)"
    }
  }
}

Optional Fields:

{
  "icon": "fa-star",
  "config_schema": {
    "api_key": {
      "description": "Your API key",
      "secret": true,
      "required": true
    },
    "endpoint": {
      "description": "API endpoint URL",
      "secret": false,
      "default": "https://api.example.com"
    }
  },
  "constraints": {
    "timeout_seconds": 9,
    "cost_budget": 1000
  },
  "sandbox": {
    "memory": "512m",
    "network": "bridge|none|host",
    "writable": false
  },
  "notification": {
    "default_enabled": false
  }
}

2. Dockerfile

Must be a valid Dockerfile that:

Example (Python):

FROM python:3.9-slim

WORKDIR /app
COPY . .
RUN pip install -q requests

ENTRYPOINT ["python", "-u", "runner.py"]

Example (Bash):

FROM alpine:3.19
RUN apk add --no-cache bash jq
WORKDIR /tool
COPY runner.sh .
RUN chmod +x runner.sh
ENTRYPOINT ["bash", "runner.sh"]

3. runner.py or runner.sh

The tool script receives the formalized payload and must return the formalized output.

Python example:

#!/usr/bin/env python3
import json
import base64
import sys

# Decode base64 payload from command arg
payload = json.loads(base64.b64decode(sys.argv[1]).decode())

params = payload.get("params", {})    # user-provided parameters
settings = payload.get("settings", {}) # stored tool config (API keys, etc.)
telemetry = payload.get("telemetry", {}) # client context (lat, lon, city, etc.)

try:
    # Your tool logic here
    result_data = fetch_data(params, settings, telemetry)

    # Format output with text and optional HTML
    output = {
        "text": f"Weather: {result_data['temp']}°C and {result_data['condition']}",
        "html": f'<div style="padding:16px"><div style="font-size:2rem">{result_data["temp"]}°C</div></div>'
    }
    print(json.dumps(output))
except Exception as e:
    print(json.dumps({"error": str(e)}))
    sys.exit(1)

Bash example (canonical reference):

#!/usr/bin/env bash
set -euo pipefail

# Decode base64 payload
PAYLOAD=$(echo "$1" | base64 -d)

# Extract fields using jq
NAME=$(echo "$PAYLOAD" | jq -r '.params.name // "World"')
CITY=$(echo "$PAYLOAD" | jq -r '.telemetry.city // ""')

# Compose text and HTML
TEXT="Hello, $NAME!"
HTML="<div style=\"padding:16px;font-family:sans-serif\"><div style=\"font-size:1.4rem\">Hello, $NAME!</div></div>"

# Output formalized contract JSON
jq -n \
  --arg text "$TEXT" \
  --arg html "$HTML" \
  '{"text": $text, "html": $html}'

Using Tools

Configure Tool via REST API

  1. List available tools:

    curl http://localhost:8080/tools \
      -H "Authorization: Bearer YOUR_API_KEY"
  2. Set configuration (API keys, endpoints):

    curl -X PUT http://localhost:8080/tools/my_tool/config \
      -H "Authorization: Bearer YOUR_API_KEY" \
      -H "Content-Type: application/json" \
      -d '{"api_key": "sk-...", "endpoint": "https://..."}'
  3. Test configuration:

    curl -X POST http://localhost:8080/tools/my_tool/test \
      -H "Authorization: Bearer YOUR_API_KEY"

    Returns {"ok": true, "message": "Configuration looks complete"} if all required keys are set.

  4. Get configuration (secrets masked):

    curl http://localhost:8080/tools/my_tool/config \
      -H "Authorization: Bearer YOUR_API_KEY"
  5. Delete a config key:

    curl -X DELETE http://localhost:8080/tools/my_tool/config/api_key \
      -H "Authorization: Bearer YOUR_API_KEY"

Tool Execution Flow

When user sends a message that matches ACT mode:

  1. Semantic Matching — Tool Relevance Service embeds user intent, scores against all available tools
  2. Tool Selection — Mode router picks most relevant tools with relevance > threshold
  3. Parameter Extraction — LLM extracts parameters from conversation context
  4. Configuration Injection — ToolConfigService fetches stored API keys/endpoints
  5. Sandbox Execution — ToolContainerService runs Docker container with timeout
  6. Output Sanitization — Result stripped of action-like patterns, truncated to 3000 chars
  7. Memory Logging — Outcome (success/failure, execution time) logged to procedural memory
  8. Integration — Tool output wrapped in [TOOL:name]...[/TOOL] markers and included in LLM context

Tool Status

Tools have three status values (from API /tools endpoint):

Safety & Constraints

Sandboxing

Every tool container runs with:

Timeouts

Cost Budgets

Optional per-tool budget tracking (if tool returns budget_remaining field):

Output Sanitization

Tool output is sanitized before integration:

Tool Development Checklist

When creating a new tool, ensure:

Tool Output Formats

All tools follow the formalized contract defined above:

Success Response

{
  "text": "Human-readable plain text result (optional)",
  "html": "<div style=\"...\">Inline HTML fragment (optional)</div>",
  "title": "Card title override (optional)"
}

Error Response

{
  "error": "Human-readable error message"
}

The framework then:

Example: Weather Tool

Complete example demonstrating the formalized contract.

Directory structure:

backend/tools/tool_example/
├── manifest.json
├── Dockerfile
└── runner.sh

manifest.json:

{
  "name": "tool_example",
  "description": "Example tool demonstrating the formalized tool contract",
  "version": "1.0",
  "category": "utility",
  "icon": "fa-star",
  "trigger": { "type": "on_demand" },
  "parameters": {
    "name": {
      "type": "string",
      "required": true,
      "description": "Name to greet"
    }
  },
  "returns": {
    "text": { "type": "string" },
    "html": { "type": "string" }
  },
  "output": {
    "synthesize": false,
    "card": {
      "enabled": true,
      "title": "Hello",
      "accent_color": "#6c63ff",
      "background_color": "rgba(108, 99, 255, 0.10)"
    }
  },
  "constraints": { "timeout_seconds": 10 },
  "sandbox": { "network": "none", "memory": "64m" }
}

Dockerfile:

FROM alpine:3.19
RUN apk add --no-cache bash jq
WORKDIR /tool
COPY runner.sh .
RUN chmod +x runner.sh
ENTRYPOINT ["bash", "runner.sh"]

runner.sh:

#!/usr/bin/env bash
set -euo pipefail

# Decode base64 payload from command arg
PAYLOAD=$(echo "$1" | base64 -d)

# Extract fields using jq
NAME=$(echo "$PAYLOAD" | jq -r '.params.name // "World"')
CITY=$(echo "$PAYLOAD" | jq -r '.telemetry.city // ""')
TIME=$(echo "$PAYLOAD" | jq -r '.telemetry.time // ""')

# Compose text output
TEXT="Hello, $NAME!"
[ -n "$CITY" ] && TEXT="$TEXT You're in $CITY."

# Compose HTML card — inline CSS only, no scripts, fragment only
HTML=$(jq -rn \
  --arg name "$NAME" \
  --arg city "$CITY" \
  --arg time "$TIME" \
  '"<div style=\"padding:16px;font-family:sans-serif\">
     <div style=\"font-size:1.4rem;font-weight:600;margin-bottom:8px\">Hello, \($name)!</div>
     " + (if $city != "" then "<div style=\"color:#666;margin-bottom:4px\">📍 \($city)</div>" else "" end) + "
     " + (if $time != "" then "<div style=\"color:#999;font-size:0.85rem\">\($time)</div>" else "" end) + "
   </div>"'
)

# Output formalized contract JSON to stdout
jq -n \
  --arg text "$TEXT" \
  --arg html "$HTML" \
  '{"text": $text, "html": $html}'

Test:

PAYLOAD='{"params":{"name":"Dylan"},"settings":{},"telemetry":{"city":"Valletta","country":"Malta","time":"2026-02-20T17:00:00Z","lat":35.8762,"lon":14.5366}}'
ENCODED=$(echo "$PAYLOAD" | base64)
docker run --rm chalie-tool-tool_example:1.0 "$ENCODED"
# Output: {"text": "Hello, Dylan! You're in Valletta.", "html": "<div style=\"...\">...</div>"}

Troubleshooting

Tool Not Appearing in List

  1. Check Docker is running: docker ps
  2. Check tool directory exists: backend/tools/tool_name/
  3. Check manifest.json is valid JSON: python -m json.tool manifest.json
  4. Check Dockerfile exists: ls backend/tools/tool_name/Dockerfile
  5. View logs: docker-compose logs -f backend | grep TOOL

“Tool not found” Error

Tool name in manifest must match directory name exactly (case-sensitive).

Configuration Not Being Used

  1. Verify config is set: curl http://localhost:8080/tools/my_tool/config
  2. Test configuration: curl -X POST http://localhost:8080/tools/my_tool/test
  3. Check required keys are present (marked with "required": true)

Tool Timeout

  1. Increase timeout in manifest: "constraints": {"timeout_seconds": 30}
  2. Optimize tool code (database queries, API calls, etc.)
  3. Check Docker resource limits (especially memory)

“Timed out after 9s” Error

Container exceeded timeout. Options:

  1. Increase timeout_seconds in manifest
  2. Optimize tool code
  3. Add caching if tool does expensive computation

Advanced

Custom Sandbox Configuration

In manifest.json:

{
  "sandbox": {
    "memory": "1g",
    "network": "none",
    "writable": true
  }
}

Cron-Triggered Tools

For scheduled tools (e.g., reminder checks, daily digests):

manifest.json:

{
  "trigger": {
    "type": "cron",
    "schedule": "*/30",
    "prompt": "Check for any overdue reminders and summarize them:"
  }
}

Webhook Tools (Not Yet Implemented)

Placeholder for future external event triggers (e.g., email received, calendar event).

Safety Guardrails