Table of Contents

Extract Action Items and Tasks from Meeting Recordings

Meetings generate decisions and assignments, but attendees often leave without a clear record of who owes what by when. LM-Kit.NET lets you chain speech-to-text transcription directly into structured data extraction, converting audio recordings into a typed list of action items with owners, deadlines, and priorities. The entire pipeline runs locally, keeping sensitive meeting content off cloud servers. This tutorial builds an automated action item extractor that processes meeting recordings and outputs structured task lists.


Why Automated Action Item Extraction Matters

Two enterprise problems that automated task extraction from audio solves:

  1. Meeting follow-up accountability. Project teams hold daily standups, sprint reviews, and planning sessions where dozens of tasks are assigned verbally. Without a structured capture system, action items get forgotten within hours. An automated pipeline ensures every assignment is captured with its owner and deadline, feeding directly into project management tools.
  2. Compliance and audit trails. Regulated industries (finance, healthcare, defense) require documented evidence of decisions and assigned responsibilities. Manually transcribing and tagging action items from hours of recorded board meetings is expensive and error-prone. An automated extractor produces consistent, auditable records from every recorded session.

Prerequisites

Requirement Minimum
.NET SDK 8.0+
VRAM ~4.5 GB (Whisper model + extraction model)
Disk ~4 GB free for model downloads
Audio file A .wav file (16-bit PCM, any sample rate)

Step 1: Create the Project

dotnet new console -n ActionItemExtractor
cd ActionItemExtractor
dotnet add package LM-Kit.NET

Step 2: Understand the Pipeline

  Meeting recording (.wav)
        │
        ▼
  ┌──────────────────┐
  │  SpeechToText    │    Whisper model
  │  (transcribe)    │    Timestamped segments
  └────────┬─────────┘
           │
           ▼
  ┌──────────────────┐
  │  TextExtraction  │    Structured extraction
  │  (extract tasks) │    JSON schema with typed fields
  └────────┬─────────┘
           │
           ▼
  Action items (JSON)
  ├── task description
  ├── assigned to
  ├── deadline
  └── priority
Stage Component Purpose
Transcribe SpeechToText Convert meeting audio to text
Extract TextExtraction Pull structured action items from transcript

Step 3: Transcribe and Extract Action Items

using System.Text;
using System.Text.Json;
using LMKit.Data;
using LMKit.Extraction;
using LMKit.Media.Audio;
using LMKit.Model;
using LMKit.Speech;

LMKit.Licensing.LicenseManager.SetLicenseKey("");

Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;

// ──────────────────────────────────────
// 1. Load Whisper model
// ──────────────────────────────────────
Console.WriteLine("Loading Whisper model...");
using LM whisperModel = LM.LoadFromModelID("whisper-large-turbo3",
    downloadingProgress: (_, len, read) =>
    {
        if (len.HasValue) Console.Write($"\r  Downloading: {(double)read / len.Value * 100:F1}%   ");
        return true;
    },
    loadingProgress: p => { Console.Write($"\r  Loading: {p * 100:F0}%   "); return true; });
Console.WriteLine("\n");

// ──────────────────────────────────────
// 2. Load extraction model
// ──────────────────────────────────────
Console.WriteLine("Loading extraction model...");
using LM extractionModel = LM.LoadFromModelID("qwen3:8b",
    downloadingProgress: (_, len, read) =>
    {
        if (len.HasValue) Console.Write($"\r  Downloading: {(double)read / len.Value * 100:F1}%   ");
        return true;
    },
    loadingProgress: p => { Console.Write($"\r  Loading: {p * 100:F0}%   "); return true; });
Console.WriteLine("\n");

// ──────────────────────────────────────
// 3. Transcribe the meeting
// ──────────────────────────────────────
string audioPath = "meeting.wav";
if (!File.Exists(audioPath))
{
    Console.WriteLine($"Place a WAV file at '{audioPath}' and run again.");
    return;
}

var stt = new SpeechToText(whisperModel)
{
    EnableVoiceActivityDetection = true,
    SuppressNonSpeechTokens = true,
    SuppressHallucinations = true
};

Console.WriteLine($"Transcribing {audioPath}...");
using var audio = new WaveFile(audioPath);
Console.WriteLine($"  Duration: {audio.Duration:mm\\:ss\\.ff}\n");

var transcription = stt.Transcribe(audio);
string transcript = transcription.Text;

Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine("=== Transcript ===");
Console.WriteLine(transcript);
Console.ResetColor();

// ──────────────────────────────────────
// 4. Extract action items
// ──────────────────────────────────────
Console.WriteLine("\n=== Extracting Action Items ===\n");

