diff --git a/docs/agent-function-providers.md b/docs/agent-function-providers.md
new file mode 100644
index 00000000..6996a276
--- /dev/null
+++ b/docs/agent-function-providers.md
@@ -0,0 +1,248 @@
+# Agent Function Providers — `huf_agent_functions` Hook
+
+## The Problem
+
+The built-in `huf_tools` hook + `Agent Tool Function` DocType workflow works well for atomic Frappe operations (CRUD, report runs, single `get_value` calls). It falls short when you need a richer **domain function** that ties multiple Frappe calls together — for example:
+
+- `get_customer_full_profile(customer_id)` → fetches the Customer doc, its open Sales Orders, linked Addresses, and Contact details in one shot
+- `summarise_monthly_inventory(warehouse)` → runs two reports, merges results, formats a summary
+- `search_knowledge_by_category(query, category)` → wraps the standard knowledge-search but pre-filters sources by metadata
+
+These functions are awkward to express through the DocType-driven tool system: they don't map to a single DocType, they often need local Python logic, and storing them in the DB adds an unnecessary sync step.
+
+---
+
+## The Pattern: `huf_agent_functions` Hook
+
+Any Frappe app can register **Python callables** directly against specific agents (or all agents) via a new hook:
+
+```python
+# myapp/hooks.py
+
+huf_agent_functions = {
+ # Agent name → list of dotted import paths to callables
+ "Customer Support Agent": [
+ "myapp.agent_functions.customer.get_customer_full_profile",
+ "myapp.agent_functions.customer.escalate_ticket_with_history",
+ ],
+ "Inventory Agent": [
+ "myapp.agent_functions.inventory.get_stock_summary",
+ "myapp.agent_functions.inventory.reorder_suggestion",
+ ],
+ # Use "*" to expose a function to every agent
+ "*": [
+ "myapp.agent_functions.shared.lookup_user_by_email",
+ ],
+}
+```
+
+At runtime, `AgentManager._setup_tools()` imports each path, wraps it with `@function_tool`, and appends it to the agent's tool list — **no database sync required**.
+
+---
+
+## How to Write a Provider Function
+
+### Requirements
+
+| Requirement | Detail |
+|-------------|--------|
+| **Typed signature** | All parameters must have Python type annotations. The SDK uses these to generate the JSON schema shown to the LLM. |
+| **Docstring** | Mandatory. The first line becomes the tool description the LLM sees. Use Google-style args docs for parameter descriptions. |
+| **Return type** | Must be `str` or JSON-serialisable `dict` / `list`. Return `str` for free-form results; `dict` for structured data. |
+| **No side effects on failure** | Return `{"success": False, "error": "..."}` instead of raising — lets the LLM report the issue gracefully. |
+| **Frappe session context** | Functions run inside a normal Frappe request or background job, so `frappe.session.user`, `frappe.db`, etc. are all available. |
+
+### Minimal Example
+
+```python
+# myapp/agent_functions/customer.py
+
+import frappe
+from frappe import client
+
+
+def get_customer_full_profile(customer_id: str) -> dict:
+ """Return a complete customer profile including open orders and addresses.
+
+ Args:
+ customer_id: The Customer document name (e.g. "CUST-00001")
+ """
+ if not frappe.db.exists("Customer", customer_id):
+ return {"success": False, "error": f"Customer {customer_id!r} not found"}
+
+ customer = client.get("Customer", customer_id)
+
+ open_orders = frappe.get_list(
+ "Sales Order",
+ filters={"customer": customer_id, "status": ["in", ["Draft", "To Deliver and Bill"]]},
+ fields=["name", "transaction_date", "grand_total", "status"],
+ limit=20,
+ )
+
+ addresses = frappe.get_list(
+ "Address",
+ filters=[["Dynamic Link", "link_doctype", "=", "Customer"],
+ ["Dynamic Link", "link_name", "=", customer_id]],
+ fields=["name", "address_type", "city", "country"],
+ )
+
+ return {
+ "success": True,
+ "customer": customer,
+ "open_orders": open_orders,
+ "addresses": addresses,
+ }
+```
+
+### Knowledge Wrapper Example
+
+Useful when you want to give an agent a knowledge-search tool that is **pre-scoped** to certain sources or categories:
+
+```python
+# myapp/agent_functions/hr_knowledge.py
+
+import frappe
+
+
+def search_hr_policies(query: str, category: str = None) -> str:
+ """Search the HR knowledge base for policy information.
+
+ Args:
+ query: Natural language search query
+ category: Optional policy category filter (e.g. "Leave", "Payroll")
+ """
+ from huf.ai.knowledge.retriever import KnowledgeRetriever
+
+ retriever = KnowledgeRetriever(agent_name="HR Assistant Agent")
+ results = retriever.search(query, top_k=5, metadata_filter={"category": category} if category else None)
+
+ if not results:
+ return "No relevant HR policy documents found."
+
+ sections = []
+ for r in results:
+ sections.append(f"### {r['title']}\n{r['content']}")
+
+ return "\n\n".join(sections)
+```
+
+---
+
+## Registration in `hooks.py`
+
+```python
+# myapp/hooks.py
+
+app_name = "myapp"
+# ... other hooks ...
+
+huf_agent_functions = {
+ "HR Assistant Agent": [
+ "myapp.agent_functions.hr_knowledge.search_hr_policies",
+ "myapp.agent_functions.hr.get_employee_leave_balance",
+ ],
+ "Customer Support Agent": [
+ "myapp.agent_functions.customer.get_customer_full_profile",
+ "myapp.agent_functions.customer.escalate_ticket_with_history",
+ ],
+ # Shared across all agents
+ "*": [
+ "myapp.agent_functions.shared.get_current_fiscal_year",
+ ],
+}
+```
+
+---
+
+## How It Works Internally
+
+`AgentManager._setup_tools()` (in `huf/ai/agent_integration.py`) has three existing loading steps:
+
+1. **DocType tools** — `Agent Tool Function` records linked to the agent (CRUD, HTTP, custom fn, etc.)
+2. **Knowledge tools** — auto-injected if the agent has linked Knowledge Sources
+3. **App-provided callables** ← *this is the new fourth step*
+
+The loader (to be implemented in `agent_integration.py`):
+
+```python
+# Step 4 — huf_agent_functions hook
+import importlib
+from agents import function_tool
+
+agent_name = self.agent_doc.agent_name
+
+for hook_entry in frappe.get_hooks("huf_agent_functions") or []:
+ # Each app contributes a dict {agent_name_or_"*": [paths]}
+ for target, func_paths in hook_entry.items():
+ if target not in (agent_name, "*"):
+ continue
+ for func_path in func_paths:
+ try:
+ module_path, fn_name = func_path.rsplit(".", 1)
+ module = importlib.import_module(module_path)
+ fn = getattr(module, fn_name)
+ if callable(fn):
+ self.tools.append(function_tool(fn))
+ except Exception as e:
+ frappe.log_error(
+ f"Failed to load agent function {func_path!r}: {e}",
+ "Agent Function Provider Error",
+ )
+```
+
+Key properties:
+- **No sync step** — functions are imported fresh each request (module cache applies normally)
+- **No DocType record** — nothing written to the DB; purely declarative
+- **Fail-safe** — a bad import logs an error but doesn't crash the agent
+- **Ordering** — DocType tools load first; app-provided functions are appended after
+
+---
+
+## `huf_agent_functions` vs `huf_tools` — When to Use Which
+
+| Situation | Use |
+|-----------|-----|
+| Simple CRUD on a known DocType | `huf_tools` → `Agent Tool Function` |
+| HTTP/webhook call | `huf_tools` → `Agent Tool Function` (type = `POST`/`GET`) |
+| Logic spanning 2+ Frappe calls | `huf_agent_functions` |
+| Domain function needing Python control flow | `huf_agent_functions` |
+| Function scoped to **one** specific agent | `huf_agent_functions` (target by name) |
+| Function needed by all agents | `huf_agent_functions` (target `"*"`) |
+| Knowledge search with custom filters | `huf_agent_functions` (wrap `KnowledgeRetriever`) |
+| Needs UI configuration by end users | `huf_tools` → `Agent Tool Function` (has DocType form) |
+
+---
+
+## Security Considerations
+
+- Functions run with the **current `frappe.session.user`** context — normal Frappe permission checks (`frappe.has_permission`, `frappe.get_doc`) still apply.
+- Do **not** bypass permissions inside provider functions (avoid `ignore_permissions=True` unless you perform your own explicit checks).
+- Validate and sanitise all LLM-supplied arguments at the top of the function — treat them the same as user input.
+- If a function can mutate data, consider calling `frappe.has_permission(doctype, "write")` before proceeding.
+- Avoid exposing file system paths or internal configuration through tool return values.
+
+---
+
+## Future: Agent-Level Module Config
+
+A complementary approach (suitable for per-site configuration rather than per-app) is to add a field to the `Agent` DocType:
+
+```
+agent_function_module (Data field)
+# e.g. "myapp.agent_functions.customer"
+```
+
+`AgentManager` would then import that module and auto-discover all public callables (those not starting with `_`), wrapping each as a `function_tool`. This gives site administrators a way to attach custom logic without deploying a full app. The `huf_agent_functions` hook is the recommended approach for app developers; the module field is the escape hatch for site-level customisation.
+
+---
+
+## Summary
+
+| | `huf_tools` hook | `huf_agent_functions` hook | Agent `function_module` field |
+|---|---|---|---|
+| Stored in DB | Yes (synced DocType) | No | No |
+| UI configurable | Yes | No | Yes (field on Agent doc) |
+| Complex Python logic | Limited | Yes | Yes |
+| Agent-targeted | No (all agents) | Yes | Yes (per-agent) |
+| Sync step needed | Yes (`after_migrate`) | No | No |
+| Discovery | Automatic | Automatic | Manual (field must be set) |
diff --git a/docs/jsx-frappe-integration-research.md b/docs/jsx-frappe-integration-research.md
new file mode 100644
index 00000000..0805699c
--- /dev/null
+++ b/docs/jsx-frappe-integration-research.md
@@ -0,0 +1,396 @@
+# JSX Preview + Frappe Integration — Research Notes
+
+**Date**: 2026-02-18
+**Branch**: `claude/jsx-frappe-integration-research-WWeIC`
+**Context**: The HUF chat UI can already generate and render AI-produced JSX dashboards and charts using `react-jsx-parser`. This document explores how far that system can go — specifically around live Frappe API calls, passing custom functions to the LLM, security, and multi-file component support.
+
+---
+
+## 1. Can the Current JSX Preview Allow Frappe API Calls?
+
+### How the preview works today
+
+The JSX preview system (`frontend/src/components/ui/jsx-preview.tsx`) uses **`react-jsx-parser`** (v2.4.1) to parse and render LLM-generated JSX strings at runtime in the browser. It does **not** use Babel standalone, Sucrase, or esbuild — it is a pure string parser that maps JSX tags to pre-registered React component references.
+
+Two things control what the generated JSX can access:
+
+| Prop | Purpose |
+|---|---|
+| `components` | Which React components the JSX can use (Recharts, shadcn/ui, Lucide icons, etc.) |
+| `bindings` | JavaScript values/functions the JSX can reference as variables |
+
+The current `defaultBindings` (lines 320–375 in `jsx-preview.tsx`) expose only:
+
+- `Math`, `JSON`, `Array`, `Object`, `console`
+- Number/date/string formatters
+- Array helpers (`sum`, `avg`, `groupBy`, `sortBy`, etc.)
+- `COLORS` array
+
+**There is no Frappe SDK object, no `call`, no `db`, and no `frappe` in the default bindings.**
+
+### Can it work technically?
+
+**Yes — the plumbing is already there.** Because the JSX preview renders inside the same browser context as the main Huf React app:
+
+- The user's Frappe session cookie is present.
+- The same origin policy is satisfied.
+- `frappe-js-sdk` (`frontend/src/lib/frappe-sdk.ts`) exports `call`, `db`, and `frappe` that are already authenticated and pointed at the correct Frappe instance.
+
+The `JSXPreviewContent` component accepts a `bindings` prop that is merged with `defaultBindings`:
+
+```tsx
+// jsx-preview.tsx:477-490
+
+```
+
+And `JSXPreview` (the outer provider) passes through:
+
+```tsx
+export interface JSXPreviewProps {
+ bindings?: Record; // ← already in the public API
+ ...
+}
+```
+
+So to enable Frappe calls you only need to pass the API objects in:
+
+```tsx
+import { call, db } from '@/lib/frappe-sdk';
+
+
+```
+
+The LLM can then generate JSX that calls:
+```jsx
+// LLM-generated JSX example
+
+```
+
+### Can you prompt the LLM to do this right now?
+
+**Not reliably, without changes.** Here is why:
+
+- The LLM has no way to discover what is in `bindings` unless it is told in the agent's system prompt (the `instructions` field of the `Agent` DocType).
+- If you manually type a prompt like *"Generate a JSX dashboard that fetches Sales Orders from Frappe using `frappeDb.getDocList`"*, the LLM will produce syntactically plausible JSX — but at render time, `frappeDb` will be `undefined` and it will silently fail or throw.
+- `react-jsx-parser` does not propagate async errors well; the `renderError` callback only receives synchronous parse errors, not runtime Promise rejections.
+
+### What is needed to make it work end-to-end
+
+1. **Pass Frappe API objects in `bindings`** when rendering JSX artifacts (`ArtifactRenderer.tsx`, `JSXPreviewRenderer.tsx`, `PreviewViewPage.tsx`).
+2. **Update agent instructions** to document the available binding names and their APIs so the LLM generates correct calls.
+3. **Handle async rendering** — the JSX can call `frappeDb.getDocList()` in an `onClick`, but rendering data fetched on mount requires using a wrapper component that manages `useState`/`useEffect`. `react-jsx-parser` does support JSX expressions, but complex hook-style logic inside JSX strings is fragile. A better pattern is described in section 2 below.
+
+---
+
+## 2. Good Pattern for Passing Custom Functions to the LLM
+
+### The core challenge
+
+The LLM generates a static JSX string. The string is parsed by `react-jsx-parser` and rendered as React elements. The parser cannot run `import` statements or define new hooks — it only evaluates the JSX tree using what is in `bindings`. This means **all dynamic behaviour must come from functions passed as bindings**.
+
+### Recommended pattern: Curated API surface + documented contracts
+
+#### Step 1 — Define a typed helper object
+
+Create a dedicated module (e.g., `frontend/src/lib/jsx-frappe-api.ts`) that wraps the raw Frappe SDK in simple, predictable async functions:
+
+```typescript
+// frontend/src/lib/jsx-frappe-api.ts
+import { call, db } from '@/lib/frappe-sdk';
+
+export const jsxFrappeApi = {
+ // List documents with optional filters
+ getList: (doctype: string, filters?: object, fields?: string[], limit = 20) =>
+ db.getDocList(doctype, { filters, fields, limit }),
+
+ // Get a single document
+ getDoc: (doctype: string, name: string) =>
+ db.getDoc(doctype, name),
+
+ // Call a whitelisted Python method
+ callMethod: (method: string, args?: object) =>
+ call.get(method, args),
+
+ // Simple GET value
+ getValue: (doctype: string, name: string, fieldname: string) =>
+ db.getValue(doctype, name, fieldname),
+};
+```
+
+#### Step 2 — Pass it as a single binding
+
+```tsx
+import { jsxFrappeApi } from '@/lib/jsx-frappe-api';
+
+
+```
+
+#### Step 3 — Document the surface in agent instructions
+
+In the Agent DocType's `instructions` field, add a section that the LLM reads at runtime. Keep it concise:
+
+```
+## JSX Environment — Available APIs
+
+When generating content you have access to a `frappe` binding:
+
+- frappe.getList(doctype, filters?, fields?, limit?) → Promise