
soumyasethy/lazychat-erpnext
Productivity workflowLazyChat — an AI assistant docked inside your ERPNext desk. Chat with your data, draft reports, stage edits (Apply-gated).
Overview
95 permission-scoped tools · two-phase mutations · composer-critic verification · BYO LLM ↑ 78-second flagship walkthrough — stakeholder ask → tool dispatch → report URL → BYO LLM in one shot. (Want HD? ▶ download the MP4 .) ↑ Still hero — the post-dispatch state of the panel, mid-conversation. "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. Step 1 — Type the stakeholder's request The consultant pastes the verbatim ask into the chat composer. Step 2 — Lazychat dispatches tools, returns data describe_doctype → run_sql_select → inline result table. Real data. No copy-pasting from /api/method .
README
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)
What the parser auto-fills from the curl
| Curl part | Form field |
|---|---|
https://api.anthropic.com/v1/messages |
URL ends /messages → Format = anthropic |
https://*/v1/chat/completions |
URL ends /chat/completions → Format = 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_charttool. Multi-chart dashboards (with cards + charts grouped) ship viaprepare_create_dashboard— you ask, lazychat stages, you click Apply, the dashboard appears at/app/dashboard-view/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_contentPDF/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/ 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 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_pathradio,iframe_base_url,llm_proxy_allowed_hostschip list. - LLM Providers — Anthropic / OpenAI-compatible CRUD with Test Connection + Reveal API Key.
- LLM Models — model picker contents;
is_defaulttoggle (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).
Mutations — prepare_* (40)
Every state-changing tool is a prepare_ that stages the action to a 5-min Redis token, returns a preview, and waits for /commit (typed by the user or fired by the chat-ui’s Apply button). The server re-checks permissions and runs inside frappe.db.savepoint. The LLM is physically incapable of committing on its own — commit_prepared_action is not in the tool registry.
Mutations marked 🔍 critic get a composer-critic LLM verdict appended to the preview ({verdict, severity, mismatches, suggested_revisions}). The chat-ui renders an amber strip when verdict=mismatch. As of Cycle 12 M2: 12 tools wire the critic.
Mutations marked ⚡ low-risk (the LOW_RISK_ACTIONS set in chat-ui) auto-Apply with a 3-second countdown in Edit-auto mode. Everything else requires explicit Apply click.
External MCP clients
The same tool registry that powers the in-Desk panel is reachable from any MCP-compliant client. Three auth modes, three endpoints — pick the one that matches your client.
A — Claude Code (CLI) — simplest
Built-in HTTP MCP transport. ~30 seconds. No tunnel needed if Frappe is on the same machine.
# 1. Set a static Bearer token in site_config
bench --site set-config lazychat_mcp_bearer_token "$(python3 -c "import secrets;print(secrets.token_urlsafe(32))")"
# 2. Wire into Claude Code (user scope = available in any directory)
claude mcp add --scope user --transport http lazychat-erpnext \
http://localhost:8000/api/method/lazychat_erpnext.desk_assistant.mcp.handle_bearer \
--header "Authorization: Bearer "
# 3. Restart Claude Code → all 101 tools available
B — Claude Desktop / MCP Inspector / any token-aware client
Works against either endpoint:
mcp.handlewith Frappe API key+secret (Authorization: token KEY:SECRET) — standard Frappe auth, generate key at/app/user → API Access → Generate Keys.mcp.handle_bearerwith the static Bearer token from step A above (Authorization: Bearer) — simpler if you don’t want to manage Frappe API keys.
{
"mcpServers": {
"lazychat-erpnext": {
"command": "npx",
"args": ["-y", "mcp-remote",
"https://your-bench.example.com/api/method/lazychat_erpnext.desk_assistant.mcp.handle_bearer",
"--header", "Authorization: Bearer YOUR_TOKEN"]
}
}
}
C — claude.ai web Custom Connector (Pro / Team / Enterprise)
OAuth 2.1 with auto-discovery. The MCP Authorization spec (2025-06) is fully wired: /.well-known/oauth-protected-resource (RFC 9728), /.well-known/oauth-authorization-server (RFC 8414), WWW-Authenticate: Bearer resource_metadata="..." on 401. Claude.ai discovers everything automatically.
Setup (one-time, ~3 min):
In Frappe Desk, open /app/oauth-client → + New:
| Field | Value |
|---|---|
| App Name | Claude.ai (lazychat MCP) |
| Scopes | all openid |
| Grant Type | Authorization Code |
| Response Type | Code |
| Redirect URIs (one per line) | https://claude.ai/api/mcp/auth_callback``https://claude.com/api/mcp/auth_callback |
Save. The auto-generated name becomes your client_id; client_secret is shown on the doc (hold-Shift-click the Client Secret field to reveal).
Both callback URLs must be present per Anthropic docs —
claude.aiusers hit one,claude.comusers hit the other.
In claude.ai → Settings → Connectors → Add Custom Connector:
| Field | Value |
|---|---|
| Remote MCP server URL | https://your-public-bench.example.com/api/method/lazychat_erpnext.desk_assistant.mcp.handle |
| OAuth Client ID (Advanced settings) | from the bench command above |
| OAuth Client Secret | from the bench command above |
Click Add. claude.ai redirects you to Frappe → log in → “Confirm Access” → back to claude.ai with all 101 tools registered.
Public-URL caveat: claude.ai’s backend lives in Anthropic’s cloud —
localhostis not reachable. Either deploy Frappe on a public HTTPS URL or use cloudflared (free, no interstitial unlike ngrok-free) to tunnellocalhost:8000.
Permission model — same for all three
The configured user (Bearer’s run-as user, OAuth-token’s user, or API-key’s owner) is what frappe.session.user reads. Every tool re-checks frappe.has_permission(...) on every call. Two-phase mutations (prepare_* → user-confirms → commit_prepared_action) still apply. No god-mode bypass on any path.
Smoke tests
Two layers, both must be green to ship:
# Layer 1 — in-process (95 cases as of cycle 13 M3)
cp lazychat-erpnext/scripts/smoke-test-tools.py \
/apps/lazychat_erpnext/lazychat_erpnext/_smoke.py
cd && bench --site execute lazychat_erpnext._smoke.run
# expected: === 244 pass, 0 fail, 2 skip ===
# Layer 2 — HTTP MCP wire (all 101 tools)
python3 lazychat-erpnext/test/curl_smoke.py
# expected: tools registered: 95, called: 95
The smoke gates exist to catch drift between schema, implementation, and live behavior — please run them before opening a PR. See CONTRIBUTING.md for the full checklist.
Cycle 15 — Apply-path hardening (2026-05-19)
Real-user transcript on claude-haiku-latest: agent stuck looping on a Print Format creation. Four root causes diagnosed and fixed in one cycle.
| Bug | Fix | Where |
|---|---|---|
URL slug 404 on prepare_create_* Apply |
New _doctype_url_slug() helper replaces frappe.scrub() in commit-response link; Print Format now → print-format (hyphen), not print_format (underscore) |
tools.py |
| Agent loops on duplicate-create instead of switching to update | New _exists_redirect_to_update() helper pre-checks 14 typed prepare_create_* wrappers; returns structured prepare_update_doc hint on duplicate |
tools.py |
PREP_TTL_SEC = 300 too short for multi-turn loops |
Bumped to 1800 (30 min); _retrieve_action refactored to distinguish malformed / expired-or-consumed / wrong-user with actionable messages |
tools.py |
| Agent emits ghost CTAs (“Click Apply” with no card attached) | MAX_TURNS 8 → 16; new CTA HONESTY + DUPLICATE PIVOT rules in system prompt (mirrored chat-ui); ghost-CTA detection in agentRunner.ts surfaces an inline warning |
claude_bridge.py + agentRunner.ts |
End-to-end browser replay
Verified claude-haiku-latest with the exact transcript that triggered the original bug. Full evidence in .github/assets/cycle-15/ including the browser-replay result writeup.
Smoke gates: in-process 283 → 293 (+10 new T103a-T103o cases, zero new failures), HTTP-wire 91/91 unchanged, chat-ui vitest 472 → 475 (+3 ghost-CTA detection). Spec: docs/superpowers/specs/2026-05-16-cycle-15-apply-path-hardening-design.md.
Roadmap
| Cycle | Status | Theme |
|---|---|---|
| 2 — Multi-tab agent runtime | ✅ done | Streams, sessions, queue, retry — chat-ui side |
| 3 — Rich rendering + extension primitives | ✅ done | Markdown, syntax highlight, charts, custom components |
| 5 — MCP timeouts + observability + voice + Desk navigation | ✅ done | 60s per-tool timeout, 250 KB result cap, voice input, clickable Desk links |
7 — Compound questions + self-correcting /commit |
✅ done | run_sql_select, run_python_readonly, plan-first prompt, schema-aware error retry |
| 8 — Real Modes + Effort | ✅ done | Ask / Edit-auto / Plan / Auto, low/medium/high/max effort tiers |
| 9 M1-M4 — Cycle 9 (composer-critic + exemplars + PEVR primitives) | ✅ done | Verification briefs, intent_signature exemplar memory, schema graph cache |
| 10 — chat-ui admin panel + allow-all defaults | ✅ done | All ERPNext config moved into the chat-ui |
| 11 M1-M4 — UX hardening | ✅ done | CommitCard, stage-and-redirect prefill, structured SQL gate, live tool progress |
| 12 M1-M2 — Critic coverage expansion | ✅ done | Critic now grades 12 prepare_* tools (helper-extracted) |
| 13 — README rewrite | 🚧 in progress | This document |
| 15 — Apply-path hardening | ✅ done | URL slugs · duplicate-pivot · token TTL · turn budget (this section above) |
| Future — GitHub Actions CI badges, per-tool deep-dive docs, multi-language | 📅 deferred |
Contributing
See CONTRIBUTING.md for branch convention, commit style, smoke-gate requirements, and the new-tool checklist.
Sister repo: lazychat.ai (the chat-ui React app this Frappe app embeds). Any change to the postMessage protocol, host SDK, or extension primitives lands there.
License
MIT © Soumya Sethy.
Install
npx -y mcp-remote https://your-bench.example.com/api/method/lazychat_erpnext.desk_assistant.mcp.handle_bearer --header Authorization: Bearer YOUR_TOKEN