/docs

paste this
into your agent.

Spookat is built to be set up by the thing that writes your code. Or read on, like it's 2019.

prompt

Add Spookat chat to my site. Site key: YOUR_SITE_KEY. Read https://spookat.com/llms.txt first, then match the chat style to my site.

01 install

One tag, before </body>. The launcher is one static file from our CDN, under 5 kb. It makes 0 requests to our API until someone clicks it.

<script>
  window.Spookat = { site: "YOUR_SITE_KEY" }
</script>
<script src="https://cdn.spookat.com/spookat.js" async></script>

02 config

The look lives in two places: the snippet, and the site’s config in the dashboard. 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
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
<script>
  window.Spookat = {
    site: "YOUR_SITE_KEY",
    style: "brutal",
    font: "Space Mono",
    accent: "#C6FF3D",
    side: "right"
  }
</script>

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

<script>
  window.Spookat = { site: "YOUR_SITE_KEY", user: { id: "42", sig: "SIG_FROM_YOUR_SERVER" } }
</script>
// 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
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 // PHP
function spookat_user($id, $secret = null) {
    $id = (string) $id;
    return ['id' => $id, 'sig' => hash_hmac('sha256', $id, $secret ?? getenv('SPOOKAT_IDENTITY_SECRET'))];
}
# 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: 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.

{
  "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). 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=<unix seconds>, v1=<hex>, an HMAC-SHA256 of t + "." + raw_body. The key is the whole signing secret, whsec_… included (reveal it on the autopilot page).

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

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 → /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:

claude mcp add --transport http spookat https://api.spookat.com/mcp --header "Authorization: Bearer sk_live_…"

In Cursor, .cursor/mcp.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.