---
title: Architecture
description: How a request moves through Hubzoid, from chat surfaces and the edge to the bridge, agent runtimes, models, the workflow engine, databases and the Console.
canonical_url: https://hubzoid.com/docs/concepts/architecture
last_updated: 2026-09-27
---

# Architecture

How a request moves through Hubzoid, from chat surfaces and the edge to the bridge, agent runtimes, models, the workflow engine, databases and the Console.

Hubzoid is a small set of processes around one idea: every surface reaches the same Hub through a single bridge per Hub. The bridge builds the agent from the Hub folder, enforces identity and access, runs scheduled work, serves the Console and the hosted MCP endpoint, and streams answers back in the OpenAI chat format.

Two seams keep the parts that change most often, the surfaces and the model, replaceable without touching the Hub folder.

## A chat request, end to end

Surface → Edge on :3080 → Open WebUI → Bridge /v1 → Agent runtime → Model provider

A web chat turn. Other surfaces join at the bridge.

1. The browser reaches the **edge** on the public port (`PORT`, default 3080). The edge passes the chat app's pages and websockets to Open WebUI on a loopback port.
2. **Open WebUI** signs the person in (when sign-in is on), stores the conversation, and calls the bridge's `POST /v1/chat/completions` with the bridge key and the person's email in forwarded headers.
3. The **bridge** derives a chat id and the caller's identity (email, groups, surface). If Hubzoid manages access for this Hub, it requires the `use_hub` grant before anything runs. It saves any attached files to the chat's upload folder and flattens the message list into one prompt.
4. The **runtime** runs the Hub's agent: system prompt, tools, skills, knowledge and MCP connectors. Restricted tools pass through the access guard, which records every allow or deny decision before a call runs.
5. The **model provider** answers through LiteLLM, through the `claude` CLI for `claude-local`, or through the `codex` CLI for `codex-local`.
6. The bridge streams the answer back as server-sent events and records a usage row with tokens, estimated cost and duration, never message content.

### A turn that calls a tool

The model never runs a tool itself. It asks, and the bridge checks access, runs the tool and hands back the result, as often as the answer needs. Here is one web chat turn on a Hub with a restricted `ledger_lookup` tool.

## Processes and ports

`hubzoid run my-hub` starts these processes. Only the edge port is meant to be reachable from other machines.

| Process | Started as | Listens on | Role |
| --- | --- | --- | --- |
| Bridge | `uvicorn hubzoid.server:build_app` | `127.0.0.1:8000` (`BRIDGE_PORT`, `--bridge-port`) | Agent runtime, access guard, workflow engine, Console and artifact viewer, hosted MCP. Always loopback. |
| Open WebUI | `open-webui serve` | `127.0.0.1`, port `PORT` plus 40000 (43080 by default, `HUBZOID_OWUI_PORT` to override) | Web chat, accounts, sessions, chat history, per-user API keys. |
| Edge | `uvicorn hubzoid.edge:_factory` | `HUBZOID_HOST:PORT`, default `127.0.0.1:3080` | The single public front door. Routes paths to the bridge or Open WebUI. |
| Slack adapter | `hubzoid slack run` (with `--slack`) | No port. Slack Socket Mode connects outward. | Relays Slack messages to the bridge. |
| Inbound server | `hubzoid inbound run` (with `--whatsapp`, `--telegram` or `--webhook`) | `127.0.0.1:8100` (`HUBZOID_INBOUND_PORT`) | Verifies and handles WhatsApp, Telegram and generic webhook deliveries. |
| Shared browser | `playwright-mcp`, started by the bridge when `HUBZOID_BROWSER=true` | Port 8931 (`HUBZOID_BROWSER_PORT`) | One resource-limited browser shared by every agent as MCP tools. |

`--no-ui` starts the bridge alone. `HUBZOID_DISABLE_EDGE=1` removes the edge and binds Open WebUI to the public port directly, which also removes the public routes to the bridge below.

### Edge routes

| Path | Goes to | Present when |
| --- | --- | --- |
| `/artifacts/...` | Bridge | Always. Download links for files the agent saved, signed per Hub. |
| `/portal/...` | Bridge | Always. The Console, its JSON API and the artifact viewer at `/portal/artifacts/<id>`. |
| `/mcp` | Bridge | `MCP_SERVER=true`. |
| `/webhooks/<hub>/...` | Inbound server | An inbound surface is enabled. Each delivery is verified before anything runs. |
| Everything else | Open WebUI | Always. |

