The chat API
Most people never need this page. The embed snippet 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 — it moves up the list.
POST /api/chat
One request, one answer, streamed. content-type: application/json going out.
{
"slug": "your-agent",
"message": "Does the export include attachments?",
"sessionId": "0f0e3b3e-6a0a-4f4b-9a1e-3f0a1b2c3d4e",
"history": [],
"preview": false
}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:
{
"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:
{ "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
twinfield 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
expis more than an hour after itsiatis 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:
// 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 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 hrefs move with them.
Where to go next
- Embedding — the iframe snippet, the floating bubble, and sizing.
- Support twins — what a token-gated agent is for, and the inbox handoff behind it.
- Team twins — the same machinery pointed at colleagues.