square-activity-46232
03/09/2026, 6:21 PM{
"meet_link": "<https://meet.google.com/abc-defg-hij>"
}
4. Agent responds in voice:
"Your meeting with Carlos for tomorrow at 7pm has been created."
But the link must appear in the UI.
My Proposed Idea
I’m thinking about implementing a tool that sends an assistant message without voice output.
Something like:
send_visual_message(content: string)
The idea would be:
1. Agent creates the meeting
2. Agent speaks the normal response
3. Agent calls this tool with the link
4. The tool generates an assistant message in the conversation but with voice mode disabled
So the UI shows:
Assistant message:
https://meet.google.com/abc-defg-hij
But the voice agent does not read it.
Important Requirement
The message must still:
Be an assistant message
Be visible to the user
• Be stored in conversation context
Otherwise the agent will not remember what it sent and the conversation history becomes inconsistent.
Alternative Idea I Considered
I also thought about using topics or metadata, but that doesn’t fully solve the issue because the message still needs to:
• appear to the user
• be part of the assistant history
• remain accessible to the LLM in the next turn
Questions
1. Is this the right architectural approach, or is there a more idiomatic LiveKit pattern for this?
2. Is there a recommended way to separate voice output from assistant messages?
3. Has anyone implemented something like voice-safe tool responses vs visual messages?
Any suggestions or design ideas would be greatly appreciated.tall-airline-16756
03/09/2026, 6:21 PMtall-airline-16756
03/09/2026, 6:22 PMsquare-activity-46232
03/09/2026, 6:27 PMtall-airline-16756
03/09/2026, 6:27 PMlk.transcription) and often lack the transcription attributes tied to an audio track. Default UIs/playgrounds don’t automatically render those text-only messages, so your frontend must explicitly listen for and render them.
What to implement on the client
• Subscribe to the text stream topic:
◦ Use registerTextStreamHandler('lk.transcription', ...) or a similar API to read incoming text stream messages.
◦ If you use the React components, use useTranscriptions() to surface transcription/text messages into your UI.
• Distinguish visual-only from audio transcriptions:
◦ Check stream attributes (for example, lk.transcribed_track_id) — if empty/null, treat the message as a visual-only assistant message and skip TTS.
• Render and UX behavior:
◦ Show the message as an assistant message (clickable link, email, metadata preview).
◦ Ensure the UI does not enqueue this message for TTS playback.
◦ Optionally add metadata badges (e.g., “visual-only”) so users and your UI logic can treat it differently.
• Persist / history:
◦ If you’re also injecting the message into the chat context on the backend, make sure your frontend reads assistant chat history and displays that same message in the conversation timeline (so UI conversation and transcription stream remain consistent).
Minimal JS example (concept)
room.registerTextStreamHandler('lk.transcription', async (reader, participantInfo) => {
const text = await reader.readAll();
const isAudioTranscription = !!reader.info.attributes['lk.transcribed_track_id'];
if (isAudioTranscription) {
// show as live transcription
} else {
// show as visual-only assistant message (do NOT send to TTS)
}
});
UX notes / edge cases
• If you show both the spoken transcript and the visual-only link, present them as two assistant items (speech bubble + visual card) so the user understands what was spoken vs. what’s actionable.
• Decide a single source of truth for history: either display messages from the transcription stream or from the persisted chat history (or both). Keep them synced to avoid duplicate/ missing entries.
• If you need to hide raw URLs from speech but still allow users to copy the link, present a short spoken summary and a separate visual card with the raw link.
If you want, I can:
• Draft a short code snippet for your frontend stack (React/TS or plain JS) that registers the handler and renders visual-only cards.
• Suggest UI patterns for showing the spoken summary + a visual card in the chat timeline.
Which would help you next — a React example or a plain JS snippet?
Sources: How to build an agent with speech-to-text input and text-only output | Live... | Text and transcriptions | LiveKit Documentationsquare-activity-46232
03/09/2026, 6:30 PMtall-airline-16756
03/09/2026, 6:30 PMsquare-activity-46232
03/09/2026, 6:32 PMtall-airline-16756
03/09/2026, 6:33 PMaudio_output=false or call session.output.set_audio_enabled(False).
1. Send the assistant reply (use your normal reply method). This will be sent as text (text/transcription stream).
2. Inject the same reply into the conversation history (assistant message) so future LLM turns see it.
3. Re-enable audio output when you want normal TTS again.
Why you should also persist the message to chat context
• Publishing to the text stream makes the UI able to display it, but adding the assistant message into the persisted chat context ensures the LLM remembers it on the next turn and conversation history is consistent.
Small caveats and best practices
• Client handling: Most clients will display text-stream messages only if they subscribe to the text/transcription stream or read chat history. If some clients do not already handle that, you may need a tiny client-side change. If all your clients already subscribe to transcription/text streams or read persisted history, no client change is required.
• Mark visual-only messages: Include an attribute/metadata like is_visual_only=true when you publish the message so clients can explicitly skip passing that message into any client-side TTS pipeline (defensive measure in case a client does text->speech by default).
• Idempotency & timing: If you toggle audio mode frequently, ensure your enable/disable calls are reliable and that messages are published only after the disable has taken effect; otherwise a race could cause unintended audio.
• Persistence consistency: When you publish to the transcription stream and separately write to chat history, ensure both operations succeed (or have retry/compensation) to avoid mismatched UI vs. history.
• Structured content: For structured returns (URLs, emails, JSON), send the raw link/content to the UI but keep spoken text strictly human-readable (no spelled-out URLs). Your current system-prompt rules + TTS toggle combo enforces that cleanly.
When this pattern is enough vs when to use a special tool
• Use the toggle approach when you want simple, occasional visual-only messages — it’s easy and server-controlled.
• If you need more structured behavior (rich cards, attachments, or consistent metadata), wrap the flow in a small server-side helper (your send_visual_message) that:
◦ disables audio,
◦ publishes the visual text with metadata,
◦ injects into chat history,
◦ re-enables audio.
If you want, I can:
• Draft a short server-side snippet (Python or Node) showing the disable-send-enable flow + chat-context injection and a suggested metadata payload.
• Draft a one-line client guard that skips TTS for messages marked is_visual_only (if you might need it later).
Which snippet language do you prefer — Python or Node?
Sources: How to build an agent with speech-to-text input and text-only output | Live... | How to Set Up Speech-to-Text Input with Text-Only Output in LiveKit Agents | Text and transcriptions | LiveKit Documentation