var extractor = new TextExtraction(extractionModel)
{
    Elements = new List<TextExtractionElement>
    {
        new("action_items",
            new List<TextExtractionElement>
            {
                new("task", ElementType.String,
                    "Description of the task or action item"),
                new("assigned_to", ElementType.String,
                    "Person responsible for the task"),
                new("deadline", ElementType.String,
                    "Due date or timeframe mentioned"),
                new("priority", ElementType.String,
                    "Priority level: high, medium, or low based on urgency in context"),
            },
            isArray: true,
            description: "List of action items, tasks, and assignments discussed in the meeting")
    }
};

extractor.SetContent(transcript);
TextExtractionResult result = extractor.Parse();

// Pretty-print the extracted action items
using JsonDocument doc = JsonDocument.Parse(result.Json);
string prettyJson = JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });

Console.ForegroundColor = ConsoleColor.Cyan;
Console.WriteLine(prettyJson);
Console.ResetColor();

Console.ForegroundColor = ConsoleColor.DarkGray;
Console.WriteLine($"\nConfidence: {result.Confidence:P0}");
Console.ResetColor();

// Save results
File.WriteAllText("action_items.json", prettyJson);
Console.WriteLine("Saved to action_items.json");

Step 4: Extract Richer Task Metadata

Add more fields to capture decisions, blockers, and follow-ups:

using System.Text;
using System.Text.Json;
using LMKit.Data;
using LMKit.Extraction;
using LMKit.Media.Audio;
using LMKit.Model;
using LMKit.Speech;

LMKit.Licensing.LicenseManager.SetLicenseKey("");

Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;

// ──────────────────────────────────────
// 1. Load Whisper model
// ──────────────────────────────────────
Console.WriteLine("Loading Whisper model...");
using LM whisperModel = LM.LoadFromModelID("whisper-large-turbo3",
    downloadingProgress: (_, len, read) =>
    {
        if (len.HasValue) Console.Write($"\r  Downloading: {(double)read / len.Value * 100:F1}%   ");
        return true;
    },
    loadingProgress: p => { Console.Write($"\r  Loading: {p * 100:F0}%   "); return true; });
Console.WriteLine("\n");

// ──────────────────────────────────────
// 2. Load extraction model
// ──────────────────────────────────────
Console.WriteLine("Loading extraction model...");
using LM extractionModel = LM.LoadFromModelID("qwen3:8b",
    downloadingProgress: (_, len, read) =>
    {
        if (len.HasValue) Console.Write($"\r  Downloading: {(double)read / len.Value * 100:F1}%   ");
        return true;
    },
    loadingProgress: p => { Console.Write($"\r  Loading: {p * 100:F0}%   "); return true; });
Console.WriteLine("\n");

// ──────────────────────────────────────
// 3. Transcribe the meeting
// ──────────────────────────────────────
string audioPath = "meeting.wav";
if (!File.Exists(audioPath))
{
    Console.WriteLine($"Place a WAV file at '{audioPath}' and run again.");
    return;
}

var stt = new SpeechToText(whisperModel)
{
    EnableVoiceActivityDetection = true,
    SuppressNonSpeechTokens = true,
    SuppressHallucinations = true
};

Console.WriteLine($"Transcribing {audioPath}...");
using var audio = new WaveFile(audioPath);
Console.WriteLine($"  Duration: {audio.Duration:mm\\:ss\\.ff}\n");

var transcription = stt.Transcribe(audio);
string transcript = transcription.Text;

var detailedExtractor = new TextExtraction(extractionModel)
{
    Elements = new List<TextExtractionElement>
    {
        new("action_items",
            new List<TextExtractionElement>
            {
                new("task", ElementType.String,
                    "Description of the action item"),
                new("assigned_to", ElementType.String,
                    "Person responsible"),
                new("deadline", ElementType.String,
                    "Due date or timeframe"),
                new("priority", ElementType.String,
                    "high, medium, or low"),
                new("category", ElementType.String,
                    "Category: engineering, design, marketing, operations, finance, or other"),
                new("depends_on", ElementType.String,
                    "Other tasks or blockers this depends on, if mentioned"),
            },
            isArray: true,
            description: "Tasks and assignments from the meeting"),
        new("decisions",
            new List<TextExtractionElement>
            {
                new("decision", ElementType.String,
                    "What was decided"),
                new("rationale", ElementType.String,
                    "Why this decision was made, if discussed"),
            },
            isArray: true,
            description: "Key decisions made during the meeting"),
        new("open_questions",
            new List<TextExtractionElement>
            {
                new("question", ElementType.String,
                    "The unresolved question or topic"),
                new("owner", ElementType.String,
                    "Person who should follow up"),
            },
            isArray: true,
            description: "Unresolved questions or items needing follow-up")
    }
};

