Developer guide

Embed the chat, wire it to your systems, and verify what we send.

Embed the widget

Paste this before the closing body tag of any page on an allowed origin:

HTML
<script src="https://YOUR-CITE-HOST/widget.js" async></script>
<script>
  window.Cite = window.Cite || { _q: [] };
  window.Cite._q.push(['init', { siteKey: 'YOUR_SITE_KEY' }]);
</script>

The chat runs in an iframe served from your Cite host, so it does not inherit your page's styles or scripts.

Site keys and allowed origins

Each agent has one or more site keys, created in the dashboard. A site key is public: it identifies the agent and is safe to put in page source. What protects it is the allowed origins list and the per-key limits.

Grounding and citations

For every message the agent searches your indexed documents and gives the model only the passages it finds. The model is instructed to answer from those passages, to cite them with bracketed numbers like [1], and to say so when your content does not cover the question. A citations event in the stream maps each number to the document title and, for crawled pages, its source URL, which the widget renders as a link. Numbers restart every turn. This reduces, but does not eliminate, wrong answers.

Actions

An action lets the assistant call an HTTPS endpoint you control, for example to look up an order. You define the name, a small typed parameter schema (up to 10 string, number, integer or boolean parameters, no nested objects), the HTTP method and the response fields the model may see. The model cannot add or change actions.

Request

POST body
{
  "action": "lookup_order",
  "arguments": { "orderId": "A-1042" },
  "conversationId": "5b0c1f4e-...",
  "timestamp": 1760000000
}

Response

Return a 2xx status and a JSON body within 8 seconds and 256 KB, with at most 2 redirects. Only endpoints on the public internet over https are called; private networks and loopback addresses are rejected. Only fields on your response allowlist (dotted paths, [] for each array item, such as items[].status) reach the model. Everything else is dropped, secrets and card or ID numbers in allowed fields are redacted, the result is capped at 4,000 characters, and it is presented to the model as data, not instructions.

Confirmation for write actions

An action marked as requiring confirmation never runs on the model's say-so. The arguments are validated and stored, and the visitor sees a card with the exact values and Confirm and Decline buttons (an action_confirmation stream event). Only the visitor's click executes it, and the confirmation expires after 10 minutes. A conversation can have at most 5 open confirmations.

Verify signatures

Signed requests carry X-Signature: t=<unix>,v1=<hex>, where v1 is the hex HMAC-SHA256, keyed with your signing secret, of the string "<t>.<body>". Compute it over the raw bytes you received, in constant time, and reject timestamps more than 5 minutes (300 seconds) from your clock to stop replays.

Node.js
import crypto from "node:crypto";

// rawBody: the exact request body string (POST), or the raw query string
// including the leading "?" (GET; "" when there are no arguments).
export function verify(secret, rawBody, header, toleranceSeconds = 300) {
  const m = /^t=(\d+),v1=([0-9a-f]{64})$/.exec(header ?? "");
  if (!m) return false;
  const t = Number(m[1]);
  if (Math.abs(Date.now() / 1000 - t) > toleranceSeconds) return false;
  const expected = crypto.createHmac("sha256", secret).update(`${t}.${rawBody}`).digest();
  const given = Buffer.from(m[2], "hex");
  return given.length === expected.length && crypto.timingSafeEqual(given, expected);
}

// Express: capture the raw body before JSON parsing.
// app.post("/cite", express.raw({ type: "application/json" }), (req, res) => {
//   const ok = verify(process.env.CITE_SECRET, req.body.toString("utf8"), req.get("x-signature"));
//   if (!ok) return res.status(401).end();
//   const { action, arguments: args } = JSON.parse(req.body.toString("utf8"));
//   ...
// });
Python
import hashlib, hmac, re, time

def verify(secret: str, raw_body: str, header: str, tolerance_seconds: int = 300) -> bool:
    # raw_body: exact POST body, or the raw query string including "?" for GET.
    m = re.fullmatch(r"t=(\d+),v1=([0-9a-f]{64})", header or "")
    if not m:
        return False
    t = int(m.group(1))
    if abs(time.time() - t) > tolerance_seconds:
        return False
    expected = hmac.new(secret.encode(), f"{t}.{raw_body}".encode(), hashlib.sha256).hexdigest()
    return hmac.compare_digest(expected, m.group(2))

Lead capture webhook

When lead capture is enabled, the assistant asks for the fields you defined and the visitor's consent, then saves a lead in the dashboard. If you set a webhook URL, each lead is also POSTed there, using the same signing, egress rules and body shape as actions with the action name lead_captured. Your response body is not used; a 2xx marks the delivery as delivered, anything else as failed.

POST body
{
  "action": "lead_captured",
  "arguments": { "name": "Ada", "email": "ada@example.com", "consent": true },
  "conversationId": "<uuid>",
  "timestamp": 1760000000
}

Handoff webhook

When the assistant flags a conversation for a person, a signed JSON payload is POSTed to your handoff webhook (the text field makes it compatible with Slack incoming webhooks). Secrets and card or ID numbers are redacted first, the transcript is limited to the last 20 messages of 1,000 characters each, and redirects are not followed. Respond with 2xx within 5 seconds.

POST body
{
  "event": "handoff.requested",
  "text": "Ada asked for a human (Support): needs a refund review\nhttps://.../conversations/<id>",
  "conversation": { "id": "<uuid>", "agentId": "<uuid>", "url": "https://.../conversations/<id>", "origin": "https://example.com" },
  "contact": { "name": "Ada", "email": "ada@example.com" },
  "summary": "needs a refund review",
  "transcript": [{ "role": "user", "content": "..." }, { "role": "assistant", "content": "..." }],
  "redactions": 0
}

Limits and error codes

Limits apply per site key, per visitor (by IP address), per conversation and per account. When one is hit, the chat endpoint answers with a status and the stream or JSON body carries a code. The widget maps the terminal codes to a final message and stops retrying.

CodeHTTPMeaning
INVALID_REQUEST400The request body was malformed.
INVALID_SITE_KEY401The site key is unknown.
ORIGIN_NOT_ALLOWED403The site key is disabled, or the page's origin is not on its allowed list.
INVALID_CONVERSATION403The conversation id does not belong to this site key.
RATE_LIMITED429Too many requests per minute from this visitor. Retry shortly.
IP_DAILY_CAP429This visitor reached the agent's daily token limit.
CONVERSATION_CAP402The conversation hit its cost ceiling. Start a new conversation.
ORG_SUSPENDED402The account is suspended.
BILLING_REQUIRED402The free trial credit is used up, or the chosen model needs a payment method. The account owner must add one.
SPEND_CAP_REACHED402A paying account reached its monthly usage limit. It resets next month or when the owner raises it.
INTERNAL500Unexpected server error.

Message size is limited to 8,000 characters. Billing: the free trial credit is a one-time allowance; usage on paid plans is billed at twice the model cost, under a monthly usage limit.