AWS Builder Center
Weekend Annoying Task Challenge: WeekWrap

Weekend Annoying Task Challenge: WeekWrap

Every Friday you have to write "what I did this week," and nobody remembers Monday by Friday. WeekWrap fixes that. Drop ten-second notes as work happens. No button, no reminder: the draft is waiting before you open your laptop. Skim it, tweak a line, paste it. The worst part of Friday now happens without you

Series: Challenges :) (4 articles)

  1. …
  2. 4
    Weekend Annoying Task Challenge: WeekWrap This article
World's Youngest AWS Community Builder and AWS SBGL | 2x AWS Certified | Ex-AWS SBCL


Vision & What the App Does

Every Friday, the same small tax comes due. Someone needs "what you did this week" — a standup doc, a manager's status thread, a client update email. And every Friday, I sit there trying to reconstruct Monday from memory, scrolling my own git log and Slack history like a detective investigating my own life.
The work wasn't the problem. The remembering was.
WeekWrap is a tiny web app that fixes this by moving the effort to where it's cheap. During the week, whenever something happens, I drop a one-line note into the app: "fixed the auth token refresh bug," "client demo went well, they want SSO next." It takes about ten seconds and requires zero thought, because I'm writing it while the context is still in my head.
Then on Friday morning at 9am, without me opening anything or clicking anything, a scheduled agent wakes up, collects the week's notes, and writes the update for me. By the time I sit down, the draft is already waiting — grouped into themes, written in the tone I picked, ready to skim, tweak one line, and paste.
The distinction I care about: this is not a chatbot that writes a status update when I ask it to. Asking is the part I was trying to eliminate. WeekWrap runs whether I remember it or not.

How I Built It

The frontend is deliberately plain — vanilla HTML, CSS, and JavaScript, no framework, no build step. That wasn't nostalgia. The entire app is three screens and one fetch wrapper, and a bundler would have added more configuration than code. Shipping is a folder sync to S3.
The three screens map to the three moments the app matters:
  1. Capture — a single text input and a Save button. Nothing else, because friction here kills the whole product.
  2. Week view — the notes I've logged, newest first, so I can see what the agent will be working from.
  3. Draft review — Friday's generated update, editable in place, with a copy button.
Key decisions and why:
One Lambda, one catch-all route. The function does its own routing rather than declaring a route per endpoint in the infrastructure. Seven endpoints in one place, no usage plans, no request transformation, one thing in the diagram. I started with a Lambda Function URL for this and ended up on an API Gateway HTTP API instead, which is a story in the challenges section below.
A DynamoDB key design that made the Friday job trivial. This was the decision that saved the most work. Instead of storing notes with a plain user partition key and filtering by date later, I bucketed the week directly into the partition key:
1
2
3
PK: USER#<userId>#WEEK#2026-W31
SK: NOTE#<utc-timestamp>#<nonce> (a captured note)
SK: DRAFT#current (the generated update)
Because the week is part of the key, "get everything from this week" is one Query on a single partition — no secondary index, no scan, no date filtering in application code. The generated draft lives in the same partition as the notes it came from, so reading a week is one round trip. I also set a TTL attribute on notes so old weeks quietly delete themselves and the table never grows unbounded.
Nova Lite over Nova Pro. Summarizing a dozen short bullet points into a structured paragraph is not a hard reasoning task. Nova Lite was fast, substantially cheaper, and I could not tell the difference in output quality for this job. Reaching for the biggest model by default is a habit worth resisting.
EventBridge Scheduler for the trigger. This is the piece that makes it an agent rather than a tool. A cron expression fires the Lambda every Friday at 9am, it queries the week's partition, calls Bedrock, and writes the draft back to DynamoDB before I've opened my laptop.
Python on the backend, because of what wasn't installed. I started reaching for Node, then actually checked the machine: no node, no npm, no Homebrew to install them with, no AWS CLI, no SAM CLI. Python 3.9 and git were there. That turned out to be the better path anyway. boto3 ships preinstalled in the Lambda Python runtime, so the deployment package has zero dependencies and is just a zip of five source files. The local dev server runs on the standard library alone, so anyone can clone this and run it without installing a thing. The frontend stayed vanilla HTML/CSS/JS as intended.
Not having SAM also pushed me to plain CloudFormation plus a small boto3 deploy script. CloudFormation still owns every resource declaratively; the script only does what a template can't — zip the code, push it to S3, drive the stack, upload the frontend, and rewrite config.js with the real Function URL.
Designing against hallucination. This was the part I was most wary of, so I built the guardrails before I had a problem. Given three thin notes, a helpful-by-default model will happily return a confident five-bullet update padded with work you never did, which is worse than useless in a document your manager reads.
The prompt is therefore mostly constraints rather than description: every bullet must trace back to a specific note; never invent metrics, ticket numbers, tool names or people that aren't in the notes; and a short update is a correct update — padding is a failure, not helpfulness. Temperature sits at 0.2. There's also a guard in code: if a week has no notes at all, the model is never called, because asking it to summarise nothing is an invitation to invent a week.
Against real Nova Lite, that half worked — and the half that failed was the interesting part.
Challenges:
Two real bugs, both caught by tests rather than by me. Writing a small assertion suite for the week-key maths felt like over-testing a toy app. It found two genuine defects immediately.
The first: my week-label formatter had three format placeholders but only two arguments in the branch that handles a week sitting inside a single month. Every such week raised an IndexError. It looked completely fine in manual testing, because the week I happened to be building in — 27 July to 2 August — spans two months and took the other branch. Only a test against an arbitrary week exposed it.
The second was subtler and would have been miserable to debug later. Notes were sorted by a key of NOTE#<timestamp>#<random-nonce> with second precision. Two notes saved in the same second therefore tied on the timestamp and fell through to sorting by a randomvalue, so rapid notes came back shuffled. The fix was microsecond precision, normalised to UTC so that lexicographic ordering is always chronological even across a daylight-saving transition. The display timestamp stays local so the UI still shows the day you actually wrote it.
The model didn't invent work. It quietly lost some. My guardrails were aimed entirely at the wrong failure. Nova Lite never once fabricated an accomplishment — the anti-invention rules held perfectly. What it did instead was drop a note. Eight notes in, seven bullets out. "Wrote the migration script for the orders table, still testing it" simply wasn't there.
I'd written seven rules about not adding things and not one about covering everything. So I added an eighth: account for every note, half-finished work still counts, dropping a note is as much a failure as inventing one. Redeployed, ran it again, and it dropped a different note — the client demo this time. Asking more firmly just moved which note went missing.
That's when I stopped negotiating. "Was every note used?" is a property I can check, so it had no business depending on the model's cooperation. I changed the output schema so every bullet cites the numbers of the notes it came from:
1
{"text": "Fixed the auth token refresh bug", "notes": [1]}
Now the code collects the cited indices, discards any outside the valid range (an out-of-range citation is the model inventing a source), and diffs against the notes that went in. Anything uncited gets appended verbatim under "Also this week". The model cannot lose your work, because losing it is now recoverable without the model's help.
I'm glad I built it that way, because the drop rate turned out to be far less stable than my first test suggested. One run covered all eight notes with the recovery path firing zero times. A later run on nine notes dropped three of them, and the recovery caught all three. If I'd stopped at the clean run and shipped the prompt-only fix, I'd have concluded it was solved and quietly lost a third of a week's notes at some point.
Don't ask a model to guarantee something you can verify in code. A prompt rule is a request. A diff against the input is a guarantee.
Lambda Function URLs, and knowing when to stop. This cost me the most time and taught me the most.
The plan was a Function URL — leaner than API Gateway, one less service. It deployed cleanly and then returned 403 Forbidden on every single request, including the unauthenticated health check. The function's resource policy was textbook: Principal: "*", action lambda:InvokeFunctionUrl, condition lambda:FunctionUrlAuthType: NONE. AuthType was NONE. Every environment variable was right.
I recreated the permission through the Lambda API using the exact statement the console generates. Still 403. That was the signal to stop fixing and start diagnosing: two attempts at the same idea had failed, so the idea was wrong.
I wrote a probe that compared an anonymous request against a properly SigV4-signed one. Signed returned 200. Anonymous returned 403. That was the answer — the account permits IAM-authenticated Function URL invocation and refuses unauthenticated invocation, and nothing I could write in a resource policy was ever going to change that.
So I redesigned around it: set the Function URL to AWS_IAM and front it with the CloudFront distribution I already had, using Origin Access Control to sign each origin request. That's genuinely better — the API stops being publicly reachable, and it lands on the same domain as the app so CORS disappears. I wired it up, confirmed OAC type lambda with signing=always, the AllViewerExceptHostHeader origin request policy, distribution status Deployed. Still 403. The CloudFront service principal was refused the same way.
Three strikes on Function URLs, so I moved to an API Gateway HTTP API. The migration was almost free, and for a reason worth knowing: HTTP API's payload format 2.0 is the same event shape a Function URL delivers. My handler parses requestContext.http.methodand rawPath, which both produce identically. Infrastructure changed; not one line of application code did.
It worked immediately. And the response I most wanted to see was 401 rather than 200 — because a 401 is my handler rejecting a bad token, which meant the request was actually arriving.
I kept the CloudFront-in-front design, so the end state is better than what I originally planned: the app and API share one domain, the frontend needs no API base URL at all, and there are no CORS headers anywhere because same-origin requests never trigger a CORS check.
"Friday 9am" is more ambiguous than it looks. A naive cron runs in UTC, which would deliver my Friday update at an hour that isn't Friday morning where I live. EventBridge Scheduler takes a timezone on the schedule itself, so I set it explicitly rather than doing offset arithmetic and re-deriving it every DST change. The same timezone also decides which ISO week a note belongs to, so a note saved late on Sunday night lands in the right week.
Public endpoint, private bill. The API endpoint is reachable by anyone who finds it, and it calls Bedrock — so a stranger with the URL could spend money on my account. I added a bearer token checked in the handler with hmac.compare_digest, and made the function fail closed: with no token configured it refuses every request rather than defaulting to open. The one exception is an explicit ALLOW_ANON flag that only the local dev server sets.
I want to be honest about the limits of that, because it's the kind of thing write-ups tend to oversell. A token held in a browser is obfuscation, not authentication — anyone using the deployed site can read it out of localStorage. It stops scanners and randoms, which is the actual threat model for a personal tool. If this were multi-user, the right answer is Cognito with a JWT authorizer, not a shared secret. The token also never touches the repo: it lives in localStorage and in a gitignored deploy file, and config.js is generated at deploy time containing only the API URL, so the source can be public safely.
CORS solved by deleting it. API Gateway has its own CORS configuration and my handler also emits CORS headers. Setting both is a well-known way to get duplicated or conflicting headers that are miserable to debug, so I gave one owner the job — the handler — and left API Gateway's CORS block unset. My template validator fails the build if anyone ever adds one.
The better outcome came from the architecture. Because CloudFront serves /api/* from the API Gateway origin on the same domain as the app, requests are same-origin and the browser never performs a CORS check at all. So the deployed default is to send no Access-Control-Allow-Origin header, which is stricter than pinning one: no other site can read a response even if it has the URL and a token. The deployed config.js has an empty apiBase for the same reason.
One wrinkle that came out of this: I'd configured CloudFront to map 403 and 404 to /index.html with status 200, the standard single-page-app rewrite. CustomErrorResponses are distribution-wide, so that would also have rewritten genuine API errors into an HTML page with a 200 status, and the frontend would have tried to JSON.parse a document. I removed them. The app is one page with no client-side routing, so there was nothing to rewrite in the first place — and the validator now fails if they come back.

AWS Services Used / Architecture Overview

ServiceRole
Amazon S3Hosts the static frontend (HTML/CSS/JS)
Amazon CloudFrontHTTPS delivery and caching in front of S3
AWS LambdaSingle function: note capture, week reads, and the scheduled draft job
Lambda Function URLPublic HTTPS endpoint, no API Gateway needed
Amazon DynamoDBNotes and generated drafts, week-bucketed partition key, TTL cleanup
Amazon EventBridge SchedulerFires the draft job every Friday 9am in my timezone
Amazon Bedrock (Nova Lite)Turns raw notes into a structured status update
Amazon SES (optional)Emails the finished draft so it lands in my inbox
Deployed in ap-south-1 as a single CloudFormation stack of 15 resources. One detail worth passing on: in that region every Nova model is inference-profile only, so the working model id is apac.amazon.nova-lite-v1:0, not the bare amazon.nova-lite-v1:0. The bare id fails with a ValidationException that says nothing about geography prefixes. My deploy script's preflight command now probes both and tells you which one to use, and the agent retries through the region's inference profile automatically — deriving the prefix from the region rather than assuming us., which was my first, Americas-centric guess**.**

How the agent is triggered — :
The important detail is that the generation path has no user in it. EventBridge invokes the Lambda directly, so it doesn't matter whether the browser is open or the laptop is shut.
Here is a real scheduled run, from CloudWatch. One Bedrock call, 833 input tokens, 366 output tokens, 1.2 seconds end to end:
1
2
3
4
5
6
{"event": "schedule_start", "week": "2026-W31", "users": 1, "tone": "neutral"}
{"event": "draft_generated", "week": "2026-W31", "trigger": "schedule",
"notes": 8, "model": "apac.amazon.nova-lite-v1:0",
"input_tokens": 833, "output_tokens": 366}
{"event": "schedule_complete", "week": "2026-W31",
"results": [{"user": "me", "ok": true, "notes": 8}]}
And the update it produced from eight one-line notes:
Week of 27 Jul - 2 Aug 2026
This week, the auth token refresh bug was fixed, and the checkout retry
logic was merged.
Shipped
  • Fixed the auth token refresh bug that was logging people out mid-session
  • Paired with Ana on the checkout retry logic and merged it
  • Client demo went well, they want SSO next quarter
In progress
  • Wrote the migration script for the orders table, still testing it
Reviewed
  • Reviewed 4 PRs for the payments refactor
Blockers
  • Blocked on the staging DB credentials, still waiting on infra
Next week
  • Need to write up the incident postmortem
    Every line traces to something I actually typed, and all eight notes are accounted for.

What I Learned

Scheduled invocation is what separates an agent from a tool. My first instinct was a "Generate" button, and I nearly shipped that. But a button means I still have to remember, and remembering was the actual annoyance. Moving the trigger from the user to EventBridge changed the product from something I'd use twice to something that works on me passively. The architectural change was small; the behavioral change was the whole point.
Data modeling upstream removes code downstream. Putting the ISO week into the partition key felt like over-thinking a toy app. It meant the Friday job was a single Query with no index and no filtering logic — maybe fifteen lines instead of a paginated scan with date comparisons. The cheapest place to solve a query problem is in the key schema.
Prompt engineering is mostly constraint engineering. I got the biggest quality jump not from describing the output I wanted, but from explicitly forbidding the behavior I didn't: no inventing, no padding, short is acceptable. A model optimizing to seem helpful will fill empty space unless you tell it that empty space is the correct answer.
Default to the smaller model. Nova Lite handled this task well at a fraction of the cost of anything larger: 833 input and 366 output tokens for a full week's update, in 1.2 seconds. Picking the model to fit the task, rather than reaching for the top of the range, is a habit I'm carrying forward.
I guarded against the wrong failure. I spent real effort stopping the model inventing work, and it never once did. It lost work instead. The lesson isn't "add a coverage rule" — it's that I'd reasoned about one failure mode confidently enough to stop looking for others, and only running the thing against a real model showed me the one that mattered. The fix that actually worked wasn't a better prompt either. It was noticing that coverage is checkable in code and therefore shouldn't have been the model's responsibility at all.
One clean test run is not evidence. My first coverage check came back 8 of 8 and I nearly moved on. A later run dropped three notes out of nine. Non-deterministic systems need to be judged over repeated runs, and "it worked when I tried it" is the weakest possible claim about a model.
A live test found a bug that every offline test missed. My rule against overwriting hand-edited drafts was meant to stop the Friday scheduler destroying your edits. It also silently disabled the manual Generate button — press it after editing and nothing happened. Every offline test passed because none of them edited a draft and then asked for regeneration in the same breath. The end-to-end check against the deployed stack caught it in the first run. Intent matters as much as state: the scheduler and a person pressing a button deserve different answers to the same question.
Two failures of the same idea means the idea is wrong. I lost the most time to a 403 on a Lambda Function URL whose configuration was, as far as I could tell, perfect. What eventually solved it wasn't a third fix — it was writing a five-minute probe that compared an anonymous request with a signed one. Signed worked, anonymous didn't, and the answer was immediate and unambiguous. I should have written that probe before the second fix attempt, not after the third. When something is refusing you and the config looks right, stop adjusting the config and go find out what specifically is being refused.
A Function URL is a real public endpoint. It's convenient enough that it's easy to forget it's internet-facing, and mine had a path to Bedrock spend behind it. Any endpoint that costs money per call needs an auth story before it's deployed, not after.
Build the thing so it runs with no cloud account at all. The store has two implementations behind one interface — DynamoDB and an in-memory twin — and the agent has an offline stub behind a MOCK_AI flag. The dev server then runs the real Lambda handler against those, translating plain HTTP into the same Function URL event shape Lambda delivers. So the entire app is usable with no credentials, no deployment, and no waiting on a stack to converge. That inverted the whole rhythm of the weekend: I did the design and debugging offline in seconds-long loops, and treated AWS as a publishing step rather than a development environment.
Test the artifact, not just the source. The check I'm most glad I wrote builds the deployment zip, extracts it to a temp directory, imports the handler from there, and drives both invocation paths — including asserting that it still refuses requests without a token. It catches the whole category of bug where the code works locally but the package is missing a module or has the wrong layout. Same instinct for the infrastructure: a validator parses the CloudFormation template offline with the intrinsic short-form tags registered, checks every Ref/GetAtt/Sub target resolves, and then cross-checks the template against the application code — that the DynamoDB key names match what the store writes, that the TTL attribute names agree, that the scheduler payload is the one the handler actually recognises. Those pairs drift silently, and CloudFormation only tells you after it has started creating resources and made you wait out a rollback.

Link to App or Repo

The live app needs an API token in its settings dialog, since it's my personal notes rather than a shared demo. The repo runs standalone with no AWS account at all.
Clone it and it runs immediately, with no AWS account and nothing to install:
1
2
python3 local/dev_server.py --seed
# open http://127.0.0.1:8000
To watch the Friday code path run on demand:
1
curl -X POST http://127.0.0.1:8000/api/_schedule -d '{}'
Deploying is two commands — python3 infra/deploy.py preflight to check your credentials, region and Bedrock model access, then python3 infra/deploy.py deploy. No AWS CLI or SAM CLI needed.

Built for the Weekend Annoying Task Challenge. Vanilla HTML/CSS/JS frontend, serverless AWS backend, entirely within Free Tier.

Series: Challenges :) (4 articles)

  1. …
  2. 4
    Weekend Annoying Task Challenge: WeekWrap This article
Any opinions in this article are those of the individual author and may not reflect the opinions of AWS.
Enjoyed reading this content? Let the author know!

Your likes, comments, shares, and saves help creators reach more builders.

Loading recommendations

Loading article