Engineering reference: the newsletter composer architecture
Same system, drawn for engineers. Region, service names, resource identifiers, Bedrock model IDs, Lambda inventory, IAM scopes, the SES inbound rule set, EventBridge Scheduler config, the DynamoDB schemas, and the Slack interactive flow. Read alongside the previous six posts; this one’s the build sheet.
Region and account shape
Default region: ap-southeast-1 (Singapore). SES inbound and outbound, Bedrock Global cross-Region inference, and EventBridge Scheduler are all in good shape there. A second region for multi-region resilience isn’t worth the extra setup at SMB volume — the failure mode for an SMB is one weekly issue going out a day late, not a regional outage. One AWS account dedicated to the composer (separate from your other workloads) keeps the IAM blast radius small and lets a single AWS Budgets alarm cover the whole system.
Topology
Lambda functions
All Lambdas use the arm64 architecture, the smallest memory size that meets latency targets (typically 256 MB), Python 3.14 runtime, and CloudWatch Logs at 7-day retention. Each function has its own least-privilege IAM role. None run inside a VPC.
drive-sync— EventBridge Scheduler target, fires every 15 minutes. Uses the Google Drive API + Sheets API (service-account credentials in Secrets Manager undernc/drive/sa) to export the item-pool sheet as CSV and write tos3://nc-items-source/items.csvonly if the sheet has changed since the last sync. Same pattern syncs the voice and rules docs tos3://nc-rules-source/. Memory: 256 MB. Timeout: 30 s.feed-sync— EventBridge Scheduler target, hourly. Fetches the configured blog RSS/Atom feed, diffs entry ids against thenc-feed-seenDynamoDB table, and for each new entry creates a Slack interactive proposal (title, link, summary). For lower latency you can switch to a WebSub/PubSubHubbub subscription that pushes to a Function URL instead of polling, at the cost of managing the subscription lease. Memory: 256 MB. Timeout: 30 s.intake-ses-parser— S3 PUT trigger ons3://nc-raw-mime/. Parses MIME, extracts the text body (and any forwarded-quote text), and calls Bedrock Haiku 4.5 (anthropic.claude-haiku-4-5-20251001-v1:0viaglobal.anthropic.claude-haiku-4-5-20251001-v1:0) to tidy the update into a proposed item (title, one-line note, category, link). Posts the proposal to Slack via the bot token with Approve/Edit/Discard buttons. Memory: 512 MB. Timeout: 30 s. No Sonnet calls.composer— EventBridge Scheduler target, weekly the morning before the send day (the schedule runs inTZ_NAMEset to the SMB’s timezone, e.g.Asia/Singapore). Readss3://nc-items-source/items.csv, the voice and rules docs, andnc-items-stateto determine fresh items. If the fresh count is below threshold, emitsnc.skipped. Otherwise calls Bedrock Sonnet 4.6 (anthropic.claude-sonnet-4-6-20250115-v1:0via the Global cross-Region profile) once to draft the issue grounded in the items, runs the self-check, stores the draft tos3://nc-drafts/<issue-id>.json, and emitsnc.ready. Memory: 1024 MB. Timeout: 120 s.sender— EventBridge rule onnc.ready. Resolves the reviewer, checks quiet hours, runs the grounding check (attaches the source item behind each paragraph, flags any unsourced line), formats the review message from the draft, and ships via Slackchat.postMessage(nc/slack/bot-tokenin Secrets Manager) or SESSendRawEmail. On a quiet-hours defer, creates a one-off EventBridge Scheduler rule that re-invokessenderat the next business minute. Writes a row tonc-issuesmarking the issue awaiting review. Memory: 256 MB. Timeout: 30 s. No Bedrock calls.action-handler— Lambda Function URL, public withAuthType: NONE; verifies a Slack signature on the request body. Triggered by Slack interactive button clicks (Approve/Edit/Skip, plus Pull-it-back) and by email-link clicks. Writes tonc-issuesandnc-audit. On edit, copies the draft to a Google Doc and replies with the link; on re-submit, re-runs the grounding check. On approve, creates a cooling-off one-off EventBridge Scheduler rule (default 30 min) targetingsend-issue. Memory: 256 MB. Timeout: 15 s.send-issue— EventBridge Scheduler target, fired once by the cooling-off rule. Renders the approved issue, sends it to the subscriber list via SESSendBulkEmailin batches that respect the account’s send rate, marks every used item asusedinnc-items-state, and writes asentrow tonc-auditwith the recipient count and a snapshot of exactly what went out. Memory: 512 MB. Timeout: 120 s.summary— EventBridge Scheduler target, monthly on the first Monday at 9am. Reads the past month’snc-issuesandnc-audit(issues sent, skips, open rate if a webhook feeds it back); calls Bedrock Haiku 4.5 to write a short narrative; emails it via SES to the configured stakeholder list. Memory: 512 MB.
Storage
- DynamoDB ·
nc-items-state— one row per item. PKitem_id; attributes:used_in_issue,used_date,category,added_by. On-demand. Drives the “fresh” count. - DynamoDB ·
nc-issues— one row per weekly run. PKissue_id; attributes:move(skip/draft/redraft/ready/approved/sent/skipped-by-owner),item_ids,draft_s3_key,reviewer,state. On-demand. - DynamoDB ·
nc-audit— one row per write action of any kind. PK(issue_id, ts); attributes:action(approve/edit/skip/pull-back/sent),by_user,before,after. On-demand. No TTL — this is the long-term audit trail. - DynamoDB ·
nc-feed-seen— one row per blog feed entry already seen. PKentry_id; attribute:first_seen. On-demand. TTL at 180 days. - S3 ·
nc-items-source— mirrored CSV from the Drive item-pool sheet. Versioning enabled. Lifecycle to Glacier at 90 days; expiry at 7 years. - S3 ·
nc-rules-source— mirrored voice and rules docs as plain text. Versioning enabled. - S3 ·
nc-drafts— each issue draft as JSON (subject, body, per-paragraph sources). Versioning enabled, so an edited issue keeps its draft history. - S3 ·
nc-raw-mime— raw inbound MIME from forwarded updates. Lifecycle to Glacier at 30 days; expiry at 7 years.
Bedrock
- Foundation models.
anthropic.claude-sonnet-4-6-20250115-v1:0via the Global cross-Region inference profileglobal.anthropic.claude-sonnet-4-6-20250115-v1:0for the one weekly issue draft (the only genuinely hard writing task).anthropic.claude-haiku-4-5-20251001-v1:0viaglobal.anthropic.claude-haiku-4-5-20251001-v1:0for the forwarded-update tidy-ups inintake-ses-parserand the monthlysummary. - Embeddings. Not used. The item pool is a short structured list passed in full to the draft prompt; there’s nothing to retrieve. No Knowledge Base, no S3 Vectors. (If a future version drew on a large archive of past issues for style, Titan Text Embeddings V2 at 1024-dim into S3 Vectors would be the path.)
- Quotas. Default account quotas are more than enough at SMB volume. The composer fires one Sonnet call per issue; Haiku fires a few times a month.
EventBridge Scheduler config
nc-weekly-compose—cron(0 7 ? * THU *)in the SMB’s timezone (Thursday 7am, the morning before a Friday send). Target:composerLambda.nc-drive-sync—rate(15 minutes). Target:drive-syncLambda.nc-feed-sync—rate(1 hour). Target:feed-syncLambda.nc-monthly-summary—cron(0 9 ? * 2#1 *)(first Monday at 9am) in TZ. Target:summaryLambda.- One-off rules — created on the fly by
action-handlerfor the cooling-off window, and bysenderfor quiet-hours defers. Useat(YYYY-MM-DDTHH:MM:SS)expressions with--action-after-completion DELETEso the rule self-cleans.
SES inbound and outbound
- Set the MX record on a dedicated subdomain (e.g.
updates.your-company.com) toinbound-smtp.ap-southeast-1.amazonaws.com. - SES inbound rule set
nc-inbound-rules: one rule with recipientupdates@your-company.com→ spam scan → S3 PUT tos3://nc-raw-mime/<message-id>→ stop. The S3 PUT triggersintake-ses-parser. - SES outbound for the review messages and the issue sends: verify a sender identity at
news@your-company.comwith DKIM and SPF on the parent domain, plus a custom MAIL FROM and a List-Unsubscribe header on every issue. Out of sandbox by request; a configuration set with event publishing feeds bounces and complaints back to an SNS topic so a hard bounce removes a subscriber.
IAM (least privilege per Lambda)
Each Lambda has its own role with policies scoped to exact ARNs. Sketch:
- composer role:
s3:GetObjecton the items, voice, and rules keys;s3:PutObjectonnc-drafts;dynamodb:Query+GetItemonnc-items-state;bedrock:InvokeModelon the Sonnet ARN;events:PutEventson the default bus. - sender role:
s3:GetObjectonnc-drafts;events:CreateSchedulefor quiet-hours one-offs;secretsmanager:GetSecretValueon the Slack bot token;ses:SendRawEmailfrom the verified sender;dynamodb:PutItemonnc-issues; outbound network access toslack.com. - action-handler role:
dynamodb:PutItemonnc-issuesandnc-audit;events:CreateSchedulefor the cooling-off one-off;secretsmanager:GetSecretValueon the Sheets and Slack secrets; outbound network access tosheets.googleapis.comandslack.com. - send-issue role:
s3:GetObjectonnc-drafts;ses:SendBulkEmailfrom the verified sender;dynamodb:UpdateItemonnc-items-state;dynamodb:PutItemonnc-audit;secretsmanager:GetSecretValueon the subscriber-list secret if the list lives outside DynamoDB. - intake-ses-parser role:
s3:GetObjectonnc-raw-mime;bedrock:InvokeModelon the Haiku ARN;secretsmanager:GetSecretValueon the Slack bot token. - drive-sync and feed-sync roles:
secretsmanager:GetSecretValueon the Google service-account secret;s3:PutObjecton the items and rules buckets;dynamodb:*Itemonnc-feed-seen(feed-sync only); outbound network towww.googleapis.comand the blog host.
Slack interactive flow
Review messages and item proposals are posted via the chat.postMessage Web API with Block Kit blocks containing the action buttons, because the simpler incoming-webhook surface doesn’t support interactive responses. Button clicks are sent by Slack to the configured Interactivity request URL, which is the action-handler Function URL. action-handler verifies the Slack signing secret on the inbound request, parses the action_id (approve, edit, skip, pull_back, and the proposal actions item_approve, item_edit, item_discard), opens a modal where needed, and processes the response on submit.
The Slack app needs chat:write and im:write, plus the Interactivity URL configured. The bot token lives in Secrets Manager under nc/slack/bot-token. The signing secret is nc/slack/signing-secret.
Observability and cost gates
- CloudWatch Logs: all Lambdas, 7-day retention, structured JSON. Subscription filter on
"error"+"throttle"+"timeout"to a CloudWatch metric for alerting. - Alarms: composer Lambda failures > 0 on a run day (the weekly run is the piece that has to work); send-issue failure rate > 1% in a send; action-handler signature-verification failures > 5/hour (might mean the Slack secret rotated); SES complaint rate above the SES threshold.
- X-Ray: off by default. Not worth the cost at SMB volume.
- AWS Budgets: $15/month threshold, alarm at 80% and 100%, posts to SNS topic
nc-cost-alarmsubscribed to the on-call admin’s email and Slack.
Config and secrets
Service-account credentials for Drive, Sheets, and Docs APIs all live in Secrets Manager under nc/drive/sa (one service account with scopes for all three APIs). Slack bot token and signing secret under nc/slack/*. SES sender identity lives in IAM and the verified-domain config. The configured timezone, item threshold, send day, reviewer mapping, quiet-hours window, cooling-off minutes, and admin fallback all live in Parameter Store under /nc/config/. Lambdas fetch config on cold start and cache for the lifetime of the execution environment.
Deploy
GitHub Actions with OIDC into a deploy role (no long-lived keys) and AWS SAM. The opinionated bits: deploy the SES rule set as a separate stack (rule-set changes affect mail flow), turn on S3 versioning for nc-items-source, nc-rules-source, and nc-drafts so a bad edit can be rolled back in one click, and version the EventBridge Scheduler timezone setting so you don’t accidentally start drafting in UTC after a CI rotation. Keep the cooling-off window and the subscriber-list handling in their own reviewable config so a change to either is a visible PR. Total deployable surface: around eight Lambdas, four DDB tables, four S3 buckets, one EventBridge rule on the default bus (plus the Scheduler rules), one SES rule set, and one Budgets alarm.
That’s the full system. Six narrative posts and this engineering reference. If you want to talk about adapting it for your business, see Work with me.
All posts