Tool Development
Workspace Tools execute arbitrary Python code on your server. Only install from trusted sources, review code before importing, and restrict Workspace access to trusted administrators only. Granting a user the ability to create or import Tools is equivalent to giving them shell access to the server. For full details, see the Plugin Security Warning.
Writing A Custom Toolkit
Toolkits are defined in a single Python file, with a top level docstring with metadata and a Tools class.
Tool methods should generally be defined as async to ensure compatibility with future Open WebUI versions. The backend is progressively moving toward fully async execution, and synchronous functions may block execution or cause issues in future releases.
Example Top-Level Docstring
"""
title: String Inverse
author: Your Name
author_url: https://website.com
git_url: https://github.com/username/string-reverse.git
description: This tool calculates the inverse of a string
required_open_webui_version: 0.4.0
requirements: langchain-openai, langgraph, ollama, langchain_ollama
version: 0.4.0
licence: MIT
"""When you create a new tool (also applies to functions and skills), the editor reads the frontmatter as you paste or type code and auto-fills the Name, ID, and Description fields from title and description if you haven't already filled them in. It never overwrites a value you've entered, and it does not re-derive fields when editing an existing item, so you no longer need to retype metadata that's already declared in the source.
Tools Class
Tools have to be defined as methods within a class called Tools, with optional subclasses called Valves and UserValves, for example:
class Tools:
def __init__(self):
"""Initialize the Tool."""
self.valves = self.Valves()
class Valves(BaseModel):
api_key: str = Field("", description="Your API key here")
async def reverse_string(self, string: str) -> str:
"""
Reverses the input string.
:param string: The string to reverse
"""
# example usage of valves
if self.valves.api_key != "42":
return "Wrong API key"
return string[::-1]Two rules follow from how the class is loaded. Every public callable on the instance is exposed to the model as a tool, so helper methods must start with _. And __init__ has to set self.valves = self.Valves(): stored admin valves are only loaded onto an instance that already has a valves attribute, so without that line the tool runs on the class defaults no matter what the admin saved.
Method Docstrings
The docstring of each method is what the model reads before deciding to call it. The text above the first :param becomes the tool's own description, and each :param name: becomes the description of that argument in the JSON schema.
A parameter description can run over as many lines as it needs. Every following line belongs to it until the next :param, or until another field such as :return:. Indentation is stripped and blank lines are dropped, so you can lay a long description out as a paragraph and the model still receives all of it:
async def search_docs(self, query: str, limit: int = 5) -> str:
"""
Searches the internal documentation.
:param query: The phrase to search for.
Supports the AND and OR operators.
Wrap an exact phrase in double quotes.
:param limit: How many results to return.
:return: The matching passages.
"""Here the model is told about the operators and the quoting rule as well as the first line. The text after :return: is not sent to the model, so put anything the model needs to know into the description or into a :param.
Only the reST :param name: form is read. A Google-style Args: block produces no parameter descriptions and lands in the tool's own description instead.
Type Hints
Each tool must have type hints for arguments. The types may also be nested, such as queries_and_docs: list[tuple[str, int]]. Those type hints are used to generate the JSON schema that is sent to the model. An argument without a type hint is typed as a string in the schema, so the model will always send a string. Optional[X] collapses to X, and a None default is dropped. Parameters whose names start with __ are stripped from the schema along with their :param lines, so the model never sees the reserved arguments listed below.
Return Values
A tool may return a str, a dict or a list. A dict is JSON-encoded for the model, a bare list is wrapped as {"results": [...]}, and anything else is converted with str(). A string that starts with data:image/ is detached as an image attachment, and the model is told <name>: Image file read successfully. instead.
Valves and UserValves (optional, but HIGHLY encouraged)
Valves and UserValves are used for specifying customizable settings of the Tool, you can read more on the dedicated Valves & UserValves page. Secrets stored in Valves (such as API keys) can be encrypted at rest.
Optional Arguments
Below is a list of optional arguments your tools can depend on:
__event_emitter__: Emit events (see following section)__event_call__: Same as event emitter but can be used for user interactions. It waits for the browser to answer, with no timeout unlessWEBSOCKET_EVENT_CALLER_TIMEOUTis set to a number of seconds.__user__: A dictionary with user information. It also contains theUserValvesobject in__user__["valves"].__metadata__: Dictionary with chat metadata__messages__: List of previous messages__files__: Attached files (an empty list when the message has none)__model__: A dictionary with model information. For workspace tools this is the task model when one is configured under Settings > Admin > Interface, and the chat model otherwise. Read__metadata__["model"]for the model the user is chatting with.__id__: The tool's own id__chat_id__and__message_id__: The current chat and message ids__request__: The FastAPI request__oauth_token__: A dictionary containing the user's valid, automatically refreshed OAuth token payload. This is the new, recommended, and secure way to access user tokens for making authenticated API calls. The dictionary typically containsaccess_token,id_token, and other provider-specific data.
For more information about __oauth_token__ and how to configure this token to be sent to tools, check out the OAuth section in the environment variable docs page and the SSO documentation.
Just add them as argument to any method of your Tool class just like __user__ in the example above. __messages__ and __files__ are refreshed before every native tool call. The others are fixed when the request starts.
file_handler
A module-level file_handler = True tells Open WebUI to drop the attached files from the request once the tool has run, so the retrieval pipeline does not inject them as well. Only the Legacy function-calling path reads the flag. The Native loop ignores it.
Using the OAuth Token in a Tool
When building tools that need to interact with external APIs on the user's behalf, you can now directly access their OAuth token. This removes the need for fragile cookie scraping and ensures the token is always valid.
Example: A tool that calls an external API using the user's access token.
import httpx
from typing import Optional
class Tools:
# ... other class setup ...
async def get_user_profile_from_external_api(self, __oauth_token__: Optional[dict] = None) -> str:
"""
Fetches user profile data from a secure external API using their OAuth access token.
:param __oauth_token__: Injected by Open WebUI, contains the user's token data.
"""
if not __oauth_token__ or "access_token" not in __oauth_token__:
return "Error: User is not authenticated via OAuth or token is unavailable."
access_token = __oauth_token__["access_token"]
headers = {
"Authorization": f"Bearer {access_token}",
"Content-Type": "application/json"
}
try:
async with httpx.AsyncClient() as client:
response = await client.get("https://api.my-service.com/v1/profile", headers=headers)
response.raise_for_status() # Raise an exception for bad status codes
return f"API Response: {response.json()}"
except httpx.HTTPStatusError as e:
return f"Error: Failed to fetch data from API. Status: {e.response.status_code}"
except Exception as e:
return f"An unexpected error occurred: {e}"Event Emitters
Event Emitters are used to add additional information to the chat interface. Similarly to Filter Outlets, Event Emitters are capable of appending content to the chat. Unlike Filter Outlets, they are not capable of stripping information. Additionally, emitters can be activated at any stage during the Tool.
Legacy Mode is legacy and no longer supported. See the Tool Calling Modes guide for the full policy. Write your tools to work correctly under Native (Agentic) Mode, which is the only supported mode going forward. The event-emitter compatibility matrix below still documents Legacy Mode behavior for historical reference and for maintainers of pre-existing tools that haven't been migrated yet, but new tools should not depend on Legacy-Mode-only event types. If your tool's UX fundamentally requires an event type that only works in Legacy Mode (message, chat:message:delta, chat:message, replace mid-stream), redesign the UX around Native-compatible events (status, notification, citation, chat:message:files, confirmation, chat:message:follow_ups) rather than requiring users to switch their model to legacy mode.
Event Emitter behavior differs between the two function calling modes. The function calling mode is controlled by the function_calling parameter:
- Native Mode (Agentic Mode) (
function_calling = "native"): the only supported mode. Uses the model's structured tool-call API. Limited event-emitter surface: see the matrix below for exactly which event types are supported. - Legacy Mode (
function_calling = "legacy"): the prompt-injection mode. Full event-emitter surface, but the mode itself is unsupported and should not be selected for new deployments.
For the full mode policy, model requirements, and configuration, see the Tool Calling Modes guide. In short: use Native Mode; the matrix below tells you which event types work there.
Function Calling Mode Configuration
You can configure the function calling mode in two places:
- Administrator Level: Go to Settings > Admin > Models, click the pencil (Edit) on the model, then Advanced Params > Function Calling (set to "Native" or "Legacy").
- Per-request basis: Set
params.function_calling = "native"or"legacy"in Chat Controls > Advanced Params.
If the model seems to be unable to call the tool, make sure it is enabled (either via the Model page or via the + sign next to the chat input field).
When writing custom tools, be aware that Open WebUI also provides built-in system tools when Native Mode is enabled. For details on built-in tools, function calling modes, and model requirements, see the Tool Calling Modes Guide.
Detecting the Mode in Your Tool
Most tools never need to branch on the mode. If yours does, read it from __metadata__. Native is the default, so test for Legacy explicitly. Anything that is not "legacy" (an unset value included) runs Native:
def is_legacy_mode(__metadata__) -> bool:
# Native is the default; only an explicit "legacy" opts out of it.
return (__metadata__ or {}).get("params", {}).get("function_calling") == "legacy"Define this once and call it wherever you branch, rather than re-deriving the mode in each method:
if is_legacy_mode(__metadata__):
# Legacy: the full event-emitter surface is available (message, replace, deltas)
...
else:
# Native (the default): use the compatible events and return the final content
..."native" or "default"Before v0.10.0 an unset model carried "default", and tools detected Native with function_calling == "native". That is now wrong. An unset model runs Native but does not carry the string "native", so a == "native" test (or treating a "default" fallback as non-native) misclassifies the now-default case as Legacy and feeds it Legacy-only events that silently break. Test for == "legacy" and treat everything else as Native.
Complete Event Type Compatibility Matrix
Here's the comprehensive breakdown of how each event type behaves across function calling modes:
| Event Type | Legacy Mode Functionality | Native Mode Functionality | Status |
|---|---|---|---|
status | ✅ Full support: Updates status history during tool execution | ✅ Identical: Tracks function execution status | COMPATIBLE |
message | ✅ Full support: Appends incremental content during streaming | ❌ BROKEN: Gets overwritten by native completion snapshots | INCOMPATIBLE |
chat:completion | ✅ Full support: Handles streaming responses and completion data | ⚠️ LIMITED: Carries function results but may overwrite tool updates | PARTIALLY COMPATIBLE |
chat:message:delta | ✅ Full support: Streams delta content during execution | ❌ BROKEN: Content gets replaced by native function snapshots | INCOMPATIBLE |
chat:message | ✅ Full support: Replaces entire message content cleanly | ❌ BROKEN: Gets overwritten by subsequent native completions | INCOMPATIBLE |
replace | ✅ Full support: Replaces content with precise control | ❌ BROKEN: Replaced content gets overwritten immediately | INCOMPATIBLE |
chat:message:files / files | ✅ Full support: Handles file attachments in messages | ✅ Identical: Processes files from function outputs | COMPATIBLE |
chat:message:error | ✅ Full support: Displays error notifications | ✅ Identical: Shows function call errors | COMPATIBLE |
chat:message:follow_ups | ✅ Full support: Shows follow-up suggestions | ✅ Identical: Displays function-generated follow-ups | COMPATIBLE |
chat:title | ✅ Full support: Updates chat title dynamically | ✅ Identical: Updates title based on function interactions | COMPATIBLE |
chat:tags | ✅ Full support: Modifies chat tags | ✅ Identical: Manages tags from function outputs | COMPATIBLE |
chat:tasks:cancel | ✅ Full support: Cancels ongoing tasks | ✅ Identical: Cancels native function executions | COMPATIBLE |
citation / source | ✅ Full support: Handles citations with full metadata | ✅ Identical: Processes function-generated citations | COMPATIBLE |
notification | ✅ Full support: Shows toast notifications | ✅ Identical: Displays function execution notifications | COMPATIBLE |
confirmation | ✅ Full support: Requests user confirmations | ✅ Identical: Confirms function executions | COMPATIBLE |
execute | ✅ Full support: Executes code dynamically | ✅ Identical: Runs function-generated code | COMPATIBLE |
input | ✅ Full support: Requests user input with full UI | ✅ Identical: Collects input for functions | COMPATIBLE |
Why Native Mode Breaks Certain Event Types
In Native Mode, the server constructs content blocks from streaming model output and repeatedly emits "chat:completion" events with full serialized content snapshots. The client treats these snapshots as authoritative and completely replaces message content, effectively overwriting any prior tool-emitted updates like message, chat:message, or replace events.
Technical Details:
middleware.pyadds tools directly to form data for native model handling- Streaming handler emits repeated content snapshots via
chat:completionevents - Client's
chatCompletionEventHandlertreats snapshots as complete replacements:message.content = content - This causes tool-emitted content updates to flicker and disappear
Best Practices and Recommendations
For Tools Requiring Real-time UI Updates:
class Tools:
def __init__(self):
# Add a note about function calling mode requirements
self.description = "This tool requires Legacy Mode for full functionality"
async def interactive_tool(self, prompt: str, __event_emitter__=None) -> str:
"""
⚠️ This tool requires function_calling = "legacy" for proper event emission
"""
if not __event_emitter__:
return "Event emitter not available - ensure Legacy Mode is enabled"
# Safe to use message events in Legacy Mode
await __event_emitter__({
"type": "message",
"data": {"content": "Processing step 1..."}
})
# ... rest of tool logicFor Tools That Must Work in Both Modes:
Branch on is_legacy_mode(): take the Legacy path for message / replace streaming, and on the Native path emit status updates and return the assembled content from the tool. The helper and the full branch pattern are in Detecting the Mode above. Do not re-derive the mode in every method.
Troubleshooting Event Emitter Issues
Symptoms of Native Mode Conflicts:
- Tool-emitted messages appear briefly then disappear
- Content flickers during tool execution
messageorreplaceevents seem to be ignored- Status updates work but content updates don't persist
Solutions:
- Switch to Legacy Mode: Change
function_callingfrom"native"to"legacy"in model settings - Use Compatible Event Types: Stick to
status,citation,notification, and other compatible event types in native mode - Implement Mode Detection: Add logic to detect function calling mode and adjust event usage accordingly
- Consider Hybrid Approaches: Use compatible events for core functionality and degrade gracefully
Debugging Your Event Emitters:
async def debug_events_tool(self, __event_emitter__=None, __metadata__=None) -> str:
"""Debug tool to test event emitter functionality"""
if not __event_emitter__:
return "No event emitter available"
# Test various event types
test_events = [
{"type": "status", "data": {"description": "Testing status events", "done": False}},
{"type": "message", "data": {"content": "Testing message events (may not work in native mode)"}},
{"type": "notification", "data": {"content": "Testing notification events"}},
]
mode_info = "Unknown"
if __metadata__:
mode_info = __metadata__.get("params", {}).get("function_calling") or "native (default)"
await __event_emitter__({
"type": "status",
"data": {"description": f"Function calling mode: {mode_info}", "done": False}
})
for i, event in enumerate(test_events):
await asyncio.sleep(1) # Space out events
await __event_emitter__(event)
await __event_emitter__({
"type": "status",
"data": {"description": f"Sent event {i+1}/{len(test_events)}", "done": False}
})
await __event_emitter__({
"type": "status",
"data": {"description": "Event testing complete", "done": True}
})
return f"Event testing completed in {mode_info} mode. Check for missing or flickering content."There are several specific event types with different behaviors:
Status Events ✅ FULLY COMPATIBLE
Status events work identically in both Legacy and Native function calling modes. This is the most reliable event type for providing real-time feedback during tool execution.
Status events add live status updates to a message while it's performing steps. These can be emitted at any stage during tool execution. Status messages appear right above the message content and are essential for tools that delay the LLM response or process large amounts of information.
Basic Status Event Structure:
await __event_emitter__({
"type": "status",
"data": {
"description": "Message that shows up in the chat",
"done": False, # False = still processing, True = completed
"hidden": False # False = visible, True = auto-hide when done
}
})Status Event Parameters:
description: The status message text shown to usersdone: Boolean indicating if this status represents completionhidden: Boolean to auto-hide the status oncedone: Trueis set
Basic Status Example
async def data_processing_tool(
self, data_file: str, __user__: dict, __event_emitter__=None
) -> str:
"""
Processes a large data file with status updates
✅ Works in both Legacy and Native function calling modes
"""
if not __event_emitter__:
return "Processing completed (no status updates available)"
# Step 1: Loading
await __event_emitter__({
"type": "status",
"data": {"description": "Loading data file...", "done": False}
})
# Simulate loading time
await asyncio.sleep(2)
# Step 2: Processing
await __event_emitter__({
"type": "status",
"data": {"description": "Analyzing 10,000 records...", "done": False}
})
# Simulate processing time
await asyncio.sleep(3)
# Step 3: Completion
await __event_emitter__({
"type": "status",
"data": {"description": "Analysis complete!", "done": True, "hidden": False}
})
return "Data analysis completed successfully. Found 23 anomalies."Advanced Status with Error Handling
async def api_integration_tool(
self, endpoint: str, __event_emitter__=None
) -> str:
"""
Integrates with external API with comprehensive status tracking
✅ Compatible with both function calling modes
"""
if not __event_emitter__:
return "API integration completed (no status available)"
try:
await __event_emitter__({
"type": "status",
"data": {"description": "Connecting to API...", "done": False}
})
# Simulate API connection
await asyncio.sleep(1.5)
await __event_emitter__({
"type": "status",
"data": {"description": "Authenticating...", "done": False}
})
# Simulate authentication
await asyncio.sleep(1)
await __event_emitter__({
"type": "status",
"data": {"description": "Fetching data...", "done": False}
})
# Simulate data fetching
await asyncio.sleep(2)
# Success status
await __event_emitter__({
"type": "status",
"data": {"description": "API integration successful", "done": True}
})
return "Successfully retrieved 150 records from the API"
except Exception as e:
# Error status - always visible for debugging
await __event_emitter__({
"type": "status",
"data": {"description": f"Error: {str(e)}", "done": True, "hidden": False}
})
return f"API integration failed: {str(e)}"Multi-Step Progress Status
async def batch_processor_tool(
self, items: list, __event_emitter__=None
) -> str:
"""
Processes items in batches with detailed progress tracking
✅ Works perfectly in both function calling modes
"""
if not __event_emitter__ or not items:
return "Batch processing completed"
total_items = len(items)
batch_size = 10
completed = 0
for i in range(0, total_items, batch_size):
batch = items[i:i + batch_size]
batch_num = (i // batch_size) + 1
total_batches = (total_items + batch_size - 1) // batch_size
# Update status for current batch
await __event_emitter__({
"type": "status",
"data": {
"description": f"Processing batch {batch_num}/{total_batches} ({len(batch)} items)...",
"done": False
}
})
# Simulate batch processing
await asyncio.sleep(1)
completed += len(batch)
# Progress update
progress_pct = int((completed / total_items) * 100)
await __event_emitter__({
"type": "status",
"data": {
"description": f"Progress: {completed}/{total_items} items ({progress_pct}%)",
"done": False
}
})
# Final completion status
await __event_emitter__({
"type": "status",
"data": {
"description": f"Batch processing complete! Processed {total_items} items",
"done": True
}
})
return f"Successfully processed {total_items} items in {total_batches} batches"Message Events ⚠️ DEFAULT MODE ONLY
🚨 CRITICAL WARNING: Message events are INCOMPATIBLE with Native function calling mode!
Message events (message, chat:message, chat:message:delta, replace) allow you to append or modify message content at any stage during tool execution. This enables embedding images, rendering web pages, streaming content updates, and creating rich interactive experiences.
However, these event types have major compatibility issues:
- ✅ Legacy Mode: Full functionality, content persists and displays properly
- ❌ Native Mode: BROKEN, content gets overwritten by completion snapshots and disappears
Why Message Events Break in Native Mode:
Native function calling emits repeated chat:completion events with full content snapshots that completely replace message content, causing any tool-emitted message updates to flicker and disappear.
Safe Message Event Structure (Legacy Mode Only):
await __event_emitter__({
"type": "message", # Also: "chat:message", "chat:message:delta", "replace"
"data": {"content": "This content will be appended/replaced in the chat"},
# Note: message types do NOT require a "done" condition
})Message Event Types:
message/chat:message:delta: Appends content to existing messagechat:message/replace: Replaces entire message content- Both types will be overwritten in Native mode
Safe Message Streaming (Legacy Mode)
async def streaming_content_tool(
self, query: str, __event_emitter__=None, __metadata__=None
) -> str:
"""
Streams content updates during processing
⚠️ REQUIRES function_calling = "legacy" - Will not work in Native mode!
"""
# Legacy Mode only; see "Detecting the Mode in Your Tool" above.
if not is_legacy_mode(__metadata__):
return "❌ This tool requires Legacy Mode. Message streaming is overwritten in Native mode, which is the default."
if not __event_emitter__:
return "Event emitter not available"
# Stream progressive content updates
content_chunks = [
"🔍 **Phase 1: Research**\nGathering information about your query...\n\n",
"