detailedExtractor.SetContent(transcript);
TextExtractionResult detailed = detailedExtractor.Parse();

using JsonDocument detailedDoc = JsonDocument.Parse(detailed.Json);
string detailedJson = JsonSerializer.Serialize(detailedDoc, new JsonSerializerOptions { WriteIndented = true });
Console.WriteLine(detailedJson);

Step 5: Format Action Items as Markdown Checklist

Convert extracted items into a Markdown task list for direct use in project management:

using System.Text;
using System.Text.Json;
using LMKit.Data;
using LMKit.Extraction;
using LMKit.Model;
using LMKit.TextGeneration;
using LMKit.TextGeneration.Chat;

LMKit.Licensing.LicenseManager.SetLicenseKey("");

Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;

// ──────────────────────────────────────
// 1. Load extraction model
// ──────────────────────────────────────
Console.WriteLine("Loading extraction model...");
using LM extractionModel = LM.LoadFromModelID("qwen3:8b",
    downloadingProgress: (_, len, read) =>
    {
        if (len.HasValue) Console.Write($"\r  Downloading: {(double)read / len.Value * 100:F1}%   ");
        return true;
    },
    loadingProgress: p => { Console.Write($"\r  Loading: {p * 100:F0}%   "); return true; });
Console.WriteLine("\n");

// ──────────────────────────────────────
// 2. Extract action items from transcript
// ──────────────────────────────────────
// Assumes 'transcript' contains the meeting text (see Step 3 for transcription)
string transcript = File.ReadAllText("transcript.txt");

var extractor = new TextExtraction(extractionModel)
{
    Elements = new List<TextExtractionElement>
    {
        new("action_items",
            new List<TextExtractionElement>
            {
                new("task", ElementType.String,
                    "Description of the task or action item"),
                new("assigned_to", ElementType.String,
                    "Person responsible for the task"),
                new("deadline", ElementType.String,
                    "Due date or timeframe mentioned"),
                new("priority", ElementType.String,
                    "Priority level: high, medium, or low based on urgency in context"),
            },
            isArray: true,
            description: "List of action items, tasks, and assignments discussed in the meeting")
    }
};

extractor.SetContent(transcript);
TextExtractionResult result = extractor.Parse();

using JsonDocument doc = JsonDocument.Parse(result.Json);
string prettyJson = JsonSerializer.Serialize(doc, new JsonSerializerOptions { WriteIndented = true });

// ──────────────────────────────────────
// 3. Format as Markdown checklist
// ──────────────────────────────────────
Console.WriteLine("\n=== Markdown Task List ===\n");

var markdownFormatter = new SingleTurnConversation(extractionModel)
{
    SystemPrompt = "You are a task list formatter. Convert the provided JSON action items into " +
                   "a clean Markdown checklist. Group by assigned person. Format each item as:\n" +
                   "- [ ] **Task description** (deadline: DATE, priority: LEVEL)\n\n" +
                   "Include a 'Decisions' section and an 'Open Questions' section if present. " +
                   "Output only the Markdown with no commentary.",
    MaximumCompletionTokens = 2048
};

var markdownOutput = new StringBuilder();

markdownFormatter.AfterTextCompletion += (_, e) =>
{
    if (e.SegmentType == TextSegmentType.UserVisible)
    {
        markdownOutput.Append(e.Text);
        Console.Write(e.Text);
    }
};

markdownFormatter.Submit($"Format these action items as a Markdown checklist:\n\n{prettyJson}");
Console.WriteLine();

File.WriteAllText("action_items.md", markdownOutput.ToString());
Console.WriteLine("\nSaved to action_items.md");

Step 6: Batch Process Multiple Meeting Recordings

Process a folder of meeting recordings and generate a consolidated task tracker:

using System.Text;
using System.Text.Json;
using LMKit.Data;
using LMKit.Extraction;
using LMKit.Media.Audio;
using LMKit.Model;
using LMKit.Speech;

LMKit.Licensing.LicenseManager.SetLicenseKey("");

Console.InputEncoding = Encoding.UTF8;
Console.OutputEncoding = Encoding.UTF8;

// ──────────────────────────────────────
// 1. Load Whisper model
// ──────────────────────────────────────
Console.WriteLine("Loading Whisper model...");
using LM whisperModel = LM.LoadFromModelID("whisper-large-turbo3",
    downloadingProgress: (_, len, read) =>
    {
        if (len.HasValue) Console.Write($"\r  Downloading: {(double)read / len.Value * 100:F1}%   ");
        return true;
    },
    loadingProgress: p => { Console.Write($"\r  Loading: {p * 100:F0}%   "); return true; });
