Chapter 17 · Operator · AI surfaces & celebration

AI surfaces. Sage in the room.

Where Sage shows up — in the right rail as a chat, in the compose drawer as suggestions, in detail pages as proactive cards. Plus the KB-suggestion pattern for support agents, a confetti win moment for closed deals (the only time we throw confetti), and the audit-surface primitives: a hero score card, a scan-narration log, a verbatim evidence quote, and a paste-and-recheck loop for iterating on a failing finding.

17.1 Sage AI drawer

A right-anchored AI assistant. 360px wide (resizable 320–520 via drag-handle). Header with the “Sage” Fraunces wordmark + context chip (“on this deal” / “everywhere”) + close (⌘.). Message stream below: assistant + user turns, plus tool-call cards (collapsed by default) and proposal cards (highlighted with --accent-soft + Accept / Edit / Dismiss). Composer at the bottom with starter-prompt chips when empty. Streaming dots while thinking. Reduced motion: no pulsing dots, replaces with “thinking…” text. In the agent builder the same conversation docks beside Test (17.18); changes arrive as proposal cards (17.17).

Conversation in flight

.sage-drawer

Showing user message, assistant reply, expanded tool-call card, proposal card with action buttons, then streaming dots while a follow-up generates. In production the drawer is fixed to the right edge of the viewport at 100vh; the demo uses a constrained box to fit on the chapter page.

Sage
On this deal · BlueRock
You
What's the next best action for BlueRock?
Sage
BlueRock's renewal date is in 23 days and they haven't opened the proposal you sent on Apr 18. From the playbook, this is when re-engagement matters most.
tool get_recent_activity(deal_id=42)
Returned 8 events. Last touchpoint: email "Renewal proposal" sent Apr 18, no opens. Previous engagement: Q1 QBR Mar 12 (very positive).
Sage suggests
Send a follow-up email to Alicia today, with subject "Quick check-in on the renewal" — friendly, short, with a calendar link for a 15-min sync.
<aside class="sage-drawer">
  <header class="sage-head">
    <div class="sage-row1">
      <span class="sage-wordmark">
        <svg class="sage-spark" viewBox="0 0 24 24" fill="currentColor"><path d="M12 2 L13.6 8.6 L20 10 L13.6 11.4 L12 18 L10.4 11.4 L4 10 L10.4 8.6 Z"/></svg>Sage
      </span>
      <button class="sage-close">×</button>
    </div>
    <span class="sage-context">On this deal · BlueRock</span>
  </header>

  <div class="sage-stream">
    <div class="sage-msg is-user">
      <span class="sage-msg-author">You</span>
      <div class="sage-msg-body">What's the next best action for BlueRock?</div>
    </div>
    <div class="sage-msg is-assistant">
      <span class="sage-msg-author">Sage</span>
      <div class="sage-msg-body">BlueRock's renewal date is in 23 days and they haven't opened the proposal you sent on Apr 18. From the playbook, this is when re-engagement matters most.</div>
    </div>

    <!-- Tool call (collapsed by default) -->
    <details class="sage-tool-call">
      <summary>tool · get_recent_activity(deal_id=42)</summary>
      <div class="sage-tc-body">Returned 8 events. Last touchpoint: email "Renewal proposal" sent Apr 18, no opens. Previous engagement: Q1 QBR Mar 12 (very positive).</div>
    </details>

    <!-- Proposal card (highlighted; Accept/Edit/Dismiss) -->
    <div class="sage-proposal">
      <div class="sage-proposal-head">✦ Sage suggests</div>
      <div class="sage-proposal-body">Send a follow-up email to Alicia today, with subject "Quick check-in on the renewal" — friendly, short, with a calendar link for a 15-min sync.</div>
      <div class="sage-proposal-actions">
        <button class="sage-prop-accept">Accept & draft</button>
        <button class="sage-prop-edit">Edit prompt</button>
        <button class="sage-prop-dismiss">Dismiss</button>
      </div>
    </div>

    <!-- Streaming dots -->
    <div class="sage-typing">
      <span class="sage-typing-dot"></span>
      <span class="sage-typing-dot"></span>
      <span class="sage-typing-dot"></span>
    </div>
  </div>

  <footer class="sage-composer">
    <div class="sage-prompts">
      <button class="sage-prompt-chip">Summarise this deal</button>
      <button class="sage-prompt-chip">Draft a follow-up</button>
      <button class="sage-prompt-chip">Find similar wins</button>
    </div>
    <div class="sage-input-row">
      <textarea class="sage-input" placeholder="Ask anything"></textarea>
      <button class="sage-send">Send</button>
    </div>
  </footer>
</aside>
.sage-drawer { width: 360px; height: 100vh;
  position: fixed; right: 0; top: 0;
  background: var(--bg-paper); border-left: 1px solid var(--hair);
  display: grid; grid-template-rows: auto 1fr auto; }

.sage-head { background: var(--gradient-glow-soft);
  padding: var(--s-4) var(--s-5); border-bottom: 1px solid var(--hair); }
.sage-wordmark { font-family: var(--f-serif); font-style: italic;
  font-size: 22px; font-variation-settings: "SOFT" 80; }

.sage-typing-dot { animation: sage-pulse 1.2s ease infinite; }
.sage-typing-dot:nth-child(2) { animation-delay: 0.2s; }
.sage-typing-dot:nth-child(3) { animation-delay: 0.4s; }
@media (prefers-reduced-motion: reduce) {
  .sage-typing-dot { display: none; }
  .sage-typing::after { content: "thinking…"; }
}

/* Resizable: drag the left edge between 320px and 520px */
.sage-drawer { resize: horizontal; min-width: 320px; max-width: 520px; }
import { useState } from "react";
import { SageDrawer } from "@magicblocksai/ui";
import type { SageMessage } from "@magicblocksai/ui";

const [open, setOpen] = useState(true);
const [messages, setMessages] = useState<SageMessage[]>([
  { id: "u1", role: "user",
    text: "What's the next best action for BlueRock?" },
  { id: "a1", role: "assistant",
    text: "BlueRock's renewal is in 23 days and they haven't opened the proposal." },
  { id: "t1", role: "tool-call", defaultExpanded: true,
    toolName: "get_recent_activity", args: "deal_id=42",
    result: "Last touchpoint: email Apr 18, no opens." },
  { id: "p1", role: "proposal",
    body: "Send a follow-up to Alicia today, with a calendar link for a 15-min sync.",
    onAccept: () => draftEmail(),
    onEdit: () => openPromptEditor(),
    onDismiss: () => dismiss("p1") },
]);

<SageDrawer
  open={open}
  onOpenChange={setOpen}
  floating
  context={{ label: "On this deal · BlueRock", scope: "this" }}
  messages={messages}
  starterPrompts={["Summarise this deal", "Draft a follow-up", "Find similar wins"]}
  onSend={(input) => {
    setMessages((m) => [...m, { id: crypto.randomUUID(), role: "user", text: input }]);
    sage.ask(input).then(append);
  }}
/>

// ⌘. toggles open globally; Esc closes when open. Reduced-motion swaps
// the pulsing dots for a "thinking…" caption (handled by the chapter-15 CSS).

17.2 Compose drawer

The single email composer used everywhere we send mail. Right-side overlay 540px. Header: From (rep selector for admin), To/CC/BCC autocomplete, schedule pill, tracking toggle, expand-to-fullscreen icon. Subject input. Rich-text body with slash-commands palette, ;intro shortcuts, and {{ }} variable autocomplete. AI assist bar above the body: Make warmer · Shorter · More direct · Add ROI line · Custom prompt. Footer: Send · Schedule · Save snippet. Draft autosaves silently — pulse → check.

Composer mid-write

.compose

Showing one recipient chip, schedule + tracking pills, AI assist bar, and a body with a variable token ({{first_name}}) that highlights as it's typed. The body is contenteditable in production; here it's static HTML for the demo.

New email

From jay@magicblocks.ai
To Alicia Chen
⏰ Schedule ✓ Tracking on · Slash / for commands · ;intro for snippets · {{ }} for variables
Sage assist

Hi {{first_name}},

Wanted to drop a friendly note about the renewal proposal we sent on Apr 18. Happy to walk through any questions on a quick 15-min sync this week if helpful — here's my calendar.

Either way, looking forward to closing this out together. The team at MagicBlocks is genuinely excited about the next year with you.

Best,
Jay

