Part 7 of 7 · Social scheduler series ~8 min read

Engineering reference: the social scheduler architecture

Same system, drawn for engineers. Region, service names, resource identifiers, Bedrock model IDs, Lambda inventory, IAM scopes, the channel-token setup, EventBridge Scheduler config, the DynamoDB schemas, and the approval flow. Read alongside the previous six posts; this one’s the build sheet.

Region and account shape

Default region: ap-southeast-1 (Singapore). Bedrock cross-Region inference, EventBridge Scheduler, and SES outbound are all in good shape there. A second region for multi-region resilience isn’t worth the extra setup work at SMB volume — the failure mode for an SMB is one post going out a few minutes late, not a regional outage. One AWS account dedicated to the scheduler (separate from your other workloads) keeps the IAM blast radius small and lets a single AWS Budgets alarm cover the whole system.

Topology

AWS topology of the social scheduler A topology diagram with three regions stacked vertically inside one AWS account boundary. Top region: ingress. Three boxes show the three intake lanes — a Drive sheet sync via the drive-sync Lambda triggered every 5 minutes by EventBridge Scheduler that mirrors the posts CSV and images to s3://ss-posts-source/, a draft-helper Lambda intake-draft-helper triggered by a Function URL that calls Bedrock Haiku 4.5 to rough out post text and posts a review card for approval, and a template-sync Lambda triggered on per-template schedules by EventBridge Scheduler that drops standing posts onto the calendar and proposes rows the same way. Middle region: scheduled processing. The scheduler Lambda is triggered daily at 7am local by EventBridge Scheduler; it reads s3://ss-posts-source/posts.csv, iterates rows, computes when each approved post is due, reads status and send state from DynamoDB, books one-off send rules for due posts, and emits one of two events to the EventBridge default bus per post that needs an action: ss.send or ss.retry. Bottom region: send and approval. The sender Lambda is triggered by an EventBridge rule on those events; it resolves channels, runs the format and image checks, refreshes the channel token from Secrets Manager if needed, posts to each channel through its posting endpoint, and writes a row to DynamoDB ss-sends. Review-card button clicks land on a Function URL Lambda approve-handler that updates ss-status and ss-audit and, on approve, sets the approved flag on the Drive sheet via the Google Sheets API and books the one-off send. CloudWatch Logs collects from every Lambda at 7-day retention. Across the right edge: a small box labelled AWS Budgets alarm at $15 monthly threshold, posting to SNS topic ss-cost-alarm. A note at the bottom: nothing posts without approval — and every interaction is logged to ss-audit. Ingress Lambda · drive-sync every 5 min Sheets API → s3://ss-posts-source/ posts.csv + images Lambda · draft-helper intake-draft-helper Function URL trigger Bedrock Haiku 4.5 → review card Lambda · template-sync per-template schedule drops standing posts onto the calendar → review card Drive posts sheet canonical store · mirrored to S3 Scheduled processing EventBridge Scheduler cron(0 7 * * ? *) in TZ_NAME target: scheduler Lambda + one-off sends Lambda · scheduler reads CSV from S3 + rules.txt + voice.txt computes due times, picks one of four moves EventBridge default bus ss.send ss.retry (queue → one-off rule) (resting → no event) Send & approval Lambda · sender resolves channels, format + image checks; posts per channel, writes ss-sends Review card card with [Approve] [Hold] [Send now] button clicks → Function URL Lambda · approve-handler writes ss-status, ss-audit, and on approve sets the flag via the Sheets API Nothing posts without approval — and every interaction is logged to ss-audit.
Fig 7. AWS topology, in three regions of the diagram: ingress (three lanes into the sheet), scheduled processing (the daily scheduler tick booking sends and emitting events), send and approval (the post ships and the owner’s response is recorded). Every Lambda is event- or schedule-driven; nothing is synchronous-chained.

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 5 minutes. Uses the Google Drive API + Sheets API (service-account credentials in Secrets Manager under ss/drive/sa) to export the posts sheet as CSV and mirror new images from the Drive folder to s3://ss-posts-source/ only if the sheet or folder has changed since the last sync. Same pattern syncs the rules and voice docs to s3://ss-rules-source/. Memory: 256 MB. Timeout: 30 s.
  • intake-draft-helper — Lambda Function URL. Triggered when the owner submits a short note from the draft-helper. Reads the brand tone from s3://ss-rules-source/voice.txt and calls Bedrock Haiku 4.5 (anthropic.claude-haiku-4-5-20251001-v1:0 via global.anthropic.claude-haiku-4-5-20251001-v1:0) to propose post text per chosen channel, with the per-channel character limit injected into the prompt so the draft fits on the first try. Posts the proposal to the review card with Approve/Edit/Discard. On approve, writes the row to the Drive sheet via the Sheets API with the approved flag unset. Memory: 512 MB. Timeout: 30 s.
  • template-sync — EventBridge Scheduler target, one schedule per recurring template (e.g. cron(0 9 ? * FRI *) for a weekly Friday post). Reads the matching template from the rules doc, drops a fresh row onto the calendar a few days ahead, and surfaces it in the same review card as the draft-helper. Memory: 256 MB. Timeout: 30 s.
  • scheduler — EventBridge Scheduler target, daily at 7am local time (the schedule expression runs in TZ_NAME set to the SMB’s timezone, e.g. Asia/Singapore). Reads s3://ss-posts-source/posts.csv and the rules and voice docs. For each row, computes minutes_to_send, reads state from ss-status and ss-sends, and decides on a move. For queued posts it creates a one-off Scheduler rule for the exact send minute; for due or retry posts it emits ss.send or ss.retry with the post context as the event payload. Resting posts emit nothing. Memory: 512 MB. Timeout: 60 s. No Bedrock calls.
  • sender — EventBridge rule on the ss.send and ss.retry events. Resolves channels, runs the format check (character limit, link count) and the image check (size, type) against the per-channel rules, fetches the image from s3://ss-posts-source/, refreshes the channel token from Secrets Manager if it’s near expiry, then posts to each channel through its posting endpoint. On a per-channel failure it records the error and lets the scheduler queue a retry; on a format or image failure it flags the post back to the owner and drops it to draft. Writes a row to ss-sends per channel after each attempt. Memory: 512 MB. Timeout: 60 s.
  • approve-handler — Lambda Function URL, public with AuthType: NONE; verifies a signed token on the request body. Triggered by review-card button clicks (Approve/Hold/Send-now). Writes to ss-status and ss-audit; on approve, sets the approved flag on the Drive sheet via the Sheets API and books the one-off send; on hold, cancels any queued one-off Scheduler rule and drops the status to draft; on send-now, emits an immediate ss.send. Memory: 256 MB. Timeout: 15 s.
  • digest — EventBridge Scheduler target, weekly Sunday 6pm. Reads ss-sends for the past week and the calendar; sends a digest email summarizing what posted and what’s coming up. No Bedrock; the message is a plain summary table. Memory: 256 MB.
  • recap — EventBridge Scheduler target, monthly on the first Monday at 9am. Reads the past month’s ss-sends and ss-audit; calls Bedrock Haiku 4.5 to write a one-paragraph recap narrative; emails it via SES to the configured stakeholder list. Memory: 512 MB.

