Skip to main content

Auto-generated daily briefing podcast: Claude + web search → script → Polly TTS → S3 + RSS

Project description

Morning Signal

CI Coverage Python 3.9+ License: MIT

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.md and 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 .env from 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, call sts:AssumeRole and 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 a 0600 file and pointed at by GOOGLE_APPLICATION_CREDENTIALS). The Telegram + GCP params are optional — absent params are skipped.
    • From S3 (content whose size can exceed the SSM cap): prompt.md and prompt_weekend.md, fetched from s3://{s3_bucket}/{prompts_s3_prefix}<file> (the bucket comes from config-yaml; prompts_s3_prefix defaults to prompts/). 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 (default us-east-1).

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 enginepolly (Amazon, uses AWS creds) or google (Chirp3 HD, e.g. the en-US-Chirp3-HD-Leda voice; needs pip install '.[google]' + GOOGLE_APPLICATION_CREDENTIALS)
  • TTS voice + playback speed (speed is a generation-time ffmpeg atempo multiplier)
  • claude_model + max_tokens + web_search_max_uses (per-episode search-fee ceiling)
  • S3 bucket + base URL (+ prompts_s3_prefix for 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)

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" in config.yaml if 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):

  1. Sign in at https://pypi.org/.
  2. Account → Publishing → "Add a new pending publisher".
  3. Fill in:
    • PyPI project name: morning-signal
    • Owner: cipher813
    • Repository name: morning-signal
    • Workflow filename: publish.yml
    • Environment name: pypi
  4. 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


Download files

Download the file for your platform. If you're not sure which to choose, learn more about installing packages.

Source Distribution

morning_signal-0.1.5.tar.gz (51.7 kB view details)

Uploaded Source

Built Distribution

If you're not sure about the file name format, learn more about wheel file names.

morning_signal-0.1.5-py3-none-any.whl (62.3 kB view details)

Uploaded Python 3

File details

Details for the file morning_signal-0.1.5.tar.gz.

File metadata

  • Download URL: morning_signal-0.1.5.tar.gz
  • Upload date:
  • Size: 51.7 kB
  • Tags: Source
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for morning_signal-0.1.5.tar.gz
Algorithm Hash digest
SHA256 162d174385033078d1074e8422365c091589a30147a7d7e1f53a15bdd3edee67
MD5 e6379e17a8b02c4c23b73bba7359fe5d
BLAKE2b-256 5b664bde0cd3aadc42401bf98cc91790212443134ac67947ab450d8499ab655c

See more details on using hashes here.

Provenance

The following attestation bundles were made for morning_signal-0.1.5.tar.gz:

Publisher: publish.yml on nousergon/morning-signal

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

File details

Details for the file morning_signal-0.1.5-py3-none-any.whl.

File metadata

  • Download URL: morning_signal-0.1.5-py3-none-any.whl
  • Upload date:
  • Size: 62.3 kB
  • Tags: Python 3
  • Uploaded using Trusted Publishing? Yes
  • Uploaded via: twine/6.1.0 CPython/3.13.12

File hashes

Hashes for morning_signal-0.1.5-py3-none-any.whl
Algorithm Hash digest
SHA256 3d2f3dc82c716e3654a5390d9b9fa7ee906eba3b2cbb97607166caee3e580ea1
MD5 6de3b5802a70eb3a10e4f8d14ea06505
BLAKE2b-256 0fff54850717bb33609b66a4c49589b99bc7ba8d5e9de9bf143cc4f5b93aaade

See more details on using hashes here.

Provenance

The following attestation bundles were made for morning_signal-0.1.5-py3-none-any.whl:

Publisher: publish.yml on nousergon/morning-signal

Attestations: Values shown here reflect the state when the release was signed and may no longer be current.

Supported by

AWS Cloud computing and Security Sponsor Datadog Monitoring Depot Continuous Integration Fastly CDN Google Download Analytics Pingdom Monitoring Sentry Error logging StatusPage Status page