Primer
Yukta’s engine knows how to run a graph of nodes, but it knows nothing about what a node does. Something has to actually fetch a web page, run a program, ask a language model, split a list into items, or post to a chat channel. Those somethings are plugins. The hard part is that they are driven by configuration written by whoever authors the workflow: that person names the URL, the command, the folder, the SQL, the model prompt. So a plugin must be flexible enough to be useful and careful enough that a configuration mistake (or a hostile configuration) does not take the server down.Where plugins sit
Eight terms to know first
One example, end to end
A chat agent that remembers the conversation, lets the model use a tool and posts the reply. Click a node to see what happens, why it is built that way, and what can go wrong.Chat message
Load history
Ask the model
Tool wanted?
Call the tool
Answer with result
Save reply
Post reply
The module in plain words
Register
Describe
Validate
Run
Resolve
Guard
Watch
Components
Triggers
API, constant, schedule Creates the first message of a run. Three types: API_TRIGGER, CONSTANT_SOURCE and SCHEDULE_TRIGGER.- API_TRIGGER takes the payload that the REST or MCP start call carried and emits it as one message with a fresh id. If the start call had no payload, it emits an empty one. It has no settings.
- CONSTANT_SOURCE emits one message built from a ‘variables’ map (dots in a key make nested fields: ‘user.name’). ‘target’ says whether the values go into the payload (default) or the metadata. The values may use {sys.p}, {payload…} is not replaced with data: a trigger has no incoming message to read from.
- SCHEDULE_TRIGGER emits {tick, firedAt} repeatedly. Set exactly one of intervalMs (fixed gap; the first message comes after one interval, not at once) or cron (a Spring 6-field expression: second, minute, hour, day, month, weekday). ‘zone’ sets the cron time zone (default is the server’s), ‘maxRuns’ stops after that many messages. A cron with no future date, or both or neither of the two settings, is rejected when the config is checked.
- A scheduled workflow is one long run that stays open and keeps emitting. The schedule lives inside that run, not in a separate scheduler: nothing remembers it after a restart, so a restarted server does not fire missed ticks.
- Guarantees. Each start produces a message with a new random id. SCHEDULE_TRIGGER never runs both schedules at once and refuses invalid cron or non-positive intervals at validation time.
- State. None of its own. A schedule’s position is held by the running stream and is lost with the process.
- Ceiling. A trigger is one stream per run. Many schedules mean many open runs, each holding a timer and its downstream streams.
- On failure. A bad config is rejected before the run starts. A tick that finds downstream busy is not queued by the trigger itself; how back-pressure is handled is the engine’s job.
API_TRIGGER: none, CONSTANT_SOURCE: variables, target (PAYLOAD|METADATA), SCHEDULE_TRIGGER: intervalMs | cron, zone, maxRuns
Routing & EIP
filter, branch, split, group Decides which messages go where and in what shape: FILTER, BRANCH, RECIPIENT_LIST, SPLITTER, AGGREGATE and VALIDATE.- FILTER keeps a message if a condition is true. The default engine is SpEL; engine ‘SIMPLE’ offers a tiny language with ==, exists and matches (a regular expression). ‘REGO’ is accepted by the form but rejected at validation as not implemented. Failing messages are dropped, unless discardPort names a second port, which turns the filter into a two-way router. strictMode (default true) turns an evaluation error into a node error; false drops the message quietly.
- BRANCH sends each message to a named port. Mode SELECT_KEY (default) evaluates ‘selector’ and looks the result up in ‘cases’ (value to port name). Mode EXPRESSION treats each case key as a yes/no condition. defaultPort catches the rest. allowMultipleMatches (default false) lets one message go down several ports. With strictMode on and no match and no default, the message is an error, so a typo in a case value cannot silently lose data. errorPort sends evaluation failures to a port with the failure details attached.
- RECIPIENT_LIST copies a message to several ports at once. Mode DYNAMIC (default) computes port names with an expression; STATIC uses a fixed ‘recipients’ list; EXTERNAL reads the list from a header or payload field. ‘parallel’ (default true) sends concurrently. Because the port names can be computed, the node can declare ‘any port’.
- SPLITTER turns one message holding a list into many messages, one per item (itemsPath says where the list is; headerMapping can copy per-item values into metadata). ‘parallel’ defaults to true and ‘concurrency’ to 1024. AGGREGATE does the reverse: it batches ‘size’ messages into one message holding a list of their payloads and flushes a partial batch after ‘timeoutMs’ if set, or when the stream ends. Example: SPLITTER over 3 orders, a model call per order, then AGGREGATE size 3 to rebuild one list.
- VALIDATE checks that listed fields exist and are not null (‘required’) and have the right type (‘types’: string, number, boolean, object, array). Good messages leave on ‘default’, bad ones on ‘invalid’ with the reasons in failureReason, so bad input can be quarantined instead of crashing a later node.
- Guarantees. Routing is by port name: the engine only delivers a message on edges wired to the port it left on. With strictMode on, an unmatched or unevaluable message is an error, not a silent drop. AGGREGATE flushes what it holds when the stream completes.
- State. AGGREGATE holds a partial batch in memory until it is full, times out or the stream ends. The others hold nothing between messages. Expressions are cached after the first parse.
- Ceiling. SPLITTER with parallel true can have 1024 items in flight per node. AGGREGATE’s buffer is bounded by ‘size’ but ‘size’ is whatever the author sets. Expressions are evaluated in restricted SpEL, which is fast but still runs author-supplied logic per message.
- On failure. An expression error is a node error (strictMode true), a dropped message (strictMode false) or an errorPort message. REGO fails validation. Loss of a partial AGGREGATE batch on a crash is possible.
FILTER: condition, engine, strictMode, discardPort, allowAfterHeavy, BRANCH: mode, selector, cases, defaultPort, allowMultipleMatches, strictMode, errorPort, RECIPIENT_LIST: mode, recipients, expression, sourceField, parallel, SPLITTER: itemsPath, headerMapping, parallel, concurrency, AGGREGATE: size, timeoutMs, VALIDATE: required, types
Transform & script
MAPPER, CONTENT-FILTER Reshapes the payload: MAPPER (projection, template or script) and CONTENT-FILTER (keep or remove fields).- MAPPER has three modes. PROJECTION maps target field names (dots nest) to SpEL expressions: {‘total’: ‘payload.price * payload.qty’}. TEMPLATE fills Handlebars templates (a Mustache-like text language): one template string, or a map of field to template. SCRIPT runs JavaScript that returns the new payload.
- dropOriginal (default true) throws the old payload away and keeps only what the mapping builds; false merges the result over the original. strictMode (default true) fails the message on any evaluation error; false logs it and carries on (a failing script then gives an empty payload).
- SCRIPT mode needs the optional script-js module, which implements a small interface (ScriptEngine) using GraalJS. If it is not in the build, SCRIPT configs are rejected with a message saying to add it to yukta.plugins. The script sees ‘message’, ‘payload’ and ‘metadata’, and its result is converted back into plain maps, lists and scalars.
- The GraalJS context still has host access to the objects it is given, but Class objects are denied, Java.type may load only 12 value classes and java.time.*, and a script is stopped after 50 million statements or 10 s (yukta.script.max-statements, yukta.script.timeout-ms). It has no memory limit (risk: scriptHostAccess). The engine marks SCRIPT nodes ‘computationally heavy’ and logs a hint if a script is simple enough to be a projection.
- CONTENT-FILTER keeps (INCLUDE, paths are SpEL) or removes (EXCLUDE, paths are dot notation) parts of the payload, with optional ‘flatten’ to turn nested maps into flat ones. It is meant for stripping sensitive or unimportant data before logging or sending on. Its type id is the only kebab-case one (typeIds).
- Guarantees. PROJECTION and TEMPLATE cannot touch Java classes (restricted SpEL; Handlebars has no code access). With strictMode true an evaluation error fails the message instead of passing through a half-mapped payload.
- State. Compiled templates, parsed expressions and parsed scripts are cached in plain maps keyed by their text, for the life of the process. Nothing is persisted.
- Ceiling. Those caches have no size limit: every distinct script or template ever submitted stays (risk: unboundedMaps). Script work runs on the engine’s threads with no time limit.
- On failure. A bad mapping fails at config check (missing mode or mapping, SCRIPT without the module). At run time an error follows strictMode.
MAPPER: mode, mapping, dropOriginal, strictMode, CONTENT-FILTER: mode, paths, flatten, strictMode, errorPort, script-js is selected with -Pyukta.plugins
Flow & state
DELAY, loops, KV_STORE Pace messages, repeat another plugin, and keep small facts: DELAY, LOOP_STREAM, LOOP_PREDICATE and KV_STORE.- DELAY holds each message for delayMs and releases them one at a time, in order. Ten messages with delayMs 100 take about a second, which is a simple way to stay under a rate limit.
- LOOP_STREAM and LOOP_PREDICATE run another processor again and again on the same message. You name it with targetPluginId and give it targetConfig. After each run an exitCondition (SpEL; it can use #iterationCount and #elapsedTimeMs) decides whether to stop. With no exitCondition the loop stops after the first iteration. Safety limits: maxIterations (default 10), maxDuration (ISO-8601 duration, default PT1M) and delayInterval between rounds (default PT0S). LOOP_STREAM emits every message every iteration produced; LOOP_PREDICATE emits only the last one.
- failureStrategy (default ABORT) says what a failing iteration does: ABORT fails the node, RETRY and SKIP both move on to the next iteration with the same last message (today they behave identically), ESCALATE throws an error whose text starts with ‘WorkflowExecutionException:’ but whose Java type is a plain RuntimeException (risk: loopEscalate).
- A loop finds its target through the plugin registry and calls it directly, so the target’s own config check is not run on targetConfig (risk: loopSkipsValidation). This is how Yukta gets polling and retry-until without breaking the ‘no cycles’ rule of a graph.
- KV_STORE is a shared map (in memory, or the kv_entry table with yukta.kv.store-type=postgres) with operations get, put, incr and delete. The key comes from config or the payload’s ‘key’ field, ‘namespace’ prefixes keys to keep users apart, and the output is {key, value}. ‘put’ stores payload.value, ‘incr’ adds payload.value (default 1) to a whole number. maxEntries (default 10000) refuses new keys beyond the cap, but in the in-memory store the size check and the insert are not one atomic step, so the cap can be slightly overshot under heavy concurrency. In PostgreSQL incr is one statement.
- Guarantees. Loops always end: by exit condition, maxIterations or maxDuration. KV_STORE operations on one key are atomic (incr uses an atomic merge), and a full store refuses new keys.
- State. KV_STORE is one store for the whole server: shared by every workflow and run. By default it is in memory, lost on restart and not shared between servers; with yukta.kv.store-type=postgres it survives a restart and is shared. Loops keep their iteration state only for the duration of one message.
- Ceiling. KV_STORE is capped at 10,000 keys by default, in one JVM. A loop’s cost is iterations times the target’s cost, and maxDuration is checked between iterations, not during one.
- On failure. A full KV store throws ‘KV store is full’. A non-whole number in incr throws. A loop target that is missing or is not a processor fails the node with a message naming it.
DELAY: delayMs, LOOP_*: targetPluginId, targetConfig, exitCondition, maxIterations (10), maxDuration (PT1M), delayInterval (PT0S), failureStrategy (ABORT), KV_STORE: op, key, namespace, maxEntries (10000)
External I/O
process, http, file, sql, mcp Calls the outside world: PROCESS_EXECUTOR, HTTP_REQUEST, FILE_READ, FILE_WRITE, SQL_QUERY and MCP_TOOL_CALL.- They share one pattern. Config is turned into a typed spec; a gateway does the I/O; failureMode ERROR (default) fails the node on a failed call; CONTINUE emits a message describing the failure; routeByStatus (HTTP_REQUEST, MCP_TOOL_CALL) or routeByExitCode (PROCESS_EXECUTOR) then stamps the message with port ‘success’ or ‘failure’. FILE_READ, FILE_WRITE and SQL_QUERY use CONTINUE to send failures to an ‘error’ port instead. A node timeout is derived from the plugin’s own: HTTP and MCP add 5 s, PROCESS_EXECUTOR adds 10 % (at least 5 s). Without that margin the engine’s idle timer (default 30 s) would kill a 120 s call after 30 s.
- PROCESS_EXECUTOR runs an operating-system command (a list: program then arguments, each of which may contain variables). inputMode NONE|ENV|STDIN passes the message in. timeout defaults to 300 s, output is capped at 10,000 lines and 10 MiB per stream, outputFormat STRUCTURED (default) gives {exitCode, success, timedOut, durationMillis, outputTruncated, stdout, stderr}. On timeout it destroys the process, then kills it after a grace period. useShell wraps the command in sh or cmd.exe but escapes every special character, so pipes and && never work.
- HTTP_REQUEST sends one request per message (url, method, headers, body, timeout default 30 s) and emits {status, success, headers, body}, with JSON bodies parsed. Non-2xx answers are data, not errors, until failureMode ERROR turns them into one. The URL may come from the message, and there is no host allow-list (risk: ssrf).
- FILE_READ and FILE_WRITE work only inside ‘baseDir’: the path is normalised, anything that escapes the folder or reaches out through a symbolic link is refused. Reads are capped (maxBytes, default 10 MiB) and must be UTF-8. But baseDir is itself workflow config, so the sandbox limits a path, not an author (risk: fileSandbox).
- SQL_QUERY runs one parameterised statement per message over JDBC: only ’?’ placeholders, filled from named payload fields in order, never by pasting text. It opens one connection per message, caps rows (maxRows 1000) and sets a statement timeout (30 s). The driver comes from the deployer. MCP_TOOL_CALL calls a tool on a remote MCP server, keeps a session per server and credentials, reports progress to the engine as heartbeats, and understands a ‘reset’ control signal that drops cached sessions.
- Guarantees. Every plugin here moves blocking work off the event threads, bounds its wait with a timeout and shows the outcome as data or a clear error. SQL values are always bound parameters. File paths cannot leave baseDir by ’..’ or by a link that already exists.
- State. MCP_TOOL_CALL caches sessions in memory. Everything else is stateless between messages. Secrets used here (apiKey, authToken, SQL password) live in the node’s config.
- Ceiling. HTTP_REQUEST, MCP_TOOL_CALL and PROCESS_EXECUTOR process messages with a default concurrency of 256 per node, so a burst can start 256 processes or requests at once (risk: fanout256). SQL opens a connection per message, with no pool.
- On failure. ERROR fails the node and the engine’s retry and onError policy applies. CONTINUE keeps the run alive. A timed-out process leaves children running (risk: orphans). A SQL node’s engine timeout is the 30 s default whatever timeoutSeconds says (risk: sqlTimeout).
PROCESS_EXECUTOR: command, inputMode, timeout 300, outputFormat, failureMode, routeByExitCode, maxOutputLines, maxOutputBytes, HTTP_REQUEST: url, method, headers, body, timeout 30, failureMode, routeByStatus, FILE_*: baseDir, path, maxBytes, contentField, append, failureMode, SQL_QUERY: url, username, password, sql, params, maxRows 1000, timeoutSeconds 30, MCP_TOOL_CALL: url, tool, arguments, authToken, timeout 60, resultKey
AI processors
LLM, memory, decisions Model-backed nodes: LLM_CALL, SESSION_MEMORY and the decision family CLASSIFY, GATE, SCORE, ASK, ANOMALY and DEDUPE.- LLM_CALL calls a chat model through Spring AI for providers openai (and openai-compatible endpoints such as Groq or vLLM through baseUrl), anthropic and ollama. It takes messages (a list of {role, content} turns or a SpEL expression), an optional prompt appended as the last user turn, and a system prompt. It emits {success, content, actions, usage, latencyMs, model, cost?}. Spring AI’s own retry is switched off so the plugin’s retries (default 0, exponential backoff from 500 ms) are the only ones.
- Tool use is by text: the model is asked to put JSON inside <agent_action> tags, LLM_CALL cuts those out into the ‘actions’ list, and the graph decides what to do (BRANCH then MCP_TOOL_CALL). It does not use the providers’ native tool-calling. ‘prices’ ({input, output} per million tokens) adds a cost figure, and a ‘budget’ ({maxTokens, maxCost, maxCalls, agent, hardStop}) stops further calls once a run has used it up (hardStop ends the whole run). With failureMode CONTINUE the original payload passes through with llmSuccess=false and llmError in metadata, so a second LLM_CALL with the same config can act as a fallback model.
- SESSION_MEMORY keeps chat history per session id. Mode ‘load’ (before the model) adds ‘history’ to the payload and records the user turn; mode ‘append’ (after) records the reply. maxTurns defaults to 20. One bean holds all sessions, keyed by id alone, cached in memory; the history is also kept in a file per session (yukta.session-memory.dir) or in PostgreSQL (yukta.session-memory.store-type=postgres).
- The decision family asks a model a constrained question about the message and writes the answer into metadata under ‘into’. CLASSIFY picks one of two or more labels (routeByLabel gives one port per label, plus ‘uncertain’ below minProbability 0.6). GATE asks a yes/no question and routes to ‘pass’ or ‘reject’ (threshold 0.5). SCORE rates on 2 to 26 ordered levels and can route ‘high’ or ‘low’ by threshold. ASK asks up to 64 mixed questions in one call. ANOMALY and DEDUPE compare each message with the last window messages of the node’s current run (ANOMALY window 20, minHistory 5, threshold 0.5; DEDUPE window 10, minProbability 0.6).
- Two backends: SYSTEMONE (default) calls Ollama’s /v1/systemone endpoint (needs Ollama 0.35 or newer and a decision model) and returns calibrated probabilities; LLM goes through LLM_CALL’s gateway and asks the model for JSON, where the probability is the model’s own estimate. What the model sees is the ‘state’ expression (default the whole payload), cut to 15,000 characters. A model failure fails the message; use the engine’s retry and onError for recovery.
- Guarantees. Decision nodes always produce a value in a known shape or an error: a label that is not one of the options, or a reply that is not JSON, is rejected. Probabilities are clamped to 0..1. History per session is trimmed to maxTurns.
- State. Conversation history (SESSION_MEMORY), the ANOMALY and DEDUPE windows and LLM_CALL’s cache of model clients are all in memory. Windows live only for one node in one run. Nothing survives a restart.
- Ceiling. Each model call is a network call that can take seconds and cost money, at up to 256 concurrent calls per node. Session memory grows with the number of distinct session ids and is never evicted.
- On failure. Timeouts and provider errors fail the node (ERROR) or pass the original payload on (CONTINUE). A decision model that is down fails each message. The user turn in SESSION_MEMORY is stored before the model answers.
LLM_CALL: provider, model, prompt, messages, system, baseUrl, apiKey, temperature, maxTokens, timeout 120, retries 0, prices, failureMode, routeByStatus, SESSION_MEMORY: mode, sessionId, message, assistant, maxTurns 20, Decision common: backend, provider, model, baseUrl, apiKey, timeout 30, state, into
Terminals
console, webhook Ends a branch: CONSOLE_TERMINAL logs the payload, NOTIFY_TERMINAL posts it to a webhook.- CONSOLE_TERMINAL writes ‘Consuming message: ’ to the application log at INFO for every message. It takes no settings. It is the quickest way to see a result, and also writes every payload in full to the logs (risk: consoleLogs).
- NOTIFY_TERMINAL sends {text: …} as a JSON POST (Slack-compatible). The text is the ‘text’ setting if set, else the payload’s ‘text’ field, else the payload turned into a string. url must be https; plain http is allowed only for localhost, 127.x and [::1].
- It sends one message at a time, in order. timeoutMs defaults to 10,000. failureMode ERROR (default) fails the node on a non-2xx answer, and redirects count as failures; CONTINUE logs the failure and keeps sending later messages.
- NOTIFY_TERMINAL’s url is fixed in config and is not run through the variable resolver, so a message cannot choose where it is sent. Any https host is still accepted, including internal ones.
- Guarantees. NOTIFY_TERMINAL never sends in clear text except to the local machine, never follows a redirect silently, and sends messages in order. A terminal only consumes: it has no output ports.
- State. None.
- Ceiling. One request in flight at a time per node, so a slow webhook paces the branch (back-pressure). CONSOLE_TERMINAL is bounded only by log volume.
- On failure. A failed webhook either fails the node or is logged and skipped. A failure sinks the branch’s result, not the other branches.
CONSOLE_TERMINAL: none, NOTIFY_TERMINAL: url, text, timeoutMs 10000, failureMode
Contract & variables
plugin-api, registry, ${…} The rules every plugin follows and the shared helpers they use: type registration, description, config schema, validation, ports, timeouts, failure helper, secrets and the variable language.- A plugin is a Spring bean implementing one of three interfaces (trigger, processor, terminal). The registry collects every plugin bean by getType(); a duplicate type id stops startup. Plugins are singletons: one instance serves every node of that type, with the node’s config passed on each call.
- A plugin may describe itself (text and a UI card), list its config fields (name, type, required, default; this generates the form in the UI, and field names must equal the keys the plugin reads), declare its output ports (possibly depending on config) and validate its own config. Four hooks run in order: validateConfig at submit, initialize and prepare at compile, shutdown at the end.
- Variables (${…}) are NOT resolved by the engine. Each plugin that wants them calls core’s resolver itself: HTTP_REQUEST, MCP_TOOL_CALL, LLM_CALL, SESSION_MEMORY, PROCESS_EXECUTOR, CONSTANT_SOURCE and the decision plugins (for model, baseUrl and apiKey). FILTER, BRANCH, SPLITTER and others use SpEL instead; FILE_*, SQL_QUERY, NOTIFY_TERMINAL and KV_STORE use their config literally. So SQL_QUERY’s password ‘decrypted:db’ is NOT resolved, although the form text suggests it.
- decrypted:key asks a SecretProvider. The default one reads the environment variable YUKTA_SECRET_<KEY> (key upper-cased, other characters become underscores), so a workflow cannot read arbitrary environment variables through it. {sys.p} read only an allow-list: YUKTA_VAR_* variables, yukta.var.* properties, a short list of harmless ones and names listed in YUKTA_VARIABLES_ALLOWED; names that look like secrets stay blocked even if listed.
- The shared helpers: FailureMode and a guard that turns a failure into an ‘error’-port message, a metrics counter hook, config validators (positive numbers, required strings) and SpelUtils, which runs SpEL in a restricted mode (read properties and call methods only; no types, constructors or bean references) with a 1,000-entry LRU cache.
- Guarantees. Restricted SpEL cannot reach Java classes. A plugin’s declared ports are the only ones the engine will route on. Secrets read by decrypted: come only from YUKTA_SECRET_ variables with the default provider.
- State. The registry is built once at startup. SpEL cache is in memory, bounded at 1,000.
- Ceiling. One instance per type means plugin code must be thread-safe, and any in-memory state is shared by all nodes and runs of that type.
- On failure. Duplicate type ids and invalid config fail fast, at startup or submit. An unresolved variable path gives an empty value; a secret that is missing is an error; a blocked env name raises a SecurityException.
yukta.plugins (build property, see Selection), YUKTA_SECRET_<KEY> environment variables for decrypted:key
Supervisors & planes
rules, model, rules-first Watches the run-event stream from outside the graph and can pause or stop runs or veto commands: RuleSupervisor, RuleControlPlane, ModelControlPlane and RulesFirstControlPlane.- A supervisor reads events (node status changed, workflow status changed, log line, control outcome) and returns commands (pause or resume or stop a workflow or node). Core’s host applies them with the supervisor as the named actor, rate-limited (default 20 per second) and with a dry-run switch. Separately a supervisor may review any command, human or automated, and refuse it.
- RuleSupervisor (id ‘rules’, on when yukta.supervisor.rules.file is set) reads a JSON file of rules. A reacting rule has ‘when’ (event, status, module, nodeId, workflowId, an ‘if’ SpEL condition, and optionally count within withinSeconds, default 60) and ‘then’ (command, mode, reason, immediate). Example: three FAILURE statuses from HTTP_REQUEST within 60 s on one run, then PauseWorkflow. A deny rule has ‘command’, optional ‘if’ and ‘reason’ and vetoes matching commands, for example StopWorkflow with mode KILL.
- Failure bias is deliberate. If a deny rule’s condition cannot be evaluated, the command is denied (fail closed). If an event rule’s condition cannot be evaluated, the rule simply does not fire. An unknown command name in a rule fails at load, not at run time.
- RuleControlPlane (yukta.control-plane.rules.file) is the same rules behind the control-plane contract: one decide(event) per event, with probability 1 and source RULE. ModelControlPlane (on when yukta.control-plane.model.name is set) asks a decision model a multiple-choice question about each event: ‘instructions’ is the question, options.<key> has a description and a command (NONE, PAUSE or STOP). By default it is asked about node and workflow status changes only, the defaults are continue=NONE and pause=PAUSE so it cannot stop anything unless configured, timeout is 2 s and min-probability 0.6.
- The model plane fails open: if the model is down, slow or unsure, the decision is ‘no opinion’ and the run continues. It does not review commands (everything is allowed). RulesFirstControlPlane (on when yukta.control-plane.rules-first.model.name is set) asks rules first and consults the model only when no rule fired; a rule error is treated as no rule; deny reviews always come from the rules.
- Guarantees. A deny rule cannot be bypassed by the model plane: reviews in the rules-first plane come from rules only. A broken deny condition blocks rather than allows. A rule file with an unknown field, a missing id, or a rule that is neither ‘when/then’ nor ‘deny’, fails at startup.
- State. Rules are read once at startup and never reloaded. Count windows are kept per rule and run in memory and are never removed when a run ends. The model plane keeps no state.
- Ceiling. ModelControlPlane makes one model call per matching event, serially (concatMap), so a busy server asks the model a lot and a slow model delays decisions without limit other than its 2 s timeout.
- On failure. Model errors or timeouts mean ‘no opinion’ (fail open). A rule that cannot parse stops startup. A reacting rule that names an unknown command fails at load.
yukta.supervisor.rules.file, yukta.control-plane.rules.file, yukta.control-plane.model.name, .backend, .provider, .base-url, .api-key, .timeout (2s), .min-probability (0.6), .instructions, .options.<key>.description/.command, .events, yukta.control-plane.rules-first.rules-file and .model.*, (host, in core) yukta.supervisor.max-commands-per-second (20), yukta.supervisor.dry-run
Selection & gates
-Pyukta.plugins, quality Decides which plugin modules are compiled in and holds each module to code-quality checks.- Always included: internal-core (the 12 routing, transform and flow types), the three triggers, both terminals, the rule supervisor and the three control planes. Optional, nine modules: process-executor, http-request, file-io, sql-query, llm-call, session-memory, decision, mcp-tool-call and script-js.
- Selection is a build property: -Pyukta.plugins=all (default), none, or a comma-separated list such as http-request,llm-call. An unknown name fails the build with a list of valid names. Choosing fewer modules shrinks the application and, more importantly, removes whole capabilities (for example, no PROCESS_EXECUTOR means a workflow cannot run programs at all).
- The three control-plane modules are always compiled in, the model plane exposes the decision module, and the decision module depends on llm-call. So, reading the build files, CLASSIFY, GATE, SCORE, ASK, ANOMALY, DEDUPE and LLM_CALL are on the runtime classpath even with -Pyukta.plugins=none (not confirmed by running a build). Optional means optional to name, not necessarily absent (risk: optionalNotOptional).
- Each module is checked by the normal gates (formatting, Checkstyle, PMD, SpotBugs, coverage). internal-core and constant-source switch off PMD main and test, and internal-core also switches off Checkstyle on tests, with a note to re-enable. The riskiest code (the script mapper) therefore has the lightest checks.
- About 12,000 lines of main code across 19 modules. Leftover folders exist on disk with no source and no entry in settings.gradle.kts (triggers/auto-trigger, processors/scripting): they are not plugins.
- Guarantees. A build with a given list contains exactly those modules; an unknown name fails fast. Duplicate type ids across included modules stop startup.
- State. Build-time only. A deployment cannot add or remove an optional plugin without rebuilding.
- Ceiling. All-or-nothing per module: you cannot keep HTTP_REQUEST but forbid private addresses. Runtime allow-lists exist for commands (yukta.process.allowed-commands), MCP tools and LLM prompts, not for hosts.
- On failure. Unknown plugin names fail the Gradle configuration step. Disabled gates mean violations are not caught.
-Pyukta.plugins=all|none|a,b,c (boot/build.gradle.kts)
Scenarios
Tool-using chat turn
A chat agent that remembers the conversation, lets a model call a tool, and posts the reply. Use it first to learn how the families fit together.A message arrives
History is loaded
The model answers
BRANCH picks a path
The tool runs
Final answer, save, post
A failing external call
What an outside call does when it fails, how the failure pattern turns it into something you can route on, and the burst case.The request fails
ERROR mode: the node fails
CONTINUE mode: a status you can route on
The fallback runs
The burst problem
A script, and where its limits end
How MAPPER’s SCRIPT mode works and why it is the sharpest edge in the module.An author writes a script
The script is compiled once
Each message runs it
A hostile script
Choosing the safe path
Poll until ready with a loop
How a loop node repeats another plugin inside one node, which limits end it, and where its shortcuts show.Configure the loop
The target is looked up
Each round
Limits end it
A round fails
A rule pauses a run
How a written rule watches a run, pauses it, and how a deny rule refuses a command.The rules are loaded
Events stream in
A counting rule fires
The host applies it
A deny rule vetoes
Model control plane, model down
How a model-driven control plane behaves, including when the model is unavailable, and why rules-first is safer.Configure the plane
An event arrives
The model answers
The model is down
Rules-first as the safer shape
Capabilities
Eighteen things the plugins can do, grouped by purpose, each with what it guarantees and where it stops.Starting runs
Routing and shaping
Flow and state
Calling out
Models and memory
Watching runs
Finishing
Guarantees, limits and defaults
What the plugins promise, what they leave to the engine, and what is not enforced at all. Open the tables to see how messages are ordered, what is lost on a restart and which protections exist only as advice.How messages are processed
The engine hands each plugin a stream. How a plugin consumes it decides ordering and concurrency.What survives a restart
Enforced or not
A table of protections a reader might assume. ‘Enforced’ means code rejects or limits it; ‘Not enforced’ means only the workflow author’s care stands in the way.Timeouts and margins
Reference
Catalogue 1: triggers, routing, transform, flow
Type id, what it does, key settings with defaults, and notes. All ‘strictMode’ defaults are true. Risk ids refer to the Risks board.Catalogue 2: external I/O and AI
All of these use failureMode ERROR (default) or CONTINUE. Where routeByStatus or routeByExitCode exists it needs CONTINUE and adds ports ‘success’ and ‘failure’.Catalogue 3: terminals, supervisors, control planes
Decision plugins share these settings: backend SYSTEMONE|LLM (SYSTEMONE), provider (needed for LLM), model, baseUrl (http://localhost:11434 for SYSTEMONE), apiKey, timeout (30 s), state (payload, cut to 15,000 characters), into (the metadata key).The ${…} variable syntax
Only plugins that call the resolver use this (see the Contract component). If the whole value is one variable it keeps its type; mixed with text it becomes text.Which module brings which types
Always-on modules are compiled into every build. Optional modules are picked with -Pyukta.plugins.Rule file fields
One JSON file, {“rules”: […]}. Each rule has an id and either when/then or deny. Unknown fields fail at startup.Architecture decisions
One singleton bean per plugin type, found by type id
One singleton bean per plugin type, found by type id
Typed spec, gateway, failure mode and routeBy ports for external calls
Typed spec, gateway, failure mode and routeBy ports for external calls
Plugins resolve their own variables
Plugins resolve their own variables
Optional plugins are chosen at build time
Optional plugins are chosen at build time
Two backends behind one DecisionModel interface
Two backends behind one DecisionModel interface
Model tool use is explicit in the graph, as text actions
Model tool use is explicit in the graph, as text actions
State is in memory first
State is in memory first