know.2nth.ai โ€บ Technology โ€บ tech โ€บ frappe โ€บ framework
tech/frappe ยท Framework ยท Skill Leaf

A framework where every
object is a doctype.

Frappe is the Python full-stack that ERPNext, HRMS, CRM, Helpdesk, LMS, Builder, Drive, Wiki, and the rest of the suite all sit on. Its central idea: declare a "doctype" in JSON, and the framework synthesises the database table, the form, the list view, the REST endpoint, the permissions, and the audit trail โ€” without you writing a controller, a migration, or a route.

Live Python ยท Vue ยท MariaDB/Postgres AGPL v3 REST + RPC v15

A metadata-driven full-stack with batteries included.

The Frappe Framework is the Python/JavaScript engine the Frappe team built โ€” initially to power ERPNext, then opened up so anyone could build their own apps the same way. Think Django plus admin plus a dynamic form engine plus a permissions system plus a job queue plus a REST/RPC API, all glued together by a single concept: the doctype.

A doctype is a JSON file that declares fields, links, child tables, and permissions. The framework reads that metadata at runtime and synthesises everything around it โ€” the database schema, the list view, the form view, the API endpoints, the role checks, the audit log. There is no hand-written ORM mapping, no separate API layer, no separate admin scaffolding. One source of truth, many surfaces generated from it.

Why this matters for an AI-native stack

Because every object in the system is described by metadata, an agent can introspect a Frappe site and discover its data model at runtime. frappe.get_meta("Sales Order") returns the full field list, types, options, and links. That makes Frappe one of the friendliest backends for an AI agent to operate against โ€” it can ask the system what exists before doing anything destructive to it.

The unit of everything.

A doctype is the framework's atom. Once you understand what a doctype declaration produces, every other Frappe concept slots into place.

# customer_visit.json โ€” a complete custom doctype
{
  "doctype": "DocType",
  "name": "Customer Visit",
  "module": "CRM",
  "naming_rule": "Expression",
  "autoname": "format:CV-{YYYY}-{#####}",
  "track_changes": 1,
  "is_submittable": 1,
  "fields": [
    { "fieldname": "customer",    "fieldtype": "Link",      "options": "Customer", "reqd": 1 },
    { "fieldname": "visit_date",  "fieldtype": "Date",      "reqd": 1 },
    { "fieldname": "sales_rep",   "fieldtype": "Link",      "options": "User" },
    { "fieldname": "notes",       "fieldtype": "Long Text" },
    { "fieldname": "items",       "fieldtype": "Table",     "options": "Visit Item" }
  ],
  "permissions": [
    { "role": "Sales User",    "read": 1, "write": 1, "create": 1, "submit": 1 },
    { "role": "Sales Manager", "read": 1, "write": 1, "create": 1, "submit": 1, "cancel": 1 }
  ]
}

What you get from that one file. A MariaDB/Postgres table called tabCustomer Visit with the right columns and indices. A list view at /app/customer-visit. A form view with link picker, date picker, and inline child-table editor for the items grid. Auto-incrementing IDs in the format CV-2026-00001. Submit/cancel semantics with docstatus 0/1/2. Role-checked REST and RPC endpoints. A change history. All of it wired up; none of it written by hand.

Field type What it stores Notes
DataShort textThe default โ€” varchar(140)
Long Text / Text EditorMulti-lineEditor type renders TinyMCE in the form
LinkFK to another doctypeStores the linked doctype's name
Dynamic LinkFK with target chosen at runtimePair with a Link field to a "DocType" doctype
TableChild rowsThe child must itself be a doctype with istable: 1
SelectEnumOptions as newline-separated string
Currency / Float / IntNumericsCurrency respects company default
Date / Datetime / TimeTime valuesISO 8601 in the API
Attach / Attach ImageFile referenceStores the path; file lives in /files
JSONArbitrary JSONUse sparingly โ€” defeats the introspection story

Two APIs you get for free.

