Do this as a Val Town “remixable app template”: one user remixes, pastes their Readwise token in environment variables, and instantly gets the full triage + search + projects experience (and you never touch their token).[1][2] Below is a Townie-ready build spec (files, endpoints, schema, and agent tool contracts) that fits Val Town’s Deno runtime + HTTP triggers + SQLite.[1]
Create a new Val Town project with these files/vals (TypeScript everywhere).[1]
Use one HTTP val as the single entrypoint that serves both the SPA and the JSON
API using routing (Hono is compatible with Val Town’s Request/Response
model).[1] Use Val Town SQLite (std/sqlite) as the local index so the UI can
query 10k docs fast, while Readwise stays the source of truth.[1]
Files to create
app/http.ts(HTTP val): routes/api/*,/webhooks/readwise, and serves SPA assets for everything else.[1]lib/env.ts: env var loading + validation (Readwise token, AI provider key, admin secret).[1]lib/readwise.ts: Readwise client: list/paginate, update, delete, tags list, auth check.[2]lib/db.ts: sqlite helpers (migrations, row mapping, indexes).[1]lib/sync.ts: incremental sync runner (pagination + updatedAfter watermark).[2]lib/agent.ts: Mastra agent + tool definitions + structured “plan” schema.[3]jobs/sync/interval.ts(Cron/Interval val): callsrunSync()every N minutes.[2]web/index.html,web/app.js,web/styles.css(static frontend files) served by Val Town file serving utilities or SPA fallback.[1]
Environment variables (per-user remix)
READWISE_TOKEN(required): used asAuthorization: Token XXXfor Readwise Reader API calls.[2]OPENAI_API_KEY(or your chosen provider key) (required for AI features).[1]APP_ADMIN_SECRET(required): simple shared secret for protecting your endpoints (since Val Town URLs are public-by-URL).[1]
Implement these HTTP routes in app/http.ts (all JSON, all require
Authorization: Bearer <APP_ADMIN_SECRET> except /health).[1]
Core
GET /health→{ ok: true }(no auth, used for webhook “test endpoint”).[4]POST /api/sync/run→ triggers sync now; returns{ started: true }.[2]GET /api/stats→ counts by location/category/tag + sync status from SQLite.[1]
Inbox Zero
GET /api/inbox/queue?location=new|later&limit=50→ list items from local SQLite. [2][1]POST /api/docs/:id/triagewith{ location, tagsAdd?, tagsRemove?, notes? }→ patches Readwise viaPATCH /api/v3/update/<id>/then updates local SQLite.[2]
Search + saved views
GET /api/search?q=&tag=&category=&location=&from=&to=&progressMin=&progressMax=&limit=&cursor=→ SQL query against local index with pagination.[1]POST /api/viewswith{ name, filterJson }→ stores a saved view.[1]GET /api/views→ list saved views.[1]
Projects (collections + automation)
POST /api/projectswith{ name, rulePrompt, tagKey }→ creates a project that maps to a tag + an AI rule.[3][1]POST /api/projects/:id/apply→ runs AI rule over a candidate set and returns a plan (no writes yet).[3]
AI endpoints
POST /api/ai/planwith{ goal, scope }→ returns a structured plan: a list of proposed patches (moves/tags/notes/title/summary).[3][2]POST /api/ai/applywith{ planId }→ executes patches with rate-limit-aware batching and logs results.[2]
Readwise webhooks
POST /webhooks/readwise→ accepts Readwise webhook JSON and verifies authenticity using the webhooksecretvalue included in the payload.[4]- Subscribe users to Reader event types like
reader.any_document.createdandreader.document.tags_updatedso your index updates in near-real-time.[4]
Use SQLite tables that mirror Readwise docs, plus app-specific metadata.[1]
Tables
docs:id TEXT PRIMARY KEY,title TEXT,author TEXT,site_name TEXT,source_url TEXT,url TEXT,category TEXT,location TEXT,word_count INTEGER,reading_progress REAL,created_at TEXT,updated_at TEXT,published_date TEXT,notes TEXT,summary TEXT,last_moved_at TEXT,saved_at TEXT.[2]tags:key TEXT PRIMARY KEY,name TEXT,type TEXT,created INTEGER(from webhook payload tag objects when present).[4]doc_tags:doc_id TEXT,tag_key TEXT,PRIMARY KEY (doc_id, tag_key).[1]views:id TEXT PRIMARY KEY,name TEXT,filter_json TEXT.[1]projects:id TEXT PRIMARY KEY,name TEXT,tag_key TEXT,rule_prompt TEXT,created_at TEXT.[1]sync_state: single row withupdated_after TEXT,last_sync_started_at TEXT,last_sync_finished_at TEXT,last_error TEXT.[2]ai_plans:id TEXT PRIMARY KEY,created_at TEXT,goal TEXT,scope_json TEXT,plan_json TEXT,status TEXT.[3]ai_jobs:id TEXT PRIMARY KEY,plan_id TEXT,started_at TEXT,finished_at TEXT,applied_count INTEGER,error TEXT.[1]
Indexes
- Index
docs(location, updated_at)for inbox sorting.[1] - Index
docs(category)anddocs(site_name)for filters.[1] - Index
doc_tags(tag_key)for fast “tag view” pages.[1]
Implement the agent as “read-only by default” and force all writes to go through
one validator tool that only allows fields Readwise supports on update.[3][2]
Use Agent.generate() with maxSteps > 1 so the agent can search, inspect,
propose, and refine without looping forever.[5][3] Return structured output for
the plan so your UI can show a diff and your apply step can be deterministic.[3]
Tools (the only ones the agent can call)
searchIndex(args)→ queries SQLite and returns doc ids + thin metadata.[1]getDoc(args)→ returns full local doc row + current tags.[1]proposePatch(args)→ (optional) internal helper; or skip and rely on structured output.[3]validatePatch(args)→ rejects anything outside Readwise update fields (title, author, summary, published_date, image_url, seen, location, category, tags, notes).[2]applyPatch(args)→ calls ReadwisePATCH https://readwise.io/api/v3/update/<document_id>/then updates SQLite.[2]rateLimitSleep(args)→ used when Readwise returns 429 (respectRetry-Afterwhen present).[2]
Plan schema (structured output)
Plan { items: Array<{ docId: string, patch: { location?, tagsAdd?, tagsRemove?, notes?, summary?, title? }, rationale: string }> }(no direct HTML/markdown injection; keep it plain text).[3]
“Create the file tree above, implement schema + sync first, then implement
/api/inbox/queue + /api/docs/:id/triage, then add the Mastra plan/apply
endpoints, then build the SPA UI.”
Citations: [1]
llms-full-valtown.txt
[2] Reader API
[3]
LangGraph overview - Docs by LangChain
[4] LangGraph
[5]
Using Agents | Agents | Mastra Docs
[6] Reference: Agent.generate()
[7]
[DOCS] How to use maxSteps and what does it do #2930
[8] llms.txt
[9]
maxSteps on agent tool calling - Mastra
[10] File I/O
[11] Reference: Agent.network()
[12]
Readwise API/Access token
[13] std
[14] Why use Mastra?
[15] Readwise API
[16]
History | sqlite
[17]
Reference: Agents API | Client SDK
[18]
GitHub - Scarvy/readwise-reader-cli: Use Readwise Reader 📖 in the Command-line (CLI) 💻