Transcription with Voxtral, summaries with Amazon Nova, powered by one Bedrock API key. Bedrock exposes three different API surfaces, none of which speak Riffado's dialect, so this is the story of two small sidecar proxies and every wrong door I opened first.
Background
Lately, my TikTok feed kept nudging me towards an AI note-taker device. I was amazed by what it could do, but there was one big catch: the recording and transcription hours are locked behind a subscription, and the asking price made my eyebrows leave my face. I had almost given up on the idea until I came across Riffado, a free self-hosted companion app that happily talks to any OpenAI-compatible transcription provider you point it at.
And then it hit me: my credits, my workloads, and frankly my heart all live with AWS, so why not let Amazon Bedrock be that provider? Two models later, the answer is a firm yes: Amazon Nova 2 Lite for the summaries, key points, and titles, and Mistral's Voxtral Mini 3B for the transcription, all running on a single Bedrock API key.
Bedrock even advertises an OpenAI-compatible endpoint, so this should have been a paste-the-URL job. It was not. What follows is the honest version: a weekend of API archaeology, two tiny shims, and several confidently opened wrong doors, written down because every one of those failure modes fails quietly.
The models

The Bedrock model catalogue filtered to speech input: six matches, of which only the two Voxtral models accept audio files.

Amazon Nova 2 Lite's model page: model ID amazon.nova-2-lite-v1:0, up to a million tokens of context, and inputs of text, image, and video only. No audio; that detail matters later.

Voxtral Mini 3B 2507: audio and text in, text out, a 32K-token context window, and model ID mistral.voxtral-mini-3b-2507, the exact string the shim needs.
Let me introduce the cast first. Nova 2 Lite is Amazon's fast multimodal reasoning model, happy with classic chat parameters and priced for everyday work; it handles the summaries, key points, and titles. Voxtral Mini 3B 2507 is Mistral's compact speech-understanding model with near Whisper-level accuracy on clean audio; it handles the transcription. Here they are in the Bedrock catalogue:
Generate a long-lived API key
One more thing before Riffado enters the picture: authentication. From the Amazon Bedrock console, generate a long-term API key (the ABSK sort). Pick an expiry that suits your paranoia, anywhere from one day to never. This single key is the only credential everything in this section will use:

Generating a long-term API key in the Bedrock console. Expiry runs from one day to never; pick your level of paranoia.
Wiring it into Riffado
Riffado never needs to know about any of the drama coming up in the next section. Each shim registers as a plain Custom provider under Settings > Providers > Add Provider: voxtral-shim as the transcription provider, and bedrock-shim (nova-2-lite) as the enhancement provider for summaries and titles. As far as Riffado can tell, these are just two ordinary OpenAI endpoints; ignore any extra providers in my list, this recorder has seen some experiments:

The voxtral-shim provider in Riffado: a plain Custom provider with base URL http://voxtral-shim/v1, model voxtral-mini, and Use for transcription ticked.
Let's test the setup
Time for the moment of truth. On the Plaud, just tap to start recording; the LED bars light up to show it is listening:

The Plaud Note mid-recording. The LED bars mean it is listening.
Sync the recording over, or skip the device entirely and upload an audio file with the Upload Audio button. Hit Transcribe and let the spinner do its thing:

The Recordings page mid-transcription; the Upload Audio button is the manual route.
And here is the payoff: a full transcript with word count and detected language, plus a proper summary and key points, served entirely from Bedrock:

The payoff: a 3:32 recording transcribed to 209 words with the language auto-detected, plus a summary and key points generated by Nova 2 Lite.
The debugging trail
So that is the finished product. Now the fun part: how we got here. Bedrock exposes three different API surfaces, none of which speak Riffado's dialect, so what follows is the story of two small sidecar proxies and every wrong door I opened first.
TL;DR
Amazon's Nova models only answer on Bedrock's native InvokeModel API (with inference-profile IDs, in their own JSON dialect), and the only Bedrock models that can transcribe audio files, Mistral's Voxtral, accept audio exclusively inside chat messages. Two dependency-free Node sidecars translate: bedrock-shim maps OpenAI chat/completions ↔ Nova's native schema for summaries, and voxtral-shim turns Riffado's multipart /audio/transcriptions upload into a Voxtral chat call (ffmpeg-transcoding Plaud's .m4a on the way) and answers in Whisper's verbose_json shape. No Riffado code changes.
The contract to satisfy
Riffado is a self-hosted transcription app for Plaud Note voice recorders. It integrates with any OpenAI-compatible provider: a base URL, an API key, a model name. It then sends two kinds of requests: POST {base_url}/audio/transcriptions (multipart file upload) for speech-to-text, and POST {base_url}/chat/completions for summaries, key points, and titles, always authenticated with Authorization: Bearer .
So the mission: make Bedrock answer both of those requests, with a single long-lived API key, and with Riffado none the wiser. Spoiler: neither connection was a paste-the-URL job.
First, the model that can't do the job
The obvious-sounding transcription candidate was Nova 2 Sonic, Amazon's flagship speech model. But Sonic is a real-time speech-to-speech conversation model: it powers live voice assistants over a persistent bidirectional HTTP/2 stream (InvokeModelWithBidirectionalStream), with audio events flowing in both directions. There is no file-in → text-out API at all.
No proxy can bridge a multipart file upload to a live conversation protocol; this isn't a format mismatch, it's a different product category. First lesson of this project: check a model's API shape before its benchmarks.
One key, three doors
A Bedrock API key authenticates as a Bearer token on three distinct surfaces, each with a different catalog and a different level of OpenAI compatibility. Mapping them empirically saved hours later:
| Surface | Endpoint | What I found |
|---|---|---|
| Mantle | bedrock-mantle.{region}.api.aws/v1 | Genuinely OpenAI-compatible. 54 third-party chat models (Claude, GPT-5.x, Qwen, Mistral, DeepSeek…), but zero Amazon models, and no /audio/transcriptions endpoint (404). |
| bedrock-runtime, OpenAI route | bedrock-runtime.{region}.amazonaws.com/openai/v1 | Recognizes Nova model IDs but rejects OpenAI-shaped bodies one key at a time, starting with content: expected JSONArray, then extraneous key [type], then extraneous key [model]. It validates against the native schema. A dead end for Nova. |
| bedrock-runtime, native | …/model/{id}/invoke | Works for Nova, with two catches, below. |
Error messages that lead you on
The OpenAI route's progressive validation errors feel like you're one tweak away from success. You aren't; each "fix" surfaces the next native-schema violation until it rejects the model field itself, which no OpenAI-style body can omit. Recognize the pattern early and switch to the native API.
Part 1 · Summaries: Nova 2 Lite, natively
Catch 1: inference profiles
Invoking amazon.nova-2-lite-v1:0 directly returns:
POST /model/amazon.nova-2-lite-v1:0/invoke
{"message":"Invocation of model ID amazon.nova-2-lite-v1:0 with
on-demand throughput isn't supported. Retry your request with the
ID or ARN of an inference profile that contains this model."}
Nova wants a cross-region inference-profile ID, which is just the same model ID with a routing prefix: us.amazon.nova-2-lite-v1:0 (or global., eu., apac.). With the prefix: instant 200. Availability varies by account; in mine, Nova 2 Lite worked, Nova Premier was refused as "legacy," and Nova 2 Pro / Omni didn't resolve at all. Test what your account can actually reach before designing around a model.
Catch 2: a different JSON dialect
The native API doesn't speak OpenAI. System prompts live in a top-level system field, message content is an array of {"text": …} parts, generation parameters sit in inferenceConfig, and the response nests under output.message. The whole translation is two small functions:
bedrock-shim.js: request/response translation (core)
const UPSTREAM_HOST = 'bedrock-runtime.us-east-1.amazonaws.com';
const DEFAULT_PROFILE = 'us.amazon.nova-2-lite-v1:0';
// OpenAI chat/completions request → Nova native invoke body
function toNova(openai) {
const system = [], messages = [];
for (const m of openai.messages || []) {
const text = typeof m.content === 'string' ? m.content
: (m.content || []).map(p => p.text || '').join('\n');
if (m.role === 'system') system.push({ text });
else messages.push({ role: m.role === 'assistant' ? 'assistant' : 'user',
content: [{ text }] });
}
const body = { messages, inferenceConfig: {} };
if (system.length) body.system = system;
body.inferenceConfig.maxTokens =
Math.max(Number(openai.max_completion_tokens ?? openai.max_tokens) || 0, 1024);
if (openai.temperature !== undefined)
body.inferenceConfig.temperature = openai.temperature;
return body;
}
// Nova native response → OpenAI chat.completion
function toOpenAI(nova, model) {
const text = nova.output?.message?.content?.map(p => p.text || '').join('') ?? '';
return { object: 'chat.completion', model,
choices: [{ index: 0,
message: { role: 'assistant', content: text },
finish_reason: nova.stopReason === 'max_tokens' ? 'length' : 'stop' }],
usage: { prompt_tokens: nova.usage?.inputTokens || 0,
completion_tokens: nova.usage?.outputTokens || 0 } };
}
Wrap those in a ~40-line http.createServer that forwards the caller's Bearer header to /model/{profile}/invoke (URL-encode the profile ID; it contains a colon), and Nova looks exactly like OpenAI to any client on the Compose network:
docker-compose.yml (added service)
bedrock-shim:
image: node:20-alpine
restart: unless-stopped
working_dir: /app
command: ["node", "/app/bedrock-shim.js"]
volumes:
- ./bedrock-shim.js:/app/bedrock-shim.js:ro
Riffado's provider settings: base URL http://bedrock-shim/v1, model nova-2-lite, API key = the Bedrock key. Set as default enhancement provider.
Verified
Riffado's exact title payload (temperature: 0.7, max_tokens: 50) → 200, "Q3 Budget Strategy Session". Full meeting summary → structured key points and action items. Unlike reasoning models, Nova 2 Lite accepts the classic parameters as-is; the shim's token floor is just belt-and-braces.
Part 2 · Transcription: Voxtral, the only model that transcribes files
With Sonic ruled out, I tested every remaining candidate reachable with the key:
So on Bedrock it's Voxtral or nothing. Voxtral is Mistral's dedicated speech-understanding family with Whisper-class transcription accuracy. The friction: Mantle exposes no /audio/transcriptions endpoint, so the audio must travel inside a chat message as base64, while Riffado only speaks multipart file upload. Bridging that takes the most involved of the shims, though it's still a single dependency-free Node file.
What the bridge does
-
Parse the multipart upload: file bytes plus the
model,response_format, andlanguagefields. About thirty lines of boundary-splitting on a Buffer; no libraries. -
Transcode to mp3 when needed. Plaud recorders produce
.m4a; rather than gamble on codec support, anything that isn't already wav/mp3 goes through ffmpeg (-ac 1 -ar 16000 -b:a 48k). Mono 16 kHz mp3 also keeps the base64 payload small; an hour of speech stays in the tens of megabytes.ffprobemeasures duration for step 4. -
Call Mantle chat completions with two content parts, the audio and a strict instruction: "Transcribe this audio verbatim. Output ONLY the transcription text - no preamble, no commentary, no quotes." at
temperature: 0. -
Answer in Whisper's
verbose_jsonshape. The subtle one: Riffado picks its expected response format from the model name; anything that isn'tgpt-4o*gets asked forverbose_json, so a bare{"text": …}would break parsing. The shim fabricates the full structure:task,language,duration(from ffprobe),text, and a single segment spanning the whole file.
voxtral-shim.js: the upstream call and the response shape (core)
const payload = JSON.stringify({
model: /mini/i.test(fields.model) ? 'mistral.voxtral-mini-3b-2507'
: 'mistral.voxtral-small-24b-2507',
messages: [{ role: 'user', content: [
{ type: 'input_audio', input_audio: { data: mp3.toString('base64'), format: 'mp3' } },
{ type: 'text', text: 'Transcribe this audio verbatim. Output ONLY the ' +
'transcription text - no preamble, no commentary, no quotes.' },
]}],
max_tokens: 16000,
temperature: 0,
});
// …POST to bedrock-mantle /v1/chat/completions, then:
const body = fields.response_format === 'verbose_json'
? { task: 'transcribe', language: fields.language || 'en', duration,
text, segments: [{ id: 0, start: 0, end: duration, text, /* … */ }] }
: { text };
docker-compose.yml (added service)
voxtral-shim:
image: node:20-alpine
restart: unless-stopped
working_dir: /app
# ffmpeg needed to transcode Plaud .m4a to mp3 for Voxtral
command: ["sh", "-c", "apk add --no-cache ffmpeg >/dev/null && node /app/voxtral-shim.js"]
volumes:
- ./voxtral-shim.js:/app/voxtral-shim.js:ro
Riffado's provider settings: base URL http://voxtral-shim/v1, model voxtral-mini, same Bedrock key. Set as default transcription provider.
Mini or Small?
Small (24B) has the better word-error rate, especially on noisy audio and heavy accents. Mini (3B) is roughly 8× cheaper and faster, and for clean, close-mic voice-recorder audio it's the pragmatic default. One entertaining data point: fed a one-second sine-wave beep (no speech at all), Mini hallucinated a single "Yeah." while Small confidently invented an entire film-festival interview. Smaller model, smaller imagination; in this case, a feature. The shim routes on the model name, so switching is a one-word settings change.
The final shape
┌─────────────┐ transcription ┌─────────────────────────┐ ┌──────────────────┐
│ │─────────────────>│ voxtral-shim (Node) │──────>│ voxtral-mini │
│ │ │ multipart → chat call, │ │ @ Bedrock Mantle │
│ riffado-app │ │ ffmpeg m4a → mp3 │ └──────────────────┘
│ │ └─────────────────────────┘
│ │ summaries & ┌─────────────────────────┐ ┌──────────────────┐
│ │ titles │ bedrock-shim (Node) │──────>│ nova-2-lite │
│ │─────────────────>│ OpenAI ↔ InvokeModel, │ │ @ Bedrock native │
└─────────────┘ │ inference profile IDs │ └──────────────────┘
└─────────────────────────┘
Reading it left to right: every transcription request is a multipart file upload that voxtral-shim unpacks, transcodes with ffmpeg when needed, wraps into a Voxtral chat message on Bedrock Mantle, and answers in Whisper's verbose_json shape. Summaries flow through bedrock-shim, which translates between OpenAI's chat schema and Nova's native InvokeModel dialect, inference profile ID and all. One Bearer key rides along untouched on both paths.
Both shims live on the Compose network only; no published ports, nothing exposed publicly. The Bearer key passes through untouched, and Riffado is unmodified.
Wrapping up
What started as a mission to dodge a subscription turned into one of the more entertaining API expeditions I have run in a while, and ended as a fully self-hosted pipeline: Plaud for recording, Riffado as the interface, and AWS Bedrock serving Voxtral and Nova 2 Lite behind two tiny shims. The total extra infrastructure came to two containers and a couple of hundred lines of dependency-free code, and not a single monthly subscription in sight.
If you try this setup and run into a failure mode I have not covered here, do share it in the comments. Half the fun is in the debugging.
Top comments (0)