Documentation

The plugin works out of the box. One line of HTML puts a microphone next to a field, and everything else on this page is optional.

Quick setup

Using Claude, Codex or any other agent? Simply copy and paste the following prompt to hand off the installation entirely.

Install AddSpeech on this site by following https://addspeech.com/skill.md

Those instructions are an ordinary page you can open and read before you paste anything. It works out what your project is built with, asks before it writes, and no part of your project is sent to us.

Saving that same page as a skill, at ~/.claude/skills/addspeech/SKILL.md, lets you call it by name on the next site instead of pasting a link again. It is the same file either way.

Installation

Everything below changes with this choice: the installation step, the examples, and the way every option is spelled. It is remembered in this browser, so the next page you open is already on the right one.

A few lines in nuxt.config.ts, so the tag is part of your app head on every page and there is no component to remember.

nuxt.config.ts

export default defineNuxtConfig({
  app: {
    head: {
      script: [
        {
          src: 'https://cdn.addspeech.com/widget.js',
          'data-key': 'as_pub_your_key_here',
          defer: true,
        },
      ],
    },
  },
});

It sits in the page source, the way a Stripe publishable key or a Google Maps key does. One key per domain, and in this install it is readable there on purpose. It only works on the domains listed in your account, so somebody who copies it onto their own site gets nothing.

If you would rather it never appeared there, your own server can hold the key instead and hand each page a short-lived token per recording. That is the session option below, and it is the install we recommend to anyone who has a server to put it on.

Nothing else is required. Left alone the microphone fills the textarea on the page - the field somebody was last typing in, when there are several - so a form with one message box needs no target at all. Add data-target, a CSS selector like the one you would hand to querySelector, when the field is an input rather than a textarea, or when only one of several textareas is yours to fill. On a page where nothing matches, the button does not appear at all - so the script tag can sit in the template that every page uses, and the microphone shows up only where there is a form.

Requirements

A short list, most of which a typical site already meets.

The page has to be served over HTTPS.

Browsers will not hand a microphone to a page that is not, and there is nothing we can do about that from our side. Localhost is exempt, so development works as normal.

The field has to exist in the page by the time the script runs, and it has to be a real input or textarea.

Something editable that is not a form control - a rich-text editor drawn out of divs - is not supported yet.

The field needs room for a few thousand characters.

People speak in far longer sentences than they type, and a minute of ordinary speech is around a thousand - so a maxlength of 255 or 500 quietly eats the end of every answer. Either drop the attribute or set it to about 3000. The widget checks the field it has been pointed at and says so in the browser console, and on your account page, when it finds one that is too small.

The column behind that field needs the same room.

This is the half that is usually missed: a form that accepts nine hundred characters in the browser, in front of a VARCHAR(255), discards the end at the last step, after the person has already spoken. Use TEXT, or a VARCHAR long enough to mean it, and check any server-side validation rule that mirrors the old length.

Any browser from the last few years.

Chrome, Edge, Firefox and Safari all record; on iOS it works in Safari and in every other browser on the phone, because they are all Safari underneath.

The visitor is asked for the microphone once, by their own browser, and the answer is remembered for your domain.

We never see that dialogue and cannot pre-empt it.

When a month's allowance is used up the button removes itself from your pages until the allowance renews.

This is intentional rather than a fault. Your forms and text areas continue to work exactly as they did before the widget was added, so a visitor sees no microphone rather than a microphone that refuses. The allowance renews on your billing date rather than on the first of the month, so a subscription that began on the twentieth runs from the twentieth. Your account page shows what has been used and the date it comes back.

React, Vue, Livewire, WordPress, a plain HTML page - it makes no difference.

The widget writes into the field the way a person typing would and fires the input and change events afterwards, so a framework watching that field sees exactly what it expects to see.

Usage

Everything the widget can be told, told as attributes on the script tag. Only the key is required; the rest have defaults chosen so that leaving one out gives you something sensible rather than something broken.

The rows below name each option the way a script tag names it. On Nuxt the same option is written like this:

'data-theme': 'spotlight',
data-keyRequired

Your key, from your account. It says which site this is, and whose allowance the recording comes out of.

data-targettextarea

A CSS selector for the field to fill. Every textarea on the page unless you say otherwise.

data-themeglow

How the widget shows that it is listening. Glow lifts a coloured light off the bottom of the window, spotlight dims the page, subtle only the button.

data-accentOurs

Your colour, in any form CSS accepts. Everything else is worked out from it, so this one attribute moves the widget onto your house style.

