email-agent
Val Town is a collaborative website to build and scale JavaScript apps.
Deploy APIs, crons, & store data – all from the browser, and deployed in milliseconds.
Viewing readonly version of main branch: v27View latest version
An email-based personal AI agent built on Val Town. Users interact entirely via email — sending messages, tasks, ideas, and memories to memory-do@valtown.email. The agent classifies, stores, executes, and replies.
email-agent/
CLAUDE.md — This file. System instructions and conventions.
main.ts — HTTP endpoint for status/admin/debugging (type: http)
email-handler.ts — Email ingestion point (type: email, address: memory-do@valtown.email)
task-runner.ts — Interval that processes pending/recurring tasks (type: interval)
lib/
classifier.ts — Claude-powered email classification
scheduler.ts — Natural language → ISO datetime conversion
agent.ts — Task execution engine with full user context
db/
schema.ts — SQLite schema (FTS5 for full-text search)
queries.ts — All CRUD operations, FTS sync, user context builder
- Email arrives at
memory-do@valtown.email - Sender is identified/created as a user
- Interaction is logged (inbound)
- Email is classified by Claude into: memories, instant tasks, future tasks, recurring tasks, ideas
- All extracted data is stored in SQLite with FTS indexes synced
- Instant tasks execute immediately via the agent; results are emailed back
- Future/recurring tasks are queued with computed
next_run_attimestamps - Acknowledgment email is sent summarizing what was captured
- Runs on an interval (every 15 minutes)
- Finds tasks where
next_run_at <= nowandstatus = 'pending' - Executes each via the agent with full user context
- Emails results to the user
- Recurring tasks get rescheduled to their next occurrence
- Failed tasks still get rescheduled if recurring
GET /orGET /stats— system-wide counts (users, memories, tasks, ideas, interactions)GET /user/:email— full user profile with all associated data
Uses project-scoped SQLite (std/sqlite/main.ts). Do NOT change to std/sqlite or std/sqlite/global.ts — this project uses its own database, not the user's global one.
- users — unique sender emails, optional name
- memories — long-term facts/preferences per user (category: preference, fact, context, general)
- tasks — instant/future/recurring with status lifecycle (pending → in_progress → completed/failed/cancelled)
- ideas — freeform captured ideas with tags
- interactions — raw log of every inbound/outbound email
Every content table has a corresponding *_fts table. FTS indexes must be synced manually on every insert — this is done in queries.ts. If you add a new content table, add its FTS counterpart in schema.ts and sync inserts in queries.ts.
idx_memories_user— memories by useridx_tasks_user_status— tasks by user + statusidx_tasks_pending_run— partial index for due task lookupidx_ideas_user— ideas by useridx_interactions_user— interactions by user
- ANTHROPIC_API_KEY — required. Used by all Claude API calls (classifier, scheduler, agent). Accessed implicitly by the Anthropic SDK.
npm:@anthropic-ai/sdk@0.39.0— Claude API client (pin the version)https://esm.town/v/std/sqlite/main.ts— project-scoped SQLitehttps://esm.town/v/std/email— Val Town email sending
- TypeScript everywhere (.ts files only)
- File and val names in kebab-case (e.g.,
email-handler.ts,task-runner.ts) - Interfaces for all data shapes crossing boundaries (API responses, DB rows, function params)
- Use
Record<string, unknown>for untyped DB rows, cast fields at point of use - Always use parameterized queries (
args) — never interpolate user input into SQL - Emoji prefixes in console.log for easy scanning: 📧 📝 🧠 📊 💾 💡 ⏰ 🔄 ⚡ ✅ ❌ 📤 🔧 📋
- Every email processing step is wrapped in try/catch
- Task failures still send a notification email to the user
- Recurring tasks get rescheduled even on failure
- FTS search failures are non-fatal (empty tables can cause issues)
- JSON parse failures in classifier/scheduler fall back to sensible defaults
- All Claude calls use
claude-sonnet-4-20250514— keep this consistent - System prompts are defined as module-level constants (not inline)
- Responses are parsed via regex (
/\{[\s\S]*\}/) to extract JSON from potential markdown code blocks - Always have a fallback if JSON parsing fails
initDatabase()is called at the top of every entry point (HTTP handler, email handler, task runner) — safe to call multiple times (CREATE TABLE IF NOT EXISTS)- FTS inserts happen immediately after main table inserts in the same function
- Use
Promise.allfor independent parallel queries getUserContext()is the canonical way to build a user context bundle for the agent
- Don't break the email flow —
email-handler.tsis the critical path. Changes there should be tested carefully. A broken email handler means users get no response. - Idempotent schema —
initDatabase()must always be safe to re-run. UseIF NOT EXISTSon everything. - Keep classifier prompts tight — The classification prompt directly controls what gets stored. Be precise about what counts as a memory vs. idea vs. task. Over-extraction creates noise.
- Ideas require explicit intent — The classifier is specifically instructed to only capture ideas the user explicitly asks to store. Don't weaken this — it prevents every email from spawning spurious ideas.
- Agent has full context — When executing tasks, the agent gets memories, active tasks, recent ideas, recent conversations, and FTS search results. This is intentional — the agent should feel like it knows the user.
- Two Claude calls per task execution — One for the task response, one for the subject line. This is a known cost trade-off for better email subjects. Consider whether a single call with structured output could replace this.
- Voice input tolerance — Users may send emails via voice transcription. Expect imperfect input — misspellings, run-on sentences, homophones. Interpret intent rather than demanding precision.
- No real-time data — The agent can't fetch live stock prices, weather, etc. It acknowledges this in responses. Adding tool use (web search, API calls) would be a significant upgrade.
- No conversation threading — Each email is processed independently. The agent has recent interaction history but doesn't maintain true conversation threads.
- No email HTML rendering — Replies are plain text only. Adding markdown-to-HTML conversion would improve readability.
- No auth on HTTP admin — The
/statsand/user/:emailendpoints are open. Add authentication if this becomes public-facing. - FTS content tables aren't content-synced — The FTS tables use
content_rowidbut aren't configured as content tables (nocontent=parameter). This means deletes/updates won't sync automatically. Currently not an issue since we only insert, but would matter if edits are added. - Single Claude model — All calls use the same model. Could use a lighter model for scheduling/subject lines and a stronger one for complex task execution.
- No rate limiting — No protection against a user flooding the system with emails.
- No memory deduplication — Same fact can be stored multiple times across emails. Could use semantic similarity to deduplicate.
This agent should grow organically based on usage patterns. When you notice recurring needs:
- Propose new capabilities — If users frequently ask for something the agent can't do, suggest adding it (e.g., web search tool, calendar integration).
- Refine classification — If the classifier is miscategorizing things, tighten the prompt. Log examples of misclassifications.
- Add new entity types — If a new category of data emerges (e.g., contacts, projects, bookmarks), propose a new table and update the schema, queries, classifier, and agent context.
- Keep this file updated — When the system changes, update CLAUDE.md. This is the source of truth for how the system works.