Techalicious Academy / 2026-02-11-openwebui

(Visit our meetup for more great tutorials)

WORKSPACE TOOLS

Custom models give you control over how a model behaves. Tools give it new abilities. A base model can only work with what's in its training data. Add a tool and suddenly it can search the web, run calculations, fetch live data, or interact with external services.

What Are Tools?

Tools are Python scripts that extend what a model can do. When you attach a tool to a model (or enable it globally), the model gains access to functions it can call during a conversation.

Think of it this way. A custom model with a great system prompt is like a smart employee who knows their job description. A tool is like giving that employee a phone, a calculator, or access to the internet. Same person, new capabilities.

OpenWebUI has a built-in tool system. You can install community tools from the OpenWebUI community site, or write your own in Python. Tonight we're going to add one that makes a huge difference: web search powered by Perplexity.

Why Web Search Matters

Local models have a knowledge cutoff. They know what was in their training data and nothing after that. Ask about something that happened last week and the model will either make something up or tell you it doesn't know.

Web search fixes this. When you attach a search tool, the model can look things up in real time. It sends a search query, gets current results back, and uses those results to answer your question with real, up-to-date information.

The difference is dramatic. Without search, ask "What's the weather in Toronto?" and the model guesses or refuses. With search, it actually looks it up and gives you a real answer.

Adding the Perplexity Search Tool

We're going to use a community tool that connects to Perplexity AI's search API. Perplexity is a search engine built for AI. It returns clean, sourced answers rather than a list of links.

You'll need a Perplexity API key for this. You can get one at perplexity.ai. They offer a free tier that's enough for testing.

Step 1: Open the Tools Section

  1. Click Workspace in the sidebar
  2. Click the Tools tab
  3. Click the "+" button or "Create a Tool"

Step 2: Paste the Tool Code

You'll see a code editor. Paste the following Python code. This is the complete Perplexity web search tool:

"""
title: Perplexity Web Search
author: OpenWebUI Community
author_url: https://github.com/open-webui
description: Production-ready Perplexity API integration
required_open_webui_version: 0.6.3
requirements: httpx, pydantic
version: 1.0.3
license: MIT
"""

import httpx
import asyncio
import re
import logging
from datetime import datetime
from pydantic import BaseModel, Field
from typing import Callable, Any, Optional

logger = logging.getLogger(__name__)


class EventEmitter:
    def __init__(self, emitter: Optional[Callable[[dict], Any]] = None):
        self.emitter = emitter

    async def emit(self, event_type: str, data: dict) -> None:
        if self.emitter:
            await self.emitter({"type": event_type, "data": data})

    async def status(self, description: str, done: bool = False) -> None:
        await self.emit("status", {"description": description, "done": done})

    async def citation(
        self, title: str, url: str, content: str,
        date: Optional[str] = None
    ) -> None:
        await self.emit(
            "citation",
            {
                "document": [content[:1000] if content else ""],
                "metadata": [
                    {
                        "date_accessed": datetime.now().isoformat(),
                        "source": title,
                        "url": url,
                        "date": date or "",
                    }
                ],
                "source": {"name": title, "url": url},
            },
        )


