Chat surfaces

WhatsApp and Telegram

Let teammates ask the agent from WhatsApp or Telegram, with each sender matched by the phone number you record and refused when unknown.

WhatsApp and Telegram reach the Hub through webhooks: the provider posts each message to your Hub, Hubzoid checks who sent it, and the agent answers in the same chat. The webhook address is public, so the phone numbers you record decide who may talk to the agent at all.

By the end of this page your Hub will answer recorded teammates on WhatsApp or Telegram, and refuse everyone else without reaching the model. It suits people who work from a phone, such as store staff or a manager on the move.

What you need

  • A Hub behind a public HTTPS address the provider can reach, with sign-in on. See Single server.
  • For WhatsApp, a Meta app with the WhatsApp Cloud API. For Telegram, a bot token from @BotFather.
  • Each teammate's phone number, recorded in Settings, and their Use this agent grant.

Turn the channel on

Add one file per surface to the Hub's channels/ folder:

my-hub/channels/whatsapp.yaml
version: 1
id: whatsapp
provider: whatsapp_inbound
enabled: true

Add the provider's settings

my-hub/.env
WHATSAPP_VERIFY_TOKEN=any-string-you-choose
WHATSAPP_APP_SECRET=<Meta app secret, from App settings, Basic>
WHATSAPP_TOKEN=<Graph API access token>
WHATSAPP_PHONE_NUMBER_ID=<from WhatsApp API setup>

All four are required.

Start the Hub

Terminal
hubzoid run my-hub

hubzoid run starts the inbound server with the Hub. A surface whose settings are missing stays off, the start names what is missing, and the rest of the Hub keeps running.

Point the provider at your Hub

Every inbound address is https://<host>/webhooks/<hub>/<surface>. <hub> is the folder name in lowercase, with anything other than letters and digits turned into hyphens, such as https://hub.example.com/webhooks/my-hub/whatsapp.

  1. In Meta's configuration, set the callback URL to https://<host>/webhooks/<hub>/whatsapp, the verify token to your WHATSAPP_VERIFY_TOKEN, and subscribe to the messages field. Hubzoid answers Meta's handshake only when the verify token matches.
  2. Subscribe the WhatsApp Business Account to the app with POST /{waba-id}/subscribed_apps on the Graph API. Without it, messages do not reach the webhook.

Record each person's number

An organization administrator opens Settings → People → the person and adds their Phone with the country code, or fills in Phone in Add user. See a user's details. The person also needs Use this agent on the agent's Access tab.

Send a message

On WhatsApp, message the business number. On Telegram, send /start to the bot, tap Share my number, then ask. A Telegram bot cannot message anyone first, so share its link with your team another way.

Who a sender is

A sender is matched to a person in this order:

  1. A phone number recorded in Settings. A WhatsApp sender, or a Telegram sender who shared their number, with that number is that person. One number belongs to one person, and a blocked person is not matched.
  2. The Hub's own contact list in its identity/ folder, for numbers Settings does not know.

A number neither knows gets a fixed refusal and nothing else. Who a sender is never decides what they may do: that is their grants in Settings. A known sender without Use this agent gets no answer, and the refusal is logged.

The contact list

my-hub/identity/access.csv
phone,email,groups,store
+44 7700 900101,asha@example.com,stock-lead,harbor
447700900102,ben@example.com,,market-street
Column rules, and how the file is reread
ColumnRule
phoneRequired. Only the digits are compared, so +44 7700 900101 and 447700900101 match. Use the full international form
emailRequired for access. Their grants in Settings attach to it. A row without an email is not registered
groupsOptional, separated by ;. Descriptive labels that grant nothing
Anything elseAllowed, for example a store or a name

Rows that share an email combine, so one person can have several numbers. The file is reread when it changes, so an edit applies to the next message. Save it in one step (write a new file, then rename). If it cannot be read, every lookup denies.

Look people up in your own directory instead

To look people up in your own directory instead, put a function in identity/access.py. It wins over the CSV:

my-hub/identity/access.py
def resolve(surface, handle):
    """handle is the sender's phone number. Return a record or None."""
    row = staff_directory.find_by_phone(handle)
    if row is None:
        return None
    return {"email": row.email, "groups": row.roles}

Returning None, or raising an error, denies the sender.

Unknown numbers get nothing

With no numbers recorded in Settings and no contact list, every sender is refused. Unknown senders never reach the model, tools or data.

How a message is handled

  1. Verify request
  2. Drop duplicates
  3. Match the number
  4. Load history
  5. Ask the agent
  6. Send reply
  7. Save the turn
Unknown senders stop at the number match.

The provider gets its 200 ok straight away and the work runs in the background, because Meta and Telegram resend when an answer is slow.

Request checks, streaming and formatting on each provider

The duplicate check absorbs those. The message then goes to the Hub's bridge with the sender's email, the surface (whatsapp or telegram) and that chat's recent history, signed with the deployment key.

WhatsAppTelegram
Request checkX-Hub-Signature-256, an HMAC of the body under your app secret. Anything else gets 403The secret in X-Telegram-Bot-Api-Secret-Token. Anything else gets 403
DuplicatesDropped by message idDropped by update id
While the agent worksThe message is marked read with a typing indicatorA typing indicator
The answerSent in one piece, because WhatsApp cannot edit a messageStreams by editing one message, at most once per INBOUND_STREAM_INTERVAL seconds (1.0, never below 0.8). TELEGRAM_STREAM=false sends it once
FormattingBold, strikethrough and monospace. Links become label: url, tables a monospace blockTelegram HTML with real links
LengthCapped at 4,096 charactersCapped at 4,096 characters