Console.WriteLine("\n");

// ──────────────────────────────────────
// 2. Load extraction model
// ──────────────────────────────────────
Console.WriteLine("Loading extraction model...");
using LM extractionModel = LM.LoadFromModelID("qwen3:8b",
    downloadingProgress: (_, len, read) =>
    {
        if (len.HasValue) Console.Write($"\r  Downloading: {(double)read / len.Value * 100:F1}%   ");
        return true;
    },
    loadingProgress: p => { Console.Write($"\r  Loading: {p * 100:F0}%   "); return true; });
Console.WriteLine("\n");

// ──────────────────────────────────────
// 3. Set up speech-to-text and extraction
// ──────────────────────────────────────
var stt = new SpeechToText(whisperModel)
{
    EnableVoiceActivityDetection = true,
    SuppressNonSpeechTokens = true,
    SuppressHallucinations = true
};

var extractor = new TextExtraction(extractionModel)
{
    Elements = new List<TextExtractionElement>
    {
        new("action_items",
            new List<TextExtractionElement>
            {
                new("task", ElementType.String,
                    "Description of the task or action item"),
                new("assigned_to", ElementType.String,
                    "Person responsible for the task"),
                new("deadline", ElementType.String,
                    "Due date or timeframe mentioned"),
                new("priority", ElementType.String,
                    "Priority level: high, medium, or low based on urgency in context"),
            },
            isArray: true,
            description: "List of action items, tasks, and assignments discussed in the meeting")
    }
};

Console.WriteLine("\n=== Batch Meeting Processing ===\n");

string meetingsDir = "meetings";
string outputDir = "extracted_tasks";

if (!Directory.Exists(meetingsDir))
{
    Console.WriteLine($"Create a '{meetingsDir}' folder with WAV files, then run again.");
    return;
}

Directory.CreateDirectory(outputDir);

string[] meetingFiles = Directory.GetFiles(meetingsDir, "*.wav");
Console.WriteLine($"Found {meetingFiles.Length} meeting recording(s)\n");

var allTasks = new List<string>();

foreach (string meetingPath in meetingFiles)
{
    string meetingName = Path.GetFileNameWithoutExtension(meetingPath);
    Console.Write($"  {Path.GetFileName(meetingPath)}: ");

    try
    {
        // Transcribe
        using var wav = new WaveFile(meetingPath);
        var meetingResult = stt.Transcribe(wav);

        // Extract action items
        extractor.SetContent(meetingResult.Text);
        TextExtractionResult items = extractor.Parse();

        // Save individual meeting tasks
        string outPath = Path.Combine(outputDir, $"{meetingName}_tasks.json");
        using JsonDocument meetingDoc = JsonDocument.Parse(items.Json);
        string meetingJson = JsonSerializer.Serialize(meetingDoc,
            new JsonSerializerOptions { WriteIndented = true });
        File.WriteAllText(outPath, meetingJson);

        allTasks.Add($"// {meetingName}\n{meetingJson}");

        Console.ForegroundColor = ConsoleColor.Green;
        Console.WriteLine($"done → {outPath}");
        Console.ResetColor();
    }
    catch (Exception ex)
    {
        Console.ForegroundColor = ConsoleColor.Red;
        Console.WriteLine($"failed: {ex.Message}");
        Console.ResetColor();
    }
}

Console.WriteLine($"\nAll tasks saved to {Path.GetFullPath(outputDir)}");

Model Selection

Whisper Models (Transcription)

Model ID VRAM Accuracy Best For
whisper-large-turbo3 ~870 MB Best Important meetings, clear audio (recommended)
whisper-small ~260 MB Very good Quick extraction, large meeting backlogs

Extraction Models

Model ID VRAM Quality Best For
gemma3:4b ~3.5 GB Good Simple meetings with clear action items
qwen3:8b ~6 GB Very good Complex meetings with implicit tasks (recommended)
gemma3:12b ~8 GB Excellent Dense meetings with nuanced assignments

Common Issues

Problem Cause Fix
Missing action items Tasks were implied, not explicitly stated Use a larger model (qwen3:8b+) that better understands implicit assignments
Wrong person assigned Transcript lacks speaker identification Add speaker names via stt.Prompt with attendee names
No deadlines extracted Deadlines were not mentioned in the meeting The extraction correctly returns empty; add "if mentioned" to the field description
Duplicate tasks Same task discussed multiple times Post-process with an LLM deduplication step
Low confidence score Transcript is noisy or meeting was unfocused Improve audio quality; use domain-specific stt.Prompt

Next Steps

Share