🧭 How toProject ManagementIntermediate✨ AI-assisted

How to Extract Action Items From Meeting Notes With Claude

WittyTech Daily··6 min read
#claude#structured outputs#pydantic#action items

Action items die in meeting notes. Someone says they'll send the revised SOW by Friday, the notes get filed, and by Monday nobody remembers who owed what.

By the end of this guide you'll have a Python script that turns raw notes into Claude meeting action items you can trust: each one has an owner, a due date and a quote from the notes proving it was said. It uses client.messages.parse() and a Pydantic model on the Claude API, so you get a typed object back instead of a string you hope is JSON. You'll also compare the cost of Claude Sonnet 5.5 and Claude Haiku 5.5 on the same notes.

Step 1: Install the SDK

You need Python 3.10 or newer (the str | None syntax below requires it).

pip install -U anthropic pydantic
export ANTHROPIC_API_KEY="<your-api-key>"

The SDK reads the key from the environment, so nothing secret lands in your code.

Step 2: Define the action item schema with Pydantic

The schema is the contract. Every item must carry an evidence_quote, which is what lets you catch invented owners in step 4.

from datetime import date
from typing import Literal

from pydantic import BaseModel


class ActionItem(BaseModel):
    task: str
    owner: str | None
    due_date: date | None
    priority: Literal['high', 'normal', 'low']
    evidence_quote: str


class MeetingActions(BaseModel):
    action_items: list[ActionItem]

owner and due_date are nullable but still required, so Claude has to answer "nobody" or "no date" explicitly instead of quietly skipping the field. Dates use the date format, which the structured outputs docs list as supported.

Step 3: Extract Claude meeting action items with messages.parse

import anthropic

client = anthropic.Anthropic()  # reads ANTHROPIC_API_KEY

SYSTEM = '''You extract action items from meeting notes.
- Include a task if someone committed to it or the group agreed it must happen. Skip open discussion.
- owner: write the person's name exactly as it appears in the notes. If nobody was named, use null.
- due_date: resolve relative dates such as 'by Friday' against the meeting date. If no date was given, use null.
- evidence_quote: copy the exact words from the notes that support the item. Never paraphrase.'''


def extract(notes: str, meeting_date: str, model: str = 'claude-sonnet-5-5'):
    return client.messages.parse(
        model=model,
        max_tokens=4096,
        system=SYSTEM,
        messages=[{
            'role': 'user',
            'content': f'''Meeting date: {meeting_date}

Notes:
{notes}''',
        }],
        output_format=MeetingActions,
    )

The SDK turns your Pydantic model into a JSON schema, sends it as output_config.format, validates the reply and returns the result in response.parsed_output. No beta header is needed. Passing the meeting date is what lets Claude turn "by Friday" into a real date.

Step 4: Reject invented owners with an evidence quote check

A schema guarantees shape, not truth. A model can hand you a perfectly typed item with an owner nobody mentioned. So check the quote and the owner against the source text.

def check_items(notes: str, response):
    if response.stop_reason == 'refusal':
        raise RuntimeError('Claude refused; send these notes to a human')
    if response.stop_reason == 'max_tokens':
        raise RuntimeError('Output was cut off; retry with a higher max_tokens')

    def norm(text: str) -> str:
        return ' '.join(text.lower().split())

    source = norm(notes)
    good, suspect = [], []
    for item in response.parsed_output.action_items:
        quote_ok = norm(item.evidence_quote) in source
        owner_ok = item.owner is None or norm(item.owner) in source
        (good if quote_ok and owner_ok else suspect).append(item)
    return good, suspect

The stop_reason checks run first because the docs say a refusal or a truncated reply might not match your schema. Items in suspect go to a human instead of your tracker.

Step 5: Compare Claude Sonnet 5.5 and Haiku 5.5 on the same notes

Run both models over identical notes and count what survives the check. The prices come from the Claude pricing page.

NOTES = '''Weekly sync, 21 Oct 2026. Attendees: Priya, Marcus, Dana.
- Priya will send the revised SOW to the client by Friday.
- Marcus to rerun the load test once staging is rebuilt, target next Wednesday.
- Someone should look at the flaky login test.
- Dana is thinking about moving the demo, no decision yet.'''

PRICES = {  # USD per million tokens: (input, output)
    'claude-sonnet-5-5': (2.00, 10.00),
    'claude-haiku-5-5': (0.10, 0.50),  # prompts up to 100,000 tokens
}

for model, (price_in, price_out) in PRICES.items():
    response = extract(NOTES, '2026-10-21', model)
    good, suspect = check_items(NOTES, response)
    usage = response.usage
    cost = (usage.input_tokens * price_in + usage.output_tokens * price_out) / 1_000_000
    print(f'{model}: {len(good)} ok, {len(suspect)} suspect, ${cost:.5f}')
    for item in good:
        print('  ', item.due_date, item.owner, item.task)

The notes include two traps: an unowned task and a musing that isn't a commitment. Read the output for both.

For scale, assume a meeting costs 3,000 input and 500 output tokens. Sonnet 5.5 lands at about $0.011 and Haiku 5.5 at about $0.00055, a 20x gap, or roughly $11 against $0.55 per 1,000 meetings. That is list-price arithmetic. Thinking tokens are billed as output too, so trust usage over my estimate.

Where each model fits

Anthropic positions Haiku 5.5 for high-volume tasks such as classification and extraction, so it is a sensible default for tidy, bulleted notes on every meeting. Move to Sonnet 5.5 when the input is a rambling transcript with implied commitments, or when Haiku's suspect rate climbs. I'd run both on 20 past meetings and compare against what your PM would have marked. Note that Haiku 5.5 costs more for prompts over 100,000 tokens ($0.50 / $2.50 per MTok), which matters for full-day transcripts.

Gotchas

  • Unsupported constraints. The API rejects minimum, maximum, minLength, maxLength and array bounds beyond a minItems of 0 or 1. The Python SDK strips them from the sent schema, moves them into field descriptions and still validates against your original model. Treat them as hints and enforce rules in code, as in step 4.
  • Refusals are billed. A refusal stop reason returns a 200 and charges you for the tokens. Don't add a field asking for Claude's step-by-step reasoning, which can trigger a reasoning_extraction refusal. Ask for a short explanation instead.
  • Thinking eats max_tokens. Thinking tokens count toward the limit, so keep headroom and raise it for long notes.
  • The first call with a new schema is slower. The schema is compiled once and cached for 24 hours from last use. Changing its structure resets that; changing only names or descriptions doesn't.
  • Schema limits. A request allows 24 optional parameters and 16 union-typed ones. Two nullable fields per item is well inside that.

Typed output plus a quote you can search for is what lets project status automation trust its own input, which is the difference between a demo and something a team will let write to the real tracker.

← More in Project Management