# Line agent

> An AI agent drawn as one line. No face: what it's doing is the shape.

Every AI product has a blob with eyes now. This one is a single closed line. It breathes while idle, leans toward you while it listens, draws itself round and round while it thinks, carries a bump round its edge like a pen while it works, settles with a small bounce when it's done and sags and shakes when something breaks.

It's always the same loop, and every change is eased with springs, so it never jumps between shapes.

Three dials set its personality: patience, confidence and chattiness. They change how it moves, not how it looks. A patient agent is slower and calmer, a confident one settles into a new state quickly, and a chatty one moves more and gets bored when you leave it alone.

## Install

Copy these files into your project:

### line-agent.js

```js
// Line agent: an AI agent drawn as one line, by Baked Design Studio (baked.design/interfaces/line-agent).
// No face. It's always the same closed loop, and what the agent is doing shows in how the loop behaves: it
// breathes while idle, leans toward you while it listens, draws itself round and round while it thinks, carries
// a bump round its edge like a pen while it works, closes with a small bounce when it's done and sags and
// shakes when something breaks. Every change is eased with springs, so it never jumps or tangles.
// Personality (patience, confidence, chattiness) changes how it moves, not how it looks.
//
//   import { mount } from "./line-agent.js";
//   const agent = mount(document.querySelector("#agent"));
//   agent.set("thinking");            // idle · listening · thinking · working · done · error
//   agent.hear();                     // call on each keystroke while listening
//   agent.tune({ patience: 0.8 });    // 0 to 1: patience, confidence, chattiness
//
// The line takes the element's text colour (currentColor). No dependencies.

const STATES = ["idle", "listening", "thinking", "working", "done", "error"];
const N = 120, TAU = Math.PI * 2, R = 54;

// What each state asks of the loop. Everything is a gentle change to one circle, so moving between states is a
// smooth blend, never a different drawing.
//   breath: slow swelling · wob: soft lobes · lean: how far it leans toward the pointer · arc: how much of the
//   loop is drawn (1 = all) · spin: how fast the drawn part travels round · bump: a travelling bulge (a pen)
//   · squash: flattening · sag: drooping · size: overall scale · fill: a faint wash inside (done)
const POSE = {
  idle: { breath: 0.035, wob: 0.025, lean: 0.1, arc: 1, spin: 0, bump: 0, squash: 0, sag: 0, size: 1, fill: 0 },
  listening: { breath: 0.02, wob: 0.015, lean: 0.24, arc: 1, spin: 0, bump: 0, squash: -0.04, sag: 0, size: 1.06, fill: 0 },
  thinking: { breath: 0.01, wob: 0.05, lean: 0, arc: 0.62, spin: 0.55, bump: 0, squash: 0, sag: 0, size: 0.94, fill: 0 },
  working: { breath: 0, wob: 0.01, lean: 0, arc: 1, spin: 0, bump: 0.16, squash: 0, sag: 0, size: 1, fill: 0 },
  done: { breath: 0.012, wob: 0, lean: 0, arc: 1, spin: 0, bump: 0, squash: 0, sag: 0, size: 1.08, fill: 0.06 },
  error: { breath: 0, wob: 0.035, lean: 0, arc: 1, spin: 0, bump: 0, squash: 0.16, sag: 0.1, size: 0.92, fill: 0 },
};
const KEYS = Object.keys(POSE.idle);

export function mount(el, options = {}) {
  const p = { patience: 0.5, confidence: 0.5, chattiness: 0.5, ...options };
  const still = matchMedia("(prefers-reduced-motion: reduce)");
  const NSV = "http://www.w3.org/2000/svg";
  const svg = document.createElementNS(NSV, "svg");
  svg.setAttribute("viewBox", "-100 -100 200 200");
  svg.setAttribute("aria-hidden", "true");
  Object.assign(svg.style, { width: "100%", height: "100%", display: "block", overflow: "visible" });
  const path = document.createElementNS(NSV, "path");
  Object.assign(path.style, { fill: "currentColor", fillOpacity: 0, stroke: "currentColor", strokeWidth: options.weight ?? 3, strokeLinecap: "round", strokeLinejoin: "round" });
  svg.append(path); el.append(svg);

  let state = STATES.includes(options.state) ? options.state : "idle";
  // each value eases toward its target with a spring (value + velocity), so changes never snap
  const cur = {}, vel = {};
  KEYS.forEach((k) => { cur[k] = POSE[state][k]; vel[k] = 0; });
  let t = 0, last = 0, raf = 0, seen = true, heard = 0, since = 9, turn = 0, pen = 0;
  let look = [0, 0], lookV = [0, 0], aim = [0, 0], lastMove = performance.now();

  function shape() {
    const pts = [], c = cur;
    const shake = state === "error" ? Math.sin(since * 26) * 5 * Math.exp(-since * 2.4) : 0;
    const pop = state === "done" ? Math.sin(Math.min(since, 1.2) * Math.PI / 1.2) * 0.06 * Math.exp(-since * 1.5) : 0;
    const ripple = heard * 0.05;
    for (let i = 0; i < N; i++) {
      const a = (i / N) * TAU - Math.PI / 2;
      let r = 1 + c.breath * Math.sin(t * 1.1) + c.wob * (Math.sin(2 * a + t * 0.45) * 0.6 + Math.sin(3 * a - t * 0.35) * 0.4)
        + ripple * Math.sin(5 * a - t * 9) + pop;
      // the pen: a soft bulge travelling round the edge
      const d = Math.atan2(Math.sin(a - pen), Math.cos(a - pen));
      r += c.bump * Math.exp(-(d * d) / 0.09);
      r *= R * c.size;
      // lean toward where the pointer is, more on the near side, like something paying attention
      const toward = Math.max(0, Math.cos(a - Math.atan2(look[1], look[0] || 1e-6))) * Math.hypot(look[0], look[1]);
      r *= 1 + c.lean * 0.35 * toward;
      const x = r * Math.cos(a) * (1 + c.squash) + look[0] * c.lean * 22 + shake;
      const y = r * Math.sin(a) * (1 - c.squash) + look[1] * c.lean * 14 + c.sag * R * (1 + Math.sin(a)) * 0.35;
      pts.push([x, y]);
    }
    return pts;
  }
  // a smooth closed path through the points (Catmull-Rom, drawn as cubic curves)
  function pathOf(pts) {
    const n = pts.length, q = (i) => pts[(i + n) % n];
    let d = `M${q(0)[0].toFixed(2)},${q(0)[1].toFixed(2)}`;
    for (let i = 0; i < n; i++) {
      const p0 = q(i - 1), p1 = q(i), p2 = q(i + 1), p3 = q(i + 2);
      d += `C${(p1[0] + (p2[0] - p0[0]) / 6).toFixed(2)},${(p1[1] + (p2[1] - p0[1]) / 6).toFixed(2)} ${(p2[0] - (p3[0] - p1[0]) / 6).toFixed(2)},${(p2[1] - (p3[1] - p1[1]) / 6).toFixed(2)} ${p2[0].toFixed(2)},${p2[1].toFixed(2)}`;
    }
    return d + "Z";
  }
  function draw() {
    path.setAttribute("d", pathOf(shape()));
    path.style.fillOpacity = Math.max(0, cur.fill).toFixed(3);
    // thinking draws only part of the loop and sends it round; at arc 1 the whole loop shows
    const L = path.getTotalLength?.() || TAU * R, on = Math.max(0.02, Math.min(1, cur.arc)) * L;
    if (cur.arc > 0.995) { path.style.strokeDasharray = ""; path.style.strokeDashoffset = ""; }
    else { path.style.strokeDasharray = `${on.toFixed(1)} ${(L - on + 0.01).toFixed(1)}`; path.style.strokeDashoffset = (-turn * L).toFixed(1); }
  }
  function frame(now) {
    raf = 0;
    const dt = Math.min(0.04, (now - (last || now)) / 1000); last = now;
    // patient agents move slower and calmer; impatient ones a little quicker
    const pace = 1.25 - p.patience * 0.5;
    t += dt * pace; since += dt; heard = Math.max(0, heard - dt * 1.6);
    // springs: confident agents settle quickly, unsure ones take their time; always critically damped (no wobble)
    const w = 4 + p.confidence * 5, goal = POSE[state];
    for (const k of KEYS) {
      let target = goal[k];
      if (k === "wob" || k === "breath") target *= 0.6 + p.chattiness * 0.8;
      const a = w * w * (target - cur[k]) - 2 * w * vel[k];
      vel[k] += a * dt; cur[k] += vel[k] * dt;
    }
    turn = (turn + cur.spin * dt * pace) % 1;
    pen += dt * pace * 2.2 * (state === "working" ? 1 : 0.3);
    // where it looks: the pointer, or, once bored (sooner if chatty), a slow look around
    const bored = (now - lastMove) / 1000 > 3 + 8 * (1 - p.chattiness);
    const want = bored && state === "idle" ? [Math.sin(t * 0.4) * 0.8, Math.sin(t * 0.27) * 0.3] : aim, lw = 3;
    for (let i = 0; i < 2; i++) { const a = lw * lw * (want[i] - look[i]) - 2 * lw * lookV[i]; lookV[i] += a * dt; look[i] += lookV[i] * dt; }
    draw();
    if (seen && !document.hidden && !still.matches) raf = requestAnimationFrame(frame);
  }
  const wake = () => { if (!raf) { last = 0; raf = requestAnimationFrame(frame); } };
  const settle = () => { KEYS.forEach((k) => { cur[k] = POSE[state][k]; vel[k] = 0; }); since = 9; draw(); };

  const onMove = (e) => {
    const r = el.getBoundingClientRect(); if (!r.width) return;
    const x = (e.clientX - (r.left + r.width / 2)) / (r.width * 1.5), y = (e.clientY - (r.top + r.height / 2)) / (r.height * 1.5);
    aim = [Math.max(-1, Math.min(1, x)), Math.max(-1, Math.min(1, y))]; lastMove = performance.now();
  };
  addEventListener("pointermove", onMove, { passive: true });
  const io = new IntersectionObserver(([e]) => { seen = e.isIntersecting; if (seen) wake(); });
  io.observe(el);
  const onVis = () => !document.hidden && wake();
  document.addEventListener("visibilitychange", onVis);

  settle(); wake();
  return {
    get state() { return state; },
    set(next) { if (!STATES.includes(next) || next === state) return; state = next; since = 0; if (still.matches) settle(); else wake(); },
    hear() { heard = Math.min(1, heard + 0.45); lastMove = performance.now(); wake(); },
    tune(next) { Object.assign(p, next); if (still.matches) settle(); else wake(); },
    destroy() { cancelAnimationFrame(raf); io.disconnect(); removeEventListener("pointermove", onMove); document.removeEventListener("visibilitychange", onVis); svg.remove(); },
  };
}
```

## Use

```js
import { mount } from "./line-agent.js";

const agent = mount(document.querySelector("#agent"), { patience: 0.6 });
agent.set("thinking");
// later
agent.set("done");
```

## Notes

- States: idle, listening, thinking, working, done and error. Call set() whenever your agent changes what it's doing; it moves between states on its own.
- Call hear() on each keystroke while someone types to your agent, so it reacts as they type.
- Give the element a size (it fills it) and a text colour (the line uses currentColor).
- tune({ patience, confidence, chattiness }) takes values from 0 to 1, any time.
- Respects reduced motion: with it on, the character holds a still pose for each state.
- It pauses when it's off screen or the tab is hidden. destroy() removes it.
- No dependencies. One plain JavaScript file.

By Baked Design Studio: https://baked.design/interfaces/
