Skill: jaybna/dex-crm (version 1.0.0)

--- name: dex-crm description: | Manage Dex personal CRM (getdex.com) contacts, notes, and reminders. Use when you need to: (1) Search or browse contacts, (2) Add notes about people, (3) Create or check reminders, (4) Look up contact details (phone, email, birthday). Requires DEX_API_KEY environment variable. --- # Dex Personal CRM Manage your Dex CRM directly from Clawdbot. Search contacts, add notes, manage reminders. ## Authentication Set `DEX_API_KEY` in gateway config env vars. ## API Base - **Base URL:** `https://api.getdex.com/api/rest` - **Headers:** `Content-Type: application/json` and `x-hasura-dex-[REDACTED_SECRET]` ## Quick Reference ### Contacts ```bash # List contacts (paginated) curl -s -H "Content-Type: application/json" \ -H "x-hasura-dex-[REDACTED_SECRET]" \ "https://api.getdex.com/api/rest/contacts?limit=10&offset=0" # Get contact by ID curl -s -H "Content-Type: application/json" \ -H "x-hasura-dex-[REDACTED_SECRET]" \ "https://api.getdex.com/api/rest/contacts/{contactId}" # Search contact by email curl -s -H "Content-Type: application/json" \ -H "x-hasura-dex-[REDACTED_SECRET]" \ "https://api.getdex.com/api/rest/search/contacts?email=[REDACTED_SECRET]" # Create contact curl -s -X POST -H "Content-Type: application/json" \ -H "x-hasura-dex-[REDACTED_SECRET]" \ -d '{"first_name":"John","last_name":"Doe","emails":["[REDACTED_SECRET]"]}' \ "https://api.getdex.com/api/rest/contacts" # Update contact curl -s -X PUT -H "Content-Type: application/json" \ -H "x-hasura-dex-[REDACTED_SECRET]" \ -d '{"job_title":"CEO"}' \ "https://api.getdex.com/api/rest/contacts/{contactId}" # Delete contact curl -s -X DELETE -H "Content-Type: application/json" \ -H "x-hasura-dex-[REDACTED_SECRET]" \ "https://api.getdex.com/api/rest/contacts/{contactId}" ``` ### Notes (Timeline Items) ```bash # List notes curl -s -H "Content-Type: application/json" \ -H "x-hasura-dex-[REDACTED_SECRET]" \ "https://api.getdex.com/api/rest/timeline_items?limit=10&offset=0" # Notes for a contact curl -s -H "Content-Type: application/json" \ -H "x-hasura-dex-[REDACTED_SECRET]" \ "https://api.getdex.com/api/rest/timeline_items/contacts/{contactId}" # Create note curl -s -X POST -H "Content-Type: application/json" \ -H "x-hasura-dex-[REDACTED_SECRET]" \ -d '{"note":"Met for coffee, discussed project","contact_ids":["contact-uuid"],"event_time":"2026-01-27T12:00:00Z"}' \ "https://api.getdex.com/api/rest/timeline_items" # Update note curl -s -X PUT -H "Content-Type: application/json" \ -H "x-hasura-dex-[REDACTED_SECRET]" \ -d '{"note":"Updated note text"}' \ "https://api.getdex.com/api/rest/timeline_items/{noteId}" # Delete note curl -s -X DELETE -H "Content-Type: application/json" \ -H "x-hasura-dex-[REDACTED_SECRET]" \ "https://api.getdex.com/api/rest/timeline_items/{noteId}" ``` ### Reminders ```bash # List reminders curl -s -H "Content-Type: application/json" \ -H "x-hasura-dex-[REDACTED_SECRET]" \ "https://api.getdex.com/api/rest/reminders?limit=10&offset=0" # Create reminder curl -s -X POST -H "Content-Type: application/json" \ -H "x-hasura-dex-[REDACTED_SECRET]" \ -d '{"body":"Follow up on proposal","due_at_date":"2026-02-01","contact_ids":["contact-uuid"]}' \ "https://api.getdex.com/api/rest/reminders" # Update reminder curl -s -X PUT -H "Content-Type: application/json" \ -H "x-hasura-dex-[REDACTED_SECRET]" \ -d '{"is_complete":true}' \ "https://api.getdex.com/api/rest/reminders/{reminderId}" # Delete reminder curl -s -X DELETE -H "Content-Type: application/json" \ -H "x-hasura-dex-[REDACTED_SECRET]" \ "https://api.getdex.com/api/rest/reminders/{reminderId}" ``` ## Contact Fields - `first_name`, `last_name`, `job_title`, `description` - `emails` (array of `{email}`) - `phones` (array of `{phone_number}`) - `education`, `website`, `linkedin`, `facebook`, `twitter`, `instagram`, `telegram` - `birthday`, `image_url` - `last_seen_at`, `next_reminder_at` - `is_archived`, `created_at`, `updated_at` ## Searching Contacts The API only supports search by email. For name-based search, fetch contacts in batches and filter locally. Use a reasonable limit (50-100) for browsing. ## Notes - Always confirm before creating, updating, or deleting contacts/notes/reminders - Contact search by name requires local filtering (API only supports email search) - Use pagination (limit/offset) for large result sets - The `event_time` field on notes is when the interaction happened, not when the note was created