Draft saved · 2s ago
<aside class="compose">
  <header class="compose-head">
    <h4>New email</h4>
    <div class="compose-head-actions">
      <button class="compose-icon-btn">⛶</button>  <!-- expand -->
      <button class="compose-icon-btn">×</button>  <!-- close -->
    </div>
  </header>

  <div class="compose-headers">
    <div class="compose-header-row">...From...</div>
    <div class="compose-header-row">...To with chips...</div>
    <div class="compose-header-row">...Cc / Bcc reveal...</div>
  </div>

  <div class="compose-meta-row">
    <span class="compose-meta-pill">⏰ Schedule</span>
    <span class="compose-meta-pill is-on">✓ Tracking on</span>
  </div>

  <input class="compose-subject" placeholder="Subject…">

  <!-- AI assist bar -->
  <div class="compose-ai-bar">
    <span class="compose-ai-eyebrow">✦ Sage assist</span>
    <button class="compose-ai-chip">Make warmer</button>
    <button class="compose-ai-chip">Shorter</button>
    <button class="compose-ai-chip">More direct</button>
    <button class="compose-ai-chip">Add ROI line</button>
    <button class="compose-ai-chip" style="border-style: dashed;">Custom prompt…</button>
  </div>

  <div class="compose-body" contenteditable="true">
    <p>Hi <code class="var-tag">{{first_name}}</code>,</p>
    <p>Wanted to drop a friendly note about the renewal proposal we sent on Apr 18. Happy to walk through any questions on a quick 15-min sync this week if helpful — here's my <a href="#">calendar</a>.</p>
    <p>Either way, looking forward to closing this out together. The team at MagicBlocks is genuinely excited about the next year with you.</p>
    <p>Best,<br>Jay</p>
  </div>

  <footer class="compose-foot">
    <div class="compose-autosave">
      <span class="compose-autosave-dot"></span>
      Draft saved · 2s ago
    </div>
    <div class="compose-foot-actions">
      <button>Save snippet</button>
      <button>Schedule</button>
      <button class="compose-send-btn">Send</button>
    </div>
  </footer>
</aside>
.compose { width: 540px;
  background: var(--bg-paper); border: 1px solid var(--hair);
  border-radius: var(--r-lg); box-shadow: var(--sh-3);
  display: grid; grid-template-rows: auto auto auto auto auto 1fr auto; }

.compose-ai-bar { background: var(--ai-glow-bg);
  padding: var(--s-3) var(--s-4); }
.compose-ai-chip { background: var(--bg-paper);
  border: 1px solid color-mix(in oklab, var(--accent) 20%, transparent);
  border-radius: var(--r-pill); padding: 5px 10px;
  font: 500 11px var(--f-body); color: var(--fg); }

.compose-body code.var-tag {
  display: inline-block; padding: 1px 6px;
  background: var(--accent-soft); color: var(--accent-text);
  border-radius: var(--r-xs);
  font: 500 12.5px var(--f-mono); }
import { useState } from "react";
import { ComposeDrawer } from "@magicblocksai/ui";
import type { Recipient } from "@magicblocksai/ui";

const [open, setOpen] = useState(true);
const [to, setTo] = useState<Recipient[]>([
  { label: "Alicia Chen", email: "alicia@northpeak.co" },
]);
const [subject, setSubject] = useState("Quick check-in on the renewal");
const [body, setBody] = useState("Hi {{first_name}},\n\nWanted to drop a friendly note…");
const [tracking, setTracking] = useState(true);

<ComposeDrawer
  open={open}
  onOpenChange={setOpen}
  floating
  from="jay@magicblocks.ai"
  to={to}
  onChangeTo={setTo}
  subject={subject}
  onChangeSubject={setSubject}
  body={body}
  onChangeBody={setBody}
  tracking={tracking}
  onChangeTracking={setTracking}
  onAiAction={(kind, customPrompt) => sage.rewrite({ body, kind, customPrompt }).then(setBody)}
  onSend={() => mail.send({ to, subject, body })}
  autosaveLabel="Draft saved · 2s ago"
/>

// AI assist actions: "warmer" · "shorter" · "more-direct" · "add-roi" · "custom".
// "custom" prompts the user via window.prompt and forwards the answer as the
// second arg to onAiAction.

17.3 AI suggestion card

Sage's proactive surface inside detail pages. Card with a subtle gradient (using --ai-glow-bg), spark icon + “Sage suggests” eyebrow, one-paragraph suggestion, primary CTA + secondary “Edit” + dismiss. Three states: generating (shimmer), ready (default), dismissed (collapsed to a chip “Sage has 1 idea”).

Three states

.ai-card

In the right rail of a detail page (13.1) it sits naturally above other context cards. The dismiss action shouldn't lose the suggestion — it collapses to a small chip the user can re-open.

Sage suggests

Send the renewal proposal today. BlueRock's renewal date is in 23 days. The pattern from past closes: send proposal > 14 days out for a 70% acceptance rate.

Sage suggests

Loading…

<!-- Ready state -->
<div class="ai-card">
  <div class="ai-card-head">
    <span class="ai-card-eyebrow">✦ Sage suggests</span>
    <button class="ai-card-dismiss">×</button>
  </div>
  <p class="ai-card-body">Send the renewal proposal <em>today</em>. BlueRock's renewal date is in 23 days...</p>
  <div class="ai-card-actions">
    <button class="ai-card-cta">Draft email</button>
    <button class="ai-card-edit">Edit prompt</button>
  </div>
</div>

<!-- Generating state — body becomes typing dots, actions hidden via CSS -->
<div class="ai-card is-generating">
  <div class="ai-card-head">
    <span class="ai-card-eyebrow">✦ Sage suggests</span>
  </div>
  <p class="ai-card-body">Loading…</p>
</div>

<!-- Dismissed → collapsed chip -->
<button class="ai-card-chip">✦ Sage has 1 idea</button>
.ai-card { background: var(--bg-paper);
  border: 1px solid color-mix(in oklab, var(--accent) 25%, var(--hair));
  border-radius: var(--r-lg); padding: var(--s-4);
  position: relative; overflow: hidden; }
.ai-card::before { content: ""; position: absolute; inset: 0;
  background: var(--ai-glow-bg);     /* dark mode: 1.5× opacity */
  pointer-events: none; }
.ai-card > * { position: relative; z-index: 1; }

.ai-card.is-generating .ai-card-body { color: transparent; }
.ai-card.is-generating .ai-card-body::after {
  content: "Sage is thinking…";
  font: 400 13px var(--f-mono); color: var(--fg-dim); font-style: italic; }
import { AiSuggestionCard } from "@magicblocksai/ui";

// Three states drive the surface:
//   "ready"      → body + CTA + Edit + dismiss
//   "generating" → "Sage is thinking…" italic-mono shimmer
//   "dismissed"  → small chip ("Sage has 1 idea") that re-opens via onReopen

// 01 · Ready — body + primary CTA + Edit + dismiss
<AiSuggestionCard
  state="ready"
  body={
    <>Send the renewal proposal <em>today</em>. BlueRock's renewal date is in 23 days.</>
  }
  primaryAction={<button className="ai-card-cta">Draft email</button>}
  onEdit={() => sage.openPromptEditor()}
  onDismiss={() => sage.dismiss()}
/>

// 02 · Generating — shimmer body, "Sage is thinking…" placeholder
<AiSuggestionCard
  state="generating"
/>

// 03 · Dismissed — collapsed to a chip; onReopen restores the card
<AiSuggestionCard
  state="dismissed"
  ideaCount={1}
  onReopen={() => sage.reopen()}
/>

// In practice you'll drive `state` from useState so the same card transitions
// between all three (generating → ready → dismissed → ready):
//   const [state, setState] = useState<AiSuggestionState>("ready");

17.4 KB suggestion card

Sage's “assist” surface for support agents. Shows a relevant KB article with title, snippet (matching highlight), helpful/not-helpful counts, and three actions: Insert into reply, Open article, Mark not helpful. The matched search term is wrapped in a <mark> with --accent-soft.

Three suggestions for the current ticket

.kb-card-suggest

Stacks vertically in the right rail. The agent can insert any one with one click; helpful counts feed back into Sage's ranking model.

Configuring SAML SSO with Okta

KB · 4 min read · last updated Apr 12

If your customer is using Okta, navigate to Settings → Authentication and choose SAML SSO. The metadata URL we provide goes into Okta's app settings; we'll handle the rest…

92% helpful · 47 reads

Troubleshooting SAML signature mismatch

KB · 2 min read · last updated Mar 28

Most SAML errors come from a clock skew between the IdP and our service. Confirm both sides are syncing to NTP, then re-test…

