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 → unified entries
scheduler.ts — Natural language → ISO datetime conversion
agent.ts — Task execution engine with per-user system prompt
db/
schema.ts — SQLite schema (unified entries + FTS5 + entry links + migration)
queries.ts — Unified CRUD, FTS search, user context builder
- Email arrives at
memory-do@valtown.email - Sender is identified/created as a user
- Interaction is logged as an entry (type:
interaction) - Email is classified by Claude into unified entries — any type: memories, tasks, ideas, people, meetings, projects, or anything new
- System instructions are extracted and persisted to the user's personal system prompt
- All entries are stored in the unified
entriestable with FTS synced - Entries are cross-linked to each other and to the source interaction
- 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, with follow-up questions
- Runs on an interval (every 15 minutes)
- Finds task entries where
next_run_at <= nowandstatus = 'pending' - Executes each via the agent with full user context (including per-user system prompt)
- 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, entries by type, link countsGET /user/:email— full user profile with entries grouped by type and linksGET /migrate— one-time migration from old tables to unified schema
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.
Everything is an entry with a type column. No new tables needed for new content types — just use a new type string.
- users — unique sender emails, optional name, per-user system_prompt (their personal "CLAUDE.md")
- entries — all content: memories, tasks, ideas, interactions, people, meetings, projects, and any future type
- entry_links — relationship graph between entries (cross-references, co-extraction, parent-child, etc.)
| Type | Purpose | Key metadata fields |
|---|---|---|
memory | Long-term facts/preferences | { category: "preference" | "fact" | "context" | "general" } |
task | Actionable items | { task_type: "instant" | "future" | "recurring", result: "..." } |
idea | Captured ideas (explicit only) | { status: "seed" | "exploring" | ... } |
interaction | Raw email log | { direction: "inbound" | "outbound" } |
person | People mentioned by the user | { relationship: "...", contact: { ... } } |
meeting | Scheduled conversations | { with: [...], date: "...", agenda: [...] } |
project | Active codebases/builds | { repo: "...", url: "...", tech: [...] } |
id— primary keyuser_id— ownertype— the content type (any string)status— lifecycle:active,pending,in_progress,completed,failed,archivedtitle— optional, used by tasks/ideas/meetingscontent— the main body text (always present)tags— comma-separated, searchable via FTSmetadata— JSON blob for type-specific fieldssource_entry_id— which entry spawned this (creates a provenance chain)next_run_at— for scheduled entries (tasks, meeting reminders)schedule_description— natural language schedule for recurring entriescreated_at,updated_at— timestamps
The entry_links table creates a relationship graph between entries:
from_entry_id→to_entry_idwith arelationshiplabel- Relationships:
related,created,co_extracted,parent,blocks,became, etc. - Enables: "This idea became this project", "These entries were extracted from the same email", "This person is connected to this meeting"
Single entries_fts table covers all entry types. Indexes title, content, and tags. Synced manually on every insert in queries.ts.
idx_entries_user_type— entries by user + typeidx_entries_user_status— entries by user + statusidx_entries_pending_run— partial index for due task lookupidx_entries_type_created— entries by type + creation dateidx_entry_links_from/to— link traversal
Each user has a system_prompt column on the users table — their personal "CLAUDE.md" that controls how the agent behaves for them.
- User emails: "Be more casual and always use emojis. Call me Paul."
- Classifier extracts
system_instructions:["Use casual, informal tone with emojis", "Address user as Paul"] - Email handler calls
updateSystemPrompt()— appends new instructions, deduplicates - Every future agent call loads this prompt and injects it into the system prompt as "User's Personal Instructions"
- User can see their prompt via the admin endpoint, or say "show me my preferences"
- Tone/style preferences ("be concise", "use bullet points")
- Name/addressing preferences ("call me Captain")
- Format preferences ("always include a summary section")
- Any standing instruction the user gives
- 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)
- 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)
- Per-user system prompt is injected dynamically from the
users.system_promptcolumn - 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 — safe to call multiple times (CREATE TABLE IF NOT EXISTS)- FTS inserts happen immediately after main table inserts in
addEntry() - Use
Promise.allfor independent parallel queries getUserContext()is the canonical way to build a user context bundle for the agentmetadatais stored as JSON string, parsed inparseEntryRow()
- Returns a unified
entries[]array — each entry has atypeand optionalmetadata system_instructions[]— persistent instructions extracted for the user's promptfollow_up_questions[]— natural follow-ups included in the acknowledgment email- New entry types can be output by the classifier without any code changes
- Voice input tolerance: the classifier prompt explicitly handles transcription errors
- 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 each type. 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.
- Agent has full context — When executing tasks, the agent gets memories, active tasks, recent ideas, recent conversations, FTS search results, AND the user's personal system prompt.
- Two Claude calls per task execution — One for the task response, one for the subject line. Known cost trade-off for better email subjects. Consider structured output to combine.
- Voice input tolerance — Users may send emails via voice transcription. Expect imperfect input. Interpret intent rather than demanding precision.
- Cross-link everything — Every entry should be linked to its source and to related entries. The graph is what makes context retrieval powerful.
- Follow-up questions — After processing, include 1-2 natural follow-ups in the reply to help deepen the conversation. Don't force them.
- No real-time data — The agent can't fetch live stock prices, weather, etc. 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.
- No auth on HTTP admin — The endpoints are open. Add authentication if this becomes public-facing.
- No rate limiting — No protection against flooding.
- No memory deduplication — Same fact can be stored multiple times. Could use semantic similarity to deduplicate.
- Single Claude model — All calls use the same model. Could use a lighter model for scheduling/subject lines.
- No system prompt reset — User can add instructions but can't easily remove or reset them yet. Add "reset my preferences" as a recognized command.
This agent should grow organically based on usage patterns. When you notice recurring needs:
- New entry types need zero code changes — just use a new type string in the classifier. The unified model handles it.
- Refine classification — If the classifier is miscategorizing things, tighten the prompt. Log examples of misclassifications.
- New link relationships — If new relationships emerge between entries, add relationship types to
entry_links. - Proactive suggestions — The agent should notice patterns and suggest improvements, like the journal system's "Claude Tasks" concept.
- Keep this file updated — When the system changes, update CLAUDE.md. This is the source of truth.
The system was migrated from separate tables (memories, tasks, ideas, interactions) to a unified entries table. The old tables still exist but are no longer used. The /migrate endpoint runs the migration. Old FTS tables (memories_fts, tasks_fts, ideas_fts, interactions_fts) are superseded by entries_fts.