Guides

Chat inside your own app

Put a Hubzoid agent's chat inside your own app, with each person signed in by your app and limited to the agents and tools you allow.

You will register your app with Hubzoid, give each signed-in person a short pass from your app's server and add a chat to your page that you design yourself. People chat as themselves, signed in by your app, and the agent's tools can reach your app with that person's own access. Your app's API does not change.

Hubzoid speaks AG-UI (opens in a new tab) at /api/agui, so you can build the chat with assistant-ui (opens in a new tab) or CopilotKit (opens in a new tab) using their own packages. Hubzoid ships no client or server library for this. The snippets below and a working example are all you need.

  1. Your app signs the person in
  2. Your server signs a pass
  3. Your page calls /api/agui
  4. Hubzoid checks the pass and the app limit
  5. Tools reach your app as the person
Your app vouches for the person. Hubzoid decides what they may use from your app's registration.

What you need

  • A Hub that your app's page can reach, such as https://hub.example.com. The examples use the starter Hub my-hub, so the agent is my-hub.
  • Your app's web address, here https://backoffice.example.com, a fictional back office for Kestrel & Oak.
  • A server route behind your app's own sign-in that can sign a JWT with any JWT library. If your app already signs people in with JWTs, see your app's own JWTs instead.
  • A React page for assistant-ui, or a Node server for the CopilotKit runtime.

Register the app

Make the app's client secret. The client id, backoffice here, is 2 to 40 lowercase letters, digits or _, starting with a letter.

Terminal
hubzoid embed secret backoffice --hub ./my-hub \
  --origin https://backoffice.example.com --agent my-hub \
  --tool app_lookup --tool read_knowledge --token-to app_lookup

The secret is stored write-only and shown once, with the entry to paste and the settings for your app's server:

Output
Client id: backoffice
Client secret (shown once):
<the secret>

1. Add this under apps: in hubzoid.yaml (the agents pick it up within seconds):

  backoffice:
    secret_ref: secret:deployment/app-backoffice
    origins: ["https://backoffice.example.com"]
    access:
      agents: ["my-hub"]
      tools: [app_lookup, read_knowledge]
    token_to: [app_lookup]

2. Give the app's server these settings:

HUBZOID_URL=https://<your Hubzoid address>
HUBZOID_CLIENT_ID=backoffice
HUBZOID_CLIENT_SECRET=<the secret above>

3. Check it: hubzoid embed list. The rest is in docs/embedding.md.

Put the secret straight into your app's server settings. Never commit it or paste it into chat. Without --tool, the app may use every tool of the agent.

Add the app to hubzoid.yaml

The command never edits files. Paste the entry under apps: in your deployment's hubzoid.yaml:

hubzoid.yaml
apps:
  backoffice:                                # the client id
    name: Kestrel & Oak back office
    secret_ref: secret:deployment/app-backoffice
    origins: ["https://backoffice.example.com"]   # pages that may call Hubzoid
    access:                                  # the most this app may allow
      agents: ["my-hub"]
      tools: [app_lookup, read_knowledge]
    token_to: [app_lookup]                   # tools that receive the person's app token
    max_lifetime: 3600                       # longest a pass may last, in seconds

A Hub without a hubzoid.yaml, such as one started with hubzoid run my-hub, reads the same apps: section from apps.yaml in its own folder. Every agent picks up the change within a few seconds. Then check it:

Terminal
hubzoid embed list --hub ./my-hub

The table lists each app with its mode, agents, tools and origins. The State column says ready, or why not, such as a missing secret.

Give the person a pass

Add one route behind your app's own sign-in that returns a pass for whoever is signed in. Give your server the three settings from step 1 first.

server.js
import { SignJWT } from "jose";
const secret = new TextEncoder().encode(process.env.HUBZOID_CLIENT_SECRET);