78% helpful · 22 reads
<div class="kb-card-suggest">
  <div class="kb-card-suggest-head">
    <span class="kb-card-suggest-icon">
      <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><path d="M3 5q0-1 1-1h7q1 0 1 1v15q0-1-1-1H4q-1 0-1-1z" stroke-linejoin="round"/></svg>
    </span>
    <div>
      <h4 class="kb-card-suggest-title">Configuring SAML SSO</h4>
      <div class="kb-card-suggest-meta">KB · 4 min · last updated Apr 12</div>
    </div>
  </div>
  <p class="kb-card-suggest-snippet">
    If your customer is using Okta… choose <mark>SAML SSO</mark> …
  </p>
  <div class="kb-card-suggest-foot">
    <span class="kb-card-suggest-helpful">
      <strong>92%</strong> helpful · 47 reads
    </span>
    <div class="kb-card-suggest-actions">
      <button class="kb-action-primary">Insert into reply</button>
      <button>Open</button>
    </div>
  </div>
</div>
.kb-card-suggest { background: var(--bg-paper);
  border: 1px solid var(--hair); border-radius: var(--r-md);
  padding: var(--s-4); }

.kb-card-suggest-snippet mark {
  background: var(--accent-soft); color: var(--fg);
  padding: 1px 3px; border-radius: 2px; }

.kb-action-primary { background: var(--accent); color: var(--paper);
  border-color: var(--accent); }
import { KbSuggestionCard } from "@magicblocksai/ui";

<KbSuggestionCard
  title="Configuring SAML SSO with Okta"
  meta="KB · 4 min read · last updated Apr 12"
  snippet="If your customer is using Okta, navigate to Settings → Authentication and choose SAML SSO. The metadata URL we provide goes into Okta's app settings; we'll handle the rest…"
  match="SAML SSO"
  helpfulCount={43}
  notHelpfulCount={4}
  onInsert={() => insertIntoReply(article.id)}
  onOpen={() => router.push(article.href)}
  onMarkUnhelpful={() => sage.markUnhelpful(article.id)}
/>

// `match` (and any `highlightTerms`) wrap occurrences of the term in <mark>
// inside the snippet — case-insensitive, longer matches win when overlapping.
// Pass arbitrary user input safely; terms are escaped before regex compile.

17.5 Confetti win moment

Reserved for closed-won deals (and only those). Full-screen transparent overlay; ~30 small spans drift down from centre over 1.4s with brand-palette particle colours (pink, blue, warm, ink). Paired with a one-line toast (“🎉 Won — $36k ARR. Nice.”). Under reduced motion, particles are suppressed; the toast still fires.

Click to fire

.confetti-stage · MB.confetti.fire()

In production this is a tiny JS helper (per spec § 27): 30 spans appended to body, CSS keyframes do the drift, helper removes them after 1.6s. The demo scopes it inside the stage so it doesn't take over the chapter page.

Won — $36k ARR. Nice.
<!-- Markup is just the toast; particles are injected by JS -->
<div class="confetti-toast">
  🎉 Won — <span class="em">$36k</span> ARR. Nice.
</div>

<!-- Helper (drop into _shared.js) -->
<script>
  window.MB = window.MB || {};
  MB.confetti = {
    fire(opts = {}) {
      const reduce = matchMedia('(prefers-reduced-motion: reduce)').matches;
      if (reduce) return;  // toast still fires; particles don't
      const colors = ['var(--accent)', 'var(--info)', 'var(--warning)', 'var(--ink)'];
      const host = opts.host || document.body;
      for (let i = 0; i < 30; i++) {
        const el = document.createElement('span');
        el.className = 'confetti-particle';
        el.style.background = colors[i % colors.length];
        // Random end position (full-screen spread)
        el.style.setProperty('--cx', `${(Math.random() * 600 - 300)}px`);
        el.style.setProperty('--cr', `${Math.random() * 720 - 360}deg`);
        el.style.animationDelay = `${Math.random() * 0.2}s`;
        host.appendChild(el);
        setTimeout(() => el.remove(), 1600);
      }
    }
  };
</script>
.confetti-particle {
  position: absolute; top: 50%; left: 50%;
  width: 8px; height: 12px;
  background: var(--accent);
  opacity: 0; pointer-events: none;
}
.confetti-stage.is-firing .confetti-particle {
  animation: confetti-drift 1.4s var(--ease) forwards;
}
@keyframes confetti-drift {
  0%   { opacity: 0; transform: translate(-50%, -50%) rotate(0deg); }
  10%  { opacity: 1; }
  100% { opacity: 0; transform: translate(var(--cx, 0), 250px) rotate(var(--cr, 360deg)); }
}
@media (prefers-reduced-motion: reduce) {
  .confetti-stage.is-firing .confetti-particle { animation: none; opacity: 0; }
}
import { ConfettiTrigger, useConfetti } from "@magicblocksai/ui";

// 1. Drop-in button — fires confetti + toast on click.
<ConfettiTrigger toast="🎉 Won — $36k ARR. Nice.">
  Mark won
</ConfettiTrigger>

// 2. Programmatic — for mutation handlers, websocket events, etc.
const { fire } = useConfetti();

async function markWon(deal: Deal) {
  await api.deals.update(deal.id, { stage: "won" });
  fire(`🎉 Won — $${deal.arr} ARR. Nice.`);
}

// Reduced-motion: particles suppressed; toast still fires (per spec § 27).
// Reserved for closed-won deals only — never form-saved or onboarding-step.

17.6 ScoreCard

Hero “score + interpretation + breakdown” card. Composes the kit’s <ScoreRing> at size="xl" (96px) with a label / headline / meta breakdown line. Three surface fills: paper (default), warm, and ink — pick the one that matches the surface the card lives on. The headline is consumer-owned (“Ready to submit”) rather than band-derived — the kit knows the score, the consumer knows what it means in their domain.

ScoreCard

.score-card

Three fills stacked vertically: a warm-fill approval-probability hero, a paper-fill lead-quality readout, and an ink-fill close-rate dashboard tile. band drives the ring stroke colour (low = red, medium = amber, high = green) — the opposite direction of <RiskPill>, where low = good (see the docs note).

87

Approval probability

Ready to submit

33 of 38 points

62

Lead quality

Worth a follow-up

12 of 20 points

41

Close rate · last 30 days

Below team average

9 of 22 deals

<article class="score-card" data-fill="warm">
  <div class="score-card-ring">
    <span class="score-ring score-ring--xl" data-band="high" aria-label="Score 87">
      <svg viewBox="0 0 36 36">
        <circle class="track" cx="18" cy="18" r="15"/>
        <circle class="fill"  cx="18" cy="18" r="15"
                stroke-dasharray="94.2" stroke-dashoffset="12.3"/>
      </svg>
      <span class="v">87</span>
    </span>
  </div>
  <div class="score-card-body">
    <p class="score-card-eyebrow">Approval probability</p>
    <h2 class="score-card-headline">Ready to submit</h2>
    <p class="score-card-meta">33 of 38 points</p>
  </div>
</article>

<!-- data-fill: "paper" (default) · "warm" · "ink" -->
.score-card {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: var(--s-5);
  align-items: center;
  padding: var(--s-5);
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-lg);
}

.score-card[data-fill="warm"] { background: var(--warm-3); }

.score-card[data-fill="ink"] {
  background: var(--ink);
  color: var(--paper);
  border-color: transparent;
}

.score-card-ring { display: inline-flex; align-items: center; justify-content: center; }

.score-card-body { display: flex; flex-direction: column; gap: var(--s-2); min-width: 0; }

.score-card-eyebrow {
  font: 500 11px/1 var(--f-mono);
  text-transform: uppercase; letter-spacing: 0.08em;
  color: var(--fg-soft); margin: 0;
}

.score-card[data-fill="ink"] .score-card-eyebrow { color: color-mix(in oklab, var(--paper) 65%, transparent); }

.score-card-headline {
  font: 700 22px/1.2 var(--f-display);
  letter-spacing: -0.015em;
  color: inherit;
  margin: 0;
}

.score-card-meta {
  font: 400 13px/1.4 var(--f-body);
  color: var(--fg-soft);
  margin: 0;
}

.score-card[data-fill="ink"] .score-card-meta { color: color-mix(in oklab, var(--paper) 75%, transparent); }

@media (max-width: 520px) {
  .score-card { grid-template-columns: 1fr; justify-items: center; text-align: center; }
}
import { ScoreCard } from "@magicblocksai/ui";

// Warm-fill hero: high band, consumer-owned interpretation, point breakdown.
<ScoreCard
  score={87}
  band="high"
  label="Approval probability"
  interpretation="Ready to submit"
  earnedPoints={33}
  applicablePoints={38}
  fill="warm"
