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

lightweight

todoSweeper

Public
Like
todoSweeper
Home
Code
11
.claude
1
backend
6
frontend
4
shared
3
.vtignore
AGENTS.md
CHANGELOG.md
CLAUDE.md
README.md
deno.json
H
main.http.tsx
Environment variables
15
Branches
2
Pull requests
Remixes
1
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
/
README.md
Code
/
README.md
Search
2/5/2026
Viewing readonly version of main branch: v423
View latest version
README.md

Notion Todo Sync System

A Val Town application that automatically syncs Notion checkbox todos (to_do blocks) to a database. Extracts structured data from your checkboxes, stores it in blob storage, and syncs to a Notion database with intelligent owner resolution and relation mapping.

Table of Contents

  • Overview
  • Project Structure
  • MVC Architecture
  • Search Workflow
  • Block Type Handling
  • Validation & Auto-Assignment Rules
  • Endpoints
  • Cron Jobs
  • Caching
  • Environment Variables
  • Search Configuration
  • Context Gathering & Assignment Logic
  • Relation Mapping
  • Owner Resolution
  • Project Matching

Overview

This system automatically captures checkbox todos from your Notion pages and syncs them to a centralized database:

  1. Search: Scans recent Notion pages for to_do blocks (checkboxes)
  2. Extract: Captures block content including @mentions and dates
  3. Validate: Filters out blocks that are too short (< 5 words by default)
  4. Enrich: Captures author and dates for AI resolution during sync
  5. Store: Saves to Val Town blob storage with timestamp tracking and sync metadata
  6. Optimize: Skips already-synced items to reduce API calls by 90%+
  7. Sync: Creates/updates Notion database pages

Note: Keyword-based search can be added on top of block type search. See Search Configuration.

Project Structure

├── backend/
│   ├── controllers/         # Business logic
│   │   ├── pageController.ts           # Page operations
│   │   ├── todoController.ts           # Block/keyword search logic
│   │   ├── todoSaveController.ts       # Blob → Notion sync
│   │   └── todoOrchestrationController.ts  # Batch workflow
│   ├── crons/              # Time-based triggers
│   │   ├── todoSearch.cron.ts  # Periodic todo search
│   │   └── todoSync.cron.ts    # Periodic database sync
│   ├── routes/             # HTTP handlers
│   │   ├── api/            # API endpoints
│   │   │   └── pages.ts    # Recent pages API
│   │   └── tasks/          # Task automation endpoints
│   │       ├── todoSearch.ts # Single page search webhook
│   │       ├── todoSave.ts # Blob sync webhook
│   │       └── todos.ts    # Batch search & sync
│   ├── services/           # External API integrations
│   │   ├── notion/         # Notion API wrapper
│   │   │   ├── index.ts    # Client initialization
│   │   │   ├── pages.ts    # Page operations
│   │   │   ├── databases.ts # Database operations
│   │   │   ├── blocks.ts   # Block operations
│   │   │   └── search.ts   # Search operations
│   │   ├── aiService.ts    # OpenAI for summaries & owner disambiguation
│   │   └── blobService.ts  # Val Town blob storage
│   └── utils/              # Utility functions
│       ├── notionUtils.ts  # Block transformation
│       ├── blobUtils.ts    # Blob key parsing
│       └── emojiUtils.ts   # Emoji extraction
├── frontend/               # React frontend
├── shared/                 # Shared types and utilities
│   ├── types.ts            # TypeScript interfaces
│   └── utils.ts            # Shared utility functions
├── main.http.tsx           # Application entry point (Hono)
├── CLAUDE.md               # Development guidelines
└── AGENTS.md               # Val Town platform guidelines

MVC Architecture

This application follows a strict 3-layer MVC architecture with clear separation of concerns:

Request → Route → Controller → Service → External API
                      ↓
Response ← Format ← Standard Response ← Result

Layer 1: Routes (backend/routes/)

Responsibility: HTTP handling only

  • Extract request parameters (query, body, headers)
  • Call controller functions
  • Format responses with appropriate HTTP status codes
  • Never contain business logic
// Example: backend/routes/tasks/todos.ts app.post("/", async (c) => { const keyword = c.req.query("keyword") || undefined; const result = await todoOrchestrationController.processBatchTodos( hours, keyword ); return c.json(result, 200); });

Layer 2: Controllers (backend/controllers/)

Responsibility: Business logic and orchestration

  • Validate input data
  • Orchestrate multiple service calls
  • Transform and filter data
  • Return standardized response format: {success, data, error, details?}
  • Never make direct HTTP calls to external APIs
// Example: backend/controllers/todoController.ts export async function processTodoSearch(pageId: string, keyword: string = 'todo') { // Validation if (!pageId) return { success: false, error: "Invalid pageId", ... }; // Call service layer const blocks = await notionService.getPageBlocksRecursive(pageId); // Business logic const matches = blocks.filter(block => searchBlockForKeyword(block, keyword)); return { success: true, data: matches, error: null }; }

Layer 3: Services (backend/services/)

Responsibility: External API calls only

  • Make HTTP requests to external APIs
  • Handle API authentication
  • Parse and normalize API responses
  • Return structured results: {success, data, error}
  • Never contain business logic
// Example: backend/services/notion/pages.ts export async function getPageBlocksRecursive(blockId: string) { const response = await notion.blocks.children.list({ block_id: blockId }); return response.results; }

Golden Rule: Never skip layers! Routes call controllers, controllers call services. This ensures testability, maintainability, and clear separation of concerns.

Search Workflow

The system follows a three-stage pipeline: Notion → Blob Storage → Notion Database

By default, the system searches for to_do blocks (Notion checkboxes). The pipeline remains the same regardless of search mode.

Stage 1: Search & Extract (Notion → Blobs)

Flow:

  1. Get recent pages from Notion (configurable time window)
  2. For each page, recursively fetch all blocks
  3. Find to_do blocks (checkboxes) - or keyword matches in alternative mode
  4. Extract structured data from matching blocks
  5. Validate word count: Skip blocks below MIN_BLOCK_WORDS (default: 5)
  6. Capture context: Extract dates and block author for AI resolution during sync
  7. Save enriched blocks to blob storage with timestamp

Validation & Enrichment:

  • ❌ Word count < MIN_BLOCK_WORDS? → Skip (too short to be meaningful)
  • ✅ Only skips if: too short (below MIN_BLOCK_WORDS)
  • Note: Both owner AND due date are determined by AI during sync, not during search
  • Result: All blobs in storage are meaningful; AI resolves owner and due date during sync

Endpoints:

  • POST /tasks/todo/search - Single page search (webhook-triggered)
  • POST /tasks/todos?hours=24 - Batch search across recent pages

Default Configuration:

  • By default, searches for to_do blocks (Notion checkboxes)
  • No configuration needed - works out of the box
  • To add keyword search, see Search Configuration

Block Extraction:

When a matching block is found, the system extracts and transforms it into a reduced format:

