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
memory-do@valtown.emailinteraction)entries table with FTS syncednext_run_at timestampsnext_run_at <= now and status = 'pending'GET / or GET /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 schemaUses 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.
| 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 — timestampsThe entry_links table creates a relationship graph between entries:
from_entry_id → to_entry_id with a relationship labelrelated, created, co_extracted, parent, blocks, became, etc.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 traversalEach user has a system_prompt column on the users table — their personal "CLAUDE.md" that controls how the agent behaves for them.
system_instructions: ["Use casual, informal tone with emojis", "Address user as Paul"]updateSystemPrompt() — appends new instructions, deduplicatesFull bidirectional attachment support — receive files from users and send generated files back.
File objects in email.attachments[]attachments/{userId}/{entryId}/{filename}attachments table (filename, MIME type, size, blob key, is_text flag)getTextAttachmentContents()<attachment> tags in its response:
<attachment filename="report.csv" mime_type="text/csv">
col1,col2
val1,val2
</attachment>
email-handler.ts (instant tasks) and task-runner.ts (scheduled tasks) handle outbound attachmentsattachment_count and attachment_names in metadata@valtown/sdk blob API — actual file contentattachments table: metadata linking blobs to entries and userslib/attachments.ts)attachments ( id, entry_id, user_id, filename, mime_type, size_bytes, blob_key, is_text, created_at )
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 sendingemail-handler.ts, task-runner.ts)args) — never interpolate user input into SQLclaude-sonnet-4-20250514 — keep this consistentusers.system_prompt column/\{[\s\S]*\}/) to extract JSON from potential markdown code blocksinitDatabase() is called at the top of every entry point — safe to call multiple times (CREATE TABLE IF NOT EXISTS)addEntry()Promise.all for independent parallel queriesgetUserContext() is the canonical way to build a user context bundle for the agentmetadata is stored as JSON string, parsed in parseEntryRow()entries[] array — each entry has a type and optional metadatasystem_instructions[] — persistent instructions extracted for the user's promptfollow_up_questions[] — natural follow-ups included in the acknowledgment emailemail-handler.ts is the critical path. Changes there should be tested carefully. A broken email handler means users get no response.initDatabase() must always be safe to re-run. Use IF NOT EXISTS on everything.toolCallCount and iterationCount to monitor costs. The subject line is still a separate call.The agent (lib/agent.ts) uses an iterative tool-use loop, not a one-shot request-reply.
tool_use blocks, execute each tool and feed results backend_turn response (or max 15 iterations)| 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_url timeout: 15 seconds, response limit: 50KBresponse — the final text responsesuggestedSubject — email subject line (separate Claude call)attachments — generated file attachmentstoolCallCount — total number of tool calls madeiterationCount — total number of loop iterationsThis agent should grow organically based on usage patterns. When you notice recurring needs:
entry_links.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.