---
title: "The chat API"
description: "POST /api/chat, minting visitor tokens, every limit by name, every error shape, and what an agent can fetch."
source: https://hiy.ai/docs/developers
---

# The chat API

Most people never need this page. The [embed snippet](/docs/embed) puts the whole
chat — citations, honest gaps, handoff — inside your site with one iframe, and it
already does everything described here. This is for building your own interface
instead.

There is one endpoint, and it is the same one hiy's own pages call. It is not a
versioned public API yet: no keys, no SDK, no compatibility promise beyond "we
will tell you before we break it". If you need something stable to build a
product on, say so at [hello@hiy.ai](mailto:hello@hiy.ai) — it moves up the list.

## POST /api/chat

One request, one answer, streamed. `content-type: application/json` going out.

```json
{
  "slug": "your-agent",
  "message": "Does the export include attachments?",
  "sessionId": "0f0e3b3e-6a0a-4f4b-9a1e-3f0a1b2c3d4e",
  "history": [],
  "preview": false
}
```

- **slug** (`string`, required) — The agent's address, 1–64 characters.
- **message** (`string`, required) — The visitor's question, 1–2000 characters.
- **sessionId** (`string (uuid)`, optional) — A UUID from a previous reply's `x-session-id`. Ignored unless it belongs to this agent.
- **history** (`array`, optional) — Up to 20 turns of `role` + `content` (4000 characters each). Advisory — see below.
- **preview** (`boolean`, optional) — The owner's own sandbox. Anyone who isn't the owner gets a 403.

Anything that fails validation is a `400` with `"Invalid request."` — the same
body for a missing field, an over-long message, and a malformed UUID.

### What the server doesn't take your word for

`history` and `sessionId` are hints, not state. Outside owner preview the server
rebuilds the transcript from the messages it wrote itself, and a `sessionId` that
belongs to another agent is dropped rather than followed. Assistant turns you send
are discarded: forged prior answers are the most effective attack on a guardrail
that is written in words, and every promise this product makes — the AI label,
the honest "I don't know", the creator's off-limits topics — is written in words.
So the transcript is ours.

Keep sending `sessionId` anyway. It is what threads a conversation together for
the creator's dashboard, and what a citation link resolves against later.

### The response

A `200` streams `text/plain; charset=utf-8` with `cache-control: no-store`, and
carries the conversation's id in an `x-session-id` header. Read the body as it
arrives; the answer is the text.

After the last token comes one NUL byte (`U+0000`) and then a single line of JSON
— the metadata frame. Split on the NUL: everything before it is the answer,
everything after it is this:

```json
{
  "citations": [{ "title": "Refund policy", "snippet": "…", "origin": "source", "url": "https://…" }],
  "searches": ["refund window", "returns after 30 days"],
  "searchCount": 2,
  "records": [{ "id": "…", "type": "services", "title": "Migration review", "url": "https://…", "cta": "Book a time" }],
  "lookups": ["services"]
}
```

Every field is optional and every one can be an empty array. The frame is at the
end rather than in a header because the agent may search its own material again
part-way through an answer — what it drew on isn't known when a header is sent.

`citations` is what the answer was built from. `searchCount` is how many searches
ran even when the creator has hidden their wording, so "looked three times and
found nothing" never reads the same as "never looked". `lookups` does the same
job for row lookups. `cta` is the button's wording, derived from the creator's
own schema — `Book a time` or `Open` — and it only appears alongside a `url`.
Render it; never switch on it.

A failure after streaming has started errors the stream rather than closing it
cleanly. A truncated answer that looks complete is the one failure mode worth
being loud about, so treat an aborted read as a failed message.

## Rate limits

This is the canonical limit reference. Product pages name the kinds of
boundaries that matter; the enforced values, response behavior, and source
locations live here so there is only one list to keep current.

Three separate bounds, all returning `429`:

| Bound | Limit | Body |
| --- | --- | --- |
| Per IP, all agents | 20 requests a minute | `"Slow down a little."` |
| Per IP, per agent | 100 messages a day | `"This twin has reached its monthly message limit."` + reset date |
| Per organisation, every agent | the plan's monthly allowance | `"This twin has reached its monthly message limit."` + reset date |

