Approval probability
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-drawerShowing 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.
tool get_recent_activity(deal_id=42)
<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
.composeShowing 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
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
<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-cardIn 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.
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.
<!-- 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-suggestStacks 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
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…
Troubleshooting SAML signature mismatch
Most SAML errors come from a clock skew between the IdP and our service. Confirm both sides are syncing to NTP, then re-test…
<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.
<!-- 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-cardThree 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).
Lead quality
Worth a follow-up
Close rate · last 30 days
Below team average
<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-logEight 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.
- Scanning yourcompany.com…
- Resolved 14 internal links
- Found privacy policy at /privacy
- Site is HTTPS
- Domain is branded — no URL shorteners detected
- Terms-of-service page returns 302 redirect
- Privacy policy missing SMS consent disclosure
- 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 source → cite. Kept for back-compat; new code should use <SourcePassage>, which adds a relevance score, freshness meta, and tone.
EvidenceQuote
.source-passageTwo 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.
For SMS programs, message frequency varies. Reply STOP to cancel, HELP for help.
<!-- 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-recheckA 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?
<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
.evaluationsFive 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
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-entryHeader 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.
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?
<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-detailEach 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 →
<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).
2d ago
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.
3d ago
2d ago
5d ago
<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.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.
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.
{{budget}}Show exactly what changed
<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-cardA 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.
{{budget}}<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>