Chat surfaces

OpenAI-compatible API

Call a Hub's agent from your own code in the OpenAI chat completions format, with a personal API key or, on the server itself, a bridge key.

Every Hub answers in the OpenAI chat completions format at /v1, so OpenAI's own clients and any tool that speaks that format can ask the agent. The agent runs with a real person's access, tools and knowledge, exactly as in the chat.

By the end of this page you will have asked the agent from a script and read its answer. There are two ways in:

Way inWhere it worksUse it for
A personal API key (hzp_...)The deployment's public address, with sign-in onScripts, CI jobs and your own app's server, acting as one person
A bridge key with a signed identityOnly on the server, on 127.0.0.1Code running next to the Hub. Hubzoid's Slack app and WhatsApp and Telegram server use it

For people, use the web chat, Slack or WhatsApp and Telegram. For an assistant such as Claude Code, use MCP. For scheduled work, a code workflow calls the agent with hub.call_agent and needs no key. To put the chat inside your own web app, see Chat inside your own app.

What you need

  • A deployment with sign-in on, and an account that holds Use this agent. See Authentication.
  • Python with the openai package, or curl.

Create a personal API key

In the web app open Settings → Account → Security → API keys and create a key with the Chat preset. Or, after hubzoid auth login --server https://hub.example.com:

Terminal
hubzoid auth keys create "Nightly summary" --preset chat --server https://hub.example.com

The key is shown once. Hubzoid keeps only its digest. It expires after 90 days by default (--days), and --agent limits it to one agent.

Find the model id

Terminal
export HUBZOID_KEY=hzp_...
curl -s https://hub.example.com/v1/models -H "Authorization: Bearer $HUBZOID_KEY"

The answer lists the agent's model id. With several Hubs, each agent has its own address, https://hub.example.com/b/<agent>/v1, and the bare /v1 is the first agent.

Ask the agent

ask_hub.py
import os

from openai import OpenAI

client = OpenAI(base_url="https://hub.example.com/v1", api_key=os.environ["HUBZOID_KEY"])
reply = client.chat.completions.create(
    model="my-hub",
    messages=[{"role": "user", "content": "When is support open?"}],
)
print(reply.choices[0].message.content)

The agent answers from the Hub's knowledge with the key owner's access. With a personal key the answer is the answer text only: tool activity and thinking are left out.

What a personal key can do

PresetWhat it reaches
Chat (chat)Asking agents (/v1/chat/completions, /v1/models), and the person's own conversations, files and shares
Read (read)Any read the person can make
All (all)Chat, read, and the changes the person is allowed
  • A key never does more than its owner can do right now. Blocking or deleting the person, or removing their access, applies to their keys at once.
  • A key limited to some agents can only chat, only with those agents, and only through /v1.
  • A key never signs in, changes a password or two-step sign-in, creates other keys, or answers an approval. A step that needs an approval waits for the person, and the answer says so.
  • A request that carries both a session cookie and a key is refused.

The deployment serves a reference of its public API at /docs, titled Hubzoid API, with the schema at /openapi.json.

On the server: a bridge key

Each Hub's bridge listens on 127.0.0.1 only, on BRIDGE_PORT, else the first free port from 8000. The public port answers 401 for /v1 without a personal key, and 404 for the bridge's other internal routes (/uploads, /otel).

Keep the bridge on loopback

A holder of a bridge key reaches the agent directly and vouches for who is asking. That is why it stays off the public port.

Requests carry a bridge key as a Bearer token. Keys come from BRIDGE_API_KEYS in the Hub's .env, a comma-separated list. hubzoid init writes a random one into a new Hub's .env.

The dev key, rotating keys and a wrong key
  • With no value set, the bridge accepts the public key dev and logs a warning. hubzoid doctor reports an unset list, or one that includes dev, as a failure (auth.bridge_keys).
  • The Slack app and the inbound server use the first key in the list, so you can add a second key for your own code and rotate it separately.
  • A missing or wrong key returns 401 with {"detail": "invalid api key"}. Key changes apply when the Hub restarts.

Who is asking

The bridge key proves the caller may reach the bridge. It does not say who is asking, and the bridge refuses a request without a verified person who holds Use this agent. The person is named in headers:

Prop

Type

The headers count only when X-Hubzoid-Assertion covers exactly their values. Python code on the server can sign them the way Hubzoid's own processes do. It needs the deployment key, so run it with the Hub's environment: HUBZOID_SECRET_KEY when set, otherwise <hub>/.hubzoid/secret.key, or the secret.key in the deployment's data folder when several Hubs run together.

ask_bridge.py
from pathlib import Path

