> ## Documentation Index
> Fetch the complete documentation index at: https://docs.gethinto.com/llms.txt
> Use this file to discover all available pages before exploring further.

# Use your own chat

> Keep your chatbot and let it hand a conversation to your team in Hinto, from the browser or from your server.

If your product already has a chat assistant, keep it. When the bot can't help, it hands the conversation to your team in Hinto. Your team answers from the Inbox, and your customer reads the answers in the same chat.

One rule decides what your team sees:

* **Before the handoff**, your code picks which turns of the bot's chat to send. The default is the whole current session.
* **After the handoff**, everything your customer sees goes into the Hinto thread: the customer's messages and every message your bot shows them.

When the conversation is resolved, your bot answers again. The next handoff opens a new conversation.

Your team sees the handoff at the top of the ticket: the bot's name, its reason, its summary, and the earlier chat, collapsed. Only your team sees the reason and summary. Your bot's later messages appear in the thread on your team's side, under your bot's name with an **AI agent** tag. If you don't name the bot, your team sees "{Organization}'s AI agent".

There are two ways to connect:

| Your bot runs | Use |
| - | - |
| In your web app | [The browser SDK](#in-the-browser) |
| Only on your server (a native app, WhatsApp, SMS, Slack, or email) | [The server API](#from-your-server) with a secret key |

## In the browser

This path uses only `@gethinto/browser-sdk`. It needs the same setup as [Hinto Signal](/native-support): the publishable key and an authenticated token endpoint at `/api/support-token`. The handoff keeps the customer's browser session, so investigations can cite what the customer did.

```bash theme={null}
npm install @gethinto/browser-sdk@0.9.2
```

### Connect with your bot's name

Connect once per signed-in customer. The name you pass is the name your team sees. A name with nothing left after cleaning throws `TypeError("invalid_bot_name")`.

```ts theme={null}
import { initHinto } from "@gethinto/browser-sdk";
import { connectConversation } from "@gethinto/browser-sdk/conversation";

export async function connectSupport(options: {
  customerId: string;
  publishableKey: string;
  botName: string;
}) {
  const capture = initHinto({
    apiKey: options.publishableKey,
    initialIdentity: { kind: "external_id", value: options.customerId },
  });
  const support = await connectConversation({
    capture,
    ingestKey: options.publishableKey,
    auth: {
      subject: options.customerId,
      getToken: async () => {
        const response = await fetch("/api/support-token", {
          credentials: "same-origin",
          cache: "no-store",
        });
        if (!response.ok) throw new Error("Support authentication required");
        return ((await response.json()) as { token: string }).token;
      },
    },
    bot: { name: options.botName },
  });
  await support.open();
  return { capture, support };
}
```

### Hand off

The customer's own words open the ticket, so put their last message in the draft. Then send with a `handoff`. The `reason` is required. The `summary` and `transcript` are optional.

```ts theme={null}
import type { ConversationRuntime, HandoffTurn } from "@gethinto/browser-sdk/conversation";

export interface BotTurn {
  id: string;
  from: "user" | "bot";
  text: string;
  sentAt: Date;
}

export function toTranscript(turns: readonly BotTurn[]): HandoffTurn[] {
  return turns.map((turn) => ({
    id: turn.id,
    author: turn.from === "user" ? "customer" : "bot",
    text: turn.text,
    at: turn.sentAt.toISOString(),
  }));
}

export async function handOff(
  support: ConversationRuntime,
  turns: readonly BotTurn[],
  handoff: { reason: string; summary?: string; startFromTurnId?: string },
) {
  const lastCustomerTurn = [...turns].reverse().find((turn) => turn.from === "user");
  support.setDraft(lastCustomerTurn?.text ?? "I would like to talk to a person.");
  return support.send({ handoff: { ...handoff, transcript: toTranscript(turns) } });
}
```

The SDK fits the handoff before it sends it:

* It starts the transcript at `startFromTurnId` when you pass one. An id it can't find keeps every turn.
* It drops blank turns and keeps the newest 100.
* It cuts each turn to 4,000 characters, the reason to 500, and the summary to 4,000.
* It tells your team how many earlier turns it left out.

The fitted handoff is saved with the send, so a page reload sends the same request and never opens a second ticket. A `send({ handoff })` answers like any other send. A blank reason answers `invalid_draft`. After the last conversation resolved, it starts a new conversation. While a conversation is still open, it sends a plain message and drops the handoff.

### Let your model decide

If your bot uses a language model, give it a tool. The model decides when to hand off and writes the reason and summary. Your code still decides which turns go along.

```json theme={null}
{
  "name": "escalate_to_support",
  "description": "Hand this conversation to the human support team when you cannot resolve it.",
  "input_schema": {
    "type": "object",
    "properties": {
      "reason": { "type": "string", "maxLength": 500 },
      "summary": { "type": "string", "maxLength": 4000 },
      "start_from_turn_id": { "type": "string" }
    },
    "required": ["reason", "summary"]
  }
}
```

Run the tool in the browser, where the conversation lives. With the Vercel AI SDK, declare the tool without `execute`, and call this from `onToolCall`.

```ts theme={null}
import type { ConversationRuntime } from "@gethinto/browser-sdk/conversation";
import { handOff, type BotTurn } from "./hand-off";

export interface EscalateArgs {
  reason: string;
  summary: string;
  start_from_turn_id?: string;
}

// Run this in the browser when your model calls escalate_to_support, and return the string to the
// model as the tool's result.
export async function escalateToSupport(
  support: ConversationRuntime,
  turns: readonly BotTurn[],
  args: EscalateArgs,
): Promise<string> {
  const outcome = await handOff(support, turns, {
    reason: args.reason,
    summary: args.summary,
    ...(args.start_from_turn_id === undefined ? {} : { startFromTurnId: args.start_from_turn_id }),
  });
  return outcome === "ok"
    ? "Handed to the support team. Tell the customer a person will reply here."
    : `The handoff failed (${outcome}). Offer the support email instead.`;
}
```

### Mirror what your bot says

Call `shareBotMessage` for every message your bot shows the customer. Your team then never contradicts something the bot already said. Each message is recorded once per `id`, so a retry or a reload never adds it twice.

```ts theme={null}
import type { ConversationRuntime } from "@gethinto/browser-sdk/conversation";

// Call this for every message your bot shows the customer. While a conversation is open or
// opening, the team sees it in the same thread; otherwise nothing is sent.
export async function showBotMessage(
  support: ConversationRuntime,
  message: { id: string; text: string },
  render: (text: string) => void,
) {
  render(message.text);
  return support.shareBotMessage(message);
}
```

| `shareBotMessage` answers | Meaning |
| - | - |
| `ok` | Your team can see it. |
| `queued` | It waits until the conversation is open and no customer message is sending, then goes out in order. It survives a reload. |
| `no_open_conversation` | No conversation is open or opening, so the bot is on its own. Nothing was sent. |
| `invalid_message` | The `id` or `text` is blank or too long. |
| `unavailable` | The customer is signed out, or 100 messages are already waiting. |
| `destroyed` | The conversation was destroyed. |

A message shared before the runtime first reads the conversation, such as right after a reload, answers `queued`. It goes out once the read finds an open conversation, and it is dropped when the read finds none.

Call it right after `send({ handoff })` for the bot's own "I'm passing you to the team" message. It waits for the new conversation, then appears after the customer's message. A bot message that fails never holds back the customer's messages.

### Send the customer's messages to your team

While your team has the conversation, the customer's messages go to them through `send()`, not to your bot.

```ts theme={null}
import type { ConversationRuntime } from "@gethinto/browser-sdk/conversation";

export function withTheTeam(support: ConversationRuntime): boolean {
  const snapshot = support.snapshot();
  return "current" in snapshot && snapshot.current.state === "active";
}

// While your team has the conversation, the customer's messages go to them, not to the bot.
export async function routeCustomerMessage(
  support: ConversationRuntime,
  text: string,
  answerWithBot: (text: string) => Promise<void>,
) {
  if (!withTheTeam(support)) return answerWithBot(text);
  support.setDraft(text);
  await support.send();
}
```

### Show replies and status, and give control back

Your chat shows your team's replies and the status from the snapshot. Teammate names stay hidden from the customer. `snapshot.current.state` is `none` before the first handoff, `active` while your team has the conversation, and `resolved` once they close it; then your bot answers again.

```ts theme={null}
import type { ConversationRuntime } from "@gethinto/browser-sdk/conversation";

export function followSupport(
  support: ConversationRuntime,
  chat: {
    showReply(text: string): void;
    showStatus(label: string): void;
    resumeBot(): void;
  },
) {
  const shown = new Set<string>();
  let handedBack = false;
  return support.subscribe((snapshot) => {
    if (!("current" in snapshot) || snapshot.current.state === "none") return;
    const { conversation } = snapshot.current;
    for (const message of conversation.messages) {
      if (message.author !== "support" || shown.has(message.id)) continue;
      shown.add(message.id);
      chat.showReply(message.body);
    }
    const step = conversation.status?.steps.find((candidate) => candidate.state === "current");
    if (step) chat.showStatus(step.label);
    if (snapshot.current.state === "resolved" && !handedBack) {
      handedBack = true;
      chat.resumeBot();
    }
  });
}
```

If your bot already tells the customer that a person will reply, turn off Hinto's automatic reply in **Settings > Auto-responses**. Otherwise the customer gets two acknowledgments in a row. To show only replies from people, check each message with `isSupportReply(message)` from `@gethinto/browser-sdk/conversation`: the automatic reply has `origin: "automatic"`.

### Handle sign-out, errors, and help offers

* **Sign-out and account switches.** Call `support.updateAuth({ subject, getToken })` when another customer signs in, and `support.updateAuth(null)` at sign-out. The runtime moves capture to that customer, so do not call `identify` yourself. Call `support.destroy()` when your chat unmounts for good.
* **What the snapshot can be.** `snapshot.state` is `idle`, `loading`, `ready`, `sending`, `offline`, `retryable_failure`, `auth_required`, `error`, or `closed`. Tell the customer when support is `offline` or failed and call `support.retry()`, and send them to sign in on `auth_required`. A send answers `busy` while a help offer's message is on its way. While a send is `sending`, `offline`, or `retryable_failure`, its message is `snapshot.pendingText` and `snapshot.draft` no longer holds it, so empty your input as soon as you call `send`.
* **What a call answers.** `send`, `open`, and `retry` answer `ok`, or a reason: `invalid_draft` (nothing to send), `attachments_not_ready`, `busy`, `requires_start_new` (call `startNew` first), `unavailable`, `auth_required`, `error`, or `destroyed`.
* **Help offers.** Your chat can show Hinto Signal's help offer next to the step where a customer is stuck, through `support.offers`. See [Offer help without the React kit](/journeys#offer-help-without-the-react-kit).

## From your server

For a bot with no web page, your server talks to Hinto directly. It hands off, sends the end user's messages, mirrors your bot's messages, and reads your team's replies. These requests carry no browser session, so the ticket cites no session evidence.

### Create a secret key

Your server needs two values:

| Value | Where to find it |
| - | - |
| `HINTO_SECRET_KEY` | Any secret key (`hinto_sk_…`). An organization admin chooses **New secret key** in Settings > API keys and names it. |
| `HINTO_SIGNAL_APP_ID` | Your Signal app ID, under Backend in Settings > Hinto Signal. A backend that already signs tokens has it in its env. |

The secret key is shown once. Keep it on your server only. Never put it in a website or an app, and never use the publishable key here. You can revoke it at any time in Settings > API keys.

### The endpoints

Every path starts with `/v1/native-support/server/apps/{appId}/users/{userId}`. `appId` is your Signal app ID, `HINTO_SIGNAL_APP_ID`. `userId` is your own stable ID for the end user, the same value a browser token sends as `sub`.

| Request | Body | Answers |
| - | - | - |
| `POST .../conversations` | `{ message, user?, handoff? }` | `201`, or `200` for a retry. `409 active_conversation_exists` while one is open. |
| `POST .../messages` | `{ message }` | `201`, or `200` for a retry. `404` with no conversation, `409 conversation_resolved` after it resolves. |
| `POST .../bot-messages` | `{ id, text, botName? }` | `201`, or `200` for a retry. `409 conversation_resolved` after it resolves. |
| `GET .../conversation` | none | `200` with the conversation, the same one the browser reads. `404` for an unknown user. |

Send an `Idempotency-Key` header with a UUID on both `POST`s that carry a customer message. Store the UUID with the end user's message, so a retry sends the same key and never adds a second message. A retry with the same key and a different body answers `409 idempotency_conflict`. A bot message is idempotent on its `id` instead.

A missing or unknown key answers `401`. A publishable key, or an older key made for another use, answers `403`. An app ID that is not your organization's Signal app answers `404`, and a paused app answers `403 application_disabled`. Each key may send 600 requests a minute, and each end user 120.

### Hand off

The first request creates or updates the end user from `user` and opens the conversation. A `user` field you leave out keeps its stored value, and `null` clears it.

The `handoff` takes `botName`, `reason`, `summary`, and `transcript`. Hinto fits it the way the browser runtime does: it drops blank turns, keeps the newest 100, clips each to 4,000 characters, and counts the turns it left out. A turn's `at` that does not parse is dropped. A blank `reason` answers `400 invalid_message`.

```bash theme={null}
curl -sS -X POST "$HINTO_API_URL/v1/native-support/server/apps/$HINTO_SIGNAL_APP_ID/users/$USER_ID/conversations" \
  -H "Authorization: Bearer $HINTO_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $MESSAGE_UUID" \
  -d '{
    "message": "The CSV export of the Q3 report fails every time.",
    "user": { "email": "maya@brightline.example", "name": "Maya Chen", "company": "Brightline Foods" },
    "handoff": {
      "botName": "Northwind Assistant",
      "reason": "CSV export fails after a cache clear and a retry.",
      "summary": "Maya cannot export the Q3 revenue report as CSV.",
      "transcript": [
        { "id": "wa-101", "author": "customer", "text": "The export keeps failing." },
        { "id": "wa-102", "author": "bot", "text": "Please clear your cache and try again." }
      ]
    }
  }'
```

### Send the end user's messages

```bash theme={null}
curl -sS -X POST "$HINTO_API_URL/v1/native-support/server/apps/$HINTO_SIGNAL_APP_ID/users/$USER_ID/messages" \
  -H "Authorization: Bearer $HINTO_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -H "Idempotency-Key: $MESSAGE_UUID" \
  -d '{ "message": "I need it for a board meeting at 3pm." }'
```

### Mirror your bot's messages

```bash theme={null}
curl -sS -X POST "$HINTO_API_URL/v1/native-support/server/apps/$HINTO_SIGNAL_APP_ID/users/$USER_ID/bot-messages" \
  -H "Authorization: Bearer $HINTO_SECRET_KEY" \
  -H "Content-Type: application/json" \
  -d '{ "id": "wa-104", "text": "A person from the team will reply here.", "botName": "Northwind Assistant" }'
```

### Read your team's replies

To hear about a reply without polling, set a reply webhook (see [Reply webhook](#reply-webhook)). Otherwise poll the conversation, and wait `conversation.status.refreshAfterMs` milliseconds between polls, or 15 seconds when there is no status. Every poll returns the whole conversation. Send each support message to the end user once, by its `id`. When `state` is `resolved`, your bot answers again, and the next handoff opens a new conversation.

```bash theme={null}
curl -sS "$HINTO_API_URL/v1/native-support/server/apps/$HINTO_SIGNAL_APP_ID/users/$USER_ID/conversation" \
  -H "Authorization: Bearer $HINTO_SECRET_KEY"
```

### A complete server bot

The same four requests in TypeScript, with `fetch`:

```ts theme={null}
// Chat handoff from your server, for a bot with no web page. Keep the secret key on the server only.
export interface HintoServer {
  apiUrl: string;
  /** HINTO_SIGNAL_APP_ID, from Settings > Hinto Signal. */
  appId: string;
  /** A secret key (hinto_sk_...) from Settings > API keys. */
  secretKey: string;
}

export interface Turn {
  id: string;
  author: "customer" | "bot";
  text: string;
  at?: string;
}

interface SupportMessage {
  id: string;
  author: "customer" | "support";
  body: string;
}

type Current =
  | { state: "none" }
  | {
      state: "active" | "resolved";
      conversation: { messages: SupportMessage[]; status: { refreshAfterMs: number } | null };
    };

function userUrl(hinto: HintoServer, userId: string, path: string): string {
  return `${hinto.apiUrl}/v1/native-support/server/apps/${encodeURIComponent(hinto.appId)}/users/${encodeURIComponent(userId)}/${path}`;
}

async function call(hinto: HintoServer, url: string, init: RequestInit = {}): Promise<Response> {
  const response = await fetch(url, {
    ...init,
    headers: {
      Authorization: `Bearer ${hinto.secretKey}`,
      "Content-Type": "application/json",
      ...init.headers,
    },
  });
  if (response.status >= 500 || response.status === 429) {
    throw new Error(`Hinto is unavailable (${String(response.status)}); retry with the same key`);
  }
  return response;
}

// `messageKey` is a UUID you store with the end user's message, so a retry sends the same key.
export function handOff(
  hinto: HintoServer,
  userId: string,
  input: {
    messageKey: string;
    message: string;
    user?: { email?: string; name?: string; company?: string };
    handoff: { botName?: string; reason: string; summary?: string; transcript: Turn[] };
  },
) {
  return call(hinto, userUrl(hinto, userId, "conversations"), {
    method: "POST",
    headers: { "Idempotency-Key": input.messageKey },
    body: JSON.stringify({
      message: input.message,
      user: input.user,
      handoff: { ...input.handoff, transcript: input.handoff.transcript.slice(-100) },
    }),
  });
}

export function sendCustomerMessage(
  hinto: HintoServer,
  userId: string,
  input: { messageKey: string; message: string },
) {
  return call(hinto, userUrl(hinto, userId, "messages"), {
    method: "POST",
    headers: { "Idempotency-Key": input.messageKey },
    body: JSON.stringify({ message: input.message }),
  });
}

export function mirrorBotMessage(
  hinto: HintoServer,
  userId: string,
  message: { id: string; text: string; botName?: string },
) {
  return call(hinto, userUrl(hinto, userId, "bot-messages"), {
    method: "POST",
    body: JSON.stringify(message),
  });
}

// Poll at `status.refreshAfterMs`. Each poll returns the whole conversation, so remember which
// reply ids you already sent to the end user.
export async function readReplies(
  hinto: HintoServer,
  userId: string,
  delivered: Set<string>,
): Promise<{ replies: string[]; resolved: boolean; pollAfterMs: number }> {
  const response = await call(hinto, userUrl(hinto, userId, "conversation"));
  if (response.status === 404) return { replies: [], resolved: true, pollAfterMs: 60_000 };
  const current = (await response.json()) as Current;
  if (current.state === "none") return { replies: [], resolved: true, pollAfterMs: 60_000 };
  const replies = current.conversation.messages.filter(
    (message) => message.author === "support" && !delivered.has(message.id),
  );
  for (const reply of replies) delivered.add(reply.id);
  return {
    replies: replies.map((reply) => reply.body),
    resolved: current.state === "resolved",
    pollAfterMs: current.conversation.status?.refreshAfterMs ?? 15_000,
  };
}
```

## Check your work

1. Talk to your bot, then hand off. The ticket appears in your Hinto Inbox with the handoff at the top, and the earlier chat collapsed under it.
2. Let your bot say something after the handoff. It appears in the thread under your bot's name.
3. Reply from the Inbox. The reply appears in your chat.
4. Resolve the ticket. Your bot answers again, and the next handoff opens a new ticket.

## Reply webhook

Hinto can call your backend when a teammate replies, so you can send a push or wake your bot. Set the URL in Settings > Hinto Signal, under Reply notifications. It must be a public `https` URL. The webhook has its own signing secret. Hinto shows it once, when you first save the URL and on each rotation; a rotation keeps the previous secret signing for 24 hours.

After a reply is delivered, Hinto sends one `POST` with `content-type: application/json`:

```json theme={null}
{
  "id": "6f1c9a52-6a5e-4c1e-9f4f-0c9d2b7e8a11",
  "type": "native_support.reply",
  "created_at": "2026-10-10T12:00:00.000Z",
  "data": {
    "application_id": "app_123",
    "environment": "live",
    "conversation_id": "1b6e0d6c-3f0e-4a8b-9d55-2f6f0a3c4d21",
    "ticket_number": 1042,
    "message_id": "9d2f7c1e-0b8a-4e5f-a1c3-7e6d5b4a3f20",
    "sub": "user_42",
    "preview": "Thanks for the details. The export works again."
  }
}
```

`sub` is the subject your backend signed into the user's token, and `preview` is the reply's first 140 characters. A test send from Settings carries `"test": true`.

Verify each request before you act on it. The `Hinto-Signature` header reads `t=<unix seconds>,v1=<hex>`, where the hex is HMAC SHA-256 of `<t>.<raw body>` keyed with your secret. During a rotation it carries one `v1` per live secret: accept the event when any of them matches, and refuse a `t` more than 5 minutes old. Answer with any 2xx. Hinto retries a 429, a 5xx or a network error up to six times over about 51 minutes, so deduplicate by `id` (also in the `Hinto-Event-Id` header). Settings lists recent deliveries with their status codes.


This documentation is built and hosted on [Mintlify](https://mintlify.com), a developer documentation platform.