diff --git a/.chachalog/js-server-extension-sdk.md b/.chachalog/js-server-extension-sdk.md
new file mode 100644
index 00000000..63d7e8c9
--- /dev/null
+++ b/.chachalog/js-server-extension-sdk.md
@@ -0,0 +1,6 @@
+---
+# Allowed version bumps: patch, minor, major
+javascript-modules: minor
+---
+
+Java modules can now consume JavaScript-declared server extensions through the new `JSServerExtensionInvoker` OSGi service. A module can define its own extension type, let JavaScript modules contribute entries via `server.registry.add`, and invoke their callbacks from Java without depending on GraalVM APIs. This enables, for example, form-field validators written in JavaScript to run during server-side form submission processing.
diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvoker.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvoker.java
new file mode 100644
index 00000000..c68cc022
--- /dev/null
+++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvoker.java
@@ -0,0 +1,73 @@
+/*
+ * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jahia.modules.javascript.modules.engine.sdk;
+
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Public SDK entry point letting other OSGi bundles consume JavaScript-declared server
+ * extensions registered via {@code server.registry.add(type, key, entry)} in JS modules — without any
+ * dependency on GraalVM/polyglot types or on the engine internals.
+ *
+ *
This is the supported extension surface for modules (such as Formidable) that define their own
+ * server-side extension type and need to run the JS callbacks contributed against it. It complements the
+ * built-in registrars ({@code node-validator}, {@code action}, …), which wire JS entries to Jahia's own
+ * extension points; here the consumer owns the extension point.
+ *
+ *
All work happens inside a single pooled GraalVM context for the duration of one {@link #forEach}
+ * call. GraalVM values (the JS callables stored in entries) are only valid during that call, so callables
+ * must be invoked through the {@link Invoker} passed to the handler, never captured for later use.
+ */
+public interface JSServerExtensionInvoker {
+
+ /**
+ * Iterates all registry entries of {@code registryType} (across every deployed JS module) within one
+ * JS context, invoking {@code handler} for each. Results that are non-null are collected and returned
+ * in registry order.
+ *
+ *
The handler receives the entry as a plain {@code Map} (scalar fields such as
+ * {@code nodeType} are plain Java; function fields are opaque handles to pass to the {@link Invoker}).
+ * A handler exception propagates to the caller — callers that need fail-closed semantics should catch
+ * their own errors inside the handler and translate them into a result.
+ *
+ * @param registryType the JS registry type to look up (e.g. {@code "formidable-field-validator"})
+ * @param handler invoked once per matching entry; return {@code null} to skip an entry
+ * @param the result type accumulated across entries
+ * @return the non-null handler results, in registry order (never {@code null})
+ */
+ List forEach(String registryType, ExtensionHandler handler);
+
+ /** Handles a single registry entry, optionally invoking its JS callables through {@code invoker}. */
+ @FunctionalInterface
+ interface ExtensionHandler {
+ T handle(Map entry, Invoker invoker);
+ }
+
+ /** Invokes a JS callable stored in a registry entry and converts its result to plain Java. */
+ @FunctionalInterface
+ interface Invoker {
+ /**
+ * Executes {@code callable} (a function field read from an entry) with {@code args} and returns
+ * the result converted to plain Java: {@code null}, {@link Boolean}, {@link Long}/{@link Double},
+ * {@link String}, {@link List}, or {@link Map}. Host objects passed as arguments (e.g. a
+ * {@code JCRNodeWrapper}) are forwarded to JS as-is.
+ *
+ * @throws RuntimeException if the callable is not executable or throws
+ */
+ Object call(Object callable, Object... args);
+ }
+}
diff --git a/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvokerImpl.java b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvokerImpl.java
new file mode 100644
index 00000000..089dc572
--- /dev/null
+++ b/javascript-modules-engine-java/src/main/java/org/jahia/modules/javascript/modules/engine/sdk/JSServerExtensionInvokerImpl.java
@@ -0,0 +1,92 @@
+/*
+ * Copyright (C) 2002-2023 Jahia Solutions Group SA. All rights reserved.
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ */
+package org.jahia.modules.javascript.modules.engine.sdk;
+
+import org.graalvm.polyglot.Value;
+import org.jahia.modules.javascript.modules.engine.jsengine.GraalVMEngine;
+import org.osgi.service.component.annotations.Component;
+import org.osgi.service.component.annotations.Reference;
+import org.osgi.service.component.annotations.ReferenceCardinality;
+
+import java.util.ArrayList;
+import java.util.HashMap;
+import java.util.LinkedHashMap;
+import java.util.List;
+import java.util.Map;
+
+/**
+ * Default {@link JSServerExtensionInvoker}. Runs each {@link #forEach} within one pooled GraalVM context
+ * (re-resolving the registry inside the context, as GraalVM contexts are recycled on module (un)deploy),
+ * and converts GraalVM {@link Value} results to plain Java so callers never see polyglot types.
+ */
+@Component(service = JSServerExtensionInvoker.class, immediate = true)
+public class JSServerExtensionInvokerImpl implements JSServerExtensionInvoker {
+
+ private GraalVMEngine graalVMEngine;
+
+ @Reference(cardinality = ReferenceCardinality.MANDATORY)
+ public void setGraalVMEngine(GraalVMEngine graalVMEngine) {
+ this.graalVMEngine = graalVMEngine;
+ }
+
+ @Override
+ public List forEach(String registryType, ExtensionHandler handler) {
+ return graalVMEngine.doWithContext(contextProvider -> {
+ List results = new ArrayList<>();
+ Invoker invoker = (callable, args) -> convert(Value.asValue(callable).execute(args));
+ Map filter = new HashMap<>();
+ filter.put("type", registryType);
+ for (Map entry : contextProvider.getRegistry().find(filter)) {
+ T result = handler.handle(entry, invoker);
+ if (result != null) {
+ results.add(result);
+ }
+ }
+ return results;
+ });
+ }
+
+ /** Recursively converts a GraalVM value to plain Java ({@code null}/Boolean/Long/Double/String/List/Map). */
+ static Object convert(Value value) {
+ if (value == null || value.isNull()) {
+ return null;
+ }
+ if (value.isBoolean()) {
+ return value.asBoolean();
+ }
+ if (value.isNumber()) {
+ return value.fitsInLong() ? (Object) value.asLong() : (Object) value.asDouble();
+ }
+ if (value.isString()) {
+ return value.asString();
+ }
+ if (value.hasArrayElements()) {
+ List