The last two are **deliberately indistinguishable**. If "you are throttled" and
"this agent is out of messages for the month" read differently, anyone could
measure a stranger's plan and how much of it they had spent by sending a hundred
messages. So they share one wording, and no header tells them apart. Don't try to
infer which one you hit — back off and retry later either way.

Both bodies are one sentence plus a reset: *"This twin has reached its monthly
message limit. Its allowance resets on 1 September."* The date is the first of
the next calendar month, UTC — the same string in both cases, because a date
that differed would be the tell the shared wording exists to remove. A
rate-limited caller is back sooner than that; the sentence is a fact about the
allowance, not a promise about your next request. The body never names the
allowance itself, for the same reason: a number would say which plan the
creator is on.

The per-IP-per-agent bound is checked *before* the organisation's counter, so
traffic rejected there never spends the creator's allowance.

## Every limit, by name

There are no performance numbers on this page — no latency percentiles, no
first-token target, no accuracy figure. hiy doesn't measure any of those, so
printing one would be inventing it. What is real is the constants, so here they
are with the file each one lives in. If a number below disagrees with what the
endpoint does, the file is right and this table is the bug.

| Name in the code | Limit | Where it lives |
| --- | --- | --- |
| `message` | 1–2000 characters, per request | `api/chat/route.ts` |
| `slug` | 1–64 characters | `api/chat/route.ts` |
| `history` | 20 turns, 4000 characters each | `api/chat/route.ts` |
| `rateLimitDistributed("chat:<ip>")` | 20 requests per 60 seconds, per IP | `api/chat/route.ts` |
| `VISITOR_DAILY_PER_TWIN` | 100 messages a day, per visitor IP per agent | `api/chat/route.ts` |
| `HISTORY_TURNS` | 20 turns replayed into the prompt, from the server's own stored messages | `api/chat/route.ts` |
| `monthly_messages` | 300 messages a month on Free, 2000 on Founding, per organisation | `lib/billing/plans.ts` |
| `MAX_TOKEN_LIFETIME_SECONDS` | 3600 seconds (1 hour) — the largest `exp − iat` accepted | `lib/twin-token.ts` |
| `CLOCK_SKEW_SECONDS` | 60 seconds, tolerated in both directions | `lib/twin-token.ts` |
| `MAX_ATTESTED_KEYS` | 6 keys in a token's attested set — a seventh fails the whole token | `lib/twin-token.ts` |
| `MAX_ATTESTED_VALUE_CHARS` | 200 characters per attested value, and it must be a string | `lib/twin-token.ts` |
| `ATTESTED_KEY_RE` | `/^[a-z][a-z0-9_]{0,38}$/` — an attested key is lowercase, and 39 characters at most | `lib/twin-token.ts` |
| `citationsFrom(…, { max })` | 3 citations per answer | `lib/rag/tools.ts` |
| `PASSAGE_MAX` | 1200 characters — the longest citation snippet | `lib/rag/tools.ts` |
| `QUIET_SNIPPET` | 260 characters — the snippet when the creator hasn't opened receipts | `lib/rag/tools.ts` |

Two rows have no constant behind them, and say so rather than inventing one. The
per-IP burst is an inline literal in the route, so the row names the call. The
three-citation cap is a default parameter of `citationsFrom`, not an exported
name. Naming a constant that doesn't exist is the same lie as printing a number
nobody measured.

`monthly_messages` is a *plan* cap rather than an API one: it is metered per
organisation, not per caller and not per agent, and it is the third of the
three `429` bounds above. One allowance covers every agent an account owns —
so a second agent divides the month rather than doubling it. The gate is
`try_increment_message_usage`, which compares an org-month counter and keeps a
per-agent breakdown for attribution.

## Every error shape

| Status | When | Body |
| --- | --- | --- |
| 400 | The body failed validation | `"Invalid request."` |
| 403 | `preview: true` from someone who isn't the owner | `"Not authorized to preview this twin."` |
| 404 | No such agent — **or** it's restricted and your token didn't check out | `"Twin not found."` |
| 429 | Any of the three bounds above | see the table above |
| 503 | No model key configured on the server | `"Chat isn't configured yet (no LLM key on the server)."` |

