Extracting Action Items and Owners From Meeting Minutes
10 min read · updated August 11, 2026
Turning a recording into minutes and pulling the actions out of finished minutes are different jobs with different failure modes. This is the second one: the document already exists, a human already edited it, and the two things that will wreck your extraction are duplication and carry-forward.
Minutes are not a transcript
A transcript is everything anybody said, in order, with no editorial judgement applied. Minutes are the opposite: a secretary has already decided what mattered, dropped the rest, numbered the items and usually rewritten the wording into a house register. That editing is why extraction from minutes is tractable at all — the document is short, the vocabulary is small and conventional, and the actions are frequently already marked with a literal token like ACTION: or AI followed by initials.
It also creates the two problems this page is about. Because the document is a formal record rather than a stream, it repeats itself on purpose, and it carries state forward from the previous meeting. Both of those inflate an extraction that is otherwise correct, and neither shows up as a low confidence score, because the model is reading real text and reporting it accurately. It is your record set that ends up wrong, not any individual field.
Every action appears twice
Most minutes templates state an action inside the narrative of the agenda item it came from, and then again in a consolidated action table at the end or at the top. The two statements are not identical. The narrative version has the context and often the reasoning; the table version has the owner initials and a date and nothing else. A model asked to “extract all action items” will return both, because both are action items.
You cannot dedupe on the task text, because the two wordings differ. Dedupe on the agenda item number, which both versions carry: the narrative because it sits under the heading, the table because most templates put a reference column in it. Many organisations use a compound action reference of the form meeting-and-year then a sequence, so an action might be recorded as 2026-07/03. Where one exists, take it verbatim and make it the identity of the record. Never renumber — that reference is how a human will find the action in the minutes six months later, and a renumbered action is an untraceable one.
When there is no reference at all, dedupe on the pair of normalised owner and agenda item number, and keep both source spans on the record. Two distinct actions for the same person under the same item are rare but real, so make the dedup a merge that concatenates evidence rather than a drop that picks a winner.
Matters arising re-opens closed actions
Almost every board or committee template has a section near the front — “matters arising”, “actions from the previous meeting”, “action tracker” — that lists last meeting’s actions with a status against each. An extractor that does not know about this section emits every one of them as a new open action, every month, forever. After four meetings the tracker has four copies of the same task and three of them are already done.
The fix is structural rather than a prompt tweak. Give the schema an origin field with two values — the action was raised at this meeting, or it is being reported on from an earlier one — and a status field, and require the model to fill both. The status vocabulary in these sections is small and worth pinning to an enum: open, in progress, complete, closed, carried forward, superseded. Anything outside it goes to a nullable status_text so you can see what the house style actually says.
Once origin exists, the ingestion rule writes itself: a carried-forward action updates the status of the existing record if you can match it, and creates nothing if you cannot. It is worth logging the unmatched ones rather than swallowing them, because a carried-forward action that matches nothing usually means you missed a meeting’s minutes entirely.
A schema that survives both problems
The interesting part of this schema is not the task field. It is that three separate things are allowed to be null, and each null means something different from the others.
{
"type": "object",
"properties": {
"meeting_date": { "type": ["string", "null"], "format": "date" },
"next_meeting": { "type": ["string", "null"], "format": "date" },
"actions": {
"type": "array",
"items": {
"type": "object",
"properties": {
"reference": { "type": ["string", "null"] },
"agenda_item": { "type": ["string", "null"] },
"task": { "type": "string" },
"owner_text": { "type": ["string", "null"] },
"owner_kind": { "enum": ["person", "role", "group", "unassigned"] },
"due_text": { "type": ["string", "null"] },
"due_date": { "type": ["string", "null"], "format": "date" },
"origin": { "enum": ["this_meeting", "carried_forward"] },
"status": { "enum": ["open", "in_progress", "complete",
"closed", "carried_forward", "superseded",
"unstated"] },
"evidence": { "type": "string" }
},
"required": ["task", "owner_kind", "origin", "status", "evidence"]
}
}
}
}owner_text is null with owner_kind set to unassigned when the minutes genuinely do not say who is doing it, which happens constantly because minutes are written in the passive: “it was agreed that the risk register would be updated before the next meeting.” There is no owner in that sentence. The correct extraction is a null owner, not the chair, and not the person who happened to be speaking in the previous paragraph. An unassigned action is a real and useful finding — it is exactly the thing a secretary wants flagged — and a model that invents an owner destroys that signal.
owner_kind exists because minutes assign work to roles and committees as often as to people: “Finance to circulate”, “the Audit Committee to review”. Those are not names and should not be resolved to one. Where the owner is initials, resolve them against the attendee list from the same document rather than against your staff directory — the attendee list is the local namespace and two people in your directory may share initials.
due_text and due_date are separate because most due dates in minutes are relative: “by the next meeting”, “end of Q3”, “before the audit”. Keep the words verbatim, and only fill due_date when the document itself contains the anchor — which is why next_meeting is at the top level. If the minutes state the date of the next meeting, “by the next meeting” resolves. If they do not, it does not resolve, and a date you computed from the run date is a fabrication that will look authoritative in a tracker.
The run
- Get the minutes as text with the item numbering intact. Numbered headings are the anchor for everything else here, so if your PDF path is flattening them, fix that before you touch the model — see PDF parsing.
- Send the whole document in one request rather than chunking it. A set of minutes is typically two to six pages, the matters-arising section is at the front and the action table at the back, and deduplication depends on the model seeing both. Chunking is what makes the duplicate problem unsolvable.
- Ask for the schema above with strict structured output, and put the three hard rules in the instruction rather than hoping: if no person or role is named in the sentence,
owner_kindisunassigned; anything under a matters-arising heading iscarried_forward;due_dateis null unless the document contains the anchor date. - Require
evidenceto be a verbatim span from the document. This is the cheapest possible check: a span that does not appear in the source text is a fabricated record and you can test that with a string search, with no model involved. - Run the checks below, then write only the records where
originisthis_meetingas new rows, and apply the rest as status updates.
const res = await client.chat.completions.create({
model: "gpt-4.1",
temperature: 0,
response_format: { type: "json_schema", json_schema: { name: "minutes_actions", strict: true, schema } },
messages: [
{ role: "system", content: [
"Extract action items from board minutes.",
"Do not infer an owner. If no person, role or group is named in the",
"sentence recording the action, set owner_kind to unassigned.",
"Items under a matters arising or previous actions heading have",
"origin = carried_forward.",
"due_date is null unless the document states the anchor date.",
"evidence must be copied verbatim from the input."
].join(" ") },
{ role: "user", content: minutesText },
],
});Checks that catch a bad extraction
- Evidence is a substring. Normalise whitespace and check every
evidencevalue appears in the source. Failures here are almost always paraphrase, which means the model was summarising rather than extracting. - Table count equals extraction count. If the minutes have a consolidated action table, count its rows and compare with the number of
this_meetingactions. A mismatch in either direction is worth a human look, and this is the single most effective check available because the document is telling you the answer. - Owners resolve into the attendance list. Every
owner_textwithowner_kindof person should match somebody recorded as present, in attendance, or explicitly absent. An owner who appears nowhere in the attendance section is usually an initials misread, occasionally a genuinely delegated action, and never something to write silently. - No due date precedes the meeting date. An ordinary date field validation rule, and it catches the year-rollover mistake where a December meeting sets a January deadline and the resolver keeps the old year.
- Unassigned rate is not zero. Real minutes contain unassigned actions. An extraction run over a hundred meetings that produces an owner for every single action has a model filling in gaps, and you will not notice it any other way.
Actions are only half of what a set of minutes records. The other half is what was decided, which uses a different and much more reliable set of verbal cues — distinguishing a recorded decision from a discussion point is worth doing in the same pass.