---
title: Tools and connectors
description: 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.
canonical_url: https://hubzoid.com/docs/guides/tools-and-connectors
last_updated: 2026-09-27
---

# 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](https://hubzoid.com/docs/reference/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](https://hubzoid.com/docs/guides/tools-and-connectors#when-to-restrict-a-tool-instead).

## Add a Python tool

The example gives the operations desk of a home goods retailer with four stock locations a live stock lookup.

1. **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.

   ```python title="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())
   ```

2. **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.

3. **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:

   ```bash
   hubzoid test ./ops-desk --prompt "How many LMP-2041 lamps are in the north store?"
   ```

4. **Keep it working**

   Add an [eval](https://hubzoid.com/docs/guides/evals) that expects the tool, so a later change to `AGENTS.md` cannot quietly stop the agent from using it.

   ```markdown title="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](https://hubzoid.com/docs/guides/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](https://hubzoid.com/docs/reference/hub-files#agent-definitions-agents).

## Connect an MCP server

Add servers to `connectors/.mcp.json`. The format is the familiar `mcpServers` map.

```json title="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.

1. **Turn it on**

   Add to the Hub's `.env` (in a gateway, to the environment of `hubzoid gateway`, where it applies to every Hub):

   ```bash title=".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.

2. **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.

3. **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.

4. **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:

| 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:

```bash
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:

```bash
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](https://hubzoid.com/docs/reference/configuration#tools-and-the-shared-browser). 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](https://hubzoid.com/docs/concepts/workflows-and-schedules).

The full walkthrough is in [restrict tools](https://hubzoid.com/docs/guides/restrict-tools).

## Next steps

- [Restrict tools](https://hubzoid.com/docs/guides/restrict-tools): Gate a tool behind a permission granted in the Console.
- [Built-in tools](https://hubzoid.com/docs/reference/built-in-tools): The tools every Hub already has, with their limits.
- [Evals](https://hubzoid.com/docs/guides/evals): Check that the agent keeps using your tools correctly.
- [Connect an assistant](https://hubzoid.com/docs/guides/connect-an-assistant): Serve your Hub's tools to Claude Code, Cursor and others.
