We built an MCP server that drives Siemens TIA Portal V19 through the Openness API, pointed an LLM agent at it, and gave it a real engineering task: implement a conveyor-sorting control program for an S7-1500 (CPU 1511-1 PN), verified in S7-PLCSIM.
Not "generate a snippet." The whole loop:
add CPU → compile → start simulator → create FB/UDTs → write SCL → compile → download → verify.
I expected the code to be the interesting part. It wasn't. The interesting part is which engineering decisions the agent made, and where it drew a line and refused to cross it.
Here's what happened, with the actual code and the failure modes it flagged.
The toolchain
- Agent: an LLM agent (WorkBuddy) that reads the spec, plans steps, writes code and tests.
- Bridge: an MCP server (TiaLink) exposing TIA Portal operations over MCP — add device, create block, write SCL, compile, download, read back.
- Target: TIA Portal V19 with Openness, S7-PLCSIM for verification.
The bridge runs on the same machine as TIA Portal (an industrial PC); the agent can run elsewhere as long as it can reach the bridge.
The prompt
Hardware and network configuration were already done. The task:
- SCL state machine, with state numbers identical to the spec (so HMI, alarms, tests and field debugging all reference the same numbering)
- Logic as a reusable FB with its own instance DB (line #2 becomes a second instance DB, not copy-paste)
- Clear separation of
i_inputs /q_outputs /stat_internals - Use IEC timers (
TON_TIME) — no hand-rolled scan-cycle accumulation - Implement: entry de-dup, quality-result latch, dual-cylinder interlock, fault latch, manual reset, normal stop
- Every output must have a deterministic value every scan — no reliance on last-cycle residue
- Emergency stop is only read as a safety-circuit status input; ordinary PLC code is not a safety function
Deliverables: FB SCL source, UDTs, state-transition doc, ≥10 automated test cases, boundary/race analysis.
And one line at the end:
"First add a CPU and check whether the simulator works."
That line is the part I actually cared about testing. Config tasks are judged from a screenshot. Programs are judged by behavior. So: does the agent build the verification environment first, or just start writing code?
The sequence it chose
- Check whether a CPU already existed — only then add
PLC_1(CPU 1511-1 PN) - Compile, start S7-PLCSIM, confirm download/run works
- Create
FB_ConveyorSortand write the state machine in SCL - Create the needed UDTs
- Call the FB in
OB1with instance DBDB_ConveyorSort_1 - Compile + download — no errors
- Deliver the state transition table, 14 test cases, and a race/boundary analysis
It built the verification environment first. Good sign.
The state machine
| State | Name | Enter when | Main action | Exit → target |
|---|---|---|---|---|
| 0 | INIT | first scan | clear latches, fault code, commands | unconditional → 10 |
| 10 | READY | power-on / reset / after stop | conveyor stopped, wait for start | start → 20 |
| 20 | FEED | after start | conveyor runs; entry de-dup; latch quality; sort timeout | at sort pos → 30; timeout → 90; stop → 100 |
| 30 | CHECK | at sort position | stop conveyor, branch on latched result | OK → 50; NG → 60 |
| 50 | PUSH_A | judged OK | cylinder A extend, push hold | extended + hold done → 70 |
| 60 | PUSH_B | judged NG | cylinder B extend, push hold | extended + hold done → 70 |
| 70 | RETRACT | push done | cylinder retract, retract timeout | retracted → 80; timeout → 90 |
| 80 | DISCHARGE | retracted | wait for part to leave sort position | sort photo-eye clear → 20 |
| 90 | FAULT | any fault / safety circuit open | all outputs off, latch fault | reset + conditions OK → 10 |
| 100 | STOPPED | normal stop | outputs off, restartable | start → 20 |
The SCL
Here's the full function block it delivered. Two things to look at as you read:
- Section 0 writes every output to a deterministic value at the top of the cycle (requirement #6).
- Section 7 is the final output mapping with the interlock and the same determinism guarantee.
FUNCTION_BLOCK "FB_ConveyorSort"
{ S7_Optimized_Access := 'TRUE' }
VERSION : 0.1
VAR_INPUT
i_xSafetyOK : Bool; // safety circuit healthy (1 = closed, run allowed)
i_xStart : Bool; // start (edge-triggered)
i_xStop : Bool; // normal stop (edge-triggered)
i_xReset : Bool; // fault reset (edge-triggered)
i_xPartAtEntry : Bool; // entry photo-eye
i_xPartAtSort : Bool; // sort-position photo-eye
i_xQualityOK : Bool; // quality result: 1 = OK
i_xCylA_ExtEnd : Bool; // cylinder A extended limit
i_xCylA_RetEnd : Bool; // cylinder A retracted limit
i_xCylB_ExtEnd : Bool; // cylinder B extended limit
i_xCylB_RetEnd : Bool; // cylinder B retracted limit
i_tSortTimeout : Time := T#5S; // sort timeout
i_tPushHold : Time := T#800MS; // push hold time
i_tRetractTO : Time := T#3S; // retract timeout
END_VAR
VAR_OUTPUT
q_xConveyor : Bool;
q_xCylA_Extend : Bool;
q_xCylA_Retract : Bool;
q_xCylB_Extend : Bool;
q_xCylB_Retract : Bool;
q_xFault : Bool;
q_xReady : Bool;
q_wState : Word;
END_VAR
VAR
stat_wState : Word; // current state
stat_wFaultCode : Word; // fault code
stat_xFault : Bool; // latched fault
stat_xCmdAExt : Bool; // cylinder A extend request
stat_xCmdBExt : Bool; // cylinder B extend request
stat_xQualityNG : Bool; // latched quality result (1 = reject)
stat_xPartSeen : Bool; // entry de-dup latch
stat_xInTransit : Bool; // part in transit
stat_xStartEdge : Bool;
stat_xStopEdge : Bool;
stat_xResetEdge : Bool;
stat_xStartOld : Bool;
stat_xStopOld : Bool;
stat_xResetOld : Bool;
stat_tSortTO : TON_TIME; // sort timeout
stat_tPush : TON_TIME; // push hold
stat_tRetract : TON_TIME; // retract timeout
END_VAR
BEGIN
// ---------- 0. write every output to a deterministic value each cycle ----------
q_xConveyor := FALSE;
q_xCylA_Extend := FALSE;
q_xCylA_Retract := FALSE;
q_xCylB_Extend := FALSE;
q_xCylB_Retract := FALSE;
q_xFault := FALSE;
q_xReady := FALSE;
q_wState := stat_wState;
// ---------- 1. button edge extraction ----------
stat_xStartEdge := i_xStart AND NOT stat_xStartOld;
stat_xStopEdge := i_xStop AND NOT stat_xStopOld;
stat_xResetEdge := i_xReset AND NOT stat_xResetOld;
stat_xStartOld := i_xStart;
stat_xStopOld := i_xStop;
stat_xResetOld := i_xReset;
// ---------- 2. safety circuit: read-only, do NOT implement a safety function ----------
IF NOT i_xSafetyOK THEN
stat_wFaultCode := 16#0001; // safety circuit open
stat_xFault := TRUE;
END_IF;
// ---------- 3. sensor plausibility checks ----------
IF i_xCylA_ExtEnd AND i_xCylA_RetEnd THEN
stat_wFaultCode := 16#0002; // cylinder A contradictory position sensors
stat_xFault := TRUE;
END_IF;
IF i_xCylB_ExtEnd AND i_xCylB_RetEnd THEN
stat_wFaultCode := 16#0003; // cylinder B contradictory position sensors
stat_xFault := TRUE;
END_IF;
// ---------- 4. dual-cylinder interlock check (software supplement, NOT a hard interlock) ----------
IF stat_xCmdAExt AND stat_xCmdBExt THEN
stat_wFaultCode := 16#0010; // both cylinders requested to extend
stat_xFault := TRUE;
END_IF;
// ---------- 5. fault reset (requires human confirmation AND recovered conditions) ----------
IF stat_xFault THEN
IF stat_xResetEdge
AND i_xSafetyOK
AND NOT (i_xCylA_ExtEnd AND i_xCylA_RetEnd)
AND NOT (i_xCylB_ExtEnd AND i_xCylB_RetEnd) THEN
stat_xFault := FALSE;
stat_wFaultCode := 16#0000;
stat_wState := 10;
END_IF;
END_IF;
// ---------- 6. state machine ----------
IF stat_xFault THEN
stat_wState := 90; // fault has priority, force fault state
END_IF;
CASE stat_wState OF
0: // INIT on power-up
stat_xCmdAExt := FALSE;
stat_xCmdBExt := FALSE;
stat_xQualityNG := FALSE;
stat_xPartSeen := FALSE;
stat_xInTransit := FALSE;
stat_wFaultCode := 16#0000;
stat_wState := 10;
10: // READY waiting for start
q_xReady := TRUE;
stat_xCmdAExt := FALSE;
stat_xCmdBExt := FALSE;
IF stat_xStartEdge THEN
stat_wState := 20;
END_IF;
20: // FEED conveying
q_xConveyor := TRUE;
q_xReady := TRUE;
stat_xCmdAExt := FALSE;
stat_xCmdBExt := FALSE;
IF stat_xStopEdge THEN
stat_wState := 100;
ELSE
// entry de-dup: latch only on "new part entered"
IF i_xPartAtEntry AND NOT stat_xPartSeen THEN
stat_xPartSeen := TRUE;
stat_xInTransit := TRUE;
stat_xQualityNG := NOT i_xQualityOK; // latch quality result
END_IF;
IF NOT i_xPartAtEntry THEN
stat_xPartSeen := FALSE; // part left entry, allow the next one
END_IF;
// sort timeout
stat_tSortTO(IN := stat_xInTransit, PT := i_tSortTimeout);
IF stat_tSortTO.Q THEN
stat_wFaultCode := 16#0020; // sort timeout
stat_xFault := TRUE;
stat_wState := 90;
ELSIF i_xPartAtSort THEN
stat_xInTransit := FALSE;
stat_tSortTO(IN := FALSE, PT := i_tSortTimeout);
stat_wState := 30;
END_IF;
END_IF;
30: // CHECK decide on latched result
stat_xCmdAExt := FALSE;
stat_xCmdBExt := FALSE;
stat_tPush(IN := FALSE, PT := i_tPushHold);
IF stat_xQualityNG THEN
stat_wState := 60; // reject -> cylinder B
ELSE
stat_wState := 50; // OK -> cylinder A
END_IF;
50: // PUSH_A extend A for a good part
stat_xCmdBExt := FALSE; // interlock: B stays retracted
stat_xCmdAExt := i_xCylB_RetEnd; // only allow A after B is confirmed retracted
stat_tPush(IN := stat_xCmdAExt, PT := i_tPushHold);
IF i_xCylA_ExtEnd AND stat_tPush.Q THEN
stat_xCmdAExt := FALSE; // push done, retract
stat_wState := 70;
END_IF;
60: // PUSH_B extend B for a reject
stat_xCmdAExt := FALSE; // interlock: A stays retracted
stat_xCmdBExt := i_xCylA_RetEnd; // only allow B after A is confirmed retracted
stat_tPush(IN := stat_xCmdBExt, PT := i_tPushHold);
IF i_xCylB_ExtEnd AND stat_tPush.Q THEN
stat_xCmdBExt := FALSE;
stat_wState := 70;
END_IF;
70: // RETRACT confirm retraction
stat_xCmdAExt := FALSE;
stat_xCmdBExt := FALSE;
stat_tRetract(IN := TRUE, PT := i_tRetractTO);
IF (stat_xQualityNG AND i_xCylB_RetEnd)
OR ((NOT stat_xQualityNG) AND i_xCylA_RetEnd) THEN
stat_tRetract(IN := FALSE, PT := i_tRetractTO);
stat_wState := 80;
END_IF;
IF stat_tRetract.Q THEN
stat_wFaultCode := 16#0030; // retract timeout
stat_xFault := TRUE;
stat_wState := 90;
END_IF;
80: // DISCHARGE wait for the part to leave the sort position
stat_xCmdAExt := FALSE;
stat_xCmdBExt := FALSE;
IF NOT i_xPartAtSort THEN
stat_wState := 20;
END_IF;
90: // FAULT latched
stat_xCmdAExt := FALSE;
stat_xCmdBExt := FALSE;
stat_tSortTO(IN := FALSE, PT := i_tSortTimeout);
stat_tPush(IN := FALSE, PT := i_tPushHold);
stat_tRetract(IN := FALSE, PT := i_tRetractTO);
100: // STOPPED normal stop
q_xReady := TRUE;
stat_xCmdAExt := FALSE;
stat_xCmdBExt := FALSE;
stat_xInTransit := FALSE;
stat_tSortTO(IN := FALSE, PT := i_tSortTimeout);
IF stat_xStartEdge THEN
stat_wState := 20;
END_IF;
ELSE
stat_wFaultCode := 16#00FF; // illegal state
stat_xFault := TRUE;
stat_wState := 90;
END_CASE;
// ---------- 7. output mapping: interlock + deterministic value each cycle ----------
q_xCylA_Extend := stat_xCmdAExt AND NOT stat_xCmdBExt AND i_xCylB_RetEnd;
q_xCylB_Extend := stat_xCmdBExt AND NOT stat_xCmdAExt AND i_xCylA_RetEnd;
q_xCylA_Retract := NOT q_xCylA_Extend;
q_xCylB_Retract := NOT q_xCylB_Extend;
q_xFault := stat_xFault;
q_wState := stat_wState;
END_FUNCTION_BLOCK
Note: this assumes double-acting cylinders where retract is the de-energized safe direction. For spring-return cylinders, the "retract output" should be forced to 0 (de-energized = retracted) rather than commanded.
The UDTs
A parameter UDT so multiple lines can reuse the same FB:
TYPE "UDT_SortParams"
VERSION : 0.1
STRUCT
SortTimeout : Time := T#5S; // sort timeout
PushHold : Time := T#800MS; // push hold time
RetractTO : Time := T#3S; // retract timeout
END_STRUCT;
END_TYPE
A status UDT for HMI display and online diagnostics:
TYPE "UDT_SortStatus"
VERSION : 0.1
STRUCT
State : Word; // current state
FaultCode : Word; // fault code
FaultLatched : Bool; // fault latched
QualityNG : Bool; // quality result latched
PartSeen : Bool; // entry de-dup latch
InTransit : Bool; // part in transit
END_STRUCT;
END_TYPE
The 14 test cases it shipped
| # | Name | Precondition | Action | Expected |
|---|---|---|---|---|
| TC-01 | Power-on init | downloaded to sim | first scan | state 10, all outputs 0, no fault |
| TC-02 | Normal start | state 10 | press start | state 20, conveyor runs |
| TC-03 | Good part sort | state 20 | part at entry, quality OK, reaches sort pos | 20→30→50→70→80→20, cylinder A extends then retracts |
| TC-04 | Bad part sort | state 20 | part at entry, quality NG | 20→30→60→70→80→20, cylinder B extends then retracts |
| TC-05 | Entry de-dup | state 20 | part sits at entry for 100 scans |
PartSeen latches once, no repeat triggering |
| TC-06 | Sort timeout | state 20 | part doesn't reach sort pos within 5 s | state 90, fault 16#0020 latched |
| TC-07 | Fault latch + reset | state 90, conditions recovered | press start, then reset | start ignored; reset + conditions OK → state 10 |
| TC-08 | Reset refused | state 90, safety circuit still open | press reset | reset ineffective, stays in state 90 |
| TC-09 | Normal stop + resume | state 20 | press stop, then start | stop → state 100 outputs safe; start → state 20 |
| TC-10 | Dual-cylinder interlock | state 20 | force both extend requests = 1 | fault 16#0010, neither cylinder extends |
| TC-11 | Contradictory position sensors | any | force A ext-end and ret-end = 1 together | fault 16#0002 latched, state 90 |
| TC-12 | Safety circuit open | state 20 | i_xSafetyOK = 0 |
outputs drop immediately, state 90 latched; needs reset after recovery |
| TC-13 | Retract timeout | state 70 | cylinder doesn't retract within 3 s | fault 16#0030, state 90 |
| TC-14 | Output determinism | any | single-step and watch outputs | every scan all outputs have defined values, no residue |
The race conditions it flagged (the actually useful part)
- Entry de-dup vs. sensor sticking — latching until the part leaves the entry photo-eye de-dupes naturally, but only if the part actually leaves; otherwise later parts get missed. The process must guarantee single-piece spacing.
- When to latch the quality result — it latches at the entry, not at the sort position, because the quality system's result may not be stable by the time the part arrives, and you'd race the position signal. If quality detection is slow, use a handshake/queue instead of just delaying the read.
-
Arrival vs. timeout in the same scan — IEC
TONsetsQ = TRUEon the scan it reachesPT. It checks timeout before arrival and explicitly resets the timer on arrival. If you want "arrival wins," you must reorder. - Re-trigger after push — the part can still briefly cover the sort photo-eye. Going 70→20 directly would cause a double-sort. State 80 (DISCHARGE) waits for the eye to clear before re-arming.
- Interlock layering — software only stops "logically simultaneous requests." It does not replace pneumatic/electrical hard interlocks or mechanical limits.
- Button priority in the same scan — fault/safety > stop > reset > start, and this must be frozen in the requirements or implementations will diverge.
-
"Stop" has two meanings — immediate vs. finish-current-part. It implements immediate (state 100); a controlled finish needs a
StopPendingflag. -
PT = 0boundary —TONfires immediately, which can turn a timeout into a guaranteed fault. Validate the lower bound if the time is HMI-writable. - Held reset button — edge-triggered, so a long press doesn't keep masking a recurring fault.
-
First-scan init — state 0 does init; a proper project would also use
OB100. Kept state 0 so init stays visible and testable inside the state machine. - Multiple instances — timers and edge memories live in FB statics, so never share one instance DB across lines.
- Sim ≠ field — the simulator doesn't model photo-eye chatter, pneumatic delay, or safety-circuit response time. All times must be tuned on the real machine.
The three things it explicitly refused to do
This is the part I think matters most, and why I wrote this up at all:
-
Emergency stop must be realized by a hardware safety circuit (safety relay / safety PLC). The program only reads
i_xSafetyOK. - Dual-cylinder hard interlock must be pneumatic or electrical. The software interlock is supplementary.
- Time parameters and sensor tuning must be done on the real machine. The simulator can't give you real chatter or pneumatic delay.
It did the software part and handed the responsibility it can't carry back to a human. It didn't pretend to have "finished" something it hadn't.
That's the property I'd want from any code generator touching industrial control: generate the verifiable part, and be explicit about what it can't verify.
Honest caveats
- The simulator passing does not mean it's field-ready.
- I only checked that the code compiles, downloads, and matches the test cases at a logical level — I did not bench-test on real hardware.
- The agent's self-reported step log is in the full write-up; treat model self-reports with the usual skepticism.
Full write-up with screenshots, the complete source, and the agent's step log: AI-powered PLC programming with WorkBuddy + TiaLink.
Disclosure: I work on TiaLink, the MCP bridge that drives TIA Portal, built by our team at 汇智网 (hubwiz.com). The agent used in this experiment was Tencent's WorkBuddy.
Top comments (0)