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
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 underqf/drive/sa) to export the quote sheet as CSV and write tos3://qf-quotes-source/quotes.csvonly if the sheet has changed since the last sync. Same pattern syncs the rules and voice docs tos3://qf-rules-source/. Memory: 256 MB. Timeout: 30 s.intake-parser— S3 PUT trigger ons3://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:0viaglobal.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 theintake-webhookFunction URL. Memory: 512 MB. Timeout: 60 s.intake-webhook— Lambda Function URL,AuthType: NONEwith 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 asintake-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 inTZ_NAMEset to the SMB’s timezone, e.g.Asia/Singapore). Readss3://qf-quotes-source/quotes.csvand the rules and voice docs. For each row, computesdays_since_sentanddays_to_expiry, reads chain state fromqf-nudgesandqf-reply, decides on a move. Emits one event per quote that needs a nudge:qf.first_nudge,qf.followup, orqf.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 SESSendRawEmailwith reply-to set to the quotes inbox. On a quiet-hours or weekend defer, creates a one-off EventBridge Scheduler rule that re-invokessenderat the next available business minute. Writes a row toqf-nudgesafter 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 toqf-replyandqf-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. Readsqf-nudgesand 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’sqf-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. PKquote_id; sort keyreply_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(orby_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. PKtoken; 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:0via the Global cross-Region inference profileglobal.anthropic.claude-haiku-4-5-20251001-v1:0. Three callsites:intake-parserfor reading a sent-quote email,senderfor drafting each nudge, andreply-handlerfor classifying each reply. If a heavier reasoning task ever appears (e.g. summarizing a long multi-thread negotiation for the monthly narrative),sender/summarycan escalate that one call toanthropic.claude-sonnet-4-6-20250930-v1:0via 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-check—cron(0 9 * * ? *)in the SMB’s timezone. Target:timerLambda.qf-drive-sync—rate(15 minutes). Target:drive-syncLambda.qf-weekly-digest—cron(0 8 ? * MON *)in TZ. Target:digestLambda.qf-monthly-summary—cron(0 9 ? * 2#1 *)(first Monday at 9am) in TZ. Target:summaryLambda.- One-off rules — created on the fly by
senderwhen a quiet-hours or weekend defer is needed. 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.
quotes.your-company.com) toinbound-smtp.ap-southeast-1.amazonaws.com. - SES inbound rule set
qf-inbound-rules: one rule with recipientquotes@your-company.com→ spam scan → S3 PUT tos3://qf-raw-mime/<message-id>→ stop. The S3 PUT triggersintake-parserfor BCC’d quotes andreply-handlerfor 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:GetObjecton the quotes, rules, and voice keys;dynamodb:Query+GetItemonqf-nudges,qf-reply;events:PutEventson the default bus. Nobedrock:*. - sender role:
events:ListSchedules+CreateSchedulefor the deferred-send one-offs;bedrock:InvokeModelon the Haiku ARN;secretsmanager:GetSecretValueon the rep sender-identity config;ses:SendRawEmail;dynamodb:PutItemonqf-nudgesandqf-approvals. - reply-handler role:
s3:GetObjectonqf-raw-mime;bedrock:InvokeModelon the Haiku ARN;dynamodb:PutItemonqf-replyandqf-audit;ses:SendRawEmailfor the rep notification;dynamodb:Queryfor chain-state lookup. - intake-parser role:
s3:GetObjectonqf-raw-mime;bedrock:InvokeModelon the Haiku ARN;ses:SendRawEmailfor the inbox proposal;dynamodb:PutItemonqf-approvals. - intake-webhook role:
dynamodb:PutItemonqf-approvals;secretsmanager:GetSecretValueon the Sheets-API service-account secret and the webhook shared secret; outbound network tosheets.googleapis.comfor the on-approve write. - drive-sync role:
secretsmanager:GetSecretValueon the Google service-account secret;s3:PutObjecton the quotes and rules buckets; outbound network towww.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-alarmsubscribed 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