Config Kits

How Praetor's Frappe/ERPNext Config Kits translate DocType specifications into TypeScript code, covering DocType JSON structure, field patterns, and the emitter convergence router.

Config Kits

Config Kits are the subset of Kits that handle ERPNext / Frappe DocType configuration nodes. Where standard Kits (EntityKit, EndpointKit, etc.) generate general-purpose TypeScript for any backend, Config Kits understand the Frappe object model: DocTypes, link fields, child tables, submittable workflows, naming series, and document lifecycle hooks.

The emitter convergence router determines which path a project takes — Config, Code, or both — based on the project's emitter_targets field.


DocType JSON structure

A Frappe DocType is defined by a JSON document. The key fields that the Config Kits read are:

{
  "name": "Sales Invoice",
  "doctype": "DocType",
  "module": "Accounts",
  "is_submittable": 1,
  "istable": 0,
  "autoname": "INV-.YYYY.-.#####",
  "fields": [
    {
      "fieldname": "customer",
      "fieldtype": "Link",
      "options": "Customer",
      "reqd": 1,
      "permlevel": 0
    },
    {
      "fieldname": "customer_name",
      "fieldtype": "Data",
      "fetch_from": "customer.customer_name",
      "fetch_if_empty": 1,
      "permlevel": 0
    },
    {
      "fieldname": "status",
      "fieldtype": "Select",
      "options": "Draft\nSubmitted\nCancelled",
      "depends_on": "eval:doc.docstatus != 0"
    },
    {
      "fieldname": "items",
      "fieldtype": "Table",
      "options": "Sales Invoice Item",
      "reqd": 1
    }
  ],
  "permissions": [
    { "role": "Accounts Manager", "read": 1, "write": 1, "create": 1, "submit": 1, "permlevel": 0 }
  ]
}

The Config Kits parse these JSON documents (or their equivalent spec node metadata) and produce TypeScript code that mirrors the DocType's behavior outside of Frappe.


Key field patterns

depends_on — conditional field visibility

depends_on holds a JavaScript expression evaluated in the Frappe form context. The most common form is:

"depends_on": "eval:doc.some_field == 'some_value'"

The Config Kits translate this into a TypeScript predicate function. At runtime, the generated UI layer calls this predicate to show or hide the field.

// Generated by ConditionKit / DocLifecycleKit
export function fieldIsVisible(
  fieldname: string,
  doc: Record<string, unknown>
): boolean {
  switch (fieldname) {
    case 'status':
      return doc.docstatus !== 0
    // ...
    default:
      return true
  }
}

The mandatory_depends_on variant uses the same expression syntax but controls whether the field is required rather than just visible.


fetch_from — automatic value propagation

fetch_from copies a value from a linked document into the current document when the link field is set. The format is:

"fetch_from": "link_fieldname.source_field"

For example:

{
  "fieldname": "customer_name",
  "fetch_from": "customer.customer_name"
}

This means: when the customer link field is set to a Customer document, copy customer_name from that Customer into the local customer_name field.

LinkFieldKit translates fetch_from entries into a FetchFromMap and an applyFetchFrom helper:

// Generated by LinkFieldKit
export const CUSTOMER_FETCH_FROM_MAP: FetchFromMap = {
  customer_name: 'customer_name',
  customer_group: 'customer_group',
  territory: 'territory',
}

export function applyFetchFrom(
  doc: Record<string, unknown>,
  linkedDoc: Record<string, unknown> | null
): Record<string, unknown> {
  if (!linkedDoc) {
    // Clear all fetch_from fields when the link is removed
    const cleared: Record<string, unknown> = { ...doc }
    for (const localField of Object.keys(CUSTOMER_FETCH_FROM_MAP)) {
      cleared[localField] = null
    }
    return cleared
  }
  const updated: Record<string, unknown> = { ...doc }
  for (const [localField, sourcePath] of Object.entries(CUSTOMER_FETCH_FROM_MAP)) {
    updated[localField] = linkedDoc[sourcePath] ?? null
  }
  return updated
}

fetch_if_empty: 1 is also supported — in this case applyFetchFrom only copies values when the local field is null or empty.


permlevel — field-level access control

permlevel is an integer (0–9) that controls which permission levels can read and write a field. permlevel: 0 is the default (accessible to all roles with read/write access). Higher levels restrict access to roles explicitly granted that level.

The Config Kits translate permlevel into a guard check in the generated service layer:

// Generated by RLSKit when permlevel fields are present
export function checkFieldPermission(
  field: string,
  role: string,
  operation: 'read' | 'write'
): boolean {
  const fieldLevel = FIELD_PERM_LEVELS[field] ?? 0
  const roleLevel = ROLE_PERMISSION_LEVELS[role]?.[operation] ?? 0
  return roleLevel >= fieldLevel
}

The permissions array in the DocType JSON maps roles to permission levels and allowed operations (read, write, create, delete, submit, cancel, amend).


Frappe Kit inventory

