Now liveThe Skillselion MCP - thousands of ranked skills, loaded into your agent mid-task. No install.Get it →
openaec-foundation avatar

Frappe Syntax Jinja

  • 68 installs
  • 159 repo stars
  • Updated July 8, 2026
  • openaec-foundation/erpnext_anthropic_claude_development_skill_package

Helps with ai & agent building tasks.

About

frappe-syntax-jinja is a Claude Code skill for ai & agent building. It helps solo builders move faster with AI-assisted coding.

  • frappe-syntax-jinja
  • AI & Agent Building
  • AI-coding skill

Frappe Syntax Jinja by the numbers

  • 68 all-time installs (skills.sh)
  • Ranked #5,828 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/erpnext_anthropic_claude_development_skill_package --skill frappe-syntax-jinja

Add your badge

Show developers this skill is listed on Skillselion. Paste this into your README.

Listed on Skillselion
Installs68
repo stars159
Last updatedJuly 8, 2026
Repositoryopenaec-foundation/erpnext_anthropic_claude_development_skill_package

What it does

Helps with ai & agent building tasks.

Files

SKILL.mdMarkdownGitHub ↗

Frappe Jinja Templates Syntax

Deterministic Jinja reference for Print Formats, Email Templates, Notification Templates, and Portal Pages in Frappe v14/v15/v16.

---

When to Use This Skill

