# Spookat > Chat widget for websites. Visitors write in a tiny widget, your team answers in a Slack or Discord thread. 0 requests to our API before a visitor clicks, no cookies, priced per site, not per seat. 14 days free, no card. Plans: [pricing](https://spookat.com/pricing/) This file is the whole manual. It is the same text as [spookat.com/docs](https://spookat.com/docs/) and it is written for the agent that writes your code. Setting Spookat up for someone? You need their site key (it starts with `site_live_` and lives in the [dashboard](https://app.spookat.com)). Install the snippet, pick a style that matches their site, then connect Slack or Discord. A human approves that connection, and a human always pays. ## 01 install One tag, before ``. The launcher is one static file from our CDN, under 5 kb. It makes 0 requests to our API until someone clicks it. ~~~html ~~~ ## 02 config The look lives in two places: the snippet, and the site's config in the [dashboard](https://app.spookat.com). If they disagree, the snippet wins. Editing their repo? Put it in the snippet. No repo access? Use the MCP server (06): it changes the dashboard's draft, and a human publishes it. | key | values | does | |---|---|---| | `site` | `"site_live_…"` | required. which of your sites this is | | `style` | `"brutal"` · `"clean"` · `"glass"` · `"soft"` · `"terminal"` · `"paper"` · `"retro"` · `"swiss"` | the look. see [/how](https://spookat.com/how/) | | `font` | `"system"` or any of 12: `"Geist"` · `"Geist Mono"` · `"JetBrains Mono"` · `"IBM Plex Sans"` · `"IBM Plex Serif"` · `"Space Grotesk"` · `"Space Mono"` · `"Archivo"` · `"DM Sans"` · `"Instrument Serif"` · `"Fraunces"` · `"VT323"` | self-hosted, no google requests | | `accent` | `"#RRGGBB"` | text color on it is picked for contrast | | `side` | `"right"` \| `"left"` | where the launcher sits | | `greeting` | string | first message; empty = the style's default | | `label` | string | text for a pill launcher; leave it out for the icon | | `lang` | `"en"` · `"pl"` | widget language; default is the visitor's browser, then `"en"` | | `user` | `{ id, sig }` · cool guy+ | who's logged in, signed on your server | ~~~html ~~~ **Logged-in users, cool guy+.** Your app knows who's signed in; pass it and your team sees `logged in as 42` on the Slack or Discord card and in the inbox, and webhooks carry it as `visitor.userId`. The id is signed, so nobody can pose as someone else: `sig = hex(hmac_sha256(identity_secret, id))`. Make the identity secret on the dashboard's [install page](https://app.spookat.com/install) and keep it on your server: sign there, never in the browser. A missing or wrong `sig` isn't an error, the chat just doesn't say who it is. Rotating the secret makes every old `sig` wrong at once. ~~~html ~~~ ~~~javascript // Node, Bun, Deno import { createHmac } from "node:crypto"; export const spookatUser = (id, secret = process.env.SPOOKAT_IDENTITY_SECRET) => ({ id: String(id), sig: createHmac("sha256", secret).update(String(id)).digest("hex"), }); ~~~ ~~~python # Python import hashlib, hmac, os def spookat_user(user_id, secret=os.environ.get("SPOOKAT_IDENTITY_SECRET", "")): uid = str(user_id) return {"id": uid, "sig": hmac.new(secret.encode(), uid.encode(), hashlib.sha256).hexdigest()} ~~~ ~~~php $id, 'sig' => hash_hmac('sha256', $id, $secret ?? getenv('SPOOKAT_IDENTITY_SECRET'))]; } ~~~ ~~~ruby # Ruby require "openssl" def spookat_user(id, secret = ENV.fetch("SPOOKAT_IDENTITY_SECRET")) { id: id.to_s, sig: OpenSSL::HMAC.hexdigest("SHA256", secret, id.to_s) } end ~~~ Render the result into the snippet as JSON. Ids are strings up to 255 characters. **Reply emails.** A visitor can leave an email after their first message. On cool guy+, when your team (or your agent) replies while they're gone, they get the reply by email about 2 minutes later, several replies in one email, in the widget's language. It comes from your site's name, replies to it go nowhere, and it links back to the page they chatted on. The link opens the chat once, on any device, and moves the chat there. A bounce or the email's stop link and we forget the address. On broke af nothing is emailed: the reply waits in the widget. ## 03 slack / discord One click from the dashboard, or ask your agent to call `connect_chat`. You approve in Slack or Discord, not in our app. Every conversation becomes a thread in Slack or a forum post in Discord. Replies in the thread go to the visitor. Start a message with `//` and it stays internal. A visitor who spams: `/spookat block SP-0001` (in Discord, run it inside the thread without the ref), the "Block visitor" message shortcut in Slack, or the inbox. Their chat says it isn't taking messages and nothing they send reaches you. It goes by their chat token, so clearing browser storage dodges it; the rate limits catch the rest. ## 04 webhooks cool guy and up. Set the endpoint on the dashboard's [autopilot page](https://app.spookat.com/autopilot): url, events, payload. We POST each event to it as JSON, signed. The payload carries the full message text by default. Want ids only? Switch the webhook to thin and your agent fetches the text through the reply api (05) when it needs it. - `message.created` - `convo.created` - `handoff.requested` - `convo.assigned` - `convo.closed` - `note.created` - `convo.purged` (always on) `message.created` fires for visitor messages, `note.created` for your team's notes. What your agent posts never comes back to it as an event. ~~~json { "id": "evt_…", "type": "message.created", "created": 1790000000000, "site": "fitro.pl", "data": { "convo": { "ref": "SP-0001", "status": "open", "assignee": null, "autopilot": true, "flag": null, "visitor": { "name": null, "email": null, "userId": null }, "tags": [], "createdAt": 1790000000000, "lastMessageAt": 1790000000000 }, "message": { "id": 42, "author": "visitor", "name": null, "kind": "msg", "text": "…", "at": 1790000000000, "ctx": { "url": "https://fitro.pl/cennik", "lang": "pl-PL", "browser": "safari ios", "country": "PL" } } } } ~~~ `visitor.userId` is the signed-in user from `window.Spookat.user` (02), only when its signature checked out. `tags` are intent tags from Jev, our classifier: off unless you asked us to turn them on. When they're on, the text of each visitor message, and nothing else about the chat, goes to Jev through OpenRouter, which is then one of our subprocessors ([privacy](https://spookat.com/privacy/)). Tags arrive a moment after the message, so the convo's first events may not have them yet. Thin: `"data": { "convo": "SP-0001", "message": 42 }`. `handoff.requested` adds `"reason"` (full only). `convo.purged` is `{ "convo": "SP-0001" }` either way: delete it on your side too. Every request carries `Spookat-Signature: t=, v1=`, an HMAC-SHA256 of `t + "." + raw_body`. The key is the whole signing secret, `whsec_…` included (reveal it on the autopilot page). ~~~js // verify before you trust it (Node, Bun, Deno) import { createHmac, timingSafeEqual } from "node:crypto"; export function verify(secret, header, rawBody, now = Date.now()) { const { t, v1 = "" } = Object.fromEntries(header.split(",").map((p) => p.trim().split("="))); const expected = createHmac("sha256", secret).update(`${t}.${rawBody}`).digest("hex"); if (!(Math.abs(now / 1000 - Number(t)) <= 300)) return false; // older than 5 min return v1.length === expected.length && timingSafeEqual(Buffer.from(v1), Buffer.from(expected)); } ~~~ - Answer 2xx within 10 s. Anything else is a failure, a redirect too: we don't follow them. - Failures retry with backoff (30 s, 1 min, 2 min, … up to 3 h apart) for 24 h. Every attempt carries the same `id`: dedupe on it. - Failing for 3 days: the endpoint is paused and the site's owners get an email. Turn it back on from the autopilot page. - The delivery log there shows status and ms for each event, with resend. - https only, and never to a private or loopback address. ## 05 reply api Base url `https://api.spookat.com/v1`, any secret key (see 06). The key picks the site. Convos go by their ref, `SP-0001`. Writing to convos (messages, notes, handoff, assign, close) is cool guy and up; reading, deleting and export work on every plan. ~~~http GET https://api.spookat.com/v1/convos?status=open # or closed. latest activity first, 50 GET https://api.spookat.com/v1/convos/SP-0001 # the visitor, every message and note POST https://api.spookat.com/v1/convos/SP-0001/messages { "text": "…", "as": "autopilot" } POST https://api.spookat.com/v1/convos/SP-0001/notes { "text": "…" } POST https://api.spookat.com/v1/convos/SP-0001/handoff { "reason": "pricing question" } POST https://api.spookat.com/v1/convos/SP-0001/assign { "assignee": "marta" } # admin key POST https://api.spookat.com/v1/convos/SP-0001/close # admin key DELETE https://api.spookat.com/v1/convos/SP-0001 # admin key. fires convo.purged GET https://api.spookat.com/v1/export # admin key. everything for the site as json, no secrets Authorization: Bearer sk_live_… ~~~ Agent keys read convos (notes included: your team writes them for the agent too), reply, add notes and hand off. Errors come as `{ "error": "…", "code": "…" }`: | status | code | means | |---|---|---| | 401 | `unauthorized` | no key, or a revoked one | | 402 | `plan_required` | the site's plan doesn't include it. The message links to billing | | 403 | `forbidden_author` | `as` is anything but `"autopilot"` | | 403 | `forbidden_scope` | an agent key called an admin route | | 404 | `not_found` | no such convo on this key's site | | 409 | `autopilot_off` | autopilot is off for the site | | 409 | `stepped_back` | a teammate replied, or it was handed off. Final until a human gives it back | | 409 | `closed` | the convo is closed. A new visitor message reopens it | | 409 | `working_hours` | the rule is "outside working hours", and it's inside them | | 409 | `too_early` | the rule is "if nobody replies in 3 min". `retryAfter` (and `Retry-After`) says when | **When may it answer.** Autopilot is on or off per site, on the autopilot page. A convo that starts while it's on is the agent's to answer, under one rule: - **always answers first:** replies go through until a teammate replies. - **if nobody replies in 3 min:** a reply is taken 3 minutes after the visitor's last message, if no teammate answered by then. - **outside working hours:** Mon–Fri, from–to in your time zone. Inside them the team answers. A shift past midnight (22:00–06:00) belongs to the day it starts: Friday night runs into Saturday morning. - **drafts only, human sends:** the reply lands in the thread as an internal note. A teammate sends it. **Handoff** turns autopilot off for that convo, flags it with the reason and pings the channel: `autopilot stepped back · flagged: pricing question`. The widget's "talk to a human" button does the same. It happens once: asking again changes nothing and pings nobody. A teammate can give the convo back to autopilot from the dashboard. ## 06 mcp + webmcp Same tools, two doors. The main door is the remote MCP server at `https://api.spookat.com/mcp` (Streamable HTTP). It works from any coding agent that speaks MCP. **1. Get a key.** A human makes one in the [dashboard](https://app.spookat.com) → /install → secret keys. It starts with `sk_live_`, belongs to one site, and is shown once. Keep it out of the repo. - `admin`: every tool. - `agent`: `get_config` · `preview` · `get_snippet` · `list_convos` · `read_convo` · `reply` · `add_note` · `handoff`. The setup ones only read; the rest answer visitors as autopilot, add notes and hand off. **2. Add the server.** In Claude Code: ~~~sh claude mcp add --transport http spookat https://api.spookat.com/mcp --header "Authorization: Bearer sk_live_…" ~~~ In Cursor, `.cursor/mcp.json`: ~~~json { "mcpServers": { "spookat": { "url": "https://api.spookat.com/mcp", "headers": { "Authorization": "Bearer sk_live_…" } } } } ~~~ **3. Set it up.** The key picks the site. Setters change the dashboard's draft only, never the live widget, and every call shows up in the site's activity log. - **setup tools:** `get_config` · `set_style` · `set_font` · `set_accent` · `set_position` · `set_greeting` · `preview` · `get_snippet` · `publish` · `connect_chat` · `change_plan` - **conversation tools, cool guy+:** `list_convos` · `read_convo` · `reply` · `add_note` · `handoff` · `assign` · `close` · `delete_convo`. Same functions and rules as the reply api (05): `reply` posts as autopilot; `assign`, `close` and `delete_convo` need an admin key, and `delete_convo` works on every plan. MCP only, not WebMCP. **A human clicks the last step.** You get a link back and hand it to them: - `publish` doesn't publish. It asks: the human approves the draft on the dashboard's appearance page. - `connect_chat` returns the page where they add Slack or Discord and approve it there. - `change_plan` returns the page where they change the plan. Agents never pay or cancel. WebMCP is the bonus door: while the dashboard tab is open at https://app.spookat.com, a browser agent can call the same tools right there, with no key. `publish` still waits for the human's approve. ## 07 ai rules. not optional. - Keys post as **autopilot**, agent and admin keys alike. Sending as a human name returns 403. - Every AI message is labelled AI in the widget, whatever style you picked, and in your team's thread. - Every conversation goes to your Slack or Discord, agent or not. A teammate's reply takes over: the agent steps back and its next reply gets 409. - Agent keys can reply and flag a convo for the team. They can't assign, close, delete or export. - Billing stays human. An agent can start a plan change, but all it gets back is a checkout link a person confirms. Agents never pay or cancel on their own.