KitimplNodeTypeWhat it generates
NamingSeriesKitspec_naming_seriesgenerateId() function + naming_counters migration. Handles four autoname patterns: PREFIX-.YYYY.-.#####, field:fieldname, hash, and Prompt.
ChildTableKitspec_child_tableDrizzle schema with mandatory child columns (parent_id, parent_type, parent_field, idx) + TypeScript types + Hono CRUD router for the parent-scoped REST routes.
LinkFieldKitspec_link_fieldTanStack Query hook for the linked DocType, FetchFromMap, applyFetchFrom utility, and a DynamicLinkRef type for polymorphic links.
SubmittableKitspec_submittableDocstatus enum (0=Draft, 1=Submitted, 2=Cancelled), SubmittableService with submit()/cancel() methods, Hono router for submit/cancel actions, and an Inngest workflow for post-submit side effects.
DocLifecycleKitspec_lifecycleDocLifecycleService class with all 9 typed hook methods: beforeInsert, afterInsert, validate, beforeSave, afterSave, onSubmit, onCancel, onAmend, onTrash.
AmendmentKitspec_amendmentamended_from field addition, amendment chain traversal (getAmendmentChain()), and amendment creation function that copies a cancelled document to a new Draft.
DynamicLinkKitspec_dynamic_linkDynamicLinkResolver registry for polymorphic references where the target DocType is stored in a companion {field}_doctype column.
ReportKitspec_reportReport query function + result type + renderer. Supports Script Report and Query Report variants.
PrintFormatKitspec_print_formatHTML/Jinja-style print format template with entity field placeholders.
EmailTemplateKitspec_email_templateEmail template module with subject and body render functions + variable substitution.
AttachmentKitspec_attachmentFile upload, retrieve, delete, and list-by-entity helpers with size limit enforcement.
ActivityKitspec_activityActivity log service: logActivity() records user, timestamp, action type, and detail payload for audit trail.
TagKitspec_tagTag CRUD helpers + tag-to-entity join table migration.
SharingKitspec_sharingshareDocument(), revokeShare(), checkAccess() with role-based sharing rules.
VersioningKitspec_versioncreateVersion() snapshots the document state; restoreVersion() replaces current fields; diffVersions() computes field-level changes.
WebFormKitspec_web_formPublic-facing web form with GET (render) and POST (submit) Hono handlers, CSRF token validation, and optional reCAPTCHA support.

Emitter convergence router

Every project has an emitter_targets array in its projects row. This array controls which generation paths run:

emitter_targets valueWhat runs
['config']Config emitter only — produces Frappe DocType JSON configurations and ERPNext-compatible TypeScript helpers. No full-stack generation.
['codegen']Code generation only — full-stack TypeScript app via the standard Kit pipeline (EntityKit through WorkflowKit).
['config', 'codegen']Both paths run — Frappe configuration is emitted alongside the full-stack app. Used for ERPNext customization layers that also need a companion web app.

The router is implemented in src/services/convergence/convergence-router.ts:

export async function checkProjectConvergence(
  projectId: string,
  tenantId?: string
): Promise<ProjectConvergenceResult> {
  const rows = await sql<Array<{ emitter_targets: string[] }>>`
    SELECT emitter_targets FROM projects WHERE id = ${projectId}::uuid
  `
  const targets: string[] = rows[0].emitter_targets ?? ['config']

  const results: EmitterConvergenceResult[] = []
  for (const target of targets) {
    if (target === 'config') {
      results.push(await checkConfigConvergence(projectId))
    } else if (target === 'codegen') {
      results.push(await checkCodegenConvergence(projectId, tenantId ?? ''))
    }
  }

  return {
    projectId,
    overallDone: results.every(r => r.isDone),
    byEmitter: results,
  }
}

The EmitterConvergenceResult type tracks completion conditions per target:

interface EmitterConvergenceResult {
  emitterTarget: string
  isDone: boolean
  conditions: Record<string, boolean>
  gaps: string[]
}

For the config target, isDone is true when all spec_entity nodes have a maps_to edge and all required framework modules have been activated. For the codegen target, isDone is true when all impl nodes in the topological order have status IMPLEMENTED.


Config vs Code vs Hybrid decision

The discovery conversation sets emitter_targets based on what the user describes:

  • Config target: User is building on top of ERPNext — they want DocType customizations, custom scripts, and print formats. The generated output is a Frappe app directory.

  • Code target: User wants a standalone application built from their spec. The generated output is a full-stack TypeScript project.

  • Hybrid (both targets): User has an ERPNext installation but also wants a custom web portal or mobile app that talks to it. The Config target produces the ERPNext side; the Code target produces the companion app.

The discovery engine sets this based on keywords in the user's description (ERPNext, Frappe, DocType, "we use ERPNext") and can be overridden manually in the project settings.


Child table anatomy

Child tables are a central pattern in Frappe. A child table is a DocType with istable: 1. It always belongs to a parent and carries four mandatory system columns:

ColumnTypePurpose
parent_idUUID / TEXTForeign key to the parent document
parent_typeTEXTThe parent's DocType name
parent_fieldTEXTThe field on the parent that holds this child table
idxINTEGERSort order within the child table

ChildTableKit enforces this structure unconditionally. Any spec_child_table node that does not provide parentEntity, parentField, entityName, and tableName in its metadata is skipped with a warning — it cannot be generated without all four.

The generated Hono router scopes all routes under the parent path:

GET    /api/{parent-entity}/:parentId/{parentField}
POST   /api/{parent-entity}/:parentId/{parentField}
PUT    /api/{parent-entity}/:parentId/{parentField}/:id
DELETE /api/{parent-entity}/:parentId/{parentField}/:id

This keeps the child table API semantically attached to its parent and makes it impossible to access child rows without knowing the parent context.

Command Palette

Search for a command to run...