The `404` is the one to plan for. A restricted agent returns the same "not found"
for a wrong slug, a missing token, a bad signature, an expired token and a token
minted for a different agent — every reason merged into one. A `403` would confirm
the agent exists, which is exactly what someone probing for a company's internal
agent is trying to learn. The reason is logged on our side; it is never sent.

That merge is why a client holding a token should check the expiry itself before
sending — hiy's own embed does, and shows "Session timed out — reload" rather than
letting a doomed request come back as "not found".

## Tokens for restricted agents

Support Agents and Team Agents have no public link. A visitor gets in with a
**signed token your own server mints**: your application vouches for its own
users, so they never need hiy accounts, and you never send us your user list.

Send it as a bearer token on every chat request:

```
Authorization: Bearer v1.eyJ0d2luIjoi…
```

### The format

`v1.` + base64url of the payload + `.` + base64url of the signature. The payload
is four fields:

```json
{ "twin": "the agent's id", "sub": "your own user id", "iat": 1755000000, "exp": 1755000900 }
```

The signature is **HMAC-SHA256** over `v1.<payload>` with your signing secret.

This is deliberately **not a JWT**. It's a bearer credential for one agent for a
few minutes, not a general-purpose identity token — so there is no algorithm
field to confuse, no `alg: none`, and no key discovery. There is exactly one
algorithm and it isn't negotiable.

### The rules we enforce

- **Audience-bound.** The `twin` field must name the agent being addressed. A
  valid signature is not enough: a token minted for one agent never opens
  another, even inside the same organisation.
- **One hour, maximum.** A token whose `exp` is more than an hour after its `iat`
  is refused even though it signed correctly — the lifetime is our policy, not
  the signer's. Fifteen minutes is the sensible default. Sixty seconds of clock
  skew is tolerated in both directions.
- **Server-side only.** The secret signs on your server. Shipping it to a browser
  hands every visitor the ability to mint a token for any of your users.
- **Per organisation.** Your secret is derived from a server master secret, so
  it's never sitting in a table waiting to leak, and holding it tells you nothing
  about anyone else's.

### Minting one

Your signing secret is on the agent's **Publish** tab, next to the snippet. Copy
it into your server's environment, then mint a token per page view:

```js
// Node.js — run on YOUR server, never in the browser:
const crypto = require("crypto");
function mintTwinToken(secret, twinId, userId) {
  const now = Math.floor(Date.now() / 1000);
  const payload = Buffer.from(JSON.stringify({
    twin: twinId,
    sub: String(userId),
    iat: now,
    exp: now + 900, // 15 minutes; 1 hour is the maximum we accept
  })).toString("base64url");
  const body = "v1." + payload;
  const sig = crypto.createHmac("sha256", secret).update(body).digest("base64url");
  return body + "." + sig;
}
```

That is the exact text the Publish tab hands you: this page and the API response
both render one constant, and a test in the repo runs that constant through the
real verifier, so a change to either side has to be a deliberate one.

Pass the result to the embed as `?token=…`, or send it as the bearer header
above. If a secret ever leaks, tell us and we'll rotate it.

### Attested attributes

A token can carry a few facts about the person holding it, so an action doesn't
have to ask a visitor to type what your site already knows about them.

**Attested values are readable by an action for the duration of one answer and
are never written to the session row, the leads table, or anything joined to
them.** That is the whole retention story: hiy hands them to the action that
needs them, and there is nothing to delete afterwards because nothing was kept.

Put them under `att`, a flat object of strings — everything else about the mint
is unchanged:

```js
// The same payload as above, with one key added.
const payload = Buffer.from(JSON.stringify({
  twin: twinId,
  sub: String(userId),
  att: {
    order_id: "A-4471",
    plan: "Pro",
  },
  iat: now,
  exp: now + 900,
})).toString("base64url");
```

**The owner declares the keys; your server fills in the values.** Up to six of
them, named on the agent's **Publish** tab beside the signing secret. Two
different parties, and neither can act for the other: you cannot send a fact the
owner has not declared, and the owner cannot make you send one you do not have. A
well-formed key nobody declared is dropped before anything reads it — it never
reaches the action, and nothing tells the model it was there. Sending one is not
an error; it is simply not an event. A *malformed* key is a different thing
entirely, and the next paragraph is that difference.

