Frappe dev
Production-grade Frappe Framework & ERPNext skills, agents, and scaffolding commands for Claude Code
npx -y skills add prilk-consulting/frappe-agent-kit --skill frappe-devAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 0 stars0 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
What its author says it does
Copied from the file, not written here
Frappe Framework core development reference. Use proactively for DocType controllers, server scripts, whitelisted APIs, database operations, hooks.py, permissions, background jobs, caching, desk client scripts, Jinja/print formats, and custom app structure on Frappe v15+ and ERPNext. Not for React/Vue SPA frontends (use frappe-frontend), portal/website pages (use frappe-portal), or writing tests (use frappe-test).
SKILL.md
19.5 KB, ~4.9k tokens by cl100k_base, as published. Nobody here has run it
Frappe Framework Core Reference
Usage
Use this skill when:
- Writing or reviewing DocType controllers, whitelisted API methods, or hooks.py wiring
- Database access decisions (
get_listvsget_all, query builder vs raw SQL) - Permission enforcement, background jobs, caching, desk client scripts
- Structuring a custom app that extends other apps' DocTypes
- Debugging Jinja/print-format errors → see references/jinja-and-print-formats.md
- Validating a hand-written DocType JSON before migrate → run
scripts/validate_doctype_json.py <file>(bundled in this skill; exit 4 = errors)
Document Lifecycle Hooks
class MyDocType(Document):
def before_insert(self): ... # before first save
def after_insert(self): ... # after first save
def validate(self): ... # before save (insert/update)
def before_save / on_update(self): ...
def before_submit / on_submit(self): ...
def before_cancel / on_cancel(self): ...
def on_trash(self): ... # before delete
API Development
Two surfaces:
| Shape | URL | Use for |
|---|---|---|
| REST | /api/resource/<DocType> (v1) or /api/v2/document/<DocType> (v15+) | Standard CRUD |
| RPC | /api/method/<dotted.path> | Custom server logic |
| Webhook | Webhook DocType (configured in UI) | Notify external systems |
Whitelisted methods
@frappe.whitelist()
def get_balance(customer):
frappe.has_permission("Customer", "read", doc=customer, throw=True)
return frappe.db.get_value("Customer", customer, "outstanding_amount")
@frappe.whitelist(methods=["POST"])
def create_payment(customer, amount): ...
@frappe.whitelist(allow_guest=True)
def public_status(): return {"status": "ok"}
Decorator options: methods=[...] (restrict HTTP verbs), allow_guest=True (never on state changes), xss_safe=True (skip XSS escape).
Response shapes: RPC returns {"message": <value>}, REST returns {"data": ...}. frappe.throw() returns HTTP 417 with the message in _server_messages.
REST CRUD
Always send Accept: application/json (without it, Frappe may return HTML). List query params: fields, filters, or_filters, order_by, limit_start, limit_page_length (or limit alias on v15+). Filter operators: =, !=, >, <, >=, <=, like, not like, in, not in, is set, is not set, between.
filters = [
["status", "in", ["Open", "Pending"]],
["date", "between", ["2024-01-01", "2024-12-31"]],
]
Authentication
headers = {"Authorization": "token api_key:api_secret", "Accept": "application/json"}
Generate keys: User → Settings → API Access → Generate Keys (shown once). Create a dedicated API user per integration; never use Administrator's key.
File upload
requests.post(f"{base_url}/api/method/upload_file",
files={"file": ("doc.pdf", open("doc.pdf", "rb"), "application/pdf")},
data={"doctype": "Customer", "docname": "CUST-001", "is_private": 1},
headers={"Authorization": "token ..."})
# Do NOT set Content-Type — requests adds the multipart boundary itself.
Client-side calls (Desk JavaScript)
frappe.xcall("method", args)— preferred, async/awaitfrappe.call({ method, args, freeze, freeze_message })— promise/callbackfrm.call("server_method")— sends current form values to a doc method- Gotcha:
frm.callwrites form values back; if the server method writes a Password field, the round-trip clobbers it. Usefrappe.xcallinstead.
- Gotcha:
Webhooks send X-Frappe-Webhook-Signature = base64(HMAC-SHA256(payload, secret)). Always set a secret and verify on the receiving side.
Log every outbound request via Integration Request
ALL outbound HTTP calls to third parties (payment gateways, signing providers, e-invoicing access points, custom webhooks) SHOULD be logged via the Integration Request DocType. Same for inbound webhook receipts. This gives you: audit trail, retry path, debugging UI in /app/integration-request, and proof-of-attempt for compliance.
from frappe.integrations.utils import create_request_log
# 1. Create the log BEFORE making the call
req = create_request_log(
data=payload, # dict — auto-serialized to JSON
service_name="MyProvider", # shows up as the column in the list view
request_headers=headers,
url=endpoint,
request_description="Submit invoice",
is_remote_request=1, # marks this as outbound (vs. notification)
reference_doctype="Sales Invoice",
reference_docname=invoice.name,
)
# 2. Make the call
try:
response = requests.post(endpoint, json=payload, headers=headers, timeout=30)
response.raise_for_status()
req.handle_success(response.json()) # status="Completed", output=<response JSON>
except Exception as e:
req.handle_failure({"error": str(e)}) # status="Failed", error=<error JSON>
raise
Statuses: Queued → Authorized → Completed / Failed / Cancelled. Use req.update_status(params, status) to merge new params into data and bump status.
reference_doctype+reference_docnameare mandatory — they connect the log to the business record (so you can find "what did we send to the payment gateway for SI-00001?").- Auto-cleanup: rows older than 30 days are deleted by the scheduled
clear_old_logsjob. Don't depend on Integration Request as long-term storage; copy critical fields onto the reference doc if you need them retained. - Retry pattern: a retry button on the reference doc re-reads
req.data, re-POSTs, and creates a fresh Integration Request (don't mutate the failed one — keep the audit trail). - Inbound webhooks: log them too — store the raw payload + signature verification result. If the provider replays, you have proof.
Database Operations
doc = frappe.get_doc("DocType", name)
docs = frappe.get_all("DocType", filters={"status": "Open"}, fields=["name"])
value = frappe.db.get_value("DocType", name, "field")
frappe.db.set_value("DocType", name, "field", value)
doc.db_set("field", value)
frappe.db.exists("DocType", name)
# Tuple filter operators
frappe.db.get_value("DocType", {"status": ("!=", "Cancelled")}, ["name"], as_dict=True)
frappe.get_all("DocType", filters={"date": ["between", [start, end]]})
get_list vs get_all
| Method | User Permissions | Permission Query Hook | Use for |
|---|---|---|---|
frappe.get_list() | Applied | Applied | User-facing queries |
frappe.get_all() | Ignored | Ignored | System/background queries |
Use get_list() for anything returned to a user. get_all() bypasses ALL permission filtering — fine for jobs, dangerous in whitelisted endpoints.
Query Builder (frappe.qb)
Preferred over frappe.db.sql() for new code — parameterized by default.
Task = frappe.qb.DocType("Task")
Customer = frappe.qb.DocType("Customer")
from frappe.query_builder.functions import Count, Sum
(frappe.qb.from_(Task)
.inner_join(Customer).on(Task.customer == Customer.name)
.select(Task.status, Count(Task.name).as_("n"))
.where((Task.status == "Open") | (Task.priority == "High")) # OR uses `|`, not `or`
.groupby(Task.status)
).run(as_dict=True)
# Inspect SQL without executing
query.get_sql() # SQL string
query.walk() # (SQL, params)
SQL safety
frappe.db.sql("SELECT ... WHERE name = %(name)s", {"name": user_input}) # safe
where = f"`tabCustomer`.owner = {frappe.db.escape(user)}" # escape dynamic fragments
Never f-string user input into raw SQL.
Client Scripts (Desk)
frappe.ui.form.on("DocType", {
refresh(frm) {
frm.add_custom_button(__("Action"), () => {
frappe.call({ method: "app.module.api", args: { name: frm.doc.name },
freeze: true, callback: r => frm.reload_doc() });
}, __("Actions"));
},
field_name(frm) {
frm.set_value("other_field", frm.doc.field_name * 2);
}
});
// Child table events
frappe.ui.form.on("Child DocType", {
qty(frm, cdt, cdn) {
const row = locals[cdt][cdn];
frappe.model.set_value(cdt, cdn, "amount", row.qty * row.rate);
}
});
Client-script gotchas
onloaddoesn't always fire on hard reload — lazy-fetch insiderefreshinstead.add_custom_buttonis NOT idempotent —frm.remove_custom_button(label, group)first if the handler can fire multiple times (refresh + field-change both trigger).frappe.callignores theerror:callback — that key is silently dropped. Usealways:for success-or-failure cleanup.__()inside JS template literals is invisible tobench get-untranslated— extract to a const first:const label = __("Label")then`<th>${label}</th>`.frm.set_value(...).then(...)— only returns a Promise on v15+; v14 returnsundefinedand.then()silently never runs.
hooks.py
after_install = "myapp.install.after_install"
after_migrate = "myapp.install.after_migrate"
doctype_js = { "Sales Invoice": "public/js/sales_invoice.js" }
doc_events = { "Sales Invoice": { "validate": "myapp.mymodule.custom.sales_invoice.validate" } }
scheduler_events = {
"hourly": ["myapp.tasks.hourly_task"],
"cron": { "0 9 * * *": ["myapp.tasks.morning_task"] },
}
override_doctype_class = { "Sales Invoice": "myapp.overrides.CustomSalesInvoice" }
Permissions
Five layers: Role → User Permission → Perm Level → has_permission hook → Data Masking (v16+).
Check permission
frappe.has_permission("Sales Order", "write", doc=doc, throw=True)
doc.has_permission("write") # bool
doc.check_permission("write") # raises frappe.PermissionError
# Debug evaluation steps
frappe.has_permission("Sales Order", "read", debug=True)
print(frappe.local.permission_debug_log)
has_permission hook — deny-only
Can only deny; returning True does NOT grant. Return None to let standard checks proceed.
# hooks.py
has_permission = { "Sales Order": "myapp.permissions.check_order" }
def check_order(doc, ptype, user):
if ptype == "write" and doc.docstatus == 2:
if "Sales Manager" not in frappe.get_roles(user):
return False
return None
permission_query_conditions — row-level filter
Affects frappe.get_list() only. Returns a SQL WHERE fragment. Always frappe.db.escape() user input.
permission_query_conditions = { "Customer": "myapp.permissions.customer_query" }
def customer_query(user):
user = user or frappe.session.user
if "Sales Manager" in frappe.get_roles(user):
return ""
return f"`tabCustomer`.owner = {frappe.db.escape(user)}"
Bypass — last resort
doc.flags.ignore_permissions = True # always comment WHY
Built-in roles: Guest (anonymous), All (any auth user), Administrator (always passes).
Background Jobs & Scheduler
scheduler_events vs frappe.enqueue
scheduler_events (hooks.py) | frappe.enqueue() | |
|---|---|---|
| Triggered by | Time/interval | Code |
| Arguments | NONE (parameterless) | Any serializable |
| Queue control | *_long event suffix | queue= parameter |
Run bench migrate after editing scheduler_events in hooks.py.
Scheduler event keys
| Key | Queue | For |
|---|---|---|
all | short (NEVER >60s) | Every tick |
hourly / daily / weekly / monthly | short | Tasks <5 min |
hourly_long / daily_long / … | long | Tasks 5-25 min |
cron: { "0 9 * * *": [...] } | short | Custom schedule |
frappe.enqueue pattern
from frappe.utils.background_jobs import is_job_enqueued
@frappe.whitelist()
def process(doctype, filters):
job_id = f"process_{doctype}_{frappe.session.user}"
if is_job_enqueued(job_id):
return {"message": "Already in progress"}
frappe.enqueue("myapp.tasks.process_batch",
queue="long", # ALWAYS explicit
timeout=1800,
job_id=job_id,
enqueue_after_commit=True, # wait until current txn commits
doctype=doctype, filters=filters)
Queue defaults: short 300s · default 300s · long 1500s.
bench --site <site> execute myapp.tasks.daily_cleanup # run direct
bench --site <site> show-pending-jobs
bench --site <site> doctor
Caching
# Document cache — DO NOT mutate the returned doc (shared reference)
settings = frappe.get_cached_doc("System Settings")
frappe.clear_document_cache("Item", "ITEM-001")
# Redis (auto-prefixed with site name)
frappe.cache.set_value("key", data, expires_in_sec=300)
frappe.cache.delete_keys("item_price*")
frappe.cache.hset("user|perms", "[email protected]", perms) # also hget/hdel/hgetall
# Function memoization
from frappe.utils.caching import redis_cache
@redis_cache(ttl=300)
def get_rate(from_ccy, to_ccy): ...
get_rate.clear_cache()
# Rules: args must be hashable (no dicts/lists); never decorate side-effecting fns.
# Per-request memo — plain dict, lives one HTTP request
if "user_settings" not in frappe.local.cache:
frappe.local.cache["user_settings"] = frappe.get_doc("User Settings", frappe.session.user)
Print Formats & Jinja Templates
Two production traps live here — the frappe.utils.X sandbox namespace trap (silent None resolution in Jinja) and the Print Format JSON-to-DB sync-skip. Full reference with fixes: references/jinja-and-print-formats.md
Custom App Development Patterns
Recommended structure
myapp/
├── myapp/
│ ├── hooks.py
│ ├── install.py # custom fields, master data
│ ├── mymodule/
│ │ ├── setup.py # alternate: module-local custom fields
│ │ ├── custom/ # hook handlers for OTHER apps' DocTypes
│ │ │ └── sales_invoice.py
│ │ └── doctype/ # this app's own DocTypes
│ ├── shared/ # cross-module utilities
│ └── public/js/
│ ├── sales_invoice.js # client scripts for existing DocTypes
│ └── customer.js
└── pyproject.toml
install.py for custom fields
Define fields in a single get_custom_fields() dict, wire into BOTH after_install and after_migrate for idempotency:
from frappe.custom.doctype.custom_field.custom_field import create_custom_fields
def after_install():
create_custom_fields(get_custom_fields())
setup_master_data()
def after_migrate():
create_custom_fields(get_custom_fields())
def get_custom_fields():
return {
"Customer": [
{"fieldname": "my_field", "label": "My Field", "fieldtype": "Data",
"insert_after": "customer_name"},
],
"Sales Invoice": [...],
}
For multi-module apps, prefer module-level setup.py with one make_custom_fields() per module, then list them all in after_migrate:
after_migrate = [
"myapp.module1.setup.make_custom_fields",
"myapp.module2.setup.make_custom_fields",
]
Ownership rule: an app only creates, reads, and writes its OWN custom fields. Never manage (or delete) custom fields that belong to another app — that's how two apps end up fighting over a field on every migrate.
custom/ folder for doc events
One file per DocType you're hooking. Handler functions take (doc, method=None). Register paths in hooks.py doc_events.
# mymodule/custom/sales_invoice.py
def validate(doc, method=None): ...
def on_submit(doc, method=None): ...
Client scripts for existing DocTypes
- One file per DocType, named after it in lowercase:
public/js/customer.js,public/js/sales_invoice.js. Never put two doctypes'frappe.ui.form.on(...)in one file — it kills grep-by-doctype-name. - Register via
doctype_jsinhooks.py.
Sharing client scripts across DocTypes
doctype_js accepts a list of files per doctype — Frappe concatenates them into the form's __js only when that form opens (smaller blast radius than app_include_js):
doctype_js = {
"Customer": ["public/js/shared_helpers.js", "public/js/customer.js"],
"Supplier": ["public/js/shared_helpers.js", "public/js/supplier.js"],
}
List helpers first (they register a namespace), doctype-specific file last (it uses the namespace).
| Approach | Loads on | Use when |
|---|---|---|
app_include_js = "myapp.bundle.js" | Every desk page | Helpers used app-wide (ERPNext pattern) |
doctype_js: [helpers, doctype] | Only that form | Helpers shared by a small set of related doctypes |
Best Practices Summary
- Custom fields:
install.pyget_custom_fields(), wired into BOTHafter_installandafter_migrate. - Hook handlers: in
custom/folder, one file per DocType. - Client scripts: one file per DocType in
public/js/, named after the DocType. Register viadoctype_js. - Shared client scripts:
doctype_js: ["helpers.js", "doctype.js"](per-doctype). Useapp_include_jsonly for truly cross-cutting helpers. - Shared server code:
shared/folder. - Permissions:
frappe.has_permission(..., throw=True)in whitelisted methods.get_list(notget_all) for user-facing data. - Validations live server-side: enforce rules in the controller (
validate) or field metadata (reqd, options, depends_on) — client-side checks are UX hints only, never the enforcement layer. - i18n:
_()/__()on all user-facing strings; extract from JS template literals first. - Logging:
frappe.log_error(), neverprint(). - Errors:
frappe.throw()for user errors; try/except +log_error()for system errors. - Queries:
frappe.qboverfrappe.db.sql(). Parameterize /frappe.db.escape()when raw SQL is unavoidable. - Background jobs: always explicit
queue=;enqueue_after_commit=Truewhen the job depends on data the request is writing. - Caching: never mutate a
get_cached_doc()result (shared ref); invalidate viadoc_eventsor.clear_cache(). - Outbound HTTP: every third-party call (and inbound webhook receipt) goes through
frappe.integrations.utils.create_request_log→handle_success/handle_failure. Setreference_doctype+reference_docnameso the log links back to the business record. - No raw-SQL workarounds: when something seems impossible through the ORM, there is almost always a proper Frappe API for it — find it before reaching for
frappe.db.sqlwrites. - No throwaway scripts inside
apps/: usebench executeorbench consolefor one-off diagnostics; ad-hoc.pyfiles in app folders end up committed.
Common Commands
bench --site <site> run-tests --app app_name
bench --site <site> migrate
bench --site <site> console
bench --site <site> clear-cache
bench build --app app_name
bench --site <site> execute myapp.install.after_install
Utilities
from frappe.utils import (
nowdate, nowtime, now_datetime, getdate, get_datetime,
flt, cint, cstr, fmt_money, get_link_to_form,
)
What ships with it: 2 files
9.9 KB alongside SKILL.md, 1 of them executable
references/
scripts/
- validate_doctype_json.pyruns6.0 KB
Gives 0 of the 12 instructions most test skills give in ~4.9k tokens
Counted across 964 of the 1,571 authors here whose files we hold, read 2026-08-07
- Close the browser when donein 55 of 964, across 12 files
- Wait for network idle statein 51 of 964, across 6 files
- Launch Chromium in headless modein 49 of 964, across 6 files
- Use descriptive selectors for elementsin 49 of 964, across 6 files
- Run provided scripts with help flag firstin 49 of 964, across 6 files
- Add appropriate explicit waitsin 48 of 964, across 5 files
- Use bundled scripts as black boxesin 46 of 964, across 3 files
- Do not read script source codein 46 of 964, across 3 files
- Use sync playwright for scriptsin 46 of 964, across 3 files
- Inspect dom before executing actionsin 46 of 964, across 3 files
- Run the full test suitein 37 of 964
- Write the failing test firstin 29 of 964, across 23 files
Said here and by no other author read
- validate hand-written doctype json before migrate
- send accept application json header for rest requests
- create a dedicated api user per integration
- do not set content type header manually for file uploads
- prefer xcall for desk client calls
- log all outbound and inbound webhook traffic via integration request
Grouped from the skills themselves: near-identical wordings counted once, and counted by distinct author, so one author publishing three of these counts once. Length counted with cl100k_base; the agent that loads this file may tokenize it differently.