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
}
slugrequiredstring

The agent's address, 1–64 characters.

messagerequiredstring

The visitor's question, 1–2000 characters.

sessionIdoptionalstring (uuid)

A UUID from a previous reply's x-session-id. Ignored unless it belongs to this agent.

historyoptionalarray

Up to 20 turns of role + content (4000 characters each). Advisory — see below.

previewoptionalboolean

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.

What the model is given, band by bandThe system prompt is assembled in layers — platform rules identical for every twin, then the variant’s rules, then the one per-twin block, then the passages retrieved for this question — and the conversation below it is rebuilt from the messages the server itself wrote, never from the transcript the browser sent.cachedstable per twindynamicper questionmessagesserver-rebuiltPLATFORM_RULESbyte-identical for every twinSEARCH_RULEonly when search_knowledge is offeredVARIANT_RULES[variant]public_personal · internal · supportWHO YOU AREscope, guardrails, persona — the per-twin blockCataloguethe lists this twin can look up, or nothingCONTEXTthe passages retrieved for this questionTranscriptrebuilt from stored messages · HISTORY_TURNS 20The question1–2000 chars
The top three bands are shared: no creator can edit “say you are an AI” or “never invent”, because those bytes are the same for every twin on the platform. The highlighted band is the one a visitor might expect to control and cannot — assistant turns sent by a browser are discarded, because a forged prior answer is the most effective attack on a rule written in words.

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"]
}
The chat response, byte for byteA chat response is the answer text streamed as plain text, then a single NUL byte, then one line of JSON carrying the citations and the searches the answer actually used.everything before the cut is the answereverything after it is the frameanswer bytestext/plain, streamedmetadata frameone line of JSON, appended lastMETA_SEPARATOR = U+0000a byte no model output can contain — no escaping, and no answer can forge a frame{ citations, searches, searchCount, records, lookups }
The frame rides at the end rather than in a header because the twin may search its own material again part-way through an answer — what it drew on is not known when a header is sent.

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:

BoundLimitBody
Per IP, all agents20 requests a minute"Slow down a little."
Per IP, per agent100 messages a day"This twin has reached its monthly message limit." + reset date
Per agentthe 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 codeLimitWhere it lives
message1–2000 characters, per requestapi/chat/route.ts
slug1–64 charactersapi/chat/route.ts
history20 turns, 4000 characters eachapi/chat/route.ts
rateLimitDistributed("chat:<ip>")20 requests per 60 seconds, per IPapi/chat/route.ts
VISITOR_DAILY_PER_TWIN100 messages a day, per visitor IP per agentapi/chat/route.ts
HISTORY_TURNS20 turns replayed into the prompt, from the server's own stored messagesapi/chat/route.ts
monthly_messages300 messages a month on Free, 2000 on Founding, per organisationlib/billing/plans.ts
MAX_TOKEN_LIFETIME_SECONDS3600 seconds (1 hour) — the largest exp − iat acceptedlib/twin-token.ts
CLOCK_SKEW_SECONDS60 seconds, tolerated in both directionslib/twin-token.ts
citationsFrom(…, { max })3 citations per answerlib/rag/tools.ts
PASSAGE_MAX1200 characters — the longest citation snippetlib/rag/tools.ts
QUIET_SNIPPET260 characters — the snippet when the creator hasn't opened receiptslib/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

StatusWhenBody
400The body failed validation"Invalid request."
403preview: true from someone who isn't the owner"Not authorized to preview this twin."
404No such agent — or it's restricted and your token didn't check out"Twin not found."
429Any of the three bounds abovesee the table above
503No 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 twinYour own server mints a signed token for a user it already knows, your page passes it to the embed, and every chat request carries it as a bearer token — hiy verifies the signature against the twin it names and answers a failure with a plain not-found.Your serverYour pagehiymintTwinToken()the token, into the pageembed.js → /embed/<twin>?token=…POST /api/chat · Bearer v1.…verifyTwinToken → canAccessTwinthe answer, streamedor 404 “Twin not found.”
Every rejection reads the same. A wrong slug, a missing token, a bad signature, an expired one and a token minted for a different twin all return “Twin not found.” — a 403 would confirm the twin exists, which is the one bit somebody probing for a company’s internal twin is after.

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 tokenA token is a version marker, a base64url payload naming the twin, the host application’s own user id and an expiry, and an HMAC-SHA256 signature over the first two — and the signature is compared before the payload is ever parsed.version — one algorithm, no alg field, not a JWTMAX_TOKEN_LIFETIME_SECONDS = 3600 sCLOCK_SKEW_SECONDS = 60 sv1.base64url(payload).base64url(HMAC-SHA256){ twin, sub, iat, exp }twin binds the audience: a token for one twin never opens anotherHMAC-SHA256 over v1.<payload> with the org’s derived secretcompared with timingSafeEqual before the payload is parsed
Your server mints it; hiy only verifies. A signer that asks for a year-long token is refused even though it signed correctly — the lifetime ceiling is our policy, not the caller’s.

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:

// 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.

PathWhat it is
/llms.txtThe 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.jsonThe 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.jsThe 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.

Was this page helpful?

View Markdown