USE when:

  • Creating or modifying Print Formats (Jinja-based)
  • Writing Email Templates with dynamic fields
  • Building Portal Pages (www/*.html) with Python controllers
  • Writing Notification Templates (system/email/SMS)
  • Registering custom Jinja methods or filters via hooks.py

DO NOT USE for:

  • Report Print Formats — they use JavaScript templating ({%= %}), NOT Jinja
  • Client Scripts — see frappe-syntax-clientscripts
  • Server Scripts — see frappe-syntax-serverscripts

---

Decision Tree: Which Template Type?

Need a printable document?
├─ YES → Is it for a Query/Script Report?
│        ├─ YES → Use JS Template ({%= %}), NOT Jinja
│        └─ NO  → Use Jinja Print Format
└─ NO  → Is it for email?
         ├─ YES → Is it triggered by workflow/notification?
         │        ├─ YES → Notification Template (Jinja)
         │        └─ NO  → Email Template (Jinja)
         └─ NO  → Is it a web page?
                  ├─ YES → Portal Page (www/*.html + .py controller)
                  └─ NO  → frappe.render_template() for ad-hoc rendering

---

Quick Reference: Jinja Syntax

SyntaxPurposeExample
{{ }}Output expression{{ doc.name }}
{% %}Control statement{% if doc.status == "Paid" %}
{# #}Comment{# This is a comment #}
{{ _("text") }}Translation{{ _("Invoice") }}
`{{ val \filter }}`Filter

CRITICAL: Jinja vs JS Template Syntax

AspectJinja (Print Formats)JS Template (Report Print Formats)
Output{{ expression }}{%= expression %}
Code block{% statement %}{% js_code %}
LanguagePythonJavaScript
Contextdoc, frappedata, filters

NEVER use Jinja syntax in Report Print Formats. NEVER use `{%= %}` in standard Print Formats.

---

Context Objects by Template Type

Print Formats

ObjectDescription
docThe document being printed (full Document object)
frappeFrappe module (whitelisted methods only)
frappe.utilsUtility functions
_()Translation function
doc.items, doc.taxesChild table accessors (by fieldname)

Email Templates

ObjectDescription
docThe linked document (when triggered from a DocType)
frappeFrappe module (limited)
_()Translation function

Notification Templates

ObjectDescription
docThe document that triggered the notification
frappeFrappe module
_()Translation function

Portal Pages (www/*.html)

ObjectDescription
frappeFrappe module
frappe.session.userCurrent authenticated user
frappe.form_dictQuery parameters from URL
frappe.langCurrent language code
Custom contextSet via get_context(context) in .py controller
Full details: references/context-objects.md

---

Essential Methods (Whitelisted in Jinja)

Formatting: ALWAYS Use for Display

{# ALWAYS use get_formatted() for fields in Print Formats #}
{{ doc.get_formatted("posting_date") }}
{{ doc.get_formatted("grand_total") }}

{# Child table rows — ALWAYS pass parent doc for currency context #}
{% for row in doc.items %}
    {{ row.get_formatted("rate", doc) }}
    {{ row.get_formatted("amount", doc) }}
{% endfor %}

{# General formatting with explicit fieldtype #}
{{ frappe.format(value, {'fieldtype': 'Currency'}) }}
{{ frappe.format_date(doc.posting_date) }}

Document Retrieval

{# Full document — use only when multiple fields needed #}
{% set customer = frappe.get_doc("Customer", doc.customer) %}

{# Single field — ALWAYS prefer over get_doc for one field #}
{% set abbr = frappe.db.get_value("Company", doc.company, "abbr") %}

{# List of records (no permission check) #}
{% set tasks = frappe.get_all("Task",
    filters={"status": "Open"},
    fields=["title", "due_date"],
    order_by="due_date asc",
    page_length=10) %}

{# List with permission check (portal pages) #}
{% set orders = frappe.get_list("Sales Order",
    filters={"customer": doc.customer},
    fields=["name", "grand_total"]) %}

Translation: REQUIRED for All User-Facing Strings

<h1>{{ _("Invoice") }}</h1>
<p>{{ _("Total: {0}").format(doc.get_formatted("grand_total")) }}</p>

System & Session

{{ frappe.get_url() }}
{{ frappe.get_fullname() }}
{{ frappe.get_fullname(doc.owner) }}
{{ frappe.db.get_single_value("System Settings", "time_zone") }}
{% if frappe.session.user != "Guest" %}...{% endif %}
Full method reference: references/methods-reference.md

---

Control Structures

Conditionals

{% if doc.status == "Paid" %}
    <span class="paid">{{ _("Paid") }}</span>
{% elif doc.status == "Overdue" %}
    <span class="overdue">{{ _("Overdue") }}</span>
{% else %}
    <span>{{ doc.status }}</span>
{% endif %}

Loops with Child Tables

{% for item in doc.items %}
<tr>
    <td>{{ loop.index }}</td>
    <td>{{ item.item_name }}</td>
    <td>{{ item.get_formatted("amount", doc) }}</td>
</tr>
{% else %}
<tr><td colspan="3">{{ _("No items") }}</td></tr>
{% endfor %}

Loop Variables

VariableDescription
loop.index1-indexed position
loop.index00-indexed position
loop.firstTrue on first iteration
loop.lastTrue on last iteration
loop.lengthTotal number of items

Variables

{% set total = 0 %}
{% set name = doc.customer_name | default("Unknown") %}

---

Filters

FilterExampleNotes
default`{{ val \default("N/A") }}`
length`{{ items \length }}`
join`{{ names \join(", ") }}`
truncate`{{ text \truncate(100) }}`
escape`{{ input \escape }}`
safe`{{ html \safe }}`
round`{{ num \round(2) }}`
lower / upper`{{ text \upper }}`
Full filter reference: references/filters-reference.md

---

Custom Jinja Methods & Filters via hooks.py

hooks.py Registration

# hooks.py
jenv = {
    "methods": [
        "myapp.jinja.methods"       # Module with callable functions
    ],
    "filters": [
        "myapp.jinja.filters"       # Module with filter functions
    ]
}

Custom Method

# myapp/jinja/methods.py
import frappe

def get_company_logo(company):
    """Returns company logo URL. Called as get_company_logo() in Jinja."""
    return frappe.db.get_value("Company", company, "company_logo") or ""
<img src="{{ get_company_logo(doc.company) }}" alt="Logo">

Custom Filter

# myapp/jinja/filters.py
def nl2br(text):
    """Convert newlines to <br> tags. Used as {{ text | nl2br }}."""
    return (text or "").replace("\n", "<br>")
{{ doc.notes | nl2br | safe }}
Details: references/methods.md

---

Print Format Patterns

Minimal Print Format Template

<style>
    .print-header { background: #f5f5f5; padding: 15px; }
    .item-table { width: 100%; border-collapse: collapse; }
    .item-table th, .item-table td { border: 1px solid #ddd; padding: 8px; }
    .text-right { text-align: right; }
</style>

<div class="print-header">
    <h1>{{ doc.select_print_heading or _("Invoice") }}</h1>
    <p>{{ doc.name }} — {{ doc.get_formatted("posting_date") }}</p>
</div>

<table class="item-table">
    <thead>
        <tr>
            <th>#</th>
            <th>{{ _("Item") }}</th>
            <th class="text-right">{{ _("Qty") }}</th>
            <th class="text-right">{{ _("Amount") }}</th>
        </tr>
    </thead>
    <tbody>
        {% for row in doc.items %}
        <tr>
            <td>{{ loop.index }}</td>
            <td>{{ row.item_name }}</td>
            <td class="text-right">{{ row.qty }}</td>
            <td class="text-right">{{ row.get_formatted("amount", doc) }}</td>
        </tr>
        {% endfor %}
    </tbody>
</table>

<p><strong>{{ _("Grand Total") }}: {{ doc.get_formatted("grand_total") }}</strong></p>

Page Breaks

/* v14/v15 (wkhtmltopdf) */
.page-break { page-break-before: always; }

/* v16 (Chrome PDF) — ALWAYS prefer break-* in v16 */
.page-break { break-before: page; }
Full examples: references/examples.md | Patterns: references/patterns.md

---

V16: Chrome PDF Rendering

Aspectv14/v15 (wkhtmltopdf)v16 (Chrome)
CSS SupportLimited CSS3Full modern CSS
Flexbox/GridPartialFull support
Page breakspage-break-*break-* preferred
FontsSystem fonts onlyWeb fonts supported

V16 Configuration

// site_config.json
{
    "pdf_engine": "chrome",
    "chrome_path": "/usr/bin/chromium"
}

---

Portal Page Pattern

www/projects/index.html

{% extends "templates/web.html" %}
{% block title %}{{ _("Projects") }}{% endblock %}

{% block page_content %}
<h1>{{ _("Projects") }}</h1>
{% for project in projects %}
    <h3>{{ project.title }}</h3>
    <p>{{ project.description | default("") | truncate(150) }}</p>
{% else %}
    <p>{{ _("No projects found.") }}</p>
{% endfor %}
{% endblock %}

www/projects/index.py

import frappe

def get_context(context):
    context.title = "Projects"
    context.no_cache = True
    context.projects = frappe.get_all("Project",
        filters={"is_public": 1},
        fields=["name", "title", "description"],
        order_by="creation desc")
    return context
Full structure: references/structure.md | Templates: references/templates.md

---

Critical Rules

ALWAYS

1. Use _() for ALL user-facing strings 2. Use get_formatted() for currency, date, and numeric fields 3. Use default() filter for optional/nullable fields 4. Pass parent doc to child row get_formatted("field", doc) 5. Use frappe.db.get_value() when you need only one field 6. Keep calculations in Python controllers, not Jinja templates

NEVER

1. Execute database queries inside loops (N+1 problem) 2. Use | safe on user-supplied input (XSS vulnerability) 3. Use Jinja syntax in Report Print Formats (they require JS {%= %}) 4. Use frappe.get_doc() when frappe.db.get_value() suffices 5. Hardcode strings without _() translation wrapper 6. Disable safe_render without security review

Anti-patterns with fixes: references/anti-patterns.md

---

Reference Files

FileContents
references/syntax.mdJinja syntax reference (tags, filters, tests, loops)
references/methods.mdCustom Jinja methods/filters via hooks
references/context-objects.mdAvailable objects per template type
references/filters-reference.mdAll standard and custom Frappe filters
references/methods-reference.mdAll frappe.* methods available in Jinja
references/examples.mdComplete Print Format, Email, Portal examples
references/anti-patterns.mdCommon mistakes and correct alternatives
references/templates.mdTemplate structure patterns
references/patterns.mdConditional rendering, loops, child tables
references/structure.mdFile structure for template types

---

See Also

  • frappe-syntax-hooks — jenv configuration in hooks.py
  • frappe-impl-printformat — Print Format implementation patterns
  • frappe-errors-serverscripts — Server-side error handling

Related skills

This week in AI coding

Five minutes, every Monday - the tools, releases and tactics for developers.

unsubscribe anytime.