---
title: Chat inside your own app
description: 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.
canonical_url: https://hubzoid.com/docs/guides/embed-chat
last_updated: 2026-10-25
---

# 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](https://docs.ag-ui.com) at `/api/agui`, so you can build the chat with [assistant-ui](https://www.assistant-ui.com) or [CopilotKit](https://www.copilotkit.ai) 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

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](https://hubzoid.com/docs/guides/embed-chat#your-apps-own-jwts) instead.
- A React page for assistant-ui, or a Node server for the CopilotKit runtime.

1. **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.

   ```bash title="Terminal" copy
   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:

   ```text title="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.

2. **Add the app to hubzoid.yaml**

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

   ```yaml title="hubzoid.yaml" copy
   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:

   ```bash title="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.

3. **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.

   **Node**

   ```js title="server.js" copy
   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);
   });
   ```

   **Python**

   ```python title="hubzoid_pass.py" copy
   import os
   import time

   import jwt  # PyJWT


   def hubzoid_pass(user):
       now = int(time.time())
       return jwt.encode({
           "iss": "backoffice", "aud": "hubzoid", "sub": str(user.id), "name": user.full_name,
           "access": {"agent": "my-hub", "tools": ["app_lookup", "read_knowledge"]},
           "app_token": user.api_token,
           "iat": now, "exp": now + 600,
       }, os.environ["HUBZOID_CLIENT_SECRET"], algorithm="HS256")
   ```

   `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](https://hubzoid.com/docs/guides/embed-chat#what-a-pass-may-say).

4. **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:

   ```bash title="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](https://hubzoid.com/docs/guides/embed-chat#if-something-goes-wrong).

5. **Add the chat to your page**

   Every request carries a fresh pass. Behind a [gateway](https://hubzoid.com/docs/deploy/gateway) of several agents, the agent's address is `https://hub.example.com/b/my-hub`.

   **assistant-ui**

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

   ```bash title="Terminal"
   npm install @assistant-ui/react @assistant-ui/react-ag-ui @ag-ui/client
   ```

   ```jsx title="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.

   **CopilotKit**

   CopilotKit gives a ready chat and goes through its runtime on your server, with Hubzoid registered as an AG-UI agent. The runtime adds the pass on every call, server side:

   ```js title="server.js"
   import { CopilotRuntime, createCopilotRuntimeHandler } from "@copilotkit/runtime/v2";
   import { HttpAgent } from "@ag-ui/client";

   const runtime = new CopilotRuntime({
     agents: async ({ request }) => {
       const user = await yourSignedInUser(request);
       return { "my-hub": new HttpAgent({ url: `${process.env.HUBZOID_URL}/api/agui`,
                                          headers: { Authorization: `Bearer ${await hubzoidPass(user)}` } }) };
     },
   });
   const handler = createCopilotRuntimeHandler({ runtime, basePath: "/api/copilotkit" });
   ```

   ```jsx title="Chat.jsx"
   import { CopilotChat, CopilotKit } from "@copilotkit/react-core/v2";

   <CopilotKit runtimeUrl="/api/copilotkit" agent="my-hub" useSingleEndpoint={false}>
     <CopilotChat threadId={`${user.id}:${crypto.randomUUID()}`} />
   </CopilotKit>
   ```

   **Thread safety, telemetry and the paid browser mode in CopilotKit**

   CopilotKit's default runner keeps a thread's events in memory by thread id, for any caller. Start thread ids with the signed-in person, as above, and refuse runtime requests for a thread that is not theirs. Set `COPILOTKIT_TELEMETRY_DISABLED=true` if you do not want CopilotKit to send usage telemetry. Its direct-from-browser mode is part of its paid tier, so it is not the path shown here.

   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.

6. **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()`:

   ```python title="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](https://hubzoid.com/docs/guides/tools-and-connectors#add-a-connector-in-settings).

## 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:

```yaml title="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**

| 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 `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](https://hubzoid.com/docs/guides/restrict-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 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`](https://github.com/hubzoid/hubzoid/tree/main/examples/embed-agui). 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](https://hubzoid.com/docs/guides/restrict-tools): Keep sensitive tools behind a permission before an app can reach them.
- [Tools and connectors](https://hubzoid.com/docs/guides/tools-and-connectors): Write the hub tool or add the connector your app's chat calls.
- [Connect an assistant](https://hubzoid.com/docs/guides/connect-an-assistant): Use the same Hub from Claude Code or another MCP client.
- [Several Hubs](https://hubzoid.com/docs/deploy/gateway): Give each agent its own address behind one front door.
