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.
This system enables automatic extraction and organization of action items from Notion 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
// 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
{success, data, error, details?}// 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
{success, data, error}// 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:
MIN_BLOCK_WORDS (default: 5)Validation & Enrichment (happens here, not during sync):
MIN_BLOCK_WORDS? → Skip (too short to be meaningful)date_mention? → Auto-assign based on DEFAULT_DUE_DATE setting (default: today)Endpoints:
POST /tasks/todo/search - Single page search (webhook-triggered)POST /tasks/todos?hours=24 - Batch search across recent pagesKeywords Configuration:
SEARCH_KEYWORDS environment variable (comma-separated)SEARCH_KEYWORDS=todo,zinger,bit,😀todo if not setKeyword Matching Logic:
/\btodo\b/iBlock 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:
date_mentions array for Notion APIStorage Format:
{projectName}--{category}--{blockId}demo--todo--abc-123-def-456Blob 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:
last_edited_time of existing blob vs new blocksynced: true status)synced: false (triggers re-sync)target_page_id across updatesFlow:
synced: true (0 API calls)target_page_id if available (1 API call - update only)synced: true and cache page IDNote: 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:
synced: false, no target_page_idsynced: trueOn subsequent syncs (no changes):
synced: trueOn subsequent syncs (block changed):
synced: false (block edited in Notion)target_page_id from previous syncsynced: truePerformance impact:
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:
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 typeNon-container blocks (skipped):
paragraph, heading_1/2/3, code, equation - cannot have to_do childrenimage, video, file, pdf, audio, embed, bookmark - media blocksPerformance 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:
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_row.cells[][] (array of arrays)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):
MIN_BLOCK_WORDS words (default: 5)2. Due Date (CONDITIONAL + AUTO-ASSIGNED):
DEFAULT_DUE_DATE env var (defaults to "today")3. Owner Assignment (AI-DETERMINED during sync):
author for potential use in owner resolutionAutomatic Due Date Assignment:
DEFAULT_DUE_DATE environment variabletoday (default), tomorrow, one_week, end_of_week, next_business_day"2025-10-31")Owner Resolution (during sync):
generateSummary() + resolveOwner()author and may be used if owner source is "heading"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:
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:
synced: true)Note: All blobs have due dates assigned; owner is determined by AI during sync.
GET /api/pages/recent?hours=24
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_KEYWORDS env var (comma-separated){ "page_id": "abc-123" }POST /tasks/todo/save
POST /tasks/todos?hours=24
SEARCH_KEYWORDS env varResponse:
{ "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:
.cron.tsx extension for Val TownPurpose: Search recent pages for keywords/block types and save matches to blob storage
Workflow:
Configuration:
SEARCH_KEYWORDS or SEARCH_BLOCK_TYPE env varOutput:
=== 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:
Configuration:
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:
Fault isolation:
Cost optimization:
todoSearch.cron.tsx - Copy content from backend/crons/todoSearch.cron.tstodoSync.cron.tsx - Copy content from backend/crons/todoSync.cron.tstodoSearch.cron.tsx: Every 1 minute (* * * * *)todoSync.cron.tsx: Every 1 minute (* * * * *)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)
TODOS_DB_ID - Notion database ID for todo sync (required)
abc123def456... (32-character ID without hyphens)PROJECTS_DB_ID - DEPRECATED (optional, still works)
notion.config.ts instead (see Project Matching)SEARCH_KEYWORDS - Keywords to search for (optional, keyword mode)
todo,zinger,bit or todo,😀,🎉todo if not setSEARCH_BLOCK_TYPE - Block type to search for (optional, block type mode)
to_do (searches all Notion checkbox blocks)to_do if set with empty valueSEARCH_KEYWORDS if both are setto_do, paragraph, bulleted_list_item, numbered_list_itemMIN_BLOCK_WORDS - Minimum word count for blocks to be saved (optional)
5 if not setDEFAULT_DUE_DATE - Default due date for blocks without date mentions (optional)
today, tomorrow, one_week, end_of_week, next_business_daytoday if not settoday - 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)
0 if not set (no delay - blocks saved immediately)2 for 2 minutes)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 savedRECENT_PAGES_LOOKBACK_HOURS - Default lookback window for searching recent pages (optional)
24 hours if not set?hours=X query parameterRECENT_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)NOTION_WEBHOOK_SECRET - API key for protecting webhooks and API endpoints (recommended)
/tasks/* and /api/* endpoints (except /api/health)X-API-KEY with this valueX-API-KEY: your-secret-valueNOTION_WEBHOOK_SECRET=abc123xyz789...Security note: Without this, anyone can:
/api/pages/recent (potential data leak)/api/health and use them to query other endpointsPublic endpoint exception: /api/health remains public (needed for frontend dashboard)
API_KEY - Legacy API key (deprecated, use NOTION_WEBHOOK_SECRET instead)
NOTION_WEBHOOK_SECRET for new deploymentsCRONS_DISABLED - Disable all cron jobs without changing Val Town UI (optional)
true to disable crons (they will exit early with a log message)false = crons run normally (default)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:
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:
to_do blocks (Notion checkboxes)Use case:
to_do block)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
SEARCH_KEYWORDS will be ignoredIf 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 on DEFAULT_DUE_DATE (default: today)author - Block creator is captured for potential use in AI owner resolutionAll 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:
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 │
└─────────────────────────────────────────────────────────────────┘
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:
TODOS_PROPERTIES.projects also gets AI matching (Strategies 3-4)Example: If your Todos database has relations to "Projects db", "Clients", and "Tags":
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:
### 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:
preceding_heading in blob storageFuzzy name matching runs after explicit @mentions and before AI 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:
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 matchingThe 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:
When generating the AI summary, the system extracts an owner name from context using this priority:
If Owner is a People property:
The owner name is matched to a Notion workspace user:
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:
Notion distinguishes between two types of users:
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:
Use Relation type when:
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:
gpt-4o-mini)CLIENT:ClientName (if matches a client but not a specific project)NONE (no match)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:
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:
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
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:
How relation mapping works:
fuzzyMatch array also get fuzzy name matching (Strategy 3)TODOS_PROPERTIES.projects also gets AI matching (Strategies 4-5)OpenAI Integration:
@std/openai)gpt-4o-mini (fast, cost-effective)NOTION_API_KEY = your integration tokenTODOS_DB_ID = your database IDSEARCH_KEYWORDS = todo (or your preferred keywords, comma-separated)SEARCH_BLOCK_TYPE = to_do (or your preferred block type)notion.config.ts for project AI matching:
TODOS_PROPERTIES.projects to your relation property namePROJECTS_PROPERTIES for disambiguation (see Project Matching)POST /tasks/todos?hours=1Find 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"
CLAUDE.mdAGENTS.md┌─────────────────────────────────────────────────────────────┐
│ 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