Deterministic syntax for Frappe hooks.py configuration. Use when Claude needs to configure app events, scheduler tasks, document events, fixtures, boot session, jenv customization, or website routing. Covers v14/v15/v16 including extend_doctype_class. Triggers: hooks.py, doc_events, scheduler_events, fixtures, app_include_js, override_whitelisted_methods.
Scanned 2/12/2026
Install via CLI
openskills install OpenAEC-Foundation/ERPNext_Anthropic_Claude_Development_Skill_Package---
name: erpnext-syntax-hooks
description: "Deterministic syntax for Frappe hooks.py configuration. Use when Claude needs to configure app events, scheduler tasks, document events, fixtures, boot session, jenv customization, or website routing. Covers v14/v15/v16 including extend_doctype_class. Triggers: hooks.py, doc_events, scheduler_events, fixtures, app_include_js, override_whitelisted_methods."
---
# ERPNext Syntax: Hooks (hooks.py)
Hooks in hooks.py enable custom apps to extend Frappe/ERPNext functionality.
## Quick Reference
### doc_events - Document Lifecycle
```python
# In hooks.py
doc_events = {
"*": {
"after_insert": "myapp.events.log_all_inserts"
},
"Sales Invoice": {
"validate": "myapp.events.si_validate",
"on_submit": "myapp.events.si_on_submit"
}
}
```
```python
# In myapp/events.py
import frappe
def si_validate(doc, method=None):
"""doc = document object, method = event name"""
if doc.grand_total < 0:
frappe.throw("Total cannot be negative")
```
### scheduler_events - Periodic Tasks
```python
# In hooks.py
scheduler_events = {
"daily": ["myapp.tasks.daily_cleanup"],
"hourly_long": ["myapp.tasks.heavy_sync"],
"cron": {
"0 9 * * 1-5": ["myapp.tasks.weekday_morning"]
}
}
```
```python
# In myapp/tasks.py
def daily_cleanup():
"""No arguments - called automatically"""
frappe.db.delete("Log", {"creation": ["<", one_month_ago()]})
```
### extend_bootinfo - Client Data Injection
```python
# In hooks.py
extend_bootinfo = "myapp.boot.extend_boot"
```
```python
# In myapp/boot.py
def extend_boot(bootinfo):
"""bootinfo = dict that goes to frappe.boot"""
bootinfo.my_setting = frappe.get_single("My Settings").value
```
```javascript
// Client-side
console.log(frappe.boot.my_setting);
```
---
## Most Used doc_events
| Event | When | Use Case |
|-------|------|----------|
| `validate` | Before every save | Validation, calculations |
| `on_update` | After every save | Notifications, sync |
| `after_insert` | After new doc | Creation-only actions |
| `on_submit` | After submit | Ledger entries |
| `on_cancel` | After cancel | Reverse entries |
| `on_trash` | Before delete | Cleanup |
**Complete list**: See [doc-events.md](references/doc-events.md)
---
## Scheduler Event Types
| Event | Frequency | Queue/Timeout |
|-------|-----------|---------------|
| `hourly` | Every hour | default / 5 min |
| `daily` | Every day | default / 5 min |
| `weekly` | Every week | default / 5 min |
| `monthly` | Every month | default / 5 min |
| `hourly_long` | Every hour | long / 25 min |
| `daily_long` | Every day | long / 25 min |
| `cron` | Custom timing | default / 5 min |
**Cron syntax and examples**: See [scheduler-events.md](references/scheduler-events.md)
---
## Critical Rules
### 1. bench migrate after scheduler changes
```bash
# REQUIRED - otherwise changes won't be picked up
bench --site sitename migrate
```
### 2. No commits in doc_events
```python
# ❌ WRONG
def on_update(doc, method=None):
frappe.db.commit() # Breaks transaction
# ✅ CORRECT - Frappe commits automatically
def on_update(doc, method=None):
update_related_docs(doc)
```
### 3. Changes after on_update via db_set
```python
# ❌ WRONG - change is lost
def on_update(doc, method=None):
doc.status = "Processed"
# ✅ CORRECT
def on_update(doc, method=None):
frappe.db.set_value(doc.doctype, doc.name, "status", "Processed")
```
### 4. Heavy tasks to _long queue
```python
# ❌ WRONG - timeout after 5 min
scheduler_events = {
"daily": ["myapp.tasks.process_all_records"] # May take 20 min
}
# ✅ CORRECT - 25 min timeout
scheduler_events = {
"daily_long": ["myapp.tasks.process_all_records"]
}
```
### 5. Tasks receive no arguments
```python
# ❌ WRONG
def my_task(some_arg):
pass
# ✅ CORRECT
def my_task():
# Fetch data inside the function
pass
```
---
## Cron Syntax Cheatsheet
```
* * * * *
│ │ │ │ │
│ │ │ │ └── Day of week (0-6, Sun=0)
│ │ │ └──── Month (1-12)
│ │ └────── Day of month (1-31)
│ └──────── Hour (0-23)
└────────── Minute (0-59)
```
| Pattern | Meaning |
|---------|---------|
| `*/5 * * * *` | Every 5 minutes |
| `0 9 * * *` | Daily at 09:00 |
| `0 9 * * 1-5` | Weekdays at 09:00 |
| `0 0 1 * *` | First day of month |
| `0 17 * * 5` | Friday at 17:00 |
---
## doc_events vs Controller Hooks
| Aspect | doc_events (hooks.py) | Controller Methods |
|--------|----------------------|-------------------|
| Location | `hooks.py` | `doctype/xxx/xxx.py` |
| Scope | Hook OTHER doctypes | Only OWN doctype |
| Multiple handlers | ✅ Yes (list) | ❌ No |
| Priority | After controller | First |
| Wildcard (`*`) | ✅ Yes | ❌ No |
**Use doc_events when**:
- Hooking other apps' DocTypes from your custom app
- Reacting to ALL DocTypes (wildcard)
- Registering multiple handlers
**Use controller methods when**:
- Working on your own DocType
- You want full lifecycle control
---
## Reference Files
| File | Contents |
|------|----------|
| [doc-events.md](references/doc-events.md) | All document events, signatures, execution order |
| [scheduler-events.md](references/scheduler-events.md) | Scheduler types, cron syntax, timeouts |
| [bootinfo.md](references/bootinfo.md) | extend_bootinfo, session hooks |
| [overrides.md](references/overrides.md) | Override and extend patterns |
| [permissions.md](references/permissions.md) | Permission hooks |
| [fixtures.md](references/fixtures.md) | Fixtures configuration |
| [examples.md](references/examples.md) | Complete hooks.py examples |
| [anti-patterns.md](references/anti-patterns.md) | Mistakes and corrections |
---
## Configuration Hooks
### Override DocType Controller
```python
# In hooks.py
override_doctype_class = {
"Sales Invoice": "myapp.overrides.CustomSalesInvoice"
}
```
```python
# In myapp/overrides.py
from erpnext.accounts.doctype.sales_invoice.sales_invoice import SalesInvoice
class CustomSalesInvoice(SalesInvoice):
def validate(self):
super().validate() # CRITICAL: always call super()!
self.custom_validation()
```
**Warning**: Last installed app wins when multiple apps override the same DocType.
### Override Whitelisted Methods
```python
# In hooks.py
override_whitelisted_methods = {
"frappe.client.get_count": "myapp.overrides.custom_get_count"
}
```
```python
# Method signature MUST be identical to original!
def custom_get_count(doctype, filters=None, debug=False, cache=False):
# Custom implementation
return frappe.db.count(doctype, filters)
```
### Permission Hooks
```python
# In hooks.py
permission_query_conditions = {
"Sales Invoice": "myapp.permissions.si_query_conditions"
}
has_permission = {
"Sales Invoice": "myapp.permissions.si_has_permission"
}
```
```python
# In myapp/permissions.py
def si_query_conditions(user):
"""Returns SQL WHERE fragment for list filtering"""
if not user:
user = frappe.session.user
if "Sales Manager" in frappe.get_roles(user):
return "" # No restrictions
return f"`tabSales Invoice`.owner = {frappe.db.escape(user)}"
def si_has_permission(doc, user=None, permission_type=None):
"""Document-level permission check"""
if permission_type == "write" and doc.status == "Closed":
return False
return None # Fallback to default
```
**Note**: `permission_query_conditions` only works with `get_list`, NOT with `get_all`!
### Fixtures
```python
# In hooks.py
fixtures = [
{"dt": "Custom Field", "filters": [["module", "=", "My App"]]},
{"dt": "Property Setter", "filters": [["module", "=", "My App"]]},
{"dt": "Role", "filters": [["name", "like", "MyApp%"]]}
]
```
```bash
# Export fixtures to JSON
bench --site sitename export-fixtures
```
### Asset Includes
```python
# In hooks.py
# Desk (backend) assets
app_include_js = "/assets/myapp/js/myapp.min.js"
app_include_css = "/assets/myapp/css/myapp.min.css"
# Website/Portal assets
web_include_js = "/assets/myapp/js/web.min.js"
web_include_css = "/assets/myapp/css/web.min.css"
# Form script extensions
doctype_js = {
"Sales Invoice": "public/js/sales_invoice.js"
}
```
### Install/Migrate Hooks
```python
# In hooks.py
after_install = "myapp.setup.after_install"
after_migrate = "myapp.setup.after_migrate"
```
```python
# In myapp/setup.py
def after_install():
create_default_roles()
def after_migrate():
clear_custom_cache()
```
---
## Complete Decision Tree
```
What do you want to achieve?
│
├─► REACT to document events from OTHER apps?
│ └─► doc_events
│
├─► Run PERIODIC tasks?
│ └─► scheduler_events
│ ├─► < 5 min → hourly/daily/weekly/monthly
│ ├─► > 5 min → hourly_long/daily_long/etc.
│ └─► Specific time → cron
│
├─► Send DATA to CLIENT at page load?
│ └─► extend_bootinfo
│
├─► Modify CONTROLLER of existing DocType?
│ ├─► Frappe v16+ → extend_doctype_class (recommended)
│ └─► Frappe v14/v15 → override_doctype_class
│
├─► Modify API ENDPOINT?
│ └─► override_whitelisted_methods
│
├─► Customize PERMISSIONS?
│ ├─► List filtering → permission_query_conditions
│ └─► Document-level → has_permission
│
├─► EXPORT/IMPORT configuration?
│ └─► fixtures
│
├─► ADD JS/CSS to desk or portal?
│ ├─► Desk → app_include_js/css
│ ├─► Portal → web_include_js/css
│ └─► Form specific → doctype_js
│
└─► SETUP on install/migrate?
└─► after_install, after_migrate
```
---
## Version Differences
| Feature | v14 | v15 | v16 |
|---------|-----|-----|-----|
| doc_events | ✅ | ✅ | ✅ |
| scheduler_events | ✅ | ✅ | ✅ |
| extend_bootinfo | ✅ | ✅ | ✅ |
| override_doctype_class | ✅ | ✅ | ✅ |
| extend_doctype_class | ❌ | ❌ | ✅ |
| permission_query_conditions | ✅ | ✅ | ✅ |
| has_permission | ✅ | ✅ | ✅ |
| fixtures | ✅ | ✅ | ✅ |
---
## Anti-Patterns Summary
| ❌ Wrong | ✅ Correct |
|----------|-----------|
| `frappe.db.commit()` in handler | Frappe commits automatically |
| `doc.field = x` in on_update | `frappe.db.set_value()` |
| Heavy task in `daily` | Use `daily_long` |
| Change scheduler without migrate | Always `bench migrate` |
| Sensitive data in bootinfo | Only public config |
| Override without `super()` | Always `super().method()` first |
| `get_all` with permission_query | Use `get_list` |
| Fixtures without filters | Filter by module/app |
**Full anti-patterns**: See [anti-patterns.md](references/anti-patterns.md)
No comments yet. Be the first to comment!