Not Every Morning Briefing Needs Inference
My briefing ran through a headless Claude for months. Via tool-use it pulled Calendar, Tasks and mails, formatted the result, sent it to a Telegram bot. It worked. It was conveniently wired up that way. On substance it was clear to me from the start: for structured data in known formats, inference is overhead — latency and cost without a corresponding gain in the output. Today the setup is pure Bash with jq.
My first signal of the day arrives at 7:00 in the morning as a Telegram message. In it: what's on the calendar today, which tasks are due today, how full the inbox is, plus a 7-day lookahead. Four sections, always the same fields, always the same shape. I read it on my phone before I'm at the computer, and then roughly know what the day looks like.
The setup has worked for a few weeks. Until recently an LLM was part of the pipeline. Now it isn't. Here's why.
How it was before
The briefing script called a headless Claude with a prompt that sounded roughly like: "Collect the appointments for the next hours from both Outlook accounts. Get the open tasks. Count the unread mails. Write a compact German briefing with emoji accents from that."
Claude called the necessary CLIs via tool-use — o365-cli calendar today, ape-tasks list, o365-cli mail list --unread. Collected the JSON outputs. Formatted the briefing. My Bash wrapper took the finished briefing and sent it via curl to the Telegram bot API.
That worked. Really. Every morning a reasonably summarized briefing came in. Patrick-style formatted, with emojis in the right places, with weekday abbreviations, with a 1–2-sentence focus at the end that prioritized the most important thing of the day.
The focus sentence, by the way, was the only part an LLM was needed for at all. The rest could have been done by a Bash script too — with jq over the JSON outputs, deterministic, without an API call.
Why I took it out
I built it in because it was convenient: one prompt, a single call, the model's tool-use does the rest. On substance, though, it was clear to me from the start that this doesn't fit. The AI spent about a minute on intelligent tool-call processing, which my current script does deterministically in two seconds — and that only because the Microsoft Graph API takes a moment to answer. Factor 30. Plus every run costs a few cents of inference. Plus on every run there's a tiny chance the model parses something wrong — a date, a time, an account name.
For a briefing built on structured data in known formats, all of that is unnecessary. The calendar events come as JSON from the Microsoft Graph API. The tasks come as JSON from the ape-tasks API. The mail counts are a simple length operation on a JSON array. There's nothing to interpret, nothing to balance, nothing to infer.
In the script header I noted it like this:
"The previous Claude-headless version made cost & latency that wasn't earning its keep on this kind of structured data."
How it looks now
The current script is pure shell with jq for the JSON processing. Four functions, one per section:
section_today— appointments today, both Outlook accountssection_tasks— open/in-progress tasks, with overdue marker and reminder countersection_mails— unread counts per account, simplelengthoperation on JSON arraysection_next7— appointments from tomorrow for the next 7 days, both accounts
One section as an example, today's appointments:
section_today() {
local out="" events lines
for acct in "${ACCOUNTS[@]}"; do
events=$(o365-cli calendar today --account "$acct" --json 2>/dev/null)
if [ -z "$events" ] || [ "$events" = "null" ]; then continue; fi
lines=$(printf '%s' "$events" | jq -r '
.[]
| if .is_all_day
then "- ganztags " + .subject
else "- "
+ (.start | fromdateiso8601 | localtime | strftime("%H:%M"))
+ "-"
+ (.end | fromdateiso8601 | localtime | strftime("%H:%M"))
+ " " + .subject
end')
if [ -n "$lines" ]; then
out+="$lines"$'\n'
fi
done
if [ -z "$out" ]; then
printf '🗓 Keine Termine heute\n'
else
printf '🗓 Termine heute\n%s' "$out"
fi
}
Classic jq pipeline that turns a calendar JSON into a Markdown-like list. All-day events get their own format, normal events get HH:MM-HH:MM Title. When both accounts deliver no appointments, there's a default string.
The section_tasks function is a bit more sophisticated, because it's supposed to distinguish between due today and overdue, plus dig the reminder counter out of the task object:
section_tasks() {
local tasks lines
tasks=$(ape-tasks list --status open,doing --json 2>/dev/null)
if [ -z "$tasks" ] || [ "$tasks" = "null" ]; then return; fi
lines=$(printf '%s' "$tasks" | jq -r \
--argjson today_end "$TODAY_END" \
--argjson today_start "$TODAY_START" '
.[]
| select(
(.remind_at != null and .remind_at <= $today_end)
or (.due_at != null and .due_at <= $today_end)
)
| "- " + .title
+ (if (.remind_at != null and .remind_at < $today_start)
then (if (.reminder_count // 0) > 0
then " (überfällig, " + ((.reminder_count) | tostring) + "x erinnert)"
else " (überfällig)"
end)
else ""
end)
')
if [ -n "$lines" ]; then
printf '\n✅ Heute fällig\n%s\n' "$lines"
fi
}
jq with two external arguments (day boundary in Unix time), select filter over the tasks, conditional formatting. One line per due task, with a hint whether it's overdue and how often a reminder already went out. That's a concrete example of how far you get with jq alone: filter, conditions, formatting — all deterministic, all offline.
The rest of the script is of similar nature. The four functions are called one after another, the output lands in a Bash variable, a single curl sends the whole thing to the Telegram bot API.
What fell away
The focus sentence. The one part inference would actually have been suited for. I deleted that too.
The reason: with three appointments a day and a handful of due tasks, the most important thing of the day is mostly obvious. If I have an architecture workshop at 11:00, then that's the most important thing of the day — regardless of what a model recommends. If nothing is clearly important, then that too is information I can put together myself.
Inference is good when unclear data should become a clear recommendation. For a briefing that has the same shape daily and shows me four concrete sections, the clarity is already there. An additional recommendation sentence only lengthens the briefing.
Use-AI-where-it-helps
This isn't an anti-AI position. It's a position on where in a pipeline AI belongs.
Of course I wrote the Bash code with AI — the jq filter for the tasks, the date conversion with fromdateiso8601, the script scaffold. If it works, the only thing that really matters is that it doesn't shoot a security hole into the system for me. The rest matters to me only out of interest.
What really makes a difference, though: solving the deterministic with inference is slow and expensive — even if that's not so obvious with subscription models. Everywhere I don't want to engage much with the how and what, I can quickly drop AI in and pray it works. And often it does. But it doesn't take super-intelligence to see that it's more efficient to build a solution deterministically than to work out the solution path anew on every run.
AI is perfectly suited to working out the solution path. That's also where it belongs. The repetition is taken over by deterministic code.
CLIs and jq are simply good at manipulating structured data. Cron too. If I replace that layer with an LLM, I gain nothing and lose time, money, determinism.
The Office 365 part
For the whole briefing construct to work, I need CLI access to my Outlook calendar and my mailbox. Microsoft Graph API is the official interface for that — open, well documented. But the way there is an app-registration dance: open the Azure portal, register an app, copy the client ID, allow the public-client flow, add permissions, possibly obtain admin consent. At many companies that means an IT ticket and a few weeks of waiting.
I built a CLI that bypasses this dance: o365-cli. It uses OAuth2 Device Authorization Flow with a multi-tenant public client app. Every Office 365 user can log in themselves — no admin consent, no own app registration, no weeks of waiting.
You log in via apes login (or follow Microsoft OAuth directly), get a refresh token back, and can then run o365-cli mail list, o365-cli calendar today, o365-cli mail query --kql "...", o365-cli mail create-reply and more. Multi-account support is built in, because most devs in enterprise contexts have more than one identity anyway.
The tool is MIT-licensed, on GitHub, brew-installable:
brew install patrick-hofmann/tap/o365-cli
Or directly from source:
go install github.com/patrick-hofmann/o365-cli/cmd/o365-cli@latest
Closing
The briefing script today is 130 lines of Bash with a handful of jq pipelines. It calls two CLIs, formats their output, sends the result via curl to a Telegram bot. No OpenAI call. No token consumption. No hallucination possibility for a date.
Sometimes the right answer is to take away a tool you built in because it's trending. Not every morning briefing needs inference. Sometimes a cron, a CLI, a Telegram bot is enough.
Tools in the setup: o365-cli (MIT-licensed), @openape/ape-tasks (MIT-licensed). The briefing script itself lives in my private dotfiles collection — if you want a template, write me.