Engineering reference: the proposal generator architecture
Same system, drawn for engineers. Region, service names, resource identifiers, Bedrock model IDs, Lambda inventory, IAM scopes, the S3 Vectors retrieval setup, EventBridge 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, Bedrock cross-Region inference (Global profile), S3 Vectors, and EventBridge are all in good shape there. A second region for resilience isn’t worth the setup at SMB volume — the failure mode for an SMB is a proposal that takes an extra hour, not a regional outage. One AWS account dedicated to the generator 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, 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.
intake-form— Lambda Function URL (AuthType: NONE, behind a shared-secret token in the form). Serves the short brief form and accepts its POST, writing a row topg-briefswithstatus: ready. No model call. Memory: 256 MB. Timeout: 15 s.intake-email-parser— S3 PUT trigger ons3://pg-raw-mime/. Parses MIME, strips quoted replies and signatures, and calls Bedrock Haiku 4.5 (anthropic.claude-haiku-4-5-20251001-v1:0viaglobal.anthropic.claude-haiku-4-5-20251001-v1:0) to read a brief out of the thread as strict JSON, leaving unfound fields null. Writes topg-briefswithstatus: needs-reviewand posts a Slack card. Memory: 512 MB. Timeout: 30 s.intake-slack— Lambda Function URL; verifies the Slack signing secret. Handles the/proposalslash command; runs the same Haiku tidy step on the one-line input and writes topg-briefs. Memory: 256 MB. Timeout: 15 s.drive-sync— EventBridge Scheduler target, every 15 minutes. Uses the Google Drive + Docs APIs (service-account credentials in Secrets Manager underpg/drive/sa) to mirror the templates, past proposals, and rules docs tos3://pg-templates-source/when changed. On a new or changed past proposal, splits it into sections, embeds each with Titan Text Embeddings V2 (amazon.titan-embed-text-v2:0, 1024-dim), and upserts into the S3 Vectors indexpg-proposal-vectors. Memory: 512 MB. Timeout: 60 s.build— invoked asynchronously via EventBridge when a brief’s start-draft action fires. Reads the brief, embeds the need with Titan V2, queriespg-proposal-vectorsfor the nearest sections, computes the price summary in plain Python from the rate card ins3://pg-templates-source/rules.txt, assembles the grounded prompt, and makes one Claude Sonnet 4.6 call (anthropic.claude-sonnet-4-6-20250115-v1:0viaglobal.anthropic.claude-sonnet-4-6-20250115-v1:0) returning five sections as structured JSON. Runs the price-and-date guard (pure Python), retries once on a structural failure, then writes the draft tos3://pg-drafts/and a row topg-draftswithstatus: ready-for-revieworheld. Memory: 1024 MB. Timeout: 120 s.approve-handler— Lambda Function URL, public withAuthType: NONE; verifies a Slack signature on the request body. Triggered by Approve and Discard button clicks. On approve, renders the five sections to a branded PDF (a small HTML-to-PDF step), writes it to the DriveProposals/sent/folder via the Drive API, sends it to the client via SESSendRawEmail, and writes asentrow topg-audit. On discard, archives the brief and draft and writes adiscardedrow. Memory: 512 MB. Timeout: 30 s.edit-handler— Lambda Function URL. Triggered by the Edit button; ensures the linked Google Doc exists (creates it from the draft if needed) and returns its URL. On re-approve, pulls the doc text back via the Docs API, re-runs the price-and-date guard, and hands off to the same render-and-send path asapprove-handler. A human-overridden price is logged as an override inpg-auditrather than blocked. Memory: 512 MB. Timeout: 30 s.digest— EventBridge Scheduler target, weekly Sunday 6pm. Readspg-draftsandpg-auditfor the past week; sends a Slack summary of briefs in, proposals sent, and drafts still open. No Bedrock; a plain summary table. Memory: 256 MB.summary— EventBridge Scheduler target, monthly on the first Monday at 9am. Reads the past month’spg-audit; calls Bedrock Haiku 4.5 to write a one-paragraph pipeline narrative (briefs, sent, win-rate if outcomes are recorded); emails it via SES. Memory: 512 MB.
Storage
- DynamoDB ·
pg-briefs— one row per brief. PKbrief_id; attributes:client,need,budget,due_date,lane(form/email/slack),by_user,status(ready/needs-review/drafting/done/archived). On-demand. - DynamoDB ·
pg-drafts— one row per draft attempt. PK(brief_id, draft_index); attributes:status(ready-for-review/held/sent/discarded),price_total,doc_url,pdf_key,guard_result. On-demand. - DynamoDB ·
pg-audit— one row per write action of any kind. PK(brief_id, ts); attributes:action(sent/edited/discarded/override),by_user,before,after. On-demand. No TTL — this is the long-term audit trail. - S3 Vectors ·
pg-proposal-vectors— embeddings of each past-proposal section, 1024-dim, with metadata (proposal id, section type, client industry). Queried per draft for the nearest sections. - S3 ·
pg-templates-source— mirrored section templates, past proposals, and the rules/voice doc as plain text. Versioning enabled. - S3 ·
pg-raw-mime— raw inbound MIME from forwarded threads. Lifecycle to Glacier at 30 days; expiry at 7 years. - S3 ·
pg-drafts— the structured draft JSON and the rendered PDFs. Versioning enabled; lifecycle to Glacier at 90 days.
Bedrock
- Draft model.
anthropic.claude-sonnet-4-6-20250115-v1:0via the Global cross-Region inference profile. One callsite:build, for writing the five sections. This is the only heavy call in the system, justified because writing a coherent multi-section document is real reasoning work. - Cheap model.
anthropic.claude-haiku-4-5-20251001-v1:0via the Global profile. Callsites:intake-email-parserandintake-slack(read a brief), Gate 1 voice touch-ups insidebuild, andsummary(monthly narrative). - Embeddings.
amazon.titan-embed-text-v2:0, 1024-dim. Callsites:drive-sync(embed past sections on ingest) andbuild(embed the brief for retrieval). - Quotas. Default account quotas are more than enough at SMB volume. The Sonnet draft fires once per started draft.
EventBridge config
pg-drive-sync—rate(15 minutes). Target:drive-syncLambda.pg-weekly-digest—cron(0 18 ? * SUN *)in TZ. Target:digestLambda.pg-monthly-summary—cron(0 9 ? * 2#1 *)(first Monday at 9am) in TZ. Target:summaryLambda.- Start-draft rule — an EventBridge rule on the custom event
pg.start_draft(emitted by the intake handlers when the owner taps start) with targetbuild, so drafting runs async off the request that triggered it.
SES inbound and outbound
- Set the MX record on a dedicated subdomain (e.g.
proposals.your-company.com) toinbound-smtp.ap-southeast-1.amazonaws.com. - SES inbound rule set
pg-inbound-rules: one rule with recipientproposals@your-company.com→ spam scan → S3 PUT tos3://pg-raw-mime/<message-id>→ stop. The S3 PUT triggersintake-email-parser. - SES outbound for the approved proposals: verify a sender identity at
hello@your-company.comwith 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:
- build role:
dynamodb:GetItem+PutItemonpg-briefsandpg-drafts;s3:GetObjecton the templates and rules keys;s3vectors:QueryVectorsonpg-proposal-vectors;bedrock:InvokeModelon the Sonnet, Haiku, and Titan ARNs;s3:PutObjectonpg-drafts. - approve-handler role:
dynamodb:PutItemonpg-draftsandpg-audit;s3:GetObjectonpg-drafts;ses:SendRawEmailfrom the verified sender identity;secretsmanager:GetSecretValueon the Drive and Slack secrets; outbound network towww.googleapis.com. - edit-handler role:
dynamodb:GetItem+PutItemonpg-draftsandpg-audit;secretsmanager:GetSecretValueon the Docs-API service-account secret; outbound network todocs.googleapis.com; the same render-and-send permissions asapprove-handler. - intake-email-parser role:
s3:GetObjectonpg-raw-mime;bedrock:InvokeModelon the Haiku ARN;dynamodb:PutItemonpg-briefs;secretsmanager:GetSecretValueon the Slack webhook. - drive-sync role:
secretsmanager:GetSecretValueon the Google service-account secret;s3:PutObjectonpg-templates-source;s3vectors:PutVectorsonpg-proposal-vectors;bedrock:InvokeModelon the Titan ARN; outbound network towww.googleapis.com.
Slack interactive flow
Alert and draft messages are posted via the chat.postMessage Web API with Block Kit blocks containing the action buttons (Start draft on a brief card; Approve/Edit/Discard on a draft card). Button clicks are sent by Slack to the configured Interactivity request URL, which is the relevant handler Function URL. Each handler verifies the Slack signing secret on the inbound request, parses the action_id, opens a modal where needed (Edit links the doc; the others are one-tap), and processes the response.
The Slack app needs chat:write, commands (for the slash command), and the Interactivity URL configured. The bot token lives in Secrets Manager under pg/slack/bot-token. The signing secret is pg/slack/signing-secret.
Observability and cost gates
- CloudWatch Logs: all Lambdas, 7-day retention, structured JSON. Subscription filter on
"error"+"throttle"+"timeout"+"guard_held"to a CloudWatch metric for alerting. - Alarms:
buildfailures > 0 in an hour; price-and-date guard holds > 10% of drafts in 24h (suggests the prompt or rate card drifted); approve-handler signature-verification failures > 5/hour (might mean the Slack 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
pg-cost-alarmsubscribed to the admin’s email and Slack.
Config and secrets
Service-account credentials for the Drive, Docs, and Sheets APIs live in Secrets Manager under pg/drive/sa (one service account with scopes for all three). Slack bot token and signing secret under pg/slack/*. SES sender identity lives in IAM and the verified-domain config. The configured timezone, the rate-card location, the banned-claim list reference, and the brand assets (logo, terms page) live in Parameter Store under /pg/config/. Lambdas fetch config on cold start and cache for the lifetime of the execution environment.
Deploy
GitHub Actions with OIDC into a short-lived deploy role — no long-lived AWS keys — running 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 pg-templates-source and pg-drafts so a bad Drive edit or a bad render can be rolled back, and keep the rate card under version control alongside the templates so a price change is reviewable. Total deployable surface: around nine Lambdas, three DDB tables, one S3 Vectors index, four S3 buckets, a handful of EventBridge 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