`/v1`, `/uploads` and `/healthz` stay on the loopback bridge. The edge refuses `.` and `..` path segments and drops client-sent `X-Hubzoid-*` and `X-OpenWebUI-*` headers, so a browser cannot assert its own identity.

## How each surface reaches the Hub

| Surface | Path into the bridge | Identity and surface name |
| --- | --- | --- |
| Web chat | Open WebUI calls `/v1/chat/completions` | The signed-in email, surface `owui` |
| Slack | The adapter calls `/v1/chat/completions` | `slack-dm` or `slack-channel`. The email is resolved when `SLACK_IDENTITY_MAPPING=true`. |
| WhatsApp, Telegram | The inbound server calls `/v1/chat/completions` | The sender from the `identity/` roster. Unknown senders are rejected first. |
| Generic webhook | The inbound server stores each event as a file for a `schedule/` task | No chat reply. A task picks up the events. |
| OpenAI-compatible API | Direct calls to `/v1/chat/completions` on the host, with a bridge key | The identity headers the calling application sets |
| MCP clients | Edge `/mcp`, served inside the bridge | The caller's Open WebUI API key, surface `mcp` |
| Markdown tasks, code workflows | In process, on the workflow engine | The account the run acts as (`run_as`, else `HUBZOID_WORKFLOW_USER`, else the owner recorded at setup), surface `workflow` |

