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
attachments.ts β Inbound/outbound attachment storage (blob + DB)
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
Full bidirectional attachment support β receive files from users and send generated files back.
- Email attachments arrive as
Fileobjects inemail.attachments[] - Each is stored in blob storage with key
attachments/{userId}/{entryId}/{filename} - Metadata is tracked in the
attachmentstable (filename, MIME type, size, blob key, is_text flag) - Text-readable files (txt, csv, json, md, html, xml, code files) have their content extracted and:
- Fed to the classifier as part of the email body
- Available to the agent when executing tasks via
getTextAttachmentContents()
- Binary files (images, PDFs, zips) are stored but content is not extracted
- The agent can generate files by including
<attachment>tags in its response:<attachment filename="report.csv" mime_type="text/csv"> col1,col2 val1,val2 </attachment> - These are parsed from the response, stored in blob storage, and sent as email attachments
- Both
email-handler.ts(instant tasks) andtask-runner.ts(scheduled tasks) handle outbound attachments - Outbound interaction entries record
attachment_countandattachment_namesin metadata
- Blob storage:
@valtown/sdkblob API β actual file content attachmentstable: metadata linking blobs to entries and users- Max inbound text extraction: 100KB per file (configurable in
lib/attachments.ts)
attachments ( id, entry_id, user_id, filename, mime_type, size_bytes, blob_key, is_text, created_at )
- 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.
- Multiple Claude calls per task execution β The agentic loop may make several calls (one initial + N tool-use rounds + 1 subject line). Log
toolCallCountanditerationCountto monitor costs. The subject line is still a separate call. - 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.
The agent (lib/agent.ts) uses an iterative tool-use loop, not a one-shot request-reply.
- Build initial context (memories, tasks, ideas, conversations, attachments, FTS search)
- Send to Claude with tool definitions β Claude can call tools mid-execution
- Loop: If Claude returns
tool_useblocks, execute each tool and feed results back - Repeat until Claude returns a final
end_turnresponse (or max 15 iterations) - If max iterations hit, ask Claude to wrap up with what it has
| Tool | Purpose | DB function used |
|---|---|---|
search_memories | Full-text search across all entry types | searchEntries() |
get_entries | Browse entries by type/status | getEntries() |
get_linked_entries | Traverse the entry relationship graph | getLinkedEntries() |
store_memory | Save a new memory discovered during task execution | addEntry() |
create_task | Create follow-up/sub-tasks (instant, future, recurring) | addEntry() + parseSchedule() |
fetch_url | Fetch live data from the web (news, APIs, weather, etc.) | fetch() |
MAX_TOOL_ITERATIONS = 15β safety limit on the loopfetch_urltimeout: 15 seconds, response limit: 50KB- HTML responses are auto-stripped of tags for readability
responseβ the final text responsesuggestedSubjectβ email subject line (separate Claude call)attachmentsβ generated file attachmentstoolCallCountβ total number of tool calls madeiterationCountβ total number of loop iterations
- 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.