type. You write it against the plugin-api module, build it into the server and restart.
Prerequisites: a clone of the Yukta repository, JDK 25, and these ideas: a node has a role (trigger, processor or terminal), it works on streams of messages, and the engine, not your plugin, handles retries, time limits, pause, skip and failure routing. See How Yukta works.
Pick a role
All three live in
com.infenia.yukta.plugin.type and extend Plugin in com.infenia.yukta.plugin.core. The steps build a processor; a trigger and a terminal differ only in this operation.
Steps
1
Make a module
Plugins are Gradle modules under Register the module in
plugins/. Copy the build file of a small sibling, such as plugins/processors/http-request/build.gradle.kts, into plugins/processors/shout/. It applies the library conventions and depends on :messaging and :plugin-api:settings.gradle.kts with include("plugins:processors:shout"), and in boot/build.gradle.kts add implementation(project(":plugins:processors:shout")) next to the other plugins. To let -Pyukta.plugins=... select it, add it to the optionalPlugins map there instead.2
Write the plugin
getType()is the string users write as a node’stype. Two plugins with the same type stop the server at start. Use the upper-case, underscore style of the built-in types.getConfigSchema()lists the options. The web UI builds the node form from it, andGET /api/plugins/{type}returns it. Keep the field names equal to the keys the plugin reads. Declare a credential asConfigField.Type.SECRETso users write adecrypted:<key>reference.validateConfigruns when a workflow is saved, so a typo fails there and not deep in a run. It runs again at compile time;validateInContext(not shown) checks the node’s place in the graph.DefaultMessage.from(message, payload)keeps the message id, trace id and metadata. Set the source port on every output message: the engine routes only on the ports you declare (default: one port nameddefault; overridegetOutputPorts()for more).- Throw
IllegalArgumentExceptionfor invalid input. The engine does not retry it by default, because repeating the same input cannot succeed.
com.infenia.yukta, so @Component in a package below it is found. A plugin in another package registers through a Spring Boot @AutoConfiguration class listed in META-INF/spring/org.springframework.boot.autoconfigure.AutoConfiguration.imports, as process-executor does.3
Test it
Test the plugin on its own with Reactor’s Then register the plugin with the server-wide checks. The
StepVerifier, as the built-in ones do:boot module runs every registered plugin through a conformance check (the schema matches the config, a bad config gives a clear error, the minimal config runs, a cancel reaches the input and leaks no thread, a 10 MB message is handled, an upstream error surfaces and no secret reaches the logs). A new plugin fails these tests until you add a fixture, a minimal config that needs no outside world, to fixtures() in PluginConformanceSweepTest and to FIXTURES in PluginRuntimeAcceptanceTest, and list the type in PluginCoverageAcceptanceTest. A plugin that cannot run without the outside world gets a reasoned entry in conformance-exemptions.properties or in the tests’ EXEMPT maps instead.4
Build it into the server and run it
http://localhost:9858 with the dev profile. If you contribute to the repository, run the project’s quality gates (Spotless, Checkstyle, PMD, SpotBugs) before a pull request: they apply to plugin code too, and every Java file needs the Apache-2.0 header. See the contributing guide.5
Use it in a workflow
Verify it worked
default output port. The plugin also appears in the Plugins page of the web UI (its form comes from getConfigSchema()), and in the MCP tools list_plugins and get_plugin_details. The result command prints the payload {"shouted": "HELLO!!"}. A workflow that uses a type the server does not have is rejected when it is validated, so a typo in type fails at save time. If the server stops at start with “Duplicate plugin type”, two plugins use the same string.
Variations that matter
- A long-running step. The engine owns the idle timeout: a node’s
timeoutSecondsoption, else your plugin’sgetDefaultTimeout()(30 seconds). A step that works for a long time without emitting must send heartbeats. A heartbeat restarts the idle clock, but not a hard deadline (engine.deadlineMs). Take it from the Reactor context (WorkflowContext.HEARTBEAT_KEY) and run it as aRunnable. - Live logs. A plugin writes live log lines through the sink in the Reactor context (
WorkflowContext.LOG_SINK_KEY):line("stdout" | "stderr", text). They appear in the run’s logs while the node works. - Failures as data. Support
failureModewithERROR(the error ends the node and the node’senginepolicy decides) andCONTINUE(the original message leaves on theerrorport with the failure attached).PluginGuardinplugin-apidoes this for you. - Make side effects safe to repeat. Delivery is at least once: a retry, a restart from a node or a resume can run a step again with the same message. Use the stable message id as an idempotency key, and the
attemptheader, when you call an outside system. See Persist sessions. - Ask a person. A node that waits for a decision asks an input request on the
InputGatewaythroughInputAsk.ask(gatewaySupplier, spec). Take the gateway through anObjectProvider<InputGateway>, build the request key from the message id so a resumed run finds its earlier answer, and remember that a request with riskHIGHcan be answered only by a person. SeeAskInputProcessorin the repository and Ask for input. - Budgets.
RunBudgetsinplugin-apiis the shared ledger of tokens, cost and calls for one run, used byLLM_CALLandMCP_TOOL_CALL. It lives in server memory. - State. The registered instance is a shared singleton that serves every node of its type, in every workflow and session, so keep it stateless. For per-node state (a window, a cache, a parsed config), override
forNode(config): the engine calls it once per node when a run is assembled and uses the object you return for every call of that node. Makeinitializesafe to run many times. The engine never callsshutdown, so release long-lived resources with the server’s own lifecycle, for example@PreDestroy. A plugin that keeps state in the server process and so cannot move to a worker overridesrequiresControlPlane(). - Blocking work. If the plugin blocks on input or output, override
isBlocking()to returntrueso it runs off the shared event threads. - Run it on a worker. Plugins can run on a remote worker: build the plugin into the worker and list its type in
yukta.workers.plugin-types. - An assistant can help. The MCP tool
get_plugin_creation_guidereturns a template for a new plugin. See Connect an assistant.
Related
- Plugin reference: the built-in plugins and how a build chooses them
- Messages reference
- Handle failures: the
enginepolicy around your plugin - How time limits work
- Deploy Yukta: build switches and shipping a custom build