Back to articles
/6 min read/Personal

Daily recaps with Cron, a Sandbox, and Claude Code

TL;DR: The v1 of this pulls my activity from Calendar, Gmail, Slack, Notion, and GitHub. It hands the blob to Claude Code running in a Vercel Sandbox, then distributes the result out to a Notion page, a private repo, and a Slack DM. A recap of my day (the commitments I made, things I learned, DMs I owe a reponse too, open threads that need my attention you know... all the things you get paid to do), shipped and recorded every evening.

Code lives here: github.com/ryanxkh/daily-recap. This post is the why behind the architecture, but the full source, schemas, and prompt template are in the repo (so you can just copy that repo link and build this much more quickly than I did).

Major shout to Drew Bredvick for the inspo. His build and breakdown can be found here


What this solves

  • State loss between days. The recap compounds into a searchable archive of what I did, committed to, and learned.
  • Unanswered asks. A loose_ends section flags emails and DMs I didn't reply to.
  • Meeting prep tax. tomorrow_on_deck lists tomorrow's meetings with one-line prep notes (v1.5).
  • Selective attention. Hard rules in the prompt drop the noise — promo emails, security notifications, @channel scroll-bys, CI/CD bot chatter — so I don't have to.

Architecture at a glance

Show code
Vercel Cron (0 23 * * * UTC — 6pm CDT / 5pm CST)
  ↓
/api/cron/recap (Next.js App Router, Node runtime)
  ↓ start() returns runId immediately
dailyRecapWorkflow (WDK, "use workflow")
  ↓
  Step 1 — parallel prefetch (5 "use step" fns)
    Calendar | Gmail | Slack | Notion | GitHub
      each returns Result<T> = { ok: true, data } | { ok: false, reason }
  ↓
  Step 2 — synthesize
    Boot Vercel Sandbox from snap_xxx
    Run `claude` headless with prompt + JSON schema
    Extract + Zod-validate the Recap
  ↓
  Step 3 — parallel fan-out
    writeNotionPage(recap)       → Notion Daily Briefs DB
    writeArchiveMarkdown(recap)  → private github repo commit
  ↓
  Step 4 — sendSlackDM(TL;DR + Notion URL)
  ↓
  Step 5 — log metadata

Any prefetch can fail without aborting. The workflow carries sources_degraded forward and the recap ships with a visible callout. A hard failure in synthesis triggers a Slack error-alert DM and aborts.

The one decision that mattered that really mattered was the prefetch taking place outside the sandbox.

The version of this I expected to build was going to be pointing Claude Code at a bunch of MCP connectors (Google, Slack, Notion, GitHub), let the agent loop figure out what to fetch, and have it write the recap. Basically whats on trend right now and is most likely titled "Agentic Chief of Staff" on someones substack post. However, after building something like that, and then seeing what Drew Bredvick had built, this Cron and Sandbox made so much more sense.

So, we do a little "fucking around and finding out" and landed on the below as my prefered solution. This workflow prefetches every source deterministically outside the sandbox using official SDKs and Claude only sees the assembled blob of it all. This means it only synthesizes that blob of information and doesn't actually fetch it.

Why this way?

  • Faster and cheaper (I think). There is no tool-use round trips. The prompt goes in and the JSON comes out so just one single model call.
  • Predictable. A deterministic prefetch layer with five Result<T> outputs means I know exactly what Claude will and won't see.
  • Tokens stay out of the Sandbox. Google, Slack, Notion, and GitHub credentials never enter the microVM and the Sandbox only gets ANTHROPIC_API_KEY and a prefetched JSON blob.
  • The Sandbox still earns its keep — Edge Functions are V8 isolates that can't run Node binaries at all, and Node Functions would fight the 250MB bundle cap plus reinstall the CLI on every cold start. A pre baked Sandbox snapshot sidesteps both with Linux microVM, Claude CLI already installed, and boots in a second or two.

If you're building a personal tool on top of Claude Code, the default MCP-everywhere pattern isn't always the right one. A "prefetch then synthesize" pipeline probably beats it for anything where the data sources are known and stable. This also makes building out new repeatable automations much easier in the future.

Stack

