Automatically syncs Notion checkbox todos to a centralized database with intelligent owner resolution, relation mapping, and AI-powered summaries.
- Quick Start
- How It Works
- Property Mapping
- Owner Resolution
- Other People Resolution
- Due Date Resolution
- Relation Mapping
- Configuration Reference
- Advanced Topics
| Variable | Description |
|---|---|
NOTION_API_KEY | Your Notion integration token (get one here) |
TODOS_DB_ID | Database ID where todos are synced (32-char ID without hyphens) |
Your Todos database needs these properties at minimum:
| Property Name | Type | Purpose |
|---|---|---|
| Name | Title | The todo text (cleaned up by AI) |
| Source block ID | Rich text | Unique identifier to prevent duplicates |
- Create a Notion integration and get the API key
- Create a database with the required properties above
- Share the database with your integration
- Set the environment variables
- Test:
POST /tasks/todos?hours=1
That's it! Additional properties (Owner, Due date, Projects, etc.) are optional and auto-detected.
Notion pages → Search for todos → Blob storage → Sync to database
- Search: Scans recent pages for checkbox (
to_do) blocks - Extract: Captures text, @mentions, dates, page links, and surrounding context
- Store: Saves to blob storage with sync tracking
- Sync: Creates/updates pages in your Todos database with AI-powered summaries
What gets captured from a todo:
- Block text → Name (polished by AI)
- @mentions → Owner and Other People
- Dates in text → Due Date (AI interprets which is the deadline)
- Page links → Relations (auto-mapped to matching databases)
- Preceding heading → Owner context (if heading matches a contact name)
- Checkbox state → Status (if configured)
Visit /api/health or the root URL to see a dashboard showing:
- Connection status: Notion API and OpenAI connectivity
- Property mappings: Which properties are configured and whether they exist in your database
- Relations discovered: What relation properties were found and how many pages each targets
- Owner/Status property types: Whether Owner is people or relation type, Status property detection
Use this to verify your setup is working and debug property mapping issues.
The system maps todo data to your database properties. Properties are matched by name.
| Key | Default Name | Required | Purpose |
|---|---|---|---|
name | Name | Yes | Todo text (title property) |
sourceBlockId | Source block ID | Yes | Unique ID for deduplication |
owner | Owner | No | Person responsible for the todo |
otherPeople | Other people | No | Other people mentioned |
dueDate | Due date | No | When it's due |
status | (disabled) | No | Checkbox sync (Done/Not started) |
projects | (disabled) | No | Project relation |
sourceBlockUrl | Source block URL | No | Link to original block |
sourcePageUrl | Source page URL | No | Link to source page |
sourceLastEditedTime | Source last edited time | No | When source was edited |
sourceAuthor | Source author | No | Who created the block |
sourceText | Source text | No | Raw block text |
links | Links | No | URLs found in block |
If your database uses different property names, override with environment variables:
TODOS_PROP_{KEY}=Your Property Name
Examples:
TODOS_PROP_DUE_DATE=Deadline # Use "Deadline" instead of "Due date" TODOS_PROP_OWNER=DRI # Use "DRI" instead of "Owner" TODOS_PROP_STATUS=Status # Enable status sync (disabled by default) TODOS_PROP_PROJECTS=Projects # Enable project linking (disabled by default)
To disable a property: Set it to empty string:
TODOS_PROP_OTHER_PEOPLE= # Don't sync other people
Owner and Other People can be either:
- People type: Links to Notion workspace users
- Relation type: Links to pages in another database (e.g., Contacts)
The system auto-detects the type from your database schema and adjusts matching behavior accordingly.
Owner is determined using a strict priority order. The first match wins.
┌─────────────────────────────────────────────────────────────┐
│ Priority 1: HEADING MATCH │
│ Does the preceding heading match someone in owner DB? │
│ Example: "### Alex" matches "Alex Johnson" in Contacts │
├─────────────────────────────────────────────────────────────┤
│ MATCH → Use heading match (most reliable) │
│ NO → Continue to Priority 2 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Priority 2: @MENTION │
│ Is there an @mention in the todo? │
│ Example: "@Jane should review the PR" │
├─────────────────────────────────────────────────────────────┤
│ YES → Use first @mention │
│ NO → Continue to Priority 3 │
└─────────────────────────────────────────────────────────────┘
↓
┌─────────────────────────────────────────────────────────────┐
│ Priority 3: AI EXTRACTION │
│ AI extracts owner from todo text patterns │
│ Example: "Taylor to call Jordan" → Taylor is owner │
├─────────────────────────────────────────────────────────────┤
│ Name extracted → Validate against owner database │
│ FOUND → Use matched page/user │
│ NOT FOUND → Owner = null (prevents hallucination) │
└─────────────────────────────────────────────────────────────┘
Headings only apply to todos directly beneath them. Any intervening block resets the heading context.
Heading applies:
### Alex
- [ ] Todo 1 ← owner context: "Alex"
- [ ] Todo 2 ← owner context: "Alex"
Paragraph resets heading:
### Alex
Some intro text here
- [ ] Todo 1 ← owner context: null (reset by paragraph)
Empty line resets heading:
### Alex
- [ ] Todo 1 ← owner context: null (reset by empty line)
This prevents distant headings from incorrectly matching todos far below them.
| Owner Type | Matches Against |
|---|---|
| People | Notion workspace members only |
| Relation | Any page in target database |
Recommendation: Use Relation type pointing to a Contacts database. It's more flexible and works with both workspace members and external contacts.
Other People captures everyone mentioned in a todo except the owner.
- @Mentions: All @mentions (except the resolved owner) are added
- AI-Extracted Names: Names mentioned in text (only when Other People is a relation type)
When Other People is a relation property, AI extracts names from the todo text:
"Call Kevin about the project" → AI extracts "Kevin"
Matching rules:
- Exact match first (case-insensitive)
- Prefix match fallback: "Kevin M" matches "Kevin McIntyre"
- Skipped if ambiguous (multiple matches)
- Skipped if already the owner or an @mention
AI determines the due date from your todo content.
- AI examines all dates mentioned in the todo
- AI picks the one that looks like a deadline
- If no date in text, uses
DEFAULT_DUE_DATEsetting - AI always returns a date (never leaves it blank)
Set DEFAULT_DUE_DATE environment variable:
| Value | Meaning |
|---|---|
today | Due today (default) |
tomorrow | Due tomorrow |
one_week | Due 7 days from now |
end_of_week | Due next Friday |
next_business_day | Due next weekday |
Todos are automatically linked to related pages (projects, tags, etc.) using these strategies.
Works for any relation property.
1a: @Mentions If the todo @mentions a page that exists in a relation's target database, it's linked.
- [ ] Review [[Project Alpha]] specs
→ Linked to "Project Alpha" in Projects
1b: Heading Match If the preceding heading exactly matches a page name in a relation's target database, it's linked.
### Project Alpha
- [ ] Review the specs
→ Linked to "Project Alpha" in Projects
These strategies only apply to the Projects relation. Other relations require explicit @mentions or heading matches.
2a: Source Page Relations If the source page has a relation property with matching values, those are inherited.
Meeting Notes page has Project = "Acme Redesign"
→ All todos on that page inherit "Acme Redesign"
2b: Source Page is Database Entry If the source page is a direct entry in the Projects database, todos link to that page.
Todo on "Project Beta" page (which is in Projects DB)
→ Linked to "Project Beta"
2c: Parent Page Traversal For nested pages, walks up the hierarchy (up to 3 levels) looking for 2a/2b matches.
| Variable | Description |
|---|---|
NOTION_API_KEY | Notion integration token |
TODOS_DB_ID | Database ID for synced todos |
| Variable | Default | Description |
|---|---|---|
TODOS_PROP_NAME | Name | Todo title property |
TODOS_PROP_SOURCE_BLOCK_ID | Source block ID | Deduplication ID |
TODOS_PROP_OWNER | Owner | Person responsible |
TODOS_PROP_OTHER_PEOPLE | Other people | Other mentions |
TODOS_PROP_DUE_DATE | Due date | Deadline |
TODOS_PROP_STATUS | (disabled) | Checkbox sync |
TODOS_PROP_PROJECTS | (disabled) | Project relation |
TODOS_PROP_LINKS | Links | URLs found |
TODOS_PROP_SOURCE_BLOCK_URL | Source block URL | Link to block |
TODOS_PROP_SOURCE_PAGE_URL | Source page URL | Link to page |
TODOS_PROP_SOURCE_LAST_EDITED_TIME | Source last edited time | Edit timestamp |
TODOS_PROP_SOURCE_AUTHOR | Source author | Block creator |
TODOS_PROP_SOURCE_TEXT | Source text | Raw text |
| Variable | Default | Description |
|---|---|---|
DEFAULT_DUE_DATE | today | Fallback when no date in todo |
MIN_BLOCK_WORDS | 5 | Skip todos with fewer words |
BLOCK_STABILITY_MINUTES | 0 | Wait before syncing (cron only) |
RECENT_PAGES_LOOKBACK_HOURS | 24 | How far back to search |
CRONS_DISABLED | false | Disable automatic sync |
| Variable | Default | Description |
|---|---|---|
SEARCH_BLOCK_TYPE | to_do | Block type to search for |
SEARCH_KEYWORDS | (none) | Additional keywords (comma-separated) |
| Variable | Description |
|---|---|
NOTION_WEBHOOK_SECRET | API key for protecting endpoints |
OPENAI_API_KEY | For AI summaries (optional, uses Val Town shared if not set) |
| Endpoint | Method | Purpose |
|---|---|---|
/tasks/todos?hours=24 | POST | Search + sync recent pages |
/tasks/todo/search | POST | Search single page (webhook) |
/tasks/todo/save | POST | Sync all blobs to database |
/api/pages/recent?hours=24 | GET | List recently edited pages |
/api/health | GET | System health check |
Three crons run independently:
- todoSearch.cron (every 1 min): Scans recent pages for todos
- todoSync.cron (every 1 min): Syncs blobs to database
- cacheWarm.cron (every 1 min): Keeps health cache fresh
Set CRONS_DISABLED=true during setup to test via webhooks instead.
The system tracks sync state in blob storage:
- First sync: Query + create = 2 API calls
- No changes: Skip immediately = 0 API calls
- Block updated: Direct update = 1 API call
This reduces API calls by 90%+ for stable todos.
Two caches with 1-minute TTL:
- Health cache: Instant dashboard loads
- Relation cache: Fast sync initialization
Property renames in Notion may take up to 1 minute to propagate.
When TODOS_PROP_STATUS is set:
- Checked checkbox → "Done" (or "Complete", "Completed", "Finished")
- Unchecked → First non-done option (e.g., "Not started", "To Do")
Supports Status, Select, and Checkbox property types.
Todos are validated during search:
- Must have at least
MIN_BLOCK_WORDSwords (default: 5) - Shorter blocks are skipped as not meaningful
backend/
├── controllers/ # Business logic
├── services/ # External API integrations
├── routes/ # HTTP handlers
├── crons/ # Scheduled jobs
└── utils/ # Helpers
frontend/ # React dashboard
shared/ # Shared types
Notion Timestamp Precision: Block edit times are rounded to the minute. If you edit a todo twice within 60 seconds, only the first edit syncs until a later edit. Checkbox toggles are explicitly compared to catch same-minute changes.