How we built AI-powered monthly reports for a diary app using Lambda and Anthropic

Storyie Engineering Team
7 min read

A deep dive into the architecture behind Storyie's monthly report feature — SST Cron, Lambda, Anthropic API, structured JSON output, idempotent processing, and a two-queue email pipeline.

How we built AI-powered monthly reports for a diary app using Lambda and Anthropic

Storyie ships a monthly report for every Pro user: on the first of each month, an automated job reads their previous month's diary entries, runs them through the Anthropic API, and emails a concise analysis covering emotion trends, recurring topics, and memorable quotes. This post is about how that pipeline is designed and the decisions that made it reliable.

TL;DR

  • An SST Cron triggers a Lambda on the first of each month (production only). 15-minute timeout, 1 GB memory.
  • The job fetches Pro users, retrieves their prior-month diaries, converts Lexical JSON to plain text, calls the Anthropic API, writes results to monthly_reports, and enqueues an email notification.
  • Input is capped at 50 entries and 30,000 characters. Statistics are computed from the full dataset before the cap is applied.
  • The AI response is structured JSON defined precisely in the system prompt.
  • The job is fully idempotent: if a report already exists for the target month, it skips that user.

| Concern | Approach |
| -------------------- | ---------------------------------------------------------------- |
| Scheduling | sst.aws.Cron, cron(0 3 1 * ? *), production stage only |
| Compute | Lambda, 15 min timeout, 1 GB RAM |
| AI model | Claude 3.5 Sonnet via Anthropic API |
| Input limits | 50 entries max, 30,000 characters max (stats computed pre-cap) |
| Output format | Structured JSON (emotionTrend, topics, highlights, summary) |
| Multi-language | AI auto-matches diary language; email uses locale-based templates |
| Email delivery | Queued via email_queue table, processed by a separate Cron |
| Idempotency | DB check before insert; safe to retry on partial failures |

Why build this

Retention in a diary app depends on giving users a reason to look back. Reading your own old entries is valuable but has a high activation cost. An AI-generated recap lowers that cost to zero: the report shows up in your inbox on the first of the month, summarizes the emotional arc of your last thirty days, and surfaces a sentence you wrote that you'd likely forgotten.

For us it also anchors the Pro plan. Free users don't get monthly reports, which gives subscribers a recurring tangible benefit.

Architecture

1st of every month, 03:00 UTC
  ↓
SST Cron → Lambda (15-min timeout)
  ↓
1. Fetch Pro users from DB
2. Fetch each user's prior-month diary entries
3. Convert Lexical JSON → plain text
4. Call Anthropic API for analysis
5. INSERT into monthly_reports
6. Enqueue email in email_queue

The Cron is defined in sst.config.ts and is only provisioned when stage === "production":

if (stage === "production") {
  new sst.aws.Cron("MonthlyReportGenerator", {
    schedule: "cron(0 3 1 * ? *)",
    job: {
      handler: "packages/jobs/src/handlers/reports/monthlyReport.handler",
      runtime: "nodejs22.x",
      timeout: "15 minutes",
      memory: "1024 MB",
    },
  });
}

The 15-minute timeout and 1 GB memory allocation exist because the job's runtime grows linearly with the number of Pro users — one Anthropic API call per user. For the current scale of a small subscription product, a single sequential Lambda handles this comfortably. Step Functions would be the natural upgrade path if that changes.

The design decisions

1. Lexical JSON to plain text

Storyie stores diary content as Lexical editor state — a JSON structure that captures node types, marks, and nesting. To pass diary content to the AI, we need readable text, not JSON.

The conversion is handled by extractPlainTextFromLexicalWithNewlines, a helper that walks the Lexical node tree and preserves paragraph breaks. That last part matters: naively extracting text nodes concatenates all content into a single wall of text, which degrades the AI's ability to track context across separate thoughts. Keeping paragraph boundaries in the plain text output consistently produces better analysis.

2. Input size limits

Two constraints apply before anything reaches the API:

  • Entry count: at most 50 entries per user (MAX_DIARIES_PER_USER)
  • Character count: concatenated text truncated at 30,000 characters (MAX_INPUT_CHARS)

Critically, statistics — total entries, total word count, longest streak — are derived from the full untruncated set. Only the text that goes to the AI is limited. For prolific writers, word count is extrapolated:

const totalWords =
  totalEntries <= MAX_DIARIES_PER_USER
    ? sampledWords
    : Math.round((sampledWords / userDiaries.length) * totalEntries);

