Auto-generated daily briefing podcast: Claude + web search → script → Polly TTS → S3 + RSS
Project description
Morning Signal
Auto-generated daily briefing podcast. A scheduler fires once daily at 5 AM Pacific, Claude with web search writes the script, a TTS engine (Amazon Polly or Google Chirp3 HD) converts it to audio, and the MP3 + RSS feed publish to S3. Subscribe in any podcast app — episodes just show up on your phone.
Open-source and self-hostable. You run it on your own keys — no morning-signal account, no platform lock-in. Because it publishes a standard RSS feed, it plays in whatever podcast app you already use (Apple Podcasts, Overcast, Pocket Casts…), which gives you playback speed, offline download, and CarPlay for free. New to the codebase? See ARCHITECTURE.md; want to contribute? See CONTRIBUTING.md.
Requirements
To run your own briefing today you need:
- Python 3.9+ and ffmpeg (audio concat + speed adjust).
- An Anthropic API key — Claude writes the script. Always required.
- A text-to-speech backend — one of: an AWS account (Amazon Polly, the default) or a Google Cloud account (Chirp3 HD via
pip install '.[google]'). - A place to publish the MP3 + RSS feed — currently an AWS S3 bucket (or anything that serves the same
feed.xml+episodes/over HTTP).
No fully-offline mode yet. TTS and publishing both currently require a cloud account (AWS or GCP). A local/offline TTS engine and a zero-cloud output backend are the most-wanted contributions — see
CONTRIBUTING.mdand the open issues. The engine seams (tts.synthesize(), the publish step) are designed to make these drop-in.
How it works
Scheduler (systemd timer / cron / launchd)
│
├─ 1. Load prompt + config (local files; OR config/secrets from SSM + prompts from S3)
├─ 2. Call Claude with web search → script (one call, driven by your prompt)
├─ 3. Call TTS engine (Polly or Google Chirp3 HD) → synthesize speech, ffmpeg speed-adjust
├─ 4. Upload MP3 + regenerate RSS feed → S3
├─ 5. Send success/failure notification (optional, via Telegram)
│
└─ Episode appears in your podcast app within minutes
Two production deployment styles are supported:
- Local CLI (Mac/Linux dev) — reads
config.yaml,prompt.md, and.envfrom disk. Schedule with cron or launchd. - Cloud deploy — runs on a long-lived EC2 instance under systemd, reads small structured config + secrets from AWS SSM Parameter Store and the (larger) prompt files from S3, and assumes a dedicated IAM role for TTS + S3 + SSM. Survives laptop sleep, supports DST-aware scheduling, and surfaces failures over Telegram.
Project structure
morning-signal/
├── src/morning_signal/ The engine — episode, claude, tts, feed, aws, publish, notify, cli, … (see ARCHITECTURE.md)
├── generate_episode.py Entry-point shim → morning_signal.cli (kept so existing systemd/launchd units keep working)
├── prompt.example.md Example prompt — copy to prompt.md and customize
├── config.yaml.example Configuration template — copy to config.yaml
├── artwork.jpg Podcast cover art (3000×3000 recommended)
├── pyproject.toml Build + dependency manifest (single source of truth)
├── ARCHITECTURE.md Pipeline map + module guide + extension seams
├── CONTRIBUTING.md Dev setup + how to contribute (CODE_OF_CONDUCT.md, SECURITY.md alongside)
├── analyze_searches.py Summarize web_search telemetry (top queries + domains)
├── tests/ pytest suite (run via `pytest --cov`)
├── prompt.md / prompt_weekend.md YOUR prompts (gitignored — start from prompt.example.md)
├── episodes/ Generated MP3s + metadata JSON (gitignored)
└── feed.xml Generated RSS (gitignored; also lives on S3)
Quick start (local CLI)
1. Install
git clone https://github.com/cipher813/morning-signal.git && cd morning-signal
python3 -m venv .venv && .venv/bin/pip install -e .
2. Configure
cp config.yaml.example config.yaml
$EDITOR config.yaml # set s3_bucket + base_url + podcast metadata
cp prompt.example.md prompt.md
$EDITOR prompt.md # set your segments, sources, and style (prompt.md is gitignored)
echo 'ANTHROPIC_API_KEY=sk-ant-...' > .env
3. Create the S3 bucket
BUCKET=morning-signal-podcast # or your name
REGION=us-west-2
aws s3 mb "s3://$BUCKET" --region "$REGION"
aws s3api put-public-access-block --bucket "$BUCKET" \
--public-access-block-configuration BlockPublicAcls=false,IgnorePublicAcls=false,BlockPublicPolicy=false,RestrictPublicBuckets=false
aws s3api put-bucket-policy --bucket "$BUCKET" --policy "$(cat <<EOF
{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":"*","Action":"s3:GetObject","Resource":"arn:aws:s3:::$BUCKET/*"}]}
EOF
)"
The bucket must be publicly readable so podcast apps can fetch the episodes.
4. Verify
.venv/bin/python generate_episode.py --script-only # Claude only, no TTS, no upload
.venv/bin/python generate_episode.py --no-publish # Add TTS, no upload
.venv/bin/python generate_episode.py # Full pipeline
5. Subscribe in Apple Podcasts (or any podcast app)
Once the first episode publishes successfully:
- Apple Podcasts: Library → ··· → "Follow a Show by URL…" → paste your feed URL
- Overcast / Pocket Casts: "Add URL" → paste
Your feed URL is <base_url>/feed.xml.
Cloud deploy (recommended for reliability)
The local CLI is fine for testing, but a laptop that sleeps at 5 AM won't run the cron. For dependable daily delivery, deploy on a long-lived host with systemd.
The pipeline supports two environment-variable knobs that turn on production behavior:
MORNING_SIGNAL_RUNNER_ROLE_ARN=<role-arn>— at startup, callsts:AssumeRoleand use that role's credentials for all subsequent boto3 clients. Lets you keep secrets/perms scoped to a dedicated runtime identity instead of the host's instance profile.MORNING_SIGNAL_USE_SSM=1— bootstrap config + secrets from SSM and prompts from S3:- From SSM Parameter Store (small, structured, secret):
/morning-signal/config-yaml,/morning-signal/anthropic-api-key(SecureString), and — when set —/morning-signal/flow-doctor-telegram-bot-token,/morning-signal/flow-doctor-telegram-chat-id, and/morning-signal/gcp-tts-key(the Google Chirp3 HD service-account JSON, materialized to a0600file and pointed at byGOOGLE_APPLICATION_CREDENTIALS). The Telegram + GCP params are optional — absent params are skipped. - From S3 (content whose size can exceed the SSM cap):
prompt.mdandprompt_weekend.md, fetched froms3://{s3_bucket}/{prompts_s3_prefix}<file>(the bucket comes fromconfig-yaml;prompts_s3_prefixdefaults toprompts/). The weekday prompt is required (boot fails loud if missing); the weekend prompt is optional (falls back to the weekday prompt). Prompts live in S3 rather than SSM because SSM Advanced-tier parameters cap at 8,192 chars and a customized prompt can exceed that. - Override the SSM region with
MORNING_SIGNAL_SSM_REGION(defaultus-east-1).
- From SSM Parameter Store (small, structured, secret):
If neither is set, the script behaves as the local CLI — reads config + all prompts from disk, uses the default boto3 credential chain.
A representative systemd unit:
# /etc/systemd/system/morning-signal.service
[Unit]
Description=Morning Signal podcast generator
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
User=ec2-user
WorkingDirectory=/home/ec2-user/morning-signal
Environment="MORNING_SIGNAL_RUNNER_ROLE_ARN=arn:aws:iam::ACCOUNT_ID:role/morning-signal-runner-role"
Environment="MORNING_SIGNAL_USE_SSM=1"
Environment="MORNING_SIGNAL_SSM_REGION=us-east-1"
ExecStart=/home/ec2-user/morning-signal/.venv/bin/python generate_episode.py
TimeoutStartSec=600
PrivateTmp=true
# /etc/systemd/system/morning-signal.timer
[Unit]
Description=Morning Signal — 5 AM Pacific (DST-aware)
[Timer]
Unit=morning-signal.service
OnCalendar=*-*-* 05:00:00 America/Los_Angeles
Persistent=true
[Install]
WantedBy=timers.target
Persistent=true catches missed firings — e.g., if the host was rebooting at the calendar moment, the run fires when the host comes back up. America/Los_Angeles automatically tracks PDT/PST.
One edition per day
The production deployment fires once daily at 5 AM Pacific. When the --edition flag is unset, it's inferred from the Pacific clock (am if local hour < 12, else pm), and the episode date is likewise stamped on the Pacific clock. Filenames carry the suffix: 2026-05-14-am.mp3.
A second evening edition is still supported by the code: add a OnCalendar=*-*-* 17:00:00 America/Los_Angeles line to the timer and the 5 PM firing will produce a pm edition (each edition is prompted to cover only news that has broken since the prior one). The PM path no-ops cleanly on weekends and NYSE holidays. The cipher813 production runs a single 5 AM edition for lower cost and complexity.
CLI reference
# Default: generate today's edition + publish (edition inferred from clock)
python generate_episode.py
# Specific edition / date
python generate_episode.py --edition pm
python generate_episode.py --date 2026-05-13 --edition am
# Re-generate an episode that already exists (overrides front-door dedup)
python generate_episode.py --force
# Script only — free; no TTS, no upload
python generate_episode.py --script-only
# Generate locally, skip S3
python generate_episode.py --no-publish
# Rebuild feed only (no Claude / TTS call), republish to S3
python generate_episode.py --publish-only
Customizing your podcast
Everything is controlled by two files:
prompt.md + prompt_weekend.md — Content + segments
These are the production prompts sent to Claude. prompt.md drives the
weekday MORNING + EVENING editions; prompt_weekend.md drives the
Saturday / Sunday / NYSE-holiday AM "deep-dive" edition (the weekend
PM cron fire is skipped — episode.main() no-ops cleanly).
Edit freely:
- Add / remove / reorder segments
- Pin specific sources, tickers, or themes
- Tune the word-count cap (weekday targets ~2,000 words ≈ 9 min audio at 1.5× playback; weekend ~3,000 words ≈ 13 min)
- Adjust the news-window instruction if you want one or two editions
For the cipher813 deployment specifically, both prompt files are
canonical-sourced from the private alpha-engine-config repo at
apps/morning-signal/prompts/ (with git history, PR review, and a
sync.sh that pushes edits to SSM + the local dev cache in one step).
The local prompt*.md files in this repo are gitignored proprietary IP
and treated as a derived cache of the canonical source. Fresh public-repo
clones get the example prompts via morning-signal init and edit them
directly; there's no requirement to use a separate canonical-source repo
unless you want PR-reviewed prompt changes.
config.yaml — Infrastructure + metadata
- TTS engine —
polly(Amazon, uses AWS creds) orgoogle(Chirp3 HD, e.g. theen-US-Chirp3-HD-Ledavoice; needspip install '.[google]'+GOOGLE_APPLICATION_CREDENTIALS) - TTS voice + playback speed (
speedis a generation-time ffmpegatempomultiplier) claude_model+max_tokens+web_search_max_uses(per-episode search-fee ceiling)- S3 bucket + base URL (+
prompts_s3_prefixfor the SSM/S3 production path) - Podcast title / description / category
feed_max_episodes— max episodes kept in the RSS feed- Generation-mode knobs — see below
- Telegram notification creds (optional)
skip_dates— dates to skip entirely (travel/vacation): clean no-op for both editions, and the watchdog treats the absent episode as expectedschedule— optional add-on (default OFF): a per-date schedule manifest read from S3 that can devote an episode to a scheduled deep-dive topic (override), add an extra segment (extend), or skip the day (skip). Disabled = zero behavior change; any schedule read failure fails soft to the regular episode. Manifest schema:docs/schedule-schema.json
Customizing the script
The episode content is entirely driven by your prompt — there's no fixed topic
list or built-in format. Edit prompt.md (and prompt_weekend.md for
weekend/holiday editions) to define the voice, sections, length, and subject
matter you want. The src/morning_signal/data/prompt-* starters
(markets-only, tech-only, generic-news, local-news, blank) are ready-made
variants to copy from. Whatever your prompt asks for, the engine makes a single
Claude call with web search and renders the result to audio.
Cost
Claude + web search dominates the per-episode cost, and it scales with how many web searches the model runs (Anthropic bills web-search result content as cache-create tokens). Rough per-episode figures from production telemetry:
| Component | Claude Sonnet | Claude Haiku |
|---|---|---|
| Claude + web search | ~$0.50–0.65 | ~$0.12 |
| TTS (Polly neural, ~10 KB chars; Google Chirp3 HD has a 1M-char/mo free tier) | ~$0.04 | ~$0.04 |
| S3 storage + transfer | ~$0.01 | ~$0.01 |
| Total | ~$0.55–0.70 | ~$0.17 |
The biggest lever is the model choice (claude_model) and the per-episode
search ceiling (web_search_max_uses) — not the TTS engine. Run analyze_searches.py over a few days of
episodes/*.searches.jsonl telemetry to find frequently-repeated queries worth
replacing with curated sources. Add an always-on EC2 t3.micro (~$8/month) if you
don't already have a host; serverless options (Lambda + EventBridge, Fly
scheduled Machine) come in cheaper but require a container image because ffmpeg
is needed for the speed adjustment.
Tests
.venv/bin/pip install -e .[dev]
.venv/bin/pytest --cov
The suite uses moto for boto3 mocking and an inline anthropic mock — no real API calls. Coverage target: 80%+.
Contributing
Contributions are welcome — see CONTRIBUTING.md for dev setup and ARCHITECTURE.md for how the pipeline fits together. The most natural place to extend is a new TTS engine: tts.synthesize() is a clean dispatcher, so adding one alongside Polly and Google is a small, self-contained change. Bug reports and PRs that reproduce on a fresh pip install get prioritized. By participating you agree to the Code of Conduct; security issues go through SECURITY.md.
Alpha disclaimer
v0.1.x is an alpha release. The CLI surface, config schema, and SSM/IAM hooks may change in breaking ways before v1.0.0. Pin to a specific version (pip install morning-signal==0.1.0) if you're depending on a stable interface; otherwise expect to read the CHANGELOG when bumping.
Troubleshooting
Episodes not appearing in podcast app
- Verify the feed URL returns HTTP 200:
curl -I <feed_url> - Check the bucket policy allows public reads on
s3:GetObject - Apple Podcasts can take 10–15 minutes to poll a new feed; Overcast / Pocket Casts are usually faster
TTS chunking artifacts (slight pauses mid-script)
- Polly's neural engine has a 3000-char per-request limit; the script chunks at sentence boundaries and concatenates. Try a different voice or
polly_engine: "standard"inconfig.yamlif it bothers you, or switch to the Google Chirp3 HD engine (tts.engine: "google").
Re-publish everything
python generate_episode.py --publish-only
Skip a dedup
python generate_episode.py --force
Releasing to PyPI
The .github/workflows/publish.yml workflow runs on every push to main. It builds an sdist + wheel, validates them with twine check, then publishes to PyPI via OIDC trusted publishing (no API token in repo secrets), using skip-existing so the publish is idempotent — a version bump auto-publishes on merge, and an unchanged version is a no-op. (This replaced the old tag-triggered flow, which silently lapsed when tags weren't cut.)
One-time PyPI setup (do this once on PyPI's web UI before the first publish):
- Sign in at https://pypi.org/.
- Account → Publishing → "Add a new pending publisher".
- Fill in:
- PyPI project name:
morning-signal - Owner:
cipher813 - Repository name:
morning-signal - Workflow filename:
publish.yml - Environment name:
pypi
- PyPI project name:
- Save.
Cutting a release:
# 1. Bump __version__ in src/morning_signal/__init__.py (e.g., "0.1.1")
# 2. Update CHANGELOG.md (move Unreleased entries into a dated version section)
# 3. Open a PR; merging it to main triggers the publish
Within ~2 minutes of the merge the package appears at https://pypi.org/project/morning-signal/ and pip install morning-signal works for anyone. No tag step is required.
License
MIT — see LICENSE.
Project details
Release history Release notifications | RSS feed
Download files
Download the file for your platform. If you're not sure which to choose, learn more about installing packages.
Source Distribution
Built Distribution
Filter files by name, interpreter, ABI, and platform.
If you're not sure about the file name format, learn more about wheel file names.
Copy a direct link to the current filters
File details
Details for the file morning_signal-0.2.0.tar.gz.
File metadata
- Download URL: morning_signal-0.2.0.tar.gz
- Upload date:
- Size: 66.5 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
511d129c0655491d2649182b3e9084554d9208b82c84e28f520bb74206eb7c49
|
|
| MD5 |
f192dee1bfb8f94648003b37a1fb9a0e
|
|
| BLAKE2b-256 |
a2bb3832216a6c2c1d1fde243f66f51d12c415a20b9fea51a6a7260a9d523bbc
|
Provenance
The following attestation bundles were made for morning_signal-0.2.0.tar.gz:
Publisher:
publish.yml on nousergon/morning-signal
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
morning_signal-0.2.0.tar.gz -
Subject digest:
511d129c0655491d2649182b3e9084554d9208b82c84e28f520bb74206eb7c49 - Sigstore transparency entry: 2063075902
- Sigstore integration time:
-
Permalink:
nousergon/morning-signal@c38f6a3654f6053e3556e5adbbfffe2216c635f7 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/nousergon
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c38f6a3654f6053e3556e5adbbfffe2216c635f7 -
Trigger Event:
push
-
Statement type:
File details
Details for the file morning_signal-0.2.0-py3-none-any.whl.
File metadata
- Download URL: morning_signal-0.2.0-py3-none-any.whl
- Upload date:
- Size: 79.3 kB
- Tags: Python 3
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
b1d8825f08c3212faed2d41a84fa28ad853b1d4f65b259f63c16b48aa65e8c88
|
|
| MD5 |
94cbf1c8a391bca2c75d99e8e090f457
|
|
| BLAKE2b-256 |
eb17918a0cf838ed25650fd899d5824e26218c68522f42e12a38c76959ed0fff
|
Provenance
The following attestation bundles were made for morning_signal-0.2.0-py3-none-any.whl:
Publisher:
publish.yml on nousergon/morning-signal
-
Statement:
-
Statement type:
https://in-toto.io/Statement/v1 -
Predicate type:
https://docs.pypi.org/attestations/publish/v1 -
Subject name:
morning_signal-0.2.0-py3-none-any.whl -
Subject digest:
b1d8825f08c3212faed2d41a84fa28ad853b1d4f65b259f63c16b48aa65e8c88 - Sigstore transparency entry: 2063075924
- Sigstore integration time:
-
Permalink:
nousergon/morning-signal@c38f6a3654f6053e3556e5adbbfffe2216c635f7 -
Branch / Tag:
refs/heads/main - Owner: https://github.com/nousergon
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@c38f6a3654f6053e3556e5adbbfffe2216c635f7 -
Trigger Event:
push
-
Statement type: