Lint Your Meetings
You know the invite. It lands on a Tuesday afternoon:
Fifteen minutes, no agenda, no attendees you can see, no idea what “sync” means or what you’re supposed to bring. It could be a layoff. It could be someone asking where a config file lives. You have three buttons — Yes, No, Maybe — and not one shred of information to choose between them. So you click Yes, because declining feels rude, and now fifteen minutes of your Friday belong to a word.
I have spent my whole career refusing to merge code that looks like this. And then accepting meetings that look exactly like this, all day, without complaint.
Linting is a solved problem. For code.
Think about how much scaffolding we’ve built to stop a human from shipping a
sloppy artifact. You cannot merge without a description. CI won’t go green if the
formatter disagrees with you about a space. golangci-lint will fail the build
over an unchecked error return. We have decided, as an industry, that the
document must meet a standard before it costs other people time.
A meeting invite is a document too. It even has a schema —
RFC 5545, the .ics format — with a title, a description, an
organizer, a guest list, a start and an end. It is structured data. It is the
single most common artifact a knowledge worker produces, it routinely consumes an
hour of eight people’s time at once, and it ships with zero review gate. Anyone
can put anything on your calendar and there is no linter standing between their
laziness and your afternoon.
That asymmetry finally annoyed me enough to fix it. So I wrote meeting-linter: a linter for calendar invites.
What a bad invite actually is
Before writing rules I had to be honest about what makes the “Sync” invite above so infuriating. It’s not one thing, it’s a pile of small omissions:
- The title is a filler word. “Sync”, “Catch up”, “Quick chat”, “Hold”. These convey nothing to someone who wasn’t in the hallway conversation that spawned them.
- There’s no agenda. No list of what will be discussed, so you can’t prepare and can’t tell if you’re even the right person to be there.
- Nobody’s role is stated. Eight people are invited and it’s a mystery which of them is presenting, deciding, or just decoration.
- There’s no stated outcome. Is this a decision? A brainstorm? A status readout? What does “done” look like? Unknown.
- No pre-reads, no link, no location. You will find out where it is when it starts.
Some of these are mechanical — the title is empty, the guest list is over eight people, the duration is zero. A computer can check those with certainty. Others are subjective — “does this title actually convey the purpose?” — and no regex is going to answer them honestly.
That split is the whole design.
Two kinds of rules
meeting-linter has two rule types, and picking the right one for each check is the interesting part.
Deterministic rules are CEL expressions — Google’s Common Expression
Language — evaluated locally against the invite. They’re fast, offline, and
predictable. The invite is bound to a meeting object:
- id: attendee-count
type: deterministic
severity: warning
expr: 'meeting.attendees.size() >= 1 && meeting.attendees.size() <= 8'
message: "keep invites to 1–8 attendees"
- id: has-agenda
type: deterministic
severity: error
expr: 'hasAgenda(meeting.description)'
message: "no agenda in the description"hasAgenda is a helper I registered in the CEL environment alongside wordCount;
the rest is stock CEL, so you get .size(), .matches(re), .contains(),
.all(x, ...), and timestamp helpers like .getHours(tz) for free. Want to warn
about meetings scheduled at 7am or on a Saturday? That’s one expression, no code.
LLM rules describe what “good” looks like in plain language, and Claude judges the invite against the description. This is for the checks a regex can’t do without lying:
- id: descriptive-title
type: llm
severity: warning
prompt: >-
The title should let someone who wasn't invited understand the meeting's
purpose. Vague titles like "Sync" or "Quick chat" fail.
- id: documented-outcomes
type: llm
severity: warning
prompt: >-
The description should state the intended outcomes or decisions the meeting
is meant to produce.wordCount(title) >= 3 is a fine cheap proxy for “the title has substance,” but it
happily passes “Let us meet up” and fails “Q3 planning.” The LLM rule reads the
title the way a human would. Use the deterministic rule as the cheap guard and the
LLM rule as the judgment call — belt and suspenders.
Running it
It’s a Go CLI. Point it at an .ics file:
go install github.com/husobee/meeting-linter/cmd/meeting-linter@latest
meeting-linter lint invite.ics
meeting-linter lint --no-llm invite.ics # deterministic only, offline
cat invite.ics | meeting-linter lint - # or read from stdinHere’s that “Sync” invite as an actual .ics file — this is the literal document
behind the calendar card at the top of the post:
BEGIN:VCALENDAR
VERSION:2.0
PRODID:-//meeting-linter//test//EN
BEGIN:VEVENT
UID:bad-1@example.com
SUMMARY:Sync
DESCRIPTION:quick chat
DTSTART:20260710T150000Z
DTEND:20260710T151500Z
END:VEVENT
END:VCALENDARRun the deterministic rules over it and the mechanical failures fall out instantly, no network required:
$ meeting-linter lint --no-llm --config examples/rules.yaml testdata/bad.ics
✗ Sync (bad-1@example.com)
warning title-not-vague: title is a filler word; say what the meeting is about
warning title-has-substance: title is very short; use a few words that convey the purpose
error has-agenda: no agenda in the description
warning description-substantial: description is too thin to prepare from
warning attendee-count: keep invites to 1–8 attendees
warning has-organizer: invite has no organizer
warning has-location-or-link: no location or video link
6 passed, 7 failedNow turn the LLM rules on (set ANTHROPIC_API_KEY) and Claude piles on with the
judgment calls a regex can’t make:
$ meeting-linter lint --config examples/rules.yaml testdata/bad.ics
✗ Sync (bad-1@example.com)
...
warning descriptive-title: Title is 'Sync', an explicitly vague example that fails; it conveys no purpose.
warning attendee-contributions: No attendees are listed and the description names no one or their contributions.
warning documented-outcomes: Description ('quick chat') states no intended outcomes or decisions.
info decision-vs-discussion: The invite does not indicate whether this is a decision or discussion, nor what needs deciding.
info pre-reads-linked: No pre-read, doc, data, or proposal is linked in the description.
6 passed, 12 failedTwelve findings for a fifteen-minute meeting. That feels about right.
For the record, here’s what passing looks like. Same linter, an invite that respects your time — a real title, an agenda, named contributors, a stated outcome:
$ meeting-linter lint --no-llm testdata/good.ics
✓ Roadmap planning for Q3 (good-1@example.com)
5 passed, 0 failedExit codes, because this belongs in CI
The whole point of a linter is that it fails the build. meeting-linter exits 1
when it finds anything at or above --fail-on (default error), 0 when it’s
clean, and 2 on operational errors. There’s a --format json for machine
consumption too:
[
{
"title": "Sync",
"uid": "bad-1@example.com",
"findings": [
{ "rule_id": "has-agenda", "severity": "error", "passed": false,
"message": "the invite has no agenda in its description" }
]
}
]I’ll admit the dream is a little absurd: a pre-commit hook for your calendar. A bot that bounces your “Sync” invite back to you with a lint report before it ever reaches eight people’s Friday. But the pieces are all here — a parser, a rule engine, structured output, exit codes. The gap between “linter for a file” and “linter for a meeting” turned out to be almost entirely a gap in attitude, not tooling.
We already decided that documents which cost other people time have to meet a standard before they ship. A meeting invite is just the document we forgot to hold to it.
What the other side of the linter looks like
We opened on the “Sync” invite. Here’s what lands in your calendar once an invite
clears every rule — same tool, same guy’s Friday, 0 failed:
Look at the difference. You don’t have to click anything to know what this is. You know why you’re there, what you’re expected to bring, who else is in the room, and what “done” looks like — an agreed, ranked list of Q3 priorities. You can decide in two seconds whether you’re the right person for it, and if you are, you can walk in prepared instead of spending the first ten minutes figuring out why you were summoned. It costs the organizer maybe ninety extra seconds to write. It saves every invitee that ninety seconds back, several times over, and it’s the exact difference between a meeting that respects your time and one that just takes it. That gap is the whole product.
The whole thing — the CEL engine, the LLM judge, the built-in ruleset, and the example config I used above — is at github.com/husobee/meeting-linter. Go lint a meeting.