PieceWhy it's here
Vercel CronPlatform-native scheduler. Config lives in vercel.json.
Next.js App Router (Node runtime)Hosts the cron route handler.
Vercel Workflow (WDK)Durable orchestration. Each step retries independently, the run shows up in a trace, state survives crashes.
Vercel SandboxFirecracker microVM. Boots from a snapshot so the Claude Code CLI is pre-installed.
Claude Code CLI (claude)Agent loop, model access, JSON-schema output — all provided. I don't maintain any of it.
ZodOne schema drives the JSON schema passed to claude, the runtime validation, and the types every sink consumes.
Google / Slack / Notion / Octokit SDKsDeterministic prefetch. Faster, cheaper, more predictable than MCP.

Three failure modes I hit on first deploy

Each was a one-to-three-line fix, but each cost real debugging time.

1. "use workflow" silently no-op'd. I forgot to wrap next.config.ts with withWorkflow from workflow/next. The directive became a no-op. No build error — just a useless "invalid workflow function" at runtime. One-line fix.

2. VERCEL_SANDBOX_SNAPSHOT_ID missing in prod env. Worked locally via .env.local, blew up on Vercel the first time. Set it for both production and preview. One env add.

3. Claude wrapped the JSON in markdown fences. claude --output-format json --json-schema ... returns different shapes across CLI versions: sometimes root.structured_output, sometimes root.result as a string with ```json ... ``` fences around it. JSON.parse on the raw string fails. I added a stripMarkdownJsonFence + multi-path extractRecapJson to absorb the variation. Code in lib/sandbox.ts.

End-to-end first real recap was ~1m 36s, with the Slack degradation surfacing exactly as designed.

Vercel Workflow trace view of a dailyRecapWorkflow run — all five prefetch steps in parallel, synthesizeStep dominating runtime, total 1m 36s.
The WDK trace view. Five prefetch steps fire in parallel at the top; synthesizeStep (Claude in the Sandbox) owns most of the runtime. 1m 36s end-to-end.

Implementation order, if I were doing it again

  1. Scaffold Next.js App Router. Add withWorkflow in next.config.ts immediately — skipping step 1 of the WDK setup is how you get silent no-ops.
  2. Write the Zod schema first. It's the contract between Claude, the workflow, and the sinks.
  3. Build the prompt against fake prefetch data. Iterate on selectivity and voice until you'd be happy reading the output.
  4. Set up external access: Google OAuth refresh token, Slack app, Notion integration + DB, GitHub PAT. All tokens go into Vercel env.
  5. Write one prefetch source end-to-end (Calendar is simplest). Lock the Result<T> shape so the other four are copy-paste.
  6. Write the remaining four sources.
  7. Bake the sandbox snapshot. Save the snap_xxx to Vercel env.
  8. Write the sandbox runner: boot snapshot, write prompt + schema via heredoc, run claude, extract the JSON.
  9. Write the sinks (Notion, GitHub archive, Slack).
  10. Wire the WDK orchestrator. "use workflow" at the top of the file, "use step" on each step function.
  11. Write the cron route. Verify CRON_SECRET, call start().
  12. Configure vercel.json, deploy, trigger manually with curl before trusting the schedule.

Trade-offs and known gaps

  • Boxed in by the Claude Code CLI. If I ever need a planner/executor pattern with different models per step, this design doesn't bend without surgery. For a personal recap, that's a good trade.
  • Snapshot lifecycle. Re-bake when Anthropic ships a new CLI version. Two minutes, one env change, easy to forget.
  • Slack reads half-working (v1 gap). search.messages requires a user token (xoxp-). v1 uses a bot token, so Slack search calls return not_allowed_token_type and the section marks itself degraded. Recap still ships.
  • Tomorrow-on-deck empty (v1 gap). Calendar prefetch grabs today only; the tomorrow window is a v1.5 tweak.
  • Octokit deprecation. search.issuesAndPullRequests is deprecated. Works today, will break eventually.
  • Single-fire assumed. No idempotency guard yet — if cron fires twice, you'd get two Notion pages. Fine for personal; a real product would key off date + runId.
  • First-run setup is a lot. OAuth dance, Slack app, Notion schema, GitHub PAT, env, snapshot bake. 30–60 minutes the first time.

If you want to build this

The full implementation with the schema, prompt, workflow, sandbox runner, all five prefetch sources, all three sinks, the snapshot baker, and an ADMIN_SETUP.md checklist is in the repo github.com/ryanxkh/daily-recap. Fork it, or give the repo url to your vibe coding agent of choice (also can we stop calling it vibe coding and start calling it "agentic coding"?) and you'll have the same thing running in a couple of hours.