app.get("/hubzoid/pass", requireSignIn, async (req, res) => {
  const pass = await new SignJWT({
    name: req.user.name,
    access: { agent: "my-hub", tools: ["app_lookup", "read_knowledge"] }, // optional, narrower than the app's limit
    app_token: req.user.apiToken,            // the person's own token for your API (optional)
  })
    .setProtectedHeader({ alg: "HS256" })
    .setIssuer("backoffice")                 // the client id
    .setAudience("hubzoid")
    .setSubject(req.user.id)                 // your app's user id
    .setIssuedAt()
    .setExpirationTime("10m")
    .sign(secret);
  res.type("text").send(pass);
});

requireSignIn and req.user stand for your app's own sign-in. Every claim a pass may carry is listed under what a pass may say.

Try it by hand

Before you build the page, check that Hubzoid answers. hubzoid embed pass signs a short test pass for one of your app's user ids:

Terminal
PASS=$(hubzoid embed pass backoffice priya --hub ./my-hub)
curl -N https://hub.example.com/api/agui \
  -H "Authorization: Bearer $PASS" \
  -H "Content-Type: application/json" \
  -d '{"threadId": "t1", "messages": [{"id": "m1", "role": "user", "content": "When is support open?"}]}'

The answer streams as AG-UI events, starting with RUN_STARTED and ending with RUN_FINISHED, with the agent's reply in between. A refusal answers with a code and a message, listed under if something goes wrong.

Add the chat to your page

Every request carries a fresh pass. Behind a gateway of several agents, the agent's address is https://hub.example.com/b/my-hub.

assistant-ui is headless: you build the chat from its parts, and the browser talks to Hubzoid directly.

Terminal
npm install @assistant-ui/react @assistant-ui/react-ag-ui @ag-ui/client
Chat.jsx
import { useMemo } from "react";
import { AssistantRuntimeProvider } from "@assistant-ui/react";
import { HttpAgent } from "@ag-ui/client";
import { useAgUiRuntime } from "@assistant-ui/react-ag-ui";

const HUBZOID = "https://hub.example.com";   // the agent's address

async function withPass(url, init = {}) {
  const pass = await (await fetch("/hubzoid/pass")).text();
  return fetch(url, { ...init, credentials: "omit",
                      headers: { ...init.headers, Authorization: `Bearer ${pass}` } });
}

export function Chat({ threadId }) {
  const agent = useMemo(() => new HttpAgent({ url: `${HUBZOID}/api/agui`, threadId, fetch: withPass }), [threadId]);
  const runtime = useAgUiRuntime({ agent, resumeTranscript: "appended" });
  return <AssistantRuntimeProvider runtime={runtime}>{/* your chat UI */}</AssistantRuntimeProvider>;
}

credentials: "omit" matters: a request that carries a Hubzoid sign-in cookie as well as a pass is refused.

Both libraries are free on these paths, and both were tested with chat, tools, approvals and history. The look and feel is your page's: Hubzoid sends only AG-UI events.

Let tools act as the person

This step is optional. Tools named in token_to receive the person's own app token from the pass, so your API applies its own permissions as usual. Any other tool gets nothing.

A tool in the Hub reads it with app_token():

my-hub/tools_local/app_lookup.py
import httpx
from agents import function_tool
from hubzoid.embed import app_token


@function_tool
def app_lookup(query: str) -> str:
    """Look up orders in the back office for the signed-in person."""
    token = app_token()  # None unless this tool is in token_to and the reply came from your app
    r = httpx.get("https://backoffice.example.com/api/orders",
                  params={"q": query}, headers={"Authorization": f"Bearer {token}"})
    return r.text
Use your app's MCP server as a connector instead

Or add your app's MCP server as a connector: Settings → Agents → the agent → Connectors → Add connector → Custom server, with its address and No sign-in. List connector:<id> in the app's access.tools and token_to. The server then gets the token as a header on every call, Authorization: Bearer <token> unless you set token_header. It must use that per-request token, not one fixed key, and must not share cached results between people. Saving a connector in Settings needs browser editing turned on. Otherwise write it as a connectors/<id>.yaml file, described in tools and connectors.

Approvals and questions

When a tool needs a person's approval, or the agent asks a question, the run ends with an AG-UI interrupt: RUN_FINISHED with outcome: {type: "interrupt", interrupts: [...]}. Your page answers with a new run whose resume lists the answers, and the reply continues in that response.