This prevents the report from claiming someone wrote 3,000 words in a month when they actually wrote 12,000.

3. Structured JSON output

The system prompt defines the exact response schema and instructs the model to return only JSON:

const systemPrompt = `You are a diary analysis expert.
Respond using the following JSON format only. Output JSON and nothing else.

{
  "emotionTrend": [...],
  "topics": [...],
  "highlights": [...],
  "summary": "..."
}`;

Structured output serves three things at once: the frontend can render each field without post-processing, the email pipeline can pull summary and highlights[0] directly, and idempotency checks can reliably store and compare the result. Extraction uses a regex that strips markdown code fences if the model wraps its output in them.

4. Multi-language handling

Diary language is not uniform — some users write in English, some in Japanese, some mix both. Rather than running language detection and branching on the result, the system prompt instructs the model to respond in whatever language the diary entries are written in. This works reliably in practice.

Email templates are a separate concern. We maintain pre-translated templates for 10 locales; the dispatch step selects the right one from the user's stored locale preference. Adding a new email language requires only a new template file.

5. Streak calculation

The monthly streak — longest run of consecutive days with at least one entry — sounds simple but has a timezone trap. Dates from the database include time components, and comparing them directly can incorrectly count two entries at 23:55 and 00:05 as two separate days.

We normalize every entry date to UTC midnight before building the sorted day array:

const sorted = [...dates]
  .map((d) => {
    const dt = new Date(d);
    return Date.UTC(dt.getUTCFullYear(), dt.getUTCMonth(), dt.getUTCDate());
  })
  .sort((a, b) => a - b);

const uniqueDays = [...new Set(sorted)];

The Set deduplicates entries that fall on the same calendar day, so multiple entries on one day count once toward the streak.

Email pipeline

After writing to monthly_reports, the job enqueues a record in email_queue — a separate table processed by another Cron (the EmailQueueProcessor) that handles actual delivery via Resend. Decoupling generation from delivery means a transient email failure doesn't require rerunning the AI analysis.

The email body is intentionally minimal:

- **Entries:** {diaryCount}
- **Total words:** {totalWords}
- **Longest streak:** {streakDays} days

### Topics you wrote about most
{topTopics}

### This month's highlight
> {highlightQuote}

We deliberately do not include the full report in the email. The email is a hook; the full report lives in the app. Every email ends with a CTA linking to the in-app report view, and click-through rates from the email consistently outperform in-app notification opens.

Cost

At the scale we're operating, Claude 3.5 Sonnet costs a few cents per user per month. Total monthly spend across all Pro users stays in the single-digit dollar range. Lambda execution cost is negligible. The 100 ms inter-request delay between users keeps us comfortably within Anthropic API rate limits without rate-limit errors, and it slows the Lambda run by a few seconds at most.

Operational lessons

Idempotency is non-negotiable. Lambda functions can be retried — by AWS on failure, or by us when debugging. The job checks whether a report already exists for the target user and month before calling the API or writing to the database. If a report is present, the user is skipped. This makes the entire job safe to rerun at any point with no risk of duplicate reports.

Structured logs pay off immediately. Every meaningful event is emitted as a JSON object to stdout:

console.log(JSON.stringify({
  event: "report_generated",
  userId,
  diaryCount: userDiaries.length,
}));

CloudWatch lets you filter on event: "report_generated" or query for a specific userId without parsing free-form log lines. When a user reports a missing report, the investigation takes seconds rather than minutes.

Production-only scheduling eliminates accidental costs. Without the stage === "production" guard, the Cron would fire in every SST stage — including local dev and staging. That would burn Anthropic API credits and send test emails to real users. The guard is a single conditional around the entire sst.aws.Cron block.

Takeaways

  • SST Cron + Lambda handles low-frequency batch jobs cleanly for subscription-scale products. Step Functions is the natural next step if parallelism becomes necessary.
  • Preserving paragraph structure when converting Lexical JSON to plain text noticeably improves AI output quality.
  • Computing statistics from the full dataset before applying input size limits means heavy users get accurate numbers even when the AI sees only a sample.
  • Structured JSON output from the system prompt makes the same AI response usable by the frontend renderer, the email template, and the idempotency check — no transformation layer required.
  • Idempotency and production-only guards are not optional for any scheduled job that calls a paid external API.

Related Posts

Try Storyie

Monthly reports are a Pro feature. Start a free account at storyie.com and write for a month — the report arrives in your inbox on the first.