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 at 5 AM (and optionally 5 PM) Pacific, Claude with web search writes the script, Amazon Polly converts it to audio, and the MP3 + RSS feed publish to S3. Subscribe in any podcast app — episodes just show up on your phone.
How it works
Scheduler (systemd timer / cron / launchd)
│
├─ 1. Load prompt + config (from local files OR SSM Parameter Store)
├─ 2. Call Claude with web search → ~2,000-word script
├─ 3. Call Amazon Polly → synthesize speech, ffmpeg speed-adjust
├─ 4. Upload MP3 + regenerate RSS feed → S3
├─ 5. Email success/failure notification (optional, via SES)
│
└─ 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 config + prompt + secrets from AWS SSM Parameter Store, assumes a dedicated IAM role for Polly + S3 + SSM + SES. Survives laptop sleep, supports DST-aware scheduling, and surfaces failures by email.
Project structure
morning-signal/
├── generate_episode.py Main script — generates and publishes one episode
├── feed.py RSS feed builder (Apple-compatible)
├── config.yaml.example Configuration template
├── prompt.md YOUR PODCAST — segments, sources, tone, length cap
├── run.sh Local-dev launcher (sources .env + venv → python)
├── pyproject.toml Build + dependency manifest (single source of truth)
├── artwork.jpg Podcast cover art (3000×3000 recommended)
├── tests/ pytest suite (run via `pytest --cov`)
├── episodes/ Generated MP3s + metadata JSON (gitignored)
├── scripts/ Generated transcripts (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
$EDITOR prompt.md # set segments, tickers, sources
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 Polly, 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— fetchconfig.yaml,prompt.md, andANTHROPIC_API_KEYfrom AWS SSM Parameter Store paths/morning-signal/config-yaml,/morning-signal/prompt-md,/morning-signal/anthropic-api-key(SecureString). Override the region withMORNING_SIGNAL_SSM_REGION(defaultus-east-1).
If neither is set, the script behaves as the local CLI — reads 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 + 5 PM Pacific (DST-aware)
[Timer]
Unit=morning-signal.service
OnCalendar=*-*-* 05:00:00 America/Los_Angeles
OnCalendar=*-*-* 17: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.
Two editions per day
When the --edition flag is unset, it's inferred from the Pacific clock (am if local hour < 12, else pm). Filenames carry the suffix: 2026-05-14-am.mp3, 2026-05-14-pm.mp3. Each edition is told via the prompt to cover only news that has broken since the prior edition (~12-hour window), avoiding duplicated content.
To run one edition daily, just omit the second OnCalendar line in the timer.
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 / Polly call), republish to S3
python generate_episode.py --publish-only
Customizing your podcast
Everything is controlled by two files:
prompt.md — Content + segments
This is the production prompt sent to Claude. Edit freely:
- Add / remove / reorder segments
- Pin specific sources, tickers, or themes
- Tune the word-count cap (the supplied prompt targets ~2,000 words ≈ 9 min audio at 1.5× playback)
- Adjust the news-window instruction if you want one or two editions
config.yaml — Infrastructure + metadata
- TTS voice + engine + playback speed
- S3 bucket + base URL
- Podcast title / description / category
- Max episodes in the feed
- SES notification recipients (optional)
Cost
For two editions per day (5 AM + 5 PM Pacific):
| Component | Per episode | Monthly (60 episodes) |
|---|---|---|
| Claude Sonnet + web search | ~$0.03 | ~$1.80 |
| Amazon Polly neural (~10 KB chars) | ~$0.04 | ~$2.40 |
| S3 storage + transfer | ~$0.01 | ~$0.30 |
| Total | ~$0.08 | ~$4.50 |
One edition per day is half that. 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%+.
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.
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 any push of a tag matching v*.*.*. It builds an sdist + wheel, validates them with twine check, then publishes to PyPI via OIDC trusted publishing (no API token in repo secrets).
One-time PyPI setup (do this once on PyPI's web UI before tagging v0.1.0):
- 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.0")
# 2. Update CHANGELOG.md (move Unreleased entries into a dated version section)
# 3. Commit + push the version bump to main
# 4. Tag + push the tag — the workflow takes it from there
git tag v0.1.0
git push origin v0.1.0
Within ~2 minutes the package appears at https://pypi.org/project/morning-signal/ and pip install morning-signal works for anyone.
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.1.1rc5.tar.gz.
File metadata
- Download URL: morning_signal-0.1.1rc5.tar.gz
- Upload date:
- Size: 29.0 kB
- Tags: Source
- Uploaded using Trusted Publishing? Yes
- Uploaded via: twine/6.1.0 CPython/3.13.12
File hashes
| Algorithm | Hash digest | |
|---|---|---|
| SHA256 |
1323bc5d63593b4ac7440782c7a86569de477b8725fb4d763e18c67a92070cff
|
|
| MD5 |
dd819d5fa280ac1bf4a6a66d8fe2b2e6
|
|
| BLAKE2b-256 |
6656cc2dbd0ee3bd6d2d1a999d3efa75026ea891fd4057ec8b3a3f59ae774ba0
|
Provenance
The following attestation bundles were made for morning_signal-0.1.1rc5.tar.gz:
Publisher:
publish.yml on cipher813/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.1.1rc5.tar.gz -
Subject digest:
1323bc5d63593b4ac7440782c7a86569de477b8725fb4d763e18c67a92070cff - Sigstore transparency entry: 1525428945
- Sigstore integration time:
-
Permalink:
cipher813/morning-signal@f0bf77a5b3b04a619d3963aab1e4eec10840b56e -
Branch / Tag:
refs/tags/v0.1.1rc5 - Owner: https://github.com/cipher813
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f0bf77a5b3b04a619d3963aab1e4eec10840b56e -
Trigger Event:
push
-
Statement type:
File details
Details for the file morning_signal-0.1.1rc5-py3-none-any.whl.
File metadata
- Download URL: morning_signal-0.1.1rc5-py3-none-any.whl
- Upload date:
- Size: 38.0 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 |
b7539846763d5e53848e65d3fe0d5fb4812fed1b4a2f533e2fd3e9232698d003
|
|
| MD5 |
5a4eddd5da63d8474d49dab3593d0035
|
|
| BLAKE2b-256 |
d678544d0e5d22982b2307c6f617d35fe45c98174ccf2d69ee84c604907edcfa
|
Provenance
The following attestation bundles were made for morning_signal-0.1.1rc5-py3-none-any.whl:
Publisher:
publish.yml on cipher813/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.1.1rc5-py3-none-any.whl -
Subject digest:
b7539846763d5e53848e65d3fe0d5fb4812fed1b4a2f533e2fd3e9232698d003 - Sigstore transparency entry: 1525428979
- Sigstore integration time:
-
Permalink:
cipher813/morning-signal@f0bf77a5b3b04a619d3963aab1e4eec10840b56e -
Branch / Tag:
refs/tags/v0.1.1rc5 - Owner: https://github.com/cipher813
-
Access:
public
-
Token Issuer:
https://token.actions.githubusercontent.com -
Runner Environment:
github-hosted -
Publication workflow:
publish.yml@f0bf77a5b3b04a619d3963aab1e4eec10840b56e -
Trigger Event:
push
-
Statement type: