One model of background AI agents, five lenses — Board, Inbox, Tasks, Calendar, Graph — and every agent is one persistent card that physically travels between layouts: switching lens FLIPs each card from its old rect to its new one while it morphs from full card to row, time bar or graph node, so you never lose an agent. Each lens covers another's blind spot: drop intentions on the board (“Drop here = restart the agent”), dependency chains grouped in the inbox, child status summaries on collapsed tasks, open-ended runs fading off the calendar, live flowing edges in the dependency graph. Live progress, elapsed time and quick replies to blocked agents.
Canonical markup — start from it and customize with attributes, data-* and CSS variables:
<div id="al-demo" style="width:100%;max-width:78rem;margin-inline:auto">
<style>
#al-demo .al-head { display:flex; align-items:flex-end; justify-content:space-between; gap:.5rem 1.5rem; flex-wrap:wrap; margin:0 0 .75rem }
#al-demo .al-head h3 { margin:0; font-size:1.125rem; letter-spacing:-.01em }
#al-demo .al-head p { margin:.2rem 0 0; color:var(--mv-fg-muted); font-size:.8125rem }
#al-demo .al-hint { color:var(--mv-fg-muted); font-size:.75rem; line-height:1.9 }
#al-demo mv-agent-lenses { --mv-agent-lenses-max-height: 36rem }
#al-demo .al-log { display:grid; gap:.25rem; margin:.75rem 0 0; padding:0; list-style:none; font-size:.75rem; color:var(--mv-fg-muted) }
#al-demo .al-log li { display:flex; gap:.5rem; align-items:baseline; min-width:0 }
#al-demo .al-log code { flex:none; padding:.0625rem .375rem; border-radius:var(--mv-radius-sm); background:var(--mv-bg-muted); color:var(--mv-fg); font:500 .6875rem/1.4 var(--mv-font-mono) }
#al-demo .al-log span { overflow:hidden; text-overflow:ellipsis; white-space:nowrap }
</style>
<div class="al-head">
<div>
<h3>Fall collection launch</h3>
<p>Fieldnote Outfitters · 11 agents and subagents working in the background</p>
</div>
<span class="al-hint">Drag a board card, or press <kbd class="mv-kbd">Alt</kbd> <kbd class="mv-kbd">←</kbd> <kbd class="mv-kbd">→</kbd> on a focused card</span>
</div>
<mv-agent-lenses id="al-demo-lenses" label="Fall launch agents" lens="board"></mv-agent-lenses>
<ol class="al-log" id="al-demo-log" aria-label="Event log"></ol>
<script type="module">
const el = document.getElementById("al-demo-lenses");
const logEl = document.getElementById("al-demo-log");
const at = (min) => Date.now() + min * 60000;
// Set before the element is defined: the component picks it up on upgrade.
el.agents = [
{ id: "research", name: "Competitor research", goal: "Review the fall lines of six competing brands: pricing, materials, colorways.", status: "reviewed", start: at(-190), end: at(-122), updated: at(-96) },
{ id: "prices", name: "Price watch", goal: "Check competitor prices every hour and flag any gap above 10%.", status: "running", start: at(-172), trigger: "On every competitor price drop", updated: at(-9) },
{ id: "copy", name: "Product copywriting", goal: "Write the 48 product pages of the collection from the brief and the research.", status: "running", dependsOn: ["research"], start: at(-112), eta: at(58), progress: 0.58, updated: at(-3) },
{ id: "copy-coats", name: "Coats & jackets copy", parent: "copy", goal: "16 pages: fit, materials, care, size guide.", status: "done", start: at(-108), end: at(-41), updated: at(-41) },
{ id: "copy-knit", name: "Knitwear copy", parent: "copy", goal: "18 sweater and cardigan pages, highlighting recycled wool.", status: "running", start: at(-62), eta: at(24), progress: 0.66, updated: at(-2) },
{ id: "copy-acc", name: "Accessories copy", parent: "copy", goal: "14 pages for beanies, scarves and gloves.", status: "scheduled", start: at(22), eta: at(58), updated: at(-30) },
{ id: "translate", name: "German & Italian translation", goal: "Translate and localize the product pages for the Berlin and Milan stores.", status: "scheduled", dependsOn: ["copy"], eta: at(150), updated: at(-52) },
{ id: "visuals", name: "Campaign visuals", goal: "Homepage banner, six social posts and a 20-page lookbook.", status: "running", dependsOn: ["research"], start: at(-84), eta: at(72), progress: 0.41, updated: at(-6) },
{ id: "retouch", name: "Packshot retouching", parent: "visuals", goal: "Cutouts and color matching for 24 packshots.", status: "done", start: at(-78), end: at(-13), updated: at(-13),
needsAction: "All 24 retouched packshots are ready. Publish them to the store?", replies: ["Approve", "Request changes"] },
{ id: "newsletter", name: "Newsletter scheduling", goal: "Schedule the collection announcement for 42,000 subscribers.", status: "blocked", dependsOn: ["research"], start: at(-38), eta: at(35), updated: at(-19),
needsAction: "Send the newsletter on Thursday, Oct 2 at 9:00 AM?", replies: ["Approve", "Suggest another time"] },
{ id: "golive", name: "Store go-live", goal: "Publish the collection on all three storefronts and switch on the homepage banner.", status: "scheduled", dependsOn: ["translate", "visuals"], eta: at(205), updated: at(-52) },
];
const name = (id) => el.agents.find((a) => a.id === id)?.name ?? id;
const log = (type, text) => {
const li = document.createElement("li");
const code = document.createElement("code");
code.textContent = type;
const span = document.createElement("span");
span.textContent = text;
li.append(code, span);
logEl.prepend(li);
while (logEl.children.length > 3) logEl.lastElementChild.remove();
};
el.addEventListener("mv-lens-change", (e) => log("mv-lens-change", `${e.detail.previous} → ${e.detail.lens}`));
el.addEventListener("mv-agent-select", (e) => log("mv-agent-select", e.detail.agent.name));
el.addEventListener("mv-agent-move", (e) => log("mv-agent-move", `${name(e.detail.id)}: ${e.detail.from} → ${e.detail.to} (${e.detail.meaning})`));
el.addEventListener("mv-agent-reply", (e) => {
const { id, reply } = e.detail;
log("mv-agent-reply", `${name(id)}: “${reply}”`);
// The host decides what a reply does; here, a plausible agent reaction.
if (id === "newsletter" && reply === "Approve") el.update(id, { status: "running", needsAction: null, progress: 0.15, eta: at(12) });
else if (id === "newsletter") el.update(id, { needsAction: "Which slot works better?", replies: ["Tue, Oct 7 · 9 AM", "Thu, Oct 9 · 6 PM"] });
else if (id === "retouch" && reply === "Approve") el.update(id, { status: "reviewed", needsAction: null });
else if (id === "retouch") el.update(id, { status: "running", needsAction: null, progress: 0.7, start: at(0), end: null, eta: at(20) });
else el.update(id, { status: "running", needsAction: null, progress: 0.1, start: at(0), eta: at(15) });
});
// Simulated live progress: running agents advance every few seconds.
const timer = setInterval(() => {
if (!el.isConnected) return clearInterval(timer);
for (const a of el.agents) {
if (a.status !== "running" || a.progress === null) continue;
const p = Math.min(1, a.progress + 0.006 + Math.random() * 0.01);
if (p < 1) { el.update(a.id, { progress: p }); continue; }
el.update(a.id, { status: "done", progress: 1, end: at(0) });
if (a.id === "copy-knit") el.update("copy-acc", { status: "running", start: at(0), progress: 0.02 });
}
}, 2500);
</script>
</div>
API
Attributes
Name
Type
Default
Description
lens
board | inbox | tasks | calendar | graph
board
Active lens. Changing it animates every card to its new place (instant under reduced motion).
label
string
Agents
Accessible name of the region (and of the lens tab list).
Properties
Name
Type
Description
agents
Agent[]
The model: { id, name, goal?, status: "scheduled" | "running" | "blocked" | "done" | "reviewed", parent?, dependsOn?: id[], start?, eta?, end?, progress? (0–1), needsAction?: question, replies?: string[], trigger?: string, updated? }. Times are ms, Date or ISO strings. Cards are reused by id. Can be set before the element is defined, or given as a child <script type="application/json">.
lens
string
Reflects the lens attribute.
collapsed
string[]
Ids of collapsed parents in the Tasks lens.
selected
string | null
Id of the selected agent (set without firing mv-agent-select).
Methods
Name
Description
update(id, patch)
Merges a live change into one agent (an unknown id adds it). Progress-only changes refresh the card in place; status, dependencies, timing or questions relayout with FLIP. Batched to one layout per frame; status changes are announced.
setLens(name)
Switches lens (same as setting the attribute). Returns false for an unknown lens.
toggle(id, open?)
Collapses / expands a parent in the Tasks lens.
remove(id)
Removes an agent; its card fades out.
Events
Name
Description
mv-lens-change
The lens changed. detail: { lens, previous }.
mv-agent-select
Click, Enter or Space on a card. detail: { id, agent }.
mv-agent-move
Cancelable. A card was dropped in another board column (drag, Alt+arrows or the Move menu). detail: { id, from, to, meaning } — meaning is the plain-language intent, e.g. “unblock and resume the agent”. preventDefault() sends the card back.
mv-agent-reply
A quick-reply button was pressed on an agent waiting for a human. detail: { id, reply, question }. The host decides what happens (usually update(id, { status, needsAction: null })).
Content structure
Name
Description
<script type="application/json">
Optional: initial agents as JSON, for server-rendered pages.
CSS classes
Name
Description
mv-agent-lenses-card
The persistent card; data-density = card | item | row | bar | node, data-status, data-ask, data-selected.
mv-agent-lenses-tab / -indicator
Lens switcher (role tablist) and its sliding surface.
mv-agent-lenses-col / -intent
Board columns and the drop-intention label shown while dragging.
mv-agent-lenses-edge / -edge-flow
Graph edges (data-kind dep | sub, data-state waiting | flowing | done | idle) and the flowing dash of running work.
mv-agent-lenses-now
Calendar now-line.
CSS variables
Name
Default
Description
--mv-agent-lenses-max-height
none
Caps the panel height; content then scrolls inside (both axes).
--mv-agent-lenses-bg
var(--mv-bg)
Background behind the lenses.
--mv-agent-lenses-radius
var(--mv-radius-xl)
Outer corner radius.
Accessibility
The lens switcher is a role="tablist" (roving focus, arrows switch lens) controlling a single role="tabpanel" that is re-labelled on every change; the change is announced with the agent count and pending actions. Cards are role="listitem" (board, inbox, calendar, graph) or role="treeitem" with aria-level, aria-setsize/posinset and aria-expanded (tasks). Each card's accessible name carries its name, status, progress, timing and relations (“waiting on …”, “subagent of …”); goal and question are its description. One tab stop (roving tabindex): arrows move spatially in every lens, Home/End jump to the ends, Enter/Space selects; in Tasks, Right/Left expand, collapse or move between parent and children. On the board, Alt+Left/Right moves the card to the neighbouring column and Shift+F10 (or the Move button) opens a menu listing each column with what the move means; pointer drag shows that meaning on every column; touch uses the menu so scrolling stays free. Quick replies are real buttons reachable with Tab from the focused card; Escape returns to the card. Status is always an icon shape plus a text label, never color alone. A polite live region announces moves, replies, status changes and collapse summaries. Reduced motion (OS or data-motion="reduce"): instant relayout, no spinner, flowing dash or indeterminate bar. Decorative scaffolding (columns, axis, edges) is aria-hidden.