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 {
/* Neutrals — fixed, never remixed. */
--ivory: #faf9f5;
--paper: #ffffff;
--oat: #e3dacc;
--slate: #141413;
/* Knobs — the remix API. A knob exists iff declared here and used below. */
--hue: 38.8;
--lift: 0;
--chroma: 1;
--speed: 1;
/* Accents — derived from the knobs with the family's offsets baked in,
so one hue roll moves them together. Defaults reproduce the original hex. */
--clay: oklch(calc(0.6724 + var(--lift)) calc(0.1308 * var(--chroma)) var(--hue));
--clay-d: oklch(calc(0.5797 + var(--lift)) calc(0.127 * var(--chroma)) var(--hue));
--olive: oklch(
calc(0.6118 + var(--lift)) calc(0.0713 * var(--chroma)) calc(var(--hue) + 88.3)
);
--rust: oklch(
calc(0.5408 + var(--lift)) calc(0.1357 * var(--chroma)) calc(var(--hue) - 10.3)
);
/* The caption gray sits off the neutral ramp — no mix of ivory and
slate lands on it — so it stays a literal. */
--_muted: #87867f;
/* Clock — every duration in the document runs off --t, and the node
delays divide by the same --speed, so the think/act/observe beat
keeps its spacing under any pace roll. */
--cycle: 5s;
--t: calc(var(--cycle) / var(--speed));
--_calm: calc(9s / var(--speed));
--ease-out: cubic-bezier(0.23, 1, 0.32, 1);
--ease-accent: 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);
}
/* Fluid rather than a hard 264px: the whole diagram is one SVG, so letting
the container take the stage width scales the 10-11px labels up with it
instead of leaving them sub-legible on a phone. Caps at 400px so a wide
stage cannot blow it up, and at 100% so a narrow one can never clip it. */
.loop {
width: min(400px, 100%);
}
.loop svg {
display: block;
width: 100%;
height: auto;
}
.box {
fill: var(--ivory);
stroke: var(--oat);
stroke-width: 1.5;
}
.box-on {
fill: color-mix(in srgb, var(--clay) 10%, transparent);
stroke: var(--clay);
stroke-width: 1.5;
opacity: 0;
}
.label {
font-size: 11px;
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 var(--t) infinite;
animation-delay: var(--_d);
}
.node .box-on {
animation: node-on var(--t) infinite;
animation-delay: var(--_d);
}
.n-think {
--_d: 0s;
transform-origin: 40px 38px;
}
.n-act {
--_d: calc(0.3s / var(--speed));
transform-origin: 132px 38px;
}
.n-observe {
--_d: calc(0.62s / var(--speed));
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 var(--t) 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(--ease-accent);
}
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(--ease-accent);
}
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(--ease-accent);
}
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(--ease-accent);
}
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 var(--t) infinite;
}
.state {
fill: var(--clay);
stroke: var(--ivory);
stroke-width: 2;
animation: state-swing var(--t) 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 var(--t) 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: 10px;
letter-spacing: 0.1em;
fill: var(--_muted);
text-anchor: middle;
}
@media (prefers-reduced-motion: reduce) {
/* Gentler, not absent. The conclusion is still what is held — the
marker parked on the goal, the goal lit, the error bar shrunk to
nothing — but it breathes rather than freezing. All three steps light
in sequence on the same slow 9s crossfade the other two variants use,
so THINK/ACT/OBSERVE reads as a complete loop; the difference from
the open variant is that no beat is ever dark. Opacity only — the
marker does not travel and the bar does not swing. */
.node,
.token,
.err {
animation: none;
}
.node .box-on {
animation: node-on-reduced var(--_calm) infinite;
animation-delay: var(--_r);
}
.n-think {
--_r: 0s;
}
.n-act {
--_r: calc(3s / var(--speed));
}
.n-observe {
--_r: calc(6s / var(--speed));
}
@keyframes node-on-reduced {
0%,
2% {
opacity: 0;
}
12%,
24% {
opacity: 0.9;
}
34%,
100% {
opacity: 0;
}
}
/* The readout keeps its terminal transform and breathes in place: here
that is the landed marker and the lit goal, glowing together. */
@keyframes readout-breathe-reduced {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.45;
}
}
.token {
opacity: 0;
}
.err {
transform: scaleX(0.012);
}
.state {
opacity: 1;
transform: translateX(1px);
animation: readout-breathe-reduced var(--_calm) ease-in-out infinite;
}
.goal-on {
opacity: 1;
animation: readout-breathe-reduced var(--_calm) ease-in-out infinite;
}
}
</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.