• Blog
  • Docs
  • Pricing
  • We’re hiring!
Log inSign up
paulkinlan

paulkinlan

email-agent

Public
Like
email-agent
Home
Code
6
db
2
lib
4
CLAUDE.md
E
email-handler.ts
H
main.ts
C
task-runner.ts
Connections
Environment variables
1
Branches
1
Pull requests
Remixes
History
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.
Sign up now
Code
/
CLAUDE.md
Code
/
CLAUDE.md
Search
…
Viewing readonly version of main branch: v52
View latest version
CLAUDE.md

Email Agent System

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.

Architecture

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

How It Works

Email Flow (email-handler.ts)

  1. Email arrives at memory-do@valtown.email
  2. Sender is identified/created as a user
  3. Interaction is logged as an entry (type: interaction)
  4. Email is classified by Claude into unified entries β€” any type: memories, tasks, ideas, people, meetings, projects, or anything new
  5. System instructions are extracted and persisted to the user's personal system prompt
  6. All entries are stored in the unified entries table with FTS synced
  7. Entries are cross-linked to each other and to the source interaction
  8. Instant tasks execute immediately via the agent; results are emailed back
  9. Future/recurring tasks are queued with computed next_run_at timestamps
  10. Acknowledgment email is sent summarizing what was captured, with follow-up questions

Task Runner (task-runner.ts)

  • Runs on an interval (every 15 minutes)
  • Finds task entries where next_run_at <= now and status = '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

HTTP Admin (main.ts)

  • GET / or GET /stats β€” system-wide counts, entries by type, link counts
  • GET /user/:email β€” full user profile with entries grouped by type and links
  • GET /migrate β€” one-time migration from old tables to unified schema

Database

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.

Unified Model

Everything is an entry with a type column. No new tables needed for new content types β€” just use a new type string.

Tables

  • 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.)

Entry Types (non-exhaustive β€” new types can be added without schema changes)

TypePurposeKey metadata fields
memoryLong-term facts/preferences{ category: "preference" | "fact" | "context" | "general" }
taskActionable items{ task_type: "instant" | "future" | "recurring", result: "..." }
ideaCaptured ideas (explicit only){ status: "seed" | "exploring" | ... }
interactionRaw email log{ direction: "inbound" | "outbound" }
personPeople mentioned by the user{ relationship: "...", contact: { ... } }
meetingScheduled conversations{ with: [...], date: "...", agenda: [...] }
projectActive codebases/builds{ repo: "...", url: "...", tech: [...] }

Entry Fields

  • id β€” primary key
  • user_id β€” owner
  • type β€” the content type (any string)
  • status β€” lifecycle: active, pending, in_progress, completed, failed, archived
  • title β€” optional, used by tasks/ideas/meetings
  • content β€” the main body text (always present)
  • tags β€” comma-separated, searchable via FTS
  • metadata β€” JSON blob for type-specific fields
  • source_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 entries
  • created_at, updated_at β€” timestamps

Entry Links (the Graph)

The entry_links table creates a relationship graph between entries:

  • from_entry_id β†’ to_entry_id with a relationship label
  • 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"

FTS5

Single entries_fts table covers all entry types. Indexes title, content, and tags. Synced manually on every insert in queries.ts.

Indexes

  • idx_entries_user_type β€” entries by user + type
  • idx_entries_user_status β€” entries by user + status
  • idx_entries_pending_run β€” partial index for due task lookup
  • idx_entries_type_created β€” entries by type + creation date
  • idx_entry_links_from/to β€” link traversal

Per-User System Prompt

Each user has a system_prompt column on the users table β€” their personal "CLAUDE.md" that controls how the agent behaves for them.

How it works:

  1. User emails: "Be more casual and always use emojis. Call me Paul."
  2. Classifier extracts system_instructions: ["Use casual, informal tone with emojis", "Address user as Paul"]
  3. Email handler calls updateSystemPrompt() β€” appends new instructions, deduplicates
  4. Every future agent call loads this prompt and injects it into the system prompt as "User's Personal Instructions"
  5. User can see their prompt via the admin endpoint, or say "show me my preferences"

What it captures:

  • 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

Attachments

Full bidirectional attachment support β€” receive files from users and send generated files back.

Inbound (Receiving)

  • Email attachments arrive as File objects in email.attachments[]
  • Each is stored in blob storage with key attachments/{userId}/{entryId}/{filename}
  • Metadata is tracked in the attachments table (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

Outbound (Generating)

  • 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) and task-runner.ts (scheduled tasks) handle outbound attachments
  • Outbound interaction entries record attachment_count and attachment_names in metadata

Storage

  • Blob storage: @valtown/sdk blob API β€” actual file content
  • attachments table: metadata linking blobs to entries and users
  • Max inbound text extraction: 100KB per file (configurable in lib/attachments.ts)

Tables

attachments ( id, entry_id, user_id, filename, mime_type, size_bytes, blob_key, is_text, created_at )

Environment Variables

  • ANTHROPIC_API_KEY β€” required. Used by all Claude API calls (classifier, scheduler, agent). Accessed implicitly by the Anthropic SDK.

Dependencies

  • npm:@anthropic-ai/sdk@0.39.0 β€” Claude API client (pin the version)
  • https://esm.town/v/std/sqlite/main.ts β€” project-scoped SQLite
  • https://esm.town/v/std/email β€” Val Town email sending

Conventions

Code Style

  • 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: πŸ“§ πŸ“ 🧠 πŸ“Š πŸ’Ύ πŸ’‘ ⏰ πŸ”„ ⚑ βœ… ❌ πŸ“€ πŸ”§ πŸ“‹ 🧬 πŸ‘€

Error Handling

  • 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

LLM Calls

  • 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_prompt column
  • Responses are parsed via regex (/\{[\s\S]*\}/) to extract JSON from potential markdown code blocks
  • Always have a fallback if JSON parsing fails

Database Patterns

  • 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.all for independent parallel queries
  • getUserContext() is the canonical way to build a user context bundle for the agent
  • metadata is stored as JSON string, parsed in parseEntryRow()

Classifier Output

  • Returns a unified entries[] array β€” each entry has a type and optional metadata
  • system_instructions[] β€” persistent instructions extracted for the user's prompt
  • follow_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

Working Style

  • Don't break the email flow β€” email-handler.ts is 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. Use IF NOT EXISTS on 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.

Known Limitations & Future Improvements

  • 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.

Evolving the System

This agent should grow organically based on usage patterns. When you notice recurring needs:

  1. New entry types need zero code changes β€” just use a new type string in the classifier. The unified model handles it.
  2. Refine classification β€” If the classifier is miscategorizing things, tighten the prompt. Log examples of misclassifications.
  3. New link relationships β€” If new relationships emerge between entries, add relationship types to entry_links.
  4. Proactive suggestions β€” The agent should notice patterns and suggest improvements, like the journal system's "Claude Tasks" concept.
  5. Keep this file updated β€” When the system changes, update CLAUDE.md. This is the source of truth.

Migration

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.

FeaturesVersion controlCode intelligenceCLIMCP
Use cases
TeamsAI agentsSlackGTM
DocsShowcaseTemplatesNewestTrendingAPI examplesNPM packages
PricingNewsletterBlogAboutCareers
We’re hiring!
Brandhi@val.townStatus
X (Twitter)
Discord community
GitHub discussions
YouTube channel
Bluesky
Open Source Pledge
Terms of usePrivacy policyAbuse contact
Β© 2026 Val Town, Inc.