Extensions for Frappe CRM that add features without forking the core app.
- Global search bar — Cmd/Ctrl+K modal backed by
rtcamp/frappe_search(optional). - Extensible sidebar — any installed Frappe app can inject list views, routes, groups, or separators into the CRM sidebar via the
crm_sidebarhook. - Events tab — calendar events tab injected into every Lead and Deal page; create, edit, duplicate, and delete Frappe
Eventrecords linked to the record. - Event notifications — scheduler sends in-browser realtime alerts and optional emails to event owners and participants before their events.
- Deal inactivity follow-ups & Slack digest — daily scheduler flags every CRM Deal with no activity (email, note, task, comment, or field edit) for N working days (holidays in the resolved Holiday List don't count), posts a single Slack digest, and optionally creates a plain follow-up CRM Task. Configured from the CRM XT Settings doctype (notification, working-day threshold, holiday list / the ERPNext CRM Settings company's holiday list, follow-up toggle + Jinja task title/body).
- Gmail Thread Reminder (incoming-email SLA) — stamps a holiday-aware due datetime (
custom_incoming_sla_due) on each deal = incoming-email time + a configurable number of calendar hours (default 24), maintained by the Gmail-thread sync. A native Frappe Notification (event "Minutes After" on that field) then posts a Slack channel alert — Frappe's own offset scheduler fires and dedups it, so this app writes no scheduler. Shares the deal-inactivity Holiday List and theutils/holidayhelper. - Gmail thread activities — activity entries on Lead/Deal records resolve Gmail threads via
rtcamp/frappe_gmail_thread(optional). - Address management (Deal only) — add, create, link, and unlink
Addressrecords directly from Deal forms via an inline HTML panel. - Follow button (eye icon) — injected into the Lead/Deal header icon row. Toggles
Document Followfor the current user; filled eye = following, outline eye = not following. - Quotation auto-items — opening a
Quotationfrom a CRM Deal's "Create Quotation" link auto-populates items, customer, currency, and missing values from the source Deal. - Public Lead intake API — guest-allowed
POSTendpoints for creating CRM Leads and uploading files against them; intended for website / external form integrations. - Gmail Add-on backend — whitelisted endpoints powering the Gmail sidebar Add-on (contact lookup by email, linked Leads / Deals, latest activity).
- Bundled fixtures — Lead & Deal field layouts, Property Setters, Custom Fields, CRM Form Scripts, and a curated set of CRM View Settings ship as fixtures and install automatically. (Optional)
- Project creation on Won — when a Deal is marked Won, dialogs guide the user through updating MSA & Insurance details on the linked Customer and creating an ERPNext
Projectpre-filled from the deal.
Frappe CRM XT ships a Vite-built Vue 3 IIFE bundle (crm_xt_app.js) that is loaded into the Frappe CRM SPA alongside the standard Frappe assets. The bundle:
- mounts a minimal
App.vueroot component on a#crm-xt-appdiv injected into the page - uses
MutationObserverto watch the CRM SPA's DOM and inject the sidebar, custom routes, and the Events tab without touching any FCRM source files - ships its own copy of Vue 3 and frappe-ui, so all components use the same design tokens and Tailwind classes as native CRM views
| Component | Purpose |
|---|---|
App.vue |
Root — sidebar injection, route registration, Events tab injection, global search trigger |
ExtListView.vue |
Full-featured list view (frappe-ui ListView, filters, sort, column picker, pagination) |
InjectedEventsTab.vue |
Events tab timeline and empty state |
InjectedEventModal.vue |
Create / edit / delete / duplicate Event dialog |
SearchDialog.vue |
Cmd/Ctrl+K global search modal |
EventNotifications.vue |
Per-event notification rules editor |
ListFilterLocal.vue |
Ad-hoc filter panel used inside ExtListView |
Attendee.vue |
Email-tag input for event attendees |
UserAvatar.vue |
Resolved user avatar with fallback initials |
| Module | Purpose |
|---|---|
api/activity.py |
Overrides crm.api.activities.get_activities; appends Gmail thread entries |
api/event.py |
get_doc_events, create/save/delete helpers, notification scheduler tasks |
api/follow.py |
Document Follow toggle endpoint |
api/contact.py |
Gmail Add-on contact lookup endpoints |
api/quotation.py |
Quotation auto-populate helper |
api/search.py |
Global search backend (frappe_search or built-in fallback) |
api/sidebar.py |
Reads crm_sidebar hooks from all installed apps and returns merged item list |
tasks/deal_inactivity.py |
Daily "deal gone quiet" scheduler — creates follow-up tasks + posts the Slack digest, holiday-aware |
doc_events/incoming_sla.py |
Maintains custom_incoming_sla_due (incoming-email SLA) so a native "Minutes After" Notification can alert the owner |
utils/holiday.py |
Shared working-day / holiday helpers (used by the digest and the SLA-due field) |
Press Cmd+K (macOS) or Ctrl+K (Linux/Windows) anywhere in Frappe CRM to open a full-screen search modal.
Two backends are tried in order:
| Priority | Backend | Notes |
|---|---|---|
| 1 | rtcamp/frappe_search |
Full-text search with <mark> highlighting. Used when installed. |
| 2 | Frappe built-in global search | frappe.utils.global_search — always available, no extra install. |
Every Lead and Deal page gains an Events tab injected at the right end of the tab bar.
Capabilities:
- Timeline feed of all
Eventrecords linked to the current Lead/Deal viaEvent Participants - Create a new event — title, date/time or all-day, color, attendees (email tags), visibility, location, description
- Edit an existing event — all fields editable; changes saved via
frappe.client.save - Delete an event (with confirmation)
- Duplicate an event
- Color-coded accent bar per event card; color picker shows the active selection
- Participant avatar stack on each card (up to 3 + overflow count)
Events are stored as standard Frappe Event documents and also appear in the Frappe Desk Calendar (/app/event).
Permissions note: Events are fetched via frappe_crm_xt.api.event.get_doc_events, which bypasses the child-doctype read permission issue that prevents direct frappe.client.get_list calls on Event Participants.
Scheduler tasks fire at every interval and send notifications to event owners and participants ahead of their events.
Per-event rules can be configured in the Event Notifications child table on each Event record. If no per-event rules exist, global defaults from FCRM Settings are applied.
| Setting | Description |
|---|---|
event_notifications |
Rules for timed events |
all_day_event_notifications |
Rules for all-day events |
Each rule specifies: type (Notification / Email), before (number), interval (minutes / hours / days / weeks), and optionally time (for all-day events).
A daily scheduler (cron 14:00 / 2 PM, site time zone) processes every CRM Deal that has had no activity for the configured number of WORKING days (default 7) and flags it once per idle streak. For each such deal it:
- adds the deal to a single Slack digest, and
- (when Create follow-up task is on) creates a plain follow-up CRM Task on the deal (
Todo, configured priority, assigned to the deal owner — no dates, no calendar sync).
Won/Lost deals are skipped.
Working-days against one calendar: the threshold counts only working days — days that are not in the resolved Holiday List. A single list applies to every deal (see below), so its holidays and weekly-offs don't add to the total: a deal idle Fri→Tue across a listed Sat/Sun (and any other listed holiday) has only accrued the working days in between. A deal is never nudged on a non-working day; it surfaces on the next working day. Non-working days come only from the resolved Holiday List (which normally includes its weekly-offs) — if no Holiday List is resolved, every day counts (plain calendar days).
Dedup is by the follow-up task's title: a CRM Task with the (rendered) title, created since the deal's last activity, marks the streak as handled — so keep Create follow-up task enabled for once-per-streak behaviour on the digest. No hidden marker fields are used.
Exact candidate window (no buffer): each run considers only deals whose last activity falls in the exact calendar band that crosses threshold working days today — computed from the working-day sequence, so listed holidays stretch the band precisely (its lower bound is the threshold+1-th working day counting back). Per deal we then require the count to be exactly threshold, so consecutive working-day runs tile with no gap or overlap: a deal is flagged once, on its crossing working day, and never re-visited — no duplication and no dedup flag needed. The one gap: if the cron does not run on a given working day (scheduler downtime), that day's crossings are skipped (there is no buffer to re-scan them next day).
Query cost: per run it's one bounded candidate query, one grouped-MAX per activity source, and — for the working-day count — one Holiday query for the single resolved list (dates prefetched into an in-memory set; membership checks are then pure Python) plus at most one Company lookup for the ERPNext CRM Settings company. No per-day or per-deal holiday round-trips.
Configuration lives on the CRM XT Settings single doctype (editable by System Manager and Sales Manager). A future in-app CRM XT settings tab can read/write it; today it is editable from its desk form. Blank fields fall back to the defaults shown:
| Field | Role | Default |
|---|---|---|
| Notification | Link → Notification whose subject/message/Slack webhook drive the digest; its own enabled flag is the master on/off switch. Blank ⇒ feature off. |
— |
| Inactivity Threshold (Working Days) | Working days of silence before a deal is nudged (holidays in the resolved Holiday List don't count). | 7 |
| Use the ERPNext CRM Settings company's holiday list | When on, all deals are evaluated against the default_holiday_list of the company set in ERPNext CRM Settings ("Company in ERPNext site"); falls back to the Holiday List below if that company has none. The list is not computed per deal. |
off |
| Holiday List | Fixed calendar of non-working days for the skip/catch-up. No list resolved at all ⇒ every day counts (plain calendar days). | — |
| Create a follow-up task | Enables per-deal follow-up CRM Task creation. Digest still sends when off. | off |
| Task Priority | Priority of the follow-up task (Low / Medium / High). |
High |
| Task Title | Jinja template rendered per deal ({{ doc }} = the CRM Deal). |
Follow up on inactive deal |
| Task Body | Jinja template for the task description. | — |
Excluded statuses (Won/Lost) and internal constants (Slack size ceiling, comment-divider syntax) stay in code.
"Activity" is the most recent of all of the following — a note/task/comment does not bump deal.modified, so each is checked directly:
| Source | Field / record |
|---|---|
| Status / any deal field edit | deal.modified |
| Incoming email | deal.custom_last_incoming_email_time |
| Outgoing email / reply | deal.last_responded_on (Gmail-synced) and/or deal.custom_last_responded_on (migrated) |
| Notes | FCRM Note |
| Tasks | CRM Task |
| Comments | Comment |
The digest itself is authored from the Notification selected in CRM XT Settings (the app seeds CRM Slack — CRM Deal Inactivity (7 days), disabled) — no code change is needed to restyle or re-scope it:
| Notification field | Role in the digest |
|---|---|
enabled |
Master on/off switch for the whole routine — digest and tasks (the scheduler no-ops when disabled or when no Notification is selected) |
subject |
Digest header — rendered with {{ count }} and {{ days }} |
message |
Per-deal block — rendered with {{ doc }} (the deal) |
| Condition / Filters | Extra per-deal scoping, honored before creating a task or sending |
| Slack Webhook URL | Target Slack channel. Optional — with no webhook the follow-up tasks are still created; only the Slack post is skipped. |
Dividers via Markdown comments: any <!-- ... --> comment in the message is stripped from the Slack output (like a Markdown comment); the inner text of the first comment becomes the divider between deal blocks (\n is interpreted as a newline). No comment → blocks are separated by a blank line.
Scales to large datasets: instead of scanning every stale deal, the job fetches only deals whose last activity falls in the exact holiday-aware band that crosses threshold working days today (lower bound = the threshold+1-th working day back), so cost stays flat even with 100k+ deals.
The Notification is seeded (disabled) by the create_crm_slack_notifications patch, alongside two event-driven CRM → Slack notifications:
| Notification | Trigger |
|---|---|
| CRM Slack — CRM Deal Inactivity (7 days) | Daily scheduler (this feature) |
| CRM Slack — CRM Deal Note Added | New FCRM Note on a Deal |
| CRM Slack — CRM Deal Updates | Deal saved (title / status / value / owner / stage change) |
All three ship disabled. Point each at a Slack Webhook URL (core Frappe integration) and toggle Enabled from the desk to activate — the webhook holds a secret, so it is never shipped in the app.
Nudges the deal owner on Slack when an incoming email on the Gmail thread has gone unanswered for N hours (default 24, holiday-aware). This app does no scheduling or sending for it — it stamps a due datetime on the deal and lets Frappe's native Notification engine trigger.
How it works:
- Due field —
custom_incoming_sla_due(hidden) =custom_last_incoming_email_timeadvanced by the configured number of calendar hours, then — if that lands on a holiday in the resolved list — deferred to the next working day (utils/holiday.add_hours_deferred), so the alert never fires on a holiday (a Fri incoming + 24h landing on a listed Sat falls on Mon, never Sat). It reuses the digest's Holiday List. - Maintained by the Gmail-thread sync —
doc_events/incoming_sla.refresh_incoming_due, called fromdoc_events/gmail_thread.on_update(the single writer of the incoming/response timestamps; some writesdb_setpast doc-event hooks, so a plain CRM Deal hook would miss them). The field is cleared when a reply lands after the incoming email, the deal closes (Won/Lost), or tracking is turned off. - Trigger & dedup are Frappe's — configure a Notification on CRM Deal with event "Minutes After", datetime field
custom_incoming_sla_due, minutes offset ≥ 10 (use10), channel Slack + a Slack Webhook URL. Frappe's offset scheduler (every 5 min) fires it once whennowcrossesdue + offsetand dedups via the Notification's owndatetime_last_run. A newer incoming email recomputes the due datetime, so the alert re-arms on its own. Reference the owner in the message with plain{{ doc.deal_owner }}if you want it named.
Config (CRM XT Settings → "Gmail Thread Reminder"):
| Field | Role | Default |
|---|---|---|
| Track unanswered incoming email | Maintain the custom_incoming_sla_due field. Off ⇒ stop stamping it (pending ones clear on the next email sync). |
off |
| Unanswered After (Hours) | Calendar hours after the incoming email before it falls due. If the due lands on a listed holiday the alert defers to the next working day, so it never fires on a holiday. | 24 |
The Notification's own Enabled flag is the on/off for actually sending. Nothing is seeded — create the Notification yourself so the webhook secret is never shipped.
Every CRM Deal form gains a custom Addresses HTML panel (not shown on CRM Lead). It renders all Address records linked to the Deal via Dynamic Link.
Capabilities:
- List all linked addresses with collapsible full-address detail
- Create a new address and automatically link it to the record
- Link an existing
Addressrecord by searching and selecting it - Unlink an address (removes the Dynamic Link entry; does not delete the Address document)
- Edit — each card has a direct link to the address's Frappe Desk form
An eye icon is injected into the Lead/Deal header icon row:
- Filled eye — you are currently following this record
- Outline eye — you are not following this record
Clicking the button calls the frappe_crm_xt.api.follow.toggle_follow endpoint and auto-enables track_changes on the doctype if needed.
When a CRM Deal's status is set to Won, two sequential dialogs are shown automatically:
- MSA & Insurance Details — pre-filled from the linked ERPNext
Customer; saves MSA start/end date, document link, insurance requested flag, insurance start/end date, and insurance document link back to the Customer. - Create Project — collects Project Manager, Territory, Billing Type, Customer, Currency, Estimated Hours, Service Type, Opportunity Amount, and Project Type; creates an ERPNext
Projectand opens it in a new tab.
If a project matching the deal already exists, the creation dialog is skipped and an Open Project action is added to the Deal's action menu instead.
Customer custom fields required (shipped as fixtures in custom_field.json):
| Field | Type | Description |
|---|---|---|
custom_msa_start_date |
Date | MSA start date |
custom_msa_end_date |
Date | MSA end date |
custom_msa_document_link |
Data | Link or path to MSA document |
custom_insurance_requested |
Check | Whether insurance was requested |
custom_insurance_start_date |
Date | Insurance start date |
custom_insurance_end_date |
Date | Insurance end date |
custom_insurance_document_link |
Data | Link or path to insurance document |
Any installed Frappe app can add items to the CRM sidebar by defining a crm_sidebar list in its hooks.py. Items appear below the built-in "Call Logs" entry.
Four item types are supported:
type |
Description |
|---|---|
"list_view" |
Opens the built-in list view at /xt/list/<doctype> |
"route" |
Navigates to an arbitrary URL (internal or external) |
"separator" |
Renders a horizontal divider line |
"group" |
Collapsible section; child items live in the "items" key (one level deep) |
# hooks.py (in any installed Frappe app)
crm_sidebar = [
{
# ── Required ──────────────────────────────────────────────────
"label": "Purchase Orders", # Sidebar display label
"type": "list_view", # "list_view" | "route" | "separator" | "group"
# ── Required for type == "list_view" ──────────────────────────
"doctype": "Purchase Order",
# ── Required for type == "route" ──────────────────────────────
# "url": "/app/purchase-order",
# ── Optional ──────────────────────────────────────────────────
"icon": "shopping-cart", # Lucide icon name (lucide.dev/icons)
# Filters shown in the filter UI — users can see and remove them
"default_filters": {
"status": ["=", "To Receive and Bill"],
},
# Filters always applied but never shown in the filter UI
"hidden_filters": {
"company": ["=", "My Company"],
},
# Columns to display (overrides DocType's in_list_view fields)
"fields": ["supplier", "transaction_date", "status", "grand_total"],
# Initial sort
"default_sort": {"field": "transaction_date", "dir": "desc"},
# Fieldname for the toolbar quick-search input
# Type-aware: Link → autocomplete, Select/Check → dropdown,
# Date/Datetime → date picker, everything else → text
"search_field": "supplier",
# URL template opened on row click; {name} is replaced with the record name
# Defaults to /app/<doctype-slug>/<name>
"row_url": "/desk/query-report/{name}",
},
{"type": "separator"},
{
"label": "Procurement",
"type": "group",
"icon": "package",
"items": [
{
"label": "Suppliers",
"type": "list_view",
"doctype": "Supplier",
"icon": "building",
},
{
"label": "Support Portal",
"type": "route",
"url": "https://support.example.com",
"icon": "external-link",
},
],
},
]| Key | Type | Required | Description |
|---|---|---|---|
label |
str |
✅ | Sidebar display text |
type |
str |
✅ | "list_view", "route", "separator", or "group" |
doctype |
str |
list_view only |
Frappe DocType to display |
url |
str |
route only |
Destination URL |
icon |
str |
— | Lucide icon name (default: "list") |
default_filters |
dict |
— | Pre-applied filters; users can see and clear them |
hidden_filters |
dict |
— | Always-applied filters; never shown in the UI |
fields |
list[str] |
— | Column order override; title and Modified are always included |
default_sort |
dict |
— | {"field": "<fieldname>", "dir": "asc"|"desc"} |
search_field |
str |
— | Fieldname for the toolbar quick-search input |
row_url |
str |
— | Row-click URL template; {name} is replaced with the record name |
items |
list |
group only |
Child items (same schema, one level deep) |
The built-in list view rendered for every "list_view" sidebar item includes:
- Column picker — toggle any field from the DocType on or off; selection persisted per-doctype in
localStorage - Filter panel — add, edit, and remove ad-hoc filters across any filterable field
- Hidden filters —
hidden_filtersfrom the hook are silently merged into every query; they do not appear in the filter panel - Type-aware quick search — toolbar input whose component matches the
search_fieldtype:Link→ autocomplete (loads options from the linked DocType)Select/Check→ dropdownDate/Datetime→ date picker- Everything else → text input (
like %value%)
- Sort — click any column header to toggle ascending/descending sort
- Load More — incremental pagination (20 rows per page)
- Custom row URL —
row_urltemplate determines row-click destination; defaults to the standard Frappe form view - Permission-safe — if the user lacks read permission for the doctype, the list shows an empty state instead of crashing
| Package | Required |
|---|---|
crm (Frappe CRM) |
✅ |
rtcamp/frappe_search |
Optional — enables Cmd/Ctrl+K global search |
rtcamp/frappe_gmail_thread |
Optional — enables Gmail thread activity entries on Lead/Deal |
erpnext |
Optional — required for Project creation on Won Deal and Customer MSA/Insurance fields |
bench get-app frappe_crm_xt
bench --site <your-site> install-app frappe_crm_xt
bench build --app frappe_crm_xt
bench --site <your-site> restart# Install pre-commit hooks (runs ruff, oxlint, prettier, eslint on commit)
pre-commit install
# Frontend watch build (Vite IIFE bundle)
cd frontend && yarn dev
# Production build
cd frontend && yarn build
# Run linter
cd frontend && yarn lintThe Vite build outputs frappe_crm_xt/public/js/crm_xt_app.js (IIFE, ~1.5 MB) and frappe_crm_xt/public/js/frappe-crm-xt-frontend.css. Run bench build --app frappe_crm_xt afterwards to have Frappe copy the assets to the site's public/ directory.
- The Events tab and sidebar items are injected into the Frappe CRM SPA via a
MutationObserverinApp.vue— no FCRM source files are modified. - The injected Events tab button deliberately does not carry
role="tab"to avoid Reka UI intercepting the click and collapsing all panels. get_doc_eventsusesignore_permissions=Trueonly on theEvent Participantschild-table queries (which have no standalone doctype-level permission); parentEventrecords respect normal read permissions.- The
crm_sidebarhook is collected from all installed apps viafrappe.get_hooks('crm_sidebar')inapi/sidebar.pyand returned as a single merged list to the frontend. frappe_crm_xtmust be listed aftercrminapps.txtso theoverride_whitelisted_methodsentry forcrm.api.activities.get_activitiestakes precedence correctly.