data-backgroundThe accent

The button's own background, which is your accent unless you say otherwise. Transparent removes it altogether.

data-foreground#fff

The colour of the mark and the word on the button. White, which is right on a coloured button and invisible on a pale one.

data-radius999

Corner rounding of the button, in pixels. Zero is a hard square; left out, corners go as round as they can - a circle without a word, a pill with one.

data-sizemedium

How big the button is: small, medium or large. A fifth smaller or larger than the default, moving the mark, the word and the space together.

data-positionright

Which corner the button sits in: top (top right), right (bottom right) or left (bottom left). Measured against the field, or the window.

data-anchorfield

Where the button sits: in the corner of the field it fills, or window to float it at the edge of the screen - better on a page with six fields.

data-showalways

Whether the button is there from the start. Always is the button you have always had, focus waits for the cursor in the field, never adds nothing.

data-devicealways

Which devices the button is offered on: always, mobile or desktop. Mobile means pointing with a finger: a tablet yes, a touchscreen laptop no.

data-appendtrue

Every press adds a new paragraph below what is already in the field. Set false where a press should replace the last answer instead.

data-growfalse

Lets a textarea get taller as it fills, so a spoken paragraph does not have to be scrolled back through. Off unless you ask.

data-labelNone

A word beside the microphone, which the button does not carry unless you ask. Auto is our own wording; anything else up to twenty characters.

data-languageYour page

Two letters: a hint at what is going to be spoken, and the language the widget speaks. Read from the lang attribute of your page.

data-fontNone

Set it to inherit and the button borrows the typeface of the page it stands on. Left out, it uses a rounded system face that sits quietly.

data-shortcuton

Ctrl+Shift+Space starts and stops the microphone from anywhere on the page. Switch it off if your page already uses that combination.

data-soundoff

A short tone when recording starts: chime, pop or beep, or your own file. Off unless you ask - but on a phone it is the confirmation that lands.

data-confettifalse

A small burst of paper when the words land in the field. Off unless you ask, and never after a recording that came back empty.

data-counterring

How the time left is shown: ring around the button, bar across the window, or fill rising inside it. Amber at ten seconds, red at five. On by default.

data-shadowfalse

A shadow under the button. Off, because how far a control floats off the paper is your site's own decision - true for a pale button on a pale card.

data-sessionNone

The address on your own site that hands out recording tokens, so the key that spends your allowance stays out of your page source.

Different settings on one field

Everything listed above can also be said on a field itself, with data-speech- in front of the name. The field is asked first and the script tag is the fallback, so an exception costs exactly one attribute and everything you leave unmentioned goes on following the page. A form with a subject line above a message box can word them differently, or lay the countdown along the edge of the long one and leave the short one with a ring.

<input
  id="subject"
  data-speech-label="Dictate"
  data-speech-counter="ring"
/>

<textarea
  id="message"
  data-speech-anchor="field"
  data-speech-counter="bar"
  data-speech-grow="true"
></textarea>

The widget dresses itself for whichever field is being written in at that moment, so this is still one microphone rather than one per box. The key is the one attribute not worth putting here: a second key on one page is a second account.

Worth setting on the field itself

Two attributes on your own field rather than on the script tag. A transcript is a paragraph nobody proof-read, so leave the browser's own spellcheck on: the red underline is the quickest way for the person who just spoke to find the one name that came through wrong, and correcting it is a double-click. Most browsers do this for a textarea already, but not all of them, and setting it explicitly makes it true everywhere. Autocapitalize keeps the sentences typed by hand afterwards, on a phone, consistent with the spoken ones.

<textarea
  id="message"
  spellcheck="true"
  autocapitalize="sentences"
  rows="4"
></textarea>

Styling

The widget lives in a shadow root, which is the only reliable way to survive being dropped into a stylesheet written by somebody else: nothing of yours reaches in, and nothing of ours leaks out onto your page. That same protection would make it impossible to restyle, so two doors are left open in it deliberately.

Custom properties

Set any of these on the widget from your own stylesheet and it beats what the script tag asked for. That is what makes a dark mode, a different colour per section of the site, or a print rule possible at all.