Telegram does not reveal a phone number on ordinary messages, so each person verifies once with Share my number. Hubzoid accepts only the sender's own contact and stores the link between their Telegram id and number under <hub>/.inbound/telegram-bindings/. Removing their number from Settings or the contact list stops it.

Meta's 24-hour window

Meta allows free-text replies only within 24 hours of the person's last message. A message your Hub starts on its own, such as a scheduled summary, must use an approved template. hubzoid.whatsapp.send provides send_text and send_template for code you write.

Fixed replies

Handshake and refusal replies are fixed text sent without the model. Override any of them in the Hub's .env.

Every fixed reply, its variable and its default
VariableSent whenDefault
INBOUND_MSG_NOT_REGISTEREDA number nobody has recorded"This number is not registered for access."
INBOUND_MSG_VERIFY_PROMPTTelegram /start, with Share my number"Please tap the button below to verify your number."
INBOUND_MSG_PLEASE_VERIFYA Telegram message before verifying"Please verify first. Tap the button below to share your number."
INBOUND_MSG_VERIFIEDAfter a Telegram verification"You are verified. How can I help?"
INBOUND_MSG_NOT_OWN_CONTACTSomeone shares a contact that is not their own"Please share your own number to verify."
INBOUND_MSG_NO_RESPONSEThe agent produced no visible text"Sorry, I do not have a response for that. Please try again."

A person can say "connect my Gmail" in WhatsApp. The agent's connect_account tool sends a personal link for a connector the agent offers, and the person signs in to that app on the link's page. If the reply leaves the link out, Hubzoid adds it. Once the link is used, the person gets one message saying the app is connected or not, with an offer to continue the request. A "YES" within 10 minutes sends it again as a new turn, so every access check runs again.

It needs the connector offered in the agent, whatsapp in HUBZOID_RESTRICTED_SURFACES, the connector's capability granted, and an account for the person's email. HUBZOID_CONNECT_JOURNEY=false turns it off, and HUBZOID_CONNECT_TTL sets how long a link lasts (600 seconds, from 60 to 3,600). Telegram has no confirmation or continuation.

Attachments and history

Media follows the same path as the web chat. Hubzoid downloads the file into the chat's folder, <hub>/.hubzoid/chats/<chat_id>/uploads/. Images are shown to the model, and other files are read with read_upload. Voice notes are stored, but the agent understands them only if the Hub has a transcription tool. Files over HUBZOID_MAX_UPLOAD_BYTES (25 MiB) or that fail to download are skipped. The agent answers with text.

WhatsApp and Telegram do not resend the conversation, so the inbound server keeps each chat's recent turns in the hz_inbound_history table of the hub database:

  • INBOUND_HISTORY_MAX (40 by default) is how many messages are kept and sent per chat.
  • INBOUND_HISTORY_TTL_DAYS deletes a chat's turns older than that many days when the chat saves a new turn.
  • History is kept per chat (whatsapp-<number>, telegram-<id>), so two senders never see each other's conversation. Only the visible answer is saved.
  • Messages from one chat are handled one at a time, in order. Different chats run in parallel.
  • Give each inbound Hub its own database rather than pointing several at one DATABASE_URL.

Restricted tools

Restricted tools are off on whatsapp and telegram by default. To allow them, list every surface you want, because the setting replaces the default, then grant the capability and restart the Hub:

my-hub/.env
HUBZOID_RESTRICTED_SURFACES=web,api,mcp,workflow,whatsapp,telegram

Run it for a team

All inbound surfaces share one server on a loopback port, 127.0.0.1:8100 by default (HUBZOID_INBOUND_PORT). The Hub's public port forwards /webhooks/<hub> to it. The server runs inside its Hub and restarts with it, so do not also run hubzoid inbound run next to it. Its output is in hubzoid logs my-hub -f.

Several Hubs, machine events and reviewed WhatsApp intake

When hubzoid run runs several Hubs, each Hub with an inbound surface gets its own /webhooks/<hub> route and its own port, chosen automatically. A HUBZOID_INBOUND_PORT in a Hub's .env is still honoured, and two Hubs that set the same one stop the start. When two folder names give the same slug, the second gets -2. HUBZOID_HUB_SLUG fixes the slug.

The same server can take machine events at /webhooks/<hub>/<name> with a channels/webhook.yaml file and WEBHOOK_INBOUND_SECRET. See Webhooks.

For a company WhatsApp Business number where each message waits for a person to review before work starts, Hubzoid also has a whatsapp_cloud connection managed with hubzoid channels and hubzoid intake. See the CLI reference.

If something goes wrong

What you seeWhat to do
The start says a surface is off and names missing settingsAdd those variables to my-hub/.env and restart
"This number is not registered for access."Record the sender's number under People, or add it to identity/access.csv
A known sender gets no answerGrant them Use this agent on the agent's Access tab
Meta's webhook check failsThe callback's verify token must equal WHATSAPP_VERIFY_TOKEN
WhatsApp messages never arriveSubscribe the WhatsApp Business Account to the app with POST /{waba-id}/subscribed_apps
Telegram deliveries get 403Register the webhook again with secret_token equal to TELEGRAM_WEBHOOK_SECRET

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.