
wicket
io.github.Stixels/wicket
Search a product's docs, file bugs and feature requests, and hear back when they ship.
Documentation
Wicket
A drop-in help and feedback chat for any web app, plus a Discord community and support inbox behind it. Users write one message. An assistant answers from your docs, and every conversation shows up in your team's Discord, where a reply goes straight back to the user in the app.
-
One script tag adds a chat button to your app. It captures the page, app version and recent console errors, so reports arrive with context.
-
Answers from your docs. It indexes any GitHub repo of Markdown or MDX (GitBook Git Sync, Docusaurus, Mintlify, ...), or any hosted docs site by its sitemap, and links the exact section.
-
Discord as the inbox and community. Each in-app conversation becomes a private forum post that your team answers from Discord. The same assistant can also answer new questions in your public
#helpforum. -
Hands off to a person when the user asks, is frustrated, or the docs don't cover it, and pings a role you choose.
-
Cheap to run. One Node process with SQLite. The assistant runs through the Vercel AI Gateway or Anthropic directly, and it's optional: without a key, every chat goes straight to the team.
-
Works from AI tools. Wicket is also an MCP server, so people can search your docs, file bugs and check on them from Claude Code, Claude Desktop or Cursor. See MCP server.
-
Everyone hears back. With a tracker set up, the assistant logs bugs, ideas and docs gaps itself. A repeat report becomes another report on the ticket that already exists, not a new one. Everyone who reported it gets a message in their chat (and by email if they're away) when it's fixed, and for ideas, when the team plans it or decides not to.
Run the demo (no keys needed)
npm install
npm run demo # open http://localhost:4317
The demo indexes the Escape Director docs, uses a scripted assistant and simulates Discord. To simulate a team reply, run curl -XPOST localhost:4317/__staff -H 'content-type: application/json' -d '{"body":"Hi!"}'.
Add it to your app
<script src="https://YOUR-SERVER/widget.js" defer></script>
<script>
window.addEventListener("load", () =>
Wicket.init({
user: { id: currentUser.id, name: currentUser.name, email: currentUser.email },
context: { appVersion: APP_VERSION, venue: currentVenue.name },
title: "Escape Director Support",
theme: { accentColor: "#e8590c" },
}),
);
</script>
You can also call Wicket.open(), Wicket.close(), Wicket.toggle(), Wicket.ask("question") (opens the chat and sends it), Wicket.setContext({ roomId }), Wicket.setUser({...}), Wicket.setUserToken(...), Wicket.setTheme({...}), Wicket.setAnchor(element), Wicket.setQuiet(true), Wicket.addBreadcrumb("Paused game") and Wicket.destroy() (removes the widget, for example on sign-out; init brings it back) at any time. The latest context and a fresh userToken go with every message.
Recent activity for bug reports. The widget keeps a short trail of what the person did in your app: the last 20 steps from the last 5 minutes, made of page changes (path only, never the query string), clicks on buttons and links by their visible label or aria-label, and console errors. It never records what anyone types or the value of a field. Add your own steps with Wicket.addBreadcrumb(label), for example "Paused game" or "Sent Clue"; it works before init. The trail stays in the page and is sent only with a bug report: with the first message after "Report a bug", or right after the assistant logs a bug on its own (then it's added to that ticket as a comment). The server caps it, strips anything that looks like an email, card number or token, and puts it in the model's transcript and on the ticket under ## Recent activity, nowhere else. Turn it off with Wicket.init({ trail: false }).
Your own button
To open the chat from a button in your app's header, hide the launcher and pass the button as anchor:
const button = document.querySelector("#help-chat");
Wicket.init({ hideLauncher: true, anchor: button });
button.addEventListener("click", () => Wicket.toggle());
const off = Wicket.on("unread", (count) => (button.dataset.count = count || ""));
- The panel opens just below the anchor, right-aligned to it, floating over the page. On phones it opens full screen as usual. If the anchor is gone or hidden, the panel uses the bottom corner.
- When a reply arrives while the chat is closed, a preview drops from under the anchor (or sits above the launcher) with the reply, a one-line reply box and Open chat. It never plays a sound.
quiet: true(orWicket.setQuiet(true)) turns the previews off, for screens other people can see;unreadstill fires so you can show the count yourself.Wicket.on(event, callback)returns a function that unsubscribes. Events:unread(the count, on every change, including back to 0 when the chat opens),open,close, andrating({ conversationId, messageId, rating }when someone rates an answer;ratingis"up","down"ornullwhen they take it back). You can subscribe beforeinit.
Each assistant answer has thumbs up and down on its name line. A thumbs down offers to pass the conversation to the team. Answers can come with How we got this (the numbers behind them) and Ask next questions under the latest one. When the server asks for a bot check, visitors pass a Cloudflare Turnstile check in the panel before their first answer.
Signed-in users
A name or id passed in user is only a label. For profiles that depend on who someone is (signed in, their plan) and for per-person budgets, your backend signs a short-lived token and the widget passes it on:
// Your backend, with the same IDENTITY_SECRET Wicket has (HS256 JWT)
jwt.sign({ sub: user.id, name: user.name, email: user.email, plan: user.plan }, process.env.WICKET_IDENTITY_SECRET, { expiresIn: "5m" });
Wicket.init({ userToken: () => fetch("/api/wicket-token").then((r) => r.json()).then((d) => d.token) });
userToken takes the token or a function that returns one. Without a valid token, the person counts as a visitor. Tokens must have an expiry (exp) no more than 24 hours out; a few minutes is best. The widget asks for a fresh one with every message.
Optional claims:
| Claim | What it does |
|---|---|
account | Bill per account instead of per person: everyone with the same account spends from one budget. |
budget | The account's (or person's) monthly AI allowance in dollars. When a profile also has a budget, the lower one applies, so your app stays in charge of the amount. |
env | Which of your environments signed it (staging, production), when one Wicket serves several. Picks the app-tools URL. |
Deleting someone's data
When someone deletes their account in your app, your backend signs a token with scope: "erase" and posts it to /api/erase:
const token = jwt.sign({ sub: user.id, account: user.accountId, scope: "erase" }, process.env.WICKET_IDENTITY_SECRET, { expiresIn: "5m" });
await fetch("https://YOUR-SERVER/api/erase", { method: "POST", headers: { Authorization: `Bearer ${token}` } });
Wicket deletes every conversation the person started, and with account, every conversation billed to that account. That covers messages, files, the assistant's transcript and the team's Discord post. What each answer cost stays, without any words, so budgets add up. The answer is { deleted, failed }. A 502 means some Discord posts couldn't be removed yet; those conversations are kept, so call again later until you get a 200. Calling again is always safe. Only your backend should sign these tokens: an erase token never signs anyone in, and a widget's sign-in token can't erase.
App tools
Let the assistant read the signed-in person's own data, such as their results or settings, through tools your backend offers. Wicket calls them with the person's token, so your backend checks who they are on every call and Wicket never sees data they couldn't see in your app.
profiles:
default:
appTools:
url: https://api.example.com/wicket/tools # or { production: ..., staging: ... } keyed by the token's env
GET {url}?context=<json>returns{ instructions, tools: [{ name, description, inputSchema }] }.instructionsare your rules for using the tools; they go into the assistant's prompt.inputSchemais JSON Schema.POST {url}/{name}with{ input, context, evidenceStart }returns{ content, evidence? }.contentis what the model reads.evidenceitems ({ id, label, value, period?, basis?, caveats? }) show folded under the answer as "How we got this", numbered fromevidenceStart + 1so ids stay unique within an answer. Answer with a 4xx and{ error }for a problem the model should hear about.contextis what the widget last sent withWicket.setContext(), for example the page, the record on screen and the date range. Wicket sends it to the model again whenever it changes.
Profiles with app tools get more steps per answer (AGENT_MAX_STEPS_WITH_APP_TOOLS, 20 by default).
Customizing
| Option | Default | What it does |
|---|---|---|
title, subtitle | "Help & feedback" | Header text. |
welcomeTitle, welcomeSubtitle | "Hi {first name} 👋", "How can we help you today?" | Large heading on the start screen. |
greeting | short default | Line under the start screen heading. |
suggestions | 3 quick starts | Buttons shown before the first message. Pass [] to hide them. |
placeholder | "Write a message…" | Text in the empty input. |
logoUrl | animated bot | Square image for the header and the assistant's avatar. Without one, the assistant is a small animated orb in your brand color that blinks, looks around and shows bouncing dots while it thinks. |
launcherLabel | none | Text next to the launcher icon, e.g. "Help". |
hideLauncher | false | Hide the floating button and open the panel from your own button. |
showBranding | true | The small "Powered by" line. It links to your Wicket server's home page, which is the Wicket marketing page unless SITE=false. |
theme.accentColor | #4f46e5 | Brand color. Text on it switches between white and dark automatically. |
theme.mode | "auto" | "light", "dark", or follow the visitor's system. |
theme.radius | 20 | Corner radius in px. |
theme.fontFamily | system font | Any CSS font stack your page loads. |
theme.position | "right" | "left" or "right". |
theme.offset | { x: 20, y: 20 } | Distance from the screen edges. |
theme.zIndex | very high | Stacking order. |
theme.cssVars | none | Override any internal variable, e.g. { "--wk-bg": "#fffaf5" }. |
--wk-user, --wk-user-text, --wk-user-border | accent, text on accent, none | Colors of the person's own messages, set through theme.cssVars. |
The widget renders in a shadow DOM, so your app's CSS can't break it and its CSS can't leak into your app. On phones it opens full screen.
Earlier conversations show under Recent on the start screen, and the clock button in the header lists all of them. The browser keeps each conversation's access token in localStorage (up to 50). For signed-in users (a verified userToken), the server also returns their conversations by user id, so their history, including reports and their progress, follows them to any device or browser. Visitors' history stays in the browser they used.
Set up Discord (community + support inbox)
-
Go to https://discord.com/developers/applications and create a New Application. Under Bot, copy the token and turn on Message Content Intent.
-
Under OAuth2 → URL Generator, pick the
botscope with these permissions: Manage Roles, Manage Channels, Move Members, View Channels, Send Messages, Send Messages in Threads, Create Public Threads, Manage Threads, Read Message History, Add Reactions, Embed Links and Manage Messages (to pin the welcome post). Manage Server is optional: with it the bot also points join messages and the Community welcome screen at#welcome. On a Community server it also keeps the rules channel (#📜・rules) in Start here with a pinned rules post, makes#announcementsa real announcement channel, and moves Discord's admin notices channel into Team. Open the URL and add the bot to your server. -
Turn on Developer Mode (User Settings → Advanced). Right-click your server and choose Copy Server ID.
-
Set
DISCORD_BOT_TOKENandDISCORD_GUILD_IDin the service variables. On every boot the service makes sure the server has a Team role and these channels, creating only what's missing:Category Channels Who sees it Start here #👋・welcome,#📣・announcementsEveryone; only the team posts Support #❓・help(forum),🎧 Talk to support(voice)Everyone Community #💬・general,#💡・ideas(forum),#📸・show-and-tell,➕ Start a call(voice)Everyone Team #support-inbox(forum),#team-chatTeam role only Channels are matched by name with or without the emoji, and nothing is ever deleted. An existing channel keeps its place and settings; the bot only adds the emoji while it still has the plain name, fills in an empty topic, and adds forum tags and the 👍 default reaction on
#ideaswhen they're missing. It keeps Start here at the top so new members land in#welcome, and keeps a pinned welcome post there with the house rules and where to go. Anything it can't do itself is logged asdiscord: needs you: …. To use an existing staff role, setDISCORD_TEAM_ROLEto its name. You can also run it by hand withnpm run discord:setup. -
Give your teammates the Team role.
How it behaves:
- In-app conversations become posts in
#support-inbox. The user's messages show as blue cards and the assistant's as gray cards, so your own replies (plain messages) stand out. While the assistant is working the bot shows as typing. Reply in a post to answer the user in the app. The assistant stops replying once someone from the team does. When the assistant asks for a person, it pings the Team role and tags the post Needs a person. Only a reply that fails to reach the app gets a ⚠️ reaction. - When you're done, close the post (or tag it Resolved; deleting it works too, though the transcript is then gone from Discord). The user sees "resolved" in the widget, the post is tagged Resolved, and if they write again the post reopens and the assistant takes the new question first. Posts Discord archives on its own after inactivity aren't treated as resolved.
- Tickets: with
LINEAR_API_KEYandLINEAR_TEAM_KEYset, anyone with the Team role can tag the bot anywhere in the server, e.g.@Wicket file this as a bugin a support-inbox post. It reads the conversation, writes the ticket (summary, steps, context, who reported it), files it in Linear (Triage if the team has it on, otherwise Backlog) with the matching Bug/Feature/Improvement label, and replies with the link. Messages that tag the bot are never sent to the user. To file GitHub issues instead, setGITHUB_ISSUES_REPOandGITHUB_ISSUES_TOKEN(a fine-grained token with Issues: read and write); issues get the repo'sbug,enhancement,documentationorquestionlabel when it exists, plusGITHUB_ISSUES_LABELif you set one. The tracker is an adapter, so Jira can plug in the same way.- Tag the bot with a ticket key or link (
@Wicket +1 ED-123,@Wicket same as <link>) to add the conversation to that ticket instead of filing a new one. - Tickets filed or added from a support-inbox post are linked to that app conversation, so its reporter hears back too.
- Tag the bot with a ticket key or link (
- Hearing back: Wicket asks the tracker every 15 minutes (
tickets.checkMinutes) about tickets someone is waiting on. When the assistant logs a report, the person sees a slim status row in their own words. Bugs go from Logged to Fixed. Ideas go from Logged to Planned (the team moved it out of Triage) to Shipped, or to Not planned when the team cancels it. To tell people why, comment on the issue starting withReply:(for exampleReply: It would distract players mid-game.); Wicket waits an hour after a cancel so there's time to write it, and sends the newest one. A bug canceled with aReply:comment tells its reporters too; one canceled without a word sends nothing. A ticket closed as a duplicate passes its reporters to the one it duplicates. With GitHub Issues there's no Triage, so ideas skip Planned; closing as completed counts as done and closing as not planned as declined. The check is plain tracker API calls, with no model calls. The dashboard lists each conversation's tickets. Turn off assistant filing withtickets.assistant: false. - Voice: joining
➕ Start a callcreates a voice room named after you and moves you in. It's deleted when the last person leaves.🎧 Talk to supportworks the same way, but the room is visible only to the caller and the Team role, and it pings the team. The bot needs the Move Members permission for both. - Announcements: set
announcements.feedinconfig/wicket.yamlto an RSS feed, such as your product's changelog, and each new item is posted to#📣・announcementswith its title, link, summary and date. On a Community server the post is also published, so servers that follow the channel get it. Wicket checks every 30 minutes (announcements.checkMinutes). The first check only remembers what the feed already holds, so nothing old is posted; setannouncements.latest: 1to post the newest item once so the channel doesn't start empty. To post somewhere else, setDISCORD_ANNOUNCEMENTS_CHANNEL_ID. - Community questions: with
assistant.helpForum: true, when someone opens a post in#help, the assistant answers once from the docs. If it can't, it pings the team. Everyone else can keep the thread going. It's off by default, because Discord members aren't signed in to your app, so those answers can't count toward anyone's budget.
Team dashboard
A reply inbox at /admin, for teammates who'd rather not answer from Discord. It's another place to reply, not a replacement: Wicket keeps the record, so a reply from the dashboard reaches the user's widget and shows up in the Discord post, and a reply in Discord shows up in the dashboard.
- Shared conversations. Nobody owns a conversation. The dashboard shows who has replied ("Alex and you replied"), and anyone can jump in. Teammates are linked across tools by their Discord account, so Alex replying in Discord and Alex in the dashboard are the same person.
- Online status. A dot on the user's avatar shows whether they're around: solid green while the chat is open in front of them, a green ring while they're on the site with the chat closed or in a background tab. The conversation header says "In the chat now", "On the site" or "Last seen 12m ago", so you know whether to expect a quick back-and-forth.
- Typing hints. While someone types in the dashboard or in the Discord post, the others see "Alex is typing" on the conversation, so two people don't write the same answer.
- Hand off with a note. Switch the composer to Note (or type
@) to leave a team-only note like "@Alex can you take this?". Notes never reach the user. In Discord the note pings Alex, and the dashboard lists it under Mentions until Alex writes in that conversation. In Discord, a message that starts with an @mention of a teammate or role is treated the same way: it stays with the team instead of going to the user, and gets a 📝 reaction. - Desktop notifications. Turn them on from the banner or your profile menu. You're notified when a conversation is passed to the team, when the user writes in a conversation the team has, and when a note mentions you, but not while you're looking at the dashboard. The tab title counts what you missed.
- Saved replies. Answers the team gives often, shared by everyone: the ⚡ button in the composer, or type
/in an empty reply to search them.{name}becomes the person's first name. "Save what I wrote" turns a reply you just wrote into one. - Views: Open, Needs reply (the user is waiting on the team), Mentions, Resolved, All. Resolve works like closing the Discord post. Each conversation shows the person, their plan, the page and app version they wrote from, and recent console errors.
- Attachments are up to 10 MB each (Discord's limit without server boosts), 30 per conversation, and stored on the server's volume. A teammate can delete one from the dashboard (hover it, then the bin); it's removed for everyone and the message keeps its name, marked removed. Files go automatically after
files.keepDays(90 by default, 0 keeps them) and uploads that were never sent go after a day. Copies already posted to Discord stay there.
Sign-in is with Discord, for everyone with the Team role:
- In the same Discord application as the bot, open OAuth2, copy the Client ID and Client Secret, and add a redirect:
https://YOUR-SERVER/admin/auth/callback. - Set
DISCORD_CLIENT_ID,DISCORD_CLIENT_SECRETandPUBLIC_URL(your server's address). WithDISCORD_GUILD_IDset, anyone with the Team role can sign in. To let in someone without the role, add their Discord user id toTEAM_DISCORD_IDS.
For local work, TEAM_DEV_LOGIN=true adds a one-click "local teammate" sign-in. Never set it on a public server.
Support is async, so nobody should have to keep a tab open waiting. With email set up, a team reply the user hasn't seen in the widget for 5 minutes is emailed to them (at most one email per conversation an hour, with every unread reply in it). The email links to a page with just that conversation, and replying to the email posts into the conversation, so it reaches the dashboard and Discord too. Each email has an unsubscribe link for that conversation. The address comes from the signed-in user's token, user.email in Wicket.init, or the email field in the widget (askEmail in a profile), and the "passed to the team" message adds "We'll email you when they do."
It uses Resend; the free tier is plenty for a small team.
- In Resend, verify the domain you'll send from and create an API key. Set
RESEND_API_KEY,EMAIL_FROM(e.g.Escape Director <support@escapedirector.com>) andPUBLIC_URL. - For replies by email: add a receiving domain in Resend (e.g.
reply.escapedirector.com) and set it asEMAIL_REPLY_DOMAIN. Add a webhook foremail.receivedpointing athttps://YOUR-SERVER/email/inboundand set its signing secret asRESEND_WEBHOOK_SECRET. A reply only counts if it comes from the address we emailed, to that conversation's own reply address.
MCP server
Wicket is also an MCP server, so people can reach your docs and your tracker from Claude Code, Claude Desktop, Cursor or any other MCP client. It uses the same code as the widget: the same docs index, the same tracker, the same duplicate check, and the same per-profile rules.
| Tool | What it does |
|---|---|
search_docs | Searches the docs index. Returns sections with a snippet and link. |
read_doc | The full text behind a search result. |
file_report | Files a bug or feature request. The first call checks open tickets, and if some might be the same thing, it files nothing and sends them back. The client then calls again with duplicate_of (joins that ticket as another report) or new_ticket: true. duplicate_of only takes a ticket it offered for that report, or one the person already reported. If the check can't reach the tracker, nothing is filed. Only shows up when the person's profile has report: bug, feature or both, and only offers those kinds. |
ticket_status | Where the person's reports stand (open, planned, fixed, declined), with the ticket and any team replies. |
Each report becomes a conversation like one from the widget. The team sees it in Discord and the dashboard, and the reporter hears back in it (ticket_status reads it, and the chat_url it returns opens it in a browser). The client's own model does the writing, so MCP calls don't spend the assistant's budgets.
Over HTTP
Set MCP=true (with IDENTITY_SECRET) and the server answers at /mcp (streamable HTTP, stateless). It's off by default, since it lets people file into your tracker. Every request needs a user token, the same JWT the widget uses (see Signed-in users), as Authorization: Bearer <token>. Tokens can last up to 24 hours, so give people a way to get one in your app, like a "Connect Claude" button that signs a token for them.
Limits: each tool call counts against MAX_MESSAGES_PER_CONVERSATION_PER_HOUR for that person. Each report counts as starting a chat (MAX_CONVERSATIONS_PER_IP_PER_HOUR), both for that person and for their IP. In the demo, calls also share the widget's per-IP limits.
Claude Code:
claude mcp add --transport http wicket https://YOUR-SERVER/mcp --header "Authorization: Bearer $WICKET_TOKEN"
Cursor (~/.cursor/mcp.json):
{ "mcpServers": { "wicket": { "url": "https://YOUR-SERVER/mcp", "headers": { "Authorization": "Bearer YOUR_TOKEN" } } } }
Claude Desktop's config file takes local servers, so bridge it with mcp-remote (Settings › Developer › Edit Config):
{
"mcpServers": {
"wicket": {
"command": "npx",
"args": ["-y", "mcp-remote", "https://YOUR-SERVER/mcp", "--header", "Authorization:${WICKET_AUTH}"],
"env": { "WICKET_AUTH": "Bearer YOUR_TOKEN" }
}
}
}
Try it on the demo
wicketchat.com/demo has a Try it from Claude Code card. It runs the bot check, then gives you a claude mcp add command with a day-long token for Sam, the demo customer. Then ask Claude Code something like "search the Escape Director docs for adding a clue", "report a bug: the clue counter doesn't go up after I send a clue", or "check on my reports". Reports go to the demo tracker and move on after a few minutes, like in the widget, and they show up in the widget's history in the same browser.
Over stdio
For a client on the machine with Wicket's config and database, such as a self-hosted install:
npm run build
claude mcp add wicket -- sh -c 'cd /path/to/wicket && node --env-file-if-exists=.env dist/src/mcp/stdio.js'
Claude Desktop:
{
"mcpServers": {
"wicket": {
"command": "node",
"args": ["--env-file=/path/to/wicket/.env", "/path/to/wicket/dist/src/mcp/stdio.js"],
"env": { "DATABASE_PATH": "/path/to/wicket/data/wicket.db", "WICKET_CONFIG": "/path/to/wicket/config/wicket.yaml" }
}
}
}
It uses the same settings, database and tracker as the server, and acts as the local user, or as whoever WICKET_USER_TOKEN names. It doesn't start the Discord bot, so reports filed this way go to the tracker and the dashboard but don't open a Discord thread. Run it alongside the server, which does the ticket checks that tell reporters when things move.
Deploy on Railway
- In Railway, choose New → GitHub Repo and pick this repo. It builds from the
Dockerfileand redeploys on every push tomain. The build runs the typecheck and tests first, so a failing push never replaces the running version. - Add a Volume mounted at
/data. SQLite lives there. - Copy
.env.exampleinto the service variables and fill them in. - Generate a domain and use it in the script tag above.
Keep it to one replica. SQLite and the Discord connection expect a single instance.
Hosted demo
The public demo at wicketchat.com is a separate Wicket service with DEMO=true. It runs the real server and real assistant, but nothing it does can reach a real team, tracker or inbox:
/is the marketing site with the live widget, as a visitor. Nobody is signed in anywhere except/demowith Signed-in customer picked./demoplays an escape-room app, with a Visitor / Signed-in customer switch. Visitors get docs answers only. The customer is Sam, Owner at Treehouse Escapes, signed in by the page's ownPOST /api/demo/token. Each browser gets its own id in a cookie, so nobody sees anyone else's chats. The endpoint only exists withDEMO=trueand only signs that one user.- Tickets go to a demo tracker kept in the service's SQLite. Duplicate search only covers the backlog in
config/demo.yaml, so one visitor's ticket titles never show up in another visitor's chat. A few minutes after a report (demo.advanceMinutes), the ticket moves on (bugs to Fixed, ideas to Planned) and a reply labeled as simulated arrives in the chat. - MCP:
/demohands out a token for Claude Code after the bot check (POST /api/demo/mcp-token), for the same Sam as that browser's chats. It lasts a day, and only these tokens open the demo's/mcp: the widget's own token comes without a bot check, so it doesn't. A report filed over MCP doesn't get assistant answers in its chat, since no bot check opened it./mcpfiles to the same demo tracker, so it can't reach a real one either. See Try it on the demo. - Handoffs don't go anywhere. The chat says that in a real install this goes to the team's Discord inbox.
- Limits: everyone shares one monthly AI budget (
budgets.monthlyTotal, $3). When it's spent, the chat says so and still links plain docs search results. Every chat passes a Turnstile bot check, signed in or not. Messages are capped in length and per chat, and both messages and chats are capped per IP per hour (demo:inconfig/demo.yaml). Attachments and the playground are off.
Service variables:
| Variable | Value |
|---|---|
DEMO | true |
WICKET_CONFIG | config/demo.yaml |
ANTHROPIC_API_KEY or AI_GATEWAY_API_KEY | the AI key. Without one, answers come from the scripted model and /demo says so. |
IDENTITY_SECRET | a fresh random secret of 32+ characters, not the one any real app uses |
TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY | a Turnstile widget for the demo's domain |
SITE | leave unset (on) to serve the marketing site at / |
PRODUCT_NAME | Escape Director |
MCP | true, for the Claude Code card on /demo and /mcp |
Add a volume at /data as usual. The docs come from knowledge: in config/demo.yaml, so no DOCS_* variables are needed.
With DEMO=true, Wicket refuses to start if any LINEAR_*, GITHUB_ISSUES_*, DISCORD_*, RESEND_*, EMAIL_* or TEAM_DISCORD_IDS variable is set, if RELAY isn't none, if TEAM_DEV_LOGIN=true, or if IDENTITY_SECRET or the Turnstile keys are missing. It also refuses settings with appTools, announcements.feed or assistant.helpForum. So it can't be pointed at Escape Director's real Linear, GitHub, Discord or API by mistake.
To try it locally, use Cloudflare's always-pass test keys:
npm run build:widget
DEMO=true WICKET_CONFIG=config/demo.yaml IDENTITY_SECRET=local-demo-secret-local-demo-secret-1234 \
TURNSTILE_SITE_KEY=1x00000000000000000000AA TURNSTILE_SECRET_KEY=1x0000000000000000000000000000000AA \
DATABASE_PATH=./data/demo.db npx tsx src/index.ts # open http://localhost:4318/demo
Profiles, rules and budgets
How the assistant behaves lives in one YAML file: config/wicket.yaml, or the YAML pasted into the WICKET_CONFIG variable. config/wicket.example.yaml lists every option.
profiles:
visitor: # not signed in
ai: false # no assistant: messages go straight to the team
askEmail: true # offer an email field so the team can follow up
default:
budget: 0.50 # dollars per person per month
report: both # "Report a bug" and "Request a feature" shortcuts
analytics:
instructions: Explain metrics using the docs.
suggestions: ["What does Success Rate mean?"]
budget: 2.00
rules: # first match wins; otherwise "default"
- when: { signedIn: false }
profile: visitor
- when: { page: /app/analytics, plan: pro }
profile: analytics
budgets:
warnAt: 0.8 # warn at 80% of a budget
monthlyTotal: 20 # cap across everyone
- Theme (top level) sets the widget's look for everyone, with the same fields as
Wicket.init({ theme }), so the embed snippet can stay bare. - Assistant (top level) applies everywhere, including the community help forum:
instructionsfor facts every answer should know,code(declineby default: no code, it links the closest guide or example;snippetsallows short snippets adapted from examples it has read), andhelpForum(falseby default;trueanswers new#helpposts, which no per-person budget covers). - Profiles set whether the assistant answers at all, extra instructions, whether it stays on topic (
offTopic: declineby default, so it won't do general coding or homework;topicswidens what counts), the model and effort, a monthly budget per person, and what the widget shows (title, welcome text, placeholder, quick starts). Options passed toWicket.init()still win. - Rules pick a profile from whether someone is signed in, their
plan, and the page they're on. Signed in and plan come only from a verified token; the page comes from the widget. - Visitors can get the assistant too, without opening a hole in your budget: give their profile
budgetScope: sharedso they all spend from one pot, andbotCheck: trueso each conversation starts with a Cloudflare Turnstile check (setTURNSTILE_SITE_KEYandTURNSTILE_SECRET_KEY; without them those visitors go straight to the team).tickets: falsestops the assistant logging reports for people it can't follow up with. - Report shortcuts:
report: bug,featureorbothputs "Report a bug" and "Request a feature" on the widget's start screen, under an "Ask a question" card. Choosing one tags the conversation straight away, before the first message, and the tag stays until something is logged. For a bug, the assistant's first reply offers a known fix from the docs, if there is one, and proposes the steps from the widget's recent activity for the person to confirm ("Looks like you paused the game, then sent a Clue from the Room Dashboard. Is that right, and does it happen every time?"), at most two short questions. Unless they say the fix solved it, it logs the bug in its next reply with what it has: the confirmed steps under## Steps to reproduce, the widget's context, and the trail under## Recent activity. A feature request is restated and logged. Either way a repeat goes on the ticket that already exists, and the assistant never decides whether a report is worth filing. The shortcuts are an extra way in: the assistant still logs bugs, ideas and docs gaps it hears about in any conversation on its own, just as without them. It'snoneby default. The server checks the profile, so a browser can't start a report where the profile doesn't offer one, and budgets and rate limits apply as usual. Shortcuts need a tracker and the assistant; on a profile withtickets: falsethey're off, and the boot log says so. - Follow-ups:
followUps: truehas the assistant suggest two or three questions to ask next with each answer, shown as chips. They're written with the answer, so they cost no extra call. - Budgets count what each person's assistant answers cost this calendar month (UTC), from what the AI Gateway billed for each response, or from token usage and list prices when it doesn't say. Signed-in people are counted by user id, visitors by IP. Each person has one total across all profiles; a profile's
budgetis the point where that profile stops answering, so giving every profile the same number makes it a single shared budget. Near the limit the widget says so; at the limit their messages go to the team instead.monthlyTotaldoes the same for everyone at once. Prices for current Claude models and GPT-6 Luna are built in; add others underbudgets.prices.
On boot the log lists each profile and anything that won't work as written, such as rules about sign-in without IDENTITY_SECRET.
Configuration
See .env.example. The main settings:
| Variable | What it does |
|---|---|
AI_GATEWAY_API_KEY or ANTHROPIC_API_KEY | Turns on the assistant, through the Vercel AI Gateway or Anthropic directly. Leave both empty to send every chat to the team. |
AGENT_MODEL, AGENT_EFFORT | Model and effort for the assistant (defaults: claude-haiku-5-5, low). |
DOCS_GITHUB_REPO, DOCS_BASE_URL | Markdown docs repo to index, and the public site its links should point to. Reindexed every DOCS_REINDEX_HOURS. |
DOCS_URL | No docs repo? A hosted docs site to index instead: its home page (pages under that path are read) or its sitemap.xml. Pages come from the sitemap when there is one, otherwise by following links, up to 500. |
RELAY, DISCORD_* | Where conversations show up for the team. |
ALLOWED_ORIGINS | Sites allowed to embed the widget. |
PLAYGROUND, SITE | PLAYGROUND serves a test page with the widget at / (on outside production). SITE serves the Wicket marketing page (site/index.html) at /, and moves the playground to /playground. It's on by default; SITE=false turns it off. |
DISCORD_CLIENT_ID, DISCORD_CLIENT_SECRET, PUBLIC_URL | Discord sign-in for the team dashboard at /admin. TEAM_DISCORD_IDS lets in people without the Team role. |
RESEND_API_KEY, EMAIL_FROM, EMAIL_REPLY_DOMAIN, RESEND_WEBHOOK_SECRET | Emailing unread replies to users, and replies by email. See Email. |
IDENTITY_SECRET | Shared with your backend to sign user tokens (at least 32 characters). |
TURNSTILE_SITE_KEY, TURNSTILE_SECRET_KEY | Cloudflare Turnstile, for profiles with botCheck: true. |
MCP | true turns on the MCP server at /mcp (needs IDENTITY_SECRET). Off by default. See MCP server. |
WICKET_CONFIG | Profiles, rules, budgets and extra knowledge repos (knowledge:, e.g. an SDK with examples): a file path or the YAML itself. Defaults to config/wicket.yaml. |
How it fits together
widget.js ──▶ Hono API ──▶ ConversationService ──▶ Agent (Claude + tools: search_docs, handoff_to_human)
▲ │ │
SSE live updates ├──▶ SQLite (conversations, messages, transcript, FTS5 docs index)
└──▶ Relay adapter (Discord) ◀── team replies
▲
/admin dashboard ┘ (team replies, notes, typing; live over SSE)
Each external tool sits behind an adapter interface in src/adapters/types.ts (KnowledgeSource, Relay), so Slack, other docs sources or trackers plug in without touching the core.
Development
npm test # unit + API tests (scripted model, fake relay)
npm run typecheck
cp .env.example .env # fill in your keys
npm run dev # open http://localhost:4318 for a test page with the widget
Comparing models
evals/cases.yaml is a set of real-world questions, each with the kind of reply it should get (an answer that links the right page, a handoff, a polite decline, and so on). npm run eval sends each one to the model with your .env settings and docs, scores the replies without an AI judge, and prints pass rate, cost, time and tool calls per model:
npm run eval -- --models claude-haiku-5-5,openai/gpt-6-luna # 2 runs per case by default
npm run eval -- --only 24,25 --reps 1
npm run eval -- --only 28,29,30,31,32,33,34,35,36,37,38,39 # just the routing cases
Each case runs as a chat with a signed-in person, through the same conversation code as the server, with a fake tracker that starts from the file's backlog of open tickets. Routing cases (kind: route) check what landed in it: a repeat report added to the right existing ticket, a new bug, idea or docs gap filed once with the right kind, or nothing logged for a how-to question. Cases with report: bug or report: feature start from the widget's shortcut, so the assistant should gather the details and log them rather than decide whether to; for a bug it offers a fix from the docs first, and logs nothing only when the person says the fix worked. A case's trail is sent as the widget's recent activity; kind: steps checks that the assistant proposes the steps from it for the person to confirm instead of asking what they did. Every case runs with the shortcuts on, so the routing cases also check that the assistant keeps logging on its own. No real ticket is ever created.
Full replies are saved to evals/results/ (not committed). A run of every case twice on two models costs well under a dollar.
No installation packages available.