How to answer each interrupt in assistant-ui and CopilotKit
InterruptAnswer with
An approval{interruptId, status: "resolved", payload: {approved: true}}, or false to reject. status: "cancelled" also rejects.
A question (reason: "question")payload: {answer}. Its choices are in metadata.hubzoid.options. status: "cancelled" skips it.

In assistant-ui, useAgUiInterrupts() lists them and useAgUiSubmitInterruptResponses() answers. In CopilotKit, useInterrupt({ render: ({ interrupt, resolve }) => ... }) shows them and resolve({ approved: true }) answers. Approvals still open after a reload come back from the thread's interrupts. The example shows how to show them again.

Your app's own JWTs

If your app already signs people in with JWTs (Auth0, Clerk, Keycloak, Okta or your own), Hubzoid can check them directly. You need no secret and no pass route:

hubzoid.yaml
apps:
  portal:
    mode: jwt
    issuer: https://kestrel-oak.eu.auth0.com/
    audience: https://hub.example.com
    jwks_url: https://kestrel-oak.eu.auth0.com/.well-known/jwks.json   # or public_key, or secret_ref
    origins: ["https://portal.example.com"]
    access: {agents: ["my-hub"], tools: [read_knowledge, app_lookup]}
    scopes:                                  # how access is decided
      claim: scope
      map:
        "chat:my-hub": {agents: ["my-hub"], tools: [read_knowledge, app_lookup]}
    # or, instead of scopes:  all_users: true
    token_to: [app_lookup]                   # these tools receive the person's JWT itself

A token without a mapped scope gets no access.

Several apps on one identity provider, and what your tools receive

Several apps may use one identity provider: the token's issuer and audience together pick the app, and a token that would match two apps is refused. Your tools receive your app's own JWT, so your API checks it as it already does.

Options

The app entry

Each app under apps: sets its origins, its secret and the most it may allow.

Every key of an app entry, and how to name tools
KeyWhat it does
nameThe app's display name. Defaults to the client id.
enabledfalse refuses the app's passes at once and stops replies it started.
originsWeb addresses whose pages may call Hubzoid. HTTPS, or HTTP only on localhost or 127.0.0.1.
modepass (the default) or jwt.
secret_refWhere the client secret is stored. secret:deployment/app-<id> from hubzoid embed secret, or env:<VARIABLE>.
accessThe most the app may allow: agents (at least one) and tools.
token_toTools that receive the person's app token. Never *.
token_headerThe header a connector receives the token in. Defaults to Authorization.
max_lifetimeLongest a pass may last, in seconds, from 60 to 86400. Defaults to 3600.

Name tools by the agent's own tool names, connector:<id> for a connector added in Settings, mcp:<server> for one of the Hub's MCP servers, or *. On a hosted deployment, you can skip the shell: put a random value of 32 or more characters in an environment variable and point the app at it with secret_ref: env:BACKOFFICE_CLIENT_SECRET.

What a pass may say

A pass needs iss, aud, sub, iat and exp. It may also carry labels, a narrower access and the person's app_token.

Every claim a pass may carry
ClaimMeaning
issThe client id. Required.
audhubzoid. Required.
subYour app's user id, as text. Required.
iat, expIssued and expiry times, at most max_lifetime apart. Required.
name, emailLabels shown in the chat. Optional.
access{agent or agents, tools}. Narrows the app's limit, never widens it. Without tools, every tool the app allows. tools: [] is no tools.
app_tokenThe person's own token for your API, sent only to the tools in token_to.

A pass is signed, not encrypted, so the browser can read it. Put in app_token only a token the browser may see, or mint a narrow, short-lived one for it.

Page context

Send what the person is looking at with each message as forwardedProps: { hubzoid: { page: { doctype: "Sales Order", name: "SO-1001" } } }, or as AG-UI context. The agent receives it as labelled data, never as instructions, at most 4,000 characters, and it never changes access.

Endpoints

All take Authorization: Bearer <pass or app JWT>. Behind a gateway, put the agent's address (/b/<agent>) in front.

