
Frappe Core Search
- 26 installs
- 159 repo stars
- Updated July 8, 2026
- openaec-foundation/frappe_claude_skill_package
Helps with ai & agent building tasks.
About
frappe-core-search is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted development.
- frappe-core-search
- AI & Agent Building
- AI-coding skill
Frappe Core Search by the numbers
- 26 all-time installs (skills.sh)
- Ranked #9,699 of 16,546 AI & Agent Building skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
npx skills add https://github.com/openaec-foundation/frappe_claude_skill_package --skill frappe-core-searchAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 26 |
|---|---|
| repo stars | ★ 159 |
| Last updated | July 8, 2026 |
| Repository | openaec-foundation/frappe_claude_skill_package ↗ |
What it does
Helps with ai & agent building tasks.
Files
Frappe Search System
Four Search Subsystems
| Subsystem | Module | Purpose | Real-time? |
|---|---|---|---|
| Link Field Search | frappe.desk.search | Autocomplete in link fields | Yes |
| Global Search | frappe.utils.global_search | Cross-doctype search (desk + web) | No (15min sync) |
| FullTextSearch | frappe.search.full_text_search | Whoosh-based index (website) | On rebuild |
| SQLiteSearch [v15+] | frappe.search.sqlite_search | FTS5 with scoring + spelling | Yes (5min queue) |
---
Decision Tree
What search do you need?
│
├─ Link field autocomplete (user types in a Link field)?
│ ├─ Default behavior sufficient → Configure search_fields on DocType
│ └─ Custom logic needed → standard_queries hook or query parameter
│
├─ Cross-doctype search (user searches for anything)?
│ ├─ Desk users → Global Search (auto-enabled)
│ │ └─ Set in_global_search=1 on important fields
│ └─ Website visitors → web_search() or WebsiteSearch (Whoosh)
│
├─ Custom full-text search for your app [v15+]?
│ └─ SQLiteSearch subclass + sqlite_search hook
│ → Spelling correction, recency boost, custom scoring
│
└─ Awesomebar customization?
└─ Client-side: override build_options or use search dialog---
Link Field Search
Configuring search_fields (Most Common Need)
# In DocType JSON or via customize form
{
"search_fields": "customer_name, customer_group",
"title_field": "customer_name",
"show_title_field_in_link": 1
}ALWAYS set `search_fields` — Without it, users can only search by name (often a code like CUST-001).
How Link Search Works
1. User types in link field → calls search_link(doctype, txt) 2. Searches across: name + title_field + search_fields 3. Allowed field types: Data, Text, Small Text, Long Text, Link, Select, Autocomplete, Read Only, Text Editor 4. Prefix matches rank higher than substring matches 5. Respects enabled/disabled fields automatically
Custom Link Query
# hooks.py — override search for a specific DocType
standard_queries = {
"Customer": "my_app.queries.customer_query"
}# my_app/queries.py — MUST be @frappe.whitelist()
@frappe.whitelist()
def customer_query(doctype, txt, searchfield, start, page_length, filters,
as_dict=False, reference_doctype=None,
ignore_user_permissions=False):
# Return list of dicts: [{"value": name, "description": label}, ...]
return frappe.db.sql("""
SELECT name, customer_name as description
FROM `tabCustomer`
WHERE (name LIKE %(txt)s OR customer_name LIKE %(txt)s)
AND status = 'Active'
ORDER BY customer_name
LIMIT %(start)s, %(page_length)s
""", {"txt": f"%{txt}%", "start": start, "page_length": page_length},
as_dict=True)Per-Field Query Override
// In Client Script or Form JS
frappe.ui.form.on("Sales Order", {
setup(frm) {
frm.set_query("customer", () => ({
filters: { status: "Active", territory: frm.doc.territory }
}));
}
});---
Global Search
Enabling
Set in_global_search = 1 on DocType fields that should be searchable.
How It Works
- Indexed fields stored in
__global_searchtable - Synced via Redis queue every 15 minutes
- Uses DB-native fulltext: MariaDB
MATCH...AGAINST, PostgreSQLTSVECTOR - Permission-filtered results
Rebuilding Index
# Rebuild for specific DocType
from frappe.utils.global_search import rebuild_for_doctype
rebuild_for_doctype("Sales Order")
# Rebuild everything
from frappe.utils.global_search import rebuild
rebuild()hooks.py Configuration
# Default doctypes for global search
global_search_doctypes = {
"Default": [
{"doctype": "Contact"},
{"doctype": "Customer"},
{"doctype": "Sales Order"},
]
}---
SQLiteSearch [v15+]
Creating Custom Search
# my_app/search.py
from frappe.search.sqlite_search import SQLiteSearch
class ProjectSearch(SQLiteSearch):
INDEX_SCHEMA = {
"metadata_fields": ["project", "owner", "status"],
"tokenizer": "unicode61 remove_diacritics 2 tokenchars '-_'",
}
INDEXABLE_DOCTYPES = {
"Task": {
"fields": ["name", {"title": "subject"}, {"content": "description"},
"modified", "project"],
"filters": {"status": ("!=", "Cancelled")}
},
"Project": {
"fields": ["name", {"title": "project_name"}, {"content": "notes"},
"modified", "status"],
}
}
def get_search_filters(self, query, scope=None):
"""Permission filtering — return additional WHERE conditions"""
return {}Register in hooks.py
sqlite_search = ['my_app.search.ProjectSearch']Features (automatic)
- Spelling correction: Trigram-based fuzzy matching
- Recency boosting: 1.8x (24h) → 1.5x (7d) → 1.2x (30d) → 1.1x (90d)
- Resumable indexing: Progress tracked, atomic replacement
- Auto-scheduling: Build every 3h, queue every 5min, doc events trigger updates
---
Anti-Patterns
| NEVER | ALWAYS | Why |
|---|---|---|
Omit search_fields on DocType | Set search_fields for user-friendly names | Users can't find records by name codes |
Custom query without @frappe.whitelist() | Decorate with @frappe.whitelist() | Silently fails — rejected by security check |
| Raw SQL without params in search | Use parameterized queries (%(txt)s) | SQL injection risk |
| Index all fields in global search | Only in_global_search=1 on key fields | Bloats table, slows 15-min sync |
| Use global search for real-time | Use link field search for real-time | Global search has 15-min sync delay |
Skip get_search_filters() in SQLiteSearch | Implement permission filtering | Returns all results regardless of access |
| Index cancelled/deleted docs | Set filters in INDEXABLE_DOCTYPES | Stale results confuse users |
---
Version Differences
| Feature | v14 | v15+ |
|---|---|---|
| Link search caching | -- | @http_cache(max_age=60) |
link_fieldname param | -- | Added |
page_length default | 20 | 10 |
| SQLiteSearch (FTS5) | -- | Full implementation |
| Spelling correction | -- | Trigram-based |
| Recency boosting | -- | Time-based multipliers |
sqlite_search hook | -- | Available |
| Global search | Yes | Yes |
| Whoosh FullTextSearch | Yes | Yes (legacy) |
---
Reference Files
- Link Search API — search_link, search_widget, custom queries
- Global & Website Search — Global search, WebsiteSearch, SQLiteSearch
Global & Website Search
Global Search
How It Works
1. Fields with in_global_search=1 are indexed into __global_search table 2. Changes queued via Redis, synced every 15 minutes 3. Search uses DB-native fulltext: MariaDB MATCH...AGAINST, PostgreSQL TSVECTOR 4. Results are permission-filtered
Indexing
from frappe.utils.global_search import (
rebuild_for_doctype,
rebuild,
update_global_search
)
# Rebuild index for one DocType
rebuild_for_doctype("Sales Order")
# Rebuild entire index
rebuild()
# Queue single document update (auto-called on doc save)
update_global_search(doc)hooks.py Configuration
# Define which doctypes appear in global search results
global_search_doctypes = {
"Default": [
{"doctype": "Contact"},
{"doctype": "Customer"},
{"doctype": "Sales Order"},
{"doctype": "Item"},
]
}API
from frappe.utils.global_search import search, web_search
# Desk search (permission-filtered)
results = search("acme corp", start=0, limit=20, doctype="Customer")
# Website search (published docs only, guest-accessible)
results = web_search("product guide", scope="/products", start=0, limit=20)Important Limitations
- 15-minute sync delay — Changes aren't immediately searchable
- Redis dependency — Queue fails gracefully to direct sync if Redis unavailable
- Batch size — 50,000 records per insert batch
- HTML sanitized — Script/style tags stripped from indexed content
---
WebsiteSearch (Whoosh-based)
Overview
File-based search using Whoosh library. Indexes website pages for visitor search.
Schema
# Default fields
name = ID(stored=True) # Page path
title = TEXT(stored=True) # Page title
content = TEXT(stored=True) # Page content (HTML stripped)Index Location
sites/{site}/indexes/{index_name}/API
from frappe.search.website_search import WebsiteSearch
ws = WebsiteSearch()
ws.build_index() # Full rebuild
ws.update_index(document) # Update single doc
results = ws.search("query", limit=20) # SearchWeb API
/api/method/frappe.search.web_search?text=query&scope=/blog&limit=20What Gets Indexed
- Static pages in
www/directory - Published documents with
has_web_view=1on their DocType - Pages rendered as Guest user (respects publish status)
---
SQLiteSearch (FTS5) — v15+
Architecture
Uses SQLite FTS5 virtual tables for advanced full-text search.
Creating a Custom Search Index
# my_app/search.py
from frappe.search.sqlite_search import SQLiteSearch
class TaskSearch(SQLiteSearch):
# Define metadata columns (filterable, not full-text)
INDEX_SCHEMA = {
"metadata_fields": ["project", "owner", "status", "priority"],
"tokenizer": "unicode61 remove_diacritics 2 tokenchars '-_'",
}
# Define which DocTypes and fields to index
INDEXABLE_DOCTYPES = {
"Task": {
"fields": [
"name", # Indexed as-is
{"title": "subject"}, # Map "subject" field → "title" column
{"content": "description"},# Map "description" → "content"
"modified", # For recency scoring
"project", # Metadata field
"status",
"priority",
],
"filters": {"status": ("!=", "Cancelled")} # Exclude cancelled
},
"Project": {
"fields": [
"name",
{"title": "project_name"},
{"content": "notes"},
"modified",
"status",
],
}
}
def get_search_filters(self, query, scope=None):
"""Permission filtering — restrict results to user's projects."""
user_projects = frappe.get_all("Project",
filters={"owner": frappe.session.user},
pluck="name"
)
if user_projects:
return {"project": ("in", user_projects)}
return {}
@SQLiteSearch.scoring_function
def boost_high_priority(self, doc, base_score):
"""Custom scoring — boost high priority tasks."""
if doc.get("priority") == "High":
return base_score * 1.5
return base_scoreRegister in hooks.py
# hooks.py
sqlite_search = ['my_app.search.TaskSearch']Automatic Scheduling
Once registered, Frappe handles:
- Build index: Every 3 hours (
build_index_if_not_exists) - Process queue: Every 5 minutes (
index_docs_in_queue) - Doc events:
on_update→ update index,on_trash→ delete from index
Built-in Features
| Feature | Description |
|---|---|
| Spelling correction | Trigram Jaccard (70%) + sequence similarity (30%) |
| Recency boosting | 1.8x (24h), 1.5x (7d), 1.2x (30d), 1.1x (90d) |
| Resumable indexing | Progress tracked in search_index_progress table |
| Atomic replacement | Builds in temp DB, swaps on completion |
| Snippet generation | 64-char context snippets around matches |
| Title exact match | 5x boost for exact title matches |
Constants
MAX_SEARCH_RESULTS = 100
SNIPPET_LENGTH = 64
MIN_WORD_LENGTH = 4
TITLE_EXACT_MATCH_BOOST = 5.0---
Search Hooks Summary
| Hook | Purpose | Example |
|---|---|---|
standard_queries | Override link field search per DocType | {"Customer": "app.queries.customer_query"} |
global_search_doctypes | Define global search DocTypes | {"Default": [{"doctype": "Contact"}]} |
sqlite_search | Register FTS5 search classes [v15+] | ['my_app.search.TaskSearch'] |
permission_query_conditions | Filter search results by permissions | {"ToDo": "app.perms.todo_filter"} |
Link Search API — frappe.desk.search
search_link() — Main Entry Point
@frappe.whitelist()
@http_cache(max_age=60, stale_while_revalidate=300) # v15+ only
def search_link(
doctype, # DocType to search
txt, # Search text
query=None, # Custom query function path
filters=None, # Additional filters (JSON)
page_length=10, # Results per page (v14: 20)
searchfield=None, # Override search field
reference_doctype=None, # Calling DocType (for context)
ignore_user_permissions=False,
*,
link_fieldname=None # v15+ only: calling field name
)Result format: LinkSearchResults list of dicts:
[{"value": "CUST-001", "description": "Acme Corp", "label": "Acme Corp"}]search_widget() — Internal Implementation
Called by search_link(). Builds the actual SQL query.
Search Order
1. Check standard_queries hook for custom function 2. Build field list: name + title_field + search_fields 3. Filter by text across all searchable fields (OR conditions) 4. Apply enabled/disabled field filtering 5. Rank by relevance (prefix matches first)
Allowed Search Field Types
Only these fieldtypes are searchable in link queries:
- Autocomplete, Data, Text, Small Text, Long Text
- Link, Select, Read Only, Text Editor
Relevance Ranking
-- Prefix matches rank higher (value 0) than substring matches (value 1)
ORDER BY
CASE WHEN name LIKE 'search%' THEN 0 ELSE 1 END,
CASE WHEN title LIKE 'search%' THEN 0 ELSE 1 END,
name ASCConfiguring Search Fields
DocType Properties
{
"search_fields": "customer_name, customer_group, territory",
"title_field": "customer_name",
"show_title_field_in_link": 1
}search_fields— Comma-separated list;namealways includedtitle_field— Displayed as description; auto-detectstitlefieldshow_title_field_in_link— Shows title alongside name in link displays
Via Customize Form
1. Open Customize Form for target DocType 2. Set "Search Fields" (comma-separated field names) 3. Set "Title Field" for display 4. Check "Show Title Field in Link"
Custom Link Queries
Method 1: standard_queries Hook (Global Override)
# hooks.py
standard_queries = {
"Customer": "my_app.queries.customer_query",
"Item": "my_app.queries.item_query",
}# my_app/queries.py
@frappe.whitelist()
def customer_query(doctype, txt, searchfield, start, page_length, filters,
as_dict=False, reference_doctype=None,
ignore_user_permissions=False):
"""Custom customer search with active-only filtering."""
conditions = []
if txt:
conditions.append(
"(c.name LIKE %(txt)s OR c.customer_name LIKE %(txt)s)"
)
return frappe.db.sql("""
SELECT c.name, c.customer_name AS description
FROM `tabCustomer` c
WHERE c.status = 'Active'
{conditions}
ORDER BY
CASE WHEN c.name LIKE %(prefix)s THEN 0 ELSE 1 END,
c.customer_name ASC
LIMIT %(page_length)s OFFSET %(start)s
""".format(conditions="AND " + " AND ".join(conditions) if conditions else ""),
{
"txt": f"%{txt}%",
"prefix": f"{txt}%",
"start": start,
"page_length": page_length
}, as_dict=True)Method 2: Per-Field Query (Client Script)
// In setup() of form script
frappe.ui.form.on("Sales Order", {
setup(frm) {
// Simple filter
frm.set_query("customer", () => ({
filters: { status: "Active" }
}));
// Dynamic filter based on form data
frm.set_query("item_code", "items", () => ({
filters: {
item_group: frm.doc.item_group,
disabled: 0
}
}));
// Custom query function
frm.set_query("warehouse", () => ({
query: "my_app.queries.warehouse_query",
filters: { company: frm.doc.company }
}));
}
});Method 3: query Parameter on Link Field
{
"fieldname": "custom_field",
"fieldtype": "Link",
"options": "Item",
"link_filters": "[[\"Item\", \"disabled\", \"=\", 0]]"
}Awesomebar
The Awesomebar (awesome_bar.js) is the global search bar in Desk.
What It Searches
- DocType names and reports → navigation
- Recent pages → history
- Calculator expressions (numbers,
=prefix) - Tags (prefix
#) - Current list filter (when on list view)
- Global search dialog (fallback for text queries)
Keyboard Shortcuts
| Key | Action |
|---|---|
Ctrl+K / Cmd+K | Focus Awesomebar |
| Arrow keys | Navigate results |
| Enter | Select result |
| Ctrl+Enter | Open in new tab |
| Escape | Close |