Scheduled Reports skill for Codex
Recurring reports that send once, on the right day. Codex splits a recurring report into collect, render, deliver and schedule stages, computes the reporting window in the business time zone with half-open intervals, and previews to your own inbox before anyone else sees it. A send ledger stops duplicate emails after retries, failed sources abort instead of mailing zeros, and Slack posts use Block Kit with detail files in a thread.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- scheduled-reports Installs to ~/.agents/skills/scheduled-reports/ for every project on this machine.
curl -fsSL codexskills.dev/cli.mjs -o codex-skills.mjsnode codex-skills.mjs install scheduled-reports The CLI also searches, updates and removes skills. Run it with no arguments to pick from a list. Needs Node 20.12+.
curl -fsSL codexskills.dev/install.sh \ | sh -s -- --project scheduled-reports Installs to .agents/skills/scheduled-reports/ in the current repository. Commit it and everyone who runs Codex there gets it.
curl -fsSLO codexskills.dev/downloads/scheduled-reports.zipunzip -o scheduled-reports.zip -d ~/.agents/skills/ The archive holds the skill folder with its generated agents/openai.yaml. Unzip into .agents/skills/ to scope it to a repo.
& ([scriptblock]::Create((irm codexskills.dev/install.ps1))) scheduled-reports PowerShell 5.1 or 7. Installs to %USERPROFILE%\.agents\skills\. Add -Project for the current repo.
104-line POSIX script. No sudo, no telemetry. Read install.sh
Use it in Codex
Start a prompt with $scheduled-reports, or pick it from /skills.
You can also just describe the task: Codex reads the description below and loads the playbook when your request matches.
What Codex reads before loading it
Builds automated recurring reports - query, render and deliver daily or weekly metrics by email (SMTP, HTML plus plain text) or Slack (Block Kit, file uploads) on cron or GitHub Actions schedules - with explicit reporting windows and time zones, empty-data handling, a send ledger that prevents duplicates, and a preview step before the first real send. Use when the user wants a weekly KPI email, a daily Slack digest, a report that emails a CSV, or a scheduled report that sends twice, sends blanks or posts the wrong day's numbers.
Use it when
- Metrics should arrive by email or Slack on a fixed schedule
- A recurring report sends duplicates, blanks or the wrong period
- A manual weekly export-and-email routine should be automated
Use something else when
- You need an interactive dashboard rather than a pushed report: use bi-dashboards
- The email itself is product mail to customers: use transactional-email
- You only need the cron or scheduler setup: use cron-scheduling
How to check the result
Compare each metric with a hand-written query for the same past period, send one preview to yourself, and rerun the same period to confirm the ledger skips it.
The mistake it prevents
Computing 'yesterday' or 'last week' in UTC instead of the business time zone, and retrying a timed-out send that had actually gone out.
Done well means: the report covers an explicitly stated window in a stated time zone, the numbers match a manual query for the same window, it sends exactly once per period, and it says something useful when there is no data or a source fails instead of sending a blank or a stack trace.
Pipeline
Keep four stages separate so each can be tested alone:
collect (SQL/API -> plain dict) -> render (dict -> HTML/text/Block Kit)
-> deliver (email/Slack, with ledger) -> schedule (cron/Actions/cloud scheduler)
- [ ] 1. Read AGENTS.md: where jobs live, secret handling, existing mail/Slack helpers
- [ ] 2. Agree the content: metrics, definitions, window, time zone, recipients, channel
- [ ] 3. Write collect() returning a dict; test it against a known period
- [ ] 4. Write render() to HTML + text (email) or blocks (Slack); save previews to disk
- [ ] 5. Show the user the preview; send once to the user only (test recipient)
- [ ] 6. Get explicit approval for the real recipient list or channel
- [ ] 7. Schedule with a ledger and failure alerting
Sending email or posting to Slack is outward-facing: never send to the real audience
without the user’s confirmation, and keep a --to-me or --dry-run flag forever.
Reporting window
from datetime import datetime, timedelta
from zoneinfo import ZoneInfo
TZ = ZoneInfo("Europe/London")
def last_full_week(now: datetime | None = None) -> tuple[datetime, datetime]:
now = (now or datetime.now(TZ)).astimezone(TZ)
this_monday = (now - timedelta(days=now.weekday())).replace(
hour=0, minute=0, second=0, microsecond=0)
start = this_monday - timedelta(days=7)
return start, this_monday # half-open: start <= t < end
- Compute the window from the business time zone, then convert to UTC for queries. “Yesterday” in UTC is the wrong day for anyone west of London.
- Use half-open intervals (
>= start AND < end);BETWEENdouble-counts midnight. - Allow
--period 2026-10-20to regenerate any past report; the job must not depend on “now” except to pick the default period. - Put the window in the report header: “Week of 20-26 Oct 2026 (London time)”.
Rendering
Email: always send HTML with a plain-text alternative.
from email.message import EmailMessage
import smtplib, ssl
def send_email(subject: str, html: str, text: str, to: list[str],
attachment: tuple[str, bytes] | None = None) -> None:
msg = EmailMessage()
msg["Subject"], msg["From"], msg["To"] = subject, SENDER, ", ".join(to)
msg.set_content(text)
msg.add_alternative(html, subtype="html")
if attachment:
name, data = attachment
msg.add_attachment(data, maintype="text", subtype="csv", filename=name)
with smtplib.SMTP(SMTP_HOST, 587, timeout=30) as s:
s.starttls(context=ssl.create_default_context())
s.login(SMTP_USER, SMTP_PASSWORD) # from env/secret store, never in code
s.send_message(msg)
- Email clients ignore
<style>blocks and modern CSS: inline styles, tables for layout, 600 px wide. Charts go in as PNG attachments withContent-ID, or as links, never JS. - Prefer a transactional provider (SES, Postmark, SendGrid, Resend) over a personal mailbox for anything sent to more than a few people; SPF/DKIM must pass or it lands in spam.
- Show change versus the previous period with direction and absolute values (“Signups 1,240, up 8 % from 1,148”), not just percentages.
Slack: use the Web API with a bot token for anything beyond a single message.
from slack_sdk import WebClient
client = WebClient(token=os.environ["SLACK_BOT_TOKEN"])
resp = client.chat_postMessage(
channel=CHANNEL_ID,
text=f"Weekly KPIs {label}: signups {s:,}", # fallback for notifications
blocks=[
{"type": "header", "text": {"type": "plain_text", "text": f"Weekly KPIs, {label}"}},
{"type": "section", "fields": [
{"type": "mrkdwn", "text": f"*Signups*\n{s:,} ({ds:+.0%})"},
{"type": "mrkdwn", "text": f"*Revenue*\n£{rev:,.0f} ({dr:+.0%})"},
]},
],
)
client.files_upload_v2(channel=CHANNEL_ID, file="kpis.csv",
title="KPI detail", thread_ts=resp["ts"]) # detail in thread
files.uploadis retired;files_upload_v2uses the external upload flow. The bot must be a member of the channel and needschat:writeandfiles:write.- Block Kit caps: 50 blocks per message, 3,000 characters per section text, 10 fields per section. Long tables belong in a CSV attachment or a thread.
- Incoming webhooks are fine for a single fixed channel and no files; they cannot upload or thread replies.
Delivery ledger (exactly once per period)
CREATE TABLE report_sends (
report text NOT NULL,
period text NOT NULL, -- e.g. '2026-W43'
sent_at timestamptz NOT NULL DEFAULT now(),
message_id text,
PRIMARY KEY (report, period)
);
Insert the row in a “sending” state before delivery, then mark it sent with the message ID.
On rerun, skip periods already sent unless --resend is given. Retries after a timeout
are where duplicate emails come from: a timeout does not mean the message was not sent.
Without a database, a JSON ledger in durable storage (bucket, repo artifact) works for a
single job.
Scheduling
| Runner | Notes |
|---|---|
GitHub Actions schedule | Cron is UTC, can start minutes late under load, runs only on the default branch, and is disabled after 60 days without repo activity in public repos |
| Server cron / systemd timer | Set CRON_TZ or use a timer with OnCalendar=Mon *-*-* 08:00 Europe/London |
| Cloud scheduler (EventBridge, Cloud Scheduler) | Time-zone aware schedules; pair with a function or container job |
on:
schedule:
- cron: "15 7 * * 1" # 07:15 UTC Monday; choose a minute other than :00 to dodge load
workflow_dispatch:
inputs:
period: { description: "ISO week, e.g. 2026-W43", required: false }
concurrency: { group: weekly-kpi, cancel-in-progress: false }
DST shifts a UTC cron by an hour relative to local time twice a year. If the send time matters locally, schedule hourly and let the job decide whether it is the right local hour, guarded by the ledger.
Empty data and failures
- Zero rows is a result, not an error: send “No orders in this window” only if the user wants that; otherwise skip and log.
- A failed source query must not produce a report full of zeros. Abort the send, alert the owner (separate channel), and leave the ledger unsent so the next run retries.
- Sanity-check numbers against bounds (e.g. revenue within 50 to 200 % of the trailing average) and flag outliers in the report rather than hiding them.
Verification
- Run
collect()for a past period and compare each metric with a hand-written query. - Open the saved HTML preview in a browser, and send one test to the user’s own address or a private Slack channel.
- Run the job twice for the same period: the second run skips with “already sent”.
- Trigger
workflow_dispatchonce and check the run log and delivery.
Handover
**Report:** Weekly KPIs, Mondays 08:15 London (07:15 UTC cron, DST-guarded)
**Window:** previous Monday 00:00 to Monday 00:00 London, half-open
**Delivery:** #leadership (bot "kpi-bot"), CSV detail in thread
**Rerun:** `python -m reports.weekly --period 2026-W43 --to-me` (preview) / `--resend`
**Ledger:** table report_sends; failures alert #data-alerts What the zip contains
SKILL.md plus the interface file below. Nothing runs on install.
agents/openai.yaml
Gives the Codex app a display name, a one-line description and a starter prompt for this skill.
interface:
display_name: "Scheduled Reports"
short_description: "Recurring reports that send once, on the right day"
default_prompt: "$scheduled-reports Email the leadership team a weekly KPI summary every Monday at 8am London time" About this skill
Written and maintained by the Codex Skills editors to the same editorial standard as every skill here: a selective description, a real workflow, known pitfalls and a way to verify the work. First published , last updated . MIT licensed. Something wrong or out of date? Tell us.