Skip to content

Part 7 of 7 · Training reminder bot series ~7 min read

Engineering reference: the training reminder bot architecture

The first six posts are for the person deciding whether to build this. This one is for the person building it. Same system, no analogies: the services by name, the functions, the two tables, the nightly recompute, and the one place a model appears.

Key takeaways

  • Single region, single account. Every resource is regional; nothing is global except the IAM roles.
  • 3 Lambda functions, each with its own execution role. No shared role, no wildcards on resources.
  • 2 DynamoDB tables, each keyed so the concurrency story is a condition expression rather than a lock.
  • One Bedrock model, called once, with a JSON schema it must fill or leave null.
  • Nothing always-on: no instance, no container, no provisioned capacity.

The system, by service name

The training reminder bot drawn with AWS service namesThree boxes across the top outside the AWS account. Staff and requirements, read through the Google Sheets API read-only. The Confirm page, served as static files from S3 behind CloudFront. And SES outbound, carrying assignments and the weekly gap report. Inside the account, three groups. EventBridge carrying a nightly recompute and a weekly report schedule. Three Lambda functions named derive, chase and confirm. And two DynamoDB tables named assignments and completions. A note gives the region as us-east-1, one account, and states that requirements live in a sheet rather than in the database.AWS ACCOUNTStaff + requirementsSheets API, read-onlyConfirm pageCloudFront + S3SES outboundassignments, gap reportEventBridgenightly recompute,weekly reportLambda x3derive, chase, confirmDynamoDB x2assignments, completionsingroundsoutus-east-1. One account. Requirements live in a sheet, never in the database.
Fig 1. The same shape as Part 1 with the service names filled in. Nothing here is new; it is the same three groups, named.
  • Compute
  • Database
  • App integration
  • Management
  • Front-end & mobile

Region and account

  • Region: us-east-1. Chosen because SES inbound receipt rules exist in only a subset of regions and this one has the widest Bedrock model availability. If your data has to stay elsewhere, check both constraints before moving: inbound SES is the binding one.
  • Account: one. This is a small system, and a separate account per environment costs more in wiring than it saves. A dev and a prod stack in the same account, with distinct resource prefixes, is the right size here.
  • Everything is regional. The only global resources are the IAM roles and policies. There is no CloudFront, no global table and no cross-region replication, because nothing here has a latency or durability requirement that would justify them.

Lambda inventory

FunctionTriggerDoesTimeout / memory
tb-deriveEventBridge nightlyRecomputes every person’s list; assigns the difference120s / 1024 MB
tb-chaseEventBridge daily + weeklyNudges, escalates, and builds the weekly gap report60s / 512 MB
tb-confirmFunction URLRecords a completion; refuses self-confirmation15s / 512 MB

Splitting this into separate functions is not about modularity. It is that only one of them needs Bedrock permissions and only one is reachable from the public internet, and neither of those is true if it is one handler behind a router.

IAM, scoped

RoleAllowedOn
tb-derive-roledynamodb:Query/PutItem, secretsmanager:GetSecretValueAssignments and completions; the Sheets credential only
tb-chase-roledynamodb:Query, ses:SendEmailAssignments, read; one verified identity
tb-confirm-roledynamodb:PutItem, bedrock:InvokeModelCompletions; one model arn

No role has a Resource: “*” on anything that writes, and every GetSecretValue grant names a single secret arn. That is why there is more than one secret rather than one JSON blob with everything in it.

DynamoDB schemas

Table: assignments

PK   person            S   j.reed@example.com
SK   requirement       S   abrasive-wheels
     state             S   open | done | not_required
     cause             S   joined | role_change | expiry
     assigned_at       S   2026-07-22
     due_at            S   2026-08-21   — assigned_at + the requirement’s grace
     chased_at         L   [ISO timestamps]
     closed_by         S   the completion id, or the not_required reason

One row per person per requirement. The derive function computes the set
each night and only writes the DIFFERENCE, which is what prevents
duplicate assignments on an unchanged list.

GSI  requirement-index   PK requirement, SK due_at  — the gap report, by course

Table: completions

PK   person            S   j.reed@example.com
SK   completed_at      S   2026-05-14#cmp_9a2f
     requirement       S   manual-handling
     delivery          S   in_house | external_course | external_cert | toolbox
     valid_until       S   2029-05-14   — from the requirement, or the cert’s own date
     confirmed_by      S   supervisor@example.com   — never the subject
     evidence_url      S   a link, not a stored document
     supersedes        S   another SK, for a correction

Append-only. `valid_until` is what the derive function subtracts against,
NOT completed_at — using the wrong one is the duplicate-assignment bug.

Inbound and outbound

  • The confirm page is static files in S3 behind CloudFront, reached through a signed link in the assignment message.
  • Self-confirmation is refused by comparing the confirming identity with the subject on the first line of the handler, before any write.
  • Assignment links are signed, scoped to one person and one requirement, and expire ninety days after the due date.
  • Nothing is written back to the staff or requirements sheets. They are read and never modified.

The model call

  • Model: anthropic.claude-haiku-4-5-20251001-v1:0 on Bedrock, used only to match an externally worded qualification name against your requirement list.
  • Called a handful of times a year, when somebody records an external certificate whose title does not exactly match a requirement.
  • Grounded with your requirement names and aliases, so it picks one of yours or none.
  • Output is a JSON schema with a requirement id and a confidence, both nullable. A null goes to a person with a pick list.
  • Nothing else touches a model. Deriving a required-training list is set arithmetic, and set arithmetic should be code.

Things worth knowing before you build it

  • Subtract on `valid_until`, not on `completed_at`. Getting this wrong asks people to redo training they did last month and destroys trust in the system immediately.
  • Write only the difference on the nightly recompute. A full rewrite will re-send assignment messages for everything, every night.
  • Put the grace period on the requirement. One global due window is wrong in both directions at once.
  • Record an equivalent external qualification as a completion with its own expiry, never as a person-level exception. An exception has no end date.
  • Report the gap by requirement, not by person. Six people waiting on one course date is a procurement task, and chasing six individuals will not move it.

That is the whole system. Seven posts, one diagram at a time, and nothing in it that needs a server.

All posts