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.
- 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
This system automatically captures checkbox todos from your Notion pages and syncs them to a centralized database:
- Search: Scans recent Notion pages for
to_doblocks (checkboxes) - Extract: Captures block content including @mentions and dates
- Validate: Filters out blocks that are too short (< 5 words by default)
- Enrich: Captures author and dates for AI resolution during sync
- Store: Saves to Val Town blob storage with timestamp tracking and sync metadata
- Optimize: Skips already-synced items to reduce API calls by 90%+
- Sync: Creates/updates Notion database pages
Note: Keyword-based search can be added on top of block type search. See Search Configuration.
├── 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
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
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);
});
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 };
}
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.
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.
Flow:
- Get recent pages from Notion (configurable time window)
- For each page, recursively fetch all blocks
- Find
to_doblocks (checkboxes) - or keyword matches in alternative mode - Extract structured data from matching blocks
- Validate word count: Skip blocks below
MIN_BLOCK_WORDS(default: 5) - Capture context: Extract dates and block author for AI resolution during sync
- 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_doblocks (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_mentionsarray for Notion API - Block URL: Clickable link to original block location
- Emojis: Extracted for use as page icons
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_timeof existing blob vs new block - If unchanged: Skip save (preserves
synced: truestatus) - If changed: Save with
synced: false(triggers re-sync) - Checkbox state: For
to_doblocks, also comparecheckedstate (see note below) - Preserve cached
target_page_idacross updates - Prevents data loss from out-of-order processing
Minute-Precision Note: Notion's
last_edited_timeis 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. Forto_doblocks, the system also compares thecheckedstate to catch same-minute checkbox toggles. Other same-minute edits (text changes) will be captured on the next edit in a different minute.
Flow:
- List all blobs in "todo" category
- For each blob, read reduced block data
- Optimization: Skip if
synced: true(0 API calls) - Optimization: Use cached
target_page_idif available (1 API call - update only) - If no cached ID: Query database for existing page by Block ID
- Create new page OR update existing page
- Mark blob as
synced: trueand 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, notarget_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_idfrom 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)
The search uses recursive block fetching to traverse the entire page hierarchy, including nested content.
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
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 blockstoggle- common pattern for organizing todoscolumn_list/column- layout containerssynced_block- can contain any block typecallout- can contain nested contentquote- can contain nested blocksbulleted_list_item/numbered_list_item- can have nested contenttemplate- can contain any block type
Non-container blocks (skipped):
paragraph,heading_1/2/3,code,equation- cannot have to_do childrenimage,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.
These block types are searchable (block type search matches only the configured type; keyword search checks text content in all blocks):
| Block Type | Has 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) |
column | N/A | Container - children are searched |
column_list | N/A | Container - 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
These block types are explicitly skipped:
| Block Type | Reason |
|---|---|
unsupported | Not supported by Notion API |
button | Action buttons, not content |
table | Container block, no text content |
table_row | Cells aren't individual blocks; can't be saved to blob |
child_page | Page title not in rich_text format |
child_database | Database title not in rich_text format |
divider | No text content |
table_of_contents | No text content |
breadcrumb | No text content |
image, file, video, pdf | Media blocks (captions could be added later) |
bookmark, embed | External 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
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.
Matched blocks go through validation before being saved:
1. Word Count Validation (REQUIRED):
- ✅ Block must have at least
MIN_BLOCK_WORDSwords (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_DATEsetting (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
authorfor 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_DATEenvironment 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:
- Block creator is stored as
authorand 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.
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
// 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);
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.
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.
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" }
POST /tasks/todo/search
- Search single page for checkboxes or keywords (webhook-triggered)
- Default: finds
to_doblocks (checkboxes) - Optional: set
SEARCH_KEYWORDSto 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_doblocks (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 } }
The system includes two separate cron jobs for automated workflow execution. Crons are time-based triggers that run independently of HTTP requests.
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.tsxextension for Val Town
Purpose: Search recent pages for checkboxes (or keywords) and save matches to blob storage
Workflow:
- Get recent pages from Notion (last 15 minutes)
- Search each page for
to_doblocks (checkboxes) by default - Save matching blocks to Val Town blob storage
- Does NOT sync to Notion database
Configuration:
- Lookback window: 15 minutes (optimized for frequent runs)
- Search mode: Default is
to_doblocks; setSEARCH_KEYWORDSto 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 ===
Purpose: Sync validated todo blobs to Notion database
Workflow:
- Read all todo blobs from Val Town blob storage
- Validate each blob (requires person mention + date mention)
- Query Notion database for existing pages by Block ID
- 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 ===
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)
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
- Navigate to Val Town UI
- Create new cron vals:
todoSearch.cron.tsx- Copy content frombackend/crons/todoSearch.cron.tstodoSync.cron.tsx- Copy content frombackend/crons/todoSync.cron.tscacheWarm.cron.tsx- Copy content frombackend/crons/cacheWarm.cron.ts
- Set schedules:
todoSearch.cron.tsx: Every 1 minute (* * * * *)todoSync.cron.tsx: Every 1 minute (* * * * *)cacheWarm.cron.tsx: Every 1 minute (* * * * *)
- 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.
The system uses blob storage caching to dramatically reduce Notion API calls and improve response times. Caching is automatic and requires no configuration.
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.
The system caches two types of data in Val Town blob storage:
| Cache | Key Pattern | TTL | Contents |
|---|---|---|---|
| Health Response | {project}--cache--health | 1 minute | Full health check JSON |
| Relation Cache | {project}--cache--relations | 1 minute | Database 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
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: trueandcacheAge: 45000(milliseconds)
Example cached response:
{ "project": { "name": "demo", ... }, "connections": { ... }, "properties": [ ... ], "cached": true, "cacheAge": 45000 }
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
}
| Metric | Before | After | Improvement |
|---|---|---|---|
| Health endpoint response | 1000-2000ms | ~50ms | 20x faster |
| Sync initialization | 2-5 seconds | ~100ms | 20-50x faster |
| Notion API calls (health) | 4-10 per request | 0 (cached) | 100% reduction |
| Notion API calls (sync) | ~11 per sync | 0 (cached) | 100% reduction |
Because caches have a 1-minute TTL, some changes may be delayed:
| Change Type | Propagation Time | Frequency |
|---|---|---|
| New todo in Notion | Immediate (not cached) | Common |
| Todo content changes | Immediate (not cached) | Common |
| Property value changes | Immediate (not cached) | Common |
| Property renamed | Up to 1 minute | Rare |
| Relation added/removed | Up to 1 minute | Rare |
| New page in relation DB | Up to 1 minute | Uncommon |
Why this is acceptable: The things that change frequently (todo content) are not cached. The things that are cached (schema, relations) change rarely.
Files involved:
backend/services/blobService.ts-getCache()andsetCache()functionsbackend/controllers/healthController.ts- WrapsbuildHealthResponse()with cachebackend/controllers/todoSaveController.ts- WrapsbuildRelationCache()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.
Force fresh data (health endpoint):
- Wait 1 minute for cache to expire, OR
- (Future enhancement) Add
?refresh=truequery param
Check cache age:
- Health endpoint returns
cacheAgein response when cached - Sync logs "Using cached relation data (age: Xs)" when using cache
Verify cache is working:
- Load
/api/health- note response time - Load again within 1 minute - should be instant with
cached: true - Check
cacheAgeincreases on subsequent requests
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.tsinstead (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,bitortodo,😀,🎉 - 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
5if 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
todayif not set - Examples:
today- Due today (default)tomorrow- Due tomorrowone_week- Due 7 days from todayend_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
0if not set (no delay - blocks saved immediately) - Set to a positive number to add a stability delay (e.g.,
2for 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 savedBLOCK_STABILITY_MINUTES=5- Block edited 4 minutes ago will be skipped, 6 minutes ago will be saved
- Not set or
- 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
24hours if not set - Must be a positive integer
- Can be overridden per-request with
?hours=Xquery 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)
- Not set or
- 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-KEYwith 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/healthand use them to query other endpoints
Public endpoint exception:
/api/healthremains public (needed for frontend dashboard) - Required for production use - protects all
-
API_KEY- Legacy API key (deprecated, useNOTION_WEBHOOK_SECRETinstead)- Kept for backwards compatibility
- Use
NOTION_WEBHOOK_SECRETfor 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
- Setup mode (
By default, the system searches for to_do blocks (Notion checkboxes). This is the recommended workflow:
How it works:
- Create a checkbox in Notion (this creates a
to_doblock) - Add @person mention (optional)
- Add date mention (optional - AI uses DEFAULT_DUE_DATE if none)
- 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
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
If SEARCH_KEYWORDS is set: Both searches run (OR logic)
- Block type matches are captured
- Keyword matches are also captured
- Example:
to_doblocks AND any block containing "urgent"
If SEARCH_KEYWORDS is not set: Only block type search runs (default behavior)
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).
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.
| Stage | When | What Happens | Files |
|---|---|---|---|
| Search | When pages are scanned | Extract block data + surrounding context | todoController.ts |
| Sync | When blobs sync to DB | Resolve relations, owner, due date, generate summary | todoSaveController.ts |
Extracted directly from the Notion block:
| Context | Description | Example |
|---|---|---|
todo_string | Block text content | "Email client about proposal" |
people_mentions | @mentions with user IDs | @Jane Smith |
date_mentions | All dates in block (AI interprets) | "@Friday" → AI determines due date |
link_mentions | Page links | [[Project Alpha]] |
author | Block's created_by | Who wrote the todo |
Context from the block's environment (no extra API calls):
| Context | Description | How Captured |
|---|---|---|
preceding_heading | Last h1/h2/h3 before this block | Tracked while scanning page |
parent_id | Parent block ID | From block's parent field |
source_page_database_id | Which database the page is in | From page's parent field |
page_url | URL of containing page | From page object |
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
AI is used sparingly and only when deterministic methods fail:
| Use Case | When AI is Called | Can Be Rejected? |
|---|---|---|
| Summary generation | Always (polishes text) | N/A |
| Owner extraction | When no heading or @mention match | Yes - must exist in owner database |
| Due date extraction | Always (interprets dates in text) | No (falls back to default) |
| Disambiguation | Multiple name matches | No (picks best match) |
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 │
└─────────────────────────────────────────────────────────────────┘
- Deterministic when possible: Heading matches are 100% reliable - no AI guessing
- AI as fallback, not authority: AI extracts names, we validate against owner database
- No hallucination: AI-extracted names must exist in owner DB to be used
- Debuggable: Clear priority order makes it easy to trace why a field was assigned
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
The system automatically discovers all relation properties in your Todos database and maps @mentions to the appropriate relations.
How it works:
- On each sync, the system reads your Todos database schema from the Notion API
- All relation properties are discovered automatically (no configuration needed)
- 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
The system automatically determines task ownership from context and resolves it to the correct Notion property format.
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)
When generating the AI summary, the system extracts an owner name from context using this priority:
- Heading - Preceding heading contains a person's name (e.g., "### Alex")
- Contact - Matched contact from name matching
- Mention - First @mention in the todo text
If Owner is a People property:
The owner name is matched to a Notion workspace user:
- Check @mentions in the todo (already have user IDs)
- Check if the block author's name matches (for heading-based ownership)
- 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.
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 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 Type | Can 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.
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.
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
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.
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.
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 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).
Relations are auto-discovered from your Todos database schema — no configuration needed.
- Create a Notion integration at https://www.notion.so/my-integrations
- 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
- Share the database with your integration
- Fork this val in Val Town
- Set environment variables:
NOTION_API_KEY= your integration tokenTODOS_DB_ID= your database ID- (No search configuration needed - defaults to checkbox/
to_dosearch)
- 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).
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
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
Get recent pages (API):
curl "https://your-val.express/api/pages/recent?hours=12"
- For project-specific architecture: See
CLAUDE.md - For Val Town platform guidelines: See
AGENTS.md
- 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
┌─────────────────────────────────────────────────────────────┐
│ 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} }│
└─────────────────────────────────────────────────────────────────┘
┌──────────────────────────────────────────────────────────────┐
│ 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
Notion's last_edited_time on blocks is rounded to the minute. This affects change detection:
| Scenario | Detection | Notes |
|---|---|---|
| Edits in different minutes | ✅ Detected | Timestamp comparison works |
| Edits within same minute | ⚠️ May miss | Same minute = same timestamp |
| Checkbox toggle (same minute) | ✅ Detected | Explicit checked comparison |
| Text edit (same minute) | ❌ Missed | Caught 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.
MIT