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.
- Your app signs the person in
- Your server signs a pass
- Your page calls /api/agui
- Hubzoid checks the pass and the app limit
- Tools reach your app as the person
What you need
- A Hub that your app's page can reach, such as
https://hub.example.com. The examples use the starter Hubmy-hub, so the agent ismy-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.
hubzoid embed secret backoffice --hub ./my-hub \
--origin https://backoffice.example.com --agent my-hub \
--tool app_lookup --tool read_knowledge --token-to app_lookupThe secret is stored write-only and shown once, with the entry to paste and the settings for your app's server:
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:
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 secondsA 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:
hubzoid embed list --hub ./my-hubThe 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.
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:
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.
npm install @assistant-ui/react @assistant-ui/react-ag-ui @ag-ui/clientimport { 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():
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.textUse 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
| Interrupt | Answer 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:
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 itselfA 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
| Key | What it does |
|---|---|
name | The app's display name. Defaults to the client id. |
enabled | false refuses the app's passes at once and stops replies it started. |
origins | Web addresses whose pages may call Hubzoid. HTTPS, or HTTP only on localhost or 127.0.0.1. |
mode | pass (the default) or jwt. |
secret_ref | Where the client secret is stored. secret:deployment/app-<id> from hubzoid embed secret, or env:<VARIABLE>. |
access | The most the app may allow: agents (at least one) and tools. |
token_to | Tools that receive the person's app token. Never *. |
token_header | The header a connector receives the token in. Defaults to Authorization. |
max_lifetime | Longest 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
| Claim | Meaning |
|---|---|
iss | The client id. Required. |
aud | hubzoid. Required. |
sub | Your app's user id, as text. Required. |
iat, exp | Issued and expiry times, at most max_lifetime apart. Required. |
name, email | Labels 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_token | The 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
| Route | What it does |
|---|---|
POST /api/agui | Run: an AG-UI RunAgentInput. Answers with AG-UI events. |
GET /api/agui/threads | This 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}/run | Rejoin the reply being written. 204 when there is none. |
POST /api/agui/threads/{id}/stop | Stop 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
originsmay call from a browser, without credentials. A request whoseOriginis 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.toolsrather 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 --rotatemakes 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 see | What to do |
|---|---|
401 unknown_app | The token's iss and aud match no app. Check the client id and the audience hubzoid. |
401 invalid_token | Wrong signature, algorithm or times, or no user claim. Sign with the current secret and HS256. |
401 token_expired | Get a fresh pass for each request, as the snippets do. |
403 no_access | The pass, or the token's scopes, allow no agent here. Check access.agent against the app's agents. |
403 wrong_origin | Add the page's address to the app's origins. |
More problems and fixes
| What you see | What to do |
|---|---|
403 app_disabled | The app has enabled: false. |
400 ambiguous_credentials | The request carried a Hubzoid sign-in cookie as well as the pass. Send credentials: "omit". |
503 app_unavailable | The secret is not set (check hubzoid embed list), or the JWKS address cannot be fetched. |
429 too_many | Too many messages from one person in a minute, or too many refused tokens from one address. Wait a moment. |
409 run_in_progress | A reply is being written in this chat. Rejoin it or stop it. |
422 decision_required | A 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 None | The tool is not in token_to, or the pass has no app_token. |
| A connector's tools are missing | connector:<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
Restrict tools
Keep sensitive tools behind a permission before an app can reach them.
Tools and connectors
Write the hub tool or add the connector your app's chat calls.
Connect an assistant
Use the same Hub from Claude Code or another MCP client.
Several Hubs
Give each agent its own address behind one front door.

