Everything *[NYC] 2026: see what we announced. →
Skip to content

VocalStack

Adds a vocalstack.transcript field type and a "Transcribe audio" document action. Editors click Transcribe next to an audio file; the transcript arrives with progress, survives a reload, and can be stopped. It's saved on the document as editable text plus timed sentences with speakers, ready for captions, search or a click-to-seek player. Your VocalStack API key stays on your server: a small handler (a Next.js route or any Fetch-compatible host) talks to VocalStack, and the Studio only talks to the handler. Billed per second of audio by VocalStack; the plugin is free and MIT-licensed.

Install command

npm i @vocalstack/sanity

@vocalstack/sanity

VocalStack for Sanity Studio 3 and later: a Transcribe button next to every audio file. The transcript is saved on the document as text plus timed sentences.

Read more about the VocalStack transcription plugin for Sanity on the VocalStack website.

Install

npm i @vocalstack/sanity

VocalStack API keys are for servers: the API refuses a key sent from a web page. So the plugin has two halves, the Studio plugin and a small server handler that holds the key.

1. The handler. A Web-standard (Request) => Promise<Response>. In a Next.js app (most Sanity sites have one):

// app/api/vocalstack/[...path]/route.ts
import { createVocalStackHandler } from '@vocalstack/sanity/server'

const handler = createVocalStackHandler({
  apiKey: process.env.VOCALSTACK_API_KEY, // the default; a vs_key_… key with Read and Write
  sanity: {
    projectId: process.env.NEXT_PUBLIC_SANITY_PROJECT_ID!,
    dataset: process.env.NEXT_PUBLIC_SANITY_DATASET!,
    token: process.env.SANITY_API_READ_TOKEN!, // a Viewer token is enough
  },
})

export { handler as GET, handler as OPTIONS, handler as POST }

Any other host works the same way: Remix, Hono, Bun, Deno, Cloudflare Workers, a Node server with a Fetch adapter. The handler matches the end of the path, so mount it under any prefix.

2. The Studio plugin.

// sanity.config.ts
import { vocalstack } from '@vocalstack/sanity'

export default defineConfig({
  // …
  plugins: [
    vocalstack({
      handlerURL: 'https://www.example.com/api/vocalstack', // or '/api/vocalstack' for a Studio embedded in the same Next.js app
    }),
  ],
})

Then add a transcript field next to the audio:

defineType({
  name: 'episode',
  type: 'document',
  fields: [
    defineField({ name: 'title', type: 'string' }),
    defineField({ name: 'audio', type: 'file', options: { accept: 'audio/*' } }),
    defineField({ name: 'transcript', type: 'vocalstack.transcript' }),
  ],
})

What it adds

  • A vocalstack.transcript field type, to put next to a file field (at the top level of a document, or inside an object). Its input has:
    • a Transcribe button, shown when the sibling file is audio/*. It shows progress and survives a reload mid-job, because recordingId and status are saved on the document. Stop ends a job you no longer want: the field goes back to how it was (an earlier transcript stays), and VocalStack keeps what it transcribed before the stop.
    • the stored value:
      • transcript: text you can edit; paragraphs break on a speaker change or a pause.
      • sentences: [{ start, end, speaker, text }] in seconds.
      • status (idle, processing, done or failed), language, error, recordingId, transcriptionId and transcribedAt.
  • A "Transcribe audio" document action on every document type with a transcript field. It starts a job for each transcript field next to an audio file; the field follows the job from there.
  • The handler (@vocalstack/sanity/server): GET …/connection, POST …/transcriptions, GET …/recordings/:id/job and POST …/recordings/:id/stop.

Like any edit in the Studio, the transcript lands in the draft; publish to make it public. Transcripts are plain fields, so GROQ reads them like any other:

*[_type == "episode"]{ title, "text": transcript.transcript, "captions": transcript.sentences }

Options

The Studio plugin, vocalstack({ … }):

OptionDefault
handlerURLrequiredWhere the handler is mounted. The Studio calls only this URL.

A transcript field, options: { … }:

OptionDefault
audioFieldthe first sibling file field holding audio/*The name of the sibling file field to transcribe.

The handler, createVocalStackHandler({ … }):

OptionDefault
apiKeyprocess.env.VOCALSTACK_API_KEYA vs_key_… key with Read and Write.
sanityrequired{ projectId, dataset, token, apiVersion?, apiHost?, useProjectHostname? }: the Studio's dataset, and a token that can read it. apiHost and useProjectHostname work as in @sanity/client, for a custom API domain.
spokenLanguageauto-detectA Whisper language code (en, ro, …).
fileURLthe asset's URL on Sanity's CDN(asset) => string: the audio's address. Return a signed URL if your assets are private.
allowedOriginsanyThe Studio origins allowed to call the handler (CORS), e.g. ['https://example.sanity.studio'].
apiURLhttps://api.vocalstack.com

How the audio reaches VocalStack. The Studio sends the handler the file's asset id; the handler reads the asset from your dataset, checks that it is audio/*, and VocalStack fetches it from Sanity's CDN (POST /v1/transcriptions { url }). VocalStack does not fetch loopback or private addresses, so for those the handler reads the file and uploads it. The job runs on VocalStack, and the Studio polls the handler every 3 s. The account is billed per second, with a 5-minute minimum per transcription.

How the handler knows the caller

The handler only answers someone signed in to the Studio with permission to edit the dataset:

  1. The Studio makes a random secret and, with the editor's own Sanity login, creates a document vocalstack-session.<sha256 of the secret> (type vocalstack.session).
  2. Every call to the handler carries Authorization: Bearer <secret>. The handler hashes it and looks the document up with its own token; a session is good for 12 hours from the document's _updatedAt.

Only someone who can write to the dataset can create a session. The id has a dot in it, so Sanity never serves it to unauthenticated requests, even from a public dataset; project members who can read the dataset see the hash, never the secret. Expired sessions are swept by the Studio. The handler keeps no cookies, so CORS from any origin is safe; allowedOrigins narrows it further.

The VocalStack key never reaches a browser: it lives in the handler's environment only. Anyone who can edit the dataset can start, follow and stop transcriptions on that key's account.

Testing

e2e/run.sh runs a real Studio on a fake Content Lake against the handler and a real VocalStack, in Chromium; see e2e/README.md.

One-click connect

Not in this version. The handler has no storage of its own to keep a connected key in, and the Studio cannot redeem a connect code (the API refuses it from a web page), so the key goes in VOCALSTACK_API_KEY. Create one with Read and Write permissions under Developers → API keys.