--as-accent
The one colour everything else is worked out from.
--as-face
The button's own background, normally the accent.
--as-ink
The mark and the word on the button, normally white.
--as-deep
The shadow under the button, normally a darker version of the accent.
--as-lift
The button under the pointer, normally a lighter version of the accent.
--as-radius
Corner rounding of the button.
--as-scale
How big the button is, as a plain multiple of its normal size - 0.8 is the small setting, 1.2 the large one. Being a property rather than only an attribute is the point: it can be set inside a media query, so the button can be one size on a desktop and another under a thumb.
--as-font
The typeface of the button and the status line.
--as-hot
The colour of a live recording, which follows your accent unless you say otherwise. That a recording is running is said by the pulsing halo, the countdown and the word that stops it, rather than by the colour - so set this to red if you want the old convention back.
addspeech-microphone {
  --as-accent: #7c3aed;
  --as-radius: 12px;
}

@media (prefers-color-scheme: dark) {
  addspeech-microphone {
    --as-accent: #a78bfa;
  }
}

Parts

For the things a custom property cannot express: a heavier button, a status line that matches your own notifications. Each name below is a promise - what an element is called inside the widget may change with any release, what you write in your stylesheet will not.

::part(button)
The button itself, icon and word included.
::part(label)
Only the word on the button.
::part(icon)
Only the microphone.
::part(status)
The line that says what is happening - listening, transcribing, or what went wrong.
::part(countdown)
The pill under the button counting out the last ten seconds in words.
::part(hint)
The small keyboard reminder shown while a recording is running.
::part(tip)
The little bubble above the button naming the keyboard shortcut, on a pointer device.
::part(backdrop)
The glow, or the dimmed page behind it, depending on the theme.
::part(bar)
The countdown line across the bottom of the window, when that is the counter you chose.
::part(badge)
The small AddSpeech link under the button, on the free plan.
addspeech-microphone::part(button) {
  box-shadow: none;
  font-weight: 600;
}

addspeech-microphone::part(status) {
  font-family: "Your Face", sans-serif;
}

The widget also carries its own state on itself, as plain attributes, so you can style a moment as well as an element.

addspeech-microphone[data-state="listening"]::part(label) {
  text-transform: uppercase;
}

Everything that moves is already switched off for anybody whose system asks for less motion. Each theme keeps the part that says the microphone is on and loses only the part that moves. This behaviour should not be overridden.

Examples

Four finished ones. Each is the whole of what has to be on the page.

Each one is drawn underneath it. Those are pictures rather than working microphones - a page that asked for four recording permissions while you were reading it would be a strange way to argue for one line of script. The working one is on the front page.

The smallest thing that works

A microphone in the bottom corner of the field it fills, in our colour, on the one textarea on the page. Nothing set, nothing chosen.

nuxt.config.ts

script: [
  {
    src: 'https://cdn.addspeech.com/widget.js',
    'data-key': 'as_pub_your_key_here',
    defer: true,
  },
],
A small round microphone sitting inside the bottom right corner of the message box.

In your colours, at the edge of the window

Floating at the edge of the screen rather than sitting in the field, in your own colour, with squarer corners and your own word beside the microphone. A button standing on its own is the one that can carry a word: anchored in a field it is already next to the thing it fills, so the word comes off it.

nuxt.config.ts

script: [
  {
    src: 'https://cdn.addspeech.com/widget.js',
    'data-key': 'as_pub_your_key_here',
    'data-anchor': 'window',
    'data-accent': '#7c3aed',
    'data-radius': '12',
    'data-label': 'Dictate',
    'data-sound': 'chime',
    defer: true,
  },
],
A purple pill in the bottom right of the window, reading Dictate beside a microphone, with the form behind it.

On a dashboard

Nothing on the page moves except the button that was pressed, the keyboard shortcut is handed back to the page, and the typeface is the one already loaded.

nuxt.config.ts

script: [
  {
    src: 'https://cdn.addspeech.com/widget.js',
    'data-key': 'as_pub_your_key_here',
    'data-theme': 'subtle',
    'data-position': 'left',
    'data-font': 'inherit',
    'data-shortcut': 'off',
    defer: true,
  },
],
Mid-recording: the microphone sits in the bottom left of the field wearing a soft halo, and the rest of the page is exactly as it was.

One spoken answer

For a page whose whole job is to collect what somebody says: the page steps back, the microphone comes to the middle, and the box grows to fit the paragraph that lands in it.

nuxt.config.ts

script: [
  {
    src: 'https://cdn.addspeech.com/widget.js',
    'data-key': 'as_pub_your_key_here',
    'data-theme': 'spotlight',
    'data-grow': 'true',
    defer: true,
  },
],
Mid-recording: the page is dimmed behind a large microphone in the middle, with the answer already given showing through.

Naming your forms

