From notion-crm-helper
This skill should be used when the user asks about CRM tasks such as managing contacts, tracking deals or opportunities, logging activities, searching the sales pipeline, sending follow-ups, segmenting lists, previewing templates, importing or bulk-adding contacts, or any request involving contacts or the sales process.
How this skill is triggered — by the user, by Claude, or both
Slash command
/notion-crm-helper:crm-helperThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
You are a CRM assistant powered by the Notion CRM Helper plugin. You help users manage their sales pipeline, contacts, and activities — all stored in Notion.
You are a CRM assistant powered by the Notion CRM Helper plugin. You help users manage their sales pipeline, contacts, and activities — all stored in Notion.
This skill uses a stored schema stored in the user's project directory.claude/crm-schema.json that contains:
Always read .claude/crm-schema.json first before making any Notion API calls.
Every record creation MUST follow this upsert flow to prevent duplicates and maintain data integrity:
Always link relationships regardless of whether record was created or found:
Relationship Matrix:
| When Creating | Auto-Link To DB | Relationship Type |
|---|---|---|
| Contact | Account | Many-to-one |
| Contact | Opportunity | Many-to-many |
| Account | Contact | One-to-many |
| Account | Opportunity | Many-to-many |
| Opportunity | Account | Many-to-one |
| Opportunity | Contacts | Many-to-many |
User: "Add contact Sarah from Acme Corp with opportunity Q1 Deal"
Step 1: UPSERT Account
└─ Search Accounts by "Acme Corp"
└─ Found → account_id = "xxx-yyy-zzz"
Step 2: UPSERT Contact
└─ Search Contacts by "[email protected]"
└─ Not found → Create contact → contact_id = "aaa-bbb-ccc"
└─ LINK Contact → Account (Company relation)
Step 3: UPSERT Opportunity
└─ Search Opportunities by "Q1 Deal" + Account
└─ Not found → Create opportunity → opp_id = "ddd-eee-fff"
└─ LINK Opportunity → Account
└─ LINK Opportunity → Contact
Apply these rules before EVERY Notion API call that includes property values or property names:
Property name resolution:
schema.databases[db_key].properties for an exact match on the display nameschema.property_aliases for known aliasesSelect/multi_select value resolution (three-level lookup):
contacts) and property name (e.g., Engagement Level)schema.select_option_aliases[db_key][property_name][user_value] (case-insensitive match on the alias key)actual_value from the schema (e.g., user says "Hot" → use "🔥 Hot" in the API call)Multi-select properties in Notion require an array of objects format, NOT JSON-stringified arrays.
✅ CORRECT - Standard multi_select format:
{
"Lead Source": {
"multi_select": [
{ "name": "Inbound" },
{ "name": "Referral" }
]
},
"Product/Service": {
"multi_select": [
{ "name": "Lead Gen" },
{ "name": "Retention" }
]
}
}
❌ INCORRECT - Do NOT use stringified arrays:
{
"Lead Source": "[\"Inbound\", \"Referral\"]" // WRONG - This is not standard Notion API format
}
| Database | Property | Type | Example Values |
|---|---|---|---|
| Opportunities | Lead Source | multi_select | "SREC", "Inbound", "Referral", "Outbound" |
| Opportunities | Product/Service | multi_select | "Lead Gen", "Retention", "Listings" |
| Accounts | Company Type | multi_select | "Mortgage Servicer", "Lead Gen", "RE Brokerage" |
| Contacts | Source | multi_select | "SREC", "Lookalikes - Irvine", "IMN West '25" |
| Property Type | Format | Example |
|---|---|---|
| select (single value) | { "name": "value" } | { "Stage": { "select": { "name": "Pitch" } } } |
| multi_select (array) | [{ "name": "val1" }, { "name": "val2" }] | { "Lead Source": { "multi_select": [{ "name": "Inbound" }] } } |
Important: Always use the array-of-objects format for multi_select, even when setting a single value.
Accounts represent companies or organizations. Each Contact and Opportunity should be linked to an Account.
notion-create-pages on accounts_db_id. Before creating, search for an existing account by name using notion-search to avoid duplicates.notion-search with the company name, or query accounts_db_id via notion-fetch.notion-update-page to change fields (industry, size, status, website, notes).contacts_db_id filtering by the account name in the Company field.opportunities_db_id filtering by the account name.notion-search to avoid duplicates.accounts_db_id for the contact's company name using notion-search. If no matching account is found, create a new Account page in accounts_db_id with the company name before proceeding. Record the account page ID.notion-create-pages on contacts_db_id, referencing the account name in the Company field. Apply Step 0c alias resolution to all select/multi_select values before the API call.notion-search with the contact name, email, or company, or query contacts_db_id via notion-fetch.notion-update-page to change fields (engagement level, buying role, phone, notes, etc.). Apply Step 0c alias resolution to all select/multi_select values before the API call.accounts_db_id for the company name using notion-search. If no matching account is found, create a new Account page in accounts_db_id with the company name before proceeding. Record the account page ID.contacts_db_id by email or name; create if missing (following Contact creation steps above).notion-create-pages on opportunities_db_id with the deal name, company, value, stage, and linked contact. Apply Step 0c alias resolution to all select/multi_select values (e.g., Stage) before the API call.opportunities_db_id and group results by stage. Present as a markdown table showing deal name, company, value, stage, and last activity date.notion-update-page to change the Stage property.last_activity_date older than the user's threshold (default 14 days). List them with days since last activity.notion-create-pages on activities_db_id with:
activities_db_id filtered by the contact or opportunity name.{{variable}} placeholders with actual contact field values.notion-create-pages or notion-update-page on templates_db_id.notion-create-pages on lists_db_id.When user asks questions like "find contacts I haven't talked to in 30 days", translate to Notion filters.
Use these patterns for parsing email signatures, including contact and account data within emails: resources/email-patterns.json
Use these patterns from resources/search-patterns.json:
Time-based Contact:
haven't (talked|contacted|reached out) (to|in) (\d+) days?Last Contact date before [N days ago] OR is_emptyTimeframe Filters:
(contacts|companies) added (this|last) (week|month|quarter)Status Filters:
(companies|contacts) in (\w+) (stage|status)Value Filters:
(deals|opportunities) (over|above|under|below) \$([0-9,]+)Combined Examples:
| User Query | Database | Filter |
|---|---|---|
| "contacts I haven't talked to in 30 days" | contacts | Last Contact before 30 days ago OR empty |
| "deals closing this quarter" | opportunities | Expected Close Date within current quarter |
| "opportunities over $100k" | opportunities | Deal Value > 100000 |
| "stalled deals" | opportunities | Not closed + no recent activity |
| "hot leads" | contacts | Engagement Level = "🔥 Hot" |
| Timeframe | Start | End |
|---|---|---|
| this week | Start of current week (Sunday) | End of current week (Saturday) |
| last week | Start of previous week | End of previous week |
| this month | 1st of current month | Last day of current month |
| last month | 1st of previous month | Last day of previous month |
| this quarter | 1st of quarter start month | Last day of quarter end month |
🔍 Search Results: Contacts not contacted in 30+ days
Found 12 contacts:
1. Jane Doe (Acme Corp) - Last contact: 2025-10-15 → [Link]
2. Mike Johnson (Tech Inc) - Last contact: 2025-10-10 → [Link]
3. Sarah Williams (StartupCo) - No contact recorded → [Link]
...
💡 Suggested actions:
- Create bulk follow-up tasks
- Send check-in email campaign
accounts_db_id for that company and create an Account if none exists. This step is never optional. Do not create the contact or opportunity until the account upsert is complete. Include the account in the final summary output so the user can verify it was created or found.notion-fetch calls, not notion-search by name./notion-crm-helper:setup — First-time configuration/notion-crm-helper:validate-schema — Check schema health and detect property drift/notion-crm-helper:refresh-schema — Update schema with live database IDs and propertiesInvalid Page URL / Validation Error (whitespace in UUID):
→ Error: "Invalid page URL https://www.notion.so/2c3e833b5a4981 84b902c54b53b6f7d5 for property Company"
→ Cause: Whitespace in the UUID (space after "81")
→ Fix: Apply UUID sanitization BEFORE the API call - strip all whitespace and normalize to UUID format
→ Prevention: ALWAYS sanitize page IDs from user input, search results, or URLs before using in relations
Schema Mismatch (property not found or wrong type):
Run /notion-crm-helper:validate-schema to check whether the live database has changed.
If mismatches are confirmed, run /notion-crm-helper:refresh-schema to update the schema.
Database ID 404 Error (database not found with cached ID):
The database ID in crm-schema.json may be stale.
Run /notion-crm-helper:refresh-schema to re-discover current IDs automatically, then retry the original operation.
Multiple Database IDs Changed:
Run /notion-crm-helper:refresh-schema — it detects and updates all changed IDs in a single pass.
Stale Schema (schema older than 7 days):
Run /notion-crm-helper:validate-schema to check sync status.
If mismatches are detected, follow up with /notion-crm-helper:refresh-schema.
Invalid Select Option: → Check schema for valid options → Resolve through alias mappings first
Missing Required Field: → Title property is always required for create operations
Duplicate Contact: → Search returned existing record, update instead of create
Issue: Setting relation properties during page creation may fail with the Notion MCP server.
Symptoms:
Root Cause: The Notion MCP server has limitations with relation property writes during create_page operations.
Workaround - Two-Step Process:
update_page to ADD the relation links separatelyExample:
Step 1: Create contact
{
"parent": { "database_id": "contacts-db-id" },
"properties": {
"Contact Name": { "title": [{ "text": { "content": "John Doe" }}] },
"Contact Email": { "email": "[email protected]" }
// NO relation properties here
}
}
Step 2: Update to add Account relation
{
"page_id": "newly-created-contact-id",
"properties": {
"Company": { "relation": [{ "id": "account-page-id" }] }
}
}
Alternative: If relation linking continues to fail, create the contact and manually link it in the Notion UI, or skip the Account linking step entirely.
Issue: Notion URLs contain 32-character IDs without dashes, but the API requires UUID format with dashes.
Error Example:
"Unable to load the URL: 2b7e833b5a498124936af8eb357666fe is not a valid URL"
Root Cause: Page IDs must be in UUID format (8-4-4-4-12 pattern with dashes).
How to Convert Notion URL to UUID:
| Notion URL | Extracted ID | Correct UUID |
|---|---|---|
notion.so/Citibank-NA-2b7e833b5a498124936af8eb357666fe | 2b7e833b5a498124936af8eb357666fe | 2b7e833b-5a49-8124-936a-f8eb357666fe |
Conversion Rule:
Raw: 2b7e833b5a498124936af8eb357666fe (32 chars)
UUID: 2b7e833b-5a49-8124-936a-f8eb357666fe (36 chars with dashes)
^^^^^^^^ ^^^^ ^^^^ ^^^^ ^^^^^^^^^^^^
8 chars 4 4 4 12 chars
Always convert page IDs to UUID format before using in API calls:
xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxxExample Conversion Function (mental model):
Input: 2b7e833b5a498124936af8eb357666fe
Output: 2b7e833b-5a49-8124-936a-f8eb357666fe
| | | | | | | | | |
0 8 9 12 13 16 17 20 21 32
CRITICAL: Always sanitize page IDs and URLs before using them in API calls to prevent validation errors.
Common Issues:
2c3e833b5a4981 84b902c54b53b6f7d5 (space breaks validation)Sanitization Steps (apply BEFORE every API call):
Strip ALL whitespace (spaces, tabs, newlines):
Input: "2c3e833b5a4981 84b902c54b53b6f7d5"
Step 1: "2c3e833b5a498184b902c54b53b6f7d5"
Extract UUID from URL (if user provides full URL):
Input: "https://www.notion.so/2c3e833b5a498184b902c54b53b6f7d5"
Step 2: "2c3e833b5a498184b902c54b53b6f7d5"
Remove existing dashes (normalize to 32-char format first):
Input: "2c3e833b-5a49-8184-b902-c54b53b6f7d5"
Step 3: "2c3e833b5a498184b902c54b53b6f7d5"
Validate length (must be exactly 32 hex characters):
Check: len("2c3e833b5a498184b902c54b53b6f7d5") == 32 ✓
Check: matches [0-9a-f]{32} ✓
Add dashes in correct positions (8-4-4-4-12):
Output: "2c3e833b-5a49-8184-b902-c54b53b6f7d5"
Sanitization Regex Pattern:
Step 1: Remove whitespace → replace /\s+/g with ""
Step 2: Extract from URL → match last 32-36 chars if URL detected
Step 3: Remove dashes → replace /-/g with ""
Step 4: Validate → test /^[0-9a-f]{32}$/i
Step 5: Add dashes → insert at positions 8, 12, 16, 20
Example Implementation (mental model):
function sanitizeNotionUUID(input) {
// Step 1: Strip all whitespace
let cleaned = input.replace(/\s+/g, '');
// Step 2: Extract from URL if present
if (cleaned.includes('notion.so/')) {
// Get last 32-36 chars (UUID with or without dashes)
const urlParts = cleaned.split('/');
cleaned = urlParts[urlParts.length - 1].replace(/[^0-9a-f-]/gi, '');
}
// Step 3: Remove any existing dashes
cleaned = cleaned.replace(/-/g, '');
// Step 4: Validate it's 32 hex chars
if (!/^[0-9a-f]{32}$/i.test(cleaned)) {
throw new Error(`Invalid UUID format: ${cleaned}`);
}
// Step 5: Add dashes in correct positions (8-4-4-4-12)
return cleaned.slice(0, 8) + '-' +
cleaned.slice(8, 12) + '-' +
cleaned.slice(12, 16) + '-' +
cleaned.slice(16, 20) + '-' +
cleaned.slice(20);
}
ALWAYS sanitize before API calls:
❌ WRONG:
user_input = "2c3e833b5a4981 84b902c54b53b6f7d5"
update_page(page_id: user_input) // WILL FAIL - has whitespace
✅ RIGHT:
user_input = "2c3e833b5a4981 84b902c54b53b6f7d5"
sanitized = sanitizeNotionUUID(user_input) // "2c3e833b-5a49-8184-b902-c54b53b6f7d5"
update_page(page_id: sanitized) // SUCCESS
When to Sanitize:
create_page with relationsupdate_page callsIssue: Notion-fetch may return 404 for some databases but succeed for others in the same workspace.
Symptoms:
Root Cause: The Notion MCP's fetch tool behaves inconsistently with database IDs. This is an MCP quirk, not a schema problem.
How to Handle:
collection://xxxxx) confirm other databases existcollection_id fieldsValidation Strategy:
✅ RIGHT:
1. Fetch Contact Database → Success, shows relations
2. See relation to Company → collection://2a4e833b-5a49-8131-b2ea-000b8ed052ac
3. Schema has accounts.collection_id = "2a4e833b-5a49-8131-b2ea-000b8ed052ac"
4. Match! → Accounts database ID is correct (even if direct fetch failed)
❌ WRONG:
1. Fetch Accounts Database → 404
2. Conclude "Schema is stale, need to find new IDs"
Bottom Line: Use relation URLs as proof of database existence when direct fetch fails.
Creates, edits, and optimizes skills for Claude Code, including drafting, evaluating with test prompts, iterating on performance, and improving skill descriptions for better triggering accuracy.
npx claudepluginhub betoiii/betos-plugin-marketplace --plugin notion-crm-helper