Part 7 of 7 · Inventory reorder bot series ~8 min read

Engineering reference: the inventory reorder bot 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, 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 work at SMB volume — the failure mode for an SMB is a missed reorder, not a regional outage. One AWS account dedicated to the bot (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 inventory reorder bot 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 stock CSV to s3://ir-stock-source/, an SES inbound rule set with action S3 PUT to s3://ir-raw-mime/ plus the parser Lambda intake-ses-parser that runs Textract on stock sheets and Bedrock Haiku 4.5 to propose updated rows for Slack approval, and a pos-handler Lambda exposed via a Function URL that receives sale events from the till and lowers on-hand counts in near real time. Middle region: scheduled processing. The checker Lambda is triggered daily at 8am local by EventBridge Scheduler; it reads s3://ir-stock-source/stock.csv, iterates rows, computes the reorder point per item, looks up margins and caps in s3://ir-rules-source/rules.txt, reads order state from DynamoDB, and emits one of two events to the EventBridge default bus per item that needs an order: ir.reorder or ir.urgent_reorder. Bottom region: dispatch and approval. The draft-po Lambda is triggered by an EventBridge rule on those two event types; it resolves the supplier, checks quiet hours, sizes the order quantity, fetches the PO template from s3://ir-rules-source/suppliers.txt, posts the draft to Slack via the Slack API with Approve, Edit, and Skip buttons or sends an email via SES outbound, and writes a pending row to DynamoDB ir-orders. Slack interactive button clicks land on a Function URL Lambda approve-handler that on approve sends the PO email to the supplier via SES outbound, flips the item to on-order, and writes to ir-orders and ir-audit. 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 ir-cost-alarm. A note at the bottom: nothing is ordered without a human tap — and every interaction is logged to ir-audit. Ingress Lambda · drive-sync every 15 min Sheets API → s3://ir-stock-source/ stock.csv SES inbound rule set ir-inbound-rules action: S3 PUT s3://ir-raw-mime/ trigger: intake-ses-parser Lambda · pos-handler Function URL till posts each sale lowers on-hand count → batched to sheet Drive stock sheet canonical store · mirrored to S3 Scheduled processing EventBridge Scheduler cron(0 8 * * ? *) in TZ_NAME target: checker Lambda + deferred one-offs Lambda · checker reads CSV from S3 + rules.txt + suppliers computes point, picks one of four moves EventBridge default bus ir.reorder ir.urgent_reorder (watch → digest only) (stocked → no event) Dispatch & approval Lambda · draft-po resolves supplier, quiet hours, quantity; Slack API or SES outbound Slack interactive DM with [Approve] [Edit] [Skip] button clicks → Function URL Lambda · approve-handler on approve sends PO via SES outbound, flips to on-order, writes ir-audit Nothing is ordered without a human tap — and every interaction is logged to ir-audit.
Fig 7. AWS topology, in three regions of the diagram: ingress (three lanes into the stock sheet), scheduled processing (the daily check emitting events), dispatch and approval (the draft ships and the owner’s decision 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 15 minutes. Uses the Google Drive API + Sheets API (service-account credentials in Secrets Manager under ir/drive/sa) to export the stock sheet as CSV and write to s3://ir-stock-source/stock.csv only if the sheet has changed since the last sync. Same pattern syncs the rules and supplier docs to s3://ir-rules-source/. Memory: 256 MB. Timeout: 30 s.
  • pos-handler — Lambda Function URL, AuthType: NONE, verifies an HMAC signature on the request body using a shared secret with the POS. Receives sale (and optionally received-stock) events, validates the SKU against the current stock CSV, and applies the count delta. To avoid hammering the Sheets API during a busy hour, deltas are accumulated in ir-state and flushed to the Drive sheet on a short timer (a rate(5 minutes) Scheduler target that batches pending deltas). Memory: 256 MB. Timeout: 15 s.
  • intake-ses-parser — S3 PUT trigger on s3://ir-raw-mime/. Parses MIME, extracts the attachment, runs Textract via StartDocumentTextDetection + StartDocumentAnalysis (asynchronously to handle multi-page sheets, with table extraction on). On Textract completion (via SNS notification), reads the structured text 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 match lines to known SKUs and propose updated rows. Posts the proposal to Slack with Approve/Edit/Discard buttons. For XLSX/CSV attachments (Textract isn’t needed), parses directly with openpyxl or the stdlib csv module. Both are stable and widely used in 2026; openpyxl’s maintenance velocity is light, which is acceptable for a parsing path that runs a few times a month. Memory: 512 MB. Timeout: 60 s.
  • checker — EventBridge Scheduler target, daily at 8am local time (the schedule expression runs in TZ_NAME set to the SMB’s timezone, e.g. Asia/Singapore). Reads s3://ir-stock-source/stock.csv and the rules and supplier docs. For each row, computes the reorder point (rate × lead_time + buffer, honoring any point_override), reads order state from ir-orders, and decides on a move. Emits one event per row that needs an order: ir.reorder or ir.urgent_reorder, with the item context as the event payload. Watch items are accumulated for the digest; stocked items emit nothing. Memory: 512 MB. Timeout: 60 s. No Bedrock calls.
  • draft-po — EventBridge rule on the two move events. Resolves supplier, checks quiet hours, computes the order quantity (clear-the-point target minus on-hand, rounded up to pack size, raised to the supplier minimum, capped at the rules-doc cap), formats the draft from the supplier template, and sends via the Slack chat.postMessage Web API (ir/slack/bot-token in Secrets Manager) or SES SendRawEmail. On a quiet-hours defer, creates a one-off EventBridge Scheduler rule that re-invokes draft-po at the next available business minute. Writes a pending row to ir-orders after a successful send. Memory: 256 MB. Timeout: 30 s. Never contacts the supplier — only the owner.
  • approve-handler — Lambda Function URL, public with AuthType: NONE; verifies a Slack signature on the request body. Triggered by Slack interactive button clicks (Approve/Edit/Skip) and by email-link clicks. On approve or edit, sends the PO email to the resolved supplier via SES SendRawEmail, flips the item in ir-orders to on-order with the approved quantity and an expected-delivery date, and writes to ir-audit. On skip, marks the draft dismissed and applies the skip_cooldown_days. Memory: 256 MB. Timeout: 15 s.
  • digest — EventBridge Scheduler target, weekly Sunday 6pm. Reads ir-orders for the past week and the stock sheet; sends a digest message to a configured Slack channel summarizing orders placed, items on watch, and any item skipped repeatedly. 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 ir-orders and ir-audit; calls Bedrock Haiku 4.5 to write a one-paragraph board narrative (spend by supplier, items that hit urgent, stockouts avoided); emails it via SES to the configured stakeholder list. Memory: 512 MB.

Storage

  • DynamoDB · ir-orders — one row per draft/order. PK item_id; sort key order_id; attributes: status (pending/on-order/received/skipped), qty, supplier, unit_cost, draft_date, expected_date. On-demand. No TTL.
  • DynamoDB · ir-state — per-item scratch state. PK item_id; attributes: pending_delta (un-flushed POS deltas), skip_until, last_move, last_checked. On-demand.
  • DynamoDB · ir-audit — one row per write action of any kind. PK (item_id, ts); attributes: action (approved/edited/skipped/parsed), by_user, before, after. On-demand. No TTL — this is the long-term audit trail.
  • S3 · ir-stock-source — mirrored CSV from the Drive stock sheet. Versioning enabled. Lifecycle to Glacier at 90 days; expiry at 7 years.
  • S3 · ir-rules-source — mirrored rules and supplier docs as plain text. Versioning enabled.
  • S3 · ir-raw-mime — raw inbound MIME from forwarded sheets. Lifecycle to Glacier at 30 days; expiry at 7 years.
  • S3 · ir-source-docs — the parsed source sheets and price lists after the inbound parser handles them, kept for reference and audit.

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-ses-parser for the inbound stock-sheet parsing, and summary for the monthly board narrative. Claude Sonnet 4.6 (anthropic.claude-sonnet-4-6-20250930-v1:0) is available as a config-flag fallback if a particularly messy supplier price list needs heavier reasoning, but Haiku 4.5 handles the normal case.
  • Embeddings. Not used. The stock list is structured rows; deterministic lookup beats vector retrieval here. No Knowledge Base, no S3 Vectors.
  • Quotas. Default account quotas are more than enough at SMB volume. The checker itself doesn’t call Bedrock; the parsing lane fires a few times a month at most.

EventBridge Scheduler config

  • ir-daily-checkcron(0 8 * * ? *) in the SMB’s timezone. Target: checker Lambda.
  • ir-drive-syncrate(15 minutes). Target: drive-sync Lambda.
  • ir-pos-flushrate(5 minutes). Target: a flush handler that writes accumulated POS deltas from ir-state to the Drive sheet.
  • ir-weekly-digestcron(0 18 ? * SUN *) in TZ. Target: digest Lambda.
  • ir-monthly-summarycron(0 9 ? * 2#1 *) (first Monday at 9am) in TZ. Target: summary Lambda.
  • One-off rules — created on the fly by draft-po when a quiet-hours 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. stock.your-company.com) to inbound-smtp.ap-southeast-1.amazonaws.com.
  • SES inbound rule set ir-inbound-rules: one rule with recipient stock@your-company.com → spam scan → S3 PUT to s3://ir-raw-mime/<message-id> → stop. The S3 PUT triggers intake-ses-parser.
  • SES outbound for the PO emails and email-fallback drafts: verify a sender identity at orders@your-company.com with DKIM and SPF on the parent domain. Out of sandbox by request. Supplier PO emails are sent only from approve-handler, never from draft-po.

IAM (least privilege per Lambda)

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

  • checker role: s3:GetObject on the stock, rules, and supplier keys; dynamodb:Query + GetItem on ir-orders, ir-state; events:PutEvents on the default bus. No bedrock:* and no ses:*.
  • draft-po role: scheduler:CreateSchedule for the deferred one-offs; secretsmanager:GetSecretValue on the Slack bot token; dynamodb:PutItem on ir-orders; outbound network access to slack.com. No ses:SendRawEmail to a supplier — only the owner-facing email path.
  • approve-handler role: ses:SendRawEmail from the verified sender identity (the only role that can email a supplier); dynamodb:PutItem + UpdateItem on ir-orders and ir-audit; secretsmanager:GetSecretValue on the Slack signing secret and the Sheets-API secret; dynamodb:Query for order-state lookup.
  • intake-ses-parser role: s3:GetObject on ir-raw-mime; textract:StartDocumentTextDetection + StartDocumentAnalysis; bedrock:InvokeModel on the Haiku ARN; secretsmanager:GetSecretValue on the Slack bot token.
  • pos-handler and drive-sync roles: secretsmanager:GetSecretValue on the relevant secret (POS HMAC key; Google service-account); s3:GetObject/PutObject on the stock and rules buckets; dynamodb:UpdateItem on ir-state (pos-handler); outbound network to www.googleapis.com (drive-sync).

Slack interactive flow

The Slack incoming webhook is the simplest delivery surface but doesn’t support interactive button responses. So the draft messages are posted via the chat.postMessage Web API instead, with Block Kit blocks containing the action buttons. Button clicks are sent by Slack to the configured Interactivity request URL, which is the approve-handler Function URL. approve-handler verifies the Slack signing secret on the inbound request, parses the action_id (approve, edit, skip), opens a modal if needed (Edit opens a modal; Approve and Skip are one-tap), and processes the response when the modal is submitted.

The Slack app needs chat:write, im:write, and the Interactivity URL configured. The bot token lives in Secrets Manager under ir/slack/bot-token. The signing secret is ir/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: checker Lambda failures > 0 in a day (the daily check is the one piece that has to run); draft-po failure rate > 1% in 24h; approve-handler signature-verification failures > 5/hour (might mean the Slack secret rotated); any supplier-email send failure (an order that didn’t actually reach the supplier).
  • 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 ir-cost-alarm subscribed to the on-call admin’s email and Slack.

Config and secrets

Service-account credentials for the Drive and Sheets APIs live in Secrets Manager under ir/drive/sa. The Slack bot token and signing secret are under ir/slack/*. The POS webhook HMAC key is under ir/pos/hmac. SES sender identity lives in IAM and the verified-domain config. The configured timezone, quiet-hours window, watch margin, days-of-cover target, order cap, skip cooldown, and fallback supplier all live in Parameter Store under /ir/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 ir-stock-source and ir-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. Keep the supplier-email permission isolated to approve-handler so no scheduled path can ever place an order. Total deployable surface: around eight Lambdas, three 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