Docs

Put it on a page. Ask your agent what people said.

Two parts. First, a card in your product that lets a user talk. Second, a connection so Claude, ChatGPT or Cursor can read what they said. Ten minutes for both.

Let your agent do it

Copy this. Paste it into Claude Code, Cursor, or any coding agent. It does the whole install — everything the rest of this page describes by hand.

It asks before it writes anything: which shape to use and where it goes, and whether to connect your feedback over MCP now or skip it. Skip and it asks you for the client id and your site’s hostname instead — you can connect later.

Install

One package. It has no dependencies.

Three ways to use it

Pick the one that fits. You only need one.

React

One component. Drop it on the page and you are done.

import { FeedbackCard } from '@feedback-skill/web/react'

<FeedbackCard
  clientId="fs_pk_…"
  placement="cancel-flow"
  labels={{ title: 'Why are you leaving?', start: 'Tell us' }}
  onEnd={({ turns }) => setAnswered(turns > 0)}
/>

Any framework, or none

The same card as a plain function. Give it an element.

import { mountCard } from '@feedback-skill/web/card'

const card = mountCard(document.querySelector('#feedback'), {
  clientId: 'fs_pk_…',
  placement: 'cancel-flow',
})

// When the view goes away:
card.destroy()

Just the engine

No interface at all. You draw the button, it runs the conversation.

import { createFeedback } from '@feedback-skill/web'

const feedback = createFeedback({
  clientId: 'fs_pk_…',
  placement: 'dashboard',
})

// After the person says yes. The mic will not open without it.
feedback.consent.grant()
await feedback.start()

Two things to fill in

clientId
Your project’s id, from Settings. It is not a secret. It goes in your HTML. What stops strangers using it is the list of allowed origins on your project, so add your site’s hostname there or nothing will start.
placement
A short name for where the card is, like cancel-flow. It appears the first time it is used. What the agent asks at each placement is set in the dashboard, not in your code, so you can change it without shipping.

Make it look like yours

The card uses your page’s font and text colour on its own. Give it one accent and it is done.

theme: { accent: '#e8562f', accentText: '#fffaf2' }

Reference

Everything the package exports, by entry point. The three above are the same card at three depths, so the options nest: the React component takes everything the plain card takes, and the plain card takes everything the engine takes.

React

@feedback-skill/web/react

Two components. React is a peer dependency, and is not bundled.

<FeedbackCard clientId placement theme labels onEnd onReady />

The card, as a component. Takes everything mountCard takes, plus three props for React.

Props like theme and labels are usually object literals, which are a new object on every render. The component compares them by content, so re-rendering the parent does not remount the card and end a conversation mid-sentence. Callbacks are read at call time for the same reason.

import { FeedbackCard } from '@feedback-skill/web/react'

<FeedbackCard
  clientId="fs_pk_…"
  placement="cancel-flow"
  labels={{ title: 'Why are you leaving?' }}
  onEnd={({ turns }) => setAnswered(turns > 0)}
  onReady={(card) => (cardRef.current = card)}
/>

Props

Plus every option of mountCard and createFeedback, below.

classNamestring
Applied to the wrapping element, for layout. The card itself is themed with theme, not CSS.
styleCSSProperties
Same. Layout only.
onReady(card: CardHandle) => void
Receives the mounted card, for anything it does not surface. Its session id for your logs, or stop() from your own control.

<FeedbackCardPreview turns state elapsed theme labels />

The card filled in with a conversation you supply, and inert. For a design review, Storybook, or a marketing page.

No clientId, no microphone, no credits. It is the same markup and stylesheet as the live card, so it cannot fall out of date the way a screenshot does.

import { FeedbackCardPreview } from '@feedback-skill/web/react'

<FeedbackCardPreview
  turns={[
    { speaker: 'user', text: 'I still cannot filter by team.' },
    { speaker: 'agent', text: 'What would you do once you had?' },
  ]}
  state="listening"
  theme={{ accent: '#0f766e', accentText: '#ffffff' }}
/>

Props

The options of mountPreviewCard, plus className and style.

turnsrequiredPreviewTurn[]
The conversation to show. Each turn is a speaker, user or agent, and its text.
state'listening' | 'speaking'
Which half of the exchange the card is caught in. Decides the status word and whether the orb moves. Defaults to listening.
elapsednumber
Seconds on the clock. Defaults to about how long a conversation like this would have run.
themeCardTheme
Same as the live card.
labelsCardLabels
Same as the live card.

<FeedbackBubble clientId placement label side onReady />

A floating bubble in the corner of the page. Tapping it opens the card; tapping it again collapses it.

It renders nothing in your tree — it pins its own element to the document, because a launcher inside your layout would inherit whatever overflow, transform or stacking context happened to surround it. Mount it once, high up, and leave it.

The first tap never opens a microphone. It expands the card, which is what puts the recording notice on screen; the press that starts a session is the second one, and by then the notice has been visible the whole time.

onReady hands you open() and close(), for a trigger elsewhere in your product — a menu item, a shortcut, the end of a flow.