/>

// Paper fill (default) — medium band, narrow breakdown.
<ScoreCard
  score={62}
  band="medium"
  label="Lead quality"
  interpretation="Worth a follow-up"
  earnedPoints={12}
  applicablePoints={20}
/>

// Ink fill — metric-pinned colour (variant="accent" overrides band).
<ScoreCard
  score={41}
  variant="accent"
  label="Close rate · last 30 days"
  interpretation="Below team average"
  meta="9 of 22 deals"
  fill="ink"
/>

// band="low" renders red because a low score is bad — the opposite of
// <RiskPill risk="low"> (green, low risk is good). Per docs.

17.7 NarrationLog

Event-log / scan-narration list. Vertical hairline list of status-coloured rows for any long-running async process — scan, build, deploy, sync, audit, ingest. Lighter than <ActivityTimeline> (no filters, no per-day grouping, no rich rows). Status drives the row colour: info (soft fg), pass (success-text), warn (warning-text on warning-soft), fail (error-text on error-soft).

NarrationLog — scan in progress

.narration-log

Eight rows from a 10DLC Wingman site-scan feed: a domain header, two info observations, two pass checks, one warn, one fail. Glyphs render in a 14px mono slot ahead of each row.

  1. Scanning yourcompany.com…
  2. Resolved 14 internal links
  3. Found privacy policy at /privacy
  4. Site is HTTPS
  5. Domain is branded — no URL shorteners detected
  6. Terms-of-service page returns 302 redirect
  7. Privacy policy missing SMS consent disclosure
  8. Scan complete — 5 of 7 checks passed
<ol class="narration-log" data-anim="slide-fade">
  <li class="narration-log-item" data-status="info">
    <span class="narration-log-item-glyph">·</span>
    <span class="narration-log-item-text">Scanning yourcompany.com…</span>
  </li>
  <li class="narration-log-item" data-status="pass">
    <span class="narration-log-item-glyph">✓</span>
    <span class="narration-log-item-text">Site is HTTPS</span>
  </li>
  <li class="narration-log-item" data-status="warn">
    <span class="narration-log-item-glyph">!</span>
    <span class="narration-log-item-text">Terms-of-service page returns 302 redirect</span>
  </li>
  <li class="narration-log-item" data-status="fail">
    <span class="narration-log-item-glyph">✗</span>
    <span class="narration-log-item-text">Privacy policy missing SMS consent disclosure</span>
  </li>
</ol>

<!-- data-status: "info" (default) · "pass" · "warn" · "fail" -->
<!-- data-anim:   "slide-fade" (default) · "none" -->
.narration-log {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 4px;
  font: 400 13px/1.45 var(--f-body);
}

.narration-log-item {
  display: flex;
  align-items: baseline;
  gap: var(--s-2);
  padding: 6px var(--s-3);
  border-radius: var(--r-xs);
  color: var(--fg);
  background: transparent;
}

.narration-log-item[data-status="info"] { color: var(--fg-soft); }

.narration-log-item[data-status="pass"] { color: var(--success-text); }

.narration-log-item[data-status="warn"] { color: var(--warning-text); background: var(--warning-soft); }

.narration-log-item[data-status="fail"] { color: var(--error-text); background: var(--error-soft); }

.narration-log-item-glyph {
  font: 500 11px/1 var(--f-mono);
  display: inline-block;
  width: 14px; text-align: center;
  flex: 0 0 14px;
  opacity: 0.85;
}

.narration-log-item-text { flex: 1; min-width: 0; }

.narration-log-toggle {
  appearance: none;
  background: transparent;
  border: 0;
  padding: 4px var(--s-3);
  font: 500 12px/1 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--fg-dim);
  cursor: pointer;
  border-radius: var(--r-xs);
  align-self: flex-start;
}

.narration-log-toggle:hover { color: var(--fg); background: var(--bg-warm); }

@media (prefers-reduced-motion: no-preference) {
  .narration-log[data-anim="slide-fade"] .narration-log-item {
    animation: narration-in var(--dur-2) var(--ease);
  }
  @keyframes narration-in {
    from { opacity: 0; transform: translateY(4px); }
    to   { opacity: 1; transform: translateY(0); }
  }
}
import { NarrationLog } from "@magicblocksai/ui";
import type { NarrationLogItem } from "@magicblocksai/ui";

const items: NarrationLogItem[] = [
  { id: "1", text: "Scanning yourcompany.com…",                 status: "info" },
  { id: "2", text: "Resolved 14 internal links",                  status: "info" },
  { id: "3", text: "Found privacy policy at /privacy",            status: "info" },
  { id: "4", text: "Site is HTTPS",                               status: "pass" },
  { id: "5", text: "Domain is branded — no URL shorteners detected", status: "pass" },
  { id: "6", text: "Terms-of-service page returns 302 redirect",   status: "warn" },
  { id: "7", text: "Privacy policy missing SMS consent disclosure", status: "fail" },
  { id: "8", text: "Scan complete — 5 of 7 checks passed",         status: "info" },
];

<NarrationLog
  items={items}
  maxVisible={15}
  enterAnimation="slide-fade"
/>

// `maxVisible` collapses older rows behind a "Show N earlier lines" toggle.
// Drive items from an SSE / WebSocket / poll feed; the slide-fade enter
// animation honours prefers-reduced-motion automatically via CSS.

17.8 EvidenceQuote

<EvidenceQuote> is a deprecated alias of <SourcePassage> (§20.8) since v4.0.0 — it renders the same markup, mapping sourcecite. Kept for back-compat; new code should use <SourcePassage>, which adds a relevance score, freshness meta, and tone.

EvidenceQuote

.source-passage

Two stacked quotes — the first cites a privacy policy with an “Open ↑” link, the second cites a sitemap header without a URL so only the source caption renders.

We will not sell or rent your personal information.
From your privacy policy Open ↗
For SMS programs, message frequency varies. Reply STOP to cancel, HELP for help.
From your terms of service
<!-- EvidenceQuote renders .source-passage (§20.8) since v4.0.0 -->
<figure class="source-passage">
  <blockquote class="source-passage-body">
    We will not sell or rent your personal information.
  </blockquote>
  <figcaption class="source-passage-cite">
    <span class="source-passage-source">From your privacy policy</span>
    <a class="source-passage-link"
       href="https://example.com/privacy"
       target="_blank" rel="noopener noreferrer">Open ↗</a>
  </figcaption>
</figure>

<!-- Omit the <a> to suppress the "Open ↗" link -->
/* Ships as the .source-passage* family (core surface) — full rules in §20.8.
 * The legacy .evidence-quote* classes ship on as a styling back-compat alias. */
.source-passage { border-left: 3px solid var(--trace-tone, var(--accent)); }
.source-passage-body { font: 400 13.5px/1.55 var(--f-body); color: var(--fg); }
.source-passage-cite { font: 400 11px/1 var(--f-mono); color: var(--fg-faint); }
.source-passage-source { display: inline-flex; align-items: center; }
.source-passage-link { color: var(--accent-text); text-decoration: none; }
.source-passage-link:hover { text-decoration: underline; }
import { EvidenceQuote } from "@magicblocksai/ui";

// DEPRECATED (v4.0.0): a thin alias of <SourcePassage> (§20.8) — source → cite.
// With a source URL — renders the "Open ↗" link.
<EvidenceQuote
  source="From your privacy policy"
  url="https://example.com/privacy"
>
  We will not sell or rent your personal information.
</EvidenceQuote>

// Without a URL — only the source caption renders.
<EvidenceQuote source="From your terms of service">
  For SMS programs, message frequency varies. Reply STOP to cancel, HELP for help.
</EvidenceQuote>

// `linkLabel` overrides the default "Open ↗" string.

17.9 PasteAndRecheckArea

Paste-revised-text + re-run-audit composition. Composes the kit’s <Textarea> + <Button> + a status row for the engagement loop where a user pastes revised text and clicks “Re-check” to re-run validation / audit / score against just that doc. The delta pill (“+8 points”) animates on improvement; reduced motion suppresses the pop. Consumer owns the async work and the result object; the component owns the textarea state internally.

PasteAndRecheckArea — success state

.paste-recheck

A privacy-policy revision surface that has just returned a successful re-check. The button label flips to “Re-check” (idle), the status row shows “Updated.” in success-text colour, and the delta pill (+8 points) sits to its right.

Updated your privacy policy?

