# Morning Signal — Configuration # Copy to config.yaml and edit for your environment. # ── S3 / Hosting ──────────────────────────────────────────────────────────── # The S3 bucket where episodes + feed are published. # Set up with public read access or CloudFront (see README). s3_bucket: "your-bucket-name" s3_region: "us-west-2" s3_prefix: "" # optional subfolder, e.g. "podcast/" # Cloud-deploy only: S3 prefix the prompt files are loaded from when running # with MORNING_SIGNAL_USE_SSM=1. Local runs read prompt.md from disk and ignore this. prompts_s3_prefix: "prompts/" # The public base URL where your files are served. # If using CloudFront: https://d1234abcd.cloudfront.net # If using S3 static hosting: https://your-bucket-name.s3.us-west-2.amazonaws.com base_url: "https://your-bucket-name.s3.us-west-2.amazonaws.com" # ── Podcast Metadata (Apple Podcasts / RSS) ───────────────────────────────── podcast: title: "Morning Signal" description: "A sharp daily briefing on markets, tech, AI, and Seattle — auto-generated." author: "" email: "" # optional, for iTunes owner tag language: "en-us" category: "Business" subcategory: "Investing" explicit: false artwork: "artwork.jpg" # 3000x3000 recommended. Place in project root. # ── TTS ───────────────────────────────────────────────────────────────────── tts: engine: "polly" # "polly" (Amazon, uses AWS creds) or "google" (Chirp3 HD, uses GOOGLE_APPLICATION_CREDENTIALS) # Polly (engine: polly) polly_voice: "Ruth" # Ruth = British female, Matthew = US male, Joanna = US female polly_engine: "neural" # "neural" (best quality) or "standard" # Google Chirp3 HD (engine: google) — pip install '.[google]'; all Chirp3 voices same price (1M chars/mo free) google_voice: "en-US-Chirp3-HD-Leda" google_language: "en-US" google_max_sentence_chars: 400 # Chirp3 HD rejects any single sentence over its internal cap; we pre-split run-ons longer than this at clause boundaries (keep < ~500) speed: 1.0 # Playback speed multiplier; 1.0 = normal conversational pace, raise to 1.25–1.5 for fast brief # ── Schedule ──────────────────────────────────────────────────────────────── # Dates to skip ENTIRELY (no episode, either edition) — travel days, vacations. # ISO YYYY-MM-DD strings; malformed entries fail loud at startup. Distinct from # the NYSE trading calendar (weekends/holidays still ship a weekend AM): a skip # date produces nothing, and the freshness watchdog treats the absent episode # as expected instead of alerting. `generate --force` overrides for a one-off. # skip_dates: # - "2026-07-09" # - "2026-07-10" skip_dates: [] # Scheduled per-date content overrides (OPTIONAL ADD-ON, default OFF). # When enabled, a small schedule manifest JSON is read from S3 at generation # time; an entry for today's date can (a) "override" — devote the whole # episode to a scheduled deep-dive topic instead of regular programming, # (b) "extend" — add one extra segment on top of the regular lineup, or # (c) "skip" — produce no episode at all that day (the console-editable # counterpart of the skip_dates list above; both sources are honored, and # `generate --force` overrides either). Basic form is unaffected: with the # block absent or enabled=false there is ZERO behavior change — no S3 read, # no new dependencies, no required objects. Even when enabled, a missing # manifest is a silent no-op; any schedule READ failure fails soft (the # regular episode ships) with a WARN log + Telegram alert. Manifest schema: # docs/schedule-schema.json (schema_version 1). # schedule: # enabled: true # s3_bucket: "" # default: s3_bucket above # s3_key: "schedule/schedule.json" schedule: enabled: false # ── Generation ────────────────────────────────────────────────────────────── # Active model spec via the krepis provider-agnostic adapter. Format: # "provider:model" (or a krepis ModelSpec JSON object as a string). # When omitted, falls back to the legacy anthropic + claude_model pair below — # behavior-identical for pre-migration configs. # # Grounding depends on the transport: # - anthropic / openrouter — server-side web_search (native tool or # openrouter:web_search). Coverage guards + forced-search recovery apply # (force is anthropic-only; openrouter degrades to prose forcing). # - litellm (krepis router model groups: low/med/high/ultra) — no server- # side search. Grounding comes from the pre-fetched news_context digest # (enable news_context: below). The citation / required_search_topics # guards are skipped on this path rather than falsely failing. # # Production (dashboard box) uses the krepis ``high`` group via the local # LiteLLM proxy — litellm falls through the group's own chain # (deepseek-v4-pro-max → grok-4 → qwen3-max → openrouter deepseek-v4-pro). # Env override: MORNING_SIGNAL_LLM. # llm: '{"provider": "litellm", "model": "high"}' # llm: "anthropic:claude-sonnet-4-6" claude_model: "claude-sonnet-4-6" # legacy knob — feeds the default spec # Output-token budget. On the direct-anthropic path (no `llm:` router group # set above) this is the only budget there is, and 8192 is a sane default. # # On the krepis router path (`llm: '{"provider": "litellm", ...}'`), leave # this UNSET — omitting it lets krepis use the registry's own budget for # whatever model the group resolves to, which model-router-policy §2 makes # a registry-owned parameter. Setting it here OVERRIDES that row on every # call, including for a reasoning model, where max_tokens bounds reasoning # AND content from one shared pool — a budget sized for a non-reasoning # answer can starve content generation entirely before it starts # (alpha-engine-config-I6901). Uncomment only if you have measured that a # specific model's registry row is wrong for your workload. # max_tokens: 8192 # Hard cap on Anthropic ``web_search`` server-tool requests per episode. # Insurance against runaway loops — Anthropic bills $10 per 1,000 search # requests. Typical episodes use ~15 searches; 20 sits above that as a # ceiling, not a throttle. Omit to use the in-code default of 20. web_search_max_uses: 20 # Minimum script length, in spoken words, below which generation ABORTS the # attempt (alpha-engine-config-I11831). A near-empty or truncated script that # passes every other guard used to go straight to TTS/publish and was announced # with the same check mark as a full episode (2026-09-21: ~200 words aired as a # 53-second episode). Below the floor the attempt is treated like the tool-call # and grounding guards: the fallback tier runs instead of the short script # airing, and a short episode that somehow publishes is flagged in the success # ping. Default 500 (~2:15 spoken): 2.5x the 09-21 outlier, 0.66x the lowest # normal edition in the 30 days measured (~758 words). Either an int for every # edition, or a mapping keyed by edition (am / pm / weekend) with an optional # ``default``; 0 disables the floor for that edition. A malformed value falls # back to the default rather than disabling the guard. # min_script_words: 500 # min_script_words: {am: 500, pm: 500, weekend: 500} # Minimum web searches an edition must perform or generation ABORTS before # publish (fail-loud). A zero/low-search edition is almost always # model-confabulated rather than grounded in live news — this guard stops a # hallucinated episode from going live silently (2026-06-16 incident: a # pre-fetched news block told the model it could skip search, so it ran 0 # searches and fabricated the whole episode). Default 1. Set to 0 only if # your prompt legitimately needs no live web search. min_web_searches: 1 # Per-segment search guard (optional, default none). ``min_web_searches`` above # only asserts the edition was grounded *somewhere* — it does NOT guarantee a # *specific* search-critical segment was covered. The failure mode this catches # (2026-06-17): with a tight ``web_search_max_uses`` budget the model spends its # searches on the earlier segments and reaches a no-digest segment (one with no # pre-fetched fallback) with no budget left, then writes it from memory. Declare # a topic per such segment, listing keywords any of which a search query must # contain; if fewer than ``min_matches`` (default 1) searches hit a topic, the # topic is "uncovered". Default: none (no-op). # # When a topic is uncovered the DEFAULT is publish-anyway + alert: the episode # still ships (every other segment is grounded), and a flow-doctor/Telegram # notification names the uncovered topics for async triage. Set # ``required_search_topics_fatal: true`` to restore the legacy behaviour — # ABORT before publish — for deployments that would rather skip an edition than # ship one stale segment. (The global ``min_web_searches`` floor always hard- # aborts regardless: a near-zero-search edition is hallucinated wholesale.) # required_search_topics_fatal: false # # SELF-HEALING (default on): before the publish-or-abort decision, an uncovered # topic triggers ONE bounded recovery regeneration whose user message names the # dropped segment(s) + their keyword figures, FORCES a dedicated web search via # tool_choice (not just an in-prose ask), and requires a written segment for # each. The recovered draft is adopted only if it covers # strictly more; otherwise the original draft falls through to the policy above. # Recovery runs even in fatal mode (so a hard-abort fires only when the forced # retry still can't cover). Coverage is judged with the SCRIPT in hand — a topic # counts as covered only when it was both searched AND its segment actually # aired, so an unrelated segment's search can't falsely satisfy it. Set to false # to skip recovery and go straight to publish+alert / abort. # required_search_topics_recover: true # # ``editions`` (optional) scopes a topic to specific editions — useful when a # weekday edition has a segment (e.g. a political pulse) that the "weekend" # edition's prompt does not. Edition values are "am", "pm", and "weekend" # (non-trading days). A topic with an ``editions`` list is enforced ONLY for # those editions, so a weekday-only topic never falsely aborts the weekend pod. # Omit ``editions`` to enforce on every edition. Example for a political-pulse # segment sourced only from Truth Social / X, weekday editions only: # # required_search_topics: # - name: "Political pulse" # keywords: ["truth social", "trump", "maga"] # min_matches: 2 # editions: ["am", "pm"] # not "weekend" — the weekend prompt has no politics # - name: "Seattle local" # keywords: ["seattle", "cascades"] # min_matches: 1 # ── Pre-fetched News Context (optional, default OFF) ──────────────────────── # When enabled, morning-signal reads a pre-built news digest JSON from S3 and # injects it into the Claude prompt as a SUPPLEMENTARY starting reference — # leads for the model to verify. It does NOT suppress web search: the model is # still instructed to web-search every segment (and the min_web_searches guard # above enforces that it does). Leave disabled (the default) to keep the # standard web-search-only behavior — when off, this block has zero effect. # # The digest is a JSON object of the shape # {"schema_version": 1, "date": "...", "sections": {"": [, ...]}} # where each item has a `title` (required) plus optional `ticker`, `source`, # `published`, `excerpt`, `url`. # # `required` (default true when enabled) makes the digest a HARD prerequisite: # if it is missing, malformed, STALE (its `date` != the episode's date), or # empty, generation ABORTS before publish — no valid fresh digest, no pod. This # stops a soft-failed digest from silently degrading into a pod narrated without # the news it was meant to carry. Set `required: false` to fall back to the # fail-soft behavior (warn + generate with web search only). news_context: enabled: false required: true # missing/stale/empty digest → abort the pod (set false for fail-soft) s3_bucket: "" # bucket holding the digest JSON (required when enabled) s3_key: "data/news_digest_daily/latest.json" # object key for the digest (default shown) # The episode prompt is fully yours to customize: edit ``prompt.md`` (and # ``prompt_weekend.md`` for weekend/holiday editions). See the ``data/prompt-*`` # starters (markets-only, tech-only, generic-news, local-news, blank) for # ready-made variants to copy from. # Max episodes to keep in the RSS feed (older episodes remain in S3 but # drop off the feed to keep it lightweight). feed_max_episodes: 90 # ── Notifications (optional, via Telegram / flow-doctor) ─────────────────── # When enabled, sends a one-line success ping after each successful run # and a failure ping with traceback on any uncaught exception, both # routed via flow-doctor's TelegramNotifier to a single bot + chat # (optional forum topic). # # Setup (two minutes): # 1. Message @BotFather → /newbot → save the bot token. # 2. Add the bot to your chat (or create a fresh group / supergroup). # 3. Send the bot a message, then look up the chat_id at # https://api.telegram.org/bot/getUpdates and read # result[0].message.chat.id (negative integer for groups / # supergroups / channels). # 4. Set FLOW_DOCTOR_TELEGRAM_BOT_TOKEN + FLOW_DOCTOR_TELEGRAM_CHAT_ID # in the env, OR inline the values under telegram_* below. # # Forum supergroups: optional `telegram_message_thread_id` routes # notifications to a specific topic in the group — handy for fanning # am / pm / other-flow alerts through one chat without N bots. notifications: enabled: false telegram_bot_token: "" # or set FLOW_DOCTOR_TELEGRAM_BOT_TOKEN in env telegram_chat_id: "" # or set FLOW_DOCTOR_TELEGRAM_CHAT_ID in env telegram_message_thread_id: null # optional: forum-topic routing