import { FeedbackBubble } from '@feedback-skill/web/react'

<FeedbackBubble
  clientId="fs_pk_…"
  placement="cancel-flow"
  label="Got a minute?"
  onReady={(bubble) => (window.askForFeedback = bubble.open)}
/>

Props

Plus every option of mountCard, and onReady.

exceptstring[]
Paths not to appear on. Everywhere else it shows. An entry is an exact path, or ends in * to match a prefix.
labelstring
What the bubble says when it is closed. Defaults to "Got a minute?".
side'right' | 'left'
Which bottom corner it sits in. Defaults to the right.
zIndexnumber
Stacking order against your page. Defaults to 2147483000 — below the maximum, so you can still put something over it.

Any framework, or none

@feedback-skill/web/card

The same card as two plain functions. Each takes an element and returns a handle with destroy().

mountCard(element, options) → CardHandle

Draws the card into an element and wires it to a session. Handles consent, the microphone, the transcript and the button.

It renders into a shadow root, so your CSS cannot break it and its CSS cannot leak out. With no theme it is already in your font and your text colour.

import { mountCard } from '@feedback-skill/web/card'

const card = mountCard(document.querySelector('#feedback'), {
  clientId: 'fs_pk_…',
  placement: 'cancel-flow',
  theme: { accent: '#e8562f', accentText: '#fffaf2' },
  onEnd: ({ turns }) => console.log(turns, 'turns'),
})

card.feedback.sessionId // for your own logs
card.destroy() // when the view goes away

Options

Plus every option of createFeedback, below.

themeCardTheme
Colours and font. See below.
labelsCardLabels
Every string the card can say. See below.
onEnd({ durationSeconds, turns: number }) => void
Called when a conversation finishes, so you can advance your own flow. Enable a submit button, close a dialog.

theme

All optional. Each default works on a light or a dark page.

fontstring
Font stack. Inherited from the page by default.
textstring
Primary text. The page’s own text colour by default.
mutedstring
The status line and the note. Derived from text by default.
borderstring
Border and hairlines. Derived from text by default.
backgroundstring
The card’s own background. Transparent by default.
accentstring
The orb, the rule beside the person’s words, and the button’s fill. Leave it out and the card is monochrome.
accentTextstring
Text on top of accent. Set it whenever you set accent, because contrast cannot be worked out from one colour.

labels

title and note are the two worth setting. The rest exist so the card can be translated.

titlestring
The ask, and the only line most people read. Say what you want to know. Defaults to “Tell us what you think”.
notestring
The line under the title. It is the recording notice, and pressing the button is the consent, so keep it one. Defaults to “Recorded and transcribed.”
startstring
The button before anything starts. Defaults to “Give feedback”.
stopstring
The button during a conversation. Defaults to “Done”.
permissionstring
Status while the browser asks for the microphone. Defaults to “Allow mic…”.
connectingstring
Defaults to “Connecting…”.
listeningstring
Defaults to “Listening”.
speakingstring
Defaults to “Speaking”.
endingstring
Defaults to “Wrapping up…”.
donestring
Shown when the conversation is over. Defaults to “Thanks — that was sent.”
retrystring
The button after an error. Defaults to “Try again”.

Returns

feedbackReturnType<typeof createFeedback>
The session underneath, for anything the card does not surface.
destroy() => void
Removes the card and stops any conversation in progress.

mountPreviewCard(element, options) → { destroy }

The card filled in with a conversation you supply, and inert. Never opens a session.

The whole card is inert. Nothing in it takes focus or claims to be a control somebody could operate. Assistive technology gets the conversation as text.

import { mountPreviewCard } from '@feedback-skill/web/card'

const preview = mountPreviewCard(document.querySelector('#demo'), {
  turns: [
    { speaker: 'user', text: 'I still cannot filter by team.' },
    { speaker: 'agent', text: 'What would you do once you had?' },
  ],
  state: 'speaking',
})

Options

turnsrequiredPreviewTurn[]
The conversation to show. Each turn is a speaker, user or agent, and its text.
state'listening' | 'speaking'
Which half of the exchange the card is caught in. Decides the status word and whether the orb moves. Defaults to listening.
elapsednumber
Seconds on the clock. Defaults to about how long a conversation like this would have run.
themeCardTheme
Same as the live card.
labelsCardLabels
Same as the live card.

mountBubble(options) → BubbleHandle

A floating bubble in the corner of the page, with the card inside it. Takes no element — it appends its own.

The card is the same one mountCard draws, anchored to the bottom so the orb and the button sit nearest the corner just touched. The conversation reads the same either way: it grows upward from the floor of the box.

The first tap never opens a microphone. It expands the card, which is what puts the recording notice on screen; the press that starts a session is the second one.

Collapsing is the only way out. There is no dismiss — a launcher that can be removed is one you have to decide when to bring back.

import { mountBubble } from '@feedback-skill/web/card'

const bubble = mountBubble({
  clientId: 'fs_pk_…',
  placement: 'cancel-flow',
  label: 'Got a minute?',
})

bubble.open() // from a menu item, a shortcut, the end of a flow
bubble.close()
bubble.isOpen

Options

Plus every option of mountCard, above.

exceptstring[]
Paths not to appear on. Everywhere else it shows. An entry is an exact path, or ends in * to match a prefix.
labelstring
What the bubble says when it is closed. Defaults to "Got a minute?".
side'right' | 'left'
Which bottom corner it sits in. Defaults to the right.
zIndexnumber
Stacking order against your page. Defaults to 2147483000 — below the maximum, so you can still put something over it.

Just the engine

@feedback-skill/web

One function and one error class. No interface. Everything above is built on this.

createFeedback(options) → session

A voice feedback session. You draw the button, it runs the conversation.

Nothing happens until start(), and start() throws until consent.grant() has been called. That is on purpose: a browser will not open a microphone without a gesture, and we will not record anybody who has not agreed.

import { createFeedback } from '@feedback-skill/web'

const feedback = createFeedback({
  clientId: 'fs_pk_…',
  placement: 'dashboard',
})

const off = feedback.on('transcript', (turns) => render(turns))

button.onclick = async () => {
  feedback.consent.grant()
  await feedback.start()
}

Options

clientIdrequiredstring
Your project’s id from Settings. Not a secret. The origin allowlist on the project is what authorises a session.
placementstring
Where the card is, like cancel-flow. Created the first time it is used. What the agent asks there is set in the dashboard.
voiceVoice
Which of the 28 voices the agent speaks in. Defaults to eve.
apiUrlstring
Where the API lives. Defaults to https://feedbackskill.dev. You will not need it.

Returns

stateSessionState
Where the session is now. See the states below.
transcriptTurn[]
Every turn so far, live as it is spoken. Each has a speaker, text, seconds from start, and whether it is final.
durationnumber
Seconds elapsed. 0 before start.
sessionIdstring
Matches the session in your dashboard, for your own logs. Empty until connected.
consent.grant()() => void
Records that the person agreed to be recorded. Required before start().
offer()() => Promise<boolean>
Asks whether to offer feedback here, and records that you did. Fails open: if we are unreachable it returns true.
start()() => Promise<void>
Asks for the microphone and begins. Call it from a click, or iOS Safari will refuse the microphone. Throws without consent.
stop()() => Promise<void>
Ends the conversation. Sends the last turns, then fires end.
on(event, fn)() => void
Subscribes to an event, below. Returns the function that unsubscribes.

Events

state(state: SessionState) => void
The session moved to a new state.
transcript(turns: Turn[]) => void
A turn was added or revised. You get the whole transcript each time.
level(level: number) => void
How loud it is right now, 0 to 1. The microphone while listening, the agent while speaking, and 0 once it ends.
error(error: Error) => void
Something failed. A FeedbackError means the session could not start, and carries a code.
end({ durationSeconds, turns }) => void
The session is over, however it ended. The turns are the final transcript.

States

In the order a session moves through them.

idle
Nothing has started, or a previous session ended.
permission
Waiting for the browser’s microphone prompt.
connecting
Microphone granted. Opening the session.
listening
The person is talking, or may.
speaking
The agent is talking.
ending
Stopping. The last turns are being sent.
done
Over. Calling start() again begins a new session.
error
Something failed. The error event said what.

class FeedbackError extends Error

A session that could not start, for a reason the visitor must not be shown.

Its message is bland and safe to render. The specific cause is on code, for your own code to branch on, and the server’s own wording goes to the console, where the developer is already looking. That the account behind a button is out of credit is not the visitor’s business.

import { FeedbackError } from '@feedback-skill/web'

feedback.on('error', (error) => {
  if (error instanceof FeedbackError && error.code === 'no_credits') {
    hideTheCard()
  }
})

Properties

messagestring
“Feedback is unavailable right now.” Safe to show.
code'no_credits' | 'unauthorized' | 'unavailable'
Why. no_credits is the account that owns the card being out of credit, not anything the visitor did. unauthorized is usually an origin not on the allowlist.
statusnumber
The HTTP status the server answered with.

Connect your agent

Every conversation is readable from your agent over MCP. There is no key. You sign in the way you would on any site.

Any client

This works in anything that reads an MCP config. Paste it in and sign in when asked.

Claude

Desktop, web, or mobile

  1. Open Settings, then Connectors.
  2. Choose Add custom connector.
  3. Paste the address below.
  4. Sign in when Claude asks.

Claude Code

One terminal command

  1. Paste the command below in your terminal.
  2. Sign in in the browser the first time you use it.

ChatGPT

Connectors

  1. Open Settings, then Connectors.
  2. Choose Add.
  3. Paste the address below.
  4. Sign in when ChatGPT asks.

Cursor

Server config

  1. Open Settings, then MCP.
  2. Choose Add new server.
  3. Paste the config below.
  4. Sign in when the browser opens.

Then ask it something

Once it is connected, ask in your own words. “What are people asking for this week?” is a good first question.