**The shape is checked before anything is read, and a violation fails the whole
token.** The verifier does not drop the offending key and keep the rest: an `att`
that breaks any rule below is refused together with the token carrying it — the
same fail-closed rule the audience and lifetime checks use, for the same reason,
because a token that was accepted with one assertion silently missing is a thing
nobody has reasoned about. Five bounds, all in `lib/twin-token.ts`:

- **`att` is a flat object.** An array, a string or a number in its place is
  refused.
- **At most six keys** — `MAX_ATTESTED_KEYS`, the same six the owner may declare.
  A seventh fails the token rather than being trimmed.
- **Every key matches `/^[a-z][a-z0-9_]{0,38}$/`** — `ATTESTED_KEY_RE`. A
  lowercase letter first, then lowercase letters, digits and underscores, 39
  characters at most. `order_id` passes; `orderId` and `order-id` do not.
- **Every value is a string.** A number, a boolean or a nested object is refused
  — send `"4471"`, not `4471`.
- **Every value is at most 200 characters** — `MAX_ATTESTED_VALUE_CHARS`.

What that refusal looks like from outside depends on the agent. On a restricted
agent the token was also what let you in, so the request comes back as the same
[`404`](#every-error-shape) every other token failure returns, with the reason
logged on our side and never sent. On an agent anyone can reach, the answer is
still written — but written for a stranger: the *whole* attested set is gone
rather than the one key that broke the rule, nothing tells the model anything was
attested, and no line names any arriving fact. Neither shape tells you which key
it was, so check these bounds on your own server, where you still can.

**The token is readable by the visitor, and it rides in a URL.** The payload is
base64url JSON, not encryption — hiy's own embed decodes it in the browser to
check the expiry before it sends — and `?token=` / `data-token` puts the whole
token in the frame's address, which means browser history, the `Referer` on
outbound links, CDN and edge access logs, and any analytics running on your page.
So the rule is short: **nothing goes in `att` that the person asking may not see
about themselves.** Where you have the choice, prefer the `Authorization` bearer
header over `?token=` for a token that carries attributes — a header is not an
address, and does not land in any of those logs.

**The visitor is told which keys arrived.** The answer carries a short line
naming them — the names, never the values. A host choosing a key name is
choosing a word a stranger will read: `order_id` reads as ordinary, and a key
called `churn_risk` reads as exactly what it is.

**What your endpoint sends back is a different thing, and it is your call.** An
attested value came in and is gone when the answer ends. A value your own
endpoint returns is part of the answer, and is kept with the conversation like
everything else in it.

## Recipes for the inline embed

Four ways to run that mint on infrastructure you actually have, each one
wired into the **inline** embed from [Embedding](/docs/embed) — not the
floating bubble. The bubble is part of **Founding**; the inline embed works
on every plan, which is why it's what every recipe below targets. See
[Embedding](/docs/embed) for what each mode currently offers.

Same payload, same signing input, same algorithm every time —
**HMAC-SHA256** over `v1.<payload>` with the secret from the agent's Publish
tab. What changes per host is only *where* that code runs and how its output
reaches the tag. The one thing every recipe is built to make structurally
true, not just claimed: the secret is read in a place a browser can never
reach, and only the short-lived token that function returns ever leaves the
server.

### Next.js

Mint inside a Server Component. There's no `"use client"` at the top of this
file, so the framework's own server/client boundary keeps it out of any
browser bundle — not a convention you have to remember, a build-time
guarantee.

```tsx
// app/support/page.tsx — a Server Component; this file never ships to the browser
import { createHmac } from "node:crypto";

function mintTwinToken(secret: string, twinId: string, userId: string): string {
  const now = Math.floor(Date.now() / 1000);
  const payload = Buffer.from(JSON.stringify({
    twin: twinId,
    sub: String(userId),
    iat: now,
    exp: now + 900, // 15 minutes; 1 hour is the maximum we accept
  })).toString("base64url");
  const body = "v1." + payload;
  const sig = createHmac("sha256", secret).update(body).digest("base64url");
  return body + "." + sig;
}

export default async function SupportPage() {
  const userId = await currentUserId(); // however your app already knows who's signed in
  const token = mintTwinToken(
    process.env.HIY_SIGNING_SECRET!, // read on the server, never returned to the client
    process.env.HIY_TWIN_ID!,
    userId
  );

  return (
    <iframe
      src={`https://hiy.ai/embed/your-agent?token=${encodeURIComponent(token)}`}
      width="100%"
      height="600"
      style={{ border: 0, borderRadius: 16 }}
      title="Ask support"
    />
  );
}
```

The rendered HTML does carry the iframe's `src`, token included — that's
expected, a token is a bearer credential for one visitor for fifteen minutes.
`HIY_SIGNING_SECRET` itself is never in anything the page sends: it's read
once, server-side, to compute a signature, and discarded.

### Node.js

No framework, no dependency — Node's own `http` and `crypto`, for whatever
server you already run.

```js
// server.js — plain Node.js, run on YOUR server
import { createServer } from "node:http";
import { createHmac } from "node:crypto";

