name: notion-provider description: Notion-specific provider implementation for waggle. Loaded when the active provider is notion. user-invocable: false
Waggle — Notion Provider
This file contains all Notion-specific implementation details for waggle. Load this file when the active provider is notion.
Silent operation: This skill runs as an internal step of an invoking skill. Return results to the invoking flow without user-facing narration — the caller owns all user communication. Only errors, warnings, and prompts required to proceed may surface directly.
Config Retrieval
When detecting-provider requests config retrieval for the Notion provider, follow these steps to populate headless_config:
Step 1: Cache fast path (environment-aware)
Check the cache for resolved DB IDs before searching Notion. The cache mechanism differs by execution_environment:
cli/claude-desktop: read env vars from the running shell. The full set of cached IDs is:env var headless_configfieldrequired WAGGLE_NOTION_TASKS_DB_IDtasksDatabaseIdyes WAGGLE_NOTION_TEAMS_DB_IDteamsDatabaseIdoptional WAGGLE_NOTION_INTAKE_LOG_DB_IDintakeLogDatabaseIdoptional WAGGLE_NOTION_SPRINTS_DB_IDsprintsDatabaseIdoptional WAGGLE_NOTION_ACTIVE_THREADS_DB_IDactiveThreadsDatabaseIdoptional Read every env var that is set and copy it into the corresponding
headless_configfield.cowork: scan the active system prompt / available context for a block of the form:<waggle-config> { "tasksDatabaseId": "...", "teamsDatabaseId": "...", "intakeLogDatabaseId": "...", "sprintsDatabaseId": "...", "activeThreadsDatabaseId": "..." } </waggle-config>If present and parseable as JSON, copy each key into the corresponding
headless_configfield (same mapping as the table above; keys not present in the JSON remain unset). The Cowork JSON shape is the source of truth — a user who pastes onlytasksDatabaseIdandteamsDatabaseIdgets a fast path withintakeLogDatabaseIdetc. left unset, identical to the CLI / Desktop case where only those env vars are exported.
If the cache provides at least tasksDatabaseId, populate headless_config and skip to Schema Validation. Otherwise continue to Step 2. (Optional IDs missing from the cache do not force a fallback — Step 2 only runs when tasksDatabaseId itself is absent. Downstream skills that need an unset optional ID must fetch the Config page on demand at that point.)
Step 2: Resolve via "Waggle Config" page (cache miss path)
Call notion-search with query "Waggle Config". The MCP tool performs a partial-match / semantic search, so apply this client-side filter to the results before doing anything else:
keep only results where:
result.title == "Waggle Config" (exact, case-sensitive)
AND result.type == "page"
AND result is not trashed/archived
Discard everything else. In particular, member-scoped databases such as Waggle:Hori, Waggle:Funase, parent pages like メンバー別:Waggle, or any other partial-match hit MUST be dropped — they are never the Config page.
After filtering:
- 0 results: no Config page exists. Instruct the user to run the
setting-up-tasksskill, then stop. (There is no legacy fallback — theAgentic Tasks Configlegacy name was removed in 2.6.0.) - 1 result: this is the Config page. Proceed.
- 2+ results: a workspace has multiple
Waggle Configpages. UseAskUserQuestionto ask the user which one to adopt.
notion-fetch the chosen page ID, parse the JSON code block, and populate headless_config with:
tasksDatabaseId(required)teamsDatabaseId(optional)sprintsDatabaseId(optional — exists after setting-up-scrum)intakeLogDatabaseId(optional — exists after first ingesting-messages run)activeThreadsDatabaseId(optional — exists after first ingesting-messages run that registers a thread)
Step 3: Cache populate (after Step 2 succeeds)
Persist the resolved IDs so the next session hits the fast path instead of running search again. Behavior differs by execution_environment:
cli/claude-desktop: auto-write to~/.claude/settings.json.- Read the existing file (create with
{}if missing), preserve all other keys, and merge each resolved ID into theenvfield using the table from Step 1:WAGGLE_NOTION_TASKS_DB_ID←tasksDatabaseIdWAGGLE_NOTION_TEAMS_DB_ID←teamsDatabaseId(only if present inheadless_config)WAGGLE_NOTION_INTAKE_LOG_DB_ID←intakeLogDatabaseId(only if present)WAGGLE_NOTION_SPRINTS_DB_ID←sprintsDatabaseId(only if present)WAGGLE_NOTION_ACTIVE_THREADS_DB_ID←activeThreadsDatabaseId(only if present)
- If a key is already set to the resolved value, no-op. If the existing value differs, overwrite (the searched-and-fetched value is authoritative — the previous cache was stale).
- This is silent (no user prompt) — env-var caching is non-intrusive.
- Read the existing file (create with
cowork: useAskUserQuestionat most once per session to ask the user whether to cache:"Would you like to cache these Notion DB IDs in your Cowork Global Instructions so future sessions skip the Notion search? Paste the block below into Global Instructions if Yes."
Options:
Yes — show paste block/LaterIf
Yes: display<waggle-config> { ...JSON with all resolved IDs... } </waggle-config>If
Later: set a session-local flagcowork_cache_prompt_dismissed = trueand do not ask again this session. The next session will ask again until the user pastes the block (and the block is found in Step 1).
Recovery from stale cache
If Step 1 returns a cached tasksDatabaseId but Schema Validation (next section) fails with a 404 "Could not find database with ID" error from Notion, treat the cache as stale: discard all cached IDs in headless_config, fall through to Step 2 (search), and re-populate the cache via Step 3 with the freshly-resolved IDs.
Precedence over the Error Handling table: this recovery path takes precedence over the Database access denied → Terminal row in the Error Handling table at the bottom of this file. Do not halt the current step when the failure originates from a Step 1 cached value — the same 404 signal is non-terminal in that specific context because the search-based fallback may resolve a fresh ID. The Error Handling table's terminal classification continues to apply for every other origin (e.g. a query against a task ID, a relation update, a Schema Validation failure during normal Step 2 operation).
Second failure is terminal: if Schema Validation still fails with the same 404 after Step 2 resolves a fresh ID — meaning the Config page itself contains a stale ID (e.g. the Tasks DB was deleted in Notion but the Config page was not updated) — treat it as terminal at that point. Surface the error verbatim, and instruct the user either to update the Config page's tasksDatabaseId to point at the current Tasks DB, or to re-run setting-up-tasks to recreate the DB and rewrite the Config page.
Note:
maxConcurrentAgentsmay exist in legacy config files but is no longer used. Ignore it if present.
Schema Validation
After loading config, verify Core fields by calling notion-fetch with tasksDatabaseId and inspecting the returned schema's properties object.
Required Core fields (16): Title, Description, Acceptance Criteria, Status, Blocked By, Priority, Executor, Requires Review, Execution Plan, Working Directory, Session Reference, Dispatched At, Agent Output, Error Message, Issuer, Quality Verdict (added in v2.8.0 for the quality gate cache; see waggle-protocol § Quality Spec).
Auto-Repair (Missing Fields)
If any Core field is missing, automatically repair using notion-update-data-source.
First obtain the data source ID via notion-fetch on the database URL.
Then run the appropriate DDL (one ADD COLUMN per call):
| Missing Field | Repair DDL |
|---|---|
| Status | ADD COLUMN "Status" SELECT('Backlog':gray, 'Ready':blue, 'In Progress':yellow, 'In Review':orange, 'Done':green, 'Blocked':red, 'Cancelled':purple) |
| Priority | ADD COLUMN "Priority" SELECT('Urgent':red, 'High':orange, 'Medium':yellow, 'Low':blue) |
| Executor | ADD COLUMN "Executor" SELECT('cli':purple, 'claude-desktop':green, 'cowork':blue, 'human':gray) |
| Dispatched At / Due Date | ADD COLUMN "<field>" DATE |
| Issuer | ADD COLUMN "Issuer" CREATED_BY (v2.8.1+; was PERSON in earlier versions. See "Migration Guide: v2.7.x → v2.8.1" below if upgrading an existing DB.) |
| Quality Verdict | ADD COLUMN "Quality Verdict" RICH_TEXT |
| (other text fields) | ADD COLUMN "<field>" RICH_TEXT |
After repair, re-verify and continue. Never ask the user to manually fix the schema.
The Quality Verdict column stores the v2.8.0 Reviewer verdict cache. It is populated automatically by the reviewing-quality skill — users do not edit it directly. Format: <verdict> hash=<8hex> @<iso8601> v1 [suppressed-until=<iso8601>]. See skills/reviewing-quality/references/cache-format.md.
Migration Guide: v2.7.x → v2.8.1 (Issuer column type change)
In v2.8.1 the Issuer column type changes from PERSON (a writable people property) to CREATED_BY (a read-only built-in property auto-populated by Notion with the API token's owning user). Auto-repair handles fresh databases automatically, but a database already initialized under v2.7.x has the old PERSON-typed column and the auto-repair check will see Issuer as present-but-wrong-type. It will NOT replace the column on its own — the change is destructive (existing Issuer values are lost) and so the user must run it manually.
Why this change
Under the old design, every skill flow had to set Issuer = current_user explicitly. Empirically ~27% of tasks ended up with empty Issuer because the flows had multiple ways to drop the field — third-party automations posting directly to Notion, intake flows omitting Issuer in the payload, scheduled tasks where current_user could not be resolved. Switching to created_by lets Notion enforce auto-population at the data store level, eliminating all of those paths in one step.
Trade-offs to acknowledge before migrating
- Existing Issuer values are lost. Notion does not support converting a
PERSONcolumn toCREATED_BYin place. The migration drops the old column and adds a freshCREATED_BYcolumn. Notion then back-fills the new column on every existing row using each row's storedcreated_bymetadata — so Issuer will be 100% populated immediately after migration, but the values reflect the actual creator of each page, not any deliberate "issuer override" that may have been written into the old column. - Single-issuer model.
CREATED_BYreturns one user, not an array. If your prior workflow relied on multi-issuer tasks, that capability is gone. - No more proxy/override. If a teammate previously created tasks "on behalf of" someone else by writing the other person's user into Issuer, that override is lost; the actual creator's identity surfaces instead. The recommended replacement is to set
Assigneeto the intended owner and leave Issuer alone.
Migration procedure
Do these steps in order. Skipping step 2 or running step 3 before step 2 will permanently destroy all existing Issuer values with no in-DB recovery path (only your step-1 backup file can restore them).
Back up the current Issuer values. Query the Tasks DB via the Notion API and dump
(page_id, page_url, title, properties.Issuer)to a local JSON file. Keep this file outside the repo (or git-ignore it) — it contains user IDs from your workspace and serves as an audit trail of overrides that the new column type cannot represent.Add a
CREATED_BY-typed verification column while the oldPERSON-typedIssuercolumn is still present. This must happen before step 3 — adding the verification column gives Notion an opportunity to back-fill it from each page'screated_bymetadata so you can confirm the new column type produces the expected values before dropping the old one.ADD COLUMN "Created By (verification)" CREATED_BYAfter this DDL, re-query a sample of pages and confirm that
Created By (verification)is populated for every existing row. If it is not (for example, if your Notion workspace has rows created by deleted users), STOP here and decide whether to proceed — those rows will end up with empty Issuer after step 3.Drop the old
Issuercolumn and rename the verification column into place. Run as a single DDL transaction:DROP COLUMN "Issuer"; RENAME COLUMN "Created By (verification)" TO "Issuer"Doing this in two transactions (step 2 then step 3) rather than one big transaction avoids a window where the canonical
Issuername does not exist.If Notion appends
" 1"to the renamed column (it does this when an internal trash entry for the original name still exists), run a second rename:RENAME COLUMN "Issuer 1" TO "Issuer".Verify. Re-query the database and confirm:
- The
Issuerproperty has"type": "created_by"in the schema. - All existing pages return a non-empty Issuer value (Notion back-fills from each page's
created_bymetadata). - The fill rate is 100%.
- The
Update your config. No config changes are needed —
headless_configdoes not reference Issuer's type.
If you need to revert, restore from your backup JSON manually (no rollback script is shipped).
MCP Tool Reference
notion-create-pages— Create a task (parent:{ "data_source_id": TASKS_DS_ID })notion-update-page— Update task propertiesnotion-fetch— Get a database, data source, or single task by URL/IDnotion-search— Full-text/semantic search across pages by name (e.g. finding the Waggle Config page during bootstrap). NOT for filtered task queries — server-side property filters such as Assignee/Status are unsupported. Use the Querying Tasks flow instead.notion-get-comments/notion-create-comment— Read/write task comments
Updating Relation Fields
notion-update-page properties only accept string | number | null — it cannot set relation fields (Blocked By, Parent Task, Sprint) which require arrays of {id} objects. Use the appropriate path below.
Relation Update Path Detection
The available path depends on execution_environment because NOTION_TOKEN is exposed to the shell only in CLI; in Claude Desktop / Cowork the token is injected directly into MCP tool invocations and cannot drive a bash script.
CLI (execution_environment = "cli"):
NOTION_TOKENenv var available in shell (check:[ -n "$NOTION_TOKEN" ] && echo "SET" || echo "NOT SET") → Path 1 (bash script)- Otherwise → warn the user (no fallback)
Claude Desktop / Cowork (execution_environment = "claude-desktop" or "cowork"):
mcp__notion-extension__notion-update-relationtool available → Path 2 (Desktop Extension)- Otherwise → warn the user (no fallback)
If no path is available, warn the user. The warning depends on environment:
- CLI: "Relation field updates require
NOTION_TOKENto be available in your shell environment. Set it in~/.claude/settings.jsonenv block, or export it in your shell profile." - Claude Desktop / Cowork: "Relation field updates require the
notion-extensionDesktop Extension. Install it via the plugin setup."
Path 1: Bash Script (CLI, requires NOTION_TOKEN)
bash ${CLAUDE_SKILL_DIR}/scripts/update-relations.sh \
<page_id> <property_name> <mode> [page_id_1] [page_id_2] ...
- mode
replace: Set the relation to exactly the provided IDs (zero IDs = clear) - mode
append: Merge with existing values (dedup)
Examples
Set Blocked By to multiple tasks:
bash ${CLAUDE_SKILL_DIR}/scripts/update-relations.sh \
"<page_id>" "Blocked By" replace "<blocker_id_1>" "<blocker_id_2>"
Append a blocker:
bash ${CLAUDE_SKILL_DIR}/scripts/update-relations.sh \
"<page_id>" "Blocked By" append "<new_blocker_id>"
Set Parent Task (single value):
bash ${CLAUDE_SKILL_DIR}/scripts/update-relations.sh \
"<page_id>" "Parent Task" replace "<parent_id>"
Clear a relation:
bash ${CLAUDE_SKILL_DIR}/scripts/update-relations.sh \
"<page_id>" "Blocked By" replace
Path 2: Desktop Extension (notion-update-relation MCP tool, Claude Desktop / Cowork)
Available when the mcp__notion-extension__notion-update-relation tool is present.
Call mcp__notion-extension__notion-update-relation with:
page_id: the Notion page UUIDproperty_name: relation property name (e.g.,"Blocked By","Parent Task")mode:"replace"or"append"relation_ids: array of page IDs (omit or[]with replace to clear)
Returns a minimal confirmation echo: {ok, page_id, property_name, mode, relation_ids} where relation_ids is the post-update final state (the merged + deduplicated list for append). If you need other page fields after the update, re-fetch via notion-fetch or notion-query.
When to use
Use the relation update path for any relation field update. For non-relation fields, continue using notion-update-page. A single task update that changes both relation and non-relation fields requires two calls.
Setting the Attachments Property
Attachments is a files-type property (waggle file[]). Like relations, notion-update-page cannot set
it (its properties accept only string | number | null). Local files are uploaded via the Notion File
Upload API and Notion hosts them; external URLs are stored as-is. The Notion files array is overwritten
on each write, so append is a read-modify-write (fetch existing entries, merge, write back).
Attachments Path Detection
Same environment split as relation updates — NOTION_TOKEN is exposed to the shell only in CLI; in
Claude Desktop / Cowork the token is injected into MCP tool invocations and cannot drive a bash script.
CLI (execution_environment = "cli"): NOTION_TOKEN available in shell → Path 1 (bash script);
otherwise warn the user.
Claude Desktop / Cowork: mcp__notion-extension__notion-set-files-property tool available → Path 2
(Desktop Extension v1.2.0+); otherwise warn that the operation needs the notion-extension Desktop Extension
v1.2.0 or later.
Path 1: Bash Script (CLI, requires NOTION_TOKEN)
bash ${CLAUDE_SKILL_DIR}/scripts/attach-file.sh \
<page_id> <property_name> <mode> [--file <path>]... [--url <name> <url>]...
- mode
replace: set the property to exactly the provided files (zero files = clear) - mode
append: merge with existing entries --file <path>: local file, uploaded via the File Upload API (repeatable)--url <name> <url>: external file entry, stored as-is (repeatable;nameis required by Notion)
Prints {ok, page_id, property, files: [...]} on success.
Path 2: Desktop Extension (notion-set-files-property MCP tool, Claude Desktop / Cowork)
Call mcp__notion-extension__notion-set-files-property with:
page_id: the Notion page UUIDproperty_name: the files property name (e.g."Attachments")mode:"replace"or"append"files: array of{ file_path }(local upload) and/or{ name, url }(external) entries
Returns {ok, page_id, property_name, mode, files} where files is the post-update final state.
Caveats
- Upload requires the integration's "Insert content" capability (same as page-body image upload); a
403 restricted_resourcemeans it is missing. - Uploaded entries read back as
type:"file"with a signed URL that expires after ~1 hour; external entries return a stableexternal.url. Consumers needing a fresh URL re-fetch the task. - Local-file uploads are capped at 20MB (Notion single-part upload).
Page Body Images
Two operations on images in a task page body (blocks, not database properties): upload (append an image block — e.g. an agent attaching a screenshot to a task) and read (make images pasted in the body visible to the agent — e.g. a mockup a human pasted as task context).
Image Path Detection
The available path depends on execution_environment because NOTION_TOKEN is exposed to the shell only in CLI; in Claude Desktop / Cowork the token is injected directly into MCP tool invocations and cannot drive a bash script.
CLI (execution_environment = "cli"):
NOTION_TOKENenv var available in shell (check:[ -n "$NOTION_TOKEN" ] && echo "SET" || echo "NOT SET") → Path 1 (bash scripts)- Otherwise → warn the user (no fallback)
Claude Desktop / Cowork (execution_environment = "claude-desktop" or "cowork"):
mcp__notion-extension__notion-upload-image/mcp__notion-extension__notion-read-imagestools available → Path 2 (Desktop Extension)- Otherwise → warn the user (no fallback)
If no path is available, warn the user. The warning depends on environment:
- CLI: "Page-body image operations require
NOTION_TOKENto be available in your shell environment. Set it in~/.claude/settings.jsonenv block, or export it in your shell profile." - Claude Desktop / Cowork: "Page-body image operations require the
notion-extensionDesktop Extension v1.1.0 or later. Install or upgrade it via the plugin setup." (The tools were added in v1.1.0 — their absence on an otherwise-working install means an older extension version.)
Path 1: Bash Scripts (CLI, requires NOTION_TOKEN)
Upload — append an image to a page body from a local file or an external URL:
bash ${CLAUDE_SKILL_DIR}/scripts/upload-image.sh <page_id> </path/to/image.png> ["caption"]
bash ${CLAUDE_SKILL_DIR}/scripts/upload-image.sh <page_id> --url <https://example.com/image.png> ["caption"]
Prints {ok, page_id, block_id, image_type} on success.
Download — save all page-body images to local files:
bash ${CLAUDE_SKILL_DIR}/scripts/download-images.sh <page_id> [output_dir]
Prints a JSON manifest {images: [{block_id, path, mime_type, source_type, caption}]} (default output_dir: ${TMPDIR:-/tmp}/notion-images/<page_id>). View the images by reading the saved files with the Read tool.
Path 2: Desktop Extension (Claude Desktop / Cowork, extension v1.1.0+)
Upload — call mcp__notion-extension__notion-upload-image with:
page_id: the Notion page UUID- exactly one of
file_path(local image file, max 20MB) orexternal_url(public image URL) caption: optional caption text
Returns {ok, page_id, block_id, image_type}.
Read — call mcp__notion-extension__notion-read-images with:
page_id: the Notion page UUIDmax_images: optional cap on inline images (default 10)block_ids: optional filter to specific image blocksinclude_nested: recurse into toggles/columns/callouts (default true, depth 3)
The result is a text part with a JSON summary ({count, total_found, images, skipped}; images[i].index maps to the i-th image part) followed by the images as inline image content — they are directly visible, no file handling needed.
Caveats
- Upload requires the integration's "Insert content" capability. A
403 restricted_resourceon upload means the capability is missing — enable it at https://www.notion.so/profile/integrations (integration → Capabilities → Insert content), then retry. Read paths work without it. - Local-file uploads are capped at 20MB (Notion single-part upload); Notion free workspaces enforce a lower per-file cap and return their own error.
file-type image URLs returned by the Notion API are signed and expire after ~1 hour. Both paths download immediately, so callers never handle raw URLs; do not cache or re-share URLs from raw block fetches.- Both read paths skip images over 5MB (the model cannot view larger images): the extension tool reports them in
skipped, the bash script warns on stderr. The extension tool additionally skips non-raster types (svg, tiff, heic) and reports requestedblock_idsthat match no image — all inskippedwith a reason.
Delete Operation
Notion does not support hard delete via the API. To delete a task, archive the page:
notion-update-page page_id="<page_id>" archived=true
This removes the page from views but retains it in Notion's trash (recoverable for 30 days).
Schema: Notion Property -> Canonical Role
Core Fields (15 required — verify existence at session start)
| Property | Notion Type | Canonical Role | Notes |
|---|---|---|---|
| Title | title | task_title |
Task name |
| Description | rich_text | task_description |
Orchestrator-written detail |
| Acceptance Criteria | rich_text | task_acceptance_criteria |
Verifiable completion conditions |
| Status | select | task_status |
Backlog / Ready / In Progress / In Review / Done / Blocked / Cancelled |
| Blocked By | relation | task_blocked_by |
Self-relation (dependency). Empty or all blockers Done = actionable |
| Priority | select | task_priority |
Urgent / High / Medium / Low |
| Executor | select | task_executor |
cli / claude-desktop / cowork / human |
| Requires Review | checkbox | task_requires_review |
On -> must pass In Review. Off -> can go directly to Done |
| Execution Plan | rich_text | task_execution_plan |
Orchestrator's plan written before dispatch. write-once |
| Working Directory | rich_text | task_working_directory |
Absolute path to the working directory |
| Session Reference | rich_text | task_session_ref |
Written after dispatch: tmux session name / Scheduled task ID |
| Dispatched At | date | task_dispatched_at |
Dispatch timestamp. Used for timeout detection |
| Agent Output | rich_text | task_agent_output |
Execution result |
| Error Message | rich_text | task_error_message |
Written on failure only. Query with "Error Message is not empty" |
| Issuer | created_by | task_issuer |
Who created/initiated this task. Auto-populated by Notion on insert; read-only. Do NOT pass Issuer in notion-create-pages properties — Notion will reject the write. v2.8.1+ (was people in v2.7.x). |
Extended Fields (optional — graceful degradation if absent)
| Property | Notion Type | Canonical Role | Notes |
|---|---|---|---|
| Context | rich_text | task_context |
Background info, constraints |
| Artifacts | rich_text | task_artifacts |
PR URLs, file paths (newline-separated) |
| Repository | url | task_repository |
GitHub repository URL |
| Start Date | date | task_start_date |
ISO format |
| Due Date | date | task_due_date |
ISO format |
| Tags | multi_select | task_tags |
Free tags |
| Parent Task | relation | task_parent |
Self-relation (hierarchy) |
| Assignee | people | task_assignee |
Human executor assignment |
| Attachments | files | task_attachments |
Files attached as task data. Notion hosts uploads via the File Upload API (supportsFileHosting=true). Set with attach-file.sh (CLI) / notion-set-files-property (Desktop/Cowork) — notion-update-page cannot set it. v2.13.0+ |
| Branch | rich_text | task_branch |
Git branch name (e.g. feature/task-slug). Leave blank to work on the current branch |
| Source Message ID | rich_text | task_source_message_id |
Messaging tool message unique ID (e.g. Slack channel_id:ts). Used for cross-member dedup |
| Acknowledged At | date | task_acknowledged_at |
Auto-set when assignee sees the task. Reset on delegation. |
| Created At | created_time | task_created_at |
Auto-populated by Notion on page creation. Read-only. |
Auto-Repair DDL for Extended Fields
If Start Date is missing and needed, repair with:
ADD COLUMN "Start Date" DATE
If Source Message ID is missing and needed, repair with:
ADD COLUMN "Source Message ID" RICH_TEXT
If Acknowledged At is missing and needed, repair with:
ADD COLUMN "Acknowledged At" DATE
If Created At is missing, repair with:
ADD COLUMN "Created At" CREATED_TIME
If Attachments is missing and needed, repair with:
ADD COLUMN "Attachments" FILES
Being an Extended field, this is best-effort: if the DDL is rejected, instruct the user to add a
"Files & media" property named Attachments manually, and continue with graceful degradation (an absent
property reads back as no attachments). The FILES type maps to a Notion "Files & media" property.
Intake Log Database
The Intake Log DB tracks processed message IDs to avoid reprocessing. It is created automatically by the ingesting-messages skill on first run.
| Property | Notion Type | Required | Description |
|---|---|---|---|
| Message ID | title | Yes | Message unique ID (e.g. Slack: channel_id:ts) |
| Tool Name | select | Yes | Options: slack / teams / discord |
| Processed At | date | Yes | Processing timestamp (ISO 8601) |
The database ID is stored in the config page as intakeLogDatabaseId.
Querying Tasks
Use the first available query path. The detection depends on execution_environment because server-side filtering is delivered by different mechanisms in each environment — bash script (CLI) vs. Desktop Extension MCP tool (Claude Desktop / Cowork).
Query Path Detection
CLI (execution_environment = "cli"):
NOTION_TOKENenv var available in shell (check:[ -n "$NOTION_TOKEN" ] && echo "SET" || echo "NOT SET") → Path 1 (bash script)- Otherwise → halt the current step and surface the error to the user (no fallback). See "Error Handling for Query Path" below.
Claude Desktop / Cowork (execution_environment = "claude-desktop" or "cowork"):
In these environments NOTION_TOKEN is not exposed to the shell, so the bash script is not usable. Use the Desktop Extension MCP tool:
mcp__notion-extension__notion-querytool available → Path 2 (Desktop Extension)- Otherwise → halt the current step and surface the error to the user (no fallback). See "Error Handling for Query Path" below.
Path 1: Notion API Bash Script (CLI, requires NOTION_TOKEN)
Call the query script for server-side filtering:
bash ${CLAUDE_SKILL_DIR}/scripts/query-tasks.sh \
"<tasksDatabaseId>" '<filter_json>' '<sort_json>'
The script returns {"results": [...]} with full page objects including all properties.
Filter Recipes
Tasks assigned to a user:
{"property":"Assignee","people":{"contains":"<user_id>"}}
Ready tasks assigned to a user:
{"and":[{"property":"Status","select":{"equals":"Ready"}},{"property":"Assignee","people":{"contains":"<user_id>"}}]}
In Progress tasks (for concurrency check):
{"and":[{"property":"Status","select":{"equals":"In Progress"}},{"property":"Assignee","people":{"contains":"<user_id>"}}]}
Ready tasks by executor and assignee (single executor):
{"and":[{"property":"Status","select":{"equals":"Ready"}},{"property":"Executor","select":{"equals":"cowork"}},{"property":"Assignee","people":{"contains":"<user_id>"}}]}
Ready tasks by executor and assignee (multiple executors — for cli/claude-desktop environments):
{"and":[{"property":"Status","select":{"equals":"Ready"}},{"or":[{"property":"Executor","select":{"equals":"cli"}},{"property":"Executor","select":{"equals":"claude-desktop"}},{"property":"Executor","select":{"equals":"cowork"}}]},{"property":"Assignee","people":{"contains":"<user_id>"}}]}
Sort by Priority then Due Date:
[{"property":"Priority","direction":"ascending"},{"property":"Due Date","direction":"ascending"}]
Blocked tasks owned by user (via Assignee OR Issuer fallback):
{"and":[{"property":"Status","select":{"equals":"Blocked"}},{"or":[{"property":"Assignee","people":{"contains":"<user_id>"}},{"and":[{"property":"Issuer","created_by":{"contains":"<user_id>"}},{"property":"Assignee","people":{"is_empty":true}}]}]}]}
Ready human tasks owned by user (via Assignee OR Issuer fallback):
{"and":[{"property":"Status","select":{"equals":"Ready"}},{"property":"Executor","select":{"equals":"human"}},{"or":[{"property":"Assignee","people":{"contains":"<user_id>"}},{"and":[{"property":"Issuer","created_by":{"contains":"<user_id>"}},{"property":"Assignee","people":{"is_empty":true}}]}]}]}
v2.8.1 note: the Issuer filter syntax shifted from
"people":{...}to"created_by":{...}to match the new column type. The operator names (contains,is_empty) are the same.
Hierarchy Queries
Subtasks of a parent task:
{"property":"Parent Task","relation":{"contains":"<parent_task_id>"}}
Check if a task is a parent (has children): Query with the subtasks filter above. If results are non-empty, the task has children.
Check if a candidate parent is itself a subtask: Fetch the candidate parent with notion-fetch and check if its Parent Task relation is empty. If non-empty, it is already a subtask and cannot be used as a parent (2-level limit).
Path 2: Desktop Extension (notion-query MCP tool, Claude Desktop / Cowork)
Available when the mcp__notion-extension__notion-query tool is present. Uses the same filter recipes as Path 1 above.
Call mcp__notion-extension__notion-query with:
database_id: thetasksDatabaseIdfilter: filter JSONsorts: sort JSONpage_size(optional, 1-100): when set, the tool returns one Notion API page at a time and the response includeshas_moreandnext_cursorso the caller can iterate. When omitted, all pages are aggregated server-side — this risks overflowing the MCP host's token cap on databases with hundreds of rows.start_cursor(optional): pass the previous response'snext_cursorto fetch the next page. Only meaningful alongsidepage_size.filter_properties(optional, array of Notion property IDs): when set, only the named properties appear in each result'spropertiesobject. Reduces payload but does not strip Notion's page-level metadata.
Returns {"results": [...]} in the same Notion API format as Path 1; when page_size is set, the response also includes has_more (boolean) and next_cursor (string or null).
Pagination requires extension v0.4.0+. The tool name mcp__notion-extension__notion-query is unchanged from v0.3.x, so the tool's mere presence does not guarantee pagination support. v0.3.x silently ignores page_size and start_cursor and always returns the aggregated full result set — defeating the pagination strategy and risking the original token-cap overflow. How to detect at runtime: when calling with page_size, check whether the response contains a has_more field. If it does not, the installed extension is v0.3.x or earlier; halt the calling step and surface "Notion Desktop Extension is older than v0.4.0. Install the latest version to use paginated queries on this database." Users can also verify their installed version proactively via the health-checking skill, which probes for this.
When to paginate: any time the target database may grow past a few hundred records (Intake Log, Tasks DB, custom-source mirrors). The legacy "no page_size" mode is preserved for short queries with bounded result sets where one round-trip is simpler.
Error Handling for Query Path
- If the detected query path is unavailable, OR a structured query call returns a database-access error like
Could not find database with ID: <id>. Make sure the relevant pages and databases are shared with your integration <name>, do NOT fall back tonotion-search. - Halt the current step (not the whole skill). Surface the Notion API error verbatim. The caller (e.g.
running-daily-tasks) prompts the user[Continue to next step] [End]after surfacing the error.
Halt-message templates per environment:
- CLI,
NOTION_TOKENmissing: "Cannot run Notion database query: NOTION_TOKEN is not exposed to the shell. Set it in~/.claude/settings.jsonenv block, then re-run. Step halted." - Claude Desktop / Cowork,
notion-extensionMCP missing: "Cannot run Notion database query: thenotion-extensionDesktop Extension is not installed. Install it and re-run. Step halted." - Any environment, Notion API returned
Could not find database with ID …: surface the API error verbatim, then add: "The integration<integration name from error>does not have access to this database. In Notion, share the database with the integration. If you also useingesting-messages, share the Intake Log and Active Threads databases with the same integration. Then re-run. Step halted."
The notion-search fallback was removed in 2.5.6 because it cannot filter on people properties server-side and returned tasks owned by other assignees, while masking the real setup error.
Post-Processing (all paths)
- Blocked By resolved: Check that the
Blocked Byrelation array is empty OR fetch each referenced task's Status and confirm all are "Done". This cannot be filtered server-side. - Sort (if not done server-side): Priority — Urgent > High > Medium > Low; then by Due Date (earliest first).
Displaying Task Lists
When displaying queried tasks to the user in list or table format, reduce each result to display-relevant fields to prevent output truncation. Apply this jq shape to the results array returned by the chosen query path:
[.results[] | {
id: .id,
title: (.properties.Title.title[0].plain_text // ""),
status: (.properties.Status.select.name // ""),
priority: (.properties.Priority.select.name // ""),
executor: (.properties.Executor.select.name // ""),
assignee: ([.properties.Assignee.people[]?.name] | join(", ")),
start_date: (.properties["Start Date"].date.start // ""),
due_date: (.properties["Due Date"].date.start // ""),
blocked_by: (([.properties["Blocked By"].relation[]?.id] | length | tostring) + " deps")
}]
For single-task detail views (update, status change), use the full page object.
Fetch All Tasks
To retrieve all tasks (e.g. for view server data push), use the detected query path with no filter:
- Path 1 (CLI):
bash ${CLAUDE_SKILL_DIR}/scripts/query-tasks.sh "<tasksDatabaseId>"(no filter/sort args) - Path 2 (Claude Desktop / Cowork): call
mcp__notion-extension__notion-querywithdatabase_id: <tasksDatabaseId>and nofilter/sorts - If neither Path 1 nor Path 2 is available: halt per "Error Handling for Query Path" above.
No post-processing needed (no Blocked By filter, no sort required).
Querying Any Notion Database
When querying ANY Notion database (not just the Tasks DB — e.g., Intake Log, external databases), use the same per-environment detection as the Tasks DB query:
CLI:
NOTION_TOKENenv var available → call the bash script:bash ${CLAUDE_SKILL_DIR}/scripts/query-tasks.sh "<database_id>" '<filter_json>' '<sort_json>'- Otherwise → halt and surface the error per "Error Handling for Query Path" above. Do not fall back to notion-search.
Claude Desktop / Cowork:
mcp__notion-extension__notion-queryavailable → call the MCP tool with the target database ID and filter. For databases that may grow past ~200 rows (Intake Log, Tasks DB, custom-source mirrors), passpage_size(1-100) and iterate using the response'shas_more/next_cursor. See Path 2 above for full parameter docs.- Otherwise → halt and surface the error per "Error Handling for Query Path" above. Do not fall back to notion-search.
Task Record Reference
When referring to a task in dispatch prompts and completion instructions, use:
- Task ID: the Notion page ID (from the
idfield when the task was created) - Update instruction: "Use
notion-update-pagewith page ID<Page ID>to write results to Agent Output and update Status."
In the Claude Desktop environment, the dispatch prompt is set as the Scheduled Task's prompt. Notion MCP tools (notion-update-page) are available in both environments.
On Completion Template
The following template is injected into dispatch prompts by executing-tasks. Placeholders are resolved at dispatch time.
Notion page ID for this task: <task_id>
On completion, perform the following:
1. Use notion-update-page with page ID <task_id> to write execution results to the "Agent Output" field
2. Update Status:
- If Requires Review = ON: "In Review"
- If Requires Review = OFF: "Done"
3. On error: write error details to "Error Message" and update Status to "Blocked"
4. If the Notion update fails, ignore the error and complete execution
Pushing Data to View Server
After any task operation (create, update, delete), push fresh data to the local view server:
- Use Fetch All Tasks (above) to retrieve all tasks from the tasks database
- Format the response as a
TasksResponseJSON object:{ "tasks": [...], "updatedAt": "<ISO timestamp>" } - POST to
http://localhost:3456/api/datawithContent-Type: application/json
# Silently skip if server is not running
curl -s http://localhost:3456/api/health -o /dev/null 2>/dev/null && \
curl -s -X POST http://localhost:3456/api/data \
-H "Content-Type: application/json" -d '<json>' -o /dev/null 2>/dev/null || true
View Server Field Mapping
| Notion Property | TasksResponse Field |
|---|---|
id (page ID) |
id |
| Title | title |
| Description | description |
| Acceptance Criteria | acceptanceCriteria |
| Status | status |
| Blocked By | blockedBy (array of page IDs) |
| Priority | priority |
| Executor | executor |
| Requires Review | requiresReview |
| Execution Plan | executionPlan |
| Working Directory | workingDirectory |
| Session Reference | sessionReference |
| Dispatched At | dispatchedAt |
| Agent Output | agentOutput |
| Error Message | errorMessage |
| Context | context |
| Artifacts | artifacts |
| Repository | repository |
| Start Date | startDate |
| Due Date | dueDate |
| Tags | tags |
| Parent Task | parentTaskId |
| Assignee | assignee |
| Attachments | attachments (normalize each files[] entry to {url, name}: url from file.url/external.url, name from the entry name. Provider-hosted URLs may already be expired in a pushed snapshot.) |
| Issuer | issuer |
| Acknowledged At | acknowledgedAt |
| Created At | createdAt |
url (page URL) |
url |
| Sprint (relation) | sprintId / sprintName |
| (not in Notion) | complexityScore, backlogOrder |
Identity: Resolve Current User
Called by resolving-identity shared skill when active_provider = notion.
- Call
notion-get-userswithuser_id: "self". - Map the response:
id<-response.idname<-response.nameemail<-response.person.email(null if Bot user)
- Save to session variable
current_user: { id, name, email }. - Fallback: If
notion-get-usersis unavailable or fails:id<-"unknown"name<-$USERenvironment variable or "local"email<- null
Identity: Resolve Team Membership
Called by resolving-identity shared skill when teamsDatabaseId is present in config.
- Call
notion-fetchonteamsDatabaseIdto retrieve all team pages. - For each team, inspect the
Memberspeople field. Check ifcurrent_user.idis present in the array. - Set
current_user.teamsto the list of matching teams:[{ id, name, members: [{ id, name }] }]. - Determine
current_team:- 1 matching team -> automatically set
current_teamto that team. - 2+ matching teams -> use AskUserQuestion: "You belong to multiple teams: [list]. Which team are you working with now?"
- 0 matching teams -> set
current_team: null.
- 1 matching team -> automatically set
- If
current_teamis set, populatecurrent_team.memberswith all members from that team'sMembersfield (array of{ id, name }). This is used by downstream skills for team-scoped filtering.
Identity: List Org Members
Called by resolving-identity shared skill when org_members lookup is needed.
- Call
notion-get-userswith no arguments to list all workspace members. - Map each user to
OrgMember { id, name, email }:id<-user.idname<-user.nameemail<-user.person.email(null for Bot users)
- Save to session variable
org_members: OrgMember[]. - Fallback: If
notion-get-usersis unavailable, setorg_members: []and return. Thelooking-up-membersskill will then fall back to TeamsDB Members field.
Identity: Self-Task Detection
To determine whether a task is assigned to the current user:
- Fetch the task's
Assigneeproperty (people type — returns an array of person objects). - Check if any element in the array has
id === current_user.id. - Use this check when filtering tasks in
managing-tasksandexecuting-tasks.
Error Handling
| Error Category | HTTP Code | Action |
|---|---|---|
| Rate limit | 429 | Retryable — wait for Retry-After header seconds, then retry |
| Database access denied | 404, body contains "Could not find database with ID" |
Terminal — the integration does not have access to the database. Surface the error verbatim, name the missing integration, and instruct the user to share the database in Notion. Halt the current step. Exception: if this 404 fires during Schema Validation immediately after Step 1 returned a cached tasksDatabaseId, follow the "Recovery from stale cache" path in Config Retrieval instead — the failure is non-terminal in that specific context. |
| Page not found | 404 (body does not match the database-access pattern above) | Terminal — the page was deleted or the integration lost access. Report to user |
| Server error | 500 | Retryable — exponential backoff (1s, 2s, 4s), max 3 attempts |
| MCP tool unavailable | N/A | Terminal — the Notion MCP server is not configured. Instruct user to check MCP settings |