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 [email protected] — 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.
What the model is given, band by band
cached · stable per twin
- PLATFORM_RULESByte-identical for every twin.
- SEARCH_RULEOnly when search_knowledge is offered.
- VARIANT_RULES[variant]Public personal, internal, or support.
- WHO YOU AREScope, guardrails, persona.
- CatalogueLists this twin can look up, or nothing.
dynamic · per question
- CONTEXTPassages retrieved for this question.
messages · server rebuilt
- TranscriptStored messages only; at most 20 turns.
- The question1–2000 characters.
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": "services", "title": "Migration review", "url": "https://…", "cta": "Book a time" }],
"lookups": ["services"]
}The chat response, byte for byte
- Answer bytestext/plain, streamed.
- NUL separatorMETA_SEPARATOR = U+0000; model output cannot forge it.
- Metadata frameOne final JSON line with citations, searches, records, and lookups.
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…How a visitor reaches a restricted twin
- Your servermintTwinToken() signs for a user it already knows.
- Your pageReceives the token and passes it to the embed.
- hiyReceives POST /api/chat with the bearer token.
- VerificationverifyTwinToken() then canAccessTwin().
- Streamed answerAny failed check returns the same 404.
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 three parts of a twin access token
- v1One fixed algorithm; this is not a JWT.
- base64url(payload){ twin, sub, iat, exp } binds the token to one twin.
- base64url(HMAC-SHA256)Signed with the organisation’s derived secret.
verification policy
- Compare signature firstUses timingSafeEqual before parsing the payload.
- Check lifetimeMaximum 3600 seconds, with 60 seconds of clock skew.
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.
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:
// 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:
attis 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_idpasses;orderIdandorder-iddo not. - Every value is a string. A number, a boolean or a nested object is refused
— send
"4471", not4471. - 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 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 — 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 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.
// 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.
// 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
// 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
// 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.
// 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 }) };
}<!-- 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, 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 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 Agents — what a token-gated agent is for, and the inbox handoff behind it.
- Team Agents — the same machinery pointed at colleagues.