You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Add voice input to a web app using ElevenLabs speech-to-text. Use when: adding microphone recording, speech-to-text transcription, voice chat, or ElevenLabs SDK integration to a web project. Triggers: elevenlabs, voice input, speech to text, microphone, voice recording, scribe, voice chat, STT.
ElevenLabs Voice Integration
Add voice input to a web app: record audio in the browser, transcribe via ElevenLabs Scribe, and feed the text into your app.
Key Architecture
Browser Server
| |
|-- MediaRecorder (audio/webm) ----> |
| |-- ElevenLabsClient.speechToText.convert()
|<--- { text, words } --------------|
| |
|-- inject text into chat/form |
The ElevenLabs API key stays server-side. The browser records audio, sends the blob to a server route, and gets back transcribed text.
Workflow
Step 1: Install the SDK
npm install @elevenlabs/elevenlabs-js
Add your API key to the project's config (never in client code):
{
"elevenlabs": {
"api_key": "your-key"
}
}
Step 2: Create a Server-Side STT Route
The @elevenlabs/elevenlabs-js SDK provides speechToText.convert() which accepts a File object directly from FormData.
SvelteKit example (src/routes/api/speech-to-text/+server.ts):
asyncfunctionprocessRecording(){if(audioChunks.length===0)return;constaudioBlob=newBlob(audioChunks,{type: 'audio/webm'});constformData=newFormData();formData.append('audio',audioBlob,'recording.webm');constresponse=awaitfetch('/api/speech-to-text',{method: 'POST',body: formData,// No Content-Type header — browser sets multipart boundary});constdata=awaitresponse.json();if(data.error){// Handle error (show to user)return;}if(data.text){// Inject transcribed text — either into an input field or directly as a chat messageinputMessage=data.text;awaitsendMessage();// auto-send is a good UX for voice chat}}
Important: Do NOT set Content-Type on the fetch request. The browser must set it automatically with the multipart boundary string.
Step 5: Voice UI Patterns
Recording button — toggle between start/stop states:
Build SvelteKit web apps that talk to Snowflake APIs (Cortex Agent, SQL API). Use when: creating a SvelteKit frontend for Snowflake data, proxying Cortex Agent SSE streams through server routes, calling Snowflake SQL API from TypeScript, rendering streamed markdown/mermaid in Svelte. Triggers: sveltekit snowflake, svelte cortex agent, sveltekit api route, SSE proxy, snowflake frontend, svelte app.
SvelteKit + Snowflake App
Build a SvelteKit app that queries Snowflake data and streams Cortex Agent responses to the browser.
Key Architecture
Browser (Svelte 5)
|
|-- fetch('/api/chat', POST) -----> +server.ts (proxy) ----> Cortex Agent :run (SSE)
|-- fetch('/api/data', GET) -----> +server.ts -----------> Snowflake SQL API
|
| SSE stream forwarded back through the proxy
| Client parses SSE with fetch + getReader()
Secrets stay server-side in config.json (read via readFileSync in +server.ts routes). Never expose tokens to the browser bundle.
Each row is a positional array matching rowType column order
// In a +server.ts GET handler:constresponse=awaitfetch(`https://${account}.snowflakecomputing.com/api/v2/statements`,{method: "POST",headers: {"Content-Type": "application/json",Accept: "application/json",Authorization: `Bearer ${token}`,},body: JSON.stringify({ warehouse, database, schema,timeout: 60,statement: sql}),});constresult=awaitresponse.json();// result.data is an array of arrays (positional columns)
Step 4: Client-Side SSE Parsing
The browser cannot use EventSource for POST requests with custom headers. Instead, parse SSE manually from fetch.
Critical: Use proper SSE event-boundary parsing. The SSE spec says events are delimited by empty lines, and a single event can have multiple data: lines that get concatenated. The final response event from Cortex Agent is a large JSON payload that may span multiple data: lines. A naive line-by-line parser that tries to JSON.parse() each data: line independently will fail on multi-line payloads.
Critical: The response content array has duplicated items. The agent's final response event contains a content array where text blocks and chart blocks may appear multiple times (the agent includes them during generation and again in the final assembly). You must:
Take the last non-empty text item (not iterate sequentially — the last items are often just "\n\n" spacers that would blank the answer)
Deduplicate charts and tables by tool_use_id using a Set
constresponse=awaitfetch('/api/chat',{method: 'POST',headers: {'Content-Type': 'application/json'},body: JSON.stringify({ message, history }),});constreader=response.body!.getReader();constdecoder=newTextDecoder();letbuffer='';letcurrentEvent='';letcollectedText='';letdataLines: string[]=[];constseenChartIds=newSet<string>();constseenTableIds=newSet<string>();while(true){const{ done, value }=awaitreader.read();if(done)break;buffer+=decoder.decode(value,{stream: true});constlines=buffer.split('\n');buffer=lines.pop()||'';// keep incomplete linefor(constlineoflines){if(line.startsWith('event:')){currentEvent=line.slice(6).trim();dataLines=[];}elseif(line.startsWith('data:')){dataLines.push(line.slice(5));}elseif(line.trim()===''&&dataLines.length>0){// Empty line = end of SSE event — process accumulated data linesconstdataStr=dataLines.join('\n').trim();dataLines=[];if(dataStr==='[DONE]')continue;try{constdata=JSON.parse(dataStr);// Status events — show progress to userif(data.status==='planning'){/* show "Planning..." */}if(data.status==='executing_tool'){/* show tool type via data.tool_type */}if(data.status==='proceeding_to_answer'){/* show "Generating..." */}// Text deltas — append to streaming contentif(currentEvent==='response.text.delta'&&data.text){collectedText+=data.text;}// Chart events — deduplicate by tool_use_idif(currentEvent==='response.chart'&&data.chart_spec){constkey=data.tool_use_id||data.chart_spec;if(!seenChartIds.has(key)){seenChartIds.add(key);charts.push({tool_use_id: data.tool_use_id,chart_spec: data.chart_spec});}}// Table events — deduplicate by tool_use_idif(currentEvent==='response.table'&&data.result_set){constkey=data.tool_use_id||JSON.stringify(data.result_set.data?.[0]);if(!seenTableIds.has(key)){seenTableIds.add(key);tables.push({title: data.title,tool_use_id: data.tool_use_id,result_set: data.result_set});}}// Final assembled responseif(currentEvent==='response'&&data.content){// Use the LAST non-empty text item (agent duplicates the answer;// later items may be "\n\n" spacers that would blank the content)letlastText='';for(constitemofdata.content){if(item.type==='text'&&item.text?.trim()){lastText=item.text;}if(item.type==='chart'&&item.chart?.chart_spec){constkey=item.chart.tool_use_id||item.chart.chart_spec;if(!seenChartIds.has(key)){seenChartIds.add(key);charts.push({tool_use_id: item.chart.tool_use_id,chart_spec: item.chart.chart_spec});}}// Skip: thinking, tool_use, tool_result, suggested_queries (see note below)}if(lastText)collectedText=lastText;}}catch{/* ignore partial JSON */}}}}
content: [{ type, ... }] (see content types below)
Final response content array item types:
The content array in the final response event contains a mix of item types. Not all are useful for display:
type
Fields
Action
text
text (string)
Display — use last non-empty item
chart
chart: { tool_use_id, chart_spec }
Display — deduplicate by tool_use_id
thinking
thinking: { text }
Skip — internal agent reasoning
tool_use
tool_use: { name, input, tool_use_id }
Skip — agent's internal tool calls
tool_result
tool_result: { content, status, tool_use_id }
Skip — results from internal tools
suggested_queries
suggested_queries: [{ query }]
Optional — display as follow-up suggestion chips
Important: The agent frequently duplicates text and chart items in the content array (once during generation, once in the final assembly). Always deduplicate charts by tool_use_id and use only the last substantial text block.
Chart event details:
chart_spec is a stringified Vega-Lite v5 JSON — you must JSON.parse() it before rendering
Chart specs include inline data in data.values — no external data fetch needed
Charts also appear in the final response content array as { type: "chart", chart: { tool_use_id, chart_spec } }
Not every query returns a chart — the agent decides based on the question
Table event details:
result_set matches the Snowflake SQL API ResultSet schema
resultSetMetaData.rowType is an array of { name, type, length, precision, scale, nullable }
data is a 2D array of strings (all values are string-typed, even numbers)
Step 5: Render Markdown and Mermaid
Install dependencies:
npm install marked mermaid
In your Svelte component:
import{Marked}from'marked';importmermaidfrom'mermaid';import{tick}from'svelte';mermaid.initialize({startOnLoad: false,theme: 'neutral'});constmarked=newMarked();// After adding a new assistant message:consthtml=awaitmarked.parse(responseText);messages=[...messages,{role: 'assistant',content: responseText, html }];// Then render mermaid blocks:awaittick();constblocks=document.querySelectorAll('.mermaid:not([data-processed])');for(constblockofblocks){constid=`mermaid-${Math.random().toString(36).substr(2,9)}`;const{ svg }=awaitmermaid.render(id,block.textContent||'');block.innerHTML=svg;block.setAttribute('data-processed','true');}
Render with {@html msg.html} in the template. Style injected HTML content (from markdown) carefully:
In Svelte <style> blocks (component-scoped): use :global() selectors to target child elements injected via {@html}, e.g. .message-content :global(p) { ... }
In plain CSS files like app.css (already global scope): do NOT use :global() — it's Svelte-specific syntax and causes lightningcss warnings during build. Just write normal selectors: .message-content p { ... }
Step 6: Render Charts and Tables
Install vega-embed (includes vega and vega-lite):
npm install vega-embed vega vega-lite
Chart rendering — parse the chart_spec string and render with vega-embed:
importtype{defaultasVegaEmbed}from'vega-embed';letvegaEmbedModule: typeofimport('vega-embed')|null=null;onMount(async()=>{vegaEmbedModule=awaitimport('vega-embed');});asyncfunctionrenderCharts(){if(!vegaEmbedModule)return;awaittick();constcontainers=document.querySelectorAll('.vega-chart:not([data-rendered])');for(constelofcontainers){constspecStr=el.getAttribute('data-spec');if(!specStr)continue;try{constspec=JSON.parse(specStr);awaitvegaEmbedModule.default(elasHTMLElement,spec,{actions: false,// hide export/source buttonsrenderer: 'svg',// crisp rendering, works well inline});el.setAttribute('data-rendered','true');}catch{el.textContent='Failed to render chart';}}}
Call renderCharts() after the SSE stream completes and after any chart event during streaming.
In the template — render chart and table blocks after the markdown content:
Load vega-embed lazily via dynamic import() — it's a large library (~300KB) and must only load client-side
Use data-spec attribute + DOM query pattern (not direct binding) because vega-embed mutates the container element
Call renderCharts() after tick() to ensure DOM is updated before vega-embed targets the elements
The Snowflake theme skill has Vega-Lite config overrides for brand colors — apply them to spec.config before rendering
Svelte 5 Notes
Use $props() for component props: let { children } = $props();
Use $state() for reactive variables (replaces let x = ... reactivity from Svelte 4)
Layout uses {@render children()} instead of <slot />
Critical: $state proxy gotcha with object mutation
In Svelte 5, $state arrays use deep proxies for reactivity tracking. When you create a plain object and push it into a $state array, the array stores a proxy-wrapped copy. Your original local variable still points to the raw, unproxied object. Mutating the local variable bypasses the proxy, so Svelte never detects the changes and the UI won't update.
Wrong — local variable bypasses the proxy:
letmessages=$state<Message[]>([]);constmsg={role: 'assistant',content: '',status: 'Loading...'};messages=[...messages,msg];// BUG: `msg` is the raw object, not the proxy in the array.// This mutation is invisible to Svelte's reactivity:msg.status='Done';// UI does NOT updatemsg.content='Hello';// UI does NOT update
Correct — grab the reference back from the proxied array:
letmessages=$state<Message[]>([]);messages=[...messages,{role: 'assistant',content: '',status: 'Loading...'}];constmsg=messages[messages.length-1];// This is the PROXY// Mutations now go through the proxy and trigger reactivity:msg.status='Done';// UI updatesmsg.content='Hello';// UI updates
This is especially important for streaming UIs where you incrementally mutate an object (e.g., appending SSE tokens to content, updating status during progress events).
Troubleshooting
SSE stream cuts off or hangs:
Ensure the SvelteKit server route returns Content-Type: text/event-stream
Do not attempt to parse/transform the stream server-side — forward raw bytes
Cortex Agent returns 401/403:
PAT may have expired. Regenerate via Snowsight or snow CLI.
Verify the account identifier format (no .snowflakecomputing.com suffix in config)
SQL API returns column data as positional arrays:
This is expected. Map using result.resultSetMetaData.rowType for column names, or use positional indexing: row[0], row[1], etc.
Stopping Points
After Step 1: Confirm project scaffolded and config working
After Step 2: Test chat route returns SSE stream before building UI
Output
A SvelteKit app with server-side API routes proxying Snowflake APIs, and a client-side chat UI with streaming responses.