Every doctype is a REST resource. Every server-side Python function decorated @frappe.whitelist() is reachable via RPC. There's no separate API layer to build โ€” the framework exposes both surfaces directly from the same code.

# REST โ€” CRUD on any doctype
GET    /api/resource/Customer Visit
GET    /api/resource/Customer Visit/CV-2026-00001
POST   /api/resource/Customer Visit
PUT    /api/resource/Customer Visit/CV-2026-00001
DELETE /api/resource/Customer Visit/CV-2026-00001

# Authentication โ€” API Key + Secret is the production answer
Authorization: token api_key:api_secret

# RPC โ€” call any whitelisted server function
POST /api/method/frappe.client.get_count
{
  "doctype": "Customer Visit",
  "filters": [["docstatus", "=", 1]]
}

Filters are JSON arrays. Each filter is [field, operator, value]. Multiple filters become an array of arrays. The whole thing is server-side โ€” the database does the work, not your script.

# List with filters, fields, ordering, pagination
GET /api/resource/Customer Visit
  ?filters=[["docstatus","=",1],["visit_date",">=","2026-01-01"]]
  &fields=["name","customer","visit_date","sales_rep"]
  &order_by=visit_date desc
  &limit_page_length=100
  &limit_start=0

Whitelisted methods. Any Python function decorated with @frappe.whitelist() becomes callable via /api/method/<dotted.path>. This is the escape hatch when REST CRUD isn't enough โ€” bulk operations, computed reports, document transformations, custom workflows.

# custom_app/api.py
import frappe

@frappe.whitelist()
def upcoming_visits(sales_rep: str, days: int = 7):
    return frappe.get_list(
        "Customer Visit",
        filters={
            "sales_rep": sales_rep,
            "visit_date": ["between", [
                frappe.utils.today(),
                frappe.utils.add_days(frappe.utils.today(), days)
            ]]
        },
        fields=["name", "customer", "visit_date", "notes"],
        order_by="visit_date asc",
    )

# Now reachable as:
# GET /api/method/[email protected]&days=14

Server-side events without writing a controller.

Frappe's hook system is how you attach behaviour to the document lifecycle. Define a function, point a hook at it in hooks.py, and it runs at the right moment in the right transaction.

# custom_app/hooks.py โ€” wire events to handlers
doc_events = {
  "Customer Visit": {
    "validate":    "custom_app.events.validate_visit",
    "on_submit":   "custom_app.events.create_followup_task",
    "on_cancel":   "custom_app.events.notify_manager",
  },
  "Sales Order": {
    "on_submit":   "custom_app.events.fan_out_to_warehouse",
  }
}

scheduler_events = {
  "daily":   ["custom_app.tasks.send_morning_digest"],
  "hourly":  ["custom_app.tasks.sync_external_inventory"],
  "cron": {
    "*/15 * * * *": ["custom_app.tasks.poll_webhooks"]
  }
}
Hook Fires when
validateBefore insert or update โ€” raise to abort
before_save / after_saveAround the save transaction
before_insert / after_insertAround the first save only
on_submitOn the transition from docstatus 0 to 1
on_cancelOn the transition from docstatus 1 to 2
on_trashBefore delete
on_update_after_submitUpdates to a submitted document

Background jobs ride on the same model.

Anything wrapped in frappe.enqueue("custom_app.tasks.heavy_thing", queue="long") drops onto Frappe's RQ-backed queue and runs out-of-band. The framework ships with three default queues โ€” short, default, and long โ€” and a scheduler that triggers cron and interval jobs declared in hooks.py. There's no Celery to set up, no separate broker config โ€” Bench wires Redis for you on install.

The CLI that runs everything.

Bench is the Python CLI that installs, runs, and operates Frappe sites. One bench can host many sites, each with its own database and its own subset of installed apps. It's the answer to "how do I actually deploy this thing."

# Initialise a new bench (a directory containing apps and sites)
$ bench init frappe-bench --frappe-branch version-15
$ cd frappe-bench