class Tools:
    class Valves(BaseModel):
        PERPLEXITY_API_KEY: str = Field(
            default="", description="Your Perplexity API key"
        )
        PERPLEXITY_MODEL: str = Field(
            default="sonar-pro", description="Perplexity model to use"
        )
        SEARCH_CONTEXT_SIZE: str = Field(
            default="medium",
            description="Search depth: low, medium, high"
        )
        TIMEOUT_SECONDS: int = Field(
            default=120, description="Request timeout in seconds"
        )
        MAX_TOKENS: int = Field(
            default=4096, description="Maximum tokens in response"
        )
        TEMPERATURE: float = Field(
            default=0.2,
            description="Response temperature (0.0-2.0)"
        )

    class UserValves(BaseModel):
        include_citations: bool = Field(
            default=True, description="Include source citations"
        )
        search_recency: str = Field(
            default="",
            description="Filter: hour, day, week, month, or empty"
        )

    def __init__(self):
        self.valves = self.Valves()
        self.citation = False
        self.tools = [
            {
                "type": "function",
                "function": {
                    "name": "search_web",
                    "description": (
                        "Search the web using Perplexity AI to find "
                        "current information, recent news, real-time "
                        "data, or any information that requires "
                        "up-to-date web search."
                    ),
                    "parameters": {
                        "type": "object",
                        "properties": {
                            "query": {
                                "type": "string",
                                "description": (
                                    "The search query to look up "
                                    "on the web"
                                ),
                            }
                        },
                        "required": ["query"],
                    },
                },
            }
        ]

    def _validate_api_key(self, api_key: str) -> tuple:
        if not api_key:
            return (
                False,
                "Perplexity API key not configured. "
                "Set it in Tool Settings."
            )
        api_key = api_key.strip()
        if len(api_key) < 20:
            return False, "API key appears invalid (too short)"
        if " " in api_key:
            return False, "API key contains invalid characters"
        return True, ""

    def _validate_query(self, query: str) -> tuple:
        if not query:
            return False, "Search query cannot be empty", ""
        sanitized = query.strip()
        sanitized = re.sub(
            r"[\x00-\x1f\x7f-\x9f]", "", sanitized
        )
        if not sanitized:
            return (
                False,
                "Search query cannot be empty after sanitization",
                ""
            )
        if len(sanitized) > 4000:
            return (
                False,
                "Search query too long (max 4000 characters)",
                ""
            )
        return True, "", sanitized

    async def _make_request_with_retry(
        self, client, payload: dict, max_retries: int = 3
    ):
        last_exception = None
        for attempt in range(max_retries):
            try:
                response = await client.post(
                    "https://api.perplexity.ai/chat/completions",
                    json=payload
                )
                if (
                    400 <= response.status_code < 500
                    and response.status_code != 429
                ):
                    response.raise_for_status()
                if (
                    response.status_code == 429
                    or response.status_code >= 500
                ):
                    if attempt < max_retries - 1:
                        retry_after = response.headers.get(
                            "retry-after", "2"
                        )
                        try:
                            delay = min(float(retry_after), 30.0)
                        except ValueError:
                            delay = (2**attempt) + 0.5
                        await asyncio.sleep(delay)
                        continue
                    response.raise_for_status()
                return response
            except httpx.TimeoutException as e:
                last_exception = e
                if attempt < max_retries - 1:
                    await asyncio.sleep(2**attempt)
                    continue
                raise
            except httpx.NetworkError as e:
                last_exception = e
                if attempt < max_retries - 1:
                    await asyncio.sleep(2**attempt)
                    continue
                raise
        raise last_exception or Exception("Max retries exceeded")

    async def search_web(
        self,
        query: str,
        __event_emitter__: Callable[[dict], Any] = None,
        __user__: dict = None,
    ) -> str:
        emitter = EventEmitter(__event_emitter__)

        is_valid, error_msg = self._validate_api_key(
            self.valves.PERPLEXITY_API_KEY
        )
        if not is_valid:
            await emitter.status(
                "Configuration error: " + error_msg, done=True
            )
            return "Error: " + error_msg

        is_valid, error_msg, sanitized_query = (
            self._validate_query(query)
        )
        if not is_valid:
            await emitter.status(
                "Invalid query: " + error_msg, done=True
            )
            return "Error: " + error_msg

        include_citations = True
        search_recency = ""
        if __user__ and "valves" in __user__:
            user_valves = __user__["valves"]
            include_citations = getattr(
                user_valves, "include_citations", True
            )
            search_recency = getattr(
                user_valves, "search_recency", ""
            )

        await emitter.status(
            "Searching: " + sanitized_query[:50] + "..."
        )

        payload = {
            "model": self.valves.PERPLEXITY_MODEL,
            "messages": [
                {
                    "role": "system",
                    "content": (
                        "You are a helpful search assistant. "
                        "Provide accurate, well-sourced answers "
                        "based on current web information."
                    ),
                },
                {"role": "user", "content": sanitized_query},
            ],
            "max_tokens": self.valves.MAX_TOKENS,
            "temperature": self.valves.TEMPERATURE,
            "web_search_options": {
                "search_context_size": (
                    self.valves.SEARCH_CONTEXT_SIZE
                )
            },
        }

        if search_recency:
            payload["search_recency_filter"] = search_recency

        timeout_seconds = self.valves.TIMEOUT_SECONDS
        if (
            "reasoning" in self.valves.PERPLEXITY_MODEL
            or "deep-research" in self.valves.PERPLEXITY_MODEL
        ):
            timeout_seconds = max(timeout_seconds, 180)

        try:
            async with httpx.AsyncClient(
                timeout=httpx.Timeout(
                    connect=10.0,
                    read=float(timeout_seconds),
                    write=30.0,
                    pool=10.0
                ),
                headers={
                    "Authorization": (
                        "Bearer "
                        + self.valves.PERPLEXITY_API_KEY
                    ),
                    "Content-Type": "application/json",
                    "Accept": "application/json",
                },
            ) as client:
                await emitter.status(
                    "Querying Perplexity AI..."
                )
                response = (
                    await self._make_request_with_retry(
                        client, payload
                    )
                )
                response.raise_for_status()
                result = response.json()

        except httpx.TimeoutException:
            error_msg = (
                "Search timed out. Try a simpler query "
                "or increase timeout."
            )
            await emitter.status(error_msg, done=True)
            return "Error: " + error_msg

        except httpx.HTTPStatusError as e:
            status_code = e.response.status_code
            if status_code == 401:
                error_msg = (
                    "Invalid API key. Please check your "
                    "Perplexity API key."
                )
            elif status_code == 429:
                error_msg = (
                    "Rate limit exceeded. Please wait "
                    "and try again."
                )
            elif status_code == 400:
                error_msg = (
                    "Invalid request. The query may be "
                    "malformed."
                )
            elif status_code >= 500:
                error_msg = (
                    "Perplexity service error. "
                    "Please try again later."
                )
            else:
                error_msg = (
                    "Request failed (HTTP "
                    + str(status_code) + ")"
                )
            await emitter.status(error_msg, done=True)
            return "Error: " + error_msg

        except httpx.NetworkError:
            error_msg = (
                "Network connection failed. "
                "Please check your connection."
            )
            await emitter.status(error_msg, done=True)
            return "Error: " + error_msg

        except Exception:
            error_msg = (
                "An unexpected error occurred during search."
            )
            await emitter.status(error_msg, done=True)
            return "Error: " + error_msg

        content = ""
        if "choices" in result and len(result["choices"]) > 0:
            content = (
                result["choices"][0]
                .get("message", {})
                .get("content", "")
            )

        if not content:
            await emitter.status("No results found", done=True)
            return "No results found for your query."

        citations_emitted = 0
        if include_citations:
            await emitter.status("Processing citations...")
            search_results = result.get("search_results", [])
            citation_urls = result.get("citations", [])

            if search_results:
                for sr in search_results[:10]:
                    title = sr.get("title", "Source")
                    url = sr.get("url", "")
                    snippet = sr.get("snippet", "")
                    date = sr.get(
                        "date", sr.get("last_updated", "")
                    )
                    if url:
                        await emitter.citation(
                            title=title, url=url,
                            content=snippet, date=date
                        )
                        citations_emitted += 1
            elif citation_urls:
                for i, url in enumerate(citation_urls[:10]):
                    if url:
                        await emitter.citation(
                            title="Source " + str(i + 1),
                            url=url, content=""
                        )
                        citations_emitted += 1

        if citations_emitted > 0:
            status_msg = (
                "Found " + str(citations_emitted) + " sources"
            )
        else:
            status_msg = "Search complete"
        await emitter.status(status_msg, done=True)

        return content

