lazychat-erpnext

Talk to ERPNext like a senior consultant.

95 permission-scoped tools · two-phase mutations · composer-critic verification · BYO LLM

ERPNext 15 Python 3.11+ Tools 95 License MIT GitHub stars

From stakeholder request to delivered report — in minutes

"Hey, I need to verify which December purchase invoices have payment entries against them. Can we get a report by EOD?"

That ask used to mean opening 3 Frappe doctypes, writing a custom Query Report, debugging joins, fixing field names, and probably a meeting. With lazychat, the consultant types the stakeholder's words verbatim into the chat panel.


Bring your own LLM in 30 seconds

Self-hosted LM Studio? Anthropic? NVIDIA NIM? OpenRouter? Together? Groq? You don't fill out a form. You paste a curl snippet from the provider's own docs — and lazychat parses it into endpoint, model, auth, headers, streaming flag, even provider-specific payload extras. The API key stays in the browser (browser-LLM path); no server-side credential storage, no shared org-key risk.

The 4-step flow

Tested provider snippets (paste these verbatim)

curl -X POST "https://integrate.api.nvidia.com/v1/chat/completions" \
  -H "Authorization: Bearer nvapi-..." \
  -H "Accept: text/event-stream" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "moonshotai/kimi-k2.6",
    "messages": [{"role":"user","content":""}],
    "max_tokens": 16384,
    "temperature": 1.0,
    "stream": true,
    "chat_template_kwargs": {"thinking": true}
  }'

Auto-fills label Kimi K2 (NVIDIA), endpoint, format=openai, streaming=✓, bearer token, max_tokens, temperature, plus chat_template_kwargs lands in the Extra payload field verbatim.

curl https://api.anthropic.com/v1/messages \
  -H "x-api-key: sk-ant-..." \
  -H "anthropic-version: 2023-06-01" \
  -H "Content-Type: application/json" \
  -d '{
    "model": "claude-haiku-4-5",
    "max_tokens": 4096,
    "stream": true,
    "messages": [{"role":"user","content":""}]
  }'

Auth type → API Key, header name → x-api-key, format → anthropic, response parser → anthropic-sse. The custom anthropic-version header lands in Extra headers.

curl https://api.openai.com/v1/chat/completions \
  -H "Authorization: Bearer sk-..." \
  -H "Content-Type: application/json" \
  -d '{"model":"gpt-5","messages":[{"role":"user","content":""}],"stream":true}'
curl https://openrouter.ai/api/v1/chat/completions \
  -H "Authorization: Bearer sk-or-..." \
  -H "HTTP-Referer: https://your-site.example.com" \
  -H "X-Title: Lazychat" \
  -d '{"model":"meta-llama/llama-3.3-70b-instruct","stream":true}'

The HTTP-Referer and X-Title extras are required by OpenRouter and land in Extra headers.

curl http://localhost:1234/v1/chat/completions \
  -H "Content-Type: application/json" \
  -d '{"model":"meta-llama-3-8b-instruct","stream":true}'

Auth type → none, format → openai, streaming → ✓. Browser-LLM path makes localhost work because requests originate from the user's browser, not the Frappe server.

What the parser auto-fills from the curl

Curl part Form field
https://api.anthropic.com/v1/messages URL ends /messagesFormat = anthropic
https://*/v1/chat/completions URL ends /chat/completionsFormat = openai
-H "Authorization: Bearer XYZ" Auth type = Bearer; Token = XYZ
-H "x-api-key: XYZ" Auth type = API Key; Header name = x-api-key; Token = XYZ
-H "Accept: text/event-stream" Streaming = ✓
Any other -H "X: Y" (e.g. anthropic-version, HTTP-Referer) Extra headers (kv list)
Payload "model":"X" Model = X
Payload "max_tokens":N / "temperature":N / "top_p":N Defaults block
Payload "stream":true Streaming = ✓
Any payload key not in the above Extra payload (preserved as JSON, merged into request body verbatim)
Hostname (api.anthropic.com) Label auto-generated as Anthropic Direct-style title

Test connection sends one messages: [{"role":"user","content":""}] request to the configured endpoint with the parsed auth + extras. Returns the HTTP status + first 800 chars of the response body in a green/red panel — instant proof the credential and endpoint work before you start using the model in real chats.

Same auto-fill works for requests.post(...) snippets too — paste a Python snippet from a provider's quickstart and it parses identically.


Charts and dashboards rendered in-chat

Tools like make_chart, dashboard_chart_data, and number_card_value don't return raw JSON to the user — the chat-ui's ChartBlock renders them inline as proper Vega charts and KPI cards, in the panel, alongside the conversation. The consultant asks "chart of paid PIs by month" and the chart appears as the answer. No tab-switching, no exporting to a BI tool, no Frappe Dashboard form-filling.