import httpx
from hubzoid.assertions import identity_headers

hub = Path("/opt/hubzoid/agents/my-hub")
headers = {
    "Authorization": "Bearer <a key from BRIDGE_API_KEYS>",
    # Sign just before sending: the signature is valid for 60 seconds.
    **identity_headers(hub, surface="api", email="priya@example.com"),
}
reply = httpx.post(
    "http://127.0.0.1:8000/v1/chat/completions",
    headers=headers,
    json={
        "model": "my-hub",
        "chat_id": "nightly-summary",
        "messages": [{"role": "user", "content": "When is support open?"}],
    },
    timeout=300,
)
print(reply.json()["choices"][0]["message"]["content"])
An unreadable key, restricted tools and tool blocks in the answer

If the deployment key cannot be read, identity_headers leaves the assertion out and the bridge treats the call as anonymous. With X-Hubzoid-Surface: api, restricted tools are reachable for a person who holds their capability.

On the bridge, the answer text is what Hubzoid's chat surfaces receive. With the default SHOW_TOOLS=compact it includes each tool call as a <details> block, and on claude-local the reasoning SHOW_THINKING allows as <think> blocks. Set SHOW_TOOLS=off in the Hub's .env for answers without tool blocks.

Requests and responses

A blocking POST /v1/chat/completions answers with one chat.completion object: an id, the model, one entry in choices whose message holds the assistant's content with "finish_reason": "stop", and the token usage. Read the answer from choices[0].message.content, as in the example above.

Set "stream": true to read server-sent events: a first chunk with the assistant role, one chat.completion.chunk per text delta with the same id and created, a chunk with "finish_reason": "stop", a final chunk with empty choices and the token usage, and data: [DONE]. Some chunks carry a status event, such as a tool starting, and no text. OpenAI's clients skip them.

Prop

Type

Send the whole conversation each turn. A system message from the caller becomes part of the prompt but does not replace the Hub's own instructions, knowledge or tools. Other OpenAI parameters, such as temperature, tools or response_format, are not applied: the Hub's model settings and tools decide.

Files on the bridge

Attach a file inline as a base64 data URL. Images use an image_url part. Other files use an input_file or file part with a name:

Message
{
  "role": "user",
  "content": [
    { "type": "text", "text": "Compare this count sheet with the system stock." },
    { "type": "input_file", "name": "count-sheet.csv", "data": "data:text/csv;base64,c2t1LGNvdW50ZWQK..." }
  ]
}

Or upload the raw file first, with the bridge key, and refer to it by name in the same chat:

Terminal
curl -s -X POST http://127.0.0.1:8000/uploads/nightly-summary/count-sheet.csv \
  -H "Authorization: Bearer $HUBZOID_BRIDGE_KEY" \
  -H "Content-Type: text/csv" \
  --data-binary @count-sheet.csv
Where the chat id and the chat's files come from

A chat id comes from chat_id, metadata.chat_id (or metadata.conversation_id), the X-Hubzoid-Chat-Id header, user, and finally a hash of the first user message. Files for that chat live in <hub>/.hubzoid/chats/<chat_id>/. A file over HUBZOID_MAX_UPLOAD_BYTES (25 MiB) returns 413. Files the agent saves with write_artifact come back as signed links in the answer, and can be fetched from /artifacts/{chat_id}/{filename} with the bridge key.

Bridge endpoints

Method and pathAuthPurpose
GET /healthznoneLiveness, with status, hub, agent and model
GET /v1/modelsbridge keyThe Hub's one model
POST /v1/chat/completionsbridge key and a verified personRun the agent, blocking or streamed
POST /uploads/{chat_id}/{filename}bridge keyStore a file in a chat's uploads folder
GET /artifacts/{chat_id}/{filename}signed link, the conversation's owner, or the bridge keyDownload a file the agent wrote. The key dev is refused here

The full list, including the Settings and MCP routes, is in HTTP endpoints.

If something goes wrong

What you seeWhat to do
401 "Send your personal API key: Authorization: Bearer hzp_..."The public /v1 needs a personal key. Send it as a Bearer token
401 with {"detail": "invalid api key"}On the bridge, the key is not in BRIDGE_API_KEYS. Restart the Hub after changing it
403 The hub requires sign-inThe bridge found no verified person. Sign the identity headers just before sending
403 You do not have access to the hubThe person lacks Use this agent. Grant it on the agent's Access tab
404 for a modelThe model names another agent. Use that agent's own /b/<agent>/v1 address
More problems and fixes
What you seeWhat to do
403 for a blocked personTheir account is blocked
503 The access check is unavailableThe access store cannot be read. Nothing runs

Next steps

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.