$ cat node-template.py

O

Odoo RPC

// Call an Odoo 19 model method (search_read, read, search, search_count, create, write, unlink, or a custom method) via the Odoo JSON-2 API. Uses the Odoo connection from account settings, or ODOO_URL / ODOO_API_KEY (and optional ODOO_DATABASE) as workspace secrets.

Process
Integration
#odoo#rpc#integration
template.py
1"""2Odoo RPC34Call any Odoo 19 model method over the Odoo JSON-2 API.56Credentials are prepared by workspace-service before this container starts —7from the workspace-token owner's saved Odoo connection, or from workspace8secrets of the same names — and reached here through `gais.connectors.odoo`.9This template does NOT read them itself: no `os`, no `requests`, no hand-rolled10auth header. That is why it now passes `pnpm node:test` like every other11template, where it used to carry a documented deny-list exemption.12"""1314import sys, json, traceback1516from gais import connectors17from gais._errors import ConfigError, RemoteError1819# Rows a search returns when the caller does not say. Bounded because this20# node's output is usually read by an LLM, and "all records" is a payload with21# no upper bound. Override per call with `params: {"limit": N}`; `0` means no22# limit, matching Odoo.23DEFAULT_SEARCH_LIMIT = 100242526def parse_json(v, default):27    if v in (None, ""): return default28    if isinstance(v, (dict, list)): return v29    if isinstance(v, str):30        s = v.strip()31        return json.loads(s) if s else default32    return default333435def main():36    try:37        inputs = json.loads(sys.stdin.read()).get("inputs", {})38        model = (inputs.get("model") or "").strip()39        method = (inputs.get("method") or "search_read").strip()40        if method == "__custom__":41            method = (inputs.get("customMethod") or "").strip()42            if not method:43                raise ValueError("Custom method selected but 'Custom method' is empty. "44                                 "Enter the Odoo method name (e.g. 'name_search').")45        if not model:  raise ValueError("model is required (e.g. 'res.partner')")46        if not method: raise ValueError("method is required (e.g. 'search_read')")47        ids = parse_json(inputs.get("ids"), [])48        params = parse_json(inputs.get("params"), {})49        context = parse_json(inputs.get("context"), {})50        timeout = int(inputs.get("timeout") or 60)51        if not isinstance(params, dict):52            raise ValueError("params must be a JSON object of method keyword args")53        body = dict(params)54        # search-family methods require a `domain`; empty list = "all records"55        if method in ("search", "search_read", "search_count") and "domain" not in body:56            body["domain"] = []57        # ...and an unbounded row count is not a sane default for a node whose58        # result is read back by an LLM. `domain: []` above means "all records",59        # so `search_read` on res.partner returned every row with every field:60        # the caller's context window blew and the whole chat turn died with no61        # answer and no actionable error.62        #63        # Injected the same way `domain` is, and overridable the same way — pass64        # `params: {"limit": 500}` for more, or `{"limit": 0}` for genuinely all65        # records (Odoo treats 0 as no limit). Only search/search_read take one;66        # search_count returns a scalar and a limit there would be meaningless.67        if method in ("search", "search_read") and "limit" not in body:68            body["limit"] = DEFAULT_SEARCH_LIMIT69        if ids: body["ids"] = ids70        if context: body["context"] = context7172        data = connectors.odoo.call(model, method, timeout=timeout, **body)7374        output = {"data": data,75                  "record_count": len(data) if isinstance(data, list) else None,76                  "statusCode": 200, "success": True}77        print(json.dumps(output, indent=2, ensure_ascii=False, default=str))78    except (ConfigError, RemoteError) as e:79        # Surfaced as ValueError-shaped output so the executor's error mapping80        # and the existing "[RUNTIME_ERROR] …" chat card are unchanged. The SDK81        # already phrases both cases for a user: ConfigError names the account82        # settings route, RemoteError carries Odoo's own status and body (a 40483        # "No database is selected" means ODOO_DATABASE is wrong, and that text84        # is the whole diagnosis).85        print(json.dumps({"error": str(e), "errorType": type(e).__name__,86                          "traceback": traceback.format_exc()}), file=sys.stderr)87        sys.exit(1)88    except Exception as e:89        print(json.dumps({"error": str(e), "errorType": type(e).__name__,90                          "traceback": traceback.format_exc()}), file=sys.stderr)91        sys.exit(1)929394if __name__ == "__main__":95    main()

$ git log --oneline

v2.0.0
HEAD
2026-08-18

BREAKING: the HTTP call now goes through gais.connectors.odoo instead of os.getenv + requests, so this version REQUIRES an executor image carrying gais.connectors — reseeding the template alone is not enough. Credentials may now come from the user's saved Odoo connection as well as workspace secrets, and the not-connected error names both.

v1.2.02026-08-07

Migrated to multi-file node-templates format; dropped the requests pip-install fallback (requests ships in the executor image).

v1.1.22026-08-05

Migrated to multi-file node-templates format; dropped the requests pip-install fallback (requests ships in the executor image).