{ todo_string: "Buy groceries for @John due October 30, 2025", block_id: "abc-123-def-456", block_url: "https://www.notion.so/abc123def456", last_edited_time: "2025-10-29T12:00:00.000Z", people_mentions: [{ id: "user-123", name: "John", email: "john@example.com" }], date_mentions: ["2025-10-30"], link_mentions: [{ text: "Project", url: "/page-id" }], sync_metadata: { synced: false, // Needs sync to Notion database target_page_id: undefined // Will be set after first sync (ID of page in todos database) } }

Transformation Details:

  • Dates: Formatted to human-readable (e.g., "October 30, 2025 at 3:00 PM EDT")
  • Original dates preserved: ISO format kept in date_mentions array for Notion API
  • Block URL: Clickable link to original block location
  • Emojis: Extracted for use as page icons

Stage 2: Blob Storage

Storage Format:

  • Key pattern: {projectName}--{category}--{blockId}
  • Example: demo--todo--abc-123-def-456
  • Content: JSON of reduced block structure with sync metadata

Blob Structure:

{ todo_string: "...", block_id: "...", page_url: "...", // Source page URL parent_id: "..." | null, // Parent block ID (for project matching) preceding_heading: "..." | null, // Closest h1/h2/h3 before block (for owner matching) // ... other properties sync_metadata: { synced: boolean, // true = synced to Notion, false = needs sync target_page_id?: string // Cached ID of page in todos database (optimization) } }

Update Logic:

  • Compare last_edited_time of existing blob vs new block
  • If unchanged: Skip save (preserves synced: true status)
  • If changed: Save with synced: false (triggers re-sync)
  • Checkbox state: For to_do blocks, also compare checked state (see note below)
  • Preserve cached target_page_id across updates
  • Prevents data loss from out-of-order processing

Minute-Precision Note: Notion's last_edited_time is rounded to the minute. If you edit a block twice within the same minute, the second edit may not be detected by timestamp comparison alone. For to_do blocks, the system also compares the checked state to catch same-minute checkbox toggles. Other same-minute edits (text changes) will be captured on the next edit in a different minute.

Stage 3: Sync to Notion Database (Blobs → Notion)

Flow:

  1. List all blobs in "todo" category
  2. For each blob, read reduced block data
  3. Optimization: Skip if synced: true (0 API calls)
  4. Optimization: Use cached target_page_id if available (1 API call - update only)
  5. If no cached ID: Query database for existing page by Block ID
  6. Create new page OR update existing page
  7. Mark blob as synced: true and cache page ID

Note: No validation happens during sync - all blobs are guaranteed valid because validation occurs during the search phase (Stage 1).

Endpoints:

  • POST /tasks/todo/save - Sync all blobs to database (webhook-triggered)
  • POST /tasks/todos - Batch workflow (search + sync in one call)

Property Mappings (Blob → Notion Database):

todo_string        → Name (title)
block_id           → Block ID (rich_text)
block_url          → Block URL (url)
page_url           → Page URL (url) - source page where todo was found
last_edited_time   → Todo last edited time (date)
people_mentions[0] → Owner (people)
people_mentions[1..] → Other people (people)
date_mentions[0]   → Due date (date)
link_mentions      → Links (rich_text, bullet list)
matched projects   → Projects db (relation) - see Project Matching
emoji (if found)   → Page icon

Status Sync (optional): Set TODOS_PROP_STATUS=YourStatusProperty to sync checkbox state:

  • ✅ Checked → Status set to "Done" (or first matching: "Complete", "Completed", "Finished")
  • ⬜ Unchecked → Status set to first non-done option (e.g., "Not started", "To Do")
  • Supports Status, Select, and Checkbox property types

Sync Optimization:

The system uses sync metadata to dramatically reduce Notion API calls:

On first sync:

  • Blob has synced: false, no target_page_id
  • Query database → create or update → cache page ID
  • Mark synced: true
  • API calls: 1 query + 1 create/update = 2 calls

On subsequent syncs (no changes):

  • Blob has synced: true
  • Skip immediately
  • API calls: 0 calls (100% reduction)

On subsequent syncs (block changed):

  • Blob saved with synced: false (block edited in Notion)
  • Has cached target_page_id from previous sync
  • Update directly without query
  • Mark synced: true
  • API calls: 1 update (50% reduction)

Performance impact:

  • Before optimization: 100 blobs = 100 queries + 50 updates = 150 API calls
  • After optimization: 90 synced + 10 changed = 0 + 10 updates = 10 API calls (93% reduction)

Block Type Handling

The search uses recursive block fetching to traverse the entire page hierarchy, including nested content.

Recursive Fetching

How it works:

function getPageBlocksRecursive(blockId, containerFilter?) { 1. Fetch immediate children of blockId 2. For each child: - Add child to results - If child.has_children === true: - If containerFilter provided: only recurse if block type is in filter - Otherwise: recurse into all children - Add to results 3. Return flattened array of all blocks }

What this means:

  • ✅ Finds blocks nested inside toggles
  • ✅ Finds blocks nested inside columns
  • ✅ Finds blocks nested inside lists
  • ✅ Finds blocks nested N levels deep

Block Type Optimization

When only block type search is active (no SEARCH_KEYWORDS set), the system optimizes recursive fetching by only traversing into container blocks that can hold children of the target type:

Container blocks (recursed into):

  • to_do - to_do blocks can nest inside other to_do blocks
  • toggle - common pattern for organizing todos
  • column_list / column - layout containers
  • synced_block - can contain any block type
  • callout - can contain nested content
  • quote - can contain nested blocks
  • bulleted_list_item / numbered_list_item - can have nested content
  • template - can contain any block type

Non-container blocks (skipped):

  • paragraph, heading_1/2/3, code, equation - cannot have to_do children
  • image, video, file, pdf, audio, embed, bookmark - media blocks

Performance impact: Significantly reduces API calls by skipping recursion into blocks that cannot contain to_do items. When SEARCH_KEYWORDS is set, all blocks are traversed (no filter applied) to find keyword matches.

Included Block Types

These block types are searchable (block type search matches only the configured type; keyword search checks text content in all blocks):

Block TypeHas rich_text?Notes
paragraph✅Standard text blocks
heading_1, heading_2, heading_3✅All heading levels
bulleted_list_item✅Bullet lists
numbered_list_item✅Numbered lists
to_do✅Checkbox items
toggle✅Collapsible toggles
quote✅Quote blocks
callout✅Callout/alert blocks
code✅Code blocks (captions only)
columnN/AContainer - children are searched
column_listN/AContainer - children are searched

Column Behavior:

  • Column blocks themselves have no searchable text
  • But their children (paragraphs, lists, etc.) ARE searched
  • Example: A todo in a column will be found

Excluded Block Types

These block types are explicitly skipped:

Block TypeReason
unsupportedNot supported by Notion API
buttonAction buttons, not content
tableContainer block, no text content
table_rowCells aren't individual blocks; can't be saved to blob
child_pagePage title not in rich_text format
child_databaseDatabase title not in rich_text format
dividerNo text content
table_of_contentsNo text content
breadcrumbNo text content
image, file, video, pdfMedia blocks (captions could be added later)
bookmark, embedExternal content (could be added later)

Why tables are excluded:

  • Table content lives in table_row.cells[][] (array of arrays)
  • Cells contain rich_text but aren't individual blocks
  • Can't be saved to blob storage as standalone blocks
  • Can't create Notion pages from cell content

Validation & Auto-Assignment Rules

Matched blocks are validated for minimum length before being saved to blob storage. Validation ensures quality; both owner and due date are determined by AI during sync.

Validation & Enrichment for Blob Storage

Matched blocks go through validation before being saved:

1. Word Count Validation (REQUIRED):

  • ✅ Block must have at least MIN_BLOCK_WORDS words (default: 5)
  • ❌ Blocks with fewer words are skipped - too short to be meaningful todos
  • Counts all words including mentions and dates (simple whitespace split)
  • Example: "Buy groceries for @John tomorrow" = 5 words (passes)
  • Example: "todo" = 1 word (skipped)

2. Due Date (AI-DETERMINED during sync):

  • ALL date mentions are extracted from the block (no prefix required)
  • AI determines which date is the due date based on context
  • Example: "finish report @October 30" → AI interprets October 30 as due date
  • Example: "meeting @October 30 about Q4" → AI determines if this is a due date or just content
  • Fallback: If no date in content, AI uses DEFAULT_DUE_DATE setting (default: today)
  • AI always includes a date in both the summary (Name) and the dueDate property for consistency

3. Owner Assignment (AI-DETERMINED during sync):

  • Owner is determined by AI during sync, not during search
  • AI uses context with priority: (1) heading, (2) matched contacts, (3) @mentions
  • Block creator is captured as author for potential use in owner resolution
  • Owner can be null if AI cannot determine from context
  • @mentions populate "Other people" property

Default Due Date (AI Fallback):

  • When no date is mentioned in the todo content, AI uses the default due date
  • Configurable: Set via DEFAULT_DUE_DATE environment variable
  • Options: today (default), tomorrow, one_week, end_of_week, next_business_day
  • Behavior: Default date is passed to AI as context; AI includes it in both summary and dueDate
  • Consistency: AI summary (Name property) and dueDate property always show the same date

Owner Resolution (during sync):

  • Owner is determined by AI during sync using generateSummary() + resolveOwner()
  • AI analyzes context to identify the responsible person:
    1. Heading: If preceding heading contains a person's name → that person is owner
    2. Contact: If matched contacts exist → first contact is owner
    3. Mention: If @mentions exist → first @mention is owner
  • Block creator is stored as author and may be used if owner source is "heading"
  • If AI cannot determine owner, the Owner field is left empty
  • This allows intelligent assignment based on context rather than assuming creator = owner

Result: All matched blocks are saved - no blocks are skipped due to missing people. Owner is resolved during sync.

When Validation Happens

During Search (Stage 1) - todoController.ts:

  • After keyword match is found
  • After block is transformed to reduced format
  • Before saving to blob storage

Not During Sync (Stage 3) - All blobs are guaranteed valid, no checking needed

Validation Logic

// From todoController.ts (search phase) // Step 1: Check minimum word count const minWords = getMinBlockWords(); const wordCount = countWords(reducedBlock.todo_string); if (wordCount < minWords) { console.log( `○ Skipped: block too short (${wordCount} words, minimum: ${minWords})` ); continue; // Don't save to blob } // Step 2: Capture block creator as author (for AI resolution during sync) const creator = getBlockCreator(block); (reducedBlock as any).author = creator; // May be null if no creator // All validated blocks reach this point and get saved // Owner AND due date are determined by AI during sync phase await blobService.saveBlockToBlob("todo", block.id, blobData);

Examples

Valid - @mention + date (8 words):

"Buy groceries for @John due October 30, 2025"
✅ Word count: 8 (passes minimum of 5)
✅ Has @mention (@John → Other people)
✅ Has date mention (October 30)
→ Saved to blob storage
→ Synced: AI determines owner and due date (Oct 30)

Valid - Date without prefix (6 words):

"Buy groceries October 30, 2025 for dinner"
✅ Word count: 6 (passes minimum of 5)
✅ Has date mention (October 30) - no prefix needed
→ Saved to blob storage (author captured for AI resolution)
→ Synced: AI interprets Oct 30 as due date

Valid - Date in context (7 words):

"Meeting with @John October 30 about Q4"
✅ Word count: 7 (passes minimum of 5)
✅ Has @mention (@John → Other people)
✅ Has date mention (October 30)
→ Saved to blob storage
→ Synced: AI determines if date is due date or just context

Valid - No date (5 words):

"Buy groceries at the store"
✅ Word count: 5 (passes minimum of 5)
⚠️  No date in content → AI uses DEFAULT_DUE_DATE
→ Saved to blob storage (author captured for AI resolution)
→ Synced: AI uses default date for both summary and dueDate

Invalid - Too short (2 words):

"Buy groceries"
❌ Word count: 2 (below minimum of 5)
→ NOT saved to blob storage (skipped - too short)

Invalid - Too short (1 word):

"todo"
❌ Word count: 1 (below minimum of 5)
→ NOT saved to blob storage (skipped - too short)

Invalid - Too short with emojis (2 words):

"🍋 🎉"
❌ Word count: 2 (below minimum of 5)
→ NOT saved to blob storage (skipped - too short)

Valid - @Today shorthand (5 words):

"Test the feature @Today please"
✅ Word count: 5 (passes minimum of 5)
✅ Has date mention (@Today)
→ Saved to blob storage (author captured if available)
→ Synced: AI interprets @Today as due date; summary includes "by [today's date]"

Note: With word count validation, most meaningful blocks are saved. Blocks are only skipped if too short (below MIN_BLOCK_WORDS, default: 5). AI determines due date from any date in the content; if no date, uses DEFAULT_DUE_DATE.

Sync Summary

After syncing, the controller reports:

  • Total blobs: All blobs in storage (all guaranteed valid)
  • Pages created: New pages added to database
  • Pages updated: Existing pages updated
  • Pages skipped: Blobs already synced (synced: true)
  • Pages failed: Errors during create/update

Note: Both owner and due date are determined by AI during sync. AI ensures consistent dates in both the summary (Name) and dueDate property.

Endpoints

API Endpoints

GET /api/pages/recent?hours=24

  • Get pages edited in last N hours
  • Filters out archived pages and pages in TODOS_DB_ID database
  • Returns simplified page objects with parent information

Response:

{ "pages": [ { "id": "page-id", "object": "page", "title": "My Page", "url": "https://notion.so/...", "last_edited_time": "2025-10-29T12:00:00.000Z", "parent": { "type": "page_id", "id": "parent-id" } } ], "count": 1, "timeRange": "24 hours" }

Task Endpoints

POST /tasks/todo/search

  • Search single page for checkboxes or keywords (webhook-triggered)
  • Default: finds to_do blocks (checkboxes)
  • Optional: set SEARCH_KEYWORDS to also capture keyword matches
  • Extracts and saves matching blocks to blobs
  • Body: { "page_id": "abc-123" }

POST /tasks/todo/save

  • Sync all blobs to Notion database
  • Validates and creates/updates pages
  • No request body needed

POST /tasks/todos?hours=24

  • Batch workflow: Search recent pages + sync to database
  • Default: finds to_do blocks (checkboxes)
  • Combines search and save in one call
  • Use for manual triggers or cron jobs

Response:

{ "success": true, "pagesSearched": 5, "totalTodosFound": 12, "searchResults": [ { "pageId": "abc-123", "pageTitle": "My Page", "success": true, "blocksFound": 3, "blockIds": ["block-1", "block-2", "block-3"] } ], "saveResult": { "totalBlobs": 12, "pagesCreated": 5, "pagesUpdated": 3, "pagesSkipped": 4, "pagesFailed": 0 } }

Cron Jobs

The system includes two separate cron jobs for automated workflow execution. Crons are time-based triggers that run independently of HTTP requests.

Architecture

Crons live in backend/crons/ and follow the same MVC pattern as HTTP routes:

Cron Trigger → Controller → Service → External API

Key differences from HTTP routes:

  • Triggered by time intervals (not HTTP requests)
  • No request/response cycle
  • Results logged to console only
  • Use .cron.tsx extension for Val Town

Cron 1: Todo Search (todoSearch.cron.ts)

Purpose: Search recent pages for checkboxes (or keywords) and save matches to blob storage

Workflow:

  1. Get recent pages from Notion (last 15 minutes)
  2. Search each page for to_do blocks (checkboxes) by default
  3. Save matching blocks to Val Town blob storage
  4. Does NOT sync to Notion database

Configuration:

  • Lookback window: 15 minutes (optimized for frequent runs)
  • Search mode: Default is to_do blocks; set SEARCH_KEYWORDS to also capture keyword matches
  • Recommended schedule: Every 1 minute
    • 15 minute lookback provides buffer for missed runs
    • Frequent runs catch changes quickly

Output:

=== Cron: Todo Search Started ===
Timestamp: 2025-10-29T12:00:00.000Z

Cron: Search complete - Found 12 todos in 5 pages
Pages with matches:
  - Project Planning: 3 match(es)
  - Meeting Notes: 5 match(es)
  - Weekly Review: 4 match(es)

=== Cron: Todo Search Complete ===

Cron 2: Todo Sync (todoSync.cron.ts)

Purpose: Sync validated todo blobs to Notion database

Workflow:

  1. Read all todo blobs from Val Town blob storage
  2. Validate each blob (requires person mention + date mention)
  3. Query Notion database for existing pages by Block ID
  4. Create new pages or update existing pages (timestamp-based)

Configuration:

  • No parameters: Processes all blobs in storage
  • Recommended schedule: Every 8-12 hours
    • Less frequent than search cron
    • Allows time for blob accumulation
    • Reduces Notion API calls

Output:

=== Cron: Todo Sync Started ===
Timestamp: 2025-10-29T14:00:00.000Z

Cron: Sync complete
Summary:
  Total blobs processed: 12
  Pages created: 5
  Pages updated: 3
  Pages skipped: 4
  Pages failed: 0

=== Cron: Todo Sync Complete ===

Cron 3: Cache Warm (cacheWarm.cron.ts)

Purpose: Warm caches to ensure fast first-load experience

What it warms:

  • Health cache: Ensures dashboard loads instantly (main benefit)
  • Future: Can add more cache warming (relations, project names, etc.)

How it works:

  • Calls getSystemHealth() from the health controller
  • Controller checks cache TTL internally
  • If cache is fresh → returns immediately (no API calls)
  • If cache is stale → builds fresh data and saves to cache

Configuration:

  • No parameters: Just warms caches
  • Recommended schedule: Every 1 minute
    • Keeps health cache warm for dashboard users
    • Minimal overhead when cache is already fresh

Output:

[CacheWarm] Health cache refreshed

or

[CacheWarm] Health cache hit (age: 45s)

Why Three Separate Crons?

Operational flexibility:

  • Search cron runs frequently to capture changes quickly
  • Sync cron runs less frequently to batch database updates
  • Cache warm cron keeps health UI fast for dashboard users
  • Reduces Notion API rate limit concerns
  • Allows manual triggering of each independently

Fault isolation:

  • Search failures don't block syncing existing blobs
  • Sync failures don't block new searches
  • Cache failures don't affect search/sync
  • Each cron can be debugged independently

Cost optimization:

  • Blob storage is cheap and fast
  • Notion API calls are rate-limited
  • Separate crons allow different schedules for different costs

Setting Up Crons in Val Town

  1. Navigate to Val Town UI
  2. Create new cron vals:
    • todoSearch.cron.tsx - Copy content from backend/crons/todoSearch.cron.ts
    • todoSync.cron.tsx - Copy content from backend/crons/todoSync.cron.ts
    • cacheWarm.cron.tsx - Copy content from backend/crons/cacheWarm.cron.ts
  3. Set schedules:
    • todoSearch.cron.tsx: Every 1 minute (* * * * *)
    • todoSync.cron.tsx: Every 1 minute (* * * * *)
    • cacheWarm.cron.tsx: Every 1 minute (* * * * *)
  4. Monitor logs: Check Val Town console for cron execution results

Note: Val Town cron jobs must be separate vals (not files in this project). The files in backend/crons/ serve as templates to copy into Val Town cron vals.

Caching

The system uses blob storage caching to dramatically reduce Notion API calls and improve response times. Caching is automatic and requires no configuration.

Why Caching?

Without caching:

  • Health endpoint: 4-10 Notion API calls per request (1000-2000ms)
  • Sync initialization: ~11 Notion API calls per sync (2-5 seconds startup)

With caching:

  • Health endpoint: 0 API calls when cached (~50ms)
  • Sync initialization: 0 API calls when cached (~100ms)

Trade-off: Property renames in Notion may take up to 1 minute to propagate. This is acceptable because property renames are rare, while health checks and syncs are constant.

How It Works

The system caches two types of data in Val Town blob storage:

CacheKey PatternTTLContents
Health Response{project}--cache--health1 minuteFull health check JSON
Relation Cache{project}--cache--relations1 minuteDatabase schema, relations, page IDs

Cache flow (same for both caches):

Request arrives
    ↓
Check blob cache
    ↓
Age < 1 minute? ──YES──→ Return cached data (instant, ~50ms)
    ↓ NO
Fetch fresh data from Notion (slow, 1-5 seconds)
    ↓
Save to blob cache
    ↓
Return fresh data

Health UI Cache

The /api/health endpoint caches its full response:

  • First request after cache expires: Slow (fetches from Notion)
  • Subsequent requests within 1 minute: Instant (returns cached data)
  • No cron needed: Cache refreshes lazily when accessed
  • Response includes cache info: cached: true and cacheAge: 45000 (milliseconds)

Example cached response:

{ "project": { "name": "demo", ... }, "connections": { ... }, "properties": [ ... ], "cached": true, "cacheAge": 45000 }

Sync Relation Cache

The sync process caches all relation data needed to map todos:

  • What's cached: Database schema, relation properties, page IDs
  • When refreshed: Automatically when stale and a sync runs
  • No extra cron needed: The existing todoSync.cron (runs every 1 minute) keeps the cache warm

Cache contents (serialized to blob storage):

{ relations: [...], // Discovered relation properties targetDbPageIds: [...], // Page IDs in each relation's target database ownerPropertyType: "people" | "relation", statusInfo: { ... }, // Status property config for checkbox sync validatedProperties: { ... } // Property name mappings }

Performance Impact

MetricBeforeAfterImprovement
Health endpoint response1000-2000ms~50ms20x faster
Sync initialization2-5 seconds~100ms20-50x faster
Notion API calls (health)4-10 per request0 (cached)100% reduction
Notion API calls (sync)~11 per sync0 (cached)100% reduction

Staleness Considerations

Because caches have a 1-minute TTL, some changes may be delayed:

Change TypePropagation TimeFrequency
New todo in NotionImmediate (not cached)Common
Todo content changesImmediate (not cached)Common
Property value changesImmediate (not cached)Common
Property renamedUp to 1 minuteRare
Relation added/removedUp to 1 minuteRare
New page in relation DBUp to 1 minuteUncommon

Why this is acceptable: The things that change frequently (todo content) are not cached. The things that are cached (schema, relations) change rarely.

Implementation Details

Files involved:

  • backend/services/blobService.ts - getCache() and setCache() functions
  • backend/controllers/healthController.ts - Wraps buildHealthResponse() with cache
  • backend/controllers/todoSaveController.ts - Wraps buildRelationCache() with cache

Cache key format: {projectName}--cache--{cacheKey}

  • Example: demo--cache--health
  • Example: demo--cache--relations

Serialization: Maps are converted to arrays for JSON storage and restored on retrieval.

Debugging Cache Issues

Force fresh data (health endpoint):

  • Wait 1 minute for cache to expire, OR
  • (Future enhancement) Add ?refresh=true query param

Check cache age:

  • Health endpoint returns cacheAge in response when cached
  • Sync logs "Using cached relation data (age: Xs)" when using cache

Verify cache is working:

  1. Load /api/health - note response time
  2. Load again within 1 minute - should be instant with cached: true
  3. Check cacheAge increases on subsequent requests

Environment Variables

Required environment variables (set in Val Town):

  • NOTION_API_KEY - Notion integration token (required)

    • Get from: https://www.notion.so/my-integrations
    • Required for all Notion API calls
  • TODOS_DB_ID - Notion database ID for todo sync (required)

    • The database where keyword matches are synced
    • Format: abc123def456... (32-character ID without hyphens)
  • PROJECTS_DB_ID - DEPRECATED (optional, still works)

    • No longer needed - relations are auto-discovered from the todos database schema
    • Configure project matching in notion.config.ts instead (see Project Matching)
    • If set, a deprecation warning will be logged
  • SEARCH_BLOCK_TYPE - Block type to search for (always active)

    • Searches by Notion block type (not text content)
    • Default: to_do (Notion checkbox blocks) - works without setting this variable
    • Common values: to_do, paragraph, bulleted_list_item, numbered_list_item
    • Workflow: Create a checkbox, add @person and date = instant todo
  • SEARCH_KEYWORDS - Additional keywords to search for (optional, additive)

    • Adds keyword-based search on top of block type search
    • Comma-separated list of keywords/phrases
    • Example: todo,zinger,bit or todo,😀,🎉
    • Blocks matching the block type OR any keyword will be saved
    • Use this to capture additional blocks beyond the configured block type
  • MIN_BLOCK_WORDS - Minimum word count for blocks to be saved (optional)

    • Blocks with fewer words are skipped (too short to be meaningful todos)
    • Defaults to 5 if not set
    • Word counting:
      • Counts all words including mentions and dates (simple whitespace split)
      • Hyphenated words count as 1 (e.g., "buy-now" = 1 word)
      • Emojis count as words (e.g., "🍋 🎉" = 2 words)
    • Default of 5 accounts for: ~1 word for mention + ~2-3 words for date + ~2-3 words for action
    • Example: "Buy groceries for @John tomorrow" = 5 words (minimum)
  • DEFAULT_DUE_DATE - Default due date for blocks without date mentions (optional)

    • Passed to AI as fallback context when a block has no date mentions
    • AI uses this date for both the summary (Name) and dueDate property
    • Ensures consistent dates even when no date is in the content
    • Supported values: today, tomorrow, one_week, end_of_week, next_business_day
    • Defaults to today if not set
    • Examples:
      • today - Due today (default)
      • tomorrow - Due tomorrow
      • one_week - Due 7 days from today
      • end_of_week - Due next Friday (end of work week)
      • next_business_day - Due next weekday (skips weekends)
  • BLOCK_STABILITY_MINUTES - Minimum age (in minutes) before blocks are saved to blob storage (optional)

    • Only applies to cron-triggered searches (not webhook-triggered)
    • Prevents syncing blocks that are actively being edited
    • Defaults to 0 if not set (no delay - blocks saved immediately)
    • Set to a positive number to add a stability delay (e.g., 2 for 2 minutes)
    • Examples:
      • Not set or BLOCK_STABILITY_MINUTES=0 - All blocks saved immediately (default)
      • BLOCK_STABILITY_MINUTES=2 - Block edited 1 minute ago will be skipped, 3 minutes ago will be saved
      • BLOCK_STABILITY_MINUTES=5 - Block edited 4 minutes ago will be skipped, 6 minutes ago will be saved
    • Webhook/button triggers always bypass this delay regardless of setting (immediate sync on user action)
    • Use case: Set a delay if you frequently edit todos and want cron to wait for "final" versions
  • RECENT_PAGES_LOOKBACK_HOURS - Default lookback window for searching recent pages (optional)

    • System-wide setting that applies to all triggers: cron jobs, frontend dashboard, manual API calls
    • Defaults to 24 hours if not set
    • Must be a positive integer
    • Can be overridden per-request with ?hours=X query parameter
    • Examples:
      • Not set or RECENT_PAGES_LOOKBACK_HOURS=24 - Search last 24 hours (default)
      • RECENT_PAGES_LOOKBACK_HOURS=48 - Search last 48 hours (2 days)
      • RECENT_PAGES_LOOKBACK_HOURS=168 - Search last 168 hours (1 week)
    • Use case: Match to your cron schedule or desired dashboard timeframe
      • Cron every 4 hours → Set to 6-8 hours (buffer for overlap/delays)
      • Cron every 24 hours → Set to 24-48 hours
      • No cron → Set to desired dashboard timeframe
  • NOTION_WEBHOOK_SECRET - API key for protecting webhooks and API endpoints (recommended)

    • Required for production use - protects all /tasks/* and /api/* endpoints (except /api/health)
    • Prevents unauthorized access to your Notion data and webhook triggers
    • Set to any secure random string (e.g., generated password or UUID)
    • Notion webhook configuration: Add custom header X-API-KEY with this value
    • API requests: Include header X-API-KEY: your-secret-value
    • If not set, authentication is disabled (development mode only)
    • Example: NOTION_WEBHOOK_SECRET=abc123xyz789...

    Security note: Without this, anyone can:

    • Trigger your webhooks (causing unnecessary processing)
    • Access recent pages via /api/pages/recent (potential data leak)
    • View page IDs from /api/health and use them to query other endpoints

    Public endpoint exception: /api/health remains public (needed for frontend dashboard)

  • API_KEY - Legacy API key (deprecated, use NOTION_WEBHOOK_SECRET instead)

    • Kept for backwards compatibility
    • Use NOTION_WEBHOOK_SECRET for new deployments
  • CRONS_DISABLED - Toggle between "setup mode" and "production mode" (optional)

    • Setup mode (CRONS_DISABLED=true):
      • No automatic search/sync (test via webhooks instead)
      • No cache warming (config changes reflect immediately in health UI)
      • Recommended while configuring env vars and Notion properties
    • Production mode (not set or false):
      • Automatic search/sync every minute
      • Cache warming for fast dashboard loads
      • Config changes may take up to 1 minute to reflect
    • Note: Crons still execute in Val Town (uses compute), they just exit immediately when disabled

Search Configuration

By default, the system searches for to_do blocks (Notion checkboxes). This is the recommended workflow:

Default Mode: Checkbox Search

How it works:

  1. Create a checkbox in Notion (this creates a to_do block)
  2. Add @person mention (optional)
  3. Add date mention (optional - AI uses DEFAULT_DUE_DATE if none)
  4. Done! Automatically syncs to database

No configuration needed - the system defaults to to_do block type search.

What gets captured:

  • ✅ Checkbox text becomes the todo title (polished by AI)
  • ✅ @mentions populate Owner and Other People
  • ✅ AI interprets dates from content for Due Date
  • ✅ Page links are captured for relation mapping
  • ✅ Checkbox state syncs to Status property (if configured)

Example:

☐ Email @Jane about proposal Friday

Syncs as: Name = "Jane to email about proposal by Friday", Owner = Jane, Due = Friday

Additive: Keyword Search

You can add keyword-based search on top of block type search to capture additional blocks.

Configuration:

SEARCH_KEYWORDS=todo,zinger,🍋

How it works:

  • Block type search always runs (primary)
  • Keyword search is additive - also captures blocks matching keywords
  • A block is saved if it matches the block type OR any keyword

Keyword Matching Logic:

  • Text keywords (e.g., "todo", "bit"):
    • Case-insensitive
    • Word boundary matching (finds "todo" but not "todoist")
    • Uses regex: /\btodo\b/i
  • Emojis (e.g., "😀", "🎉"):
    • Exact match
  • Multi-keyword: Block saved if it matches ANY keyword

Example:

Buy groceries todo @John due October 30

When to use keyword search:

  • You have existing content with "todo" markers you want to capture
  • You want a specific emoji or word to trigger sync
  • You need to match blocks beyond the configured block type

Combined Search

If SEARCH_KEYWORDS is set: Both searches run (OR logic)

  • Block type matches are captured
  • Keyword matches are also captured
  • Example: to_do blocks AND any block containing "urgent"

If SEARCH_KEYWORDS is not set: Only block type search runs (default behavior)

Enrichment (applies to all modes)

Regardless of search mode, all matched blocks have context captured:

  • date_mentions - All dates in the block (AI interprets during sync)
  • author - Block creator is captured for potential use in AI owner resolution

All matched blocks are saved to blob storage. Both owner AND due date are determined by AI during sync. If no date is in the content, AI uses DEFAULT_DUE_DATE (see Context Gathering below).

Context Gathering & Assignment Logic

This section explains how context is gathered from todos and used to populate database fields. The goal is to make "magical" assignments predictable and debuggable.

Code reference: backend/controllers/todoSaveController.ts contains the core owner resolution and relation matching logic.

Two-Stage Processing

StageWhenWhat HappensFiles
SearchWhen pages are scannedExtract block data + surrounding contexttodoController.ts
SyncWhen blobs sync to DBResolve relations, owner, due date, generate summarytodoSaveController.ts

Context Sources

1. Block-Level (Inherent Metadata)

Extracted directly from the Notion block:

ContextDescriptionExample
todo_stringBlock text content"Email client about proposal"
people_mentions@mentions with user IDs@Jane Smith
date_mentionsAll dates in block (AI interprets)"@Friday" → AI determines due date
link_mentionsPage links[[Project Alpha]]
authorBlock's created_byWho wrote the todo

2. Surrounding Context (Captured at Search Time)

Context from the block's environment (no extra API calls):

ContextDescriptionHow Captured
preceding_headingLast h1/h2/h3 before this blockTracked while scanning page
parent_idParent block IDFrom block's parent field
source_page_database_idWhich database the page is inFrom page's parent field
page_urlURL of containing pageFrom page object

3. Parent Page Traversal (At Sync Time)

When block-level context is insufficient for relation matching, we traverse parent pages:

  • What: Check parent pages for relation properties that match target databases
  • Depth: Up to 5 levels of parent pages
  • Used for: Project/relation contextual matching (Strategy 2c)
  • API calls: 1 per parent level traversed

4. AI Involvement

AI is used sparingly and only when deterministic methods fail:

Use CaseWhen AI is CalledCan Be Rejected?
Summary generationAlways (polishes text)N/A
Owner extractionWhen no heading or @mention matchYes - must exist in owner database
Due date extractionAlways (interprets dates in text)No (falls back to default)
DisambiguationMultiple name matchesNo (picks best match)

Owner Resolution Flow

Owner is determined using a strict priority order. Each step is checked in sequence, and resolution stops at the first match:

┌─────────────────────────────────────────────────────────────────┐
│ Priority 1: Heading Match (Deterministic)                       │
│         Does preceding heading match someone in owner database? │
│         e.g., "### Alex" matches "Alex Johnson" in Contacts     │
├─────────────────────────────────────────────────────────────────┤
│ MATCH  → Use heading match, DONE (most reliable)                │
│ NO     → Continue to Priority 2                                 │
└─────────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│ Priority 2: @mention (Explicit)                                 │
│         Is there an @mention in the todo block?                 │
│         e.g., "@Jane Smith should review the PR"                │
├─────────────────────────────────────────────────────────────────┤
│ YES → Use first @mention's user ID, DONE                        │
│ NO  → Continue to Priority 3                                    │
└─────────────────────────────────────────────────────────────────┘
                              ↓
┌─────────────────────────────────────────────────────────────────┐
│ Priority 3: AI Extraction (Validated)                           │
│         AI extracts potential owner name from todo text         │
│         e.g., "Brad to contact Kevin" → AI extracts "Brad"      │
├─────────────────────────────────────────────────────────────────┤
│ Name extracted → Validate against owner database                │
│   FOUND in DB → Use matched page/user, DONE                     │
│   NOT in DB   → Owner = null (prevents hallucination)           │
│ No name extracted → Owner = null                                │
└─────────────────────────────────────────────────────────────────┘

Why This Design?

  1. Deterministic when possible: Heading matches are 100% reliable - no AI guessing
  2. AI as fallback, not authority: AI extracts names, we validate against owner database
  3. No hallucination: AI-extracted names must exist in owner DB to be used
  4. Debuggable: Clear priority order makes it easy to trace why a field was assigned

Example Scenarios

Scenario 1: Heading determines owner

### Alex                          ← Heading matches "Alex Johnson" in owner DB
[] Connect with Taylor about docs ← "Taylor" appears but is task TARGET, not owner

Result: Owner = Alex Johnson (Priority 1: heading match)

Scenario 2: @mention determines owner

[] @Jane should review the PR     ← @mention with user ID

Result: Owner = Jane (Priority 2: @mention)

Scenario 3: AI extracts owner from text

[] Brad to contact Kevin about Q1 ← AI extracts "Brad" as performer

Result: If "Brad" or "Brad Noble" exists in owner DB → Owner = Brad Noble
        If not in DB → Owner = null (prevents hallucination)

Scenario 4: No owner signals

[] Update the documentation       ← No heading, no @mention, no name prefix

Result: Owner = null

Relation Mapping

The system automatically discovers all relation properties in your Todos database and maps @mentions to the appropriate relations.

How it works:

  1. On each sync, the system reads your Todos database schema from the Notion API
  2. All relation properties are discovered automatically (no configuration needed)
  3. For each relation, the system applies mention mapping and contextual mapping

Example: If your Todos database has relations to "Projects db", "Clients", and "Tags":

  • All three are auto-discovered
  • @mentions in todos are mapped to the correct relation based on the mentioned page's parent database
  • Contextual mapping links todos based on source page relationships

Owner Resolution

The system automatically determines task ownership from context and resolves it to the correct Notion property format.

Property Type Detection

The Owner property type is detected at runtime from your Todos database schema:

  • People type: Stores Notion workspace user references
  • Relation type: Links to pages in another database (e.g., Contacts)

How Owner is Determined

When generating the AI summary, the system extracts an owner name from context using this priority:

  1. Heading - Preceding heading contains a person's name (e.g., "### Alex")
  2. Contact - Matched contact from name matching
  3. Mention - First @mention in the todo text

Resolution by Property Type

If Owner is a People property:

The owner name is matched to a Notion workspace user:

  1. Check @mentions in the todo (already have user IDs)
  2. Check if the block author's name matches (for heading-based ownership)
  3. Search workspace users by name (supports first-name matching)

Example: Owner name "Alex Johnson" matches workspace user "Alex @ Acme Corp" via first-name matching ("Alex" = "Alex").

If Owner is a Relation property:

The owner name is matched to a page in the relation's target database using name matching with AI disambiguation if multiple pages match.

Key Insight

The AI's owner name is a search term, not a final value. The same name resolves differently based on property type:

  • "Jane Smith" → Workspace user "Jane" (if Owner is people type)
  • "Jane Smith" → Contacts page "Jane Smith" (if Owner is relation type)

Notion Workspace Members vs Guests

Notion distinguishes between two types of users:

  • Members: Full workspace users with complete access (paid seats)
  • Guests: External collaborators with limited access to specific pages

API Limitation: The Notion API's users.list() endpoint only returns members, not guests. This has important implications:

Property TypeCan match members?Can match guests?
People✅ Yes❌ No
Relation✅ Yes (via name match)✅ Yes (via name match)

If you use a People type for Owner and the owner is a guest user (e.g., an external client), the system cannot find them by name and the Owner field will remain empty.

Choosing Between People and Relation Types

Use People type when:

  • All task owners are full workspace members
  • You want native Notion user avatars and @mention integration
  • Your team is internal-only

Use Relation type when:

  • Task owners include external contacts, clients, or guests
  • You have a Contacts/People database you want to link to
  • You need to track owners who aren't Notion users
  • You want owner matching to work regardless of workspace membership

Recommendation: If you're unsure, use Relation type. It's more flexible and works with both workspace members and external contacts. Create a Contacts database with names — the system will match owners from heading context and @mentions automatically.

Example Scenarios

Scenario A: Internal team todos

Owner property: People type
Team members: All are workspace members
Result: ✅ Works perfectly - names matched to workspace users

Scenario B: Client-facing project todos

Owner property: People type
Owners include: External client (guest user)
Result: ❌ Guest not found - guests not returned by users.list()

Fix: Change Owner to Relation type pointing to Contacts database
Result: ✅ Client name matched to Contacts page via heading/mention matching

Scenario C: Mixed internal + external

Owner property: Relation type → Contacts database
Contacts database: Contains both team members and clients
Result: ✅ All names matched via heading/mention matching, regardless of workspace status

Relation Matching Strategies

When syncing todos to the database, the system automatically links them to related pages using matching strategies in priority order. Strategy 1 matches (mentions + headings) can accumulate — both @mentions and heading matches are added to the same relation. Strategy 2 only applies as a fallback when Strategy 1 finds no matches.

Strategy 1: Explicit Matches

1a: @Mentions — If the todo text contains an @mention to a page that exists in a relation's target database, the todo is linked to that page.

Example: A todo containing @Project Alpha will be linked to "Project Alpha" if it exists in the relation's target database.

1b: Heading Text — If the preceding heading text exactly matches a page name in a relation's target database, the todo is linked to that page. This allows organizing todos under project headings without needing @mentions.

Example:

### Stainless todoSweeper v1       ← Heading matches page name in Projects db
- [ ] verify this goes to the right place
→ Todo is linked to "Stainless todoSweeper v1"

Note: Both 1a and 1b can match — if a heading matches one project and the todo @mentions another, both are added to the relation.

Strategy 2: Contextual Mapping (Fallback)

If strategy 1 doesn't find a match, the system uses the source page's context to infer relations. Three sub-strategies are checked in order:

2a: Source page relation properties — If the source page has a relation property whose values exist in a target database, those values are used. This is the strongest contextual signal because someone explicitly assigned the page to a project/contact.

Example: A "Meeting Notes" page has a "Project" relation set to "Acme Redesign". Todos on that page inherit the "Acme Redesign" link. Zero extra API calls — relations are captured when the source page is fetched during search.

2b: Source page is a direct database entry — If the source page is a direct entry in a relation's target database (i.e., parent.type === 'database_id'), the todo is linked to that page.

Example: A todo on the "Project Beta" page (which is a direct entry in the Projects database) is linked to "Project Beta".

2c: Parent page traversal — If the source page is a child page (where parent.type === 'page_id', not 'database_id'), the system walks up the page hierarchy (up to 3 levels) checking each ancestor for matches via 2a or 2b.

Example: A todo on a sub-page "Sprint Notes" nested under project page "Project Beta" — the system fetches the parent page, finds it's a direct entry in the Projects database, and links the todo to "Project Beta". Costs up to 3 API calls per block, but only fires for child pages where source_page_database_id is null.

Owner Matching

Owner resolution is handled separately from general relation matching. When the Owner property is a relation type (pointing to a Contacts database), the system matches names from headings, @mentions, and AI-extracted owner names against the contacts database using its own dedicated matching logic. This supports partial name matching (e.g., "bryce" in a heading matches "Bryce Roberts" in the contacts database).

Configuration

Relations are auto-discovered from your Todos database schema — no configuration needed.

Getting Started

Prerequisites

  1. Create a Notion integration at https://www.notion.so/my-integrations
  2. Create a Notion database with these properties:
    • Name (title)
    • Block ID (rich_text)
    • Block URL (url)
    • Page URL (url) - source page where todo was found
    • Todo last edited time (date)
    • Owner (people)
    • Other people (people)
    • Due date (date)
    • Links (rich_text)
    • Projects db (relation) - optional, auto-discovered for relation mapping
  3. Share the database with your integration

Setup

  1. Fork this val in Val Town
  2. Set environment variables:
    • NOTION_API_KEY = your integration token
    • TODOS_DB_ID = your database ID
    • (No search configuration needed - defaults to checkbox/to_do search)
  3. Test with: POST /tasks/todos?hours=1

Relations are auto-discovered from your database schema - no configuration needed.

Additive keyword search - To also capture keyword matches, set SEARCH_KEYWORDS=todo (or your preferred keywords, comma-separated).

Usage Examples

Default Mode (Checkbox Search)

Find and sync all checkbox todos from last 24 hours:

# Default behavior - no env vars needed curl -X POST https://your-val.express/tasks/todos

Custom time window:

curl -X POST "https://your-val.express/tasks/todos?hours=48"

Find all bullet points instead of checkboxes:

# Set: SEARCH_BLOCK_TYPE=bulleted_list_item curl -X POST https://your-val.express/tasks/todos

Additive: Keyword Search Examples

Also capture blocks containing "todo" keyword:

# Set: SEARCH_KEYWORDS=todo # Finds: to_do blocks AND any block containing "todo" curl -X POST https://your-val.express/tasks/todos

Also capture blocks with multiple keywords:

# Set: SEARCH_KEYWORDS=todo,urgent,🍋 # Finds: to_do blocks AND blocks containing any of these keywords curl -X POST https://your-val.express/tasks/todos

Other Examples

Get recent pages (API):

curl "https://your-val.express/api/pages/recent?hours=12"

Development Guidelines

  • For project-specific architecture: See CLAUDE.md
  • For Val Town platform guidelines: See AGENTS.md

Tech Stack

  • Runtime: Deno on Val Town
  • Framework: Hono (lightweight web framework)
  • Frontend: React 18.2.0 with Pico CSS (classless CSS framework)
  • APIs:
    • Notion API (@notionhq/client v2)
    • Val Town blob storage
  • Language: TypeScript

Architecture Diagrams

Complete System Flow

┌─────────────────────────────────────────────────────────────┐
│                     Notion Workspace                        │
│  ┌──────────┐  ┌──────────┐  ┌──────────┐                 │
│  │  Page A  │  │  Page B  │  │  Page C  │                 │
│  │    ☐     │  │    ☐     │  │          │                 │
│  └──────────┘  └──────────┘  └──────────┘                 │
└──────────────────────────┬──────────────────────────────────┘
                           │
                           ▼
         ┌─────────────────────────────────────┐
         │  POST /tasks/todos                  │
         │  (Batch Search & Sync Endpoint)     │
         └─────────────────┬───────────────────┘
                           │
        ┌──────────────────┴──────────────────┐
        │                                     │
        ▼                                     ▼
┌───────────────────┐              ┌──────────────────────────┐
│  Step 1: Search   │              │  Step 3: Sync (Optimized)│
│                   │              │                          │
│ • Get recent pages│              │ • Read blobs             │
│ • Fetch all blocks│              │ • Skip if synced: true   │
│ • Find checkboxes │              │ • Use cached page ID     │
│ • Extract data    │              │ • Create/update pages    │
│ • Validate        │              │ • Mark synced: true      │
└─────────┬─────────┘              └──────────────────────────┘
          │                                   ▲
          ▼                                   │
┌─────────────────────┐                      │
│  Step 2: Store      │                      │
│                     │                      │
│ • Save to blobs     │──────────────────────┘
│ • Set synced: false │
│ • Compare timestamps│
│ • Preserve page ID  │
└─────────────────────┘
          │
          ▼
┌─────────────────────────────────────────────────────────────────┐
│                   Val Town Blob Storage                          │
│                                                                  │
│  demo--todo--block-1: { data, sync_metadata: {synced, page_id} }│
│  demo--todo--block-2: { data, sync_metadata: {synced, page_id} }│
│  demo--todo--block-3: { data, sync_metadata: {synced, page_id} }│
└─────────────────────────────────────────────────────────────────┘

MVC Layer Interaction

┌──────────────────────────────────────────────────────────────┐
│                       HTTP Request                           │
│  POST /tasks/todos?hours=24                                  │
└──────────────────────┬───────────────────────────────────────┘
                       │
                       ▼
         ┌─────────────────────────────┐
         │   ROUTE (todos.ts)          │
         │   • Extract query params    │
         │   • Call controller         │
         │   • Format HTTP response    │
         └─────────────┬───────────────┘
                       │
                       ▼
         ┌─────────────────────────────────────┐
         │   CONTROLLER (orchestration)        │
         │   • Validate inputs                 │
         │   • Orchestrate workflow:           │
         │     1. Get recent pages             │
         │     2. Search each page             │
         │     3. Sync to database             │
         │   • Return standardized result      │
         └──────────────┬──────────────────────┘
                        │
            ┌───────────┼───────────┐
            │           │           │
            ▼           ▼           ▼
    ┌───────────┐ ┌──────────┐ ┌────────────┐
    │ SERVICE:  │ │ SERVICE: │ │ SERVICE:   │
    │ pages.ts  │ │ blob.ts  │ │ database.ts│
    │           │ │          │ │            │
    │ • API call│ │ • Blob   │ │ • Query DB │
    │ • Parse   │ │   CRUD   │ │ • Create   │
    │ • Return  │ │ • Return │ │ • Update   │
    └─────┬─────┘ └────┬─────┘ └─────┬──────┘
          │            │             │
          ▼            ▼             ▼
    Notion API   Blob Storage   Notion API

Known Limitations

Notion Timestamp Precision

Notion's last_edited_time on blocks is rounded to the minute. This affects change detection:

ScenarioDetectionNotes
Edits in different minutes✅ DetectedTimestamp comparison works
Edits within same minute⚠️ May missSame minute = same timestamp
Checkbox toggle (same minute)✅ DetectedExplicit checked comparison
Text edit (same minute)❌ MissedCaught on next different-minute edit

Practical impact: If you write a todo and immediately check it off (within 60 seconds), the checkbox change is detected. If you edit text twice within 60 seconds, only the first edit syncs until a later edit.

Workaround: Wait ~60 seconds between edits, or make another edit later.

License

MIT

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.