# Get an app from a Git repo
$ bench get-app erpnext --branch version-15
$ bench get-app https://github.com/frappe/hrms

# Create a new site (separate DB, separate users, shares the bench's apps)
$ bench new-site site1.local --admin-password admin

# Install apps into a site
$ bench --site site1.local install-app erpnext
$ bench --site site1.local install-app hrms

# Run the development server (web + workers + scheduler)
$ bench start

# Create a custom app of your own
$ bench new-app custom_app
$ bench --site site1.local install-app custom_app

# Migrate after schema changes
$ bench --site site1.local migrate

# Production deploy with supervisor + nginx
$ sudo bench setup production frappe

One bench, many sites. The unit of multitenancy in Frappe is the site, not the bench. A bench is a code directory; a site is a database plus a set of installed apps plus a config file. Run ten sites on one bench, or scale horizontally with Frappe Cloud doing the bench/site management for you.

Things that only bite once you ship.

Six failure modes worth internalising before you put a Frappe site in front of agents or customers.

Docstatus is the source of truth

0 draft, 1 submitted, 2 cancelled. Most queries need ["docstatus","=",1] or you'll surface drafts that aren't really there. This catches everyone exactly once.

5 req/sec default rate limit

Frappe's API key rate limit defaults to about 5 req/sec per user. Scheduled syncs that don't batch will hit it constantly. Either raise the limit in site_config.json or batch your reads.

name is the ID, not the display label

Every document has a name field โ€” that's the primary key, not the human label. Look up by name, not by customer_name or item_name, or you'll silently miss records.

Child tables aren't fetched by default

A list call returns the parent rows only. To pull child-table rows in one shot, use dotted fields like fields=["items.item_code","items.qty"] โ€” or fetch the parent via GET, which returns children embedded.

Custom fields and customisations are per-site

Adding a field via the UI creates a Custom Field document in that site's database. To version-control it, use bench export-fixtures and ship the JSON as part of your custom app โ€” otherwise it'll vanish on a fresh install.

AGPL v3 is the licence

Frappe and the official apps are AGPL-licensed. If you host a Frappe-derived application as a service, you owe users access to the modified source. Read the licence before you build a SaaS on top โ€” most teams are fine with it, but "fine without reading it" is not.

Use it for, skip it for.

Frappe is excellent for a specific shape of work and miserable for the wrong one. The honest call.

Use Frappe when

  • You need an internal tool with forms, lists, role permissions, and an audit trail โ€” and you don't want to build the scaffolding.
  • Your data model has clear nouns (Customer, Order, Visit, Asset) that map cleanly to doctypes.
  • You want REST + RPC + a desk UI from one declaration, not three codebases.
  • You're already on ERPNext or planning to be โ€” Frappe is the substrate, your custom doctypes live alongside the standard ones.
  • You need an open-source backend an AI agent can introspect at runtime.

How this leaf compounds in the tree.

Frappe is the substrate under multiple business-domain leaves โ€” ERPNext today, HRMS, CRM, Helpdesk, and LMS as the tree fills out. Learning the framework once unlocks the entire ecosystem.

The compounding play is this: every Frappe app is built on the same primitives, so an integration pattern that works on ERPNext works on HRMS, CRM, Helpdesk, and any custom app you build yourself. The auth shape is the same. The filter syntax is the same. The hook system is the same. The doctype introspection is the same. An AI agent that knows how to operate against one Frappe site knows how to operate against all of them.

That's why tech/frappe/framework sits in the technology branch, not the business branch โ€” it's not an ERP, it's the engine. The ERP (ERPNext) is one app on top of it. Understand the engine and the apps stop being thirteen different things to learn; they become thirteen instances of one pattern.

What framework pulls on, and what pulls on it.

Frappe is the substrate for an entire branch of business apps. Everything in tech/frappe/* requires it; everything Frappe-built in biz/* ultimately depends on it.

Go deeper.

The official documentation is unusually good. The source repo is the most trustworthy reference for hook names and field types โ€” read it when the docs are quiet.