Back to the library
Agent loop, converging
Variant: Converging
Think, act, observe, around again. The payload rides the wire and each node lights as it arrives; the error bar updates on observe rather than on act, because that is when the agent actually learns anything. Each correction overshoots by about a third of the last, so four laps land it and the loop exits instead of spinning. Reach for it when a diagram has to show that a loop terminates.
controldiagramllmloop
The source
A complete HTML document. Paste it into an empty .html file and it plays — no stylesheet, script, font, or image to fetch.
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>Agentic loop, converging</title>
<style>
:root {
--ivory: #faf9f5;
--slate: #141413;
--clay: #d97757;
--oat: #e3dacc;
--gray-500: #87867f;
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--wire: cubic-bezier(0.65, 0, 0.35, 1);
}
* {
box-sizing: border-box;
margin: 0;
padding: 0;
}
body {
min-height: 100vh;
display: grid;
place-items: center;
background: var(--ivory);
font-family:
system-ui,
-apple-system,
'Segoe UI',
Roboto,
sans-serif;
color: var(--slate);
}
.loop {
width: 264px;
}
.loop svg {
display: block;
width: 100%;
height: auto;
}
.box {
fill: var(--ivory);
stroke: var(--oat);
stroke-width: 1.5;
}
.box-on {
fill: rgba(217, 119, 87, 0.1);
stroke: var(--clay);
stroke-width: 1.5;
opacity: 0;
}
.label {
font-size: 10px;
font-weight: 600;
letter-spacing: 0.07em;
fill: var(--slate);
text-anchor: middle;
}
.wire {
fill: none;
stroke: var(--oat);
stroke-width: 1.5;
}
.head {
fill: var(--oat);
}
/* One keyframe pair drives all three nodes; the delay is the only
difference between them, so the think/act/observe beat is set by the
geometry rather than by three hand-tuned timelines. Four pulses in 5s,
then the fifth second is deliberately empty — the loop has exited. */
.node {
animation: node-lift 5s infinite;
animation-delay: var(--d);
}
.node .box-on {
animation: node-on 5s infinite;
animation-delay: var(--d);
}
.n-think {
--d: 0s;
transform-origin: 40px 38px;
}
.n-act {
--d: 0.3s;
transform-origin: 132px 38px;
}
.n-observe {
--d: 0.62s;
transform-origin: 224px 38px;
}
@keyframes node-on {
0% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
1.6%,
2.8% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
4.4% {
opacity: 0;
}
20% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
21.6%,
22.8% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
24.4% {
opacity: 0;
}
40% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
41.6%,
42.8% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
44.4% {
opacity: 0;
}
60% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
61.6%,
62.8% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
64.4%,
100% {
opacity: 0;
}
}
@keyframes node-lift {
0% {
transform: scale(1);
animation-timing-function: var(--ease-out);
}
1.6%,
2.8% {
transform: scale(1.03);
animation-timing-function: var(--ease-out);
}
4.4% {
transform: scale(1);
}
20% {
transform: scale(1);
animation-timing-function: var(--ease-out);
}
21.6%,
22.8% {
transform: scale(1.03);
animation-timing-function: var(--ease-out);
}
24.4% {
transform: scale(1);
}
40% {
transform: scale(1);
animation-timing-function: var(--ease-out);
}
41.6%,
42.8% {
transform: scale(1.03);
animation-timing-function: var(--ease-out);
}
44.4% {
transform: scale(1);
}
60% {
transform: scale(1);
animation-timing-function: var(--ease-out);
}
61.6%,
62.8% {
transform: scale(1.03);
animation-timing-function: var(--ease-out);
}
64.4%,
100% {
transform: scale(1);
}
}
.token {
fill: var(--clay);
animation: token 5s linear infinite;
}
/* Three full laps, then a fourth that never takes the return leg: the
exit is the whole point, so it is the only lap allowed to break the
pattern. Corners stay linear — this is a signal on a wire, not an
object with weight. The return leg gets 210ms for its 184px rather
than 100ms: a forward hop covers 92px in 180ms, so the way back is
still visibly the quick leg without crossing into a skipped frame. */
@keyframes token {
0% {
transform: translate(0px, 0px);
opacity: 0;
}
1.2% {
opacity: 1;
}
2.4% {
transform: translate(0px, 0px);
animation-timing-function: var(--wire);
}
6%,
8.8% {
transform: translate(92px, 0px);
}
12.4%,
14% {
transform: translate(184px, 0px);
}
15% {
transform: translate(184px, 34px);
}
19.2% {
transform: translate(0px, 34px);
animation-timing-function: var(--ease-out);
}
20%,
22.4% {
transform: translate(0px, 0px);
animation-timing-function: var(--wire);
}
26%,
28.8% {
transform: translate(92px, 0px);
}
32.4%,
34% {
transform: translate(184px, 0px);
}
35% {
transform: translate(184px, 34px);
}
39.2% {
transform: translate(0px, 34px);
animation-timing-function: var(--ease-out);
}
40%,
42.4% {
transform: translate(0px, 0px);
animation-timing-function: var(--wire);
}
46%,
48.8% {
transform: translate(92px, 0px);
}
52.4%,
54% {
transform: translate(184px, 0px);
}
55% {
transform: translate(184px, 34px);
}
59.2% {
transform: translate(0px, 34px);
animation-timing-function: var(--ease-out);
}
60%,
62.4% {
transform: translate(0px, 0px);
animation-timing-function: var(--wire);
}
66%,
68.8% {
transform: translate(92px, 0px);
}
72.4%,
78% {
transform: translate(184px, 0px);
opacity: 1;
}
82%,
100% {
transform: translate(184px, 0px);
opacity: 0;
}
}
.track {
fill: var(--oat);
}
.err {
fill: var(--clay);
transform-origin: 132px 114px;
animation: err-swing 5s infinite;
}
.state {
fill: var(--clay);
stroke: var(--ivory);
stroke-width: 2;
animation: state-swing 5s infinite;
}
/* The readout moves when observe fires, not when act fires — the world
changed a beat earlier, the agent only finds out here. Each correction
overshoots by about a third of the last one, so four laps is genuinely
enough to land it. */
@keyframes err-swing {
0% {
transform: scaleX(-1);
opacity: 0;
animation-timing-function: var(--ease-out);
}
1.2%,
12.4% {
transform: scaleX(-1);
opacity: 1;
animation-timing-function: var(--ease-out);
}
16.4%,
32.4% {
transform: scaleX(0.38);
animation-timing-function: var(--ease-out);
}
36.4%,
52.4% {
transform: scaleX(-0.14);
animation-timing-function: var(--ease-out);
}
56.4%,
72.4% {
transform: scaleX(0.05);
animation-timing-function: var(--ease-out);
}
76.4%,
92% {
transform: scaleX(0.012);
opacity: 1;
animation-timing-function: var(--ease-out);
}
97%,
100% {
transform: scaleX(0.012);
opacity: 0;
}
}
@keyframes state-swing {
0% {
transform: translateX(-92px);
opacity: 0;
animation-timing-function: var(--ease-out);
}
1.2%,
12.4% {
transform: translateX(-92px);
opacity: 1;
animation-timing-function: var(--ease-out);
}
16.4%,
32.4% {
transform: translateX(35px);
animation-timing-function: var(--ease-out);
}
36.4%,
52.4% {
transform: translateX(-13px);
animation-timing-function: var(--ease-out);
}
56.4%,
72.4% {
transform: translateX(5px);
animation-timing-function: var(--ease-out);
}
76.4%,
92% {
transform: translateX(1px);
opacity: 1;
animation-timing-function: var(--ease-out);
}
97%,
100% {
transform: translateX(1px);
opacity: 0;
}
}
.goal {
stroke: var(--slate);
stroke-width: 2;
}
.goal-on {
stroke: var(--clay);
stroke-width: 4;
opacity: 0;
animation: goal-hit 5s infinite;
}
@keyframes goal-hit {
0%,
76.4% {
opacity: 0;
animation-timing-function: var(--ease-out);
}
78%,
92% {
opacity: 1;
animation-timing-function: var(--ease-out);
}
97%,
100% {
opacity: 0;
}
}
.caption {
font-size: 9px;
letter-spacing: 0.1em;
fill: var(--gray-500);
text-anchor: middle;
}
@media (prefers-reduced-motion: reduce) {
.node,
.node .box-on,
.token,
.err,
.state,
.goal-on {
animation: none;
}
/* The reduced view has to hold the *conclusion*, not a random frame of
it: the marker parked on the goal, the goal lit, and an error bar
that has shrunk to nothing. Killing the marker while leaving the
full-width error bar swinging — which is what this used to do —
removes the explanation and keeps the movement, exactly backwards. */
.token {
opacity: 0;
}
.err {
transform: scaleX(0.012);
}
.state {
opacity: 1;
transform: translateX(1px);
}
.goal-on {
opacity: 1;
}
}
</style>
</head>
<body>
<div
class="loop"
role="img"
aria-label="An agent loop of think, act and observe running four laps; each correction overshoots the goal by less than the last until the error lands on the goal and the loop exits"
>
<svg viewBox="0 0 264 144">
<line class="wire" x1="76" y1="48" x2="90" y2="48" />
<polygon class="head" points="90,44.5 96,48 90,51.5" />
<line class="wire" x1="168" y1="48" x2="182" y2="48" />
<polygon class="head" points="182,44.5 188,48 182,51.5" />
<path class="wire" d="M224 58 V74 Q224 82 216 82 H48 Q40 82 40 74 V65" />
<polygon class="head" points="36.5,65 40,59 43.5,65" />
<g class="node n-think">
<rect class="box" x="6" y="18" width="68" height="40" rx="8" />
<rect class="box-on" x="6" y="18" width="68" height="40" rx="8" />
<text class="label" x="40" y="35">THINK</text>
</g>
<g class="node n-act">
<rect class="box" x="98" y="18" width="68" height="40" rx="8" />
<rect class="box-on" x="98" y="18" width="68" height="40" rx="8" />
<text class="label" x="132" y="35">ACT</text>
</g>
<g class="node n-observe">
<rect class="box" x="190" y="18" width="68" height="40" rx="8" />
<rect class="box-on" x="190" y="18" width="68" height="40" rx="8" />
<text class="label" x="224" y="35">OBSERVE</text>
</g>
<circle class="token" cx="40" cy="48" r="4.5" />
<rect class="track" x="40" y="112" width="184" height="4" rx="2" />
<rect class="err" x="132" y="111" width="92" height="6" rx="3" />
<circle class="state" cx="132" cy="114" r="5" />
<line class="goal" x1="132" y1="104" x2="132" y2="124" />
<line class="goal-on" x1="132" y1="104" x2="132" y2="124" />
<text class="caption" x="132" y="138">GOAL</text>
</svg>
</div>
</body>
</html>
The variant family
Same idea, one axis moved. Compare them side by side before you commit.