Updated.+8 points
<div class="paste-recheck">
  <p class="paste-recheck-label">Updated your privacy policy?</p>
  <textarea class="input paste-recheck-area" rows="6"
            placeholder="Paste the new text here…">We will not sell…</textarea>
  <div class="paste-recheck-foot">
    <button class="btn btn-primary btn-sm">Re-check</button>
    <span class="paste-recheck-status" data-tone="success">
      Updated.<span class="paste-recheck-delta">+8 points</span>
    </span>
  </div>
</div>

<!-- data-tone: omitted (idle / in_progress) · "success" · "error" -->
.paste-recheck {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  padding: var(--s-5);
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
}

.paste-recheck-label {
  font: 500 14px/1.3 var(--f-body);
  color: var(--fg);
  margin: 0;
}

.paste-recheck-area { width: 100%; }

.paste-recheck-foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-3);
  flex-wrap: wrap;
}

.paste-recheck-status {
  font: 500 13px/1 var(--f-body);
  color: var(--fg-soft);
}

.paste-recheck-status[data-tone="success"] { color: var(--success-text); }

.paste-recheck-status[data-tone="error"] { color: var(--error-text); }

.paste-recheck-delta {
  display: inline-block;
  font: 600 13px/1 var(--f-mono);
  padding: 4px 10px;
  border-radius: var(--r-pill);
  background: var(--success-soft);
  color: var(--success-text);
  margin-left: var(--s-2);
}

@media (prefers-reduced-motion: no-preference) {
  .paste-recheck-delta { animation: paste-recheck-pop var(--dur-3) var(--ease); }
  @keyframes paste-recheck-pop {
    from { transform: scale(0.85); opacity: 0; }
    to   { transform: scale(1); opacity: 1; }
  }
}
import { useState } from "react";
import { PasteAndRecheckArea } from "@magicblocksai/ui";
import type { PasteRecheckResult } from "@magicblocksai/ui";

function PrivacyPolicyRecheck() {
  const [result, setResult] = useState<PasteRecheckResult>({ status: "success", delta: "+8 points" });

  async function handle(text: string) {
    setResult({ status: "in_progress" });
    try {
      const res = await api.recheckPrivacyPolicy(text);
      setResult({
        status: "success",
        delta: res.delta > 0 ? `+${res.delta} points` : undefined,
      });
    } catch (err) {
      setResult({ status: "error", message: err.message });
    }
  }

  return (
    <PasteAndRecheckArea
      label="Updated your privacy policy?"
      placeholder="Paste the new text here…"
      onRecheck={handle}
      result={result}
    />
  );
}

// status: "idle" | "in_progress" | "success" | "error"
// While "in_progress", the textarea + button are disabled by the kit.

17.10 Evaluations

Scored-rubric display for evaluating AI agent responses. Used to surface LLM-as-judge outputs, human-rubric scoring, or automated eval results. Each criterion carries a name, score (0–10), pass/fail/warn status, and optional per-criterion notes that reveal on click. Display-side only — the eval-runner surface is reserved for a future round.

Sage response — rubric scoring

.evaluations

Five criteria with mixed pass/warn states. Summary header is auto-derived; the tone-match row starts expanded to demo the notes block.

Sage response — rubric scoring

8.2
3 pass2 warn0 fail

Slightly more formal than the customer's prior message; consider warming up.

<div class="evaluations">
  <div class="evaluations-summary">
    <p class="evaluations-summary-title">Sage response — rubric scoring</p>
    <div class="evaluations-summary-score">8.2</div>
    <div class="evaluations-summary-counts">
      <span class="evaluations-summary-count is-pass">3 pass</span>
      <span class="evaluations-summary-count is-warn">2 warn</span>
      <span class="evaluations-summary-count is-fail">0 fail</span>
    </div>
  </div>
  <div class="evaluations-row">
    <button type="button" class="evaluations-row-head"
            aria-expanded="true" aria-controls="tone-match-notes">
      <span class="evaluations-row-status is-warn" aria-hidden="true"></span>
      <span class="evaluations-row-name">Tone match</span>
      <span class="evaluations-row-score">7.2 / 10</span>
      <svg class="evaluations-row-caret" viewBox="0 0 16 16" aria-hidden="true">…</svg>
    </button>
    <p id="tone-match-notes" class="evaluations-row-notes">Slightly more formal…</p>
  </div>
  <!-- … -->
</div>

<!-- status: "pass" | "warn" | "fail" -->
<!-- Summary auto-derived from criteria when not supplied. -->
.evaluations {
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
  overflow: hidden;
}

.evaluations-summary {
  display: grid; grid-template-columns: 1fr auto;
  gap: var(--s-3);
  padding: var(--s-4);
  background: var(--bg-sunk);
  border-bottom: 1px solid var(--hair);
}

.evaluations-summary-title {
  font: 600 14px/1.3 var(--f-body); color: var(--fg);
  margin: 0;
}

.evaluations-summary-score {
  font: 600 22px/1 var(--f-display); color: var(--fg);
  font-feature-settings: "tnum";
}

.evaluations-summary-counts {
  display: flex; gap: var(--s-3);
  font: 500 11px/1 var(--f-mono); text-transform: uppercase; letter-spacing: 0.04em;
  color: var(--fg-soft);
}

.evaluations-summary-count.is-pass { color: var(--success-text); }

.evaluations-summary-count.is-fail { color: var(--error-text); }

.evaluations-summary-count.is-warn { color: var(--warning-text); }

.evaluations-row { border-bottom: 1px solid var(--hair); }

.evaluations-row:last-child { border-bottom: 0; }

.evaluations-row-head {
  display: grid; grid-template-columns: auto 1fr auto auto;
  gap: var(--s-3); align-items: center;
  padding: var(--s-3) var(--s-4);
  background: transparent; border: 0; width: 100%;
  text-align: left; cursor: pointer;
  font: 500 13px/1.4 var(--f-body); color: var(--fg);
  min-height: 44px;
}

.evaluations-row-head:hover { background: var(--bg-sunk); }

.evaluations-row-head:focus-visible { outline: 0; box-shadow: var(--sh-focus); position: relative; z-index: 1; }

.evaluations-row-status {
  width: 8px; height: 8px; border-radius: 50%;
}

.evaluations-row-status.is-pass { background: var(--success); }

.evaluations-row-status.is-fail { background: var(--error); }

.evaluations-row-status.is-warn { background: var(--warning); }

.evaluations-row-name { font: 500 13px/1.3 var(--f-body); color: var(--fg); }

.evaluations-row-score { font: 600 13px/1 var(--f-mono); color: var(--fg); font-feature-settings: "tnum"; }

.evaluations-row-caret {
  width: 16px; height: 16px; color: var(--fg-faint);
  transition: transform 0.15s ease;
}

.evaluations-row-head[aria-expanded="true"] .evaluations-row-caret { transform: rotate(90deg); }

.evaluations-row-notes {
  padding: 0 var(--s-4) var(--s-3) var(--s-4);
  margin: 0;
  font: 400 13px/1.5 var(--f-body); color: var(--fg-soft);
}

/* …additional rules trimmed for brevity — see _shared.css */
import { Evaluations } from "@magicblocksai/ui";
import type { EvaluationCriterion } from "@magicblocksai/ui";

const criteria: EvaluationCriterion[] = [
  { id: "factual-accuracy", name: "Factual accuracy", score: 8.5, status: "pass",
    notes: "All claims cross-reference KB articles within the last 90 days." },
  { id: "tone-match",       name: "Tone match",       score: 7.2, status: "warn",
    notes: "Slightly more formal than the customer's prior message; consider warming up." },
  { id: "completeness",     name: "Completeness",     score: 9.1, status: "pass",
    notes: "Addresses all three sub-questions; pricing breakdown matches CRM." },
  { id: "safety",           name: "Safety",           score: 10,  status: "pass",
    notes: "No PII leakage; no policy violations." },
  { id: "brand-voice",      name: "Brand voice",      score: 6.0, status: "warn",
    notes: "Uses 'utilise' once — kit voice prefers 'use'. Otherwise on-brand." },
];

<Evaluations
  title="Sage response — rubric scoring"
  criteria={criteria}
  defaultExpandedIds={["tone-match"]}
/>

// Summary auto-derived: overallScore 8.2 (mean of scores, rounded 1 dp),
// passCount 3, warnCount 2, failCount 0. Pass `summary` to override.

17.11 Missing knowledge queue

The closed-loop screen: questions users asked that the agent couldn’t fully answer, each one pre-filled with a Sage-generated draft answer ready to accept into the right knowledge collection in one click. Today “Missing Knowledge” is a passive log with a single Ignore action; this composition turns it into a learning loop — the more the agent runs, the more its knowledge compounds.