Storage

  • DynamoDB · ss-status — one row per post. PK post_id; attributes: state (draft/queued/sending/sent/failed), queued_for, approved_by, scheduled_at. On-demand. No TTL.
  • DynamoDB · ss-sends — one row per send attempt. PK (post_id, channel); sort key attempt; attributes: result (ok/fail), posted_url, error, sent_at. On-demand.
  • DynamoDB · ss-audit — one row per write action of any kind. PK (post_id, ts); attributes: action (approve/hold/send-now/draft-helper), by_user, before, after. On-demand. No TTL — this is the long-term audit trail.
  • S3 · ss-posts-source — mirrored CSV from the Drive posts sheet plus the images from the Drive folder. Versioning enabled. Lifecycle to Glacier at 90 days for old images; CSV kept hot.
  • S3 · ss-rules-source — mirrored rules and voice docs as plain text. Versioning enabled.
  • S3 · ss-render-cache — resized image variants per channel, cached so a re-send doesn’t re-process the same image. Lifecycle expiry at 30 days.

Bedrock

  • Foundation model. anthropic.claude-haiku-4-5-20251001-v1:0 via the Global cross-Region inference profile global.anthropic.claude-haiku-4-5-20251001-v1:0. Two callsites: intake-draft-helper for roughing out post text, and recap for the monthly narrative. A heavier model (anthropic.claude-sonnet-4-6) is wired but unused by default; it’s only worth switching the draft-helper to Sonnet if a business wants longer-form, multi-paragraph posts where the extra reasoning earns its cost.
  • Embeddings. Not used. The posts are structured rows; deterministic lookup and date arithmetic beat vector retrieval here. No Knowledge Base, no S3 Vectors.
  • Quotas. Default account quotas are more than enough at SMB volume. The scheduler itself doesn’t call Bedrock; the draft-helper fires a few times a month at most.