Your account page breaks the month down by form: how many answers each one collected, and how long a typical one was. That only reads well if your forms have names, and this is how they get them.

A form is named by the first of these that exists: a data-speech-name on the field or the form, the form's id, the form's name, the field's name, and failing all of that the address the form posts to. So most sites get sensible names without changing anything - but the top of that list is the one you control.

<form action="/customer-support/refund" data-speech-name="Refund Form">
  <textarea name="reason"></textarea>
</form>

Worth doing when one page holds several forms, or when the id is something a page builder generated. "Refund Form" and "Complaint" tell you something at a glance; "form-7ab" does not.

When it comes to that last resort, only the path is used, and only its last two segments. The query string is dropped, and any segment that looks like an identifier - an order number, a ULID, a hash - is replaced with :id before it is stored. One form template on eight thousand product pages stays one form, and no order number is ever written down.

Nothing here is about the person filling the form in. What is recorded is the name of the form and the hour of the day on the visitor's clock, both of which describe your website rather than your visitor. There is still no column for what anybody said.

Knowing it was spoken

If it is useful to you to know which submissions were dictated rather than typed, give the form a hidden field named _mic. The widget fills that field in; it never adds it. A form without one behaves exactly as it does today.

<form method="post" action="/contact">
  <textarea name="message"></textarea>

  <input type="hidden" name="_mic">
</form>

It is set to true at the moment the words land in the field, and at no earlier moment. Somebody who opens the microphone and then thinks better of it, or whose recording comes back empty, submits the form with the field still blank - so what reaches you is either "true" or nothing at all.

What that is worth is real but modest. It says audio was recorded in this browser, sent to us, and came back with words in it, which is a great deal more trouble than a script filling in a form goes to. That makes it worth weighing alongside whatever else you already look at.

It is not proof that a person filled the form in, and we would rather say so than sell it as one. Audio can be played into a microphone from a speaker, or synthesised outright by anybody willing to spend an afternoon on it. Treat it the way you would treat a honeypot: a cheap signal worth having, never the thing standing between your form and the world.

What happens to the recording

  • Nothing is kept. The audio is transcribed and dropped inside the same request, every time - there is no bucket it lands in first, and no setting that changes that.
  • Nothing travels in the open. A browser will not hand a microphone to a page that is not on HTTPS, and the recording reaches us over the same encrypted connection - so the audio is never readable on the wire between the person speaking and the transcript coming back.
  • A key is refused over plain HTTP, even if the address was added to the allowed list that way, and there is no setting that turns that off. The only exception is localhost, where the traffic never leaves the machine and where people build.
  • Nothing is trained on what passes through. Not our models, not anybody else's.
  • Silence is not charged for, and neither is a request that failed on our side.
  • If any of that ever changes, it will say so on this page before it does.

Refer someone

Every account has a link in it. Anybody who signs up through your link earns you fills, which you can put against whichever month you like.

  • Paid in fills, not in cash. There is no invoice to send, no threshold to clear and no payout to wait for - the reward is the product itself, and it simply turns up in your account.
  • A free signup is worth 25 fills, a Freelancer 300, and an Agency 750. Those are fixed numbers rather than a percentage, so a price change never quietly changes what a referral is worth.
  • A credit is held for a month before it can be spent, and the account that earned it has to have set up a domain and actually used the microphone at least once. That applies to free and paid signups alike; on a paid plan the subscription also has to have survived the month, since a card payment can still be reversed.
  • You decide which month they land in. Put them all against next month if you are expecting a busy one, or spread them out. Whatever a month does not use comes back to your balance when it ends, so guessing high costs you nothing.
  • The pot is spent last. A site draws on its own monthly allowance first and only reaches for your referral fills once that is gone, which means allocating them can never make a month cheaper than it already was.
  • Following your link sets no new cookie. The code is remembered in the session that already exists and is gone when the browser closes, which is a shorter memory than the industry standard and the one we can describe honestly. You see how many people came and how many signed up; you never see who.

Your link, your clicks and your conversions are on your account page, under Refer someone.

When it does not work

The widget never renders an error on your page. Where something is wrong with the line you pasted, it is reported in the browser console, prefixed with our name; otherwise nothing is written. A live website is not the place for our error messages.

A microphone that does not appear at all is almost always one of three things: the page is not on HTTPS, the selector matches nothing, or the key is not allowed on this domain. The console will say which.

Anything else, write to [email protected]. Anything that looks like a security problem, please write there first and give us the chance to fix it before it is public.