Queue — 8 questions waiting for review

.mk-screen · .mk-entry

Header runs the queue stats (open count, recurring %, this-week throughput). Filter tabs separate open from recurring (asked ≥ 3 times) from resolved. Each entry shows the question, ask-count, source agent + most-recent session, and an inline AI draft answer with the source citations Sage used. Three actions per entry: Approve & add to KB (primary), Edit draft, Reject & ignore. The first entry is open by default to demo the full draft surface.

Missing knowledge 8 open

Questions users asked that the agent couldn’t fully answer. Approve a draft to close the loop — the agent will use it next time the question recurs.

Open questions 8 awaiting review
Recurring 3 asked 3+ times
Resolved this week 12 + 34 in KB
Sorted by frequency · most-asked first
7 asks
“Do you support customers in Vietnam? I'm based in Ho Chi Minh City.”
Sage drafted an answer Confidence High · 2 sources

Yes — we support customers across the APAC region, including Vietnam. Ho Chi Minh City is fully covered under our APAC support window (Mon–Fri, 9am–6pm ICT). For setup help we can connect you with the regional onboarding team, and our chat agent works around the clock. Want me to book a 15-minute call with someone in the region?

Reject to
5 asks
“What's the difference between the Scale tier and Enterprise on AI message volume?”
4 asks
“Can I export my contact data to HubSpot as CSV instead of via the API?”
3 asks
“Do you have an on-premise deployment option?”
2 asks
“What's your refund policy for annual contracts terminated mid-term?”
That’s every open question Sage caught in the last 14 days. Approve a draft to compound the agent’s knowledge — every entry resolved here is one fewer dead-end conversation tomorrow.
<div class="mk-screen">
  <header class="mk-head">
    <h2>Missing knowledge <span class="mk-count">8 open</span></h2>
    <button class="mk-pill is-primary">+ Manual entry</button>
  </header>

  <div class="mk-stats">
    <div class="mk-stat">…Open questions · 8…</div>
    <div class="mk-stat">…Recurring · 3…</div>
    <div class="mk-stat">…Resolved this week · 12…</div>
  </div>

  <div class="mk-filters">
    <div class="mk-filter-tabs">
      <button class="mk-filter-tab is-active">Open <span>8</span></button>
      <button class="mk-filter-tab">Recurring <span>3</span></button>
      <button class="mk-filter-tab">Resolved <span>47</span></button>
      <button class="mk-filter-tab">Ignored <span>12</span></button>
    </div>
  </div>

  <div class="mk-queue">

    <!-- One entry per question -->
    <article class="mk-entry is-expanded">
      <header class="mk-entry-head">
        <div class="mk-entry-marker">7 asks</div>
        <div class="mk-entry-body">
          <div class="mk-entry-question">"Do you support customers in Vietnam?"</div>
          <div class="mk-entry-meta">…ask count, agent, sessions…</div>
        </div>
        <button class="mk-entry-toggle">▾</button>
      </header>

      <!-- Expanded draft body — opens beneath the head row -->
      <div class="mk-entry-draft">
        <span class="mk-draft-eyebrow">
          ✦ Sage drafted an answer · Confidence High
        </span>

        <div class="mk-draft-card">
          <div class="mk-draft-q">Question · <strong>…</strong></div>
          <div class="mk-draft-a">Yes — we support customers across the APAC region…</div>
          <div class="mk-draft-sources">
            <a class="mk-draft-source">Regional Policy v2.1</a>
            <a class="mk-draft-source">Location Data</a>
          </div>
        </div>

        <div class="mk-draft-target">
          Add to collection: <button>Sales Playbook · APAC ▾</button>
        </div>

        <div class="mk-draft-actions">
          <button class="mk-btn is-primary">Approve & add to KB</button>
          <button class="mk-btn">Edit draft</button>
          <button class="mk-btn is-ghost is-danger">Reject & ignore</button>
        </div>
      </div>
    </article>

    …more entries…
  </div>
</div>
/* Each entry is a card with a head row + collapsible draft.
   Expanded state gets accent border + soft pink ring. */
.mk-entry { border-radius: var(--r-lg); border: 1px solid var(--hair); }
.mk-entry.is-expanded {
  border-color: var(--accent);
  box-shadow: 0 0 0 2px var(--accent-soft);
}

/* The draft surface picks up a soft pink wash to telegraph
   "this is AI-generated — review before approving". Pairs the
   .mk-draft-card with the source citations Sage used. */
.mk-entry-draft {
  display: none;
  background:
    radial-gradient(circle at 50% 0%, var(--accent-soft), transparent 60%),
    var(--bg-paper);
}
.mk-entry.is-expanded .mk-entry-draft { display: flex; flex-direction: column; }

/* Approve is GREEN (positive, irreversible-ish) — never pink.
   Pink is reserved for in-progress / focus signals. */
