Part 7 of 7 · Quote follow-up series ~8 min read

Engineering reference: the quote follow-up 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 approval 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, Bedrock 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 work at SMB volume — the failure mode for an SMB is a quote going cold, not a regional outage. One AWS account dedicated to the system (separate from your other workloads) keeps the IAM blast radius small and lets a single AWS Budgets alarm cover the whole thing.

Topology

AWS topology of the quote follow-up system 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 15 minutes by EventBridge Scheduler that mirrors the quote CSV to s3://qf-quotes-source/, an SES inbound rule set that writes BCC'd sent-quote emails to s3://qf-raw-mime/ plus the parser Lambda intake-parser that calls Bedrock Haiku 4.5 to propose a row for inbox approval, and a webhook Function URL Lambda intake-webhook that accepts a quoting-tool post and runs the same proposal flow. Middle region: scheduled processing. The timer Lambda is triggered daily at 9am local by EventBridge Scheduler; it reads s3://qf-quotes-source/quotes.csv, iterates rows, computes days_since_sent and days_to_expiry per quote, looks up the cadence in s3://qf-rules-source/rules.txt, reads reply and nudge state from DynamoDB, and emits one of three events to the EventBridge default bus per quote that needs a nudge: qf.first_nudge, qf.followup, or qf.last_call. Bottom region: send and reply. The sender Lambda is triggered by an EventBridge rule on those three event types; it resolves the owner, calls Bedrock Haiku 4.5 to draft the nudge from s3://qf-rules-source/voice.txt, emails the draft to the rep for one-tap approval, checks quiet hours and weekends, sends the nudge via SES outbound with reply-to set to the quotes inbox, and writes a row to DynamoDB qf-nudges. Customer replies and approval clicks land via SES inbound and a Function URL Lambda reply-handler that classifies the reply with Haiku 4.5, writes qf-reply, and on accept, decline, or question notifies the rep. CloudWatch Logs collects from every Lambda at 7-day retention. Across the right edge: a small box labelled AWS Budgets alarm at $25 monthly threshold, posting to SNS topic qf-cost-alarm. A note at the bottom: a reply always stops the chain — and every interaction is logged to qf-audit. Ingress Lambda · drive-sync every 15 min Sheets API → s3://qf-quotes-source/ quotes.csv SES inbound rule set qf-inbound-rules action: S3 PUT s3://qf-raw-mime/ trigger: intake-parser Lambda · intake-webhook Function URL quoting-tool post shared-secret check → inbox proposal Drive quote sheet canonical store · mirrored to S3 Scheduled processing EventBridge Scheduler cron(0 9 * * ? *) in TZ_NAME target: timer Lambda + deferred one-offs Lambda · timer reads CSV from S3 + rules.txt + voice.txt computes day counts, picks one of four moves EventBridge default bus qf.first_nudge qf.followup qf.last_call (resting → no event) Send & reply Lambda · sender resolves owner, drafts + approval, quiet hours → SES outbound Approval + reply rep approves draft; customer replies → SES inbound + Function URL Lambda · reply-handler classify with Haiku, writes qf-reply, qf-audit, and on a reply notifies rep A reply always stops the chain — and every interaction is logged to qf-audit.
Fig 7. AWS topology, in three regions of the diagram: ingress (three lanes into the quote list), scheduled processing (the daily timer emitting events), send and reply (the nudge is drafted, approved, and sent; the customer’s reply is classified and 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 15 minutes. Uses the Google Drive API + Sheets API (service-account credentials in Secrets Manager under qf/drive/sa) to export the quote sheet as CSV and write to s3://qf-quotes-source/quotes.csv only if the sheet has changed since the last sync. Same pattern syncs the rules and voice docs to s3://qf-rules-source/. Memory: 256 MB. Timeout: 30 s.
  • intake-parser — S3 PUT trigger on s3://qf-raw-mime/. Parses MIME, pulls the email body and any quote PDF, 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 a quote row: customer, contact email, quote number, amount, sent date, expiry. Posts the proposal to the rep’s inbox via SES with Approve/Edit/Discard links backed by the intake-webhook Function URL. Memory: 512 MB. Timeout: 60 s.
  • intake-webhook — Lambda Function URL, AuthType: NONE with a shared-secret check on every request. Accepts a quoting-tool post when a quote is marked sent, normalizes the payload, and runs the same proposal-and-approval flow as intake-parser. Also serves the Approve/Edit/Discard links from the inbox proposals. Memory: 256 MB. Timeout: 15 s.
  • timer — EventBridge Scheduler target, daily at 9am local time (the schedule expression runs in TZ_NAME set to the SMB’s timezone, e.g. Asia/Singapore). Reads s3://qf-quotes-source/quotes.csv and the rules and voice docs. For each row, computes days_since_sent and days_to_expiry, reads chain state from qf-nudges and qf-reply, decides on a move. Emits one event per quote that needs a nudge: qf.first_nudge, qf.followup, or qf.last_call, with the quote context as the event payload. Resting quotes emit nothing. Memory: 512 MB. Timeout: 60 s. No Bedrock calls.
  • sender — EventBridge rule on the three move events. Resolves owner, calls Bedrock Haiku 4.5 to draft the nudge from the voice template, emails the draft to the rep for one-tap approval, checks quiet hours and weekends, and ships the approved nudge via SES SendRawEmail with reply-to set to the quotes inbox. On a quiet-hours or weekend defer, creates a one-off EventBridge Scheduler rule that re-invokes sender at the next available business minute. Writes a row to qf-nudges after a successful send. Memory: 512 MB. Timeout: 30 s.
  • reply-handler — triggered by SES inbound (customer replies to the quotes address) and by the email-link approval clicks via Function URL. Calls Bedrock Haiku 4.5 to classify the reply as accept/decline/question/out-of-office. Writes to qf-reply and qf-audit; on accept, decline, or question, notifies the rep with the full reply; on out-of-office, writes a defer row instead. Verifies the quote reference (subject tag) before acting. Memory: 256 MB. Timeout: 30 s.
  • digest — EventBridge Scheduler target, weekly Monday 8am. Reads qf-nudges and the quote list; emails the owner a digest of every open quote, which ones got a nudge that week, and which are about to expire. No Bedrock; the message is a plain summary table. Memory: 256 MB.
  • summary — EventBridge Scheduler target, monthly on the first Monday at 9am. Reads the past month’s qf-nudges, qf-reply, and the closed sheet; calls Bedrock Haiku 4.5 to write a one-paragraph narrative of quotes still open, total amount in play, and quotes that went nowhere; emails it via SES to the configured stakeholder list. Memory: 512 MB.

Storage

  • DynamoDB · qf-nudges — one row per nudge sent. PK (quote_id, step_index); attributes: nudge_date, sent_via (email), recipient, move (first_nudge/followup/last_call). On-demand. No TTL.
  • DynamoDB · qf-reply — one row per reply or defer. PK quote_id; sort key reply_date; attributes: outcome (accept/decline/question/out-of-office), defer_until (if out-of-office), by_user (the rep, once they pick it up). On-demand.
  • DynamoDB · qf-audit — one row per write action of any kind. PK (quote_id, ts); attributes: action, by_user (or by_system), before, after. On-demand. No TTL — this is the long-term audit trail.
  • DynamoDB · qf-approvals — pending draft approvals keyed by a one-time token. PK token; attributes: quote_id, move, draft_body, created_at. TTL 7 days so stale drafts self-clean. On-demand.
  • S3 · qf-quotes-source — mirrored CSV from the Drive quote sheet. Versioning enabled. Lifecycle to Glacier at 90 days; expiry at 7 years.
  • S3 · qf-rules-source — mirrored rules and voice docs as plain text. Versioning enabled.
  • S3 · qf-raw-mime — raw inbound MIME from BCC’d quotes and customer replies. Lifecycle to Glacier at 30 days; expiry at 7 years.
  • S3 · qf-source-pdfs — the parsed quote PDFs after the inbox parser handles them, kept for reference if the quote row links to one.

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. Three callsites: intake-parser for reading a sent-quote email, sender for drafting each nudge, and reply-handler for classifying each reply. If a heavier reasoning task ever appears (e.g. summarizing a long multi-thread negotiation for the monthly narrative), sender/summary can escalate that one call to anthropic.claude-sonnet-4-6-20250930-v1:0 via its Global profile — but the hot paths stay on Haiku.
  • Embeddings. Not used. The quote list is structured rows; deterministic lookup beats vector retrieval here. No Knowledge Base, no S3 Vectors. (If a future version needs to retrieve past quote language for tone matching, Amazon Titan Text Embeddings V2 at 1024-dim into Amazon S3 Vectors is the path — not needed today.)
  • Quotas. Default account quotas are more than enough at SMB volume. The timer itself doesn’t call Bedrock; drafting and classify are short calls.

EventBridge Scheduler config

  • qf-daily-checkcron(0 9 * * ? *) in the SMB’s timezone. Target: timer Lambda.
  • qf-drive-syncrate(15 minutes). Target: drive-sync Lambda.
  • qf-weekly-digestcron(0 8 ? * MON *) in TZ. Target: digest Lambda.
  • qf-monthly-summarycron(0 9 ? * 2#1 *) (first Monday at 9am) in TZ. Target: summary Lambda.
  • One-off rules — created on the fly by sender when a quiet-hours or weekend defer is needed. Use at(YYYY-MM-DDTHH:MM:SS) expressions with --action-after-completion DELETE so the rule self-cleans.

SES inbound and outbound

  • Set the MX record on a dedicated subdomain (e.g. quotes.your-company.com) to inbound-smtp.ap-southeast-1.amazonaws.com.
  • SES inbound rule set qf-inbound-rules: one rule with recipient quotes@your-company.com → spam scan → S3 PUT to s3://qf-raw-mime/<message-id> → stop. The S3 PUT triggers intake-parser for BCC’d quotes and reply-handler for customer replies (the message subject tag distinguishes them).
  • SES outbound for the nudges and the rep notifications: verify each rep’s sender identity (e.g. priya@your-company.com) with DKIM and SPF on the parent domain. Out of sandbox by request. Nudges send from the owning rep’s identity so the customer sees a familiar name.

IAM (least privilege per Lambda)

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

  • timer role: s3:GetObject on the quotes, rules, and voice keys; dynamodb:Query + GetItem on qf-nudges, qf-reply; events:PutEvents on the default bus. No bedrock:*.
  • sender role: events:ListSchedules + CreateSchedule for the deferred-send one-offs; bedrock:InvokeModel on the Haiku ARN; secretsmanager:GetSecretValue on the rep sender-identity config; ses:SendRawEmail; dynamodb:PutItem on qf-nudges and qf-approvals.
  • reply-handler role: s3:GetObject on qf-raw-mime; bedrock:InvokeModel on the Haiku ARN; dynamodb:PutItem on qf-reply and qf-audit; ses:SendRawEmail for the rep notification; dynamodb:Query for chain-state lookup.
  • intake-parser role: s3:GetObject on qf-raw-mime; bedrock:InvokeModel on the Haiku ARN; ses:SendRawEmail for the inbox proposal; dynamodb:PutItem on qf-approvals.
  • intake-webhook role: dynamodb:PutItem on qf-approvals; secretsmanager:GetSecretValue on the Sheets-API service-account secret and the webhook shared secret; outbound network to sheets.googleapis.com for the on-approve write.
  • drive-sync role: secretsmanager:GetSecretValue on the Google service-account secret; s3:PutObject on the quotes and rules buckets; outbound network to www.googleapis.com.

Approval and reply flow

Every nudge is draft-then-approve. sender writes the draft to qf-approvals keyed by a one-time token and emails the rep a short message with three signed links (Approve/Edit/Skip) that resolve to the intake-webhook Function URL. Approve flips the row and triggers the actual send (subject to the quiet-hours and weekend gates); Edit returns a pre-filled form; Skip records the skip in qf-audit without affecting the rest of the chain. Tokens are single-use and the qf-approvals TTL clears stale drafts after 7 days.

On the reply side, every outbound nudge carries a hidden quote reference (a tag in the subject and a header). When the customer replies to the quotes address, reply-handler reads that reference, classifies the body, and acts. The reference is what lets a single inbound address fan replies back to the exact quote without guessing.

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: timer Lambda failures > 0 in a day (the daily check is the one piece that has to run); sender failure rate > 1% in 24h; reply-handler classify failures > 5/hour (might mean a malformed inbound or a Bedrock throttle).
  • X-Ray: off by default. Not worth the cost at SMB volume.
  • AWS Budgets: $25/month threshold, alarm at 80% and 100%, posts to SNS topic qf-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 qf/drive/sa (one service account with scopes for both APIs). The quoting-tool webhook shared secret lives under qf/webhook/secret. SES sender identities live in IAM and the verified-domain config. The configured timezone, quiet-hours window, skip-weekends flag, default cadence, nudge cap, and admin fallback owner all live in Parameter Store under /qf/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 for the stack. The opinionated bits: deploy the SES rule set as a separate stack (rule-set changes affect mail flow), turn on S3 versioning for both qf-quotes-source and qf-rules-source so a bad Drive edit can be rolled back in one click, and version the EventBridge Scheduler timezone setting so you don’t accidentally start running the daily check in UTC after a CI rotation. 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