Every route, and how Hubzoid keeps the history
RouteWhat it does
POST /api/aguiRun: an AG-UI RunAgentInput. Answers with AG-UI events.
GET /api/agui/threadsThis person's chats with this agent
GET /api/agui/threads/{id}One chat as AG-UI messages, whether a reply is running, and pending interrupts
GET /api/agui/threads/{id}/runRejoin the reply being written. 204 when there is none.
POST /api/agui/threads/{id}/stopStop the reply
DELETE /api/agui/threads/{id}Delete the chat

Hubzoid keeps the history. From messages only the new user message is used, and a chat has one reply at a time. A thread id your client makes up maps to a chat in this person's own space, so nobody can reach someone else's chat by naming its id.

Security and limits

  • Your app vouches for the person. Keep the client secret on your server. The browser only ever sees short passes.
  • Only the app's origins may call from a browser, without credentials. A request whose Origin is not the app's is refused. These routes never read a Hubzoid cookie, and a request with both a cookie and a pass is refused.
What a pass opens, the app's limit, token handling, rate limits and rotating the secret
  • A pass opens only the routes above: never Settings, People, keys or the web app's own API.
  • The app's limit takes the place of grants for these people, restricted tools included. Name tools in access.tools rather than * when the agent has restricted tools.
  • The person's app token is sent only to the tools in token_to, kept in memory for the reply and replaced with [redacted] in tool output and in Hubzoid's logs. The Claude runtime keeps it in its private config file for the length of the reply. While a reply holds a token, the OpenAI runtime sends no trace for it.
  • Refused tokens are rate-limited per client address, and runs are rate-limited per person.
  • hubzoid embed secret backoffice --rotate makes a new secret. The old one stops working at once, so update your app's server right away.

What it does not do

  • No Hubzoid account is created for people who come from your app. Their chats are kept under a private id made from your app and their user id, which never matches a Hubzoid account or anyone's grants. Name and email are labels only.
  • Hubzoid ships no client or server package for this. You use the AG-UI, assistant-ui or CopilotKit packages.
  • Messages are text only, and tools your page declares in AG-UI are ignored.
  • A reply cut off by a Hubzoid restart does not carry on by itself. It continues when the person is back with a fresh pass.

If something goes wrong

A refused request answers with {"detail": {"code", "message"}}. Show the message to the person.

What you seeWhat to do
401 unknown_appThe token's iss and aud match no app. Check the client id and the audience hubzoid.
401 invalid_tokenWrong signature, algorithm or times, or no user claim. Sign with the current secret and HS256.
401 token_expiredGet a fresh pass for each request, as the snippets do.
403 no_accessThe pass, or the token's scopes, allow no agent here. Check access.agent against the app's agents.
403 wrong_originAdd the page's address to the app's origins.
More problems and fixes
What you seeWhat to do
403 app_disabledThe app has enabled: false.
400 ambiguous_credentialsThe request carried a Hubzoid sign-in cookie as well as the pass. Send credentials: "omit".
503 app_unavailableThe secret is not set (check hubzoid embed list), or the JWKS address cannot be fetched.
429 too_manyToo many messages from one person in a minute, or too many refused tokens from one address. Wait a moment.
409 run_in_progressA reply is being written in this chat. Rejoin it or stop it.
422 decision_requiredA resume without a clear {approved: true} or {approved: false}.
"not one of the tools this app allows"The tool is outside the app's limit, the pass's access.tools or the token's mapped scopes.
app_token() returns NoneThe tool is not in token_to, or the pass has no app_token.
A connector's tools are missingconnector:<id> is not in token_to or not in the app's access.tools.

The working example

The Hubzoid repository has a complete stand-in company app in examples/embed-agui (opens in a new tab). Its README has the commands to run it.

What the example shows, and the skill that walks through it

It has an assistant-ui page and a CopilotKit page, its own orders API and an MCP server that each answer only with the signed-in person's orders, and a /source page that lists every call the agent's tools made. It shows tool calls, approvals, reopening past chats, rejoining a reply after a reload, stopping and deleting a chat, page context and a page on another address that is refused.

With Claude Code or Codex, the hubzoid-embed skill in the repository's skills/embed folder walks through these steps in your two codebases.

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.