Back to the library

Agent loop, open

Variant: Open loop

Think and act with nothing wired back — the observe step is drawn dashed and never lights, and the payload dies at the gate on every pass. With no measurement there is nothing to size the next correction against, so the same step lands three times and the error marches straight through the goal and out the far side. The marker is hollow because nothing measured it.

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, open</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 beat keeps its
           spacing under any pace roll. */
        --cycle: 3.6s;
        --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);
      }

      /* Everything downstream of act is drawn but dashed, and never lights.
         The missing step has to be visible: an absence you can point at reads
         as a design, a blank space reads as an unfinished drawing. */
      .off {
        stroke-dasharray: 3 3.5;
      }

      .head-off {
        fill: none;
        stroke: var(--oat);
        stroke-width: 1.5;
      }

      .label-off {
        fill: var(--_muted);
      }

      .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;
      }

      @keyframes node-on {
        0% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        2%,
        3.5% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        5.5% {
          opacity: 0;
        }
        25% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        27%,
        28.5% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        30.5% {
          opacity: 0;
        }
        50% {
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        52%,
        53.5% {
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        55.5%,
        100% {
          opacity: 0;
        }
      }

      @keyframes node-lift {
        0% {
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        2%,
        3.5% {
          transform: scale(1.03);
          animation-timing-function: var(--ease-out);
        }
        5.5% {
          transform: scale(1);
        }
        25% {
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        27%,
        28.5% {
          transform: scale(1.03);
          animation-timing-function: var(--ease-out);
        }
        30.5% {
          transform: scale(1);
        }
        50% {
          transform: scale(1);
          animation-timing-function: var(--ease-out);
        }
        52%,
        53.5% {
          transform: scale(1.03);
          animation-timing-function: var(--ease-out);
        }
        55.5%,
        100% {
          transform: scale(1);
        }
      }

      .token {
        fill: var(--clay);
        animation: token var(--t) linear infinite;
      }

      @keyframes token {
        0% {
          transform: translate(0px, 0px);
          opacity: 0;
        }
        1.5% {
          opacity: 1;
        }
        3.33% {
          transform: translate(0px, 0px);
          animation-timing-function: var(--ease-accent);
        }
        8.33%,
        12.22% {
          transform: translate(92px, 0px);
          animation-timing-function: var(--ease-accent);
        }
        17.22% {
          transform: translate(142px, 0px);
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        20.8% {
          transform: translate(142px, 0px);
          opacity: 0;
        }
        25% {
          transform: translate(0px, 0px);
          opacity: 0;
        }
        26.5% {
          opacity: 1;
        }
        28.33% {
          transform: translate(0px, 0px);
          animation-timing-function: var(--ease-accent);
        }
        33.33%,
        37.22% {
          transform: translate(92px, 0px);
          animation-timing-function: var(--ease-accent);
        }
        42.22% {
          transform: translate(142px, 0px);
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        45.8% {
          transform: translate(142px, 0px);
          opacity: 0;
        }
        50% {
          transform: translate(0px, 0px);
          opacity: 0;
        }
        51.5% {
          opacity: 1;
        }
        53.33% {
          transform: translate(0px, 0px);
          animation-timing-function: var(--ease-accent);
        }
        58.33%,
        62.22% {
          transform: translate(92px, 0px);
          animation-timing-function: var(--ease-accent);
        }
        67.22% {
          transform: translate(142px, 0px);
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        70.8%,
        100% {
          transform: translate(142px, 0px);
          opacity: 0;
        }
      }

      .track {
        fill: var(--oat);
      }

      .err {
        fill: var(--clay);
        transform-origin: 132px 114px;
        animation: err-march var(--t) infinite;
      }

      /* Hollow, because nothing measured it. The bar is the truth; the agent
         is not holding this number. */
      .state {
        fill: var(--ivory);
        stroke: var(--clay);
        stroke-width: 2;
        animation: state-march var(--t) infinite;
      }

      /* Three identical corrections — with no observation there is nothing to
         size the next one against, so the step never changes and the third one
         walks straight through the goal and out the far side. */
      @keyframes err-march {
        0% {
          transform: scaleX(-0.85);
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        1.5%,
        8.33% {
          transform: scaleX(-0.85);
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        13.9%,
        33.33% {
          transform: scaleX(-0.45);
          animation-timing-function: var(--ease-out);
        }
        38.9%,
        58.33% {
          transform: scaleX(-0.05);
          animation-timing-function: var(--ease-out);
        }
        63.9%,
        94% {
          transform: scaleX(0.35);
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        98%,
        100% {
          transform: scaleX(0.35);
          opacity: 0;
        }
      }

      @keyframes state-march {
        0% {
          transform: translateX(-78px);
          opacity: 0;
          animation-timing-function: var(--ease-out);
        }
        1.5%,
        8.33% {
          transform: translateX(-78px);
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        13.9%,
        33.33% {
          transform: translateX(-41px);
          animation-timing-function: var(--ease-out);
        }
        38.9%,
        58.33% {
          transform: translateX(-5px);
          animation-timing-function: var(--ease-out);
        }
        63.9%,
        94% {
          transform: translateX(32px);
          opacity: 1;
          animation-timing-function: var(--ease-out);
        }
        98%,
        100% {
          transform: translateX(32px);
          opacity: 0;
        }
      }

      .goal {
        stroke: var(--slate);
        stroke-width: 2;
      }

      .caption {
        font-size: 10px;
        letter-spacing: 0.1em;
        fill: var(--_muted);
        text-anchor: middle;
      }

      @media (prefers-reduced-motion: reduce) {
        /* Gentler, not absent. The terminal claim is untouched — the marker has
           walked past the goal and the error bar has changed sign, and both are
           still held — but the piece breathes instead of freezing. THINK and
           ACT still light in sequence on a slow 9s crossfade (2.5x the running
           duration), and the third beat of that cycle passes with nothing lit:
           the dashed OBSERVE box never comes on, so the missing step is still
           the thing you can point at. Opacity only — no travel along the wire,
           no scale, no token. */
        .node,
        .token {
          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));
        }

        @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, so
           the sign of the error is legible at any moment without the bar ever
           moving. */
        @keyframes readout-breathe-reduced {
          0%,
          100% {
            opacity: 1;
          }
          50% {
            opacity: 0.45;
          }
        }

        .token {
          opacity: 0;
        }

        .err {
          transform: scaleX(0.35);
          animation: readout-breathe-reduced var(--_calm) ease-in-out infinite;
        }

        .state {
          opacity: 1;
          transform: translateX(32px);
          animation: readout-breathe-reduced var(--_calm) ease-in-out infinite;
        }
      }
    </style>
  </head>
  <body>
    <div
      class="loop"
      role="img"
      aria-label="An open loop: think and act fire three times with the observe step missing, so the same correction is applied every pass and the error walks straight past the goal"
    >
      <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 off" x1="168" y1="48" x2="182" y2="48" />
        <polygon class="head-off" points="182,44.5 188,48 182,51.5" />
        <path class="wire off" d="M224 58 V74 Q224 82 216 82 H48 Q40 82 40 74 V65" />
        <polygon class="head-off" 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>
          <rect class="box off" x="190" y="18" width="68" height="40" rx="8" />
          <text class="label label-off" 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" />
        <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.