Install
$ agentstack add skill-txampa-claude-skill-odoo-integration-claude-skill-odoo-integration Open-source listing — not yet scanned by AgentStack. Follow the source repository for install instructions.
Security review
⚠ Flagged1 finding(s); flagged for manual review. · v0.1.0 How review works →
- • Prompt-injection patterns
- • Secret / credential exfiltration
- • Dangerous shell & filesystem operations
- • Untrusted network calls
- • Known-malicious package signatures
- high Dangerous shell/eval execution.
What it can access
- ✓ Network access No
- ✓ Filesystem access No
- ✓ Shell / process execution No
- ● Environment & secrets Used
- ● Dynamic code execution Used
From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.
About
Odoo Integration Skill
You are an expert in Odoo XML-RPC integrations using Node.js. You know the quirks of Odoo v8 through v17, the exact XML-RPC endpoint structure, key model names and their states, and how to sync data reliably to external systems.
Core Architecture
Every Odoo integration follows this structure:
odoo-sync/
├── index.js # Entry point + cron scheduler
├── config/
│ └── odoo.js # OdooClient class (XML-RPC)
├── utils/
│ ├── sync.js # SyncManager (business logic)
│ └── sheets.js # Output client (Sheets, DB, etc.)
├── test-odoo.js # Test raw Odoo connection
├── test-sync.js # Test full sync pipeline
├── debug-states.js # Inspect model states in prod
├── .env # Credentials (never commit)
├── Procfile # Railway: "web: node index.js"
└── package.json
OdooClient — XML-RPC Base Class
Always use this class as the foundation. Never call xmlrpc directly from business logic.
// config/odoo.js
const xmlrpc = require('xmlrpc');
class OdooClient {
constructor(config = {}) {
this.host = config.host || process.env.ODOO_HOST;
this.database = config.database || process.env.ODOO_DATABASE;
this.login = config.login || process.env.ODOO_LOGIN;
this.password = config.password || process.env.ODOO_PASSWORD;
this.port = config.port || 443;
this.uid = null;
this.authenticated = false;
const clientConfig = { host: this.host, port: this.port };
this.commonClient = xmlrpc.createSecureClient({
...clientConfig, path: '/xmlrpc/2/common'
});
this.objectClient = xmlrpc.createSecureClient({
...clientConfig, path: '/xmlrpc/2/object'
});
}
async authenticate() {
return new Promise((resolve, reject) => {
this.commonClient.methodCall(
'authenticate',
[this.database, this.login, this.password, {}],
(error, uid) => {
if (error || !uid) {
reject(error || new Error('Authentication failed — check credentials and database name'));
return;
}
this.uid = uid;
this.authenticated = true;
resolve(uid);
}
);
});
}
async searchRead(model, domain = [], fields = [], options = {}) {
if (!this.authenticated) throw new Error('Call authenticate() first');
return new Promise((resolve, reject) => {
this.objectClient.methodCall('execute_kw', [
this.database, this.uid, this.password,
model, 'search_read', [domain],
{
fields: fields.length > 0 ? fields : ['id', 'name'],
limit: options.limit || 100,
order: options.order || 'id desc',
offset: options.offset || 0,
}
], (error, result) => {
if (error) reject(error);
else resolve(result || []);
});
});
}
async read(model, id, fields = []) {
if (!this.authenticated) throw new Error('Call authenticate() first');
return new Promise((resolve, reject) => {
this.objectClient.methodCall('execute_kw', [
this.database, this.uid, this.password,
model, 'read', [[id]],
{ fields: fields.length > 0 ? fields : [] }
], (error, result) => {
if (error) reject(error);
else resolve(result && result.length > 0 ? result[0] : null);
});
});
}
async create(model, values) {
if (!this.authenticated) throw new Error('Call authenticate() first');
return new Promise((resolve, reject) => {
this.objectClient.methodCall('execute_kw', [
this.database, this.uid, this.password,
model, 'create', [values]
], (error, result) => {
if (error) reject(error);
else resolve(result); // returns new record ID
});
});
}
async write(model, ids, values) {
if (!this.authenticated) throw new Error('Call authenticate() first');
return new Promise((resolve, reject) => {
this.objectClient.methodCall('execute_kw', [
this.database, this.uid, this.password,
model, 'write', [ids, values]
], (error, result) => {
if (error) reject(error);
else resolve(result); // returns true
});
});
}
async search(model, domain = [], options = {}) {
if (!this.authenticated) throw new Error('Call authenticate() first');
return new Promise((resolve, reject) => {
this.objectClient.methodCall('execute_kw', [
this.database, this.uid, this.password,
model, 'search', [domain],
{ limit: options.limit || 100 }
], (error, result) => {
if (error) reject(error);
else resolve(result || []); // returns array of IDs
});
});
}
}
module.exports = OdooClient;
Key Odoo Models
stock.picking — Albaranes (Delivery/Transfer Orders)
States: | Value | Label | |-------|-------| | draft | Borrador | | waiting | En espera (waiting for another operation) | | confirmed | Esperando disponibilidad (waiting for stock) | | assigned | Listo para transferir | | done | Hecho | | cancel | Cancelado |
Key fields:
['id', 'name', 'state', 'date', 'move_lines', 'origin',
'partner_id', 'picking_type_id', 'scheduled_date']
Typical domain — "Esperando Disponibilidad":
[['state', 'in', ['waiting', 'confirmed']]]
Note on origin field: Contains the procurement group reference, e.g. "WH/OUT/259023: Sale Order SO-1234". To extract the group ID: origin.split(':')[0].trim().
stock.move — Líneas de movimiento
States: | Value | Label | |-------|-------| | draft | Nuevo | | waiting | En espera (waiting for move) | | confirmed | Esperando disponibilidad | | assigned | Disponible | | done | Hecho | | cancel | Cancelado |
Key fields:
['id', 'product_id', 'state', 'product_uom_qty',
'quantity_done', 'name', 'picking_id']
To get lines for a picking:
const moveLines = await odoo.searchRead(
'stock.move',
[['id', 'in', picking.move_lines]],
['id', 'product_id', 'state', 'product_uom_qty', 'name']
);
// Filter by state:
const waitingLines = moveLines.filter(l => l.state === 'confirmed');
Product name cleanup (remove SKU codes like [SKU-123]):
const cleanName = product.replace(/^\s*\[.*?\]\s*/, '').trim();
sale.order — Pedidos de Venta
States: | Value | Label | |-------|-------| | draft | Presupuesto | | sent | Presupuesto enviado | | sale | Pedido de venta | | done | Bloqueado | | cancel | Cancelado |
Key fields:
['id', 'name', 'state', 'date_order', 'partner_id',
'amount_total', 'order_line', 'picking_ids']
purchase.order — Pedidos de Compra
States: draft, sent, to approve, purchase, done, cancel
Key fields:
['id', 'name', 'state', 'date_order', 'partner_id',
'amount_total', 'order_line', 'picking_ids']
product.product / product.template
// product.product = variant (has stock)
// product.template = template (groups variants)
const products = await odoo.searchRead(
'product.product',
[['active', '=', true]],
['id', 'name', 'default_code', 'list_price', 'qty_available']
);
res.partner — Clientes/Proveedores
['id', 'name', 'email', 'phone', 'street', 'city',
'country_id', 'customer_rank', 'supplier_rank']
SyncManager — Incremental Sync with Deduplication
The canonical pattern for reliable, idempotent syncs:
// utils/sync.js
class SyncManager {
constructor() {
this.odoo = new OdooClient();
this.output = new OutputClient(); // Sheets, DB, etc.
}
async sync() {
await this.odoo.authenticate();
await this.output.authenticate();
// 1. Load already-synced keys (deduplication)
const existingKeys = await this.output.getExistingKeys();
// 2. Get last synced date (incremental — avoids full scans)
const lastDate = await this.output.getLastDate();
// 3. Build domain dynamically
const domain = [['state', 'in', ['waiting', 'confirmed']]];
if (lastDate) {
domain.push(['date', '>=', lastDate]);
}
// 4. Fetch from Odoo
const records = await this.odoo.searchRead(
'stock.picking', domain,
['id', 'name', 'state', 'date', 'move_lines', 'origin'],
{ limit: 1000, order: 'date asc' }
);
// 5. Process and deduplicate
const rows = [];
for (const record of records) {
const key = extractKey(record); // e.g. procurement group
if (existingKeys.has(key)) continue;
const detail = await this.odoo.read(
'stock.picking', record.id,
['id', 'name', 'date', 'move_lines', 'origin']
);
if (!detail) continue;
// ... build rows ...
rows.push(buildRow(detail));
}
// 6. Write output
if (rows.length > 0) {
await this.output.appendRows(rows);
}
return { success: true, rowsAdded: rows.length };
}
}
Google Sheets Output Client
// utils/sheets.js
const { google } = require('googleapis');
class SheetsClient {
constructor() {
this.spreadsheetId = process.env.GOOGLE_SHEETS_ID;
this.sheetName = process.env.GOOGLE_SHEET_NAME || 'Hoja1';
this.sheets = null;
}
async authenticate() {
// Supports both: local credentials.json file OR GOOGLE_CREDENTIALS env var (Railway)
let auth;
if (process.env.GOOGLE_CREDENTIALS) {
const credentials = JSON.parse(process.env.GOOGLE_CREDENTIALS);
auth = new google.auth.GoogleAuth({
credentials,
scopes: ['https://www.googleapis.com/auth/spreadsheets']
});
} else {
auth = new google.auth.GoogleAuth({
keyFile: './credentials.json',
scopes: ['https://www.googleapis.com/auth/spreadsheets']
});
}
this.sheets = google.sheets({ version: 'v4', auth });
}
async appendRows(rows) {
// Read column A to find true last row (works even with active filters)
const col = await this.sheets.spreadsheets.values.get({
spreadsheetId: this.spreadsheetId,
range: `${this.sheetName}!A:A`
});
const lastRow = (col.data.values || []).length;
const range = `${this.sheetName}!A${lastRow + 1}:Z${lastRow + rows.length}`;
await this.sheets.spreadsheets.values.update({
spreadsheetId: this.spreadsheetId,
range,
valueInputOption: 'RAW',
resource: { values: rows }
});
}
async getExistingKeys() {
// Column B = deduplication key (e.g. procurement group)
const data = await this._readColumn('B');
const keys = new Set();
data.slice(1).forEach(row => { if (row[0]) keys.add(row[0].toString()); });
return keys;
}
async getLastDate() {
// Column A = date. Walk backwards for last non-empty value.
const data = await this._readColumn('A');
for (let i = data.length - 1; i >= 1; i--) {
if (data[i] && data[i][0]) return data[i][0];
}
return null;
}
async _readColumn(col) {
const res = await this.sheets.spreadsheets.values.get({
spreadsheetId: this.spreadsheetId,
range: `${this.sheetName}!${col}:${col}`
});
return res.data.values || [];
}
}
module.exports = SheetsClient;
Cron Scheduler (index.js)
const cron = require('node-cron');
const SyncManager = require('./utils/sync');
const syncManager = new SyncManager();
// Run on start
(async () => {
console.log('Running initial sync...');
await syncManager.sync();
})();
// Schedule: every 15 min, Mon-Fri, 6am-10pm
// Adjust HOURS_START / HOURS_END via env vars if needed
const start = process.env.HOURS_START || 6;
const end = process.env.HOURS_END || 22;
cron.schedule(`*/15 ${start}-${end} * * 1-5`, async () => {
console.log(`[${new Date().toLocaleString()}] Running sync...`);
await syncManager.sync();
});
process.on('SIGTERM', () => process.exit(0));
Cron patterns reference:
*/15 6-22 * * * → every 15 min, 6am–10pm, all days
0 8-17 * * 1-5 → hourly, 8am–5pm, Mon–Fri
30 6-15 * * * → at :30 of each hour, 6am–3pm
0,30 6-15 * * * → every 30 min, 6am–3pm
Environment Variables
# Odoo
ODOO_HOST=erp.yourcompany.com
ODOO_DATABASE=production_db
ODOO_LOGIN=sync_user@company.com
ODOO_PASSWORD=your_password
ODOO_PORT=443
# Google Sheets
GOOGLE_SHEETS_ID=1abc...xyz
GOOGLE_SHEET_NAME=Marzo 2026
# On Railway: paste full credentials.json content as single line
GOOGLE_CREDENTIALS={"type":"service_account","project_id":"..."}
# Scheduler
HOURS_START=6
HOURS_END=22
Monthly sheet rotation: Just change GOOGLE_SHEET_NAME in .env (or Railway env vars) at the start of each month. No code changes needed.
Railway Deployment
Procfile:
web: node index.js
package.json engines:
{
"engines": { "node": ">=18.0.0" }
}
Steps:
- Push to GitHub
- Connect repo in Railway
- Set env vars in Railway dashboard (Settings → Variables)
- For
GOOGLE_CREDENTIALS: copy the full contents ofcredentials.json, minify to single line, paste as value - Deploy — Railway auto-restarts on crash
NEVER commit: .env, credentials.json
Debug Scripts
Always include these in the project. Run them locally to diagnose production issues.
test-odoo.js — Verify raw connection
require('dotenv').config();
const OdooClient = require('./config/odoo');
(async () => {
const odoo = new OdooClient();
await odoo.authenticate();
console.log('UID:', odoo.uid);
const pickings = await odoo.searchRead(
'stock.picking', [],
['id', 'name', 'state', 'date'],
{ limit: 5 }
);
console.log('Sample pickings:', JSON.stringify(pickings, null, 2));
})();
debug-states.js — Discover real state values in your instance
require('dotenv').config();
const OdooClient = require('./config/odoo');
(async () => {
const odoo = new OdooClient();
await odoo.authenticate();
const records = await odoo.searchRead(
'stock.picking', [],
['id', 'name', 'state', 'date'],
{ limit: 100 }
);
const states = {};
records.forEach(r => {
if (!states[r.state]) states[r.state] = 0;
states[r.state]++;
});
console.log('States found:', states);
})();
debug-move-lines.js — Inspect lines inside a specific picking
require('dotenv').config();
const OdooClient = require('./config/odoo');
const PICKING_ID = 1341658; // replace with target ID
(async () => {
const odoo = new OdooClient();
await odoo.authenticate();
const picking = await odoo.read(
'stock.picking', PICKING_ID,
['id', 'name', 'move_lines', 'origin']
);
console.log('Picking:', picking.name);
const lines = await odoo.searchRead(
'stock.move',
[['id', 'in', picking.move_lines]],
['id', 'product_id', 'state', 'product_uom_qty', 'name']
);
const byState = {};
lines.forEach(l => {
if (!byState[l.state]) byState[l.state] = [];
byState[l.state].push(l.product_id[1] || l.name);
});
console.log('Lines by state:', JSON.stringify(byState, null, 2));
})();
Common Gotchas
Many2one fields return [id, name] arrays
// product_id = [42, "Bike Model XR"]
const name = line.product_id[1]; // ✅
const id = line.product_id[0]; // ✅
const name = line.product_id; // ❌ returns array, not string
Version compatibility — field name differences
The XML-RPC protocol is identical across all Odoo versions (v8–v17). Only a handful of field names changed:
| Field | v8–v12 | v13+ | Safe fallback | |-------|--------|-------|---------------| | Transfer lines | move_lines | move_ids | picking.move_ids \|\| picking.move_lines \|\| [] | | Scheduled date | min_date | scheduled_date | `picking.scheduled_date \|\
…
Source & license
This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.
- Author: txampa
- Source: txampa/claude-skill-odoo-integration
- License: MIT
Install and usage instructions live in the source repository linked above.
Reviews
No reviews yet — be the first.
Write a review
Versions
- v0.1.0 Imported from the upstream source.