Bar / line / area / donut / scatter all map to Frappe Charts via the make_chart tool. Multi-chart dashboards (with cards + charts grouped) ship via prepare_create_dashboard — you ask, lazychat stages, you click Apply, the dashboard appears at /app/dashboard-view/<name> ready to bookmark.


How tool results render in the chat

Tool calls don't return raw JSON dumps to the user. Lazychat's response renderer formats the LLM's reply using the full markdown stack — GitHub-flavored tables, syntax-highlighted code with shiki, mermaid diagrams, KaTeX math, blockquote callouts, structured headings — so a "tool result" reads like an answer, not a debug log.

Plus: KaTeX math (for reconciliation formulas), JSON tree expanders for raw tool outputs, automatic link-card previews for any URL the LLM cites, attachment renders for extract_file_content PDF/DOCX/CSV outputs, and a streaming markdown parser that keeps tables and code blocks structurally valid mid-token. The tool gives the data; the renderer makes it readable.


Install

Frappe Cloud: install from the marketplace in one click.

Self-hosted bench: see INSTALL.md — the standard bench install (the pre-built chat-ui ships with the app, so no build step needed), building from source, and HMR dev are all documented there.

After install, the app seeds disabled-by-default LLM Provider rows (OpenAI, Anthropic, NVIDIA, OpenRouter, Vercel AI, LM Studio) — enable one and add your API key from /app/llm-provider, or skip server-side config entirely and let users bring their own keys via the chat-ui's model picker (browser-LLM path).


Why lazychat-erpnext

  • Built for ERPNext, not bolted on. Every tool runs as frappe.session.user — Frappe permissions, role checks, workflow guards, and the audit trail apply automatically. No god-mode bypass; no separate auth surface.
  • Mutations require explicit Apply. The LLM stages every write to a Redis token; you click Apply (or the 3-second auto-Apply countdown for low-risk actions) to commit inside frappe.db.savepoint. A 30-second composer-critic LLM second-opinion shows up as an amber strip when it disagrees with the staged action.
  • Bring any model. Anthropic Claude, OpenAI, NVIDIA NIM, OpenRouter, Vercel AI Gateway, Together, Groq, LM Studio. Same 95-tool registry; the API key never has to leave the browser if you don't want it to.

What you get

Built-in: schema-aware SQL retry on Unknown column, two chat paths (server-orchestrated or browser-LLM), real-execution probe before staging Query Reports, structured form prefill for HTTP-414-defying URLs, knowledge bases with reindex, scheduled jobs, dashboards, custom fields, client scripts, and an admin panel that moves all configuration into the chat-ui itself.

Token + cost tracking NEW

Every LLM turn writes a row to the Lazychat Usage Log doctype — model · provider · input/output tokens · cost estimate (USD) · session id · path (backend / browser). Cost auto-computed from LLM Model.input_price_per_mtok / output_price_per_mtok first, falls back to a built-in rate table for common Claude / GPT models, zero when both miss.

Open the chat-ui's Command Palette → Account & usage… to see:

  • 4-tile totals (calls / input / output / cost)
  • By-model rollup — sorted by total tokens, shows provider + cost per model
  • Daily breakdown for the last 14 days
  • Range picker (Today / 7d / 30d / 90d)
  • System Manager users see ALL users' usage; everyone else sees their own

No setup required — works for Claude / OpenAI / NVIDIA / OpenRouter / Vercel / Anthropic / Together / Groq / LM Studio, regardless of whether you're on the backend-LLM or browser-LLM path. Set per-model rates on /app/llm-model/<your-model> if you want exact billing.


Architecture

The Frappe app ships a 280-line vanilla-JS shim (public/js/lazychat_panel.bundle.js) loaded via app_include_js on every Desk page. The shim mounts the chat-ui (a React app, sibling repo lazychat.ai, bundled into public/lazychat_dist/) as a same-origin iframe, sets up the postMessage protocol, and intercepts /commit <token> slash commands to call the server.

Tool dispatch goes through one of two paths, both backed by the same 95-tool registry:

Path LLM lives Tool dispatch Best when
Backend-LLM Frappe (LLM Provider doctype) run_agentic_turn calls execute_tool in-process Org deployments, shared keys, central audit
Browser-LLM chat-ui (BYO key in localStorage) chat-ui calls mcp.handle JSONRPC per tool_use Single-user / power-user; key never touches server

Default chat_path = auto: chat-ui inspects the active model — built-in → backend; custom-added → browser. Both paths run with frappe.session.user's permissions, both write to Claude Conversation, both share tools.py. Zero drift, one implementation.


Configuration

Primary admin surface (in-app): Command Palette → Server config → 3 tabs:

  • General — gates (allow_email, allow_dangerous_tools, cycle9_enabled …), chat_path radio, iframe_base_url, llm_proxy_allowed_hosts chip list.
  • LLM Providers — Anthropic / OpenAI-compatible CRUD with Test Connection + Reveal API Key.
  • LLM Models — model picker contents; is_default toggle (single-default invariant enforced).