EventBridge Scheduler config

  • ss-daily-tickcron(0 7 * * ? *) in the SMB’s timezone. Target: scheduler Lambda.
  • ss-drive-syncrate(5 minutes). Target: drive-sync Lambda.
  • ss-template-* — one rule per recurring template, with the template’s own cron. Target: template-sync Lambda.
  • ss-weekly-digestcron(0 18 ? * SUN *) in TZ. Target: digest Lambda.
  • ss-monthly-recapcron(0 9 ? * 2#1 *) (first Monday at 9am) in TZ. Target: recap Lambda.
  • One-off send rules — created on the fly by scheduler and approve-handler when a post is queued for its exact minute. Use at(YYYY-MM-DDTHH:MM:SS) expressions with --action-after-completion DELETE so the rule self-cleans. Hold cancels the matching rule by name.

Channels and tokens

  • Each channel is registered in the rules doc with its name, posting endpoint, and the per-channel format rules (character limit, accepted image types and sizes, link count).
  • Each channel’s long-lived token and refresh credential live in Secrets Manager under ss/channels/<channel-name>. The sender reads the token at send time and refreshes it via the channel’s token endpoint if it’s within the refresh window.
  • SES outbound for the review cards, the weekly digest, and the monthly recap: verify a sender identity at scheduler@your-company.com with DKIM and SPF on the parent domain. Out of sandbox by request.

IAM (least privilege per Lambda)

Each Lambda has its own role with policies scoped to exact ARNs. Sketch:

  • scheduler role: s3:GetObject on the posts, rules, and voice keys; dynamodb:Query + GetItem on ss-status, ss-sends; events:PutEvents on the default bus; scheduler:CreateSchedule for the one-off sends. No bedrock:*.
  • sender role: s3:GetObject on the image keys and s3:PutObject on ss-render-cache; secretsmanager:GetSecretValue on the channel-token secrets; dynamodb:PutItem on ss-sends; outbound network access to each channel’s posting host.
  • approve-handler role: dynamodb:PutItem on ss-status and ss-audit; secretsmanager:GetSecretValue on the Sheets-API service-account secret; outbound network access to sheets.googleapis.com; scheduler:CreateSchedule + DeleteSchedule for booking and cancelling one-off sends.
  • intake-draft-helper role: s3:GetObject on ss-rules-source; bedrock:InvokeModel on the Haiku ARN; secretsmanager:GetSecretValue on the Sheets-API secret.
  • drive-sync and template-sync roles: secretsmanager:GetSecretValue on the Google service-account secret; s3:PutObject on the posts and rules buckets; outbound network to www.googleapis.com.

Approval flow

The review card is delivered as an HTML email via SES (and, if a business uses Slack, the same card can be posted with Block Kit blocks). Each button carries a signed token in its link that encodes the post_id and the action (approve, hold, send_now). Clicks land on the approve-handler Function URL, which verifies the signature, applies the action, and returns a small confirmation page. Approve and Hold are one-click; Send-now is one-click but still routes the post through the full format check in sender before anything posts. The approval flag on the Drive sheet is the single gate every post passes — a post with the flag unset is never sent, no matter what state the rest of the system is in.

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: scheduler Lambda failures > 0 in a day (the daily tick is the one piece that has to run); sender failure rate > 1% in 24h; approve-handler signature-verification failures > 5/hour (might mean the signing secret rotated).
  • 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 ss-cost-alarm subscribed to the on-call admin’s email.

Config and secrets

Service-account credentials for Drive and Sheets APIs live in Secrets Manager under ss/drive/sa (one service account with scopes for both APIs). Channel tokens and refresh credentials live under ss/channels/*. The approval-link signing secret is ss/approval/signing-secret. SES sender identity lives in IAM and the verified-domain config. The configured timezone, posting-window hours, blackout days, and account-default channel list all live in Parameter Store under /ss/config/. Lambdas fetch config on cold start and cache for the lifetime of the execution environment.

Deploy

GitHub Actions with OIDC and AWS SAM — no long-lived keys. The opinionated bits: turn on S3 versioning for both ss-posts-source and ss-rules-source so a bad Drive edit can be rolled back in one click; version the EventBridge Scheduler timezone setting so you don’t accidentally start running the daily tick in UTC after a CI rotation; and keep the channel-token secrets in a separate stack so a token rotation doesn’t require redeploying the whole system. Total deployable surface: around eight Lambdas, three DDB tables, three S3 buckets, one EventBridge rule on the default bus (plus the Scheduler rules), 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