function mintTwinToken(secret, twinId, userId) {
  const now = Math.floor(Date.now() / 1000);
  const payload = Buffer.from(JSON.stringify({
    twin: twinId,
    sub: String(userId),
    iat: now,
    exp: now + 900, // 15 minutes; 1 hour is the maximum we accept
  })).toString("base64url");
  const body = "v1." + payload;
  const sig = createHmac("sha256", secret).update(body).digest("base64url");
  return body + "." + sig;
}

createServer((req, res) => {
  if (req.url !== "/support") { res.writeHead(404).end(); return; }

  const userId = currentUserId(req); // however your app already knows who's asking
  const token = mintTwinToken(process.env.HIY_SIGNING_SECRET, process.env.HIY_TWIN_ID, userId);

  res.writeHead(200, { "content-type": "text/html" });
  res.end(
    "<!doctype html><html><body>" +
    '<script src="https://hiy.ai/embed.js" data-twin="your-agent" data-token="' + token + '" async></script>' +
    "</body></html>"
  );
}).listen(3000);
```

`HIY_SIGNING_SECRET` lives in `process.env` on this process and nowhere
else. The response is built by hand here so it's visible that only `token` —
never `secret` — goes into it.

### WordPress

The same shape in PHP, added to your theme's `functions.php` as a shortcode.
PHP already runs entirely server-side: what reaches a visitor is the HTML
this function returns, never the source that produced it.

```php
<?php
// functions.php — runs on YOUR WordPress server, never sent to a visitor

function hiy_mint_twin_token($secret, $twin_id, $user_id) {
    $now = time();
    $payload = array(
        'twin' => $twin_id,
        'sub'  => (string) $user_id,
        'iat'  => $now,
        'exp'  => $now + 900, // 15 minutes; 1 hour is the maximum we accept
    );
    $body = 'v1.' . rtrim(strtr(base64_encode(json_encode($payload)), '+/', '-_'), '=');
    $sig  = rtrim(strtr(base64_encode(hash_hmac('sha256', $body, $secret, true)), '+/', '-_'), '=');
    return $body . '.' . $sig;
}

add_shortcode('hiy_support', function () {
    $token = hiy_mint_twin_token(
        HIY_SIGNING_SECRET, // a constant from wp-config.php — see below
        HIY_TWIN_ID,
        (string) get_current_user_id()
    );
    return '<script src="https://hiy.ai/embed.js" data-twin="your-agent" data-token="'
        . esc_attr($token) . '" async></script>';
});
```

```php
<?php
// wp-config.php — above your site's document root; WordPress never serves it
define('HIY_SIGNING_SECRET', 'the secret from the Publish tab');
define('HIY_TWIN_ID', 'your-agent');
```

Drop `[hiy_support]` into the page or post where the agent belongs.
`HIY_SIGNING_SECRET` is read only inside PHP that executes on your server —
the browser gets the `<script>` tag `esc_attr($token)` produces, and nothing
about the secret that produced it.

### A static host

**Warning:** A static host — GitHub Pages, a plain storage bucket, a CDN with no server
behind it — has no process running your code on each request. There is
nowhere on it to put a secret that stays secret: anything shipped in the
page's own JavaScript is downloadable by anyone who opens the page, signing
logic included. Don't paste the mint function above into a script tag on a
static page — that ships the secret to every visitor, which is the one thing
every recipe on this page exists to prevent.

Two honest options, not a workaround:

**Add a serverless function.** Most static hosts pair with one — Netlify
Functions, Cloudflare Pages Functions, a Lambda behind API Gateway. It's
still "a server" for this purpose: your code runs on request, the secret
lives in that host's environment settings, and the static page fetches a
token before it sets `data-token`.

**Warning:** The function below, as written, mints for whoever calls it — curl the URL
directly and you get a valid token too, same as any visitor. A restriction
only means something once the function checks *who* is asking before it
mints, the way the Next.js and Node recipes read `userId` from a request
their own host application already authenticated. Skip that check and a
"restricted" agent is public with extra steps.

```js
// netlify/functions/hiy-token.js — the one part of this site that runs server-side
import { createHmac } from "node:crypto";

