leptons1618@anishgiri.dev:~/case-studies/ignition-copilot-designer less writeup.md
LLM Copilot — An AI Assistant in the Ignition Designer, Kept Away From the Keys
The interesting engineering in this Ignition module is not the model calls. It is four RPC methods, a context budget that gates live tag values, and a credential store bound to the host it was encrypted on.
- published
- August 20, 2026
- read
- 7 min
- stack
- 6 tools
- outcomes
- 10
- media
- —
01The Problem
The Ignition Perspective Designer is where a controls engineer does their real work — binding tags, building session templates — and it runs in a JVM frequently attached to a plant that is currently producing. An assistant that can edit a live project is genuinely dangerous; one that can only read it is not much use. The problem was to find the smallest amount of authority worth handing to a model, inside a platform where Designer code and Gateway code run in separate processes with different reach.
02The Solution
Split the module across the boundary Ignition already imposes. Common holds the DTOs and a four-method RPC contract; the gateway holds the provider clients, the context gatherer and the encrypted credential store; the designer holds nothing but a Swing chat panel. Whether the model sees the plant at all is an explicit argument on the chat call rather than an ambient permission, and live tag values are only gathered when the caller asks for FULL context.
The problem with putting a model next to a running plant
The Ignition Perspective Designer is where a controls engineer actually does their work — binding tags to components, building session templates, getting a faceplate animation to behave. It is also a desktop application running inside a JVM that is very often attached to a plant that is currently producing something.
That pairing is the whole design problem. An assistant that can edit a live project is a genuinely dangerous idea, and an assistant that can only read one is not worth much. So the question was never “how do we call a language model” — that part is a weekend. It was what is the smallest amount of authority we can hand over and still be useful?
Everything below follows from answering that first.
Ignition loads code in two JVMs, so the module ships in three jars
The module is com.axcend.llm.copilot, and it builds as three jars:
| Jar | Scope | What lives in it |
|---|---|---|
ignition-copilot-common | Gateway | DTOs, the ProviderType enum, the RPC contract |
ignition-copilot-gateway | Gateway | provider clients, context gathering, the HTTP API, the config pages |
ignition-copilot-designer | Designer | the chat panel and its settings |
Two hooks bring it up: GatewayHook on the gateway, DesignerHook in the Designer.
The split is not packaging tidiness. Designer code runs in the JVM that owns the project and can change it. Gateway code runs next to the tag runtime and can reach the plant. Putting the provider clients, the HTTP clients and the credential store on the gateway side means none of that is ever class-loaded into the process that owns the design. The Designer’s entire dependency on the outside world is four method calls.
The RPC contract is the design
The two JVMs share no memory, so every question crosses a declared interface in common. It has exactly four methods:
public interface LLMGatewayRpc {
String chat(String connectionName, String prompt, boolean includeContext,
String contextLevel, String projectName, int timeoutSeconds);
List<String> getConnectionNames();
String testConnection(String connectionName);
String getDefaultConnectionName();
}
Four methods is the point. The Designer never holds an API key, never opens a socket to a provider, and never decides what a model is allowed to do. It renders a panel and forwards a request.
Look closely at the signature of chat, though, because two of those parameters are doing real work: includeContext and contextLevel. Whether the model gets to see the plant’s tag tree at all is an explicit argument, not an ambient permission. The Designer asks for context; the gateway decides how much.
What the model is allowed to see
ContextAggregator pulls from three services — TagContextService, PerspectiveContextService, ProjectContextService — and merges them into a ContextData holding TagInfo, ViewInfo and ProjectInfo. It offers three levels, and they differ in more than volume:
| Level | Project | Tags | Views | Live values |
|---|---|---|---|---|
MINIMAL | no | no | no | no |
STANDARD | yes | 250 | yes | no |
FULL | yes | 500 | yes | yes |
That last column is the design decision I would point at first. A tag’s path is a name. A tag’s value is the live state of a machine. The aggregator only asks for values at FULL, and the trigger is a boolean:
int maxTags = level == ContextLevel.FULL ? DEFAULT_MAX_TAGS : DEFAULT_MAX_TAGS / 2;
List<TagInfo> tags = tagService.getTags("[default]", 5, maxTags, level == ContextLevel.FULL);
A plant’s live values are also the fastest-moving and most expensive part of the context, so gating them on the same flag that widens the budget keeps cost and sensitivity aligned. Capping at 500 tags and 50 views is what stops a large installation from producing a prompt nobody can afford.
Turning context into a prompt
PromptBuilder offers three strategies rather than one, because the right one depends on the provider and the question:
buildPrompt— renders context as readable Markdown under a system preamble and appends## User QuestionbuildContextMap— hands providers that accept structured context aMapinstead of prosebuildMinimalContext— a compact string that lists at most 20 tag paths and then says(+N more)
The system preamble is worth quoting, because the last line is the anti-hallucination measure:
You are an AI assistant helping with Ignition SCADA system development. … When referencing tags, views, or other resources, use their exact paths.
A model asked about a tag tree will otherwise cheerfully invent a plausible path. Telling it to use exact paths is cheap; verifying afterwards is not.
The budget is allocated progressively rather than evenly, and truncation is always visible:
if (contextSection.length() > maxContextLength / 2) { // tags give up first
contextSection.append("... (").append(contextData.getTags().size()).append(" tags total)\n");
break;
}
Tags yield at half the budget, views at three quarters, and whatever gets cut says how much it left behind. Silently truncating context is how you get confident answers about the 400 tags that are missing.
Secrets stay on the gateway, and are bound to the host
API keys are stored through EncryptionUtil, which is AES-256-GCM — 12-byte IV, 128-bit tag, 16-byte salt, 256-bit key — with the encryption key derived by PBKDF2 at 65,536 iterations from a seed and that per-record salt. The stored value is Base64 of salt || IV || ciphertext.
Deriving per record rather than reusing one key is the part worth keeping: two records encrypted under the same derived key would be comparable, and the salt is what prevents that.
The seed itself comes from system properties, which has a consequence that is easy to miss and expensive to discover: the ciphertext is bound to that gateway. Restoring the internal database onto different hardware will not decrypt the old keys, and they have to be re-entered. For a credential that only ever gets used from one machine, that is a defensible trade — it means a stolen database dump is not a set of usable keys. It should just be a documented one.
Failing in a way the Designer can act on
Errors are a closed set — LLMException.ErrorType covers invalid request, timeout and unknown — rather than a stack trace crossing an RPC boundary. Timeouts are clamped to a sane window on the way in:
int actualTimeout = Math.min(Math.max(timeoutSeconds, 5), MAX_TIMEOUT_SECONDS); // 120
Provider calls run on a cached pool of daemon threads named LLMService-Worker, so a hung request cannot keep the JVM from exiting, and a timeout cancels the future rather than blocking a request thread indefinitely.
Before a call goes out, chatWithConnection checks three things in order and refuses cleanly on each: that the named connection exists, that it is enabled, and that it has a non-empty key. Each is an INVALID_REQUEST with a message naming the connection — the Designer can show it, rather than surfacing a NullPointerException from inside a client.
And testConnection() does more than ping the provider. It returns a TestResult carrying success, measured latency and a message, then persists the outcome via updateVerificationStatus. The gateway therefore remembers whether a credential was last known good, which is the difference between a config screen that helps you and one that just stores what you typed.
One small thing that shows the care taken: JSON is handled with com.inductiveautomation.ignition.common.gson, the Gson that ships shaded inside the Ignition platform, rather than a bundled copy. A module that drags its own Gson onto the classpath of an application server is a module that eventually loads two of something.
Where I would want to go next
Honest gaps, read off the tree rather than off a roadmap:
- There are no tests in this repository. 32 Java files, 6,802 lines, and not one test source. For a module whose entire argument is that it is careful with authority, that is the wrong place to have no tests. The valuable ones are not unit tests — they are the ones that assert a proposal cannot reach a project without a human, and that a disabled connection refuses before a request leaves the gateway.
- The provider surface is a fixed enum with fixed default models (
gpt-4,claude-3-sonnet-20240229). Fine when written; it will age. - The RPC contract has no way to stream.
chatreturns aString, so the Designer gets a blocking call and a 5–120 second wait with nothing to render until the last token. A chat panel that cannot show progress is the single most visible thing missing from an otherwise careful design, and it is visible in the interface, not the implementation. - The sibling
ignition-copilotrepository is a different project — a Python MCP server with its own five test scripts — wearing a confusingly similar name. Worth deciding whether the two are meant to converge.
03Achievements
- One module in three jars — common, gateway and designer — so provider clients, HTTP clients and the credential store never load into the JVM that owns the project.
- A four-method RPC contract is the Designer's entire surface: it holds no API key, opens no provider socket, and decides no policy.
- `includeContext` and `contextLevel` are explicit arguments on the chat call, so access to the tag tree is requested rather than ambient.
- Live tag values are gathered only at FULL context level — the same flag that widens the budget, which keeps cost and sensitivity aligned.
- Context is truncated progressively (tags at half the budget, views at three quarters) and every cut says how much it left behind.
- The system prompt instructs the model to use exact tag and view paths, which is the cheap mitigation for confident invented paths.
- API keys use AES-256-GCM with the key derived by PBKDF2 at 65,536 iterations from a per-record salt and a host-derived seed.
- Timeouts are clamped to 5–120s and provider calls run on daemon threads, so a hung request can neither block a request thread nor hold the JVM open.
- `testConnection()` returns latency and persists the verification status, so the gateway remembers whether a credential was last known good.
- JSON goes through the Gson shaded inside the Ignition platform rather than a bundled copy, avoiding a second copy on the classpath.