Every surface builds on the same Hub files. Access rules differ per surface: by default only `owui`, `web`, `api`, `mcp` and `workflow` may reach restricted tools. See [identity and access](https://hubzoid.com/docs/concepts/identity-and-access).

## Runtime backends

The runtime is chosen from the resolved model id: `MODEL` in `.env`, then the main `AGENTS.md` model, then `claude-local`. Loaders, tools, skills and knowledge are the same on all three, so a Hub moves between them by changing `MODEL`.

|  | OpenAI Agents SDK | Claude Agent SDK | Local Codex |
| --- | --- | --- | --- |
| Selected when | `MODEL` is any id that does not start with `claude-local` or `codex-local` | `MODEL` is `claude-local` or `claude-local/<tier>` | `MODEL` is `codex-local` or `codex-local/<model-id>` |
| Model calls | LiteLLM, to OpenAI, Anthropic, Azure OpenAI, OpenRouter or another LiteLLM provider | The `claude` CLI as a subprocess, on a subscription login, `CLAUDE_CODE_OAUTH_TOKEN` or `ANTHROPIC_API_KEY` | The `codex` CLI app-server as a subprocess, pinned to version 0.147.0, on a file-backed `codex login` |
| Hub tools | Function tools on the agent | One in-process MCP server named `hubzoid`, tools seen as `mcp__hubzoid__<name>` | Offered to Codex as tools that Hubzoid runs itself |
| Built-in CLI tools | Not applicable | Disabled. Claude Code settings files are not read, and the box's own Claude account connectors are not offered. | Disabled. Each request uses a fresh thread and an isolated configuration with only the login copied in. |
| Delegate sub-agents | Called as tools, each on its own model | Native Claude sub-agents on their own tier | Called as tools, each a separate Codex run on its own model |

On all three, a person's own Open WebUI MCP connections can join their turns, and controlled tools a person may not use are left out. On a Hub whose access is managed in the Console, each connected app also needs its `connector_<app>` capability. [Agents and models](https://hubzoid.com/docs/concepts/agents-and-models) covers model ids, delegation and model settings.

## Workflow engine

Scheduled work runs on an embedded [DBOS](https://docs.dbos.dev) engine inside the bridge process. There is no separate scheduler service.

| Work | Starts when | Queue |
| --- | --- | --- |
| Markdown tasks (`schedule/*.md`) and scheduled evals | Their files exist. `HUBZOID_DISABLE_SCHEDULE=1` turns them off. | One run at a time per Hub. A due task waits while a chat request is in flight. |
| Code workflows (`workflows/<name>/*.py`) | `HUBZOID_SCHEDULES=1` on a single Hub, or automatically under `hubzoid gateway`. | One run at a time per workflow, side by side across workflows. `max_concurrent_workflows` in `workflows/settings.yaml` caps the Hub. |

Markdown tasks are checked every 30 seconds and code workflows every minute. Missed slots are recorded: a markdown task that became due while the Hub was down runs once to catch up, and a code workflow waits for its next slot. DBOS checkpoints each step, so an interrupted code workflow resumes after a restart without repeating finished steps, as long as its code has not changed. Manual runs from `hubzoid schedule run` go through the same queues. See [workflows and schedules](https://hubzoid.com/docs/concepts/workflows-and-schedules).

## Databases

A single Hub uses SQLite files inside its folder. Setting `DATABASE_URL` to a `postgresql+psycopg://` URL moves all of them into one PostgreSQL database.

| Store | Default location | Holds |
| --- | --- | --- |
| Operational store | `<hub>/.hubzoid/hub.db` | Grants, identities, the access change log, tool decisions (`hz_access_decisions`), usage rows (`hz_usage`), the workflow catalog, `hub.state` and `hub.shared_state` (`hz_workflow_kv`), artifact records (`hz_artifacts`) and email deliveries (`hz_email_deliveries`). Artifact files stay in `<hub>/.hubzoid/artifacts/`. |
| Hub database | Same file on a single Hub | WhatsApp and Telegram conversation history (`hz_inbound_history`). |
| Workflow engine | `<hub>/.hubzoid/dbos.db` | Runs, steps, checkpoints and queues. In PostgreSQL, the `dbos` schema. |
| Chat app | `<hub>/.openwebui-data/webui.db` | Accounts, sessions, chats, files and per-user API keys. |

Hubzoid upgrades its own `hz_*` tables at start and refuses a database written by a newer release. Under `hubzoid gateway`, the operational store and the chat app database are shared by all Hubs in the gateway's data folder (`hubzoid-operational.db` and `webui.db`), while each Hub keeps its own workflow engine file. `HUBZOID_OPERATIONAL_DB` and `HUBZOID_DBOS_DB` set those stores separately.

## Hosted MCP server

With `MCP_SERVER=true`, the bridge serves the Hub over MCP Streamable HTTP at `/mcp`, and the edge exposes it. Callers authenticate with their own Open WebUI API key, never the bridge key. Each call runs under that person's identity on the `mcp` surface, so restricted tools follow the same grants as chat and every decision is recorded.

The MCP client brings its own model. The Hub serves its tools, including `list_knowledge`, `read_knowledge`, `list_skills` and `load_skill`, and sends the `AGENTS.md` body (or the `mcp_instructions:` frontmatter) as the server's instructions. Tools tied to a chat folder, such as `write_artifact` and `read_upload`, and delegate sub-agents are left out. See [connect an assistant](https://hubzoid.com/docs/guides/connect-an-assistant).

## The Console

The bridge serves the Console at `/portal/` with a JSON API under `/portal/api`. It validates the viewer's Open WebUI session on the server, or an Open WebUI API key (`Bearer sk-...`) on the API, and admits accounts that hold `manage_access` for the organization or for specific agents. A chat app admin role alone does not open it. Usage and run figures come from Hubzoid's own tables and the workflow engine. People come from the chat app's account directory, read only, and account changes such as **Add user** and **Delete user** go through the chat app's own API. Grants made in the Console take effect on the next permission check. Under the gateway, the chat app's model list follows within about 30 seconds. See the [Console overview](https://hubzoid.com/docs/console/overview).

## Many Hubs behind one gateway

`hubzoid gateway ./finance ./operations` runs one shared Open WebUI, one edge and one headless bridge per Hub. Each Hub appears in the chat app as its own model, and people see only the agents they may use.

Edge → Shared Open WebUI → Bridge per Hub → Hub folder

One sign-in and one Console in front of several independent Hubs.

| Path on the gateway | Goes to |
| --- | --- |
| `/b/<hub>/artifacts/...` | That Hub's bridge |
| `/b/<hub>/mcp` | That Hub's bridge, when its `MCP_SERVER=true` |
| `/webhooks/<hub>/...` | That Hub's inbound server |
| `/portal/...` | The first Hub's bridge, which serves the organization-wide Console |

Each Hub folder name is its access domain, so names must be unique without regard to case, and each Hub needs its own `BRIDGE_PORT`. See [gateway](https://hubzoid.com/docs/deploy/gateway) and [topologies](https://hubzoid.com/docs/deploy/topologies).

## Next steps

- [The Hub](https://hubzoid.com/docs/concepts/hub): What the bridge loads from the Hub folder for every run.
- [Deployment topologies](https://hubzoid.com/docs/deploy/topologies): Single Hub, gateway, SQLite or PostgreSQL.
- [Security model](https://hubzoid.com/docs/deploy/security-model): Where identity and access are enforced.
