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
- Status 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) |
| Variable | Description |
|---|---|
OPENAI_API_KEY | OpenAI API key for AI features (strongly recommended) |
What AI does:
- Polishes raw todo text into clean, standalone summaries
- Extracts owner from text patterns (e.g., "Taylor to call Jordan" → owner: Taylor)
- Determines which date in the todo is the deadline
- Extracts other people mentioned in text
- Disambiguates when multiple contacts match a name
Without OPENAI_API_KEY: Falls back to Val Town's shared OpenAI client (10 req/min limit). If AI is unavailable, the system still works but uses raw text, heading/@mention-only owner resolution, and default due dates.
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
- Make sure the integration has access to your databases
- Set the environment variables
By using this software, you agree to the terms in DISCLAIMER.md.
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)
- Checked/unchecked → Status (Done or Not started, if configured)
One-way sync with user override:
Sync flows one direction: source pages → todos database. Updates from the source continue until a user edits the todo directly in the database. At that point, the database becomes authoritative for that todo—todoSweeper won't overwrite your explicit changes.
This works automatically using Notion's built-in last_edited_by system field (not a database property). You don't need to add or display this field—it's tracked by Notion on every page.
Visit the root URL of your val (main.http.tsx) 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
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
The {key} can be either format:
- camelCase:
TODOS_PROP_otherPeople - SCREAMING_SNAKE:
TODOS_PROP_OTHER_PEOPLE
| Key (camelCase) | Key (SCREAMING_SNAKE) |
|---|---|
name | NAME |
sourceBlockId | SOURCE_BLOCK_ID |
owner | OWNER |
otherPeople | OTHER_PEOPLE |
dueDate | DUE_DATE |
status | STATUS |
projects | PROJECTS |
sourceBlockUrl | SOURCE_BLOCK_URL |
sourcePageUrl | SOURCE_PAGE_URL |
sourceLastEditedTime | SOURCE_LAST_EDITED_TIME |
sourceAuthor | SOURCE_AUTHOR |
sourceText | SOURCE_TEXT |
links | LINKS |
Examples (both formats work identically):
# These two are equivalent: TODOS_PROP_dueDate=Deadline TODOS_PROP_DUE_DATE=Deadline # These two are equivalent: TODOS_PROP_otherPeople=Contacts TODOS_PROP_OTHER_PEOPLE=Contacts # These two are equivalent: TODOS_PROP_sourceBlockId=Block ID TODOS_PROP_SOURCE_BLOCK_ID=Block ID # Enable optional features (disabled by default): TODOS_PROP_status=Status # or TODOS_PROP_STATUS=Status TODOS_PROP_projects=Projects # or TODOS_PROP_PROJECTS=Projects
To disable a property: Set it to empty string:
TODOS_PROP_otherPeople= # or TODOS_PROP_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 |
When TODOS_PROP_STATUS is set, checkbox state syncs to your status property.
| Checkbox State | Status Value |
|---|---|
| Checked | "Done" (or "Complete", "Completed", "Finished") |
| Unchecked | First non-done option (e.g., "Not started", "To Do") |
Supports Status, Select, and Checkbox property types. The system auto-detects your property type and finds matching options.
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 features (see Quick Start for details) |
When first setting up, you may want to test on individual pages before letting the system sweep up all todos under your integration.
Setup mode (CRONS_DISABLED=true):
- Crons don't run - nothing syncs automatically
- Use the
/tasks/todos/pagewebhook to test on specific pages - Add a button in Notion that calls this endpoint to sync just that page
- Validate property mappings, owner resolution, and relations work correctly
Production mode (default, or CRONS_DISABLED=false):
- Crons run every minute
- All pages under your integration are scanned for todos
- Todos automatically sync to your central database
Once you've validated everything works in setup mode, remove CRONS_DISABLED (or set to false) to enable automatic syncing.
In setup mode, you need NOTION_WEBHOOK_SECRET to authenticate webhook requests. This is required for setup mode but not needed for production mode (crons authenticate differently).
Step 1: Set the environment variable
Set NOTION_WEBHOOK_SECRET to any secure string:
NOTION_WEBHOOK_SECRET=your-secret-key-here
Step 2: Get your val's URL
Open main.http.tsx in Val Town. Copy the HTTP endpoint of that file (something like https://<your val's subdomain>.web.val.run) - you'll need it for the button.
Step 3: Configure your Notion button
Create a button in Notion with these settings:
- Action: Send webhook
- URL: Your val's base URL +
/tasks/todos/page(e.g.,https://<your val's subdomain>.web.val.run/tasks/todos/page) - Headers: Add
X-API-KEYwith the exact same value as yourNOTION_WEBHOOK_SECRET
The header value must match the environment variable exactly (case-sensitive). If they don't match, the request will be rejected with a 401 error.
What the endpoint does:
POST /tasks/todos/page syncs only the page containing the button, letting you validate property mappings, owner resolution, and relations before enabling crons for your entire workspace.
Three crons run independently (when enabled):
- todoSearch.cron: Scans recent pages for todos
- todoSync.cron: Syncs blobs to database
- cacheWarm.cron: Keeps health cache fresh
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.
Blob storage serves two purposes: checkpointing sync state and caching for performance.
Example blob (one per todo block):
{ // Block identity "block_id": "abc123-def456-...", "block_url": "https://notion.so/page#abc123", "page_url": "https://notion.so/page", // Todo content "todo_string": "Call Kevin about the project by Friday", "checked": false, // Context for matching "preceding_heading": "Alex", "people_mentions": [{"id": "...", "name": "Kevin", "email": "..."}], "date_mentions": ["2025-01-24"], "link_mentions": [{"text": "Project Alpha", "url": "...", "pageId": "..."}], // Source page context (for project inheritance) "source_page_database_id": "projects-db-id", "source_page_relations": {"Projects": ["page-id-1"]}, // Metadata "last_edited_time": "2025-01-22T10:30:00.000Z", "author": {"id": "...", "name": "Jane", "email": "..."}, // Sync tracking "sync_metadata": { "synced": true, "syncing": false, "target_page_id": "xyz789", "archived": false } }
Key fields explained:
| Field | Purpose |
|---|---|
sync_metadata.synced | false = needs sync, true = already synced |
sync_metadata.syncing | Lock to prevent concurrent syncs (5-minute timeout) |
sync_metadata.target_page_id | Cached ID so updates don't require a database query |
preceding_heading | Used for owner matching when heading matches a contact name |
source_page_relations | Inherited project relations from the source page |
Checkpointing flow:
- Search phase finds todo → saves blob with
synced: false - Save phase acquires lock (
syncing: true) - On success → marks
synced: true, storestarget_page_id - On failure → releases lock (
syncing: false), retries next cron run
Caching:
- Health data and relation mappings are cached separately with 60-second TTL
cacheWarm.cronkeeps caches fresh for instant dashboard loads
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.
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.
This project is licensed under the MIT License. See LICENSE for details.
This software interacts with your Notion workspace and third-party services. By using this software, you accept the terms in DISCLAIMER.md, including limitations of liability and user responsibilities.