Or edit the Lazychat Settings doctype directly. All defaults are allow-on for self-hosted single-org installs; defense-in-depth is preserved (System Manager role check at tool dispatch + /commit confirmation per call).

Field Default What it does
enabled true Master switch — mount the panel at all
iframe_base_url /assets/lazychat_erpnext/lazychat_dist/index.html Where chat-ui loads from. Override for HMR (http://127.0.0.1:5173) or remote chat-ui
iframe_query_params ?frame=sidebar Appended to base_url
chat_path auto auto / browser / backend — see Architecture above
mcp_endpoint /api/method/lazychat_erpnext.desk_assistant.mcp.handle Read-only; browser-LLM path uses this
legacy_widget_enabled false Mount the OLD vanilla-JS widget INSTEAD of the iframe (mutually exclusive)
allow_email true Enable prepare_send_email
allow_dangerous_tools true Enable prepare_run_sql + prepare_run_python (still gated by System Manager role + /commit)
allow_email_setup true Enable prepare_create_email_account
cycle9_enabled true Enable composer-critic verdict, verification briefs, exemplar memory
bulk_update_max_rows 500 Ceiling for prepare_bulk_update blast radius
llm_proxy_allowed_hosts [anthropic, openai, nvidia, openrouter, vercel, …] Browser-LLM proxy allowlist

site_config.json overrides win over the doctype values (backward compat). boot.py:get_lazychat_settings() is the single resolver — use this helper anywhere on the server side.


Tool catalog — all 101

Grouped into 12 categories. Every tool runs scoped to frappe.session.user's permissions; mutations stage to a Redis token and require explicit Apply. The Try it line is verbatim text you can paste into the chat panel right now (assuming you have ecommerce-shaped data — the prompts work great against the canonical ERPNext demo dataset).

get_list

What — Fetch rows from any doctype with optional filters, fields, limit. The default workhorse for "show me X."
Why — One round-trip beats N get_doc calls when you need many headers at once.
Try it"List the 20 most recent Sales Invoices for ACME with grand_total and status."

get_doc

What — Fetch one document by name; child tables auto-truncated to 25 rows with a _note summarizing the trim.
Why — Full doc context (header + line items) without overflowing the LLM context window.
Try it"Open Sales Invoice SI-2026-00123 and show me the items table."

get_value

What — Read one or many scalar fields from a single doc — far cheaper than a full get_doc.
Why — Use it when you only need one field (e.g., the grand_total) and don't want to ship the whole row over the wire.
Try it"What's the outstanding amount on Sales Invoice SI-2026-00123?"

count_doc

WhatCOUNT(*) over any doctype with optional filters; the canonical "how many" tool.
Whylen(rows from get_list) lies above 20 (default get_list cap is 20). Always count_doc first when the question is "how many."
Try it"How many Purchase Invoices were posted in March 2026?"

describe_doctype

What — Returns the doctype's field list + types + Link targets + child tables. Per-conversation Redis cache (30-min TTL).
Why — Schema-first SQL: the LLM verifies columns exist before writing a JOIN. Catches Unknown column 'pr.purchase_order' at compose time.
Try it"Describe Purchase Receipt — what fields link it to a Purchase Invoice?"

get_current_context

What — Reads cur_frm / cur_list from the panel — the doc you're looking at right now (name, doctype, workflow_state, dirty flag).
Why — "Summarize this" / "what's wrong with this doc" works without you typing the doc name.
Try it"Summarize this Sales Order." (while standing on a SO form)

get_doctype_links

What — Returns every doctype that links TO and FROM the given doctype (Link + Dynamic Link fields, child tables).
Why — Discovers reverse relationships the model would otherwise have to guess at.
Try it"Which doctypes reference Customer?"

search_doctype

What — Substring search across doctype NAMES, returning module + is_submittable + issingle for each match.
Why — When the user says "the GST stuff" you probe search_doctype('GST') instead of guessing names.
Try it"Find any doctype with 'shipment' in the name."

search_global

What — Full-text Frappe global search across DOC content (subject, customer name, item description, etc.).
Why — "Find that invoice we discussed last week with WidgetCo" — searches values, not doctype names.
Try it"Find every document mentioning 'damaged in transit'."

search_link

What — Link-field autocomplete: given a doctype + partial text, returns rows that match the doctype's autocomplete logic.
Why — Faster than get_list for "find me the Customer matching ACM" because it uses the same indexed search Frappe's link picker uses.
Try it"Match 'ACME' against the Customer doctype — return name + customer_group."

get_doctype_relationships

What — Wraps describe_doctype with curated row-link hints for the most-mismatched ERPNext pairs (PR↔PI, SO↔SI, SLE↔PR, …).
Why — Surfaces canonical join patterns ("don't join on item_code alone") that the LLM would otherwise rediscover the hard way.
Try it"How are Purchase Receipts linked to Purchase Invoices at item-row level?"

get_form_prefill_capabilities

What — Returns the live whitelist of parent + item-row fields that prepare_form_prefill can populate on a target doctype.
Why — Tells the model what's actually safe to encode into a ?_lz_token=... URL — no guessing.
Try it"What fields can I prefill on a new Purchase Invoice form?"

find_join_path NEW

What — Walks Frappe's DocField metadata graph (Link / Table fields) plus a curated hint table to return the canonical SQL join chain between any two doctypes — including Dynamic-Link routes (PE Reference, GL Entry voucher, etc.) and reverse-Link discovery (Customer → Sales Invoice in 1 hop).
Why — The LLM no longer has to memorize join shapes per (from, to) pair. 99% of 1056 ordered business-doctype pairs covered at max_hops=3. Each hop carries the exact on_template clause and inline warnings (e.g. "ALWAYS include reference_doctype — reference_name is shared across PI/SI/JE"). Eliminates the most common SQL bug class in cross-doctype reports.
Try it"List December purchase invoices with the payment entries that paid them." — agent calls find_join_path("Purchase Invoice", "Payment Entry") first, gets pi → Payment Entry Reference (curated_reverse) → Payment Entry, composes the SQL using the returned ON clause verbatim.

aggregate

WhatGROUP BY with COUNT / SUM / AVG / MIN / MAX over any doctype + filters.
Why — One round-trip beats N get_list calls when you want totals by category, status, region, customer.
Try it"Group all paid Sales Invoices by customer and sum grand_total — top 10."

dashboard_chart_data

What — Resolves a Dashboard Chart and returns the timeseries / pie data it would render.
Why — Lets the agent quote real chart numbers instead of inventing them — and lets you ask follow-ups against the same data.
Try it"Pull data for the Sales Trend chart, give me the last 6 months."

number_card_value

What — Resolves a Number Card and returns its current numeric value (often a single COUNT or SUM).
Why — KPI questions answer in one tool call instead of ten.
Try it"What's the current Outstanding Receivables number card showing?"

list_user_dashboards

What — Returns dashboards visible to the calling user, with their chart + card composition.
Why — "What dashboards do I have access to?" used to require clicking around /app/dashboard.
Try it"What dashboards can I see right now?"

get_sales_summary

What — Pre-canned ERPNext sales summary (period, currency, customer split, item-group split).
Why — Faster than building the same thing from aggregate for the most common executive-dashboard ask.
Try it"Give me the sales summary for last quarter."

get_pending_approvals

What — Workflow-aware: returns docs awaiting the calling user's approval action.
Why — "What's blocked on me?" is the #1 morning question for ERPNext approvers.
Try it"What's pending my approval right now?"

list_my_jobs

What — Returns RQ background jobs queued/running by the calling user (downloads, exports, scheduled tasks).
Why — Lets the chat answer "where's my CSV export?" without you opening the RQ dashboard.
Try it"Show me my queued background jobs."

get_open_invoices

What — Filtered shortcut: Sales Invoice + Purchase Invoice with outstanding_amount > 0, sorted by aging.
Why — AR/AP questions answer in one call; no need to teach the model the filter shape.
Try it"Show me open invoices over 60 days, both AR and AP."

list_reports

What — Returns user-visible Reports with name, report_type, ref_doctype, is_standard.
Why — Discovers what's already built before the LLM offers to build a new one (and avoids name collisions on prepare_create_report).
Try it"What reports exist for Sales Invoice?"

report_requirements

What — Returns the filters (with type, mandatory flag, default) a Report needs at run time.
Why — Lets the LLM ask the user only for the fields the report actually requires before calling run_report.
Try it"What filters does the 'Accounts Receivable' report need?"

run_report

What — Executes a Report (Query / Script / Report Builder) with the given filters and returns rows + columns.
Why — Skip the click-through; the agent can quote the report values directly.
Try it"Run 'Accounts Receivable' for company 'My Company' as of 2026-05-01."

list_workflow_actions

What — Returns the transitions allowed from a doc's current workflow state for the calling user.
Why — "What can I do here?" — the LLM uses this before suggesting a wrong button.
Try it"What workflow actions are available on Purchase Invoice PI-26-001?"

prepare_workflow_action 🛡️

What — Stages a workflow transition (Approve / Reject / Submit / etc.). Two-phase mutation — requires Apply.
Why — Audit-safe approve-from-chat with permission re-check at commit time. Critic-graded for high-stakes flows.
Try it"Approve Purchase Invoice PI-26-001." (then click Apply)

`get_stock_bal