---
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": "service", "title": "Migration review", "cta": "Book" }],
  "lookups": ["service"]
}
```

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.

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

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 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 agent's own 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` |
| `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.

## 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 twins](/docs/support-twins) — what a token-gated agent is for, and the
  inbox handoff behind it.
- [Team twins](/docs/team-twins) — the same machinery pointed at colleagues.