Step 3: Configure the API Key

After saving the tool, click the gear icon next to it (or click into the tool settings). You'll see a Valves section with configurable fields:

PERPLEXITY_API_KEY:    Your API key from perplexity.ai
PERPLEXITY_MODEL:      sonar-pro (default, good for most searches)
SEARCH_CONTEXT_SIZE:   medium (how deep to search)
TIMEOUT_SECONDS:       120 (increase for complex searches)

The only required field is the API key. Enter it and save.

Step 4: Assign the Tool to a Model

You can enable the tool two ways:

Globally: Admin Panel > Settings > Tools. Toggle it on for all
models.

Per Model: When creating or editing a custom model in Workspace >
Models, scroll to the Tool Bindings section and select the
Perplexity search tool.

Per-model is usually better. You probably don't need web search on every model. Attach it to the ones where current information matters.

Trying It Out

Once the tool is attached, start a conversation and ask something that requires current information:

What happened in the news today?
What's the current price of Bitcoin?
What are the latest updates to OpenWebUI?

Watch the status indicator below the chat. You'll see "Searching..." appear, then "Querying Perplexity AI...", then "Found X sources." The model's response will include real, current information with source citations.

Compare this to asking the same question without the search tool. The difference is night and day.

How It Works Under the Hood

When the model decides it needs current information, it calls the search_web function from the tool. Here's what happens:

  1. The model generates a search query based on your question
  2. The tool sends that query to Perplexity's API
  3. Perplexity searches the web and returns a summarized answer with source citations
  4. The tool passes those results back to the model
  5. The model incorporates the search results into its response

The model decides when to search. If you ask a general knowledge question it can answer from training data, it might skip the search entirely. If you ask about something current, it triggers the tool automatically.

Other Tools Worth Knowing About

The OpenWebUI community has built dozens of tools. A few popular ones:

Code Interpreter:  Runs Python code directly in the conversation
URL Fetcher:       Reads the contents of a web page
Calculator:        Does precise math (models are bad at math)
YouTube Transcript: Pulls transcripts from YouTube videos

You can browse community tools at openwebui.com or search the GitHub repository. Installing them is the same process: create a new tool, paste the code, configure the settings.

Next Up

Let's try something visual. We'll use a vision model to analyze images directly in the chat.