You know the invite. It lands on a Tuesday afternoon:

Sync
Friday, July 10 · 3:00 – 3:15pm
quick chat
No location
No guest list · No organizer
Yes No Maybe

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 stdin

Here’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:VCALENDAR

Run 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 failed

Now 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 failed

Twelve 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 failed

Exit 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:

Roadmap planning for Q3
Friday, July 10 · 3:00 – 4:00pm
Agenda:
· Review Q2 outcomes (Alice: brings the metrics dashboard)
· Decide Q3 priorities (Bob: brings the customer requests list)
Intended outcome: an agreed, ranked list of Q3 priorities with owners.
Conference Room A
2 guests · Alice Organizer (organizer)
AAlice Organizer
BBob Attendee
Yes No Maybe

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.