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): calls runSync() 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 as Authorization: Token XXX for 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/triage with { location, tagsAdd?, tagsRemove?, notes? }
→ patches Readwise via PATCH /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/views with { name, filterJson } → stores a saved view.[1]GET /api/views → list saved views.[1]Projects (collections + automation)
POST /api/projects with { 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/plan with { goal, scope } → returns a structured plan: a list
of proposed patches (moves/tags/notes/title/summary).[3][2]POST /api/ai/apply with { 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 webhook secret value included in the payload.[4]reader.any_document.created and
reader.document.tags_updated so 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 with updated_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
docs(location, updated_at) for inbox sorting.[1]docs(category) and docs(site_name) for filters.[1]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 Readwise
PATCH https://readwise.io/api/v3/update/<document_id>/ then updates
SQLite.[2]rateLimitSleep(args) → used when Readwise returns 429 (respect Retry-After
when 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) 💻