function mintTwinToken(secret, twinId, userId) {
  const now = Math.floor(Date.now() / 1000);
  const payload = Buffer.from(JSON.stringify({
    twin: twinId,
    sub: String(userId),
    iat: now,
    exp: now + 900, // 15 minutes; 1 hour is the maximum we accept
  })).toString("base64url");
  const body = "v1." + payload;
  const sig = createHmac("sha256", secret).update(body).digest("base64url");
  return body + "." + sig;
}

export async function handler() {
  const token = mintTwinToken(process.env.HIY_SIGNING_SECRET, process.env.HIY_TWIN_ID, "anon");
  return { statusCode: 200, body: JSON.stringify({ token }) };
}
```

```html
<!-- runs in the visitor's browser — fetches a token, never mints one -->
<script>
  // Captured here, synchronously. `document.currentScript` is only set while a
  // script is executing its own body — by the time the fetch below resolves it
  // is null, and reading `.parentNode` off it throws. Holding the element means
  // the panel still lands exactly where you pasted this snippet.
  var here = document.currentScript;
  fetch("/.netlify/functions/hiy-token")
    .then((r) => r.json())
    .then(({ token }) => {
      var s = document.createElement("script");
      s.src = "https://hiy.ai/embed.js";
      s.dataset.twin = "your-agent";
      s.dataset.token = token;
      s.async = true;
      here.parentNode.insertBefore(s, here.nextSibling);
    });
</script>
```

**Or don't gate it.** If nothing on the host executes your code and adding a
function isn't an option, the honest fallback is a public agent — the plain
`<script data-twin="your-agent">` from [Embedding](/docs/embed), no token.
Restricted access is a promise a static host structurally cannot keep on its
own.

## What an agent can fetch

Three files on this origin are meant to be read by a program rather than a
person. They are plain GETs, they need no key, and they are covered by the same
scope statement as the endpoint above: none of them is versioned, and the shape
of any of them can change.

| Path | What it is |
| --- | --- |
| `/llms.txt` | The [llms.txt](https://llmstxt.org) file — what hiy is, what makes it different, the two plans, and an absolute URL for every key page, in Markdown. Written by hand, not generated. |
| `/docs-search.json` | The search index behind ⌘K on these docs. A JSON array of `{page, group, href, heading, text}`, one entry per docs *section* rather than per page, so a hit lands on the anchor. Rebuilt by `scripts/build-docs-index.ts` on every build. |
| `/embed.js` | The embed loader. `<script src="https://hiy.ai/embed.js" data-twin="your-agent" async>` — plus `data-token` for a restricted agent and `data-mode="bubble"` for the floating launcher. Plain JS with inline styles, because it runs on someone else's page. |

`/docs-search.json` is a build artefact of this documentation, so treat it as a
convenience rather than a contract: it exists because the search box needs it,
and if these docs are reorganised, its `href`s move with them.

## Where to go next

- [Embedding](/docs/embed) — the iframe snippet, the floating bubble, and sizing.
- [Support Agents](/docs/support-agents) — what a token-gated agent is for, and the
  inbox handoff behind it.
- [Team Agents](/docs/team-agents) — the same machinery pointed at colleagues.
