Skip to content

Tools & cards

Defining tools

Tools are functions the LLM can call. Their return values are serialized to JSON and passed back to the model. Tools can be written as Python functions or custom schemas.

Python function

Use the @uni.tool decorator. The tool schema is auto-generated from the function name, parameters, and docstring.

Basic tool definition
@uni.tool
def get_weather(location: str) -> dict:
    """
    Get current weather for a location.

    Args:
        location: City name or coordinates

    Returns:
        Weather data including temperature and conditions
    """
    return {
        "location": location,
        "temperature": 22,
        "conditions": "Sunny"
    }

Tools should return fast. For long-running operations, start a thread and use uni.send_impulse to deliver results. See Impulses.

Custom schema

For dynamic tools (like those loaded from an MCP server), use uni.register_tool with a custom JSON schema:

Custom schema registration
config = uni.get_config()
locations = config.plugin.get("locations", list, ["Amsterdam", "London"])

uni.register_tool(
    schema={
        "name": "get_weather",
        "description": "Get current weather for a location.",
        "parameters": {
            "type": "object",
            "properties": {
                "location": {
                    "type": "string",
                    "enum": locations
                }
            },
            "required": ["location"]
        }
    },
    handler=lambda params: {"temperature": 22}
)

Tool design tips

Tool schemas should work well with language models, including smaller local ones. Avoid parameters that require precise computation:

  • Absolute date/times (LLMs can't do reliable calendar math)
  • Units requiring conversion (minutes to seconds, etc.)

What not to do

A tool like update_timer(name, new_seconds) forces the LLM to convert "2 hours and 40 minutes" into seconds. That's error-prone.

A better design accepts natural language, letting your code handle the parsing:

LLM-friendly tool design
def update_timer(name: str, action: str, duration: str):
    """
    Update a running timer.

    Args:
        name: Timer name
        action: One of: "add", "subtract", "set"
        duration: Natural language (e.g., "2 hours 30 minutes")
    """

Built-in helpers

The SDK includes uni.parse_datetime and uni.parse_duration for handling natural language time inputs.

Tool approvals

External data sources (emails, web content) can contain prompt injection attacks. By default, tools require user approval before execution.

Mark safe tools with auto_approve=True to skip the approval dialog:

Auto-approved tool
@uni.tool(auto_approve=True)
def calculator(expression: str) -> str:
    """Evaluate a mathematical expression."""

Or make it conditional based on parameter values:

Conditional approval
def is_recent_search_result(url: str):
    return url in search_result_urls

@uni.tool(auto_approve=is_recent_search_result)
def web_fetch(url: str) -> str:
    """Read a webpage."""

If using uni.register_tool, that also has the auto_approve parameter.

Remembering past approvals

Your auto_approve callback can return a list of options instead of False, turning the approval dialog into a "remember this" prompt:

Approval with remember option
trusted_domains: set[str] = set()

def is_trusted(url: str):
    domain = urlparse(url).netloc
    if domain in trusted_domains:
        return True

    def remember():
        trusted_domains.add(domain)

    return [(f"Always allow {domain}", remember)]

@uni.tool(auto_approve=is_trusted)
def web_fetch(url: str) -> str:
    """Read a webpage."""

The resulting dialog buttons would look like:

Rollback-safe hint

Tells the pipeline it's safe to discard this tool's result and act as if it never ran. This enables certain latency optimizations in the response pipeline. A web search is fine to throw away, ending an email is not.

Rollback-safe tool
@uni.tool(auto_approve=True, rollback_safe=True)
def web_search(query: str) -> str:
    """Search the web."""

Cards

Cards push rich content to connected devices. Use them for notifications, information displays, or interactive widgets. Users can swipe to dismiss.

Estimated commute

About 23 minutes to work

Medium traffic

Downloading model

example-Q4_K_S.gguf - 75%

Text cards

Quick notifications with uni.push_text_card:

Simple text card
uni.push_text_card(
    title="Estimated commute",
    message="About 23 minutes to work",
    footer="Medium traffic",  # optional
    timeout="30s"  # auto-dismiss
)

To send to all connected devices, add all_devices=True. To update/replace an existing card, pass its id.

HTML cards

For custom layouts, use uni.push_card with HTML (Jinja2), CSS, and JavaScript. Cards render in a shadow DOM with the UNI CSS theme.

Custom HTML card with progress
uni.push_card(
    html="""
    <div class="card">
        <h4>{{ title }}</h4>
        <progress value="{{ progress }}" max="100"></progress>
        <p>{{ status }}</p>
    </div>
    """,
    html_values={
        "title": "Processing",
        "progress": 75,
        "status": "Almost done..."
    },
    script="""
    const progress = uni.card.element.querySelector('progress');
    let value = 75;
    setInterval(() => {
        value = Math.min(100, value + 5);
        progress.value = value;
        if (value >= 100) uni.card.dismiss();
    }, 1000);
    """
)

Avoid XSS

Don't use f-strings for dynamic values. Always use the html_values dictionary and Jinja2 templating.

Dismissal

Cards can be dismissed by users (swipe), automatically (timeout), or programmatically with uni.dismiss_card(card_id). To handle dismissal in your plugin:

Handling card dismissal
@uni.on_dismiss
def handle_dismiss(event: uni.CardDismissEvent):
    print(f"Card {event.card_id} was dismissed")

In the card's JavaScript, register a cleanup callback:

Client-side cleanup
uni.card.onDismiss(() => {
  // Perform cleanup (clear timers, close connections, etc.)
});