agentsclimarketplace

Frappe dev

Skill prilk-consulting/frappe-agent-kit/skills/frappe-dev

Production-grade Frappe Framework & ERPNext skills, agents, and scaffolding commands for Claude Code

Install
npx -y skills add prilk-consulting/frappe-agent-kit --skill frappe-dev

Assembled 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_list vs get_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:

ShapeURLUse for
REST/api/resource/<DocType> (v1) or /api/v2/document/<DocType> (v15+)Standard CRUD
RPC/api/method/<dotted.path>Custom server logic
WebhookWebhook 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/await
  • frappe.call({ method, args, freeze, freeze_message }) — promise/callback
  • frm.call("server_method") — sends current form values to a doc method
    • Gotcha: frm.call writes form values back; if the server method writes a Password field, the round-trip clobbers it. Use frappe.xcall instead.

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: QueuedAuthorizedCompleted / Failed / Cancelled. Use req.update_status(params, status) to merge new params into data and bump status.

  • reference_doctype + reference_docname are 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_logs job. 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

MethodUser PermissionsPermission Query HookUse for
frappe.get_list()AppliedAppliedUser-facing queries
frappe.get_all()IgnoredIgnoredSystem/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

  • onload doesn't always fire on hard reload — lazy-fetch inside refresh instead.
  • add_custom_button is NOT idempotentfrm.remove_custom_button(label, group) first if the handler can fire multiple times (refresh + field-change both trigger).
  • frappe.call ignores the error: callback — that key is silently dropped. Use always: for success-or-failure cleanup.
  • __() inside JS template literals is invisible to bench 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 returns undefined and .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 byTime/intervalCode
ArgumentsNONE (parameterless)Any serializable
Queue control*_long event suffixqueue= parameter

Run bench migrate after editing scheduler_events in hooks.py.

Scheduler event keys

KeyQueueFor
allshort (NEVER >60s)Every tick
hourly / daily / weekly / monthlyshortTasks <5 min
hourly_long / daily_long / …longTasks 5-25 min
cron: { "0 9 * * *": [...] }shortCustom 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_js in hooks.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).

ApproachLoads onUse when
app_include_js = "myapp.bundle.js"Every desk pageHelpers used app-wide (ERPNext pattern)
doctype_js: [helpers, doctype]Only that formHelpers shared by a small set of related doctypes

Best Practices Summary

  1. Custom fields: install.py get_custom_fields(), wired into BOTH after_install and after_migrate.
  2. Hook handlers: in custom/ folder, one file per DocType.
  3. Client scripts: one file per DocType in public/js/, named after the DocType. Register via doctype_js.
  4. Shared client scripts: doctype_js: ["helpers.js", "doctype.js"] (per-doctype). Use app_include_js only for truly cross-cutting helpers.
  5. Shared server code: shared/ folder.
  6. Permissions: frappe.has_permission(..., throw=True) in whitelisted methods. get_list (not get_all) for user-facing data.
  7. 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.
  8. i18n: _() / __() on all user-facing strings; extract from JS template literals first.
  9. Logging: frappe.log_error(), never print().
  10. Errors: frappe.throw() for user errors; try/except + log_error() for system errors.
  11. Queries: frappe.qb over frappe.db.sql(). Parameterize / frappe.db.escape() when raw SQL is unavoidable.
  12. Background jobs: always explicit queue=; enqueue_after_commit=True when the job depends on data the request is writing.
  13. Caching: never mutate a get_cached_doc() result (shared ref); invalidate via doc_events or .clear_cache().
  14. Outbound HTTP: every third-party call (and inbound webhook receipt) goes through frappe.integrations.utils.create_request_loghandle_success/handle_failure. Set reference_doctype + reference_docname so the log links back to the business record.
  15. 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.sql writes.
  16. No throwaway scripts inside apps/: use bench execute or bench console for one-off diagnostics; ad-hoc .py files 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

scripts/

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.

Keep looking

Skills are one crate of 326,970. Ordering is by how many stacks a row turns up in, so the top of any crate is what has actually been picked rather than what has the most stars.