Build a Meeting Audio Action-Item Extractor with Thinking Machines Inkling
On July 15, 2026, Thinking Machines released Inkling, its first open-weights model: a 975B-parameter Mixture-of-Experts transformer with 41B active parameters, a 1M-token context window, and native audio input, under Apache 2.0.

Introduction
On July 15, 2026, Thinking Machines released Inkling, its first open-weights model: a 975B-parameter Mixture-of-Experts transformer with 41B active parameters, a 1M-token context window, and native audio input, under Apache 2.0. By the end of this guide you have a running service that takes a raw meeting recording and returns validated JSON action items, each with an owner, a deadline, and the verbatim quote that contains the commitment. No transcription step. The audio goes straight into the model.
What native audio input replaces
The standard pipeline for meeting intelligence has two stages: a speech-to-text model (Whisper, Deepgram) produces a transcript, then an LLM extracts structure from the text. Every error in stage one becomes invisible in stage two. If the transcript renders "ship by Friday" as "shape by Friday," the extractor never knows a deadline existed.
Inkling accepts audio as a first-class input, encoded internally as dMel spectrograms. The model that extracts your action items is the same model that heard the audio. Tone, emphasis, and crosstalk survive because nothing flattened them into text first. Thinking Machines reports 91.4% on VoiceBench and 77.2% on MMAU for exactly this class of task: spoken instruction following and reasoning over recordings.
Why a 975B model bills like a 41B model
Inkling is a Mixture-of-Experts model. Of its 975B total parameters, only 41B activate for any given token. You get the knowledge capacity of a trillion-class model at roughly the serving cost of a 41B dense one. That is the entire economic argument for MoE, and it is why hosted Inkling endpoints price competitively despite the headline parameter count. The weights are on Hugging Face as thinking machines/inkling (including an NVFP4 checkpoint for NVIDIA Blackwell), and hosted APIs are live on Fireworks, Together AI, Modal, Databricks, and Base ten. Self-hosting a 975B MoE is a multi-node project; for this build, use a hosted endpoint.
Send your first recording to Inkling
Fireworks exposes Inkling through an OpenAI-compatible API, so the only dependency is the openai client.
pip install openai pydantic fastapi uvicorn pydub
export FIREWORKS_API_KEY="your-key-here"Audio travels as a base64 content part alongside your text prompt:
import base64
import os
from openai import OpenAI
client = OpenAI(
base_url="https://api.fireworks.ai/inference/v1",
api_key=os.environ["FIREWORKS_API_KEY"],
)
MODEL = "accounts/fireworks/models/inkling" # check your provider's catalog for the exact id
with open("standup_monday.mp3", "rb") as f:
audio_b64 = base64.b64encode(f.read()).decode()
response = client.chat.completions.create(
model=MODEL,
messages=[{
"role": "user",
"content": [
{"type": "text", "text": "List every action item that was agreed in this meeting, with who owns i
{"type": "input_audio", "input_audio": {"data": audio_b64, "format": "mp3"}},
],
}],
)
print(response.choices[0].message.content)Run it against any recording with a decision in it. You get prose back. Prose is where structured data goes to die, so the next step is a schema.
Force the output into a Pydantic schema
Define what an action item is, then make the model fill that shape and nothing else:
import json
from pydantic import BaseModel, Field, ValidationError
class ActionItem(BaseModel):
task: str = Field(description="What needs to be done, one sentence")
owner: str = Field(description="Person responsible, exactly as named in the audio")
deadline: str | None = Field(default=None, description="Deadline as stated, null if none was said")
quote: str = Field(description="Short verbatim quote containing the commitment")
class ActionItems(BaseModel):
items: list[ActionItem]
EXTRACT_PROMPT = f"""Listen to this meeting recording. Extract every action item that a specific person commi
Return ONLY valid JSON matching this schema, no markdown fences, no commentary:
{json.dumps(ActionItems.model_json_schema(), indent=2)}
Rules: an action item needs a named owner. General discussion is not an action item.
If no deadline was spoken, use null. Never invent one."""
def extract_items(audio_b64: str, fmt: str) -> ActionItems:
for attempt in range(2):
response = client.chat.completions.create(
model=MODEL,
temperature=0,
messages=[{
"role": "user",
"content": [
{"type": "text", "text": EXTRACT_PROMPT},
{"type": "input_audio", "input_audio": {"data": audio_b64, "format": fmt}},
],
}],
)
raw = response.choices[0].message.content.strip()
raw = raw.removeprefix("```json").removesuffix("```").strip()
try:
return ActionItems.model_validate_json(raw)
except ValidationError:
if attempt == 1:
raise
raise RuntimeError("unreachable")temperature=0 makes retries meaningful, and the one-retry loop catches the occasional malformed response. The quote field is the audit trail: every extracted task points back at the sentence that produced it, so a human reviewer can check the model's work in seconds.
Normalize owners against your team roster
Inkling returns owners exactly as spoken, which means "Sara," "Sarah," and "Sarah from platform" arrive as three people. Name resolution is deterministic work, so keep it outside the model:
from difflib import get_close_matches
ROSTER = ["Sarah Chen", "Tom Okafor", "Danielle Ruiz", "Marcus Webb"]
FIRST_NAMES = {name.split()[0].lower(): name for name in ROSTER}
def resolve_owner(spoken: str) -> str:
first = spoken.strip().split()[0].lower()
if first in FIRST_NAMES:
return FIRST_NAMES[first]
match = get_close_matches(first, FIRST_NAMES.keys(), n=1, cutoff=0.75)
return FIRST_NAMES[match[0]] if match else f"UNRESOLVED: {spoken}"Unresolved names stay visibly unresolved instead of silently guessing. A wrong owner assignment costs more trust than an honest UNRESOLVED tag.
Wrap it in a FastAPI endpoint
from fastapi import FastAPI, UploadFile, HTTPException
app = FastAPI()
ALLOWED = {"mp3", "wav", "m4a"}
@app.post("/extract")
async def extract(file: UploadFile):
fmt = (file.filename or "").rsplit(".", 1)[-1].lower()
if fmt not in ALLOWED:
raise HTTPException(422, f"unsupported format: {fmt}")
audio_b64 = base64.b64encode(await file.read()).decode()
result = extract_items(audio_b64, fmt)
for item in result.items:
item.owner = resolve_owner(item.owner)
return result.model_dump()Boot it with uvicorn app:app, POST a recording, and the response is clean JSON your task tracker can ingest directly.
Handle the ninety-minute recording
Inkling's 1M-token context absorbs long meetings whole, but provider endpoints cap request payload size before the context window ever becomes the constraint. Segment long files and merge the results:
import io
from pydub import AudioSegment # requires ffmpeg installed
SEGMENT_MINUTES = 20
def extract_long(path: str) -> ActionItems:
audio = AudioSegment.from_file(path)
step = SEGMENT_MINUTES * 60 * 1000
merged: list[ActionItem] = []
for start in range(0, len(audio), step):
buf = io.BytesIO()
audio[start:start + step].export(buf, format="mp3")
chunk_b64 = base64.b64encode(buf.getvalue()).decode()
merged.extend(extract_items(chunk_b64, "mp3").items)
seen: dict[str, ActionItem] = {}
for item in merged:
seen.setdefault(item.task.lower()[:60], item)
return ActionItems(items=list(seen.values()))The dedupe key is crude by design. Items restated across a segment boundary collapse into one; genuinely distinct tasks survive.
Inkling vs the Whisper-plus-GPT pipeline
| | Inkling, one call | Whisper + text LLM, two stages | |---|---|---| | Architecture | Audio straight into one model | Transcribe, then extract from text | | Error behavior | One model, one error surface | Stage-one errors become invisible downstream | | Non-verbal context | Tone and emphasis reach the extractor | Flattened away by transcription | | License | Apache 2.0, weights on Hugging Face | Whisper open, frontier extractor usually closed | | Cost shape | One multimodal call | Cheap transcription plus an LLM call | | Transcript artifact | None unless you ask for one | Produced by default | | When it wins | Extraction quality, pipeline simplicity | You need the full transcript anyway |
Limitations
Speaker attribution is inference, not measurement. Inkling attributes commitments by voice and conversational context, and in a heated meeting it will sometimes hand Alice's task to whoever talked the most. Keep the quote field mandatory and keep a human glancing at the output; an extractor without an audit trail is a rumor generator with an API. There is no timestamp output in this build, deadlines are extracted as spoken ("end of next sprint") rather than resolved to dates, and the segmentation dedupe can merge two legitimately similar tasks. If you need the full searchable transcript as a product feature, the two-stage pipeline still earns its keep (you can also just ask Inkling to transcribe, but then you are paying trillion-class prices for stenography).
The full working example
import base64
import io
import json
import os
from difflib import get_close_matches
from fastapi import FastAPI, HTTPException, UploadFile
from openai import OpenAI
from pydantic import BaseModel, Field, ValidationError
from pydub import AudioSegment
client = OpenAI(
base_url="https://api.fireworks.ai/inference/v1",
api_key=os.environ["FIREWORKS_API_KEY"],
)
MODEL = "accounts/fireworks/models/inkling"
ROSTER = ["Sarah Chen", "Tom Okafor", "Danielle Ruiz", "Marcus Webb"]
FIRST_NAMES = {name.split()[0].lower(): name for name in ROSTER}
SEGMENT_MINUTES = 20
ALLOWED = {"mp3", "wav", "m4a"}
class ActionItem(BaseModel):
task: str = Field(description="What needs to be done, one sentence")
owner: str = Field(description="Person responsible, exactly as named in the audio")
deadline: str | None = Field(default=None, description="Deadline as stated, null if none was said")
quote: str = Field(description="Short verbatim quote containing the commitment")
class ActionItems(BaseModel):
items: list[ActionItem]
EXTRACT_PROMPT = f"""Listen to this meeting recording. Extract every action item that a specific person commi
Return ONLY valid JSON matching this schema, no markdown fences, no commentary:
{json.dumps(ActionItems.model_json_schema(), indent=2)}
Rules: an action item needs a named owner. General discussion is not an action item.
If no deadline was spoken, use null. Never invent one."""
def extract_items(audio_b64: str, fmt: str) -> ActionItems:
for attempt in range(2):
response = client.chat.completions.create(
model=MODEL,
temperature=0,
messages=[{
"role": "user",
"content": [
{"type": "text", "text": EXTRACT_PROMPT},
{"type": "input_audio", "input_audio": {"data": audio_b64, "format": fmt}},
],
}],
)
raw = response.choices[0].message.content.strip()
raw = raw.removeprefix("```json").removesuffix("```").strip()
try:
return ActionItems.model_validate_json(raw)
except ValidationError:
if attempt == 1:
raise
raise RuntimeError("unreachable")
def resolve_owner(spoken: str) -> str:
first = spoken.strip().split()[0].lower()
if first in FIRST_NAMES:
return FIRST_NAMES[first]
match = get_close_matches(first, FIRST_NAMES.keys(), n=1, cutoff=0.75)
return FIRST_NAMES[match[0]] if match else f"UNRESOLVED: {spoken}"
def segments(data: bytes, fmt: str):
audio = AudioSegment.from_file(io.BytesIO(data), format=fmt)
step = SEGMENT_MINUTES * 60 * 1000
for start in range(0, len(audio), step):
buf = io.BytesIO()
audio[start:start + step].export(buf, format="mp3")
yield base64.b64encode(buf.getvalue()).decode()
app = FastAPI()
@app.post("/extract")
async def extract(file: UploadFile):
fmt = (file.filename or "").rsplit(".", 1)[-1].lower()
if fmt not in ALLOWED:
raise HTTPException(422, f"unsupported format: {fmt}")
data = await file.read()
merged: list[ActionItem] = []
for chunk_b64 in segments(data, fmt):
merged.extend(extract_items(chunk_b64, "mp3").items)
seen: dict[str, ActionItem] = {}
for item in merged:
item.owner = resolve_owner(item.owner)
seen.setdefault(item.task.lower()[:60], item)
return ActionItems(items=list(seen.values())).model_dump()When to use this
Use Inkling's native audio path when the recording is the input and structure is the output: standups, client calls, incident reviews, interview debriefs. The single-model pipeline removes the transcription seam where extraction quality quietly leaks, and Apache 2.0 weights mean the fine-tuning path stays open once you have accumulated enough labeled meetings to specialize it. Thinking Machines says plainly that Inkling is not the strongest model available. It does not need to be. It needs to hear your meeting, hold all ninety minutes in context, and give you back the four sentences someone actually committed to.
You might also like
Keep reading from the journal.
July 13, 2026Data
Your best week ever was a duplicate event
Event contracts put your tracking plan in CI, where bugs die cheap
July 8, 2026Data
Add a Zero-API-Key LLM Review Gate to GitHub Actions with VibeThinker-3B
On June 17, 2026, nine researchers at Sina Weibo released VibeThinker-3B: a 3.1-billion-parameter reasoning model, MIT-licensed, post-trained on Alibaba's Qwen2.5-Coder-3B.
July 6, 2026Engineering
Build a Screenshot-to-React Service with Kimi K2.7 Code HighSpeed
In late June 2026, Moonshot AI added a HighSpeed serving tier for Kimi K2.7 Code, the one-trillion-parameter open-weight coding model it published under a Modified MIT license on June 12.