From dev-assistant
/etendo:java — Create Java code in an Etendo module (EventHandlers, Background Processes, Action Processes, Webhooks)
npx claudepluginhub etendosoftware/etendo_claude_marketplace --plugin dev-assistantThis skill uses the workspace's default tool permissions.
**Arguments:** `$ARGUMENTS` (e.g., "eventhandler for SMFT_Enrollment", "background process expire", "action process assign score")
Searches, retrieves, and installs Agent Skills from prompts.chat registry using MCP tools like search_skills and get_skill. Activates for finding skills, browsing catalogs, or extending Claude.
Searches prompts.chat for AI prompt templates by keyword or category, retrieves by ID with variable handling, and improves prompts via AI. Use for discovering or enhancing prompts.
Guides agent creation for Claude Code plugins with file templates, frontmatter specs (name, description, model), triggering examples, system prompts, and best practices.
Arguments: $ARGUMENTS (e.g., "eventhandler for SMFT_Enrollment", "background process expire", "action process assign score")
First, read skills/etendo-_guidelines/SKILL.md, skills/etendo-_context/SKILL.md, and skills/etendo-_webhooks/SKILL.md.
For DAL patterns, EventHandlers, Background Processes, Callouts, and module file structure, read references/java-development.md. For Jobs, Actions, and CloneRecordHook, read references/jobs-and-actions.md.
Resolve the active module and detect the type of component to create.
Decision tree — match the user's description to the right component type:
| Type | When to use | Trigger keywords |
|---|---|---|
| EventHandler | Logic runs automatically when a record is saved/created/modified/deleted | "when saved", "on create", "validate before save", "auto-set field" |
| Background Process | Process runs asynchronously (batch, scheduled, periodic) | "run daily", "batch", "scheduled", "periodic", "cron" |
| Action Process (Button) | User clicks a button in a window to trigger logic | "button", "action", "from the window", "user launches" |
| Webhook | Expose an HTTP endpoint for external callers | "API endpoint", "webhook", "callable from Copilot", "HTTP" |
| Message | Create a reusable AD message for validation errors or info | "error message", "validation message", "AD message" |
| Computed Column | Virtual column calculated from a SQL expression | "calculated field", "derived column", "computed" |
| Callout | Logic triggered when a specific field changes in the UI | "when field changes", "on change", "callout" |
| Servlet (SWS Endpoint) | Expose a custom /sws/{name}/* HTTP endpoint inside an Etendo module | "SWS endpoint", "custom servlet", "JWT servlet", "REST API inside module" |
If the description matches multiple types, ask the user to clarify.
Ask only what is necessary:
CRITICAL: Before writing Java code, verify the actual package of generated entities:
find {etendo_base}/build/etendo/src-gen -name "{EntityName}.java" 2>/dev/null
# Example:
find /path/etendo_base/build/etendo/src-gen -name "SMFT_Enrollment.java" 2>/dev/null
The package generated by Etendo duplicates the javapackage path: {javapackage}.{javapackage}.ad
com.smf.tutorial → com.smf.tutorial.com.smf.tutorial.adfind — never assume the package path. Core entities use different patterns.Verify available properties and getters:
grep "PROPERTY_\|public.*get" build/etendo/src-gen/.../SMFT_Enrollment.java | grep -v "@see\|return\|/\*"
CRITICAL — Extension column property names (EM_ columns): The DAL property name for extension columns does not follow a simple mapping from the DB column name. The code generator transforms the name in ways that may be unexpected. For example:
EM_SMFT_Duration → property sMFTDurationMonths (NOT sMFTDuration)EM_SMFT_Is_Course → property might be sMFTIsCourse or something elseNever guess property names. Always find the generated entity class and grep for the actual PROPERTY_ constant and getter/setter names. Using the wrong name causes Property {name} does not exist for entity {Entity} at runtime.
# For extension columns on core entities (e.g., Product), search src-gen:
grep -r "PROPERTY_SMFT" build/etendo/src-gen/org/openbravo/model/ 2>/dev/null
# Or for module entities:
grep "PROPERTY_" build/etendo/src-gen/{generatedPackagePath}/{Entity}.java
IMPORTANT: If the tables/columns were recently created (via /etendo:alter-db or webhooks), the generated entity classes won't exist yet. You must run generate.entities before writing any Java code that references them:
JAVA_HOME=... ./gradlew generate.entities smartbuild > /tmp/etendo-generate.log 2>&1
tail -5 /tmp/etendo-generate.log
This creates the typed entity classes (e.g., SMFTEnrollment.java) in build/etendo/src-gen/. Without this step, imports will fail and there are no PROPERTY_ constants to reference. Always use the generated typed class — never use BaseOBObject with string-based property access.
Each Java component type has a specific subdirectory:
| Type | Path |
|---|---|
| EventHandler | modules/{javapackage}/src/{java/package/path}/eventhandler/{Name}.java |
| Background Process | modules/{javapackage}/src/{java/package/path}/background/{Name}.java |
| Action Process | modules/{javapackage}/src/{java/package/path}/process/{Name}.java |
| Webhook | modules/{javapackage}/src/{java/package/path}/webhooks/{Name}.java |
| Servlet (SWS) | modules/{javapackage}/src/{java/package/path}/rest/{Name}JwtServlet.java |
Where {java/package/path} is the javapackage with dots replaced by slashes (e.g., com/etendoerp/mymodule).
In all templates below, {generatedPackage} refers to the actual package where Etendo generated the entity class. You MUST discover this in Step 3 using find ... -name "{EntityName}.java". The result is used in the import statement. For example, if the find command returns build/etendo/src-gen/com/smf/tutorial/com/smf/tutorial/ad/SMFTEnrollment.java, then {generatedPackage} = com.smf.tutorial.com.smf.tutorial.ad.
An EventHandler observes entity persistence events: onNew (record created), onUpdate (record modified), onDelete (record deleted). Include only the events you need.
File: modules/{javapackage}/src/{path}/eventhandler/{Entity}EventHandler.java
package {javapackage}.eventhandler;
import javax.enterprise.event.Observes;
import org.openbravo.base.model.Entity;
import org.openbravo.base.model.ModelProvider;
import org.openbravo.client.kernel.event.*;
import org.openbravo.dal.service.OBDal;
import {generatedPackage}.{Entity};
public class {Entity}EventHandler extends EntityPersistenceEventObserver {
private static Entity[] ENTITIES = {
ModelProvider.getInstance().getEntity({Entity}.ENTITY_NAME)
};
@Override
protected Entity[] getObservedEntities() { return ENTITIES; }
public void onNew(@Observes EntityNewEvent event) {
if (!isValidEvent(event)) return;
handle(({Entity}) event.getTargetInstance());
}
public void onUpdate(@Observes EntityUpdateEvent event) {
if (!isValidEvent(event)) return;
handle(({Entity}) event.getTargetInstance());
}
public void onDelete(@Observes EntityDeleteEvent event) {
if (!isValidEvent(event)) return;
// handle deletion — e.g., cascade cleanup, validation
}
private void handle({Entity} record) {
// logic here — use record.getXxx() / record.setXxx()
// throw OBException for validations
}
}
A background process runs asynchronously (batch processing, data sync, scheduled tasks). Extend DalBaseProcess for DAL access.
File: modules/{javapackage}/src/{path}/background/{Name}Process.java
package {javapackage}.background;
import org.openbravo.service.db.DalBaseProcess;
import org.openbravo.scheduling.ProcessBundle;
import org.openbravo.dal.service.*;
import org.hibernate.criterion.Restrictions;
import {generatedPackage}.{Entity};
import java.util.List;
public class {Name}Process extends DalBaseProcess {
@Override
protected void doExecute(ProcessBundle bundle) throws Exception {
OBCriteria<{Entity}> crit = OBDal.getInstance().createCriteria({Entity}.class);
crit.add(Restrictions.eq({Entity}.PROPERTY_ACTIVE, true));
// more filters...
List<{Entity}> records = crit.list();
int count = 0;
for ({Entity} r : records) {
// process
OBDal.getInstance().save(r);
count++;
}
OBDal.getInstance().flush();
bundle.getLogger().log("Processed " + count + " records.");
}
}
Register in AD via webhook (Tomcat must be UP). The webhook sets: Background check = Y, Data access level = All, UI Pattern = Manual:
curl -s -X POST "${ETENDO_URL}/webhooks/RegisterBGProcessWebHook" \
-H "Authorization: Bearer ${ETENDO_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"Javapackage": "{javapackage}",
"Name": "{VisibleName}",
"SearchKey": "{PREFIX_SearchKey}",
"Description": "{description}",
"PreventConcurrent": "true"
}'
An action process is triggered from a button in a window tab. It extends BaseProcessActionHandler and receives parameters from the UI.
Required info: module javapackage, module prefix, window/tab/table where the button will appear, process name, search key (must have module prefix, e.g., COPDEV_SalesReport).
File: modules/{javapackage}/src/{path}/process/{Name}Process.java
package {javapackage}.process;
import java.util.Map;
import org.codehaus.jettison.json.*;
import org.openbravo.base.exception.OBException;
import org.openbravo.client.application.process.BaseProcessActionHandler;
import org.openbravo.dal.service.OBDal;
public class {Name}Process extends BaseProcessActionHandler {
@Override
protected JSONObject doExecute(Map<String, Object> parameters, String content) {
JSONObject result = new JSONObject();
try {
JSONObject params = new JSONObject(content).getJSONObject("_params");
// read params: params.getString("paramName")
// logic...
result.put("responseActions", new JSONArray()
.put(new JSONObject().put("showMsgInProcessView", new JSONObject()
.put("msgType", "success")
.put("msgTitle", "OK")
.put("msgText", "Process completed"))));
} catch (OBException e) {
try {
result.put("responseActions", new JSONArray()
.put(new JSONObject().put("showMsgInProcessView", new JSONObject()
.put("msgType", "error")
.put("msgTitle", "Error")
.put("msgText", e.getMessage()))));
} catch (Exception ignore) {}
} catch (Exception e) {
// log error
}
return result;
}
}
Register in AD via webhook (Tomcat must be UP):
curl -s -X POST "${ETENDO_URL}/webhooks/ProcessDefinitionButton" \
-H "Authorization: Bearer ${ETENDO_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"Prefix": "{PREFIX}",
"SearchKey": "{PREFIX_SearchKey}",
"ProcessName": "{VisibleName}",
"Description": "{description}",
"HelpComment": "{description}",
"JavaPackage": "{javapackage}",
"Parameters": "[{\"BD_NAME\":\"p_param1\",\"NAME\":\"Param 1\",\"LENGTH\":\"32\",\"SEQNO\":\"10\",\"REFERENCE\":\"Search\"}]"
}'
Note: The Java class name is auto-built as
{javapackage}.actionHandler.{ProcessName}ActionHandler— theJavaPackageandProcessNamemust match the actual class created above.Parametersis a JSON array. Each item needs:BD_NAME(DB column name),NAME(display name),LENGTH,SEQNO,REFERENCE(type name, e.g. "Search", "Date", "String").
A webhook is a Java class that extends BaseWebhookService and exposes an HTTP operation
callable by Copilot or other clients. Requires DB registration after creating the file.
File pattern: modules/{module}/src/{javapackage}/webhooks/{Name}.java
package {javapackage}.webhooks;
import static com.etendoerp.copilot.devassistant.Utils.logExecutionInit;
import java.util.Map;
import org.apache.commons.lang3.StringUtils;
import org.apache.logging.log4j.LogManager;
import org.apache.logging.log4j.Logger;
import org.openbravo.base.exception.OBException;
import org.openbravo.dal.core.OBContext;
import org.openbravo.dal.service.OBDal;
import com.etendoerp.webhookevents.services.BaseWebhookService;
/**
* Webhook to {description of what it does}.
*
* <p>Required parameters:
* <ul>
* <li>{@code Param1} -- description</li>
* </ul>
* <p>Optional parameters:
* <ul>
* <li>{@code Param2} -- description (default: value)</li>
* </ul>
* <p>Response: {@code {"message": "..."}}
*/
public class {Name} extends BaseWebhookService {
private static final Logger LOG = LogManager.getLogger();
@Override
public void get(Map<String, String> parameter, Map<String, String> responseVars) {
logExecutionInit(parameter, LOG);
String param1 = parameter.get("Param1");
try {
if (StringUtils.isBlank(param1)) {
throw new OBException("Param1 parameter is required");
}
OBContext.setAdminMode(true);
try {
// main logic
OBDal.getInstance().flush();
responseVars.put("message", "Done: " + param1);
} finally {
OBContext.restorePreviousMode();
}
} catch (Exception e) {
LOG.error("Error in {Name}: {}", e.getMessage(), e);
responseVars.put("error", e.getMessage());
OBDal.getInstance().getSession().clear();
}
}
}
After creating the file, register in DB via webhook (Tomcat must be UP):
curl -s -X POST "${ETENDO_URL}/webhooks/RegisterNewWebHook" \
-H "Authorization: Bearer ${ETENDO_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"Javaclass": "{javapackage}.webhooks.{Name}",
"SearchKey": "{Name}",
"Params": "Param1;Param2",
"ModuleJavaPackage": "{javapackage}"
}'
Note:
Paramsis a;-separated list — all are set asISREQUIRED=Yin the DB. If you need optional params, adjust them manually in the Webhooks window in the AD.To persist across deploys, manually add the entry to
src-db/database/sourcedata/SMFWHE_DEFINEDWEBHOOK.xmlandSMFWHE_DEFINEDWEBHOOK_PARAM.xmlfollowing the pattern of existing webhooks in the module.
A Callout is a server-side handler triggered when a specific field changes in the Etendo classic UI. It reads field values, performs calculations, and returns updated values for other fields.
File: modules/{javapackage}/src/{path}/callouts/{Entity}{FieldName}Callout.java
package {javapackage}.callouts;
import javax.servlet.ServletException;
import org.openbravo.erpCommon.ad_callouts.SimpleCallout;
public class {Entity}{FieldName}Callout extends SimpleCallout {
@Override
protected void execute(CalloutInfo info) throws ServletException {
// Read the changed field value:
String changedFieldValue = info.getStringParameter("{COLUMNNAME}", null);
// Read other field values from the form:
String otherField = info.getStringParameter("{OTHER_COLUMN}", null);
// Perform logic and set output fields:
// info.addResult("inp{ColumnName}", computedValue);
// info.addResult("inpDescription", "auto-filled description");
// Show a message (optional):
// info.showMessage(OBMessageUtils.messageBD("PREFIX_SomeMessage"));
}
}
{COLUMNNAME}is the DB column name in uppercase (e.g.,EM_SMFT_ISCOURSE).inp{ColumnName}inaddResultfollows the pattern:inp+ column name in camelCase with first letter uppercase (e.g.,inpEmSmftIscourse).
Register in AD — there is no webhook for callout registration. Use direct SQL:
cat > /tmp/register_callout.sql << 'EOF'
-- Get the column ID first:
-- SELECT ad_column_id FROM ad_column WHERE ad_table_id = '{TABLE_ID}' AND LOWER(columnname) = '{columnname}';
UPDATE ad_column
SET callout = '{javapackage}.callouts.{Entity}{FieldName}Callout'
WHERE ad_column_id = '{COLUMN_ID}';
EOF
docker cp /tmp/register_callout.sql etendo-db-1:/tmp/register_callout.sql
docker exec etendo-db-1 psql -U {bbdd.user} -d {bbdd.sid} -f /tmp/register_callout.sql
Then export with
export.databaseso the callout registration is saved in the XML. Seereferences/java-development.mdfor advanced callout patterns (e.g., querying the DB inside execute, cascading to multiple fields).
Create Application Dictionary messages for use in Java code (validation errors, info messages, etc.).
Register via webhook:
curl -s -X POST "${ETENDO_URL}/webhooks/CreateMessage" \
-H "Authorization: Bearer ${ETENDO_TOKEN}" \
-H "Content-Type: application/json" \
-d '{
"SearchKey": "{PREFIX_DescriptiveName}",
"MessageText": "{message text, use %s for parameters}",
"MessageType": "{I|E}",
"ModuleID": "'${MODULE_ID}'"
}'
Rules:
{PREFIX}_DescriptiveName — prefix uppercase, rest in CamelCase. Max 32 characters.I = informational, E = error%s placeholders for parameterized messagesUse in Java:
// Simple message:
String msg = OBMessageUtils.messageBD("PREFIX_DescriptiveName");
// Parameterized message:
String msg = String.format(OBMessageUtils.messageBD("PREFIX_DescriptiveName"), param1, param2);
A servlet exposes a custom /sws/{name}/* HTTP endpoint directly inside an Etendo module (i.e., in modules/). Use it when you need a JWT-authenticated REST API or mobile API without a separate microservice.
Servlet vs Webhook — when to use each:
- Webhook (type
BaseWebhookService): simpler, faster to set up. Best for one-off actions — a single endpoint that does one thing (e.g., create a record, trigger a process, return a status). Registration is a single webhook call.- Servlet (
AD_MODEL_OBJECT): better when the module needs multiple related endpoints under a single URL prefix (e.g.,/sws/myapp/users,/sws/myapp/orders,/sws/myapp/config). The servlet delegates to aRestServicesingleton that routes by subpath. More setup (AD registration + export), but scales better for a full REST API.Rule of thumb: if you only need 1-2 endpoints, use a Webhook. If you're building a REST API with many routes, use a Servlet.
| Situation | Mechanism |
|---|---|
Module in modules/ (inside Etendo Core) | AD_MODEL_OBJECT + AD_MODEL_OBJECT_MAPPING |
Independent microservice (separate process, outside modules/) | config/{module}-provider-config.xml |
Never mix: if the module is in modules/, it does NOT need provider-config.xml.
| Variant | Extends | URL | Auth | When to use |
|---|---|---|---|---|
| JwtServlet | HttpBaseServlet | /sws/{name}/* | Bearer JWT via SecureWebServicesUtils | APIs, mobile, programmatic clients |
| SecureServlet | HttpSecureAppServlet | /{name}/* | Openbravo browser session | Browser clients with session |
For APIs (mobile, Copilot, external), always use JwtServlet.
modules/{javapackage}/src/{path}/rest/{Name}JwtServlet.javapackage {javapackage}.rest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import org.openbravo.base.secureApp.HttpBaseServlet;
import org.openbravo.base.secureApp.SecureWebServicesUtils;
public class {Name}JwtServlet extends HttpBaseServlet {
@Override
public void doGet(HttpServletRequest request, HttpServletResponse response) {
try {
// 1. Decode JWT and create OBContext
var sessionInfo = SecureWebServicesUtils.decodeToken(
request.getHeader("Authorization").replace("Bearer ", ""));
SecureWebServicesUtils.createContext(
sessionInfo.getString("ad_user_id"),
sessionInfo.getString("ad_role_id"),
sessionInfo.getString("ad_org_id"),
sessionInfo.getString("m_warehouse_id"),
sessionInfo.getString("ad_client_id"));
// 2. Delegate to singleton service for routing by subpath
{Name}RestService.getInstance().doGet(request, response);
} catch (Exception e) {
try {
response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, e.getMessage());
} catch (Exception ignore) {}
}
}
@Override
public void doPost(HttpServletRequest request, HttpServletResponse response) {
// same pattern as doGet
}
}
Reference pattern:
CopilotJwtServletin thecom.etendoerp.copilotmodule.
NEVER insert in DB via raw SQL and then write XMLs by hand — this desyncs the DB from sourcedata.
Correct flow:
General Setup > Application > Model Objects → create AD_MODEL_OBJECT with:
{javapackage}.rest.{Name}JwtServlet)S (Servlet)Model Object Mappings → create AD_MODEL_OBJECT_MAPPING with:
/sws/{name}/*./gradlew export.database -Dmodule={javapackage} → Etendo generates AD_MODEL_OBJECT.xml and AD_MODEL_OBJECT_MAPPING.xml automatically.If Tomcat is not available, use SQL as fallback:
DO $$
DECLARE
v_obj_id TEXT := get_uuid();
v_map_id TEXT := get_uuid();
BEGIN
INSERT INTO AD_MODEL_OBJECT (AD_MODEL_OBJECT_ID, AD_CLIENT_ID, AD_ORG_ID, ISACTIVE,
CREATED, CREATEDBY, UPDATED, UPDATEDBY,
NAME, CLASSNAME, OBJECT_TYPE, ACTION, ISDEFAULT, AD_MODULE_ID)
VALUES (v_obj_id, '0', '0', 'Y', now(), '0', now(), '0',
'{Descriptive Name}', '{javapackage}.rest.{Name}JwtServlet',
'S', 'P', 'N', '{MODULE_ID}');
INSERT INTO AD_MODEL_OBJECT_MAPPING (AD_MODEL_OBJECT_MAPPING_ID, AD_CLIENT_ID, AD_ORG_ID,
ISACTIVE, CREATED, CREATEDBY, UPDATED, UPDATEDBY,
AD_MODEL_OBJECT_ID, MAPPINGNAME, ISDEFAULT)
VALUES (v_map_id, '0', '0', 'Y', now(), '0', now(), '0',
v_obj_id, '/sws/{name}/*', 'N');
RAISE NOTICE 'MODEL_OBJECT_ID: %', v_obj_id;
END $$;
Then run export.database to generate the XMLs from the DB.
Every module that contains a servlet (or any CDI-injectable class) must have:
etendo-resources/META-INF/beans.xml:
<beans xmlns="http://xmlns.jcp.org/xml/ns/javaee"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://xmlns.jcp.org/xml/ns/javaee http://xmlns.jcp.org/xml/ns/javaee/beans_2_0.xsd"
bean-discovery-mode="all" version="2.0">
</beans>
If missing, Weld will not detect injectable classes and errors are hard to trace. See /etendo:module for creation checklist.
After writing any Java code, check it against Etendo's Sonar rules. Read references/java-sonar-rules.md and verify the generated code does not violate BLOCKER or CRITICAL rules. Key patterns to always check:
System.out.println — use LOG.debug/info/error (BLOCKER)catch (Exception e) {} with empty body — at minimum log the error (CRITICAL)OBDal.getInstance().getSession().createQuery() with string concatenation — use parameters (CRITICAL SQL injection)OBContext.setAdminMode(true) must always have a matching OBContext.restorePreviousMode() in a finally block (CRITICAL)OBDal.getInstance().flush() should only be called when necessary — not inside loops (MAJOR performance)For Action Process and Jasper, use direct SQL only if no equivalent webhook exists.
Always prefer the RegisterBGProcessWebHook, ProcessDefinitionButton, and ProcessDefinitionJasper webhooks.
JAVA_HOME=... ./gradlew smartbuild > /tmp/smartbuild.log 2>&1
tail -20 /tmp/smartbuild.log
grep "\[ant:javac\]" /tmp/smartbuild.log | head -20
If there are compilation errors:
+ {Type} {Name} created and compiled
File: modules/{module}/src/{path}/{Name}.java
Next steps:
/etendo:smartbuild -> if it hasn't run yet
/etendo:test -> create unit tests for this component
Check Tomcat logs to confirm the handler/process registers