.mk-btn.is-primary { background: #0F8062; color: var(--paper); border-color: #0F8062; }
// Toggle entry expansion. Idempotent + defensive.
document.addEventListener('DOMContentLoaded', () => {
  document.querySelectorAll('.mk-entry-toggle').forEach((btn) => {
    btn.addEventListener('click', (e) => {
      const entry = btn.closest('.mk-entry');
      if (!entry) return;
      entry.classList.toggle('is-expanded');
      const open = entry.classList.contains('is-expanded');
      btn.setAttribute('aria-label', open ? 'Collapse' : 'Expand');
    });
  });
  // Also toggle on header click (not just the chevron) — more forgiving
  document.querySelectorAll('.mk-entry-head').forEach((head) => {
    head.addEventListener('click', (e) => {
      // Ignore clicks on the toggle button itself (already handled above)
      // or on links inside the meta row.
      if (e.target.closest('.mk-entry-toggle, a')) return;
      head.querySelector('.mk-entry-toggle')?.click();
    });
  });
});
/* PROVISIONAL — pending gap-list additions.

   Pending kit exports:
     MissingKnowledgePage   — page-shape
     MissingKnowledgeEntry  — card with collapsible draft
     SageDraftCard          — AI draft with confidence + sources
     CollectionPicker       — target-KB picker
     ApproveRejectBar       — primary green Approve + Edit + Reject */

export function MissingKnowledgeView({ entries, onApprove }) {
  return (
    <MissingKnowledgePage
      stats={{ open: 8, recurring: 3, resolvedThisWeek: 12 }}
      filter="open"
    >
      {entries.map(entry => (
        <MissingKnowledgeEntry
          key={entry.id}
          askCount={entry.askCount}
          question={entry.question}
          sources={entry.recentSources}
          draft={
            <SageDraftCard
              question={entry.question}
              answer={entry.aiDraftAnswer}
              confidence={entry.aiConfidence}
              sources={entry.aiSources}
            />
          }
          target={<CollectionPicker value={entry.suggestedCollection} />}
          actions={
            <ApproveRejectBar
              onApprove={() => onApprove(entry)}
              onEdit={() => openEditor(entry)}
              onReject={() => reject(entry)}
              estTrainTime="< 30s"
            />
          }
        />
      ))}
    </MissingKnowledgePage>
  );
}

17.12 Connections & tools — Tools & MCP

The shared infrastructure home — formerly Library. With personas, snippets, forms and goals moved to the Agents HQ shelves (18.19), what remains here is the plumbing: function tools (HTTP and MCP), connections, webhooks and guardrail policies. This composition shows the Tools & MCP view: a three-column master-detail chrome with a side nav of library sections, a list of registered tools in the middle, and a detail panel on the right showing the selected item's configuration.

Library shell — 6 tools registered · Internal Inventory MCP selected

.lb-screen · .lb-side · .lb-tool-row · .lb-detail

Each tool row reads its kind (HTTP / MCP) from its icon tint, its method/tool-count at right, and its status via a kit .badge + .dot. Click a row to surface its config in the detail panel: the AI instruction that tells the agent when to reach for it, the connection details, the available tool functions discovered from the MCP server, tags, and the Edit / Test / Delete actions.

Tools & MCP

Functions your AI agent can call during a conversation. Register HTTP endpoints directly or connect a Model Context Protocol server to expose its tool surface. More on Tools & MCP →

Internal Inventory MCP Active
Internal inventory + SKU lookup · 12 tools discovered
12 tools 12m ago
Salesforce Lookup Active
Fetch a Salesforce contact by email or ID
GET 3d ago
Slack Notify Active
Post a message to #customer-success when a goal fires
POST 1w ago
HubSpot CRM MCP Unused
CRM read/write surface · 8 tools discovered
8 tools 2w ago
Stripe Charge Active
Create a one-off charge against a Stripe customer ID
POST 4d ago
Notion Read Error
Read pages from the customer's Notion workspace · auth expired
6 tools 1d ago
<div class="lb-screen">
  <aside class="lb-side">
    <p class="sub-nav-label">Tools</p>
    <a class="sub-nav-item is-active">Tools & MCP <span class="sub-nav-item-count">6</span></a>
    <a class="sub-nav-item">Tasks <span class="sub-nav-item-count">12</span></a>
    <a class="sub-nav-item">Connections <span class="sub-nav-item-count">4</span></a>
    <a class="sub-nav-item">Webhooks <span class="sub-nav-item-count">3</span></a>
    <p class="sub-nav-label">Shared features</p>
    <a class="sub-nav-item">Personas</a>
    <a class="sub-nav-item">Forms</a>
    <a class="sub-nav-item">Snippets</a>
    <a class="sub-nav-item">Goals</a>
  </aside>

  <main class="lb-main">
    <header class="lb-main-head">
      <h2>Tools & MCP</h2>
      <p>Functions your AI agent can call…</p>
    </header>
    <div class="lb-main-toolbar">
      <label class="lb-search"><input placeholder="Search…" /></label>
      <button class="lb-create-button">+ Create new</button>
    </div>
    <div class="lb-tools-list">
      <article class="lb-tool-row is-selected">
        <span class="lb-tool-icon is-mcp">MCP</span>
        <div class="lb-tool-body">
          <div class="lb-tool-name">Internal Inventory MCP
            <span class="badge"><span class="dot dot-green"></span> Active</span>
          </div>
          <div class="lb-tool-sub">Internal inventory + SKU lookup…</div>
        </div>
        <span class="lb-tool-method">12 tools</span>
        <span class="mono">12m ago</span>
      </article>
      …more tool rows…
    </div>
  </main>

  <aside class="lb-detail">
    <header class="lb-detail-head">…icon + name + status + close…</header>
    <div class="lb-detail-section">…AI instruction…</div>
    <div class="lb-detail-section">…Connection (URL / Transport / Auth)…</div>
    <div class="lb-detail-section">…Discovered tools list…</div>
    <div class="lb-detail-section">…Tags…</div>
    <div class="lb-detail-foot"><button>Test</button><button class="is-primary">Edit</button><button class="is-danger">Del</button></div>
  </aside>
</div>
/* Chapter-private 3-col master-detail. Tool kind is encoded by
   .lb-tool-icon's modifier (.is-http blue / .is-mcp pink), so an
   operator reads "REST tool vs MCP server" at a glance. The .badge
   + .dot status row uses the kit's chapter-7.4 contract. */
.lb-screen { display: grid; grid-template-columns: 220px 1fr 360px; }
.lb-side { border-right: 1px solid var(--hair); background: var(--bg-warm); }
.lb-side-item.is-active { background: var(--accent-soft); color: var(--accent); }
.lb-tool-row.is-selected { background: var(--accent-soft); border-color: var(--accent); }
.lb-detail { background: var(--bg-warm); padding: var(--s-5); }
.lb-detail-foot button.is-primary { background: var(--accent); color: var(--paper); }
/* PROVISIONAL — composes kit primitives.
   Existing kit exports used: Badge, Dot. */

export function LibraryToolsPage({ tools, selectedId, onSelect }) {
  const selected = tools.find(t => t.id === selectedId);
  return (
    <div className="lb-screen">
      <LibrarySideNav active="tools" />
      <main className="lb-main">
        <LibraryHeader title="Tools & MCP" />
        <Toolbar onSearch={...} onCreate={...} />
        <ul className="lb-tools-list">
          {tools.map(t => (
            <ToolRow tool={t} selected={t.id === selectedId} onClick={() => onSelect(t.id)} key={t.id} />
          ))}
        </ul>
      </main>
      {selected && <ToolDetailPanel tool={selected} onClose={() => onSelect(null)} />}
    </div>
  );
}

17.13 Knowledge item row

One row in the Knowledge collection editor. A source icon (where it came from — written, website, spreadsheet, file) + the title or question + a one-line content preview + an optional topic chip + last-updated time. Composes SourceRow; the preview is its meta slot, so the row collapses to a single line when there is nothing to preview (short “Always” facts). The three retrieval lanes read in plain language: Always / When it's relevant / When a topic comes up.

Knowledge item row

.source-row · <KnowledgeItemRow>

A website page (two-line, with preview), a spreadsheet Q&A tagged with a topic, and a short hand-written fact (one line, no preview).

What makes a good CRMA good CRM centralises every customer interaction, automates repetitive data entry, and surfaces the next best action…

2d ago

How much does the Pro plan cost?Pro is $49 per user / month billed annually ($59 monthly). Includes the automation builder…
Pricing
Never promise migration timelines under 2 weeks.

3d ago

<div class="source-row">
  <span class="icon-chip icon-chip-neutral source-row-icon"><svg>…</svg></span>
  <div class="source-row-body">
    <span class="source-row-name">What makes a good CRM</span>
    <span class="source-row-meta">A good CRM centralises every interaction…</span>
  </div>
  <div class="source-row-trailing"><p class="caption">2d ago</p></div>
</div>
/* No new CSS — composes the existing .source-row (08-data-display)
   and .chip-green. The topic chip is <Chip tone="green">. */
import { KnowledgeItemRow, TopicChip } from "@magicblocksai/ui";

<KnowledgeItemRow source="website" title="What makes a good CRM" preview="A good CRM centralises every interaction…" updated="2d ago" />

<KnowledgeItemRow source="spreadsheet" title="How much does the Pro plan cost?" preview="Pro is $49 per user / month…" topic="pricing" />

// One-line fact (no preview) — the row collapses automatically:
<KnowledgeItemRow source="manual" title="Never promise migration timelines under 2 weeks." updated="3d ago" />

// The topic chip is exported standalone too:
<TopicChip topic="objection_handling" />

17.14 Knowledge lane

The spine of the Knowledge editor. Every item lives in one of three lanes, named in plain language — Always (the agent always keeps these in mind), When it's relevant (looked up when the message relates), When a topic comes up (fires only on a chosen topic). Each lane is a tinted header (coloured dot + name + helper + count) over its KnowledgeItemRows. Tones map to retrieval behaviour: accent / info / success.

Three lanes

.knowledge-lane · <KnowledgeLane>

A collection laid out by what the agent does with each item, not where it came from.

AlwaysKey facts the agent always keeps in mind.4
Never promise migration timelines under 2 weeks.

3d ago

When it's relevantLooked up when the customer's message relates.16
What makes a good CRMA good CRM centralises every customer interaction, automates repetitive data entry, and surfaces the next best action…

2d ago

Security & complianceWe're SOC2 Type II, encrypt data at rest and in transit, and offer EU data residency on Business plans…

5d ago

When a topic comes upBrought up only on the topics you choose.4
How much does the Pro plan cost?Pro is $49 per user / month billed annually ($59 monthly). Includes the automation builder…
Pricing
<div class="knowledge-lane knowledge-lane-always">
  <div class="knowledge-lane-head">
    <span class="knowledge-lane-dot"></span>
    <span class="knowledge-lane-name">Always</span>
    <span class="knowledge-lane-help">Key facts the agent always keeps in mind.</span>
    <span class="knowledge-lane-count">4</span>
  </div>
  <div class="knowledge-lane-body"><!-- KnowledgeItemRows --></div>
</div>
.knowledge-lane-always  .knowledge-lane-head { background: var(--accent-soft); }
.knowledge-lane-always  .knowledge-lane-name { color: var(--accent-text-strong); }
.knowledge-lane-semantic .knowledge-lane-head { background: var(--info-soft); }
.knowledge-lane-conditional    .knowledge-lane-head { background: var(--success-soft); }
import { KnowledgeLane, KnowledgeItemRow } from "@magicblocksai/ui";

<KnowledgeLane kind="always" count={4}>
  <KnowledgeItemRow source="manual" title="Never promise migration timelines under 2 weeks." updated="3d ago" />
</KnowledgeLane>

<KnowledgeLane kind="semantic" count={16}>…</KnowledgeLane>
<KnowledgeLane kind="conditional" count={4}>…</KnowledgeLane>

17.15 Add knowledge menu

The single entry point for bringing knowledge into a collection. A dropdown with four plain-language options — Write it yourself, Import from a website, Upload a spreadsheet, Upload a file. Composes Menu; each option opens the matching flow.

Add knowledge menu

.menu · <AddKnowledgeMenu>

Shown open (in production the menu is portal-positioned under the trigger).

<div class="menu" role="menu">
  <button class="menu-item" role="menuitem">
    <span class="menu-item-icon"><svg>…</svg></span>
    <span class="menu-item-label">Import from a website</span>
  </button>
  <!-- …three more options… -->
</div>
/* No new CSS — composes the existing .menu / .menu-item (07-navigation). */
import { AddKnowledgeMenu } from "@magicblocksai/ui";

<AddKnowledgeMenu onChoose={(choice) => openImporter(choice)} />

// choice is "write" | "website" | "spreadsheet" | "file"

17.16 Attach lane picker

How a collection is attached to an agent — you pick which lanes it gets, and within “When a topic comes up”, which topics. The three lanes are the on/off switches, so attaching only part of a collection (“certain parts, out of those 3 things”) needs no separate views. The same picker serves the per-Journey-block override.

Attach lane picker

.attach-lane-picker · <AttachLanePicker>

All three lanes on, with two topics chosen inside the conditional lane. Toggling a lane off removes it from the agent; the summary updates in plain language.

4 itemsKey facts the agent always keeps in mind.
16 itemsLooked up when the customer's message relates.
4 itemsFires only on the topics you choose.

This agent gets Always + When it's relevant + When a topic comes up, plus Pricing, Objections topics.

<div class="attach-lane-picker">
  <div class="attach-lane-row">
    <label class="cb"><input type="checkbox" checked><span class="cb-box"></span>
      <span><span class="attach-lane-name">Always</span></span></label>
    <span class="attach-lane-count">4 items</span>
    <span class="attach-lane-help">Key facts the agent always keeps in mind.</span>
  </div>
  <!-- …two more lanes; the conditional lane reveals topic chips… -->
  <p class="attach-lane-summary">This agent gets Always + When it's relevant…</p>
</div>
.attach-lane-row {
  display: grid; grid-template-columns: 1fr auto;
  gap: var(--s-1) var(--s-2); padding: var(--s-3) var(--s-4);
  border: 1px solid var(--hair); border-radius: var(--r-md); }
.attach-lane-topics {
  grid-column: 1 / -1; padding-top: var(--s-3);
  border-top: 1px dashed var(--hair); }
import { AttachLanePicker } from "@magicblocksai/ui";

<AttachLanePicker
  counts={{ always: 4, semantic: 16, conditional: 4 }}
  onValueChange={(sel) => save(sel)}
/>

// sel = { lanes: ("always"|"semantic"|"conditional")[], topics: KnowledgeTopic[] }

17.17 Proposal card

Sage’s unit of change — a copilot edit as a card the operator stays in charge of. Four hard rules, encoded in the chrome: Sage edits the working copy only and never publishes; substantive changes wait as pending proposals (Accept · Adjust · Dismiss) while simple low-risk ones may land as applied cards with a prominent Undo; every applied change also appears in the agent’s Recent changes; and a change that fails validation renders as an error card — nothing applied, with a plain explanation. The exact change sits behind a closed-by-default “Show exactly what changed” disclosure; old → new text uses .proposal-old / .proposal-new. The NextGen wiring contract lives at docs/sage-proposal-contract.md.

Three states

.proposal-card · <ProposalCard>

Pending (a rewrite shown as strikethrough → replacement), applied (with the expandable exact change and Undo), and error (why nothing changed, in plain language). Adjust pre-fills the Sage composer with the proposal so the operator can refine it.

Proposal
Rewrite the Greeting job
Warmly greet the user. Introduce yourself as a B2B lead-gen specialist. Say hi like a person, not a pitch — one line, then ask what brought them in today.
Applied to your draft
New key fact: Budget
Asked in Discovery · listened for everywhere · saves to {{budget}}
Show exactly what changed
Ask: “Ask about their budget range in a friendly way.” · Listen: “Extract a number or a range; keep the currency.”
Couldn't make that change
Add a key fact to the Pricing block
There's no block called Pricing in this journey — nothing was changed. Try Greeting, Discovery or Handoff.
<div class="proposal-card is-pending">
  <div class="proposal-eyebrow">…wand svg… Proposal</div>
  <div class="proposal-title">Rewrite the Greeting job</div>
  <div class="proposal-summary"><span class="proposal-old">old text</span> <span class="proposal-new">new text</span></div>
  <div class="sage-proposal-actions">
    <button class="sage-prop-accept">Accept</button>
    <button class="sage-prop-edit">Adjust</button>
    <button class="sage-prop-dismiss">Dismiss</button>
  </div>
</div>

<div class="proposal-card is-applied">
  <div class="proposal-eyebrow">…check svg… Applied to your draft</div>
  …title + summary…
  <details class="proposal-diff"><summary>Show exactly what changed</summary><div class="proposal-diff-body">…</div></details>
  <button class="proposal-undo">Undo</button>
</div>
/* Ships in @magicblocksai/css (@surface: operator) — .proposal-card + is-pending /
   is-applied / is-error tones; action buttons reuse the shipped .sage-prop-* styles. */
// The diff disclosure is a native <details> — closed by default, no wiring.
// Sage edits the working copy only; Save / Publish stay human buttons.
import { ProposalCard } from '@magicblocksai/ui';

<ProposalCard
  title="Rewrite the Greeting job"
  summary={<><span className="proposal-old">Warmly greet the user…</span> <span className="proposal-new">Say hi like a person, not a pitch…</span></>}
  onAccept={accept} onAdjust={adjust} onDismiss={dismiss}
/>

<ProposalCard
  state="applied"
  title="New key fact: Budget"
  summary="Asked in Discovery · listened for everywhere"
  diff="Ask: 'Ask about their budget range in a friendly way.'"
  onUndo={undo}
/>

<ProposalCard
  state="error"
  title="Add a key fact to the Pricing block"
  summary="There's no block called Pricing in this journey — nothing was changed."
/>

17.18 Sage dock

The agent builder’s right dock — one surface, two jobs. Test is the operator’s fresh-session chat; Sage is the copilot that edits the working copy through proposal cards (17.17). Building and testing share one column, so the studio never grows a third rail. Globally, Sage stays pinned at the bottom of the slim workspace rail (15.27) and opens the same conversation as a floating drawer (17.1), context-aware about the page you’re on.

Sage tab mid-conversation

.sage-dock · .sage-stream · .proposal-card

A mixed outcome, honestly reported: the user asked for two changes; Sage applied the low-risk one immediately (with Undo) and proposed the substantive one. The composer invites the next instruction.

You
Add a budget question to Discovery, and make the greeting feel less stiff
Sage
Done thinking — two changes. First one’s applied, second is waiting on you.
Applied to your draft
New key fact: Budget
Asked in Discovery · listened for everywhere · saves to {{budget}}
Proposal
Rewrite the Greeting job
Warmly greet the user. Introduce yourself as a B2B lead-gen specialist. Say hi like a person, not a pitch — one line, then ask what brought them in today.
<div class="sage-dock">
  <div class="sage-dock-tabs">
    <button class="sage-dock-tab">Test</button>
    <button class="sage-dock-tab is-active">…wand svg… Sage</button>
  </div>
  <div class="sage-dock-body">
    …sage-msg turns + proposal cards…
  </div>
  <footer class="sage-composer">…</footer>
</div>
/* Ships in @magicblocksai/css (@surface: operator) — .sage-dock / -tabs / -tab /
   -body; turns and composer reuse the 17.1 .sage-* classes. */
// Tab switching is consumer state (the Test pane is the app's test chat).
// Accept applies the patch to the client draft — the same setDraft path as a
// hand edit, so dirty-state and Publish behave identically.
import { SageDrawer, type SageMessage } from '@magicblocksai/ui';

const messages: SageMessage[] = [
  { id: 'u1', role: 'user', text: 'Add a budget question to Discovery, and make the greeting feel less stiff' },
  { id: 'a1', role: 'assistant', text: 'Done thinking — two changes. First one’s applied, second is waiting on you.' },
  { id: 'p1', role: 'proposal', state: 'applied', title: 'New key fact: Budget',
    body: 'Asked in Discovery · listened for everywhere', onUndo: undo },
  { id: 'p2', role: 'proposal', title: 'Rewrite the Greeting job',
    body: 'Say hi like a person, not a pitch — one line, then ask what brought them in today.',
    summary: proposalSummary, onAccept: accept, onEdit: adjust, onDismiss: dismiss },
];

<div className="sage-dock">
  {/* tab pair is consumer markup; the stream is the drawer in inline mode */}
  <SageDrawer floating={false} messages={messages} onSend={send}
    placeholder="Ask Sage to change anything…" />
</div>