Hubzoid
Guides

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 needUseConfigured in
Your own logic, or a call to an internal API with one service credentialA Python tooltools_local/*.py
A capability an MCP server already providesAn MCP connectorconnectors/.mcp.json
Each person to act with their own account on an MCP serverPersonal MCP connectionsOWUI_NATIVE_MCP=true, the server registered in the web chat, and connector_<app> grants on a Console-managed Hub
Pages that need a real browserThe shared browserHUBZOID_BROWSER=true
A tool only some people or workflows may useA restricted toolrestricted/<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.

tools_local/stock.py
"""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.

evals/stock-lookup.md
---
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_tool becomes 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.environ inside 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-local the model sees it as mcp__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.

connectors/.mcp.json
{
  "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, args and env, and run as child processes.
  • Remote servers use url and headers. Set "transport": "streamable-http" (or "http") for a Streamable HTTP server. A server with a url and no transport is treated as SSE.
  • Secrets. ${NAME} in any string is replaced with that variable after the Hub's .env is loaded. Keep tokens in .env, never in the JSON file.
  • Timeouts. client_session_timeout_seconds raises the per-call timeout for slow servers on the OpenAI Agents and Codex runtimes.
  • Names. On claude-local the model sees mcp__<server>__<tool>, for example mcp__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):

.env
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-local the tools appear as mcp__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 Authorization header. 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_account tool on every runtime. It checks the person's connector_<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_TTL seconds (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 in HUBZOID_RESTRICTED_SURFACES (add whatsapp for WhatsApp), and WEBUI_URL set 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:

ModeWhenWhat you get
DirectOnly HUBZOID_BROWSER=true is setHubzoid 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.
PooledHUBZOID_BROWSER_CDP_URL points at a browserless poolThe 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 chromium

Without 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 up

Memory 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 and workflow:md:<task> for a markdown task, granted like a person. See workflows and schedules.

The full walkthrough is in restrict tools.

Next steps

Read this page as Markdown

Choose which cookies Hubzoid can use. You can change this at any time from Cookie settings in the footer. Read the Cookie Policy for details.