feat: Frappe/ERPNext plugin + binary plugin protocol

- frappe/: Standalone Go binary plugin for ERPNext
  - 5 tools: frappe_list, frappe_get, frappe_create, frappe_report, frappe_rpc
  - JSON-over-stdin/stdout protocol
  - Per-request config (multi-tenant support)
  - Read-only safety mode
  - Tested against Xcite Technologies Inc

- README.md: Plugin architecture docs
- .gitignore: Exclude compiled binaries

Plugin protocol: discover → tools list, execute → tool result, health → ping
This commit is contained in:
Abby 2026-03-07 06:33:34 -05:00
parent 320cc9a353
commit 39b9ea4641
5 changed files with 497 additions and 2 deletions

10
.gitignore vendored Normal file
View File

@ -0,0 +1,10 @@
# Compiled plugin binaries
openabby-plugin-*
*/openabby-plugin-*
# Go
*.test
*.prof
# OS
.DS_Store

View File

@ -1,3 +1,71 @@
# openabby-plugins # OpenAbby Plugins
Official plugins for OpenAbby agent platform Official plugin repository for the [OpenAbby](https://git.mosaic.site/abby_hellofinance_ca/openabby) agent platform.
## Architecture
Each plugin is a **standalone binary** that communicates with the OpenAbby core via JSON over stdin/stdout. This means:
- **Zero coupling** — plugins compile independently, no shared Go version required
- **Any language** — Go, Python, Rust, whatever compiles to a binary
- **Hot-loadable** — drop into `plugins/` directory, restart agent
- **Per-agent config** — each agent loads only the plugins it needs
## Plugin Protocol
OpenAbby sends a JSON request on stdin, plugin responds on stdout:
```
→ {"action": "discover"}
← {"status": "ok", "tools": [...]}
→ {"action": "execute", "tool": "frappe_list", "args": {...}, "config": {...}}
← {"status": "ok", "result": "..."}
→ {"action": "health", "config": {...}}
← {"status": "ok", "result": "pong"}
```
## Available Plugins
| Plugin | Description | Status |
|--------|-------------|--------|
| [frappe](./frappe/) | Frappe/ERPNext connector — documents, reports, RPC | ✅ Ready |
## Installation
```bash
# Clone
git clone https://git.mosaic.site/abby_hellofinance_ca/openabby-plugins.git
# Build a plugin
cd openabby-plugins/frappe
go build -o openabby-plugin-frappe .
# Install — copy plugin dir to your agent's plugins path
cp -r frappe/ ~/openabby/plugins/frappe/
```
## Agent Config
```yaml
plugins:
dirs:
- ~/openabby/plugins
frappe:
url: https://erp.example.com
api_key: your_key
api_secret: your_secret
read_only: false # safety: set true for monitoring-only agents
```
## Creating a Plugin
1. Create a directory with `plugin.yaml` manifest
2. Build a binary that reads JSON from stdin, writes JSON to stdout
3. Implement `discover`, `execute`, and optionally `health` actions
4. See [frappe/](./frappe/) for a complete example
## License
Proprietary — Mosaic Cloud Systems Corp

3
frappe/go.mod Normal file
View File

@ -0,0 +1,3 @@
module github.com/mosaiccloud/openabby-plugins/frappe
go 1.23

390
frappe/main.go Normal file
View File

@ -0,0 +1,390 @@
// openabby-plugin-frappe is a standalone plugin binary for the OpenAbby agent platform.
// It communicates with the core via JSON over stdin/stdout using the OpenAbby plugin protocol.
//
// Build: go build -o openabby-plugin-frappe .
// Install: copy binary + plugin.yaml to your plugins/ directory
//
// Config is passed per-request via the "config" field:
// {"url": "https://erp.example.com", "api_key": "...", "api_secret": "...", "read_only": true}
package main
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
)
// --- Plugin Protocol Types ---
type Request struct {
Action string `json:"action"`
Tool string `json:"tool,omitempty"`
Args map[string]interface{} `json:"args,omitempty"`
Config map[string]interface{} `json:"config,omitempty"`
}
type Response struct {
Status string `json:"status"`
Tools []ToolDef `json:"tools,omitempty"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
}
type ToolDef struct {
Name string `json:"name"`
Description string `json:"description"`
Parameters map[string]interface{} `json:"parameters"`
}
// --- Frappe Client ---
type FrappeClient struct {
URL string
APIKey string
APISecret string
ReadOnly bool
HTTP *http.Client
}
func NewClientFromConfig(cfg map[string]interface{}) (*FrappeClient, error) {
c := &FrappeClient{
HTTP: &http.Client{Timeout: 30 * time.Second},
}
// Config from request takes priority, env vars as fallback
if u, ok := cfg["url"].(string); ok {
c.URL = u
} else {
c.URL = os.Getenv("FRAPPE_URL")
}
if k, ok := cfg["api_key"].(string); ok {
c.APIKey = k
} else {
c.APIKey = os.Getenv("FRAPPE_API_KEY")
}
if s, ok := cfg["api_secret"].(string); ok {
c.APISecret = s
} else {
c.APISecret = os.Getenv("FRAPPE_API_SECRET")
}
if ro, ok := cfg["read_only"].(bool); ok {
c.ReadOnly = ro
} else if os.Getenv("FRAPPE_READ_ONLY") == "true" {
c.ReadOnly = true
}
if c.URL == "" || c.APIKey == "" || c.APISecret == "" {
return nil, fmt.Errorf("missing frappe config: url, api_key, and api_secret required")
}
return c, nil
}
func (c *FrappeClient) doRequest(method, path string, body io.Reader) ([]byte, error) {
u := strings.TrimRight(c.URL, "/") + path
req, err := http.NewRequest(method, u, body)
if err != nil {
return nil, err
}
req.Header.Set("Authorization", fmt.Sprintf("token %s:%s", c.APIKey, c.APISecret))
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
return nil, err
}
defer resp.Body.Close()
data, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 400 {
return nil, fmt.Errorf("HTTP %d: %s", resp.StatusCode, string(data))
}
return data, nil
}
// --- Tool Implementations ---
func toolList(c *FrappeClient, args map[string]interface{}) (string, error) {
doctype, _ := args["doctype"].(string)
if doctype == "" {
return "", fmt.Errorf("doctype is required")
}
params := url.Values{}
if f, ok := args["filters"].(string); ok && f != "" {
params.Set("filters", f)
}
if f, ok := args["fields"].(string); ok && f != "" {
fields := strings.Split(f, ",")
for i := range fields {
fields[i] = strings.TrimSpace(fields[i])
}
fj, _ := json.Marshal(fields)
params.Set("fields", string(fj))
}
if o, ok := args["order_by"].(string); ok && o != "" {
params.Set("order_by", o)
}
limit := 20
if l, ok := args["limit"].(float64); ok && l > 0 {
limit = int(l)
if limit > 100 {
limit = 100
}
}
params.Set("limit_page_length", fmt.Sprintf("%d", limit))
path := fmt.Sprintf("/api/resource/%s?%s", url.PathEscape(doctype), params.Encode())
data, err := c.doRequest("GET", path, nil)
if err != nil {
return "", err
}
var result struct{ Data interface{} `json:"data"` }
json.Unmarshal(data, &result)
out, _ := json.MarshalIndent(map[string]interface{}{"doctype": doctype, "data": result.Data}, "", " ")
return string(out), nil
}
func toolGet(c *FrappeClient, args map[string]interface{}) (string, error) {
doctype, _ := args["doctype"].(string)
name, _ := args["name"].(string)
if doctype == "" || name == "" {
return "", fmt.Errorf("doctype and name required")
}
path := fmt.Sprintf("/api/resource/%s/%s", url.PathEscape(doctype), url.PathEscape(name))
data, err := c.doRequest("GET", path, nil)
if err != nil {
return "", err
}
var result struct{ Data interface{} `json:"data"` }
json.Unmarshal(data, &result)
out, _ := json.MarshalIndent(result.Data, "", " ")
return string(out), nil
}
func toolCreate(c *FrappeClient, args map[string]interface{}) (string, error) {
if c.ReadOnly {
return "", fmt.Errorf("site is configured as read-only")
}
doctype, _ := args["doctype"].(string)
dataStr, _ := args["data"].(string)
if doctype == "" || dataStr == "" {
return "", fmt.Errorf("doctype and data required")
}
path := fmt.Sprintf("/api/resource/%s", url.PathEscape(doctype))
data, err := c.doRequest("POST", path, strings.NewReader(dataStr))
if err != nil {
return "", err
}
var result struct{ Data interface{} `json:"data"` }
json.Unmarshal(data, &result)
out, _ := json.MarshalIndent(map[string]interface{}{"status": "created", "data": result.Data}, "", " ")
return string(out), nil
}
func toolReport(c *FrappeClient, args map[string]interface{}) (string, error) {
reportName, _ := args["report_name"].(string)
if reportName == "" {
return "", fmt.Errorf("report_name required")
}
reqArgs := map[string]interface{}{"report_name": reportName}
if f, ok := args["filters"].(string); ok && f != "" {
var filters interface{}
json.Unmarshal([]byte(f), &filters)
reqArgs["filters"] = filters
}
body, _ := json.Marshal(reqArgs)
data, err := c.doRequest("POST", "/api/method/frappe.desk.query_report.run", strings.NewReader(string(body)))
if err != nil {
return "", err
}
var result struct{ Message interface{} `json:"message"` }
json.Unmarshal(data, &result)
out, _ := json.MarshalIndent(result.Message, "", " ")
s := string(out)
if len(s) > 50000 {
s = s[:50000] + "\n... (truncated)"
}
return s, nil
}
func toolRPC(c *FrappeClient, args map[string]interface{}) (string, error) {
method, _ := args["method"].(string)
if method == "" {
return "", fmt.Errorf("method required")
}
var reqBody io.Reader
if a, ok := args["args"].(string); ok && a != "" {
reqBody = strings.NewReader(a)
}
path := fmt.Sprintf("/api/method/%s", method)
data, err := c.doRequest("POST", path, reqBody)
if err != nil {
return "", err
}
var result struct{ Message interface{} `json:"message"` }
json.Unmarshal(data, &result)
out, _ := json.MarshalIndent(result.Message, "", " ")
return string(out), nil
}
// --- Tool Definitions ---
var toolDefs = []ToolDef{
{
Name: "frappe_list",
Description: "List documents from ERPNext/Frappe. Supports any doctype with filters, field selection, ordering.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"doctype": map[string]interface{}{"type": "string", "description": "Doctype (e.g. 'Sales Invoice', 'Customer')"},
"filters": map[string]interface{}{"type": "string", "description": "JSON array of filters"},
"fields": map[string]interface{}{"type": "string", "description": "Comma-separated fields"},
"order_by": map[string]interface{}{"type": "string", "description": "Sort order"},
"limit": map[string]interface{}{"type": "integer", "description": "Max results (default 20, max 100)"},
},
"required": []string{"doctype"},
},
},
{
Name: "frappe_get",
Description: "Get a single document by doctype and name/ID.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"doctype": map[string]interface{}{"type": "string", "description": "Doctype"},
"name": map[string]interface{}{"type": "string", "description": "Document name/ID"},
},
"required": []string{"doctype", "name"},
},
},
{
Name: "frappe_create",
Description: "Create a new document in ERPNext. Blocked in read-only mode.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"doctype": map[string]interface{}{"type": "string", "description": "Doctype to create"},
"data": map[string]interface{}{"type": "string", "description": "JSON object with document fields"},
},
"required": []string{"doctype", "data"},
},
},
{
Name: "frappe_report",
Description: "Run ERPNext reports (General Ledger, AR, AP, Balance Sheet, P&L, etc.).",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"report_name": map[string]interface{}{"type": "string", "description": "Report name"},
"filters": map[string]interface{}{"type": "string", "description": "JSON object with report filters"},
},
"required": []string{"report_name"},
},
},
{
Name: "frappe_rpc",
Description: "Call a Frappe whitelisted RPC method.",
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
"method": map[string]interface{}{"type": "string", "description": "Dotted method path"},
"args": map[string]interface{}{"type": "string", "description": "JSON object with arguments"},
},
"required": []string{"method"},
},
},
}
// --- Main Entry Point ---
func main() {
// Read request from stdin
data, err := io.ReadAll(os.Stdin)
if err != nil {
writeError(fmt.Sprintf("read stdin: %v", err))
return
}
var req Request
if err := json.Unmarshal(data, &req); err != nil {
writeError(fmt.Sprintf("parse request: %v", err))
return
}
switch req.Action {
case "discover":
writeJSON(Response{Status: "ok", Tools: toolDefs})
case "execute":
client, err := NewClientFromConfig(req.Config)
if err != nil {
writeError(err.Error())
return
}
var result string
switch req.Tool {
case "frappe_list":
result, err = toolList(client, req.Args)
case "frappe_get":
result, err = toolGet(client, req.Args)
case "frappe_create":
result, err = toolCreate(client, req.Args)
case "frappe_report":
result, err = toolReport(client, req.Args)
case "frappe_rpc":
result, err = toolRPC(client, req.Args)
default:
writeError(fmt.Sprintf("unknown tool: %s", req.Tool))
return
}
if err != nil {
writeError(err.Error())
return
}
writeJSON(Response{Status: "ok", Result: result})
case "health":
client, err := NewClientFromConfig(req.Config)
if err != nil {
writeError(err.Error())
return
}
if _, err := client.doRequest("POST", "/api/method/frappe.ping", nil); err != nil {
writeError(fmt.Sprintf("ping failed: %v", err))
return
}
writeJSON(Response{Status: "ok", Result: "pong"})
default:
writeError(fmt.Sprintf("unknown action: %s", req.Action))
}
}
func writeJSON(v interface{}) {
enc := json.NewEncoder(os.Stdout)
enc.Encode(v)
}
func writeError(msg string) {
writeJSON(Response{Status: "error", Error: msg})
}

24
frappe/plugin.yaml Normal file
View File

@ -0,0 +1,24 @@
name: frappe
description: "Frappe Framework / ERPNext connector. Provides bookkeeping, invoicing, reporting, and document management tools for any Frappe-based ERP instance."
version: "0.1.0"
author: "Mosaic Cloud Systems Corp"
language: binary
entry_point: openabby-plugin-frappe
timeout: 60
permissions:
network: true
env_vars:
- FRAPPE_URL
- FRAPPE_API_KEY
- FRAPPE_API_SECRET
- FRAPPE_READ_ONLY
triggers:
- frappe
- erpnext
- invoice
- journal entry
- ledger
- accounts receivable
- accounts payable
- balance sheet
- bookkeeping