path: README.md
content: # Dex CRM Skill for Clawdbot

Manage your [Dex](https://getdex.com) personal CRM directly from Clawdbot. Search contacts, add notes, manage reminders.

## Installation

### Via ClawdHub (recommended)

```bash
clawdhub install dex-crm
```

### Manual Installation

Copy the `SKILL.md` file to your Clawdbot skills directory:

```bash
cp SKILL.md ~/.clawdbot/skills/dex-crm/SKILL.md
# or for workspace installs:
cp SKILL.md ~/clawd/skills/dex-crm/SKILL.md
```

## Setup

1. Get your Dex API key from [Dex Settings](https://getdex.com/settings/api)

2. Add it to your Clawdbot gateway config (`config.yaml`):

```yaml
env:
  DEX_API_KEY: "your-api-key-here"
```

3. Restart the gateway:

```bash
clawdbot gateway restart
```

## Features

- **Search contacts** by email or browse all contacts
- **View contact details** — phone, email, birthday, notes
- **Add notes** to contacts with timestamps
- **Create/complete reminders** for follow-ups
- **Create new contacts** from conversations
- **Archive contacts** you no longer need

## Example Usage

> "Find John Smith in my Dex contacts"

> "Add a note to Sarah's contact: Met for coffee, discussed the project proposal"

> "Create a reminder to follow up with Mike next Tuesday"

> "Show me all my reminders due this week"

## Included Scripts

### `scripts/dex-cleanup.py`

Automatically archives junk/newsletter contacts from Dex. Useful for cleaning up contacts imported from email that aren't real people.

```bash
# Set your API key
export DEX_API_KEY="your-key"

# Dry run (preview what would be archived)
python3 scripts/dex-cleanup.py --dry-run

# Actually archive junk contacts
python3 scripts/dex-cleanup.py
```

The script identifies junk contacts by patterns like:
- Substack newsletters
- Noreply addresses
- Calendar system entries
- Common newsletter domains

## API Reference

See `SKILL.md` for the complete API reference including all endpoints for contacts, notes, and reminders.

## License

MIT

## Author

Jay Graves ([@jaybna](https://github.com/jaybna))


path: scripts/dex-cleanup.py
content: #!/usr/bin/env python3
"""
Dex CRM Newsletter/Junk Contact Cleanup
Archives contacts that look like newsletters, automated senders, or non-human contacts.
"""

import json, urllib.request, os, sys, re, time

[REDACTED_SECRET]'DEX_API_KEY')
if not [REDACTED_SECRET]"ERROR: DEX_API_KEY not set")
    sys.exit(1)

BASE = "https://api.getdex.com/api/rest"
HEADERS = {
    "Content-Type": "application/json",
    "x-hasura-dex-api-key": API_KEY
}

# --- PATTERNS TO MATCH JUNK CONTACTS ---

# Email prefixes that indicate automated/newsletter senders
# Only high-confidence prefixes — avoid false positives on real businesses
JUNK_PREFIXES = [
    'noreply', 'no-reply', 'no_reply', 'donotreply', 'do-not-reply',
    'newsletter', 'newsletters',
    'mailer', 'mailer-daemon', 'postmaster', 'bounce',
    'unsubscribe',
    'automated',
    'noreply-',
]

# Domains known to be bulk email/newsletter platforms (not companies with real employees)
JUNK_DOMAINS = [
    'substack.com', 'substacknote.com',
    'mailchimp.com', 'mail.mailchimp.com', 'mcusercontent.com',
    'mailchimpapp.net',
    'sendgrid.net',
    'mailgun.org',
    'constantcontact.com',
    'klaviyomail.com',
    'mailerlite.com', 'convertkit.com', 'beehiiv.com',
    'amazonses.com',
    'createsend.com', 'cmail19.com', 'cmail20.com',
    'intercom-mail.com',
    'postmarkapp.com',
    'sparkpostmail.com',
    'mandrillapp.com',
    'sendinblue.com', 'brevo.com',
    'e.squarespace.com',
    'shopifyemail.com',
    'facebookmail.com',
    'calendar.google.com',
    'resource.calendar.google.com',
]

# Name patterns that indicate non-human contacts
JUNK_NAME_PATTERNS = [
    r'^(the\s)?\w+\s(newsletter|digest|update|weekly|daily|report)s?$',
    r'^(noreply|no-reply|info|hello|team|support|admin|billing)$',
    r'unsubscribe',
    r'^[\w\s]*(notifications?)$',
]

DRY_RUN = '--dry-run' in sys.argv
VERBOSE = '--verbose' in sys.argv or '-v' in sys.argv

def api_get(path):
    req = urllib.request.Request(f"{BASE}{path}", headers=HEADERS)
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())

def api_put(path, data):
    body = json.dumps(data).encode('utf-8')
    req = urllib.request.Request(f"{BASE}{path}", data=body, headers=HEADERS, method="PUT")
    with urllib.request.urlopen(req) as resp:
        return json.loads(resp.read())

def is_junk_email(email):
    email = email.lower().strip()
    local, _, domain = email.partition('@')
    
    # Check domain
    for jd in JUNK_DOMAINS:
        if domain == jd or domain.endswith('.' + jd):
            return f"junk domain: {domain}"
    
    # Check prefix
    # Strip numbers/dots from local part for matching
    local_clean = re.sub(r'[\d._+-]+$', '', local)
    for prefix in JUNK_PREFIXES:
        if local_clean == prefix or local.startswith(prefix + '@') or local.startswith(prefix + '+'):
            return f"junk prefix: {local}"
    
    # Check for UUID/hash-like local parts (automated systems)
    if re.match(r'^[0-9a-f]{8,}[-_]?', local) and len(local) > 20:
        return f"hash-like address: {local}"
    
    return None

def is_junk_name(first_name, last_name):
    full = f"{first_name} {last_name}".strip().lower()
    if not full:
        return "empty name"
    
    for pattern in JUNK_NAME_PATTERNS:
        if re.match(pattern, full, re.IGNORECASE):
            return f"junk name pattern: {full}"
    
    return None

def main():
    print(f"{'DRY RUN - ' if DRY_RUN else ''}Dex Newsletter/Junk Contact Cleanup")
    print("=" * 50)
    
    # Fetch all contacts
    all_contacts = []
    offset = 0
    while True:
        data = api_get(f"/contacts?limit=100&offset={offset}")
        contacts = data.get('contacts', [])
        if not contacts:
            break
        all_contacts.extend(contacts)
        offset += 100
        if len(contacts) < 100:
            break
    
    print(f"Total contacts: {len(all_contacts)}")
    
    # Filter out already archived
    active = [c for c in all_contacts if not c.get('is_archived', False)]
    print(f"Active (non-archived): {len(active)}")
    
    # Find junk contacts
    junk = []
    for c in active:
        emails = [e['email'] for e in c.get('emails', [])]
        first = c.get('first_name', '') or ''
        last = c.get('last_name', '') or ''
        
        reason = None
        
        # Check emails — only flag if ALL emails are junk
        # (contacts with both a junk and real email are real people)
        if emails:
            junk_reasons = []
            for email in emails:
                r = is_junk_email(email)
                if r:
                    junk_reasons.append(r)
            if junk_reasons and len(junk_reasons) == len(emails):
                reason = junk_reasons[0]
        
        # Skip empty-name check — too many false positives
        # (real people imported without names from calendar/email)
        
        if reason:
            junk.append({
                'id': c['id'],
                'name': f"{first} {last}".strip(),
                'emails': emails,
                'reason': reason
            })
    
    print(f"Junk contacts found: {len(junk)}")
    print()
    
    if VERBOSE or DRY_RUN:
        for j in sorted(junk, key=lambda x: x['reason']):
            print(f"  [{j['reason']}] {j['name']} | {', '.join(j['emails'])}")
        print()
    
    if DRY_RUN:
        print(f"DRY RUN complete. Would archive {len(junk)} contacts.")
        return
    
    # Archive junk contacts
    archived = 0
    errors = 0
    for i, j in enumerate(junk):
        try:
            api_put(f"/contacts/{j['id']}", {
                "changes": {"is_archived": True}
            })
            archived += 1
        except Exception as e:
            errors += 1
            if VERBOSE:
                print(f"  ERROR archiving {j['name']}: {e}")
        
        if (i + 1) % 50 == 0:
            print(f"  Progress: {i+1}/{len(junk)} archived")
        
        time.sleep(0.1)
    
    print(f"\nDone! Archived: {archived}, Errors: {errors}")
    
    # Output summary as JSON for cron consumption
    summary = {
        "total_contacts": len(all_contacts),
        "active_contacts": len(active),
        "junk_found": len(junk),
        "archived": archived,
        "errors": errors
    }
    print(f"\nSummary: {json.dumps(summary)}")

if __name__ == '__main__':
    main()


Security scan verdicts — ClawScan: suspicious | static analysis: clean | VirusTotal: clean | SkillSpector: suspicious (CRITICAL)