A Val Town application that searches Notion pages for keywords OR block types (like checkboxes), extracts structured data, stores it in blob storage, and syncs it back to a Notion database with intelligent filtering and validation.
- Overview
- Project Structure
- MVC Architecture
- Search Workflow
- Block Type Handling
- Validation & Auto-Assignment Rules
- Endpoints
- Cron Jobs
- Environment Variables
- Search Modes
- Context Gathering & Assignment Logic
- Relation Mapping
- Fuzzy Name Matching
- Owner Resolution
- Project Matching
This system enables automatic extraction and organization of action items from Notion pages:
- Search: Scans recent Notion pages for configurable keywords or block types
- Extract: Captures block content including mentions and dates
- Validate: Filters out blocks that are too short (< 5 words by default)
- Enrich: Auto-assigns missing due date; captures author for AI owner resolution
- 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
βββ backend/
β βββ controllers/ # Business logic
β β βββ pageController.ts # Page operations
β β βββ todoController.ts # Keyword search logic
β β βββ todoSaveController.ts # Blob β Notion sync
β β βββ todoOrchestrationController.ts # Batch workflow
β βββ crons/ # Time-based triggers
β β βββ todoSearch.cron.ts # Periodic keyword 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 fuzzy matching
β β βββ 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
This workflow supports two search modes (keyword or block type). The pipeline remains the same regardless of mode.
Flow:
- Get recent pages from Notion (configurable time window)
- For each page, recursively fetch all blocks
- Search blocks for keyword matches
- Extract structured data from matching blocks
- Validate word count: Skip blocks below
MIN_BLOCK_WORDS(default: 5) - Auto-assign due date: Add default due date if missing; capture block author
- Save enriched blocks to blob storage with timestamp
Validation & Enrichment (happens here, not during sync):
- β Word count <
MIN_BLOCK_WORDS? β Skip (too short to be meaningful) - β οΈ Missing
date_mention? β Auto-assign based onDEFAULT_DUE_DATEsetting (default: today) - β Only skips if: too short (below MIN_BLOCK_WORDS)
- Note: Owner is determined by AI during sync, not during search
- Result: All blobs in storage are meaningful with due date; owner resolved during sync
Endpoints:
POST /tasks/todo/search- Single page search (webhook-triggered)POST /tasks/todos?hours=24- Batch search across recent pages
Keywords Configuration:
- Set via
SEARCH_KEYWORDSenvironment variable (comma-separated) - Example:
SEARCH_KEYWORDS=todo,zinger,bit,π - Defaults to
todoif not set - All keywords searched in single pass through blocks (efficient)
Keyword Matching Logic:
- Text keywords (e.g., "todo", "bit", "steel"):
- Case-insensitive
- Word boundary matching (finds "todo" but not "todoist")
- Uses regex:
/\btodo\b/i
- Emojis (e.g., "π", "π"):
- Exact match
- Case-sensitivity N/A
- Multi-keyword: Block saved if it matches ANY keyword
Block Extraction:
When a keyword is found, the system extracts and transforms the block 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 fuzzy 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) - Preserve cached
target_page_idacross updates - Prevents data loss from out-of-order processing
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
Note: Status is not set by todoSweeper - configure a default in your Notion database properties.
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 using block type mode (SEARCH_BLOCK_TYPE=to_do), the system optimizes recursive fetching by only traversing into container blocks that can hold to_do children:
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. Keyword mode still traverses all blocks (no filter applied).
These block types are searched for keywords:
| 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, then enriched with auto-assigned due dates before being saved to blob storage. Validation ensures quality; owner is determined by AI during sync.
Matched blocks go through validation and enrichment 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 (CONDITIONAL + AUTO-ASSIGNED):
- Date mentions are only used as due dates if preceded by "due" or "by"
- Example: "finish report by @October 30" β October 30 becomes Due date
- Example: "meeting @October 30 about Q4" β date is content only, not Due date
- First qualifying date becomes "Due date"
- AUTO-ASSIGNED: If no qualifying date found, uses
DEFAULT_DUE_DATEenv var (defaults to "today")
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
Automatic Due Date Assignment:
- Triggered when no date mention is preceded by "due" or "by"
- Even if the block contains date mentions, they're only treated as content unless qualified
- Configurable: Set via
DEFAULT_DUE_DATEenvironment variable - Options:
today(default),tomorrow,one_week,end_of_week,next_business_day - Rationale: Blocks without explicit due dates still need deadlines; "today" is a sensible default
- Date is stored in same ISO format as explicit dates (e.g.,
"2025-10-31")
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: Auto-assign due date if no date mentioned
if (!reducedBlock.date_mentions || reducedBlock.date_mentions.length === 0) {
const setting = getDefaultDueDateSetting();
const calculatedDate = calculateDueDate(setting);
reducedBlock.date_mentions = [calculatedDate];
}
// Step 3: Capture block creator as author (for AI owner 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 will be determined by AI during sync phase
await blobService.saveBlockToBlob("todo", block.id, blobData);
Valid - @mention + qualified date (8 words):
"Buy groceries for @John due October 30, 2025"
β
Word count: 8 (passes minimum of 5)
β
Has @mention (@John β Other people)
β
Has qualified date ("due" precedes October 30 β Due date)
β Saved to blob storage
β Synced: AI determines owner; due Oct 30
Valid - Qualified date, no @mention (6 words):
"Buy groceries by October 30, 2025"
β
Word count: 6 (passes minimum of 5)
β
Has qualified date ("by" precedes October 30 β Due date)
β Saved to blob storage (author captured for AI resolution)
β Synced: AI determines owner; due Oct 30
Valid - Unqualified date (auto-assigned) (7 words):
"Meeting with @John October 30 about Q4"
β
Word count: 7 (passes minimum of 5)
β
Has @mention (@John β Other people)
β οΈ Date not preceded by "due"/"by" β treated as content only
β οΈ No qualifying date β Auto-assigned based on DEFAULT_DUE_DATE
β Saved to blob storage
β Synced: AI determines owner; due date auto-assigned
Valid - No date, no @mention (5 words):
"Buy groceries at the store"
β
Word count: 5 (passes minimum of 5)
β οΈ No qualifying date β Auto-assigned based on DEFAULT_DUE_DATE
β Saved to blob storage (author captured for AI resolution)
β Synced: AI determines owner from context or leaves empty
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 - No mentions but meaningful (6 words):
"Buy groceries at the store tomorrow"
β
Word count: 6 (passes minimum of 5)
β οΈ No date mention β Auto-assigned based on DEFAULT_DUE_DATE
β Saved to blob storage (author captured if available)
β Synced: AI determines owner from context or leaves empty
Note: With word count validation, most meaningful blocks are saved. Blocks are only skipped if too short (below MIN_BLOCK_WORDS, default: 5).
After syncing, the controller reports:
- Total blobs: All blobs in storage (all guaranteed valid with due date)
- 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: All blobs have due dates assigned; owner is determined by AI during sync.
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 keywords (webhook-triggered)
- Keywords from
SEARCH_KEYWORDSenv var (comma-separated) - 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
- Keywords from
SEARCH_KEYWORDSenv var - 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 keywords/block types and save matches to blob storage
Workflow:
- Get recent pages from Notion (last 15 minutes)
- Search each page for configured keywords or block types
- Save matching blocks to Val Town blob storage
- Does NOT sync to Notion database
Configuration:
- Lookback window: 15 minutes (optimized for frequent runs)
- Keywords/Block type: From
SEARCH_KEYWORDSorSEARCH_BLOCK_TYPEenv var - 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 ===
Operational flexibility:
- Search cron runs frequently to capture changes quickly
- Sync cron runs less frequently to batch database updates
- Reduces Notion API rate limit concerns
- Allows manual triggering of sync independently
Fault isolation:
- Search failures don't block syncing existing blobs
- Sync failures don't block new searches
- 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.ts
- Set schedules:
todoSearch.cron.tsx: Every 1 minute (* * * * *)todoSync.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.
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_KEYWORDS- Keywords to search for (optional, keyword mode)- Comma-separated list of keywords/phrases
- Example:
todo,zinger,bitortodo,π,π - Defaults to
todoif not set - All blocks matching ANY keyword will be saved to blob storage
- Efficient: Searches all keywords in a single pass through blocks
-
SEARCH_BLOCK_TYPE- Block type to search for (optional, block type mode)- Alternative to keyword search - searches by Notion block type
- Example:
to_do(searches all Notion checkbox blocks) - Defaults to
to_doif set with empty value - Takes precedence over
SEARCH_KEYWORDSif both are set - Common values:
to_do,paragraph,bulleted_list_item,numbered_list_item - Still requires people_mentions + date_mentions validation
- Useful for: "Check a box, add @person and date = instant todo"
-
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)- Used when a block matches search criteria but has no date
- 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- Disable all cron jobs without changing Val Town UI (optional)- Set to
trueto disable crons (they will exit early with a log message) - Not set or
false= crons run normally (default) - Useful for debugging or when using webhooks exclusively
- Note: Crons still execute in Val Town (uses compute), they just exit immediately
- Set to
This system supports two mutually exclusive search modes. Choose the mode that best fits your workflow.
When to use: You want to search for specific text in blocks (e.g., "todo", "zinger", emojis).
Configuration:
SEARCH_KEYWORDS=todo,zinger,π
How it works:
- Searches block text content for keywords
- Matches text keywords with word boundaries (case-insensitive)
- Matches emojis with exact match
- Example: Block containing "Buy groceries todo @John October 30"
Use case: Flexible text-based search across any block type that contains your keywords.
When to use: You want to use Notion's native block types (especially checkboxes) as task markers.
Configuration:
SEARCH_BLOCK_TYPE=to_do
How it works:
- Searches by Notion block type (not text content)
- Matches
to_doblocks (Notion checkboxes) - Works with both checked and unchecked checkboxes
- No keyword required in text
- Example: Any checkbox block with @John and October 30
Use case:
- Create a checkbox in Notion (makes it a
to_doblock) - Add @person mention
- Add date
- Done! Automatically syncs to database (no need to type "todo")
Other block types: You can also search for paragraph, bulleted_list_item, numbered_list_item, etc.
If both env vars are set: SEARCH_BLOCK_TYPE takes precedence
- System will use block type mode
SEARCH_KEYWORDSwill be ignored- A warning will be logged
If neither is set: Defaults to keyword mode with keyword "todo"
Regardless of search mode, all matched blocks are enriched with:
- β οΈ
date_mention- If missing, auto-assigned based onDEFAULT_DUE_DATE(default: today) author- Block creator is captured for potential use in AI owner resolution
All matched blocks are saved to blob storage. Owner is determined during sync using a deterministic cascade (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/utils/contextUtils.ts contains the core logic and detailed documentation.
| 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, 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 | Dates after "due"/"by" | "due Friday" β 2024-01-12 |
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, we crawl up the block tree:
- What: Check parent blocks for @mentions, page links, names
- Depth: Up to 5 levels of parents
- Used for: Project matching, fuzzy contact matching, owner candidates
- 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? |
|---|---|---|
| Project matching | No @mention or contextual match | No (semantic similarity) |
| Disambiguation | Multiple candidates match | No (picks best) |
| Summary generation | Always (polishes text) | N/A |
| Owner suggestion | Always (part of summary) | Yes - validated against actual context |
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Step 1: Does heading match someone in owner database? β
β (e.g., "### Alex" matches "Alex Johnson" in Contacts) β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β YES β Use heading match (deterministic, most reliable) β
β NO β Continue to Step 2 β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Step 2: Are there ANY owner candidates? β
β - Heading matched? (from Step 1) β
β - Contacts matched via fuzzy matching? β
β - @mentions in block? β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β NO β Check parent blocks for @mentions or headings β
β YES β Continue to Step 3 β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Step 3: Still no candidates after checking parents? β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β NO candidates anywhere β Owner = null (prevents hallucination) β
β Candidates exist β Continue to Step 4 β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
β Step 4: AI generates summary and suggests owner β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ€
β AI claims "heading" source β Verify name appears in heading β
β AI claims "contact" source β Verify name is in matched contacts β
β AI claims "mention" source β Verify name is in @mentions β
β β
β Validation FAILS β Reject AI owner, owner = null β
β Validation PASSES β Resolve name to user/page ID β
βββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββββ
- Deterministic when possible: Heading matches are 100% reliable - no AI guessing
- AI as fallback, not authority: AI suggests, we validate against actual context
- No hallucination: If no real candidates exist, owner stays null
- 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 (from heading, deterministic)
Scenario 2: @mention determines owner
[] @Jane should review the PR β @mention with user ID
Result: Owner = Jane (from @mention)
Scenario 3: No owner candidates
[] Update the documentation β No heading, no contacts, no @mentions
Result: Owner = null (prevents AI from inventing someone)
Scenario 4: Parent block provides context
Parent block: @Team Lead please assign these:
ββ [] Task one β No direct owner info
ββ [] Task two
Result: Owner = Team Lead (from parent @mention)
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 and contextual mapping (Strategies 1-2)
- The relation configured in
TODOS_PROPERTIES.projectsalso gets AI matching (Strategies 3-4)
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
- Only "Projects db" (if configured) gets AI fuzzy matching
For configured relations, the system can automatically link todos to pages based on fuzzy name matching. This works when the todo text or parent blocks contain a name (plain text, not @mentions) that matches a page in the relation's target database.
-
Configure relations for fuzzy matching in
notion.config.ts:export const TODOS_PROPERTIES = { // ... other properties fuzzyMatch: ["Contacts", "Companies"], // Relations to enable fuzzy matching }; -
During sync, for each configured relation:
- Load all page names from the relation's target database
- Check the todo text AND preceding heading for name matches (case-insensitive substring)
- If no match in todo or heading, check parent blocks (up to 5 levels)
- Single match β link automatically
- Multiple matches β AI disambiguation
### Bryce β Preceding heading contains "Bryce"
[] do something good and fine β Todo text has no name
β Matches "Bryce Taylor" in Contacts via preceding heading context
How preceding headings work:
- When searching pages, the system tracks the most recent heading (h1/h2/h3) seen
- Each todo block stores its
preceding_headingin blob storage - During fuzzy matching, heading text is combined with todo text for name search
- Zero additional API calls - heading is captured when blocks are already being read
Fuzzy name matching runs after explicit @mentions and before AI project matching:
- Strategy 1: Explicit @mentions - Direct page links in todo text (WINS)
- Strategy 2: Contextual mapping - Source page is in target database
- Strategy 3: Fuzzy name matching - Name found in todo or parent blocks
- Strategy 4-5: AI matching - Projects relation only (see Project Matching)
If a relation is already matched by @mention, fuzzy matching is skipped for that relation.
When multiple names match (e.g., "John Smith" and "John Doe" both contain "john"), the system uses AI (gpt-4o-mini) to pick the best match based on the todo context.
If a configured relation doesn't exist in your database schema:
- During sync: The relation is silently skipped (sync continues normally)
- In health check: Shows a warning so you can update your config
The /api/health endpoint shows fuzzy match configuration status:
{ "fuzzy_match": { "configured": ["Contacts", "Companies"], "status": [ { "propertyName": "Contacts", "found": true, "pageCount": 45 }, { "propertyName": "OldRelation", "found": false } ] } }
found: true- Relation exists and is activefound: false- Relation not found in database schema (remove from config)pageCount- Number of pages in the target database available for matching
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 fuzzy 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 fuzzy 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 fuzzy) | β Yes (via fuzzy) |
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, and configure fuzzy matching to link todos 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 fuzzy 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 fuzzy matching, regardless of workspace status
When syncing todos to the database, the system automatically links them to related projects using a cascade of matching strategies. Each strategy is tried in order until a match is found.
Note: Strategies 1-2 apply to ALL auto-discovered relations. Strategy 3 applies to relations in
TODOS_PROPERTIES.fuzzyMatch. Strategies 4-5 only apply to the relation named inTODOS_PROPERTIES.projects.
If the todo text contains a link to a page that exists in your Projects database, the todo is linked to that project.
Example: A todo containing [[Project Alpha]] (a page mention) will be linked to "Project Alpha" if it exists in the Projects database.
If the todo block appears on a page that is itself a project (exists in the Projects database), the todo is linked to that project.
Example: A todo on the "Project Beta" page will be linked to "Project Beta" automatically.
If strategies 1-3 don't find a match, the system uses OpenAI (via Val Town's @std/openai) to match the todo text against project names and client names.
Initial AI Match:
- Sends the todo text and list of projects (with client names) to OpenAI (
gpt-4o-mini) - AI returns one of:
- A specific project ID (if confident match to a project name)
CLIENT:ClientName(if matches a client but not a specific project)NONE(no match)
- Conservative matching - only links when confident
Date-Based Disambiguation:
When the AI returns a client match (or picks a specific project that has sibling projects for the same client), the system disambiguates using the todo's due date:
- Single date match: If the due date falls within exactly one project's date range β use that project
- Multiple overlapping dates: If the due date falls within multiple projects' date ranges β second AI call to pick the best semantic fit
- No date match: If no project contains the due date β pick project with closest start/end date boundary
- No dates on projects: Fall back to most recently edited project
Example - AI picks client, date disambiguates:
Todo: "Review Acme contract for @John due Dec 15"
AI returns: CLIENT:Acme
Projects for Acme:
- "Acme Website Redesign" (Nov 1 - Nov 30) β Dec 15 not in range
- "Acme Q4 Campaign" (Dec 1 - Dec 31) β
Dec 15 in range
Result: Linked to "Acme Q4 Campaign"
Example - Overlapping dates, second AI call:
Todo: "this should not go to mission/vision due Nov 28"
AI returns: Dealfront Mission/Vision/Purpose (specific project)
Projects for Dealfront:
- "Dealfront Mission/Vision/Purpose" (Nov 28 - Nov 28) β
Nov 28 in range
- "Dealfront Roadmap Strategies" (Nov 18 - Dec 6) β
Nov 28 in range
Both overlap! Second AI call with just these 2 candidates:
AI picks: "Dealfront Roadmap Strategies" (better semantic fit based on todo text)
Result: Linked to "Dealfront Roadmap Strategies"
If strategies 1-4 don't find a match and the todo is nested under a parent block, the system traverses up the block tree and applies strategies 1-4 to each ancestor.
How it works:
- Fetches the parent block from Notion
- Applies strategies 1-4 to the parent's content (using the todo's due date for disambiguation)
- If no match, moves to grandparent, up to 5 levels
- Stops when a match is found or reaches page level
Example: A todo nested under a toggle "Project Gamma Tasks" might match to "Project Gamma" via the parent toggle's text.
Project matching is configured in notion.config.ts, not environment variables.
Step 1: Add a relation property to your Todos database
- Create a relation property (e.g., "Projects db") pointing to your Projects database
- The system auto-discovers all relation properties from the database schema
Step 2: Configure notion.config.ts
// TODOS_PROPERTIES - declare which relation gets AI treatment
export const TODOS_PROPERTIES = {
// ... other properties
projects: "Projects db", // Name of your relation property (null to disable)
fuzzyMatch: ["Contacts"], // Relations for fuzzy name matching (see Fuzzy Name Matching section)
};
// PROJECTS_PROPERTIES - configure the target database for disambiguation
export const PROJECTS_PROPERTIES = {
groupBy: "Clients", // Relation to group projects (for AI matching)
dateStart: "Dates", // Date property for date-based disambiguation
dateEnd: "Dates", // Same property if using date ranges
};
Your Projects database should have:
- Clients relation (or similar) - Groups projects for disambiguation
- Dates property (date with start/end) - Enables date-based disambiguation
How relation mapping works:
- All relation properties in your Todos database are auto-discovered
- Basic relations get mention/contextual mapping automatically (Strategies 1-2)
- Relations in
fuzzyMatcharray also get fuzzy name matching (Strategy 3) - The relation named in
TODOS_PROPERTIES.projectsalso gets AI matching (Strategies 4-5)
OpenAI Integration:
- Uses Val Town's built-in OpenAI integration (
@std/openai) - Model:
gpt-4o-mini(fast, cost-effective) - AI calls per todo (when needed):
- Fuzzy match disambiguation (when multiple names match - Strategy 3)
- Project AI match (when strategies 1-3 fail - Strategy 4)
- Date range disambiguation (when multiple projects overlap - Strategy 4)
- 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- Choose a search mode:
- Keyword mode:
SEARCH_KEYWORDS=todo(or your preferred keywords, comma-separated) - Block type mode:
SEARCH_BLOCK_TYPE=to_do(or your preferred block type)
- Keyword mode:
- (Optional) Configure
notion.config.tsfor project AI matching:- Set
TODOS_PROPERTIES.projectsto your relation property name - Configure
PROJECTS_PROPERTIESfor disambiguation (see Project Matching)
- Set
- Test with:
POST /tasks/todos?hours=1
Find and sync all keyword matches from last 24 hours:
# Set: SEARCH_KEYWORDS=todo curl -X POST https://your-val.express/tasks/todos
Custom time window:
# Set: SEARCH_KEYWORDS=todo curl -X POST "https://your-val.express/tasks/todos?hours=48"
Search for multiple keywords:
# Set: SEARCH_KEYWORDS=todo,zinger,π curl -X POST https://your-val.express/tasks/todos
Find and sync all checkbox todos from last 24 hours:
# Set: SEARCH_BLOCK_TYPE=to_do curl -X POST https://your-val.express/tasks/todos
Find all bullet points with people + dates:
# Set: SEARCH_BLOCK_TYPE=bulleted_list_item 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 β β
β β "todo" β β "todo" β β β β
β ββββββββββββ ββββββββββββ ββββββββββββ β
ββββββββββββββββββββββββββββ¬βββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββββββββββ
β POST /tasks/todos?keyword=todo β
β (Batch Search & Sync Endpoint) β
βββββββββββββββββββ¬ββββββββββββββββββββ
β
ββββββββββββββββββββ΄βββββββββββββββββββ
β β
βΌ βΌ
βββββββββββββββββββββ ββββββββββββββββββββββββββββ
β Step 1: Search β β Step 3: Sync (Optimized)β
β β β β
β β’ Get recent pagesβ β β’ Read blobs β
β β’ Fetch all blocksβ β β’ Skip if synced: true β
β β’ Search keywords β β β’ 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&keyword=todo β
ββββββββββββββββββββββββ¬ββββββββββββββββββββββββββββββββββββββββ
β
βΌ
βββββββββββββββββββββββββββββββ
β 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
MIT