Tools and connectors
Give a Hub's agent new abilities with Python tools in tools_local/, external MCP servers, each person's own MCP connections and one shared browser.
Every Hub starts with the built-in tools for files, knowledge, skills, search and the web. This guide covers the four ways to add more, and when a tool should be restricted instead.
Choose the right mechanism
| You need | Use | Configured in |
|---|---|---|
| Your own logic, or a call to an internal API with one service credential | A Python tool | tools_local/*.py |
| A capability an MCP server already provides | An MCP connector | connectors/.mcp.json |
| Each person to act with their own account on an MCP server | Personal MCP connections | OWUI_NATIVE_MCP=true, the server registered in the web chat, and connector_<app> grants on a Console-managed Hub |
| Pages that need a real browser | The shared browser | HUBZOID_BROWSER=true |
| A tool only some people or workflows may use | A restricted tool | restricted/<permission>.py |
A Python tool is available on every surface of the Hub, and an MCP connector on every surface except the Hub's own MCP server. Nothing in tools_local/ or connectors/ checks who is asking, so everyone who can use the Hub can make the agent call them. When that is not acceptable, use a restricted tool.
Add a Python tool
The example gives the operations desk of a home goods retailer with four stock locations a live stock lookup.
Create a file in tools_local/
Any *.py file in tools_local/ is imported when the Hub starts. Files whose names start with _ are skipped, so you can keep helpers and tools you do not want loaded beside the others.
"""Stock lookups for the operations desk."""
from __future__ import annotations
import json
import os
import httpx
from agents import function_tool
LOCATIONS = ("north", "south", "east", "west")
@function_tool
def stock_level(sku: str, location: str = "all") -> str:
"""Current stock of one item, per store.
Args:
sku: The item code, for example "LMP-2041".
location: One of north, south, east or west, or "all" for every store.
Returns:
JSON with the quantity at each store.
"""
if location != "all" and location not in LOCATIONS:
return f"Unknown location {location!r}. Use one of {', '.join(LOCATIONS)}, or all."
base = os.environ["STOCK_API_URL"] # your own variables, set in the Hub's .env
token = os.environ["STOCK_API_TOKEN"]
r = httpx.get(
f"{base}/items/{sku}/stock",
params={"location": location},
headers={"Authorization": f"Bearer {token}"},
timeout=15,
)
if r.status_code == 404:
return f"No item with code {sku}."
r.raise_for_status()
return json.dumps(r.json())Describe it well
@function_tool comes from the agents package (the OpenAI Agents SDK, installed with Hubzoid). The function name becomes the tool name, the docstring becomes the description the model reads, and the type hints and Args: section define the arguments. The model decides when to call a tool from these alone, so say what it returns and when to use it. Return a short explanation for expected problems, such as an unknown code, rather than raising.
Restart and try it
Tools load when the Hub starts, so restart hubzoid run after you add or edit one. Then ask for it directly:
hubzoid test ./ops-desk --prompt "How many LMP-2041 lamps are in the north store?"Keep it working
Add an eval that expects the tool, so a later change to AGENTS.md cannot quietly stop the agent from using it.
---
expect_tools: [stock_level]
---
## Prompt
How many LMP-2041 lamps are in the north store?Rules for tools_local/
- Every module-level object created with
@function_toolbecomes a tool.@function_tool(name_override="...")gives it a different name. - Two tools with the same name: the first file in alphabetical order wins and a warning is logged.
- A tool with the same name as a built-in replaces the built-in, for example your own
web_search. - Tools run inside the bridge process with the Hub's environment, so read secrets from
os.environinside the function. Never make a secret a tool argument, because the model would have to supply it. - The same tool works on all three runtimes. On
claude-localthe model sees it asmcp__hubzoid__stock_level. On the OpenAI Agents and Codex runtimes it keeps its own name. - Tools are also served to MCP clients when the Hub has
MCP_SERVER=true. See connect an assistant. - A sub-agent that runs on its own model (a delegate) can be limited to a subset with
tools: [stock_level, read_knowledge]in its frontmatter. See Hub files.
Connect an MCP server
Add servers to connectors/.mcp.json. The format is the familiar mcpServers map.
{
"mcpServers": {
"github": {
"command": "npx",
"args": ["@modelcontextprotocol/server-github"],
"env": { "GITHUB_PERSONAL_ACCESS_TOKEN": "${GH_TOKEN}" }
},
"tickets": {
"transport": "streamable-http",
"url": "https://mcp.example.com/mcp",
"headers": { "Authorization": "Bearer ${TICKETS_TOKEN}" },
"client_session_timeout_seconds": 60
}
}
}- Local servers use
command,argsandenv, and run as child processes. - Remote servers use
urlandheaders. Set"transport": "streamable-http"(or"http") for a Streamable HTTP server. A server with aurland notransportis treated as SSE. - Secrets.
${NAME}in any string is replaced with that variable after the Hub's.envis loaded. Keep tokens in.env, never in the JSON file. - Timeouts.
client_session_timeout_secondsraises the per-call timeout for slow servers on the OpenAI Agents and Codex runtimes. - Names. On
claude-localthe model seesmcp__<server>__<tool>, for examplemcp__github__create_issue. On the other runtimes each tool keeps its own name. - Reach. Connectors serve chat, markdown tasks, code workflow agent calls and evals. The Hub's own MCP server does not relay them to MCP clients.
- Changes take effect when the Hub restarts.
Every person who uses the Hub reaches a connector with the same credential. Give that credential the narrowest scope the task needs, for example read-only.
Per-user MCP servers
For services where each person must act as themselves, such as their own mailbox or ticket tracker, an admin registers the MCP server once in the web chat, and each person connects their own account through an OAuth sign-in. These are the web chat's (Open WebUI's) native MCP connections. The bridge then calls the server with that person's token on their turns, on all three runtimes.
Turn it on
Add to the Hub's .env (in a gateway, to the environment of hubzoid gateway, where it applies to every Hub):
OWUI_NATIVE_MCP=true
WEBUI_SECRET_KEY=<a fixed random value, for example from openssl rand -hex 32>The web chat encrypts each person's token with WEBUI_SECRET_KEY, and the bridge decrypts it with the same value, so it must stay fixed. Behind a reverse proxy, also set WEBUI_URL (and HUBZOID_PUBLIC_URL) to the public address, or the provider's sign-in redirect fails. The first start with the flag clears the web chat's stored settings once, unless tool servers are already registered, so the web chat follows your environment. Accounts, groups, models and access grants are untouched.
Register the server (admin, once)
In the web chat, open Admin Panel → Settings → Integrations, add an External Tool Server, choose MCP Streamable HTTP, enter the URL and a name, choose OAuth 2.1 as the auth type, select Register Client, and save. The server must support dynamic client registration. For a server that does not, use OAuth 2.1 (Static) with an OAuth app you created with the provider. An optional tool list on the server limits which of its tools the agent may call.
Grant the connector (Console-managed Hubs)
Each registered server is an app named by the ID typed when it was registered, lowercased, with other characters turned into _. On a Hub whose access is managed in the Console, a person reaches the server only with that app's connector_<app> capability, for example connector_gmail for a server registered as gmail. Grant it like any other capability. On a Hub that still uses web chat groups for access, nothing more is needed.
Connect (each person, once)
In a chat, open Integrations next to the + button, choose Tools, switch the server on and complete the provider's sign-in.
From then on, each of that person's turns includes the server with their own token. An expired token is refreshed automatically and written back, so the person reconnects only when the refresh itself fails.
- Tool names. On
claude-localthe tools appear asmcp__owui_<name>__<tool>. On the OpenAI Agents and Codex runtimes they keep the server's own tool names. - No shadowing. A personal tool never replaces a Hub tool. When names clash, that person's server is left out for the turn and a warning is logged. A server that cannot be reached is also left out, and the turn goes on.
- Surfaces. A connection is used only on surfaces allowed to reach restricted tools (
HUBZOID_RESTRICTED_SURFACES). A shared Slack channel never carries the token of the person who mentioned the agent. - Scope. Only the main agent gets personal servers. Delegates do not. Anonymous callers and people who have not connected get nothing extra.
- Credentials. The token travels only in the MCP client's
Authorizationheader. It never enters the prompt, a log line or a tool result.
Connect from chat (optional)
With HUBZOID_CONNECT_JOURNEY=true in the Hub's .env, a person can ask the agent to connect an app, for example "connect my Gmail", in the web chat or on WhatsApp. It is off by default and built only on the native MCP connections above.
- The agent gets a
connect_accounttool on every runtime. It checks the person'sconnector_<app>capability (on a Hub that still uses web chat groups, membership of a group with that name) and answers either that the app is already connected or with a personal link to/portal/connect/<id>, never a provider URL. - The link works only for the person who asked, signed in to the web chat, and expires after
HUBZOID_CONNECT_TTLseconds (600 by default). A signed-out person signs in and comes back to the link. - After the provider's consent screen, a Hubzoid page confirms the result. It checks the connection the web chat stored, never the parameters on the return address. On WhatsApp, the person also gets a confirmation message and can continue the request that was waiting.
- It needs
OWUI_NATIVE_MCP=true, one registered OAuth 2.1 server per app, the surface inHUBZOID_RESTRICTED_SURFACES(addwhatsappfor WhatsApp), andWEBUI_URLset to the public address.
The shared browser
HUBZOID_BROWSER=true gives the Hub one shared, resource-limited browser, exposed as the Playwright MCP tools (browser_navigate, browser_click, browser_snapshot and the rest). One browser serves every agent in the Hub, so several agents do not mean several browsers. Nothing else needs configuring: Hubzoid adds a playwright entry to the Hub's MCP servers, and an entry of your own with that name replaces it.
It runs in one of two modes:
| Mode | When | What you get |
|---|---|---|
| Direct | Only HUBZOID_BROWSER=true is set | Hubzoid starts the Playwright MCP sidecar with npx on port 8931 and stops it with the Hub. One headless browser, with a separate in-memory context per session. Optional memory watchdog with HUBZOID_BROWSER_MAX_RSS_MB. |
| Pooled | HUBZOID_BROWSER_CDP_URL points at a browserless pool | The sidecar attaches to the pool, which enforces hard limits: a fixed number of parallel sessions, a queue for the rest, a timeout for stuck sessions and a memory ceiling. Recommended for production. |
Direct mode needs Node on the host and the Playwright Chromium build, installed once:
npx -p @playwright/mcp@latest playwright install chromiumWithout npx, the Hub starts without browser tools and logs a warning. For production, the shipped compose file runs the pool and the sidecar and points the Hub at them:
docker compose -f docker/docker-compose.yml -f docker/browser-compose.yml upMemory grows with the number of browser sessions active at the same time, not with the number of agents, so HUBZOID_BROWSER_CONCURRENT is the setting that bounds it and HUBZOID_BROWSER_MEMORY is the hard ceiling. Every browser variable is listed in configuration. The browser tools come from the Hub's MCP servers, so they serve chat, tasks and workflows, and are not relayed to MCP clients.
When to restrict a tool instead
Put a tool in restricted/<permission>.py instead of tools_local/ when it reads sensitive data or takes an action that only some people, or some workflows, should trigger. The code is the same @function_tool. The file name is the permission: every tool in restricted/finance.py needs finance.
- Every call is checked against the caller's permission before the tool runs, and recorded in the audit log. On every runtime the agent does not even see the tool unless the caller holds the permission.
- Surfaces without a verified personal login, such as Slack channels, never reach it.
- Secrets go in
restricted/.env, which the model cannot read through the file tools. - A workflow reaches it only when the account it runs as holds the permission. A legacy Hub with no account configured for its workflows keeps the old service identities,
workflow:<name>for a code workflow andworkflow:md:<task>for a markdown task, granted like a person. See workflows and schedules.
The full walkthrough is in restrict tools.
Next steps
Design an agent
Plan a Hub before you build it. Choose what the agent knows and can reach, agree the plan with the people it serves, then map each part to a folder.
Bring an existing agent
Move a Claude Code or similar agent project into a Hub file by file, or keep your assistant and connect it to a Hub through MCP.
