A Val.town serverless service providing a temporary inbox for content before Obsidian vault processing. This is a buffer layer between input sources (email/HTTP) and processing clients (MCP/scripts).
Key Principle: Simple, stateless inbox. Complex processing happens in clients, not here.
Critical Context:
- Runtime: Deno (not Node.js)
- No filesystem access - use SQLite/blob storage only
- Import npm packages via
https://esm.sh/package - Use Val.town stdlib via
https://esm.town/v/std/library - Serverless execution model - no long-running processes
Standard Library Available:
sqlite- SQLite databaseblob- Binary blob storageemail- Send/receive emails- Vals are exposed as HTTP endpoints, email handlers, or cron jobs
Input Sources → Handlers → Services → Storage → Clients
↓ ↓ ↓ ↓ ↓
Email emailHandler InboxService SQLite MCP Server
HTTP API → httpHandler AttachService Blob Custom Scripts
Handlers (entry points for Val.town):
emailHandler.ts- Email trigger val, processes incoming emailshttpHandler.ts- HTTP val, exportsapp.fetchfor API endpointscleanupCron.ts- Cron val, runs daily to delete old processed records
Services (data layer):
server/InboxService.ts- CRUD operations on inbox records in SQLiteserver/AttachmentService.ts- Store/retrieve binary files in blob storageserver/auth.ts- Bearer token auth & email sender allowlist
Core App:
server/app.ts- Hono HTTP application with all routesserver/db.ts- Drizzle ORM schema definition
Client Integration:
mcp/server.ts- MCP server setup (runs locally, not on Val.town)mcp/tools/- MCP tool implementationsapiClient.ts- HTTP client using Hono's RPC clientlocalMcpServer.ts- Entry point for local MCP server
| File | Val Type | Purpose | Key Functions |
|---|---|---|---|
emailHandler.ts | Email Trigger | Receives emails, saves to inbox | emailHandler(email) |
httpHandler.ts | HTTP | API endpoint, exports app | export default app.fetch |
cleanupCron.ts | Cron | Daily cleanup of old records | cleanup() |
| File | Purpose | Exports |
|---|---|---|
server/app.ts | Hono HTTP app definition | app: Hono, AppType |
server/InboxService.ts | Inbox record persistence | InboxService.{save, markProcessed, retrieveUnprocessed, getById, hardDeleteProcessedRecords} |
server/AttachmentService.ts | Blob storage operations | AttachmentService.{save, get, delete} |
server/auth.ts | Authentication logic | bearerAuthMiddleware, isAllowedSender |
server/db.ts | Database schema | db, inboxRecords table, types |
| File | Purpose |
|---|---|
mcp/server.ts | MCP server setup, registers tools |
mcp/tools/readInbox.ts | Tool: List unprocessed records |
mcp/tools/addRecord.ts | Tool: Create new record |
mcp/tools/markRecordProcessed.ts | Tool: Mark as processed |
mcp/tools/getRecordAttachments.ts | Tool: List record attachments |
mcp/tools/downloadRecordAttachment.ts | Tool: Download attachment file |
mcp/tools/index.ts | Tool registration orchestration |
localMcpServer.ts | Entry point for MCP stdio transport |
apiClient.ts | HTTP client for remote API |
Location: server/InboxService.ts
{
raw: string, // Full text content (email body, etc.)
summary?: string, // Optional summary
from?: string, // Source identifier (email, "manual", etc.)
subject?: string, // Subject line or title
date?: Date, // When received (coerced to Date)
attachmentBlobIds: string[] // Array of blob storage keys
}
Validation: Use inboxRecordSchema (Zod) for validation
Middleware: validateInboxRecord for Hono routes
Table: inbox_records (defined in server/db.ts)
CREATE TABLE inbox_records (
id TEXT PRIMARY KEY, -- UUID
record TEXT NOT NULL, -- JSON-serialized InboxRecord
created_at TEXT DEFAULT current_timestamp,
processed_at TEXT -- NULL until marked processed
);
ORM: Drizzle ORM with LibSQL driver
Access: Via db export from server/db.ts
Keys:
{uuid}- Binary file data (ArrayBuffer){uuid}:meta- JSON metadata:{filename, contentType, size}
Interface: StoredAttachment in server/AttachmentService.ts
All routes except GET / require Bearer token authentication via bearerAuthMiddleware.
| Method | Path | Middleware | Handler | Returns |
|---|---|---|---|---|
GET | / | None | Health check | "Hi mom" |
GET | /api/inbox | Bearer auth | List unprocessed | InboxRecordWithId[] |
POST | /api/record | Bearer auth + validation | Create record | {key: string} (201) |
PATCH | /api/record/:id/processed | Bearer auth | Mark processed | {key, processed: true} or 404 |
GET | /api/record/:recordId/attachments | Bearer auth | List attachments | {recordId, attachments: [...]} |
GET | /api/record/:recordId/attachments/:blobId | Bearer auth | Download file | Binary Response with headers |
-
Ingestion: Item arrives via email or HTTP POST
- Email:
emailHandler.ts→ validates sender → saves - HTTP:
POST /api/record→ validates schema → saves
- Email:
-
Storage:
InboxService.save()creates UUID, saves JSON to SQLiteAttachmentService.save()stores binary + metadata to blobprocessed_atisNULL
-
Retrieval:
- Client calls
GET /api/inbox→ returns items whereprocessed_at IS NULL - Client gets attachment metadata →
GET /api/record/:id/attachments - Client downloads files →
GET /api/record/:id/attachments/:blobId
- Client calls
-
Processing:
- Client transforms item (e.g., creates Obsidian notes)
- Outside this service - client is responsible
-
Completion:
- Client calls
PATCH /api/record/:id/processed - Sets
processed_at = current_timestamp
- Client calls
-
Cleanup:
- Cron runs daily via
cleanupCron.ts - Deletes records where
processed_at <= date('now', '-30 days') - Deletes associated blobs via
AttachmentService.delete()
- Cron runs daily via
Required in Val.town secrets:
INBOX_API_TOKEN- Bearer token for HTTP API authenticationINCOMING_EMAIL_ALLOWLIST- Comma-separated email addresses
Required for MCP client (local):
INBOX_API_URL- URL to deployed httpHandler valINBOX_API_TOKEN- Same token as above
Location: server/auth.ts
Middleware: bearerAuthMiddleware
- Checks
Authorization: Bearer {token}header - Compares with
INBOX_API_TOKENenv var - Returns 401 if missing/invalid
Location: server/auth.ts
Function: isAllowedSender(email: Email)
- Extracts email address from
From:header - Checks against
INCOMING_EMAIL_ALLOWLISTenv var - Logs rejection if not allowed
- Returns boolean
Steps:
- Update
inboxRecordSchemainserver/InboxService.ts - Change table name in
server/db.ts:// DON'T use ALTER TABLE - Val.town limitations export const inboxRecords = sqliteTable("inbox_records_2", { ... }); - Update handlers if field usage changes
- Redeploy vals
Rationale: Changing table name avoids migration complexity in serverless env.
Steps:
- Add route in
server/app.ts:.get("/api/new-endpoint", bearerAuthMiddleware, async (c) => { // handler logic }) - Add auth middleware if needed (usually yes)
- Return proper status codes (200/201/400/401/404/500)
- Update
README.mdAPI reference
Steps:
- Create
mcp/tools/newTool.ts:import type { McpServer } from "npm:@modelcontextprotocol/sdk/server/mcp.js"; import type { Client } from "../../apiClient.ts"; export function registerNewTool(server: McpServer, client: Client) { server.tool("tool-name", "description", { /* schema */ }, async (params) => { // Call client API const response = await client.api.endpoint.$get(); // Return result }); } - Register in
mcp/tools/index.ts:import { registerNewTool } from "./newTool.ts"; export function registerAllTools(server: McpServer, client: Client) { // ... existing registerNewTool(server, client); } - Test with Claude Desktop
Steps:
- Create new handler file (e.g.,
webhookHandler.ts) - Create new val in Val.town with appropriate trigger type
- Transform input to
InboxRecordformat - Call
InboxService.save(record) - Handle attachments via
AttachmentService.save(file)
Principle: Fail-fast with context
- Don't catch unless you can handle locally - let errors bubble
- Use Zod validation for input schemas → automatic error messages
- Return descriptive errors in HTTP responses
- Log before rejecting (e.g., email sender not allowlisted)
Example:
// DON'T
try {
const record = await InboxService.save(data);
} catch (err) {
console.error(err);
throw err; // Useless catch
}
// DO
const record = await InboxService.save(data); // Let it throw
- TypeScript with explicit types on public interfaces
- Functional style preferred over classes
- Services are
constobjects with methods - Pure functions where possible
- Services are
- Small, focused functions - single responsibility
- No try-catch unless local resolution exists
- Zod schemas for all external input
No automated tests - manual testing via Val.town web interface
Email Testing:
- Send test email from allowlisted address
- Check Val.town logs for processing
- Verify via
GET /api/inbox
HTTP Testing:
# Create record curl -X POST https://your-val.web.val.run/api/record \ -H "Authorization: Bearer TOKEN" \ -H "Content-Type: application/json" \ -d '{"raw": "test", "attachmentBlobIds": []}' # List unprocessed curl https://your-val.web.val.run/api/inbox \ -H "Authorization: Bearer TOKEN" # Mark processed curl -X PATCH https://your-val.web.val.run/api/record/{id}/processed \ -H "Authorization: Bearer TOKEN"
MCP Testing:
- Configure Claude Desktop with local MCP server
- Ask Claude to interact with inbox
- Check Claude Desktop logs:
~/Library/Logs/Claude/mcp*.log
Focus Areas:
- Malformed input (invalid JSON, missing fields)
- Auth failures (wrong token, unlisted sender)
- Missing data (nonexistent record IDs, blob keys)
- Edge cases (empty attachments, special characters)
Current Limitations:
- No retry logic for failed processing
- No notification when new items arrive (clients must poll)
- 30-day retention is hardcoded
- No bulk operations (must process one-by-one)
Potential Improvements:
- Push notifications / webhooks when new items arrive
- Configurable retention period
- Bulk mark as processed endpoint
- Attachment size limits
- Rate limiting on HTTP endpoints
Initial Setup:
- Create Val.town account
- Set secrets:
INBOX_API_TOKEN,INCOMING_EMAIL_ALLOWLIST - Deploy
emailHandler.tsas email trigger val - Deploy
httpHandler.tsas HTTP val - Deploy
cleanupCron.tsas cron val (daily schedule) - Note HTTP val URL for client configuration
Local MCP Setup:
- Install Deno
- Create
.envwithINBOX_API_URLandINBOX_API_TOKEN - Configure Claude Desktop (see README.md)
- Restart Claude Desktop
- Test: Ask Claude "What's in my inbox?"
Verification:
- Send test email → check inbox appears
- Call HTTP API → verify auth works
- Use MCP tools → verify connection
- Wait 24h → verify cron runs
Redirects:
- Don't use
Response.redirect()- doesn't work - Use:
new Response(null, {status: 302, headers: {Location: url}})
Imports:
- Always use
https://esm.sh/for npm packages - Val.town stdlib:
https://esm.town/v/std/library - Can reference other vals:
https://esm.town/v/username/valname
State:
- No persistent filesystem - use SQLite or blob storage
- No global state across invocations - serverless
- Use Val.town's
sqliteandblobfrom stdlib
Debugging:
- Check Val.town logs in web interface
console.log()appears in val logs- Errors are captured and displayed
MCP Server runs locally, calls remote API:
- Uses
apiClient.tswith Hono RPC client - Type-safe calls via
hc<AppType>(url) - Tools map 1:1 to API endpoints
Available Tools:
inbox_read→GET /api/inboxinbox_add_record→POST /api/recordinbox_mark_processed→PATCH /api/record/:id/processedinbox_get_attachments→GET /api/record/:id/attachmentsinbox_download_attachment→GET /api/record/:id/attachments/:blobId
Direct HTTP API:
- All endpoints require Bearer token
- Use any HTTP client
- Parse JSON responses
- Handle 401/404/500 errors
Workflow:
- Poll
/api/inboxfor new items - Process items (your logic)
- Mark each as processed
- Handle idempotency (items may reappear if processing fails)
Why separate MCP server from Val.town?
- Val.town vals are HTTP/email/cron only
- MCP requires stdio transport (local process)
- Separation keeps concerns clear: remote storage, local processing
Why not store processed items separately?
- Simple lifecycle: unprocessed → processed → deleted
- SQLite index on
processed_atis efficient - No need for complex archival
Why 30-day retention?
- Balance between "oops, need to reprocess" and storage costs
- Processed items should be in Obsidian by then
- Can be changed if needed (see
InboxService.hardDeleteProcessedRecords)
Why JSON-serialized records in SQLite?
- Schema flexibility without migrations
- Val.town SQLite is lightweight - not for complex queries
- Simple key-value pattern matches use case
Why Hono instead of native Deno HTTP?
- Type-safe RPC client via
hc<AppType> - Middleware support (auth)
- Familiar Express-like API
- Small bundle size for serverless
-
server/InboxService.ts:export const inboxRecordSchema = z.object({ // ... existing fields newField: z.string().optional(), }); -
Update handlers if needed (
emailHandler.ts, etc.) -
Redeploy vals
server/InboxService.ts in hardDeleteProcessedRecords:
sql`${inboxRecords.processed_at} <= date('now', '-60 days')` // Change 30 to 60
See "Adding MCP Tools" section above.
# Check Claude Desktop logs tail -f ~/Library/Logs/Claude/mcp*.log # Test MCP server directly deno run --allow-net --allow-env localMcpServer.ts # Then send MCP protocol messages via stdin
Call from Val.town REPL or add endpoint:
await InboxService.hardDeleteProcessedRecords();
When working on this codebase:
- Consult this file first for architecture
- Consult CLAUDE.md for AI assistant specific guidelines
- Consult README.md for user-facing documentation
- Remember: This is a simple buffer, not a full application
- Val.town constraints: Deno runtime, no filesystem, serverless
- Keep services pure - handlers contain business logic
- Let errors bubble - don't catch unless you can handle
- Test manually - no test suite, use Val.town interface
Most common tasks:
- Schema changes → update Zod schema, change table name
- New endpoints → add to
server/app.tswith auth - New MCP tools → create in
mcp/tools/, register inindex.ts - Debug → check Val.town logs, Claude Desktop logs