Chapter 18 · Operator · Agent-building surfaces

Agent builder. The configuration surface.

The operator-facing surface used to configure an agent — declarative query / condition rules, source-to-target field mapping, connection test affordances, version switching, and the structured document editor where prompts and policies are authored. Six components compose the full configuration vocabulary.

18.1 QueryBuilder

The flat AND/OR condition builder that anchors every audience filter, ask-when rule, agent action condition, and webhook event filter across the platform. The operator builds the rule top-down: each row is a field · operator · value triple; the conjunction toggle picks AND or OR; an Add condition button drops a fresh row at the bottom. The value editor swaps shape on the field’s type — single select, chip toggles, duration count + unit, or plain text — without changing the row chrome.

QueryBuilder

.qb

Three conditions stacked with the AND/OR conjunction toggle on top — a select field with one value, a multiselect with two chips, and a duration with count + unit. The WHERE label sits to the left of the first row; subsequent rows show the conjunction label. The remove affordance lives at the end of each row.

<!-- One .qb wraps the conjunction toggle, the .qb-rows stack, and  -->
<!-- the + Add condition button. Each .qb-row is a fixed-grid       -->
<!-- conj · field · op · value · remove triple.                       -->
<div class="qb">
  <div class="qb-conj-toggle" role="group" aria-label="Match">
    <button type="button" class="qb-conj-btn is-on">Match all (AND)</button>
    <button type="button" class="qb-conj-btn">Match any (OR)</button>
  </div>
  <div class="qb-rows">
    <div class="qb-row">
      <span class="qb-conj">WHERE</span>
      <select class="qb-field">…</select>
      <select class="qb-op">…</select>
      <select class="qb-value qb-value-select">…</select>
      <button class="qb-remove" aria-label="Remove condition">…</button>
    </div>
    <!-- …more rows… -->
  </div>
  <button class="qb-add">+ Add condition</button>
</div>
.qb { display: flex; flex-direction: column; gap: var(--s-3); }

.qb.is-disabled { opacity: 0.55; pointer-events: none; }

.qb-conj-toggle {
  display: inline-flex;
  align-self: flex-start;
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
  padding: 2px;
  background: var(--bg-paper);
  gap: 2px;
}

.qb-conj-btn {
  appearance: none;
  border: 0;
  background: transparent;
  padding: 5px 10px;
  font: 500 12px/1 var(--f-body);
  color: var(--fg-soft);
  border-radius: var(--r-sm);
  cursor: pointer;
}

.qb-conj-btn.is-on { background: var(--ink); color: var(--paper); }

.qb-rows { display: flex; flex-direction: column; gap: 6px; }

.qb-row {
  display: grid;
  grid-template-columns: 60px minmax(140px, 1fr) minmax(140px, 1fr) minmax(180px, 2fr) 28px;
  gap: 6px;
  align-items: center;
}

.qb-row.is-disabled { opacity: 0.55; }

.qb-conj {
  font: 600 10.5px/1 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fg-faint);
  text-align: right;
  padding-right: 4px;
}

/* Bare .qb-value is NOT styled — the container variants carry it too. */
.qb-field, .qb-op, .qb-value-select, .qb-value-text, .qb-value-number {
  height: 32px;
  padding: 0 var(--s-3);
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  background: var(--bg-paper);
  color: var(--fg);
  font: 400 13px/1 var(--f-body);
}

.qb-field:focus-visible, .qb-op:focus-visible,
.qb-value-select:focus-visible, .qb-value-text:focus-visible,
.qb-value-number:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

/* Duration editor — transparent grid wrapper; inner controls are the
   bordered fields, so the row aligns with single-field value cells. */
.qb-value-duration {
  display: grid;
  grid-template-columns: minmax(56px, 0.5fr) minmax(0, 1fr);
  gap: 4px;
  min-width: 0;
}

.qb-value-duration input,
.qb-value-duration select {
  height: 32px;
  min-width: 0;
  padding: 0 var(--s-2);
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  background: var(--bg-paper);
  color: var(--fg);
  font: 400 13px/1 var(--f-body);
}

.qb-value-chips {
  display: inline-flex;
  flex-wrap: wrap;
  gap: 4px;
  align-items: center;
  padding: 2px;
}

.qb-chip {
  appearance: none;
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: 999px;
  padding: 4px 10px;
  font: 400 12px/1 var(--f-body);
  cursor: pointer;
  color: var(--fg);
}

.qb-chip.is-on {
  background: var(--ink);
  color: var(--paper);
  border-color: var(--ink);
}

/* …additional rules trimmed for brevity — see _shared.css */
import { useState } from 'react';
import { QueryBuilder } from '@magicblocksai/ui';
import type { QueryCondition, QueryField } from '@magicblocksai/ui';

const fields: QueryField[] = [
  { id: 'loan_purpose', label: 'Loan purpose', type: 'select',
    options: [
      { value: 'purchase', label: 'Purchase' },
      { value: 'refinance', label: 'Refinance' },
    ] },
  { id: 'sentiment', label: 'Sentiment', type: 'multiselect',
    options: [
      { value: 'positive', label: 'Positive' },
      { value: 'neutral',  label: 'Neutral' },
      { value: 'negative', label: 'Negative' },
      { value: 'confused', label: 'Confused' },
    ] },
  { id: 'no_msg_for', label: 'No message received for', type: 'duration' },
];

function Example() {
  const [conditions, setConditions] = useState<QueryCondition[]>([
    { id: 'r1', fieldId: 'loan_purpose', operator: 'equals',  value: 'purchase' },
    { id: 'r2', fieldId: 'sentiment',    operator: 'one-of',  value: ['negative', 'confused'] },
    { id: 'r3', fieldId: 'no_msg_for',   operator: 'in-last', value: { count: 5, unit: 'minutes' } },
  ]);
  return (
    <QueryBuilder
      fields={fields}
      value={conditions}
      onValueChange={setConditions}
      allowConjunctionToggle
    />
  );
}

18.2 ConditionRow

The atom of the QueryBuilder. One row carries a single rule: pick a field, pick an operator, supply a value (if the operator takes one). The operator vocabulary and the value editor are derived from the field’s declared type — text fields offer equals / contains / starts-with; number fields offer comparison operators; multiselects collapse to chip toggles; is-known / is-unknown drop the value editor altogether.

ConditionRow

.qb-row

Six rows in a stack, each demonstrating a different operator variant against the same underlying field shape — equals (single text value), does not equal, contains, in-last (duration count + unit), is one of (multiselect chips), and is known (no value editor).

<!-- One .qb-row per condition. The conj span renders WHERE / AND /  -->
<!-- OR; the value column swaps editor shape on operator + field    -->
<!-- type. is-known / is-unknown render with no value editor at all. -->
<div class="qb-row">
  <span class="qb-conj">WHERE</span>
  <select class="qb-field">…</select>
  <select class="qb-op">…</select>
  <input type="text" class="qb-value qb-value-text" value="Jay Stockwell">
  <button class="qb-remove" aria-label="Remove condition">…</button>
</div>

<!-- in-last operator → duration value editor (count + unit) -->
<div class="qb-row">
  <span class="qb-conj">AND</span>
  <select class="qb-field">…</select>
  <select class="qb-op">…</select>
  <div class="qb-value qb-value-duration">
    <input type="number" value="24">
    <select><option>Hours</option></select>
  </div>
  <button class="qb-remove">…</button>
</div>

<!-- is-known → no value editor; the remove button moves up -->
<div class="qb-row">
  <span class="qb-conj">AND</span>
  <select class="qb-field"><option>Email</option></select>
  <select class="qb-op"><option>is known</option></select>
  <button class="qb-remove">…</button>
</div>
.qb-row {
  display: grid;
  grid-template-columns: 60px minmax(140px, 1fr) minmax(140px, 1fr) minmax(180px, 2fr) 28px;
  gap: 6px;
  align-items: center;
}

.qb-row.is-disabled { opacity: 0.55; }

@media (max-width: 720px) {
  .qb-row { grid-template-columns: 1fr 28px; }
  .qb-conj { grid-column: 1 / 2; text-align: left; padding-right: 0; }
  .qb-field, .qb-op, .qb-value { grid-column: 1 / 2; }
  .qb-remove { grid-row: 1 / span 5; grid-column: 2; align-self: start; }
}
import { ConditionRow } from '@magicblocksai/ui';
import type { QueryCondition, QueryField } from '@magicblocksai/ui';

declare const fields: QueryField[];
declare const condition: QueryCondition;

// Standalone — drop a single ConditionRow into your own surface
// without a QueryBuilder wrapper. Wire conjunctionLabel, onChange,
// onRemove yourself.
<ConditionRow
  fields={fields}
  condition={condition}
  conjunctionLabel="WHERE"
  onChange={(next) => updateRule(next)}
  onRemove={() => removeRule(condition.id)}
/>

// Operator drives editor shape — equals on a text field is a text
// input; in-last on a duration is a count + unit; is-known has no
// value editor at all. The operator vocabulary is derived from the
// field's `type`. Override per-field with `operators: QueryOperator[]`.

18.3 FieldMapper

Two-column source-to-target mapper. The left column picks a MagicBlocks data field (a contact attribute, a key fact, a CRM property); the right column picks the destination on an external system (a HubSpot property, a Calendar field, a CSV column). The arrow between is decorative — the data direction is conveyed by the column headers. The same primitive backs HubSpot deal-creation mapping, Calendar field auto-population, and CSV column-to-CRM import.

FieldMapper

.field-mapper

Three source-to-target mappings drawn between a MagicBlocks contact + key-fact source list on the left and a HubSpot property list on the right. The header row labels the two columns and the directional arrow; each row repeats the same shape so the operator can scan top-to-bottom for a particular field.

MagicBlocks field
HubSpot property
<!-- A .field-mapper wraps the column-label header, the .field-mapper- -->
<!-- rows stack, and the + Add row button. Each .field-mapper-row is  -->
<!-- a fixed-grid source · arrow · target · remove quadruple.        -->
<div class="field-mapper">
  <div class="field-mapper-head">
    <div class="field-mapper-col-label">MagicBlocks field</div>
    <div class="field-mapper-arrow">→</div>
    <div class="field-mapper-col-label">HubSpot property</div>
    <div></div>
  </div>
  <div class="field-mapper-rows">
    <div class="field-mapper-row">
      <select class="field-mapper-select">…</select>
      <span class="field-mapper-arrow">→</span>
      <select class="field-mapper-select">…</select>
      <button class="field-mapper-remove" aria-label="Remove mapping">…</button>
    </div>
    <!-- …more rows… -->
  </div>
  <button class="field-mapper-add">+ Add property</button>
</div>
.field-mapper { display: flex; flex-direction: column; gap: var(--s-2); }

.field-mapper.is-disabled { opacity: 0.55; pointer-events: none; }

/* The rows wrapper carries its own column flex + gap so the
   mapping rows get vertical breathing room. */
.field-mapper-rows {
  display: flex;
  flex-direction: column;
  gap: var(--s-2);
}

.field-mapper-head,
.field-mapper-row {
  display: grid;
  grid-template-columns: 1fr 24px 1fr 28px;
  gap: 6px;
  align-items: center;
}

.field-mapper-col-label {
  font: 600 10.5px/1 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fg-faint);
  padding: 0 2px;
}

.field-mapper-arrow {
  text-align: center;
  font: 400 14px/1 var(--f-body);
  color: var(--fg-faint);
}

.field-mapper-select {
  height: 32px;
  padding: 0 var(--s-3);
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  background: var(--bg-paper);
  color: var(--fg);
  font: 400 13px/1 var(--f-body);
}

.field-mapper-select:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 1px;
}

.field-mapper-remove {
  appearance: none;
  background: transparent;
  border: 0;
  width: 28px; height: 28px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  color: var(--fg-faint);
  cursor: pointer;
  border-radius: var(--r-xs);
}

.field-mapper-remove:hover { color: var(--fg); background: var(--bg-warm); }

.field-mapper-empty {
  padding: var(--s-3) var(--s-4);
  border: 1px dashed var(--hair);
  border-radius: var(--r-md);
  font: 400 13px/1.4 var(--f-body);
  color: var(--fg-soft);
}

.field-mapper-add {
  align-self: flex-start;
  appearance: none;
  background: transparent;
  border: 1px dashed var(--hair);
  border-radius: var(--r-sm);
  padding: 6px 10px;
  font: 500 12.5px/1 var(--f-body);
  color: var(--accent);
  cursor: pointer;
}

.field-mapper-add:hover { background: var(--bg-warm); }

@media (max-width: 720px) {
  .field-mapper-head { display: none; }
  .field-mapper-row {
    grid-template-columns: 1fr 28px;
    gap: 4px;
  }
  .field-mapper-row > .field-mapper-arrow { display: none; }
}
import { useState } from 'react';
import { FieldMapper } from '@magicblocksai/ui';
import type { FieldMapperOption, FieldMapping } from '@magicblocksai/ui';

const sourceOptions: FieldMapperOption[] = [
  { value: 'contact.first_name', label: 'Contact · First name' },
  { value: 'contact.email',      label: 'Contact · Email' },
  { value: 'fact.loan_purpose',  label: 'Key Fact · Loan purpose' },
];

const targetOptions: FieldMapperOption[] = [
  { value: 'firstname',       label: 'firstname' },
  { value: 'email',           label: 'email' },
  { value: 'loan_purpose__c', label: 'loan_purpose__c (custom)' },
];

function Example() {
  const [mappings, setMappings] = useState<FieldMapping[]>([
    { id: 'm1', source: 'contact.first_name', target: 'firstname' },
    { id: 'm2', source: 'contact.email',      target: 'email' },
    { id: 'm3', source: 'fact.loan_purpose',  target: 'loan_purpose__c' },
  ]);
  return (
    <FieldMapper
      value={mappings}
      onValueChange={setMappings}
      sourceOptions={sourceOptions}
      targetOptions={targetOptions}
      sourceLabel="MagicBlocks field"
      targetLabel="HubSpot property"
      addLabel="+ Add property"
    />
  );
}

18.4 TestConnectionButton

A button with a built-in pending / ok / error result chip. Wraps an async probe — MCP Discover Tools, Webhook Test, HubSpot Test, Form Test — and shows the outcome inline without forcing the consumer to manage three state variables. The button auto-disables while pending, swaps to the pending label and spinner, then renders a tonal chip on resolve. The chip auto-clears after resetAfterMs (4s default; pass 0 to keep it sticky).

TestConnectionButton

.test-connection

Four side-by-side states — untested (idle), testing (pending with spinner + busy label), success (ok chip with check + caption), and error (warning chip with cross + message). The chip sits to the right of the button on wide screens and wraps below on narrow ones.

untested · idle
testing · pending
success · ok
✓ 200 OK in 142ms
error · failed
× HTTP 502
<!-- The .test-connection wrapper carries is-idle / is-pending /     -->
<!-- is-ok / is-error to drive button + chip rendering.              -->
<div class="test-connection is-idle">
  <button class="test-connection-btn"><span>Test webhook</span></button>
</div>

<!-- Pending: button is disabled + aria-busy, spinner is visible.    -->
<div class="test-connection is-pending">
  <button class="test-connection-btn" disabled aria-busy="true">
    <span class="test-connection-spinner"></span>
    <span>Testing…</span>
  </button>
</div>

<!-- Result chip sits beside the button.                              -->
<div class="test-connection is-ok">
  <button class="test-connection-btn"><span>Test webhook</span></button>
  <span class="test-connection-chip is-ok">✓ 200 OK in 142ms</span>
</div>

<div class="test-connection is-error">
  <button class="test-connection-btn"><span>Test webhook</span></button>
  <span class="test-connection-chip is-error">× HTTP 502</span>
</div>
.test-connection {
  display: inline-flex;
  align-items: center;
  gap: var(--s-2);
  flex-wrap: wrap;
}

.test-connection-btn {
  appearance: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 12px;
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  background: var(--bg-paper);
  color: var(--fg);
  font: 500 13px/1 var(--f-body);
  cursor: pointer;
  transition: background var(--dur-2) var(--ease);
}

.test-connection-btn:hover:not(:disabled) { background: var(--bg-warm); }

.test-connection-btn:disabled { opacity: 0.6; cursor: not-allowed; }

.test-connection-spinner {
  width: 12px;
  height: 12px;
  border-radius: 999px;
  border: 1.5px solid currentColor;
  border-right-color: transparent;
  animation: test-connection-spin 0.7s linear infinite;
}

@media (prefers-reduced-motion: reduce) {
  .test-connection-spinner { animation: none; opacity: 0.5; }
}

.test-connection-chip {
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 3px 8px;
  border-radius: 999px;
  font: 500 12px/1 var(--f-body);
}

.test-connection-chip.is-ok {
  background: var(--success-soft, color-mix(in oklab, #2DAA64 12%, var(--bg-paper)));
  color: var(--success-text, #1A6A3F);
}

.test-connection-chip.is-error {
  background: var(--error-soft, color-mix(in oklab, #C0392B 12%, var(--bg-paper)));
  color: var(--error-text, #8B2417);
}
import { TestConnectionButton } from '@magicblocksai/ui';

// Async wrapper — resolve with { message } for the ok chip caption,
// throw for the error chip. The button manages all three states.
<TestConnectionButton
  onTest={async () => {
    const r = await fetch('/api/webhook/test', { method: 'POST' });
    if (!r.ok) throw new Error(`HTTP ${r.status}`);
    return { message: '200 OK in 142ms' };
  }}
>
  Test webhook
</TestConnectionButton>

// Controlled — drive state explicitly when you need to coordinate
// with other surfaces. `resetAfterMs={0}` keeps the chip sticky.
import { useState } from 'react';
import type { TestConnectionState } from '@magicblocksai/ui';

function Controlled() {
  const [state, setState] = useState<TestConnectionState>({ status: 'idle' });
  return (
    <TestConnectionButton
      state={state}
      onStateChange={setState}
      onTest={async () => { /* … */ return { message: 'OK' }; }}
      resetAfterMs={0}
    />
  );
}

18.5 VersionSwitcher

A dropdown that swaps between historical revisions of a versioned entity — agents, personas, and tasks are the platform’s three versioned entity types. The trigger renders the current version label + an optional status pill; the open menu lists every prior version with a timestamp and an optional status badge. Optional footer actions add Compare + Create new version entry points.

VersionSwitcher

.version-switcher

The trigger and a statically-rendered open menu showing four versions — v4 (current, live), v3 (draft), v2, and v1 — each with semver-style label, timestamp, and where relevant a status badge (Live / Draft). The footer carries Compare and Create-new-version actions.

<!-- .version-switcher is the relative wrapper; .is-open opens the   -->
<!-- menu, .is-compact shrinks the trigger.                          -->
<div class="version-switcher is-open">
  <button class="version-switcher-trigger" aria-haspopup="menu" aria-expanded="true">
    <span class="version-switcher-trigger-label">Version 4</span>
    <span class="version-switcher-trigger-status"><span class="badge tone-success">Live</span></span>
    <!-- inline 10×10 caret SVG -->
  </button>
  <div class="version-switcher-menu" role="menu">
    <div class="version-switcher-menu-list">
      <button role="menuitemradio" aria-checked="true"
              class="version-switcher-item is-on">
        <span class="version-switcher-item-head">
          <span class="version-switcher-item-label">Version 4</span>
          <span class="version-switcher-item-status">
            <span class="badge tone-success">Live</span>
          </span>
        </span>
        <span class="version-switcher-item-foot">
          <span class="version-switcher-item-timestamp">Published Tue 14:02</span>
          <span class="version-switcher-item-caption">Pinned production</span>
        </span>
      </button>
      <!-- …more items… -->
    </div>
    <div class="version-switcher-menu-footer">
      <button class="version-switcher-action">Compare versions</button>
      <button class="version-switcher-action is-primary">+ Create new version</button>
    </div>
  </div>
</div>
.version-switcher { position: relative; display: inline-block; }

.version-switcher.is-disabled { opacity: 0.55; pointer-events: none; }

.version-switcher-trigger {
  appearance: none;
  display: inline-flex;
  align-items: center;
  gap: 6px;
  padding: 6px 10px;
  border: 1px solid var(--hair);
  border-radius: var(--r-sm);
  background: var(--bg-paper);
  color: var(--fg);
  font: 500 13px/1 var(--f-body);
  cursor: pointer;
}

.version-switcher.is-compact .version-switcher-trigger { padding: 4px 8px; font-size: 12.5px; }

.version-switcher-trigger:hover:not(:disabled) { background: var(--bg-warm); }

.version-switcher-trigger:disabled { cursor: not-allowed; }

.version-switcher-trigger-status { margin-left: 2px; }

.version-switcher-trigger-label { line-height: 1; }

.version-switcher-caret { transition: transform var(--dur-2) var(--ease); }

.version-switcher.is-open .version-switcher-caret { transform: rotate(180deg); }

@media (prefers-reduced-motion: reduce) {
  .version-switcher-caret { transition: none; }
}

.version-switcher-menu {
  position: absolute;
  top: calc(100% + 4px);
  left: 0;
  z-index: 50;
  /* Wide enough for the two-action footer to sit on one line. */
  min-width: 300px;
  background: var(--bg-paper);
  border: 1px solid var(--hair);
  border-radius: var(--r-md);
  box-shadow: 0 12px 24px color-mix(in oklab, var(--ink) 14%, transparent);
  padding: 4px;
  max-height: 360px;
  display: flex;
  flex-direction: column;
}

.version-switcher-menu-list { overflow-y: auto; max-height: 280px; }

.version-switcher-item {
  appearance: none;
  background: transparent;
  border: 0;
  text-align: left;
  width: 100%;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 6px 10px;
  border-radius: var(--r-xs);
  cursor: pointer;
  color: var(--fg);
}

.version-switcher-item:hover:not(:disabled) { background: var(--bg-warm); }

.version-switcher-item.is-on { background: var(--bg-warm); }

.version-switcher-item.is-disabled { opacity: 0.55; cursor: not-allowed; }

.version-switcher-item-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--s-2);
}

.version-switcher-item-label { font: 500 13px/1.3 var(--f-body); }

.version-switcher-item-status { display: inline-flex; align-items: center; }

.version-switcher-item-foot {
  display: flex;
  align-items: center;
  gap: var(--s-2);
  color: var(--fg-soft);
}

.version-switcher-item-timestamp { font: 400 11.5px/1 var(--f-mono); }

/* …additional rules trimmed for brevity — see _shared.css */
import { useState } from 'react';
import { Badge, VersionSwitcher } from '@magicblocksai/ui';
import type { VersionEntry } from '@magicblocksai/ui';

const versions: VersionEntry[] = [
  { id: 'v4', label: 'Version 4', timestamp: 'Published Tue 14:02',
    status: <Badge tone="success">Live</Badge>,
    caption: 'Pinned production' },
  { id: 'v3', label: 'Version 3', timestamp: 'Edited 3h ago',
    status: <Badge tone="warning">Draft</Badge>,
    caption: 'Working copy' },
  { id: 'v2', label: 'Version 2', timestamp: '12 Apr 2026' },
  { id: 'v1', label: 'Version 1', timestamp: '28 Mar 2026',
    caption: 'Initial release' },
];

function Example() {
  const [id, setId] = useState('v4');
  return (
    <VersionSwitcher
      versions={versions}
      value={id}
      onValueChange={setId}
      onCompare={() => openDiff()}
      onCreateVersion={() => openCreateModal()}
    />
  );
}

18.6 StructuredDocEditor

Sectioned document editor for Sales Playbooks, structured Q&A docs, change logs, RFC drafts — any “named sections with rich body per section” surface. Each section has a numbered heading, an editable title, an optional kind chip, and a body. A sticky TOC rail on the left jumps between sections; required-marked sections suppress the per-section remove affordance. The body defaults to an autogrowing textarea; pass renderBody to swap in a richer editor.

StructuredDocEditor

.structured-doc

A sales-playbook editor with the TOC rail on the left and three sections in the body — Introduction (required, locked title), Qualification (editable, with a draft caption), and Close (required, with a Critical kind chip and a saved caption). The + Add section button at the bottom is enabled because allowAdd is on.

Locked · required for every playbook
Draft · edited 3h ago
Critical
Saved · required for every playbook
<!-- .structured-doc wraps the optional TOC nav + the body stack.    -->
<!-- .has-toc and .has-toc-left / -right toggle the grid layout.     -->
<div class="structured-doc has-toc has-toc-left">
  <nav class="structured-doc-toc" aria-label="On this page">
    <div class="structured-doc-toc-title">On this page</div>
    <ol class="structured-doc-toc-list">
      <li class="structured-doc-toc-item">
        <button class="structured-doc-toc-link">
          <span class="structured-doc-toc-num">1.</span>
          <span class="structured-doc-toc-label">Introduction</span>
        </button>
      </li>
      <!-- …more items… -->
    </ol>
  </nav>
  <div class="structured-doc-body">
    <!-- One <section.structured-doc-section> per chapter section.    -->
    <!-- .is-required suppresses the remove button.                    -->
    <section class="structured-doc-section is-required">
      <header class="structured-doc-section-head">
        <div class="structured-doc-section-num">01</div>
        <div class="structured-doc-section-title-block">
          <div class="structured-doc-section-title-row">
            <input class="structured-doc-section-title-input"
                   value="Introduction" disabled>
          </div>
          <div class="structured-doc-section-caption">
            Locked · required for every playbook
          </div>
        </div>
        <div class="structured-doc-section-actions"></div>
      </header>
      <div class="structured-doc-section-body">
        <textarea class="structured-doc-section-textarea" rows="3">…</textarea>
      </div>
    </section>
    <!-- …more sections… -->
    <button class="structured-doc-add">+ Add section</button>
  </div>
</div>
.structured-doc {
  display: block;
}

.structured-doc.has-toc {
  display: grid;
  gap: var(--s-5);
  align-items: flex-start;
}

.structured-doc.has-toc-left {
  grid-template-columns: 220px 1fr;
}

.structured-doc.has-toc-right {
  grid-template-columns: 1fr 220px;
}

.structured-doc.has-toc-right .structured-doc-toc {
  order: 2;
}

.structured-doc.is-disabled { opacity: 0.55; pointer-events: none; }

.structured-doc-toc {
  position: sticky;
  top: var(--s-4);
  padding: var(--s-3);
  border: 1px solid var(--hair-soft);
  border-radius: var(--r-md);
  background: var(--bg-paper);
}

.structured-doc-toc-title {
  font: 600 10.5px/1 var(--f-mono);
  text-transform: uppercase;
  letter-spacing: 0.08em;
  color: var(--fg-faint);
  padding: 0 4px 8px;
}

.structured-doc-toc-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: 1px;
}

.structured-doc-toc-link {
  appearance: none;
  background: transparent;
  border: 0;
  text-align: left;
  display: grid;
  grid-template-columns: 24px 1fr;
  gap: 4px;
  width: 100%;
  padding: 5px 8px;
  border-radius: var(--r-xs);
  font: 400 12.5px/1.4 var(--f-body);
  color: var(--fg-soft);
  cursor: pointer;
  transition: background var(--dur-2) var(--ease), color var(--dur-2) var(--ease);
}

.structured-doc-toc-link:hover {
  background: var(--bg-warm);
  color: var(--fg);
}

.structured-doc-toc-num {
  font: 500 11px/1.4 var(--f-mono);
  color: var(--fg-faint);
  font-variant-numeric: tabular-nums;
}

.structured-doc-toc-label {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

.structured-doc-body {
  display: flex;
  flex-direction: column;
  gap: var(--s-5);
}

.structured-doc-header { padding: 0 0 var(--s-2); }

.structured-doc-section {
  display: flex;
  flex-direction: column;
  gap: var(--s-3);
  padding: var(--s-4) 0 var(--s-5);
  border-bottom: 1px solid var(--hair-soft);
}

.structured-doc-section:last-child { border-bottom: 0; }

.structured-doc-section-head {
  display: grid;
  grid-template-columns: 40px 1fr auto;
  align-items: flex-start;
  gap: var(--s-3);
}

.structured-doc-section-num {
  font: 600 18px/1 var(--f-mono);
  color: var(--fg-faint);
  font-variant-numeric: tabular-nums;
  padding-top: 4px;
}

.structured-doc-section-title-block {
  display: flex;
  flex-direction: column;
  gap: 2px;
  min-width: 0;
}

/* …additional rules trimmed for brevity — see _shared.css */
import { useState } from 'react';
import { StructuredDocEditor } from '@magicblocksai/ui';
import type { StructuredSection } from '@magicblocksai/ui';

const initial: StructuredSection[] = [
  { id: 'intro', title: 'Introduction',
    body: 'Set the scene. Greet the contact…',
    caption: 'Locked · required for every playbook',
    lockedTitle: true, required: true },
  { id: 'qual', title: 'Qualification',
    body: 'Discover budget, timeline, and decision-maker…',
    caption: 'Draft · edited 3h ago' },
  { id: 'close', title: 'Close',
    body: 'Ask for the meeting. Confirm the next step…',
    kind: 'Critical',
    caption: 'Saved · required for every playbook',
    lockedTitle: true, required: true },
];

function Example() {
  const [sections, setSections] = useState<StructuredSection[]>(initial);
  return (
    <StructuredDocEditor
      value={sections}
      onValueChange={setSections}
      showToc
      tocPosition="left"
      tocTitle="On this page"
      allowAdd
      allowRemove
    />
  );
}

18.7 Agent builder shell

The host shell behind every section of agent configuration — the same IA operators have today (left rail with nested sections under General and the block list under Journey, plus single main edit pane), redesigned in the new visual language. The right side of the viewport is reserved for the live testing chat bar (a separate concern that lives outside this composition), so the shell is rail + pane only — never rail + pane + inspector.

Decoupled from the app shell. The agent builder is the .ab-builder (rail + pane) — it does not own the outer workspace chrome, so it’s shown standalone below. In a real app it mounts inside a <WorkspaceShell> main column (the canonical NextGen shell — see 15.27) as one inner section among many (dashboard, sessions, contacts …). Compose it as <WorkspaceShell><AgentBuilderShell…/></WorkspaceShell>.

Block editor — Journey · Want Secret Deals? · Jobs to Do

.ab-builder · .ab-pane

The canonical state of the builder while editing a Journey block: the rail shows the active section path (Journey, expanded, with the selected block highlighted), the pane shows the block’s tabs (Jobs to Do active, Actions count visible, Advanced reachable), and the body lists the block’s job cards — General job (free-text instruction) and Collect Key Fact (named slot the agent fills mid-conversation). Basic/Advanced lives in the rail footer; the per-block Planner toggle sits at the bottom of the pane.

Winery Example Omnichannel Version 3 ▾ Unsaved changes
JS

Want Secret Deals? Advanced

Manage block-specific settings and actions to customise this part of the conversation. More on Journey Blocks ↗

Define agent jobs ?
General job

The person we are outreaching to is a previous customer. You should ask them how they enjoyed their last wine purchase (mention the wine they purchased). Ask them if they need help with any pairing suggestions or recipes.

Collect Key Fact

Wants info on secret specials

Ask the user if they want to know about any secret deals on the wines they have enjoyed drinking.

Planner ?
<div class="ab-builder">
  <header class="ab-builder-head">
    <span class="ab-builder-head-name">
      Winery Example <span class="chip chip-pink">Omnichannel</span>
    </span>
    <span class="ab-version">Version 3 ▾</span>
    <span class="ab-unsaved">Unsaved changes</span>
    <div class="ab-builder-head-actions">
      <button class="ab-h-pill">Share</button>
      <button class="ab-h-pill">Test</button>
      <button class="ab-h-pill is-primary">Save</button>
    </div>
  </header>

  <aside class="ab-rail">
    <button class="ab-rail-item">Try my Agent</button>

    <div class="ab-rail-group" data-open="true">
      <button class="ab-rail-group-head" data-rail-group-toggle>
        General <span class="ab-rail-group-count">5</span>
      </button>
      <div class="ab-rail-group-body">
        <button class="ab-rail-item">Persona</button>
        <button class="ab-rail-item">Key Facts<span class="ab-rail-count">2</span></button>
        …
      </div>
    </div>

    <div class="ab-rail-group" data-open="true">
      <button class="ab-rail-group-head" data-rail-group-toggle>
        Journey <span class="ab-rail-group-count">4</span>
      </button>
      <div class="ab-rail-group-body">
        <button class="ab-rail-block is-active">
          <span class="ab-rail-block-handle">⋮⋮</span>
          <span class="ab-rail-block-name">Want Secret Deals?</span>
          <span class="ab-rail-block-stats">▤ 1 ⚡ 1</span>
        </button>
        … other blocks · + Add new
      </div>
    </div>

    <div class="ab-rail-foot">
      <div class="ab-mode-toggle" data-mode-toggle>
        <button>Basic</button>
        <button class="is-active">Advanced</button>
      </div>
    </div>
  </aside>

  <main class="ab-pane">
    <header class="ab-pane-head">
      <h2 class="ab-pane-title">
        <span class="ab-pane-title-glyph">◆</span>
        Want Secret Deals?
        <span class="chip chip-pink">Advanced</span>
      </h2>
      <p class="ab-pane-desc">Manage block-specific settings…</p>
      <div class="ab-pane-tabs">
        <button class="ab-pane-tab is-active">Jobs to Do</button>
        <button class="ab-pane-tab">Actions <span>1</span></button>
        <button class="ab-pane-tab">Advanced</button>
      </div>
    </header>

    <div class="ab-pane-body">
      <article class="ab-job">
        <span class="ab-job-handle"></span>
        <div class="ab-job-body">
          <span class="ab-job-tag is-general">General job</span>
          <p class="ab-job-text">The person we are outreaching to…</p>
        </div>
        <button class="ab-job-menu">⋯</button>
      </article>

      <article class="ab-job">
        <span class="ab-job-handle"></span>
        <div class="ab-job-body">
          <span class="ab-job-tag is-collect">Collect Key Fact</span>
          <h4 class="ab-job-title">Wants info on secret specials</h4>
          <p class="ab-job-text">Ask the user if they want to know…</p>
        </div>
        <button class="ab-job-menu">⋯</button>
      </article>
    </div>

    <footer class="ab-pane-foot">
      <label class="switch"><input type="checkbox"><span class="switch-track"><span class="switch-thumb"></span></span></label>
      Planner
    </footer>
  </main>
</div>
/* The shell is a two-column grid — left rail + main pane. No
   third column: the right side of the viewport is reserved for the
   live testing chat bar, which lives outside the builder shell. */
.ab-builder {
  display: grid;
  grid-template-columns: 280px 1fr;
  min-height: 780px;
}

/* Rail groups expand / collapse via [data-open]. The body uses
   display:none rather than max-height so nothing flashes on first
   paint. */
.ab-rail-group[data-open="false"] .ab-rail-group-body { display: none; }
.ab-rail-group[data-open="true"]  .ab-rail-group-chev { transform: rotate(180deg); }

/* The active state of a block in the rail mirrors the selected
   item in the pane head — accent-soft background, accent text. */
.ab-rail-block.is-active {
  background: var(--accent-soft);
  color: var(--accent);
}

/* Job-card tags telegraph job kind: General job (green dot) reads as
   "instruction", Collect Key Fact (pink dot) reads as "named slot to
   fill". Same anatomy underneath, different role. */
.ab-job-tag.is-general { background: rgba(15,128,98,0.12); color: #0F8062; }
.ab-job-tag.is-collect { background: var(--accent-soft); color: var(--accent); }

/* Mode toggle in the rail foot — the only non-section control that
   lives in the rail. Two segments, ink-on-paper for the active. */
.ab-mode-toggle button.is-active {
  background: var(--segmented-active-bg); color: var(--segmented-active-fg);
}
// Three small handlers, all idempotent and defensive:
//   1. rail-group chevrons (expand / collapse a group),
//   2. mode toggle (Basic ↔ Advanced),
//   3. single switch (Planner).
document.addEventListener('DOMContentLoaded', () => {
  document.querySelectorAll('[data-rail-group-toggle]').forEach((btn) => {
    btn.addEventListener('click', () => {
      const group = btn.closest('.ab-rail-group');
      if (!group) return;
      const open = group.getAttribute('data-open') === 'true';
      group.setAttribute('data-open', open ? 'false' : 'true');
      btn.setAttribute('aria-expanded', open ? 'false' : 'true');
    });
  });

  document.querySelectorAll('[data-mode-toggle]').forEach((root) => {
    root.querySelectorAll('button').forEach((btn) => {
      btn.addEventListener('click', () => {
        root.querySelectorAll('button').forEach((b) =>
          b.classList.toggle('is-active', b === btn)
        );
      });
    });
  });

  document.querySelectorAll('[data-toggle]').forEach((btn) => {
    btn.addEventListener('click', () => {
      const on = btn.getAttribute('aria-pressed') === 'true';
      btn.setAttribute('aria-pressed', on ? 'false' : 'true');
    });
  });
});
import { useState } from 'react';
import {
  AppShell, WorkspaceNavIcon, Logo,
  AgentBuilderShell, AgentBuilderHead, BuilderRail, RailGroup, RailItem, RailBlockItem,
  AgentBuilderPane, PaneHeader, PaneTabs, PaneTab, JobCard,
  SortableList, Switch,
} from '@magicblocksai/ui';
// AgentBuilderShell.Pane is the compound alias for the exported AgentBuilderPane.

export function AgentBuilder({ agent, blockId, onSelectBlock, onReorder, onReorderJobs }) {
  const block = agent.blocks.find((b) => b.id === blockId);
  const [collapsed, setCollapsed] = useState(true); // builder defaults collapsed
  return (
    <AppShell
      sidebarMode={collapsed ? 'collapsed' : 'expanded'}
      sidebarWidthCollapsed="56px"
      sidebarTone="paper"
      sidebar={
        <>
          <div className="app-shell-side-head">
            <Logo />
            <button type="button" className="app-shell-side-toggle"
              aria-label="Toggle sidebar" onClick={() => setCollapsed((c) => !c)}>
              <svg viewBox="0 0 14 14" fill="none" stroke="currentColor" strokeWidth={1.4}
                strokeLinecap="round" strokeLinejoin="round"><path d="M5 3 L9 7 L5 11" /></svg>
            </button>
          </div>
          <nav className="ws-nav">
            <WorkspaceNavIcon icon={<DashboardIcon />} label="Dashboard" href="/app" />
            <WorkspaceNavIcon icon={<AgentsIcon />} label="Agents" href="/agents" count={7} active />
            <WorkspaceNavIcon icon={<KnowledgeIcon />} label="Knowledge" href="/knowledge" />
          </nav>
        </>
      }
    >
      <AgentBuilderShell
        head={
          <AgentBuilderHead
            name={agent.name}
            typeChip={<span className="chip chip-pink">{agent.type}</span>}
            version={`Version ${agent.version} ▾`}
            unsaved={agent.dirty}
            onBack={() => nav('/agents')}
            actions={<>
              <button type="button" className="ab-h-pill">Share</button>
              <button type="button" className="ab-h-pill">Test</button>
              <button type="button" className="ab-h-pill is-primary">Save</button>
            </>}
          />
        }
        rail={
          <BuilderRail ariaLabel="Agent configuration"
            foot={
              <div className="ab-mode-toggle" role="group" aria-label="Builder mode">
                <button type="button">Basic</button>
                <button type="button" className="is-active">Advanced</button>
              </div>
            }
          >
            <RailItem icon={<PlayIcon />}>Try my Agent</RailItem>
            <RailGroup title="General" icon={<GearIcon />} count={5} defaultOpen>
              <RailItem icon={<PersonaIcon />}>Persona</RailItem>
              <RailItem icon={<KeyFactIcon />} count={2}>Key Facts</RailItem>
            </RailGroup>
            <RailGroup title="Journey" icon={<JourneyIcon />} count={agent.blocks.length} group="journey" defaultOpen>
              <SortableList items={agent.blocks} rowId={(b) => b.id} onReorder={onReorder}
                renderRow={(b) => (
                  <RailBlockItem name={b.name} active={b.id === blockId}
                    keyFactCount={b.keyFacts.length} actionCount={b.actions.length}
                    onSelect={() => onSelectBlock(b.id)} />
                )} />
            </RailGroup>
          </BuilderRail>
        }
      >
        <AgentBuilderShell.Pane
          head={
            <PaneHeader glyph="◆" title={block.name}
              badge={<span className="chip chip-pink">Advanced</span>}
              description={<>Manage block-specific settings and actions. <a href="#">More on Journey Blocks ↗</a></>}
              onCollapse={() => setCollapsed(true)}
              tabs={
                <PaneTabs defaultValue="jobs">
                  <PaneTab value="jobs" icon="≡">Jobs to Do</PaneTab>
                  <PaneTab value="actions" icon="⚡" count={block.actions.length}>Actions</PaneTab>
                  <PaneTab value="advanced" icon="✦">Advanced</PaneTab>
                </PaneTabs>
              }
            />
          }
          foot={<><Switch aria-label="Planner" /> Planner</>}
        >
          {/* Jobs reorder within the tab — same SortableList as the Journey rail. */}
          <SortableList
            items={block.jobs}
            rowId={(j) => j.id}
            onReorder={onReorderJobs}
            renderRow={(j) => (
              <JobCard kind={j.kind} title={j.title}>{j.text}</JobCard>
            )}
          />
        </AgentBuilderShell.Pane>
      </AgentBuilderShell>
    </AppShell>
  );
}

AgentBuilderHead

.ab-builder-head

The agent-builder topbar — back · name + type chip · version + unsaved meta · trailing Share / Test / Save pills. Spans the full shell width (pass it to AgentBuilderShell’s head slot).

Winery ExampleOmnichannelVersion 3 ▾Unsaved changes
<header class="ab-builder-head"><div class="ab-builder-head-title"><button type="button" class="ab-builder-head-back" aria-label="Back to agents">‹</button><span class="ab-builder-head-name">Winery Example<span class="chip chip-pink">Omnichannel</span></span><span class="ab-builder-head-meta"><span class="ab-version">Version 3 ▾</span><span class="ab-unsaved">Unsaved changes</span></span></div><div class="ab-builder-head-actions"><button type="button" class="ab-h-pill">Share</button><button type="button" class="ab-h-pill">Test</button><button type="button" class="ab-h-pill is-primary">Save</button></div></header>
/* Ships in @magicblocksai/css (components/_shared.css, @surface: operator).
   No chapter-private CSS — every .ab-* selector is in the package. */
// No wiring needed — the React component owns its interactivity
// (RailGroup collapse, PaneTabs roving tabindex, the shell's drag-resize).
// This static demo is a rendered snapshot.
import { AgentBuilderHead } from '@magicblocksai/ui';

<AgentBuilderHead
  name="Winery Example"
  typeChip={<span className="chip chip-pink">Omnichannel</span>}
  version="Version 3 ▾"
  unsaved
  onBack={() => nav('/agents')}
  actions={<>
    <button type="button" className="ab-h-pill">Share</button>
    <button type="button" className="ab-h-pill">Test</button>
    <button type="button" className="ab-h-pill is-primary">Save</button>
  </>}
/>

BuilderRail

.ab-rail · .ab-rail-group · .ab-rail-item · .ab-rail-block

The left rail — a scroll container for nested RailGroups, leaf RailItems, and the sortable Journey RailBlockItems (wrap them in the shipped SortableList for drag-reorder). Optional sticky foot — here a Basic / Advanced mode toggle.

<aside class="ab-rail" aria-label="Agent configuration"><div class="ab-rail-inner"><button type="button" class="ab-rail-item"><span class="ab-rail-glyph" aria-hidden="true"><svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="7" cy="7" r="3"></circle></svg></span>Try my Agent<span></span></button><div class="ab-rail-group" data-open="true"><button type="button" class="ab-rail-group-head" aria-expanded="true"><span class="ab-rail-group-glyph" aria-hidden="true"><svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="7" cy="7" r="3"></circle></svg></span><span>General</span><span class="ab-rail-group-count">5</span><span class="ab-rail-group-chev" aria-hidden="true">▾</span></button><div class="ab-rail-group-body"><button type="button" class="ab-rail-item"><span class="ab-rail-glyph" aria-hidden="true"><svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="7" cy="7" r="3"></circle></svg></span>Persona<span></span></button><button type="button" class="ab-rail-item"><span class="ab-rail-glyph" aria-hidden="true"><svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="7" cy="7" r="3"></circle></svg></span>Key Facts<span class="ab-rail-count">2</span></button></div></div><div class="ab-rail-group" data-open="true" data-group="journey"><button type="button" class="ab-rail-group-head" aria-expanded="true"><span class="ab-rail-group-glyph" aria-hidden="true"><svg viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.4"><circle cx="7" cy="7" r="3"></circle></svg></span><span>Journey</span><span class="ab-rail-group-count">4</span><span class="ab-rail-group-chev" aria-hidden="true">▾</span></button><div class="ab-rail-group-body"><div role="button" tabindex="0" class="ab-rail-block"><span class="ab-rail-block-handle" aria-hidden="true"><svg viewBox="0 0 8 14" fill="currentColor"><circle cx="2" cy="3" r="1"></circle><circle cx="6" cy="3" r="1"></circle><circle cx="2" cy="7" r="1"></circle><circle cx="6" cy="7" r="1"></circle><circle cx="2" cy="11" r="1"></circle><circle cx="6" cy="11" r="1"></circle></svg></span><span class="ab-rail-block-name">Hook</span><span class="ab-rail-block-stats"><span class="ab-rail-block-stat" title="Key facts">▤ 1</span><span class="ab-rail-block-stat" title="Actions">⚡ 2</span></span><button type="button" class="ab-rail-block-menu" aria-label="Block menu">⋯</button></div><div role="button" tabindex="0" class="ab-rail-block is-active"><span class="ab-rail-block-handle" aria-hidden="true"><svg viewBox="0 0 8 14" fill="currentColor"><circle cx="2" cy="3" r="1"></circle><circle cx="6" cy="3" r="1"></circle><circle cx="2" cy="7" r="1"></circle><circle cx="6" cy="7" r="1"></circle><circle cx="2" cy="11" r="1"></circle><circle cx="6" cy="11" r="1"></circle></svg></span><span class="ab-rail-block-name">Want Secret Deals?</span><span class="ab-rail-block-stats"><span class="ab-rail-block-stat" title="Key facts">▤ 1</span><span class="ab-rail-block-stat" title="Actions">⚡ 1</span></span><button type="button" class="ab-rail-block-menu" aria-label="Block menu">⋯</button></div></div></div></div><div class="ab-rail-foot"><div class="ab-mode-toggle" role="group" aria-label="Builder mode"><button type="button">Basic</button><button type="button" class="is-active">Advanced</button></div></div></aside>
/* Ships in @magicblocksai/css (components/_shared.css, @surface: operator).
   No chapter-private CSS — every .ab-* selector is in the package. */
// No wiring needed — the React component owns its interactivity
// (RailGroup collapse, PaneTabs roving tabindex, the shell's drag-resize).
// This static demo is a rendered snapshot.
import { BuilderRail, RailGroup, RailItem, RailBlockItem, SortableList } from '@magicblocksai/ui';

<BuilderRail ariaLabel="Agent configuration" foot={<ModeToggle />}>
  <RailItem icon={<PlayIcon />}>Try my Agent</RailItem>
  <RailGroup title="General" icon={<GearIcon />} count={5} defaultOpen>
    <RailItem icon={<PersonaIcon />}>Persona</RailItem>
    <RailItem icon={<KeyFactIcon />} count={2}>Key Facts</RailItem>
  </RailGroup>
  <RailGroup title="Journey" icon={<JourneyIcon />} count={4} group="journey" defaultOpen>
    <SortableList items={blocks} rowId={(b) => b.id} onReorder={setBlocks}
      renderRow={(b) => (
        <RailBlockItem name={b.name} active={b.id === current}
          keyFactCount={b.keyFacts.length} actionCount={b.actions.length}
          onSelect={() => select(b.id)} />
      )} />
  </RailGroup>
</BuilderRail>

AgentBuilderShell.Pane

.ab-pane · .ab-pane-head · .ab-job

The main edit pane — a PaneHeader (glyph · title · badge · description · PaneTabs) over a scrollable body of JobCards, with a sticky Planner footer. Exposed as the shell’s compound .Pane.

Want Secret Deals?Advanced

Manage block-specific settings and actions to customise this part of the conversation. More on Journey Blocks ↗

General job

The person we are outreaching to is a previous customer. You should ask them how they enjoyed their last wine purchase.

Collect Key Fact

Wants info on secret specials

Ask the user if they want to know about any secret deals on the wines they have enjoyed drinking.

Planner
<main class="ab-pane"><header class="ab-pane-head"><button type="button" class="ab-pane-collapse" aria-label="Collapse rail">«</button><h2 class="ab-pane-title"><span class="ab-pane-title-glyph">◆</span>Want Secret Deals?<span class="chip chip-pink">Advanced</span></h2><p class="ab-pane-desc">Manage block-specific settings and actions to customise this part of the conversation. <a href="#">More on Journey Blocks ↗</a></p><div role="tablist" class="ab-pane-tabs"><button type="button" role="tab" aria-selected="true" tabindex="0" class="ab-pane-tab is-active"><span aria-hidden="true">≡</span>Jobs to Do</button><button type="button" role="tab" aria-selected="false" tabindex="-1" class="ab-pane-tab"><span aria-hidden="true">⚡</span>Actions<span class="ab-pane-tab-count">1</span></button><button type="button" role="tab" aria-selected="false" tabindex="-1" class="ab-pane-tab"><span aria-hidden="true">✦</span>Advanced</button></div></header><div class="ab-pane-body"><article class="ab-job"><span class="ab-job-handle" aria-hidden="true"></span><div class="ab-job-body"><span class="ab-job-tag is-general">General job</span><p class="ab-job-text">The person we are outreaching to is a previous customer. You should ask them how they enjoyed their last wine purchase.</p></div><button type="button" class="ab-job-menu" aria-label="Job menu">⋯</button></article><article class="ab-job"><span class="ab-job-handle" aria-hidden="true"></span><div class="ab-job-body"><span class="ab-job-tag is-collect">Collect Key Fact</span><h4 class="ab-job-title">Wants info on secret specials</h4><p class="ab-job-text">Ask the user if they want to know about any secret deals on the wines they have enjoyed drinking.</p></div><button type="button" class="ab-job-menu" aria-label="Job menu">⋯</button></article></div><footer class="ab-pane-foot"><label class="switch" aria-label="Planner"><input type="checkbox"/><span class="switch-track"><span class="switch-thumb"></span></span></label><span>Planner</span></footer></main>
/* Ships in @magicblocksai/css (components/_shared.css, @surface: operator).
   No chapter-private CSS — every .ab-* selector is in the package. */
// No wiring needed — the React component owns its interactivity
// (RailGroup collapse, PaneTabs roving tabindex, the shell's drag-resize).
// This static demo is a rendered snapshot.
import { AgentBuilderShell, PaneHeader, PaneTabs, PaneTab, JobCard, SortableList, Switch } from '@magicblocksai/ui';

<AgentBuilderShell.Pane
  head={
    <PaneHeader glyph="◆" title="Want Secret Deals?"
      badge={<span className="chip chip-pink">Advanced</span>}
      description={<>Manage block-specific settings and actions. <a href="#">More on Journey Blocks ↗</a></>}
      onCollapse={collapseRail}
      tabs={
        <PaneTabs defaultValue="jobs">
          <PaneTab value="jobs" icon="≡">Jobs to Do</PaneTab>
          <PaneTab value="actions" icon="⚡" count={1}>Actions</PaneTab>
          <PaneTab value="advanced" icon="✦">Advanced</PaneTab>
        </PaneTabs>
      }
    />
  }
  foot={<><Switch aria-label="Planner" /> Planner</>}
>
  {/* Jobs reorder within the tab — whole-row drag via the shipped SortableList. */}
  <SortableList items={jobs} rowId={(j) => j.id} onReorder={setJobs}
    renderRow={(j) => (
      <JobCard kind={j.kind} title={j.title}>{j.text}</JobCard>
    )} />
</AgentBuilderShell.Pane>

18.8 Agents list composition

The agents HQ — the list that sits before any single-agent build session (18.7). + New agent opens the template gallery (18.21). Header carries a 4-tile KPI strip (conversations, qualified leads, meetings booked, average reply time). Below: status filter tabs, then a stack of rich .ag-card rows. Each card surfaces avatar, title + tag, role copy, version + last-update meta, channels icon strip, a per-agent sparkline + counts (last 7 days), status pill (Live / Paused / Unpublished / Draft), and trailing actions (chat / share / Open).

Agents HQ — 5 agents · 1,038 conversations across the live ones

.list-screen.ag-list · .ag-card

A workspace at a glance. The KPI strip shows performance across all live agents. The filter row lets the operator drill into a status (All / Live / Draft / Paused / Unpublished). Each agent card is dense by design: in one row an operator can read the agent's identity, its channels, its last-7-days throughput, its current status, and jump straight in. Built on the same list-screen shell as 19.7 sessions and 14.15 contacts; the cards are chapter-private (.ag-*) and reuse .kpi-delta-tile (7.27) for the top strip and .badge + .dot (7.4) for the status pills.

Agents

Your AI sales reps. Each one replies in under a minute — across chat, SMS, email, DMs, and CRM.

Conversations
1,038
across 5 live agents
Qualified Leads
370
35.6% qualification rate
Meetings Booked
51
30.6% prev. week
Avg Reply Time
52s
p50
Insurance Agent — Charlie Insurance
Inbound rep · SMS qualifier for marine insurance
v3· Updated 13 Jun 2026
Channels
Last 7 days
412convs 27booked
Status
Live 48s avg
SMS — Buy / Sell Sales Marine
Outbound SMS agent · qualifies local brokerage leads
v6· Updated 22 May 2026
Channels
Last 7 days
612convs 24booked
Status
Live 52s avg
SMS — Insurance
Renewal nurture for existing policyholders
v2· Updated 09 May 2026
Channels
Last 7 days
0convs 0booked
Status
Paused paused 2d ago
SMS — Ready to Buy or Sell?  — OLD Sales
Legacy version, replaced by Buy/Sell above
v1· Updated 28 Feb 2026
Channels
Last 7 days
0convs 0booked
Status
Unpublished archived
Mortgage Pre-Qual Mortgage
Webchat + text handoff for first-home buyers
v0 draft· Updated today
Channels
Last 7 days
14convs 2booked
Status
Draft never published
Showing 5 of 5
<!-- Page chrome composes the kit's .list-screen-* primitive
     (v1.66.0; see _shared.css). Chapter-private .ag-* classes
     only carry the agent-list overrides + the .ag-card stack. -->
<div class="list-screen-frame">
  <div class="list-screen ag-list">
    <header class="list-screen-head">
      <div class="list-screen-head-title">
        <h2>Agents</h2>
        <p>Your AI sales reps…</p>
      </div>
      <div class="list-screen-head-actions">
        <label class="ag-search"><input placeholder="Search agents…" /></label>
        <button class="ag-new-button">+ New agent</button>
      </div>
    </header>

    <div class="list-screen-kpi-strip">
      <div class="kpi-delta-tile">…Conversations · 1,038…</div>
      <div class="kpi-delta-tile">…Qualified Leads · 370…</div>
      <div class="kpi-delta-tile">…Meetings Booked · 51…</div>
      <div class="kpi-delta-tile">…Avg Reply Time · 52s…</div>
    </div>

    <div class="list-screen-tabs">
      <button class="is-active">All <span class="list-screen-tab-count">5</span></button>
      <button>Live <span class="list-screen-tab-count">2</span></button>
      <button>Draft <span class="list-screen-tab-count">1</span></button>
      <button>Paused <span class="list-screen-tab-count">1</span></button>
      <button>Unpublished <span class="list-screen-tab-count">1</span></button>
    <div class="ag-list-tabs-aside">
      <button class="ag-pill">Filter</button>
      <button class="ag-pill">Sort · Last update</button>
    </div>
  </div>

  <div class="ag-card-stack">
    <article class="ag-card">
      <span class="ag-card-avatar">…</span>
      <div class="ag-card-body">
        <div class="ag-card-title-row">Insurance Agent — Charlie <span class="ag-card-tag">Insurance</span></div>
        <div class="ag-card-sub">Inbound rep · SMS qualifier for marine insurance</div>
        <div class="ag-card-meta">v3 · Updated 13 Jun 2026</div>
      </div>
      <div class="ag-card-channels">…channel icon strip…</div>
      <div class="ag-card-spark">…sparkline + 412 convs / 27 booked…</div>
      <div class="ag-card-status">
        <span class="badge"><span class="dot dot-green"></span> Live</span>
        <span class="ag-card-status-meta">48s avg</span>
      </div>
      <div class="ag-card-actions">
        <button class="ag-card-action">chat</button>
        <button class="ag-card-action">share</button>
        <a class="ag-card-open">Open →</a>
      </div>
    </article>
    …more agent cards…
  </div>

  <footer class="ag-list-foot">Showing 5 of 5</footer>
</div>
/* Chapter-private agent card grid. KPI strip uses .kpi-delta-tile
   from chapter 7.27; status pills use .badge + .dot from 7.4. */
.ag-card {
  display: grid;
  grid-template-columns: 44px minmax(220px, 1fr) auto auto auto auto;
  align-items: center; gap: var(--s-4);
  padding: var(--s-3) var(--s-4);
  background: var(--bg-paper);
  border: 1px solid var(--hair); border-radius: var(--r-lg);
}
.ag-card:hover { border-color: var(--fg-faint); background: var(--row-hover); }
.ag-card-spark-svg { width: 100%; height: 32px; }
.ag-card-spark-svg path.line { fill: none; stroke: var(--accent); stroke-width: 1.5; }
.ag-card-spark-svg path.area { fill: color-mix(in oklab, var(--accent) 10%, transparent); }
.ag-card-spark-svg.is-flat path.line { stroke: var(--fg-faint); }
import {
  ListScreen,
  ListScreenHead,
  ListScreenKpiStrip,
  ListScreenTabs,
  ListScreenTabCount,
  KpiDeltaTile,
  AgentChannelStrip,
  AgentChannelIcon,
  Sparkline,
  Badge,
  Button,
  MessageSquareIcon,
  SmartphoneIcon,
  MailIcon,
  PhoneIcon,
} from "@magicblocksai/ui";

/* LEGO-piece composition. The page shell uses the kit-wide
   .list-screen-* primitive; each row uses .ag-card chrome with the
   newly-promoted <AgentChannelStrip> carrying the channels column. */

export function AgentsListPage({ agents, kpis }) {
  return (
    <ListScreen>
      <ListScreenHead
        title="Agents"
        description="Your live, draft, and paused agents — across every channel."
        actions={<Button tone="success">+ New agent</Button>}
      />
      <ListScreenKpiStrip>
        {kpis.map(k => <KpiDeltaTile key={k.label} {...k} />)}
      </ListScreenKpiStrip>
      <ListScreenTabs role="tablist">
        <button className="is-active" role="tab">
          All <ListScreenTabCount>{agents.length}</ListScreenTabCount>
        </button>
        <button role="tab">Live</button>
        <button role="tab">Draft</button>
        <button role="tab">Paused</button>
      </ListScreenTabs>
      <div className="ag-card-stack">
        {agents.map(a => <AgentCard key={a.id} agent={a} />)}
      </div>
    </ListScreen>
  );
}

function AgentCard({ agent }) {
  return (
    <article className="ag-card">
      <AgentAvatar agent={agent} />
      <div className="ag-card-body">
        <h3>{agent.name}</h3>
        <p className="ag-card-sub">{agent.role}</p>
      </div>
      <AgentChannelStrip label="Channels">
        <AgentChannelIcon active={agent.channels.web}   title="Web chat"><MessageSquareIcon /></AgentChannelIcon>
        <AgentChannelIcon active={agent.channels.sms}   title="SMS">     <SmartphoneIcon />    </AgentChannelIcon>
        <AgentChannelIcon active={agent.channels.email} title="Email">   <MailIcon />          </AgentChannelIcon>
        <AgentChannelIcon active={agent.channels.voice} title="Voice">   <PhoneIcon />         </AgentChannelIcon>
      </AgentChannelStrip>
      <Sparkline values={agent.last7Days} />
      <Badge tone={agent.statusTone}>{agent.status}</Badge>
    </article>
  );
}

18.9 Agent overview

The pane that replaces Try my Agent as the first state an operator lands on when opening an agent. Covers the same onboarding ground (run a test chat · connect a channel · take a 2-min tour) but inside a richer dashboard that grows up with the agent — KPI strips that fill in as data arrives, goal trackers, channel-connect rail, conversations-over-time chart, a live conversations strip, plus guardrails + missing-knowledge surfaces. Empty by design on day one (every number is 0, every chart is a frame), so the same composition reads from minute one through year one without a separate empty-state screen.

Agent overview — first state after creation

.ab-overview

All numbers zero, all charts blank, all conversations empty. The operator's next move is signposted by the three hero pills (Run a test chat / Connect a channel / 2-min tour) and the rail of channels each showing a Connect button. Hero headline uses the kit's display-with-serif-italic-emphasis pattern (same as .hero at 9.2). KPI tiles use the kit's mono caption + display value + delta caption shape (chapter-private chrome here because the 5+4 split-strip needs its own grid). Every section panel is a single primitive (.ab-ov-card) so consumers building their own subsets compose horizontally.

New agent — v1 draft

Your new agent is ready to meet your first lead.

Three things to do before you publish. Each takes a minute.

Visit 0 Web Chat only −100% vs last week
Outreach 0 First message sent −100% vs last week
Engaged 0 Replied at least once −100% vs last week
Worked lead 0 Engaged + qualified
Lead handover 0 Handed to a human
% Worked lead rate vs engaged
% Lead handover vs worked
Opt-out per week
Fail / Error Bounces, blocked, …

Goals · last 7 days

Qualify 500 leads 0 / 500 0%
Book 80 meetings / week 0 / 80 0%
< 60s avg reply time
< 5% opt-out rate

Where this agent is live

  • Web Chat app.thebusiness.com.au
  • SMS +61 480 022 801
  • Email [email protected]
  • API / Webhook HighLevel · Pipedrive
  • WhatsApp WhatsApp Business
  • Instagram DM @thebusiness · 79.2k
  • Voice Voice · private beta

Conversations over time · daily volume by channel · last 14 days

Web SMS Email API
Waiting for first conversation…
Will appear here as soon as your agent starts talking.
No conversations yet. Run a test chat or connect a channel.

Guardrails

✓ All clear
  • On-message Stays on persona & playbook
  • No promises No prices, dates or guarantees we can't keep
  • TCPA quiet hours 9am — 9pm AEST
  • Brand safe No banned words, no rivals named

Missing knowledge

Questions your agent couldn't answer.
Nothing missing yet — your agent hasn't taken a real conversation.
<!-- The overview composes 6 chapter-private blocks under one
     .ab-overview wrapper. Every block is independently
     reorderable / removable — the only structural rule is the
     hero comes first.                                          -->
<div class="ab-overview">
  <header class="ab-ov-hero">
    <span class="ab-ov-hero-eyebrow">New agent — v1 draft</span>
    <h2 class="ab-ov-hero-title">
      Your new agent is <em>ready to meet</em> your first lead.
    </h2>
    <p class="ab-ov-hero-sub">Three things to do before you publish.</p>
    <div class="ab-ov-hero-actions">
      <button class="ab-ov-hero-pill is-primary">Run a test chat</button>
      <button class="ab-ov-hero-pill">Connect a channel</button>
      <button class="ab-ov-hero-pill">2-min tour</button>
    </div>
  </header>

  <div class="ab-ov-kpis ab-ov-kpis-primary">
    <div class="ab-ov-kpi">
      <span class="ab-ov-kpi-label">Visit</span>
      <span class="ab-ov-kpi-value">0</span>
      <span class="ab-ov-kpi-sub">Web Chat only</span>
      <span class="ab-ov-kpi-delta is-down">−100% vs last week</span>
    </div>
    …4 more tiles
  </div>

  <div class="ab-ov-kpis ab-ov-kpis-secondary">…4 rate tiles…</div>

  <div class="ab-ov-row-2">
    <section class="ab-ov-card">…Goals…</section>
    <section class="ab-ov-card">…Where this agent is live…</section>
  </div>

  <section class="ab-ov-card">…Conversations chart…</section>
  <section class="ab-ov-card">…Conversations live + empty state…</section>

  <div class="ab-ov-row-2">
    <section class="ab-ov-card">…Guardrails…</section>
    <section class="ab-ov-card">…Missing knowledge…</section>
  </div>
</div>
/* All Overview chrome is chapter-private (.ab-ov-*) — promote to
   _shared.css once a second consumer needs the same shape.

   Hero: gradient wash + display headline w/ serif italic emphasis
   on a span (.ab-ov-hero-title em). Three pills; .is-primary uses
   --success-text (the "go live" colour) not --accent.

   KPI strips: 5 / 4 columns desktop, 2 / 2 columns ≤ 980px.
   Tiles separated by 1px hair-soft vertical rule (becomes
   horizontal on mobile). Empty state uses 0 / — for values.

   Cards: .ab-ov-card is the surface primitive — paper bg, hair
   border, r-lg radius, gap-3 flex column. Used 4 times. */
/* PROVISIONAL — composes existing kit primitives + the chapter-
   private .ab-ov-* chrome. A future  wrapper
   could codify this shape; today it lives inline so the design
   can flex per agent type (Website vs Omnichannel changes which
   channels are listed; SMS-only changes the KPI labels).
   Existing kit exports used: StatTile (KPI tiles), Button. */

import {
  Button,
  AgentChannelIcon,
  MessageSquareIcon,
  SmartphoneIcon,
  MailIcon,
  PhoneIcon,
  GlobeIcon,
} from "@magicblocksai/ui";

export function AgentOverview({ agent, kpis, goals, channels }) {
  return (
    <div className="ab-overview">
      <header className="ab-ov-hero">
        <span className="ab-ov-hero-eyebrow">
          {agent.name} — v{agent.version} {agent.status}
        </span>
        <h2 className="ab-ov-hero-title">
          Your new agent is <em>ready to meet</em> your first lead.
        </h2>
        <p className="ab-ov-hero-sub">
          Three things to do before you publish. Each takes a minute.
        </p>
        <div className="ab-ov-hero-actions">
          <button className="ab-ov-hero-pill is-primary">Run a test chat</button>
          <button className="ab-ov-hero-pill">Connect a channel</button>
          <button className="ab-ov-hero-pill">2-min tour</button>
        </div>
      </header>

      <KpiStripPrimary kpis={kpis.primary} />
      <KpiStripSecondary kpis={kpis.secondary} />

      <div className="ab-ov-row-2">
        <GoalsCard goals={goals} />
        <ChannelsCard channels={channels} />
      </div>

      <ConversationsChartCard />
      <ConversationsLiveCard />

      <div className="ab-ov-row-2">
        <GuardrailsCard />
        <MissingKnowledgeCard />
      </div>
    </div>
  );
}

Overview — the live agent’s home

.ab-overview · .golive-card · .recent-changes

The same composition once the agent is live, redesigned as the agent’s home: a status band that answers “is it okay?” in one sentence with a SetupProgressChip, the GoLiveCard deep-linking into the Channels tab, the identity card (persona + role, with full editing one level up on the Personas shelf), a four-tile KPI strip, the “Questions it couldn’t answer” queue with Teach it, the Guardrails glance, and RecentChangesList where Sage edits carry an Undo. Live agents open here by default; unpublished agents open on Journey.

Live · website agent

Live on crefco.com — 38 conversations this week.

Greeting → Discovery → Handoff · 5 blocks · last published 2 days ago

Setup 5 of 6 — connect a phone number
Where this agent talks
  • Website widgetLive on crefco.com
  • Phone & SMSNot connected yet

Sounds like

Battery Persona · v3 · creativity 0.6

“Warm, concise, and professional — ends every message with a cheer.”

Role: B2B lead qualifier for inbound website visitors.

Conversations
312
38 this week
Leads captured
47
15% conversion
Goal value
$1,940
vs $1,210 prev. week
Handed to a human
6%
18 conversations

Questions it couldn’t answer

3 new
  • “Do you do jumbo loans?” Asked twice this week
  • “What are your closing costs?” Asked yesterday

Guardrails

✓ Monitor on
  • CREFCO policy + 2 extra rules on this agent
Recent changes
  • You added the key fact budget to Discoveryyesterday
  • Sage tightened the Greeting job wording2 days ago
<div class="ab-overview">
  <header class="ab-ov-hero">
    <span class="ab-ov-hero-eyebrow">Live · website agent</span>
    <h2 class="ab-ov-hero-title">Live on crefco.com — <em>38 conversations</em> this week.</h2>
    <p class="ab-ov-hero-sub">Greeting → Discovery → Handoff · 5 blocks · last published 2 days ago</p>
    <div class="ab-ov-hero-actions">
      <span class="chip chip-amber">Setup 5 of 6 — connect a phone number</span>
      <button type="button" class="ab-ov-hero-pill">View conversations</button>
    </div>
  </header>
  <div class="ab-ov-row-2">…GoLiveCard · identity .ab-ov-card…</div>
  …4 × .kpi-delta-tile strip…
  <div class="ab-ov-row-2">…questions queue · guardrails glance (.ab-ov-card + .ab-ov-guards)…</div>
  …RecentChangesList…
</div>
/* Ships in @magicblocksai/css — .ab-ov-* chrome plus the new .golive-* and
   .recent-change* blocks (@surface: operator). */
// No wiring — Teach it converts a question into a knowledge item
// (Knowledge Phase A primitives); Undo reverts the Sage patch on the draft.
import { SetupProgressChip, GoLiveCard, RecentChangesList, KpiDeltaTile, Button, GlobeIcon, PhoneIcon } from '@magicblocksai/ui';

<div className="ab-overview">
  <header className="ab-ov-hero">
    <span className="ab-ov-hero-eyebrow">Live · website agent</span>
    <h2 className="ab-ov-hero-title">Live on crefco.com — <em>38 conversations</em> this week.</h2>
    <p className="ab-ov-hero-sub">Greeting → Discovery → Handoff · 5 blocks · last published 2 days ago</p>
    <div className="ab-ov-hero-actions">
      <SetupProgressChip done={5} total={6} nextStep="connect a phone number" />
      <button type="button" className="ab-ov-hero-pill">View conversations</button>
    </div>
  </header>
  <div className="ab-ov-row-2">
    <GoLiveCard
      channels={[
        { icon: <GlobeIcon />, name: 'Website widget', state: 'Live on crefco.com', live: true },
        { icon: <PhoneIcon />, name: 'Phone & SMS', state: 'Not connected yet' },
      ]}
      action={<Button size="sm" variant="secondary">Open Channels</Button>}
    />
    {identityCard}
  </div>
  {kpiStrip /* 4 × KpiDeltaTile */}
  {questionsAndGuardrails}
  <RecentChangesList changes={recentChanges} />
</div>

18.10 Persona

The first screen under General — where the agent gets its identity, role copy, and voice. Two states demoed: the page form (the canonical edit surface), and the edit dialog the operator opens via the pencil to manage the underlying persona record (which is independently versioned and can be reused across agents). Both states share the same .ab-form-* primitives so a future operator-form library could promote them together.

Persona page form

.ab-pane-form

Composed inside the builder pane (the shell + rail context is covered at 18.7). Three fields: agent name (required, short), role description (textarea with character count, optional), and the persona picker (select of saved personas + Create-new link + edit pencil to open the dialog). The picker is followed by a read-only summary card showing the active persona's version, description, and a fade-truncated prompt preview.

Persona

Basic ?
158 / 700
Version 1 · 08 Mar 2026 Creativity 0.3

Created from website https://www.tyrnells.com.au by the MagicBlocks Wizard.

You work for Tyrnell, the latest community family-owned winery in the Hunter Valley. Your role is to guide customers in selecting and purchasing premium wines while promoting the exclusive membership benefits we offer. You should respond with a friendly and direct tone, ensuring every interaction conveys trustworthiness and warmth. Use key phrases like ‘Experience the legacy’ and ‘Join the wine journey’ naturally within your responses to encourage engagement. Aim to evoke a warm and inviting atmosphere, suggesting wines that bring back family memories and create a sense of belonging…

<div class="ab-pane-form" role="region" aria-label="Persona configuration">
  <div class="ab-form-title-row">
    <h2>Persona</h2>
    <span class="ab-mode-chip">Basic</span>
    <span class="ab-form-help" title="What this screen does">?</span>
  </div>

  <div class="ab-form-field">
    <label class="ab-form-label" for="persona-name">
      Agent name <span class="ab-form-req">*</span>
    </label>
    <input id="persona-name" class="ab-form-input" type="text"
           value="Charlie">
  </div>

  <div class="ab-form-field">
    <label class="ab-form-label">Describe the role of the agent in the company</label>
    <div class="ab-form-input-row">
      <textarea class="ab-form-input" rows="3">…</textarea>
      <span class="ab-form-count">158 / 700</span>
    </div>
  </div>

  <div class="ab-form-field">
    <label class="ab-form-label">Choose agent persona</label>
    <div class="ab-set-picker">
      <select class="ab-form-input">…personas</select>
      <button class="ab-set-create">+ Create new</button>
      <button class="ab-set-edit">…pencil SVG</button>
    </div>
    <div class="ab-set-card">
      <div class="ab-set-card-meta">Version 1 · 08 Mar 2026 · Creativity 0.3</div>
      <p class="ab-set-card-desc">Created from website…</p>
      <p class="ab-set-card-prompt">You work for Tyrnell…</p>
    </div>
  </div>
</div>
/* Pane form — bounded, paper bg, top-aligned. Three field shapes:
   .ab-form-field         vertical stack (label + control + count)
   .ab-set-picker         3-col grid (select + create + edit)
                          (shared library-picker family — same shape
                          backs Guardrails 18.13, Knowledge 18.14,
                          Design & Go Live appearance 18.17)
   .ab-set-card           read-only summary surface (warm bg)
   .ab-set-card-prompt    fade-truncated long-text preview (chapter-
                          private to 18.10's persona-prompt slot)

   Field input: hair-bordered, accent-soft focus ring (kit standard).
   Character count: absolutely-positioned bottom-right of the .ab-form-
   input-row wrapper so it overlays the textarea/input without
   reflowing.

   The select gets a custom dropdown caret via background-image (two
   triangles forming a downward chevron) because native select arrows
   vary wildly across OS chrome. */
import { Input, Textarea, Select, Button, PenIcon } from "@magicblocksai/ui";

/* PROVISIONAL — the agent-builder form layout chrome lives chapter-
   private (.ab-pane-form / .ab-form-*) because the field set is
   stable but the shell wrapper varies per screen (Persona, Key Facts,
   Actions all reuse this layout). Promote to kit once a second
   consumer needs the same form-pane primitive. */

export function PersonaPage({ agent, personas, onEdit, onCreate }) {
  return (
    <div className="ab-pane-form">
      <header className="ab-form-title-row">
        <h2>Persona</h2>
        <span className="ab-mode-chip">Basic</span>
      </header>

      <div className="ab-form-field">
        <label htmlFor="persona-name">Agent name *</label>
        <Input id="persona-name" defaultValue={agent.name} />
      </div>

      <div className="ab-form-field">
        <label>Describe the role of the agent in the company</label>
        <Textarea
          defaultValue={agent.role}
          maxLength={700}
          showCount
        />
      </div>

      <div className="ab-form-field">
        <label>Choose agent persona</label>
        <div className="ab-set-picker">
          <Select value={agent.personaId} options={personas} />
          <Button variant="link" onClick={onCreate}>+ Create new</Button>
          <Button variant="ghost" icon onClick={onEdit}><PenIcon /></Button>
        </div>
        <PersonaSummaryCard persona={agent.persona} />
      </div>
    </div>
  );
}

Persona edit dialog — New custom persona / Edit

.ab-dialog

The modal an operator opens via the pencil on the picker. Personas are versioned + reusable, so the dialog manages the underlying record — the agent above just references it by id. Two-column top row (name + description), version dropdown + creativity slider, tag chip-input, then the long-form persona prompt. Demoed overlaid on the parent pane to read the actual chrome relationship.

<div class="ab-dialog-overlay" role="dialog" aria-modal="true">
  <div class="ab-dialog">
    <header class="ab-dialog-head">
      <span class="ab-dialog-title">New custom persona</span>
      <button class="ab-dialog-close">×</button>
    </header>
    <div class="ab-dialog-body">
      <div class="ab-dialog-row-2">
        <div class="ab-form-field">Persona name</div>
        <div class="ab-form-field">Description</div>
      </div>
      <div class="ab-dialog-row-2">
        <div class="ab-form-field">Version select</div>
        <div class="ab-form-field">Creativity slider</div>
      </div>
      <div class="ab-form-field">Tags chip-input</div>
      <div class="ab-form-field">Persona prompt textarea (rows=9)</div>
    </div>
    <footer class="ab-dialog-foot">
      <button class="ab-h-pill">Cancel</button>
      <button class="ab-h-pill is-primary">Save</button>
    </footer>
  </div>
</div>
/* The dialog overlay is absolutely-positioned inside its
   demo-stage so the modal frames the parent pane (you can see
   the dimmed page behind it). In real consumer code, the
   overlay would mount via the kit's <Modal> portal — which
   pins to body, traps focus, and locks scroll. The CSS shape
   is identical; only the mount point differs. */
import {
  Modal,
  Input,
  Textarea,
  Select,
  Slider,
  Button,
} from "@magicblocksai/ui";

/* Real consumer integration uses the kit's <Modal> for the
   overlay (focus trap + scroll lock + Esc + outside-click).
   The form body is the same .ab-* primitives demoed above. */

export function PersonaEditDialog({ persona, open, onClose, onSave }) {
  const [draft, setDraft] = useState(persona);
  return (
    <Modal open={open} onOpenChange={onClose} title="New custom persona">
      <div className="ab-dialog-body">
        <div className="ab-dialog-row-2">
          <Field label="Persona name">
            <Input value={draft.name} onChange={…} />
          </Field>
          <Field label="Description">
            <Input value={draft.description} onChange={…} />
          </Field>
        </div>
        <div className="ab-dialog-row-2">
          <Field label="Version">
            <Select value={draft.versionId} options={persona.versions} />
          </Field>
          <Field label="Creativity">
            <Slider min={0} max={1} step={0.1}
                    value={draft.creativity}
                    onValueChange={…} />
          </Field>
        </div>
        <Field label="Tags">
          <TagInput value={draft.tags} onChange={…} />
        </Field>
        <Field label="Persona prompt *">
          <Textarea
            value={draft.prompt}
            maxLength={8000}
            showCount
            rows={9} />
        </Field>
      </div>
      <Modal.Foot>
        <Button onClick={onClose}>Cancel</Button>
        <Button tone="accent" onClick={() => onSave(draft)}>Save</Button>
      </Modal.Foot>
    </Modal>
  );
}

18.11 Key Facts

The second screen under General — where the operator declares which details the agent should extract from each conversation (name, preferences, budget, contact info). In the redesigned studio each fact’s asked-vs-listened behaviour reads as a sentence — see KeyFactBehaviourLine (18.20). Three states demoed: the list page with sortable fact cards + Privacy advisory, the “Create a new key fact” chooser dialog (template vs custom), and the Edit key fact dialog. Cards reuse the chapter's shared initSortableList() drag-and-drop helper (also used by journey blocks at 18.7). The dialog's Tab 2 — the Ask-when condition builder — ships with 18.12.

Key Facts list — sortable cards + Privacy advisory

.ab-pane-form.is-list · .ab-fact-card

Operator scans the list, drags cards to reorder (try Cmd/Ctrl + Up/Down on a focused card for keyboard reorder), clicks the 3-dot menu for inline actions, and uses “+ New” for the chooser dialog below. The yellow Privacy & Safety advisory at the bottom is a permanent guard against operators wiring up sensitive-data extraction.

Key Facts

Basic

Key facts are details you collect from users — like their name, preferences, or contact info. These can be saved to user profiles, sent to your CRM, or used to guide the conversation. More on key facts ›

Shared key facts list Total: 2
Wine Preferences Ask about their preferred type of wine.
Wants Info on Secret Specials Ask the user if they want to know about any secret deals on the wines they have enjoyed drinking.
For Privacy & Safety, avoid requesting sensitive information like passwords, bank details, health records, PINs, or other private data.
<div class="ab-pane-form is-list">
  <div class="ab-form-title-row">
    <h2>Key Facts</h2> <span class="ab-mode-chip">Basic</span>
  </div>
  <p>Key facts are details you collect from users…</p>

  <div class="ab-list-toolbar">
    <div class="ab-list-search"><svg>…</svg><input></div>
    <button class="ab-list-filter"><svg>…</svg></button>
    <button class="ab-list-new">+ New</button>
  </div>

  <div class="ab-list-count">
    <span>Shared key facts list</span>
    <span>Total: <em>2</em></span>
  </div>

  <div class="ab-fact-list">
    <div class="ab-fact-card" tabindex="0">
      <span class="ab-fact-handle"><svg>…dots</svg></span>
      <span class="ab-fact-glyph"><svg>…</svg></span>
      <div class="ab-fact-body">
        <span class="ab-fact-name">Wine Preferences</span>
        <span class="ab-fact-sub">Ask about their preferred type of wine.</span>
      </div>
      <button class="ab-fact-menu">…</button>
    </div>
    <!-- …more fact cards… -->
  </div>

  <div class="ab-privacy-advisory">
    <svg>…triangle warning</svg>
    <span><strong>For Privacy & Safety,</strong> avoid requesting…</span>
  </div>
</div>
/* List page chrome (chapter-private):
   .ab-pane-form.is-list   widens the form pane to 880px
   .ab-list-toolbar         3-col grid (search / filter / new)
   .ab-list-search          rounded pill input with leading icon
   .ab-list-new             accent-green primary button
   .ab-list-count           total counter strip (mono caption)
   .ab-fact-list            vertical stack of sortable cards
   .ab-fact-card            handle / glyph / body / menu
   .ab-fact-card.is-dragging / .is-drop-before / .is-drop-after
                            sortable visual states — matches the
                            same contract used by journey blocks
                            so initSortableList() is reusable.
   .ab-privacy-advisory     warning-soft pinned strip at bottom. */
// Sortable list is wired generically by the chapter's
// initSortableList(list, { itemSelector }) helper. Same helper
// powers journey blocks at 18.7 — second consumer of the contract
// confirms the pattern is reusable across the builder.

document.querySelectorAll('.ab-fact-list').forEach((list) => {
  initSortableList(list, { itemSelector: '.ab-fact-card' });
});

// Drag-and-drop:        click + hold the card, drop above/below
//                       a target — accent rail indicates landing
// Keyboard reorder:     focus a card, Cmd/Ctrl + ArrowUp/Down
//                       moves it one position
// Visual state classes: .is-dragging / .is-drop-before /
//                       .is-drop-after (applied by the helper,
//                       styled by the chapter CSS).
import {
  SortableList,
  Input,
  Button,
  Alert,
  PlusIcon,
  FilterIcon,
  SearchIcon,
} from "@magicblocksai/ui";

export function KeyFactsPage({ facts, onReorder, onCreate }) {
  return (
    <div className="ab-pane-form is-list">
      <header className="ab-form-title-row">
        <h2>Key Facts</h2>
        <span className="ab-mode-chip">Basic</span>
      </header>
      <p>Key facts are details you collect from users…</p>

      <div className="ab-list-toolbar">
        <Input
          type="search"
          placeholder="Search key facts by name only"
          icon={<SearchIcon />}
          shape="pill"
        />
        <Button variant="ghost" icon aria-label="Filter"><FilterIcon /></Button>
        <Button tone="success" icon={<PlusIcon />} onClick={onCreate}>
          New
        </Button>
      </div>

      {/* SortableList uses the kit's useSortable hook (chapter
          1.3.0) — same shape as the inline init helper above. */}
      <SortableList items={facts} onReorder={onReorder} className="ab-fact-list">
        {(fact, dragHandleProps) => (
          <FactCard fact={fact} dragHandleProps={dragHandleProps} />
        )}
      </SortableList>

      <Alert tone="warning" className="ab-privacy-advisory">
        <strong>For Privacy & Safety,</strong> avoid requesting sensitive
        information like passwords, bank details, health records, PINs,
        or other private data.
      </Alert>
    </div>
  );
}

Create a new key fact — template or custom

.ab-chooser-grid

Opens via the “+ New” button. Two illustrated paths: pick a prebuilt template (name / email / phone / budget) or start a custom fact from scratch. The “Recently used templates” section below is empty on day one, populates as the operator drops in templates. Picking either path opens the Edit dialog (Demo 3).

<div class="ab-dialog-overlay" role="dialog">
  <div class="ab-dialog" style="max-width: 540px;">
    <header class="ab-dialog-head">
      <span class="ab-dialog-title">Create a new key fact</span>
      <button class="ab-dialog-close">×</button>
    </header>

    <div class="ab-dialog-body">
      <p>Choose how you'd like to create your Key Fact…</p>

      <div class="ab-chooser-grid">
        <button class="ab-chooser-card">
          <span class="ab-chooser-illus"><svg>…magnifier+books</svg></span>
          <span class="ab-chooser-title">Choose from template</span>
          <span class="ab-chooser-sub">Use a predefined format…</span>
        </button>
        <button class="ab-chooser-card">
          <span class="ab-chooser-illus"><svg>…book</svg></span>
          <span class="ab-chooser-title">Create custom key fact</span>
          <span class="ab-chooser-sub">Start from scratch…</span>
        </button>
      </div>

      <div class="ab-chooser-recent">
        <h4>Recently used templates (0)</h4>
        <div class="ab-chooser-recent-empty">
          <svg>…empty-state stacked books</svg>
          <p>No recently used templates yet</p>
          <span>Start creating Key Facts…</span>
        </div>
      </div>
    </div>
  </div>
</div>
/* Chooser dialog uses the same .ab-dialog primitives as the Persona
   edit dialog at 18.10. The body content is bespoke:

   .ab-chooser-grid     2-col grid of large illustrated cards
   .ab-chooser-card     clickable surface; hover → accent-soft fill
   .ab-chooser-illus    72×72 SVG slot (success-text tinted)
   .ab-chooser-recent-empty  centered "no recent" illustration block.

   Card padding stays loose to communicate the "this is a meaningful
   choice" weight — these are the entry points for everything that
   follows in the agent's key-fact configuration. */
import { Modal, Button } from "@magicblocksai/ui";

export function CreateKeyFactChooser({ open, onClose, onPick, recent }) {
  return (
    <Modal open={open} onOpenChange={onClose} title="Create a new key fact">
      <p>Choose how you'd like to create your Key Fact…</p>

      <div className="ab-chooser-grid">
        <button className="ab-chooser-card" onClick={() => onPick('template')}>
          <span className="ab-chooser-illus"><ChooserMagnifierBooks /></span>
          <span className="ab-chooser-title">Choose from template</span>
          <span className="ab-chooser-sub">Use a predefined format and customize as needed.</span>
        </button>
        <button className="ab-chooser-card" onClick={() => onPick('custom')}>
          <span className="ab-chooser-illus"><ChooserBook /></span>
          <span className="ab-chooser-title">Create custom key fact</span>
          <span className="ab-chooser-sub">Start from scratch and define your own key fact.</span>
        </button>
      </div>

      <RecentTemplatesSection items={recent} />
    </Modal>
  );
}

Edit key fact dialog — Key fact setup (Tab 1 of 2)

.ab-dialog · .ab-warning-banner

The dialog the operator opens to configure a single fact. Tab 1 (shown) holds the structural setup — type / source / name / token / ask-text / listen-text / option values. Tab 2 (Ask when) wires the conditional logic that decides whether the agent should ask this fact in a given conversation; that tab ships with 18.12 because it shares the Conditions editor with global Actions.

<div class="ab-dialog-overlay" role="dialog">
  <div class="ab-dialog">
    <header class="ab-dialog-head">
      <span class="ab-dialog-title">Edit key fact</span>
      <button class="ab-dialog-close">×</button>
    </header>

    <div class="ab-dialog-body">
      <div class="ab-warning-banner">
        <svg>…triangle</svg>
        <div>
          <b>Updates will take effect across this agent</b>
          <span>Changes will reflect…</span>
        </div>
      </div>

      <div class="ab-dialog-row-2">
        <div class="ab-form-field">Type of information</div>
        <div class="ab-form-field">Who am I listening to</div>
      </div>

      <div class="ab-dialog-row-2">
        <div class="ab-form-field">Fact name + count</div>
        <div class="ab-form-field">Token + copy button</div>
      </div>

      <label class="ab-form-check">
        <input type="checkbox" checked> Listen across all blocks
      </label>

      <div class="ab-form-field">Ask textarea + count</div>
      <div class="ab-form-field">Listen textarea + count</div>
    </div>

    <footer class="ab-dialog-foot">
      <button class="ab-h-pill">Cancel</button>
      <button class="ab-h-pill is-primary">Update Fact</button>
    </footer>
  </div>
</div>
/* Same .ab-dialog / .ab-dialog-row-2 / .ab-form-* primitives as
   the Persona dialog (18.10). New chrome for this screen:

   .ab-warning-banner   yellow advisory at top — operator changes
                        cascade across every journey block
   .ab-token-input      input + copy-button grid — for the
                        machine-readable token slot
   .ab-form-check       inline checkbox row.

   When Tab 2 (Ask-when) ships with 18.12, the dialog grows tabs
   at the top of the body; the form chrome below stays identical. */
import {
  Modal,
  Tabs,
  Select,
  Input,
  Textarea,
  Checkbox,
  Alert,
  Button,
  CopyButton,
} from "@magicblocksai/ui";

export function EditKeyFactDialog({ fact, open, onClose, onSave }) {
  const [draft, setDraft] = useState(fact);
  return (
    <Modal open={open} onOpenChange={onClose} title="Edit key fact">
      <Tabs defaultValue="setup" items={[
        { id: 'setup', label: 'Key fact setup' },
        { id: 'ask-when', label: 'Ask when condition' },
      ]} />

      <Alert tone="warning">
        <b>Updates will take effect across this agent.</b>
        Changes will reflect and override all Journey Blocks using
        this key fact.
      </Alert>

      <div className="ab-dialog-row-2">
        <Field label="Type of information to collect *">
          <Select value={draft.type} options={TYPE_OPTIONS} />
        </Field>
        <Field label="Who am I listening to for the key fact? *">
          <Select value={draft.source} options={SOURCE_OPTIONS} />
        </Field>
      </div>

      <div className="ab-dialog-row-2">
        <Field label="Fact name *">
          <Input value={draft.name} maxLength={30} showCount />
        </Field>
        <Field label="Key fact token *">
          <Input value={draft.token} mono trailing={
            <CopyButton value={draft.token} icon />
          } />
        </Field>
      </div>

      <Checkbox checked={draft.listenAcrossBlocks}
                label="Listen for this fact across all journey blocks" />

      <Field label="Ask the user *">
        <Textarea value={draft.ask} maxLength={400} showCount rows={3} />
      </Field>
      <Field label="Listen for key fact *">
        <Textarea value={draft.listen} maxLength={400} showCount rows={4} />
      </Field>

      <Modal.Foot>
        <Button onClick={onClose}>Cancel</Button>
        <Button tone="accent" onClick={() => onSave(draft)}>Update Fact</Button>
      </Modal.Foot>
    </Modal>
  );
}

18.12 Actions

The third screen under General — the conditions+effects engine that drives every conversation. In the redesigned studio the agent-level set surfaces as Anytime actions — rules that watch every block (18.20). An action is a thing the agent does (switch journey block, send a message, add a button, hand off to a human, … fifteen types total). A condition is the trigger that decides when to fire (key fact known, user said X, AI sentiment was negative, no message for N minutes, … fifteen types total). The pane composes both inside a 3-step wizard: Action · Conditions · Goal.

Design tweaks vs the current product: action types render as a 4-col card grid (vs the radio list) so all fifteen read at once with icons; the step indicator is numbered with check glyphs for completed steps; conditions use the kit's already-shipped .qb shape (18.1 QueryBuilder) so the operator vocabulary stays identical across every conditions surface in the platform (Ask-when, agent actions, webhook event filters, audience filters).

Action wizard — Step 1 (Action + config)

.action-wizard · .action-picker · .action-config

Picking an action type now reveals its config panel below the grid — driven by the actionRegistry. Here Send Message is selected, showing the snippet-or-custom <MessageField>. Each type declares its config as data (a fields list) or points to a custom panel, so adding a new action is a single registry entry. All 15 types render as cards; the four Website-only types (Add Buttons / Add Forms / Add Calendar / Embed) show disabled with a “Website only” caption on this Omnichannel agent.

Only match once

Select an action

Send Message Send a message to the end user — a saved snippet or custom text.
<div class="action-wizard">
  <aside class="action-list">… actions list …</aside>

  <div class="wizard-pane">
    <header class="wizard-head">
      <nav class="wizard-steps">
        <button class="wizard-step is-active"><span class="wizard-step-num">1</span> Action</button>
        <button class="wizard-step"><span class="wizard-step-num">2</span> Conditions <span class="wizard-step-count">1</span></button>
        <button class="wizard-step"><span class="wizard-step-num">3</span> Goal</button>
      </nav>
    </header>

    <div class="wizard-body">
      <div class="action-picker">
        <button class="action-tile is-selected"><span class="action-tile-glyph">…</span><span class="action-tile-name">Send Message</span></button>
        <!-- …14 more tiles. Website-only types carry .is-disabled -->
      </div>
      <div class="action-detail">… selected description …</div>

      <!-- The selected action's config reveals here -->
      <div class="action-config">
        <div class="field-row">
          <label class="field-label">Message</label>
          <div class="message-field">
            <div class="message-field-tabs"><button class="message-field-tab">Select</button><button class="message-field-tab is-active">Custom</button></div>
            <textarea class="message-field-textarea">…</textarea>
          </div>
        </div>
      </div>
    </div>

    <footer class="wizard-foot">…</footer>
  </div>
</div>
/* Promoted to @magicblocksai/css (operator surface) in v1.67.0:

   .action-wizard   2-col shell (.action-list / .wizard-pane)
   .wizard-steps    numbered step indicator (.is-active / .is-done)
   .action-picker   registry-driven grid of .action-tile cards
                     (.is-selected accent fill · .is-disabled "Website only")
   .action-detail   selected-action description card
   .action-config   per-action config surface — stacks .field-row rows
   .message-field   Select | Custom snippet-or-custom editor

   Full rules ship in _shared.css. */
import { useState } from "react";
import {
  ActionWizard, ActionList, WizardSteps, ActionPicker,
  ActionConfigPanel, ActionField, MessageField, Switch,
} from "@magicblocksai/ui";
// `actionRegistry` + the `Action` / `ActionFieldSpec` types are also kit exports.

// `actions` is the block's actions; `selectedId` the one being edited.
export function ActionStep({ actions, selectedId, onChange, onSelect, onAdd }) {
  const action = actions.find((a) => a.id === selectedId)!;
  const [collapsed] = useState(false);
  return (
    <ActionWizard
      actionsList={
        <ActionList
          items={actions.map((a) => ({
            id: a.id,
            name: a.name,
            scope: KIND_LABELS[a.type],              // kind chip
            meta: a.conditions.length === 0          // NEW — trigger summary
              ? "Fires every turn"
              : `${a.conditions.length} condition${a.conditions.length === 1 ? "" : "s"}`,
          }))}
          selectedId={selectedId} onSelect={onSelect} onAddAction={onAdd} />
      }
      steps={
        <WizardSteps onStepChange={() => {}} steps={[
          { id: "action",     label: "Action",     status: "active" },
          { id: "conditions", label: "Conditions", count: 1, status: "todo" },
          { id: "goal",       label: "Goal",       status: "todo" },
        ]} />
      }
      toolbar={<label className="ab-only-once">Only match once <Switch defaultChecked /></label>}
    >
      {/* Pick a type → the registry seeds a new action of that type */}
      <ActionPicker value={action.type} channel="chat"
        onChange={(type) => onChange(actionRegistry[type].makeDefault(action.id))} />

      {/* …and its config reveals below — declarative fields (rendered via
          <ActionField> → <MessageField> etc.) or a custom panel. */}
      <ActionConfigPanel action={action} onChange={onChange}
        context={{ snippets: savedSnippets }} />
    </ActionWizard>
  );
}

Add Calendar — custom config panel

.calendar-config · <FieldMapper>

The complex exemplar. When Add Calendar is picked, the registry points to a custom <CalendarActionConfig> panel (the escape hatch) rather than declarative fields. It composes <Select> (provider + embed type), <Input> (account / event), and <FieldMapper> for the MagicBlocks→Calendly form-field auto-population — with Name / Email always mapped (locked rows).

Calendar form auto-population

Quickly prefilled into your calendar’s form fields.

Name Name
Email Email
Phone (keyfact) [form_field_3]
<div class="calendar-config">
  <div class="field-row">
    <label class="field-label">Select a calendar</label>
    <select class="...">Calendly / Google / HighLevel / HubSpot</select>
  </div>
  <!-- Calendly: embed type · account · event ID -->

  <div class="calendar-autofill">
    <p class="field-label">Calendar form auto-population</p>
    <div class="calendar-autofill-locked">Name → Name</div>     <!-- locked -->
    <div class="calendar-autofill-locked">Email → Email</div>   <!-- locked -->
    <!-- <FieldMapper> renders the editable MagicBlocks→Calendly rows -->
  </div>
</div>
/* operator surface (v1.67.0):
   .calendar-config          stacked field rows
   .calendar-autofill        the form-field mapper section
   .calendar-autofill-locked Name/Email always-mapped rows
   Editable rows are <FieldMapper> (chapter 18.3 / .field-mapper). */
import { CalendarActionConfig } from "@magicblocksai/ui";

// Registered as the Add Calendar action's custom `Panel`; 
// renders it automatically when `action.type === "add_calendar"`.
export function CalendarStep({ action, onChange, contactFields, calendlyFields }) {
  return (
    <CalendarActionConfig
      action={action}
      onChange={onChange}
      context={{
        calendarSourceFields: contactFields,  // MagicBlocks fields (left)
        calendlyFields,                        // [form_field_N] slots (right)
      }}
    />
  );
}

Switch Journey & Human Takeover — conditional panels

.action-config-stack · .action-config-section

Two more custom Panels whose fields cascade. Switch Journey picks a target block (or “Current Block”), optionally switches channel — revealing a channel and, for SMS, a sender picker — and shows a follow-up action only while staying on the current block. Human Takeover reveals a Slack workspace + channel cascade when its follow-up is set to Slack, then gates an in-chat transfer message and an out-of-hours message behind a staff-availability profile.

Switch Journey

Follow-up action

Runs after the switch when staying on the current block.

Human Takeover

Transferred to Staff Message
Outside Staff Availability Message
<!-- Switch Journey — block/channel cascade + conditional follow-up -->
<div class="action-config-stack">
  <div class="field-row"><label class="field-label">Select Journey Block</label> <select>…</select></div>
  <div class="field-row field-row-switch"><label class="switch">…</label> Switch Channel</div>
  <!-- channel + sender selects appear when Switch Channel is on -->
  <div class="action-config-section">     <!-- follow-up: only on Current Block -->
    <select>Send message / Auto</select>
    <div class="message-field">…</div>
  </div>
</div>
/* operator surface (v1.68.0):
   .action-config-stack    vertical field stack (shared by every Phase-2 panel)
   .action-config-section  grouped sub-cascade (follow-up / Slack block) */
import { SwitchJourneyActionConfig, HumanTakeoverActionConfig } from "@magicblocksai/ui";

// Both are registered as their action type's custom `Panel`; <ActionConfigPanel>
// renders them automatically. Shown standalone here for clarity.
export function FlowPanels({ action, onChange, ctx }) {
  return action.type === "switch_journey" ? (
    <SwitchJourneyActionConfig
      action={action}
      onChange={onChange}
      context={{ journeyBlocks: ctx.blocks, switchChannels: ctx.channels, smsSenders: ctx.senders, snippets: ctx.snippets }}
    />
  ) : (
    <HumanTakeoverActionConfig
      action={action}
      onChange={onChange}
      context={{ slackConnections: ctx.slack, availabilityProfiles: ctx.profiles, snippets: ctx.snippets }}
    />
  );
}

Add Buttons, Add Forms & declarative actions

.action-button-rows · <ActionConfigPanel>

Add Buttons brackets a message with quick-reply buttons (label rows you add / remove). Add Forms picks a saved form and wires its before / submit / privacy messages. The simplest three — End Chat, Opt-Out and Embed — need no custom component: they declare fields in the registry and render through <ActionField> (here, Embed’s URL + height + two messages). Run Task graduated to its own panel in Phase 3 (next).

Add Buttons

Message before
Buttons

Quick-reply buttons shown with the message. Each can trigger its own action.

Message after

Add Forms

Message before
Privacy notice

Embed — declarative

Message before
<!-- Add Buttons — two messages bracketing a button repeater -->
<div class="action-config-stack">
  <div class="field-row"><span class="field-label">Message before</span> <div class="message-field">…</div></div>
  <div class="action-button-rows">
    <div class="action-button-row"><input value="Book a tasting"> <button class="icon-btn">🗑</button></div>
    <div class="action-button-row"><input value="Talk to sales"> <button class="icon-btn">🗑</button></div>
  </div>
  <button class="btn btn-secondary btn-sm">+ Add button</button>
  <div class="field-row"><span class="field-label">Message after</span> <div class="message-field">…</div></div>
</div>
/* operator surface (v1.68.0):
   .action-button-rows   the button list
   .action-button-row    one row — label <input> (flex:1) + remove .icon-btn
   Declarative actions (Embed/End Chat/Opt-Out/Run Task) reuse .field-row. */
import { ButtonsActionConfig, FormsActionConfig, ActionConfigPanel } from "@magicblocksai/ui";

// Buttons + Forms are custom panels; End Chat / Opt-Out / Run Task / Embed are
// declarative — ActionConfigPanel renders their `fields` with no bespoke code.
export function ActionConfig({ action, onChange, ctx }) {
  if (action.type === "add_buttons")
    return <ButtonsActionConfig action={action} onChange={onChange} context={{ snippets: ctx.snippets }} />;
  if (action.type === "add_forms")
    return <FormsActionConfig action={action} onChange={onChange} context={{ forms: ctx.forms, snippets: ctx.snippets }} />;
  // Embed / End Chat / Opt-Out → declarative fields; Run Task → its own panel:
  return <ActionConfigPanel action={action} onChange={onChange} context={{ snippets: ctx.snippets }} />;
}

Nested sub-actions & Run Task — recursion + prompt config

<SubActionField> · <RunTaskActionConfig>

The framework recurses: a quick-reply button (or a form submit) triggers its own action, edited by a restricted <ActionPicker> + a nested <ActionConfigPanel> — the allow-list excludes Add Buttons, so it always terminates. Run Task graduates to a panel: a saved-prompt / custom-instructions toggle plus a functions multi-select.

Add Buttons — a button with a nested action

Run Task

Instructions
Functions

Tools the task may call.

lookup_order crm_search
<!-- A button row that expands to a recursive sub-action editor -->
<div class="action-button-item">
  <div class="action-button-row"><input value="Book a tasting"> <button>Edit action</button> <button class="icon-btn">🗑</button></div>
  <div class="action-button-subaction">
    <div class="sub-action-field">
      <div class="action-picker">… restricted tiles (no Add Buttons) …</div>
      <div class="sub-action-config"><!-- nested <ActionConfigPanel> -->…</div>
    </div>
  </div>
</div>
/* operator surface (v1.69.0):
   .action-button-item       wraps a button row + its sub-action editor
   .action-button-subaction  indented, hair-bordered nesting affordance
   .sub-action-field         restricted picker + recursive config
   .sub-action-config        the chosen sub-action's panel (warm group) */
import { SubActionField, RunTaskActionConfig } from "@magicblocksai/ui";

// A button's nested action — SubActionField fronts a restricted ActionPicker
// with a recursive ActionConfigPanel (allow-list excludes add_buttons → terminates).
export function ButtonAction({ button, onChange, ctx }) {
  return (
    <SubActionField
      value={button.action}
      onChange={(action) => onChange({ ...button, action })}
      idPrefix={button.id}
      context={{ snippets: ctx.snippets }}
    />
  );
}

// Run Task — saved prompt | custom instructions + a functions multi-select.
export function TaskStep({ action, onChange, ctx }) {
  return (
    <RunTaskActionConfig
      action={action}
      onChange={onChange}
      context={{ prompts: ctx.prompts, functions: ctx.functions }}
    />
  );
}

Action type picker — searchable dropdown

.action-opt · <ActionPicker layout="dropdown">

For a long (and growing) action set, <ActionPicker layout="dropdown"> swaps the 4-col grid for a searchable <Combobox> — type to filter, and each option reads as icon · name · one-line description (pulled from the action registry). The grid stays the default; off-channel types are omitted from the dropdown (the grid shows them disabled). Below: the descriptive option rows the popover renders.

Send MessageSend a message to the end user — a saved snippet or custom text. Switch JourneyMove the conversation to another journey block. Run TaskRun a saved prompt or call functions to do work mid-chat. Human TakeoverHand off to a human agent and pause the bot.
<div class="action-opt-demo" role="listbox" aria-label="Action type">
        <span class="action-opt"><span class="action-opt-glyph" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><circle cx="8" cy="8" r="5"></circle></svg></span><span class="action-opt-text"><span class="action-opt-name">Send Message</span><span class="action-opt-desc">Send a message to the end user — a saved snippet or custom text.</span></span></span>
        <span class="action-opt"><span class="action-opt-glyph" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><circle cx="8" cy="8" r="5"></circle></svg></span><span class="action-opt-text"><span class="action-opt-name">Switch Journey</span><span class="action-opt-desc">Move the conversation to another journey block.</span></span></span>
        <span class="action-opt"><span class="action-opt-glyph" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><circle cx="8" cy="8" r="5"></circle></svg></span><span class="action-opt-text"><span class="action-opt-name">Run Task</span><span class="action-opt-desc">Run a saved prompt or call functions to do work mid-chat.</span></span></span>
        <span class="action-opt"><span class="action-opt-glyph" aria-hidden="true"><svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linejoin="round"><circle cx="8" cy="8" r="5"></circle></svg></span><span class="action-opt-text"><span class="action-opt-name">Human Takeover</span><span class="action-opt-desc">Hand off to a human agent and pause the bot.</span></span></span>
      </div>
/* The .action-opt option rows ship in @magicblocksai/css (@surface: operator).
   <ActionPicker layout="dropdown"> renders them inside <Combobox>'s popover via
   its renderOption — searchable, keyboard-navigable, viewport-aware. */
// No wiring — <ActionPicker layout="dropdown"> composes the kit's <Combobox>
// (search + roving keyboard nav + portal popover) out of the box.
import { useState } from 'react';
import { ActionPicker } from '@magicblocksai/ui';

// layout="dropdown" → a searchable <Combobox> instead of the 4-col grid.
// Each option reads icon · name · one-line description (from the registry);
// type to filter. The grid stays the default elsewhere.
const [type, setType] = useState('send_message');

<ActionPicker
  layout="dropdown"
  value={type}
  onChange={setType}
  channel="chat"
/>

Action wizard — Step 2 of 3 (Conditions)

.ab-cond-builder · uses .qb shape

Two condition rows ANDed together. The value editor changes shape by condition type: All Key Facts Found (Active Block) uses the Yes / No toggle (boolean comparator), No message received for uses the kit's number + unit duration shape from the 18.1 QueryBuilder. Step 1 carries a green tick in the indicator now that it's complete; Step 3 stays neutral. Every comparator listed at agents.md per condition-type is reachable from the same operator dropdown the kit's QueryBuilder already vocabularises — consistent surface across actions / ask-when / webhook filters.

Only match once

When should this action fire?

All conditions must be true (AND).
<div class="ab-wiz-body">
  <div class="ab-cond-builder">
    <!-- Each row: type select / comparator select / value editor / remove -->
    <div class="ab-cond-row">
      <select>All Key Facts Found (Active Block)</select>
      <select>Equals</select>
      <div class="ab-cond-row-yesno">
        <button>No</button>
        <button class="is-active">Yes</button>
      </div>
      <button class="ab-cond-row-remove">×</button>
    </div>

    <!-- Value editor swaps shape on type: number + unit for "No message
         received for", text input for "User Message Contents", chips for
         "One of", etc. Same shape vocabulary as the 18.1 QueryBuilder. -->
    <div class="ab-cond-row">
      <select>No message received from user for</select>
      <select>in the last</select>
      <div style="display: grid; grid-template-columns: 80px 1fr; gap: 6px;">
        <input type="number" value="2">
        <select><option>Minutes</option>…</select>
      </div>
      <button class="ab-cond-row-remove">×</button>
    </div>

    <button class="ab-cond-add">+ Add condition</button>
  </div>
</div>
/* Conditions builder reuses the .ab-form-input chrome for selects +
   inputs (same focus ring, same border treatment) and adds:

   .ab-cond-builder      gap-3 vertical stack
   .ab-cond-row          4-col grid (type / comparator / value / remove)
                         140px on comparator (fits "does not equal")
                         minmax(0, 1fr) on type + value so they shrink
                         gracefully on narrow viewports
   .ab-cond-row-yesno    inline segmented control for boolean values
                         (Yes activates with success-text fill)
   .ab-cond-row-remove   ghost button → error-soft on hover
   .ab-cond-add          dashed-border accent button, fills on hover.

   The 15 condition types vocabularise different value editors but the
   row chrome is identical — same contract as the kit's QueryBuilder
   at 18.1 so a single component eventually backs both. */
import { QueryBuilder } from "@magicblocksai/ui";

// The 15 condition types map to fields the kit's QueryBuilder already
// understands. Each declares its type (boolean, number, text, etc.)
// which drives the operator vocabulary + value editor shape.
const CONDITION_FIELDS = [
  { id: 'all_facts_found', label: 'All Key Facts Found (Active Block)',
    type: 'boolean' },
  { id: 'key_fact',        label: 'Key Fact',
    type: 'enum', subFields: keyFactsForThisAgent },
  { id: 'user_msg_count',  label: 'User message count (Active Block)',
    type: 'number' },
  { id: 'user_msg_content', label: 'User Message Contents',
    type: 'text' },
  { id: 'wants_human',     label: 'Wants To Talk To Human',
    type: 'boolean' },
  { id: 'sentiment',       label: 'Sentiment',
    type: 'enum', options: ['Positive','Negative','Neutral','Unknown'] },
  { id: 'no_msg_for',      label: 'No message received from user for',
    type: 'duration' },
  { id: 'custom',          label: 'Custom Condition',
    type: 'custom-prompt' },
  // …7 more.
];

export function ConditionsStep({ conditions, onChange }) {
  return (
    <QueryBuilder
      fields={CONDITION_FIELDS}
      value={conditions}
      onChange={onChange}
      conjunction="AND"
      addLabel="+ Add condition"
    />
  );
}

// Same component also backs the Ask-when tab on Key Fact edit (18.11
// Tab 2), the webhook event-filter on Channels (18.16), and audience
// filters on Campaigns (14.17). One conditions vocabulary, one
// operator set, four consumers.

Action wizard — Step 3 of 3 (Goal)

<GoalStep>

The wizard’s final step: pick the goal this director works toward, and — once chosen — its monetary target. Step 2 (Conditions) is the kit’s <QueryBuilder> from 18.1, shown above — no new component needed.

The goal this director works toward.

<div class="action-config-stack">
  <div class="field-row"><label class="field-label">Select a goal</label> <select>…</select></div>
  <!-- value shows once a goal is picked -->
  <div class="field-row"><label class="field-label">Goal value</label> <div class="input-group"><span class="input-affix">$</span><input type="number"></div></div>
</div>
/* Reuses the shared .action-config-stack + .field-row families.
   The $ prefix is the kit's <Input prefix> (.input-group / .input-affix). */
import { GoalStep } from "@magicblocksai/ui";

// Step 3 of the agent-builder wizard. Conditions (Step 2) is <QueryBuilder>.
export function GoalStepDemo({ goal, setGoal, ctx }) {
  return <GoalStep value={goal} onChange={setGoal} goals={ctx.goals} />;
}

Reference — all 15 action types: Auto · Switch Journey · Change stage · Change segment · End Chat · Send Message · Run Task · Add Buttons (Website) · Add Forms (Website) · Add Calendar (Website) · Add Link · Opt-Out · Embed (Website) · Human Takeover · Do not respond.   All 15 condition types: Key Fact · All Key Facts Found (Active Block) · User message count (Active Block) · AI message count (Active Block) · User Message Contents · AI Message Contents · Wants To Talk To Human · User Asked Question · Slash Command · Form Field · Page URL · Sentiment · Custom Condition · User Intent — Wants to Opt-Out · No message received from user for.

18.13 Guardrails

The fourth screen under General. Per Jay's direction this screen moves to a library item — a GuardrailSet is now a reusable bundle of rules an operator can load into multiple agents (same pattern as Persona at 18.10). The agent page picks a set + layers five agent-level runtime controls on top — PII collection, Redaction, Rules monitor, Jailbreak prevention, Moderation. The rules themselves live inside the set and get edited via the dialog. Per-block Advanced overrides can later override any subset for a specific phase of the conversation.

Library separation: rules = library content (versioned, reusable, edited inside the set). Runtime controls = agent-scoped (configure how the agent enforces those rules at runtime). One conceptually clear edge between “what we won't do” (set) and “how we make sure of that” (controls).

Guardrails page — library picker + agent-level controls

.ab-pane-form · .ab-set-picker · .ab-control-list

Picker loads a saved GuardrailSet (currently Winery House Rules — Standard, reused by 3 agents). The summary card surfaces the set's shape without unrolling every rule. Below sits the agent-level controls list — the five safety layers that run at conversation time. PII + Rules monitor + Jailbreak are on by default for this agent; Redaction is off; Moderation uses the kit's built-in fallback.

Guardrails

Basic ?

Pick a guardrail set from your library, or create a new one for this agent. The five controls below configure how the agent enforces those rules at runtime — reach for them when you need to tighten or relax safety per agent. More on guardrails ›

Version v3 · 12 May 2026 12 / 15 rules used Shared with 3 agents

A 12-rule baseline for every Tyrnell-family agent — covers on-message constraints, responsible service of alcohol, no-promises pricing, and the “no recommending other wineries” brand boundary.

Runtime layers that filter or rewrite responses. Each one is agent-scoped — tighten or relax independently of the set above.

PII collection control Stop the agent from asking for sensitive info even if a Key Fact would otherwise prompt it.
Redaction Strip sensitive content from user messages before storage + downstream propagation.
Off
Rules monitor Output verification layer — check every generated message against the set's rules; auto-rewrite or block violations.
On
Jailbreak prevention Detect prompt-injection / jailbreak attempts; respond with the configured fallback instead of complying.
Moderation When the moderation classifier flags a user message, respond with this fallback rather than generating a reply.
<div class="ab-pane-form">
  <div class="ab-form-title-row">
    <h2>Guardrails</h2> <span class="ab-mode-chip">Basic</span>
  </div>

  <!-- Library picker -->
  <div class="ab-form-field">
    <label>Choose guardrail set</label>
    <div class="ab-set-picker">
      <select class="ab-form-input">…sets</select>
      <button class="ab-set-create">+ Create new</button>
      <button class="ab-set-edit"><svg>…pencil</svg></button>
    </div>
    <div class="ab-set-card">
      <div class="ab-set-card-meta">
        <span>Version v3 · 12 May 2026</span>
        <span>12 / 15 rules used</span>
        <span class="is-shared">↻ Shared with 3 agents</span>
      </div>
      <p>A 12-rule baseline for every Tyrnell-family agent…</p>
    </div>
  </div>

  <!-- Agent-level controls -->
  <div class="ab-form-field">
    <label>Agent-level controls</label>
    <div class="ab-control-list">

      <div class="ab-control-row">
        <span class="ab-control-row-glyph"><svg>…</svg></span>
        <div class="ab-control-row-body">
          <span class="ab-control-row-name">PII collection control</span>
          <span class="ab-control-row-sub">…</span>
        </div>
        <div class="ab-control-row-action">
          <select>3 types blocked</select>
        </div>
      </div>

      <div class="ab-control-row is-on">
        <span class="ab-control-row-glyph"><svg>…shield</svg></span>
        <div class="ab-control-row-body">
          <span class="ab-control-row-name">Rules monitor</span>
          <span class="ab-control-row-sub">…</span>
        </div>
        <div class="ab-control-row-action">
          <span class="ab-control-row-status is-on">On</span>
          <button class="ab-pill-toggle" aria-pressed="true"></button>
        </div>
      </div>

      <!-- …PII / Redaction / Jailbreak / Moderation rows… -->
    </div>
  </div>
</div>
/* New chapter-private chrome for the library-loaded screen:

   .ab-set-picker      3-col grid: select + Create-new + edit pencil
                       (shared library-picker family — same shape
                       backs Persona 18.10, Knowledge 18.14, and
                       Design & Go Live appearance 18.17).
   .ab-set-card        warm summary card under the picker
                       .is-shared meta highlights cross-agent reuse
   .ab-control-list    paper card with horizontal hair dividers
   .ab-control-row     32px glyph / body / action grid
   .ab-control-row.is-on  glyph adopts accent fill
   .ab-control-row-status pill (Off / On)
   .ab-pill-toggle     42×24 segmented toggle (cousin of .ab-only-once-
                       toggle but bigger, label-pairing-friendly). */
import {
  Input,
  Select,
  Button,
  Switch,
  PenIcon,
  ShieldIcon,
} from "@magicblocksai/ui";

export function GuardrailsPage({ agent, guardrailSets, onEditSet }) {
  return (
    <div className="ab-pane-form">
      <header className="ab-form-title-row">
        <h2>Guardrails</h2>
        <span className="ab-mode-chip">Basic</span>
      </header>
      <p>Pick a guardrail set from your library…</p>

      {/* Library picker — same shape as Persona picker (18.10) */}
      <Field label="Choose guardrail set">
        <div className="ab-set-picker">
          <Select value={agent.guardrailSetId} options={guardrailSets} />
          <Button variant="link" onClick={onCreateSet}>+ Create new</Button>
          <Button variant="ghost" icon onClick={onEditSet}><PenIcon /></Button>
        </div>
        <GuardrailSetSummaryCard set={agent.guardrailSet} />
      </Field>

      {/* Agent-level runtime controls — 5 rows, each with its own
          configured value alongside an On/Off pill toggle. */}
      <Field label="Agent-level controls">
        <ControlList>
          <ControlRow icon={<LockIcon />} name="PII collection control"
                      sub="Stop the agent from asking for sensitive info">
            <Select value={agent.piiBlocked} options={PII_OPTIONS} />
          </ControlRow>
          <ControlRow icon={<BlackoutIcon />} name="Redaction"
                      sub="Strip sensitive content before storage">
            <Switch checked={agent.redactionEnabled} />
          </ControlRow>
          <ControlRow on icon={<ShieldIcon />} name="Rules monitor"
                      sub="Output verification layer">
            <Switch checked={agent.rulesMonitorEnabled} />
          </ControlRow>
          {/* …Jailbreak / Moderation rows… */}
        </ControlList>
      </Field>
    </div>
  );
}

Edit guardrail set — library record (sortable rules)

.ab-dialog · .ab-rule-list (sortable)

The dialog the operator opens via the pencil. This edits the library record — changes here flow to every agent that uses this set. Rules drag-reorder via the same shared initSortableList() helper (third consumer now — journey blocks, fact cards, rules — the helper's shape is locked). Try Cmd/Ctrl + Up/Down on a focused rule for keyboard reorder.

<div class="ab-dialog-overlay" role="dialog">
  <div class="ab-dialog">
    <header class="ab-dialog-head">
      <span class="ab-dialog-title">Edit guardrail set</span>
      <button class="ab-dialog-close">×</button>
    </header>

    <div class="ab-dialog-body">
      <div class="ab-warning-banner">
        <b>This set is used by 3 agents</b> Changes will roll out…
      </div>

      <div class="ab-dialog-row-2">
        <div class="ab-form-field">Set name *</div>
        <div class="ab-form-field">Version</div>
      </div>

      <div class="ab-form-field">Description</div>
      <div class="ab-form-field">Tags chip-input</div>

      <div class="ab-form-field">
        <label>Rules <span class="ab-rule-counter">Total 4 / 15</span></label>
        <div class="ab-rule-list">
          <div class="ab-rule-card" tabindex="0">
            <span class="ab-rule-handle"><svg>…</svg></span>
            <span class="ab-rule-text">Don't recommend other wineries.</span>
            <button class="ab-rule-menu">…</button>
          </div>
          <!-- …more rule cards… -->
        </div>
        <button class="ab-add-rule">+ New rule</button>
      </div>
    </div>

    <footer class="ab-dialog-foot">
      <button class="ab-h-pill">Cancel</button>
      <button class="ab-h-pill is-primary">Save & roll out</button>
    </footer>
  </div>
</div>
/* Reuses .ab-dialog / .ab-warning-banner / .ab-dialog-row-2 /
   .ab-tag-input from earlier sections. New chrome:

   .ab-rule-list       sortable vertical stack of rule cards
   .ab-rule-card       drag handle + text + 3-dot menu
                       .is-dragging / .is-drop-before / .is-drop-after
                       — same sortable contract as fact cards (18.11)
                       and journey blocks (18.7)
   .ab-add-rule        dashed-accent button, fills on hover
   .ab-rule-counter    "Total 4 / 15" caption inside the field label
                       so operators see they're at 4 of the plan limit. */
// The shared initSortableList() helper from the script block at the
// top of the chapter (also used by journey blocks 18.7 + fact cards
// 18.11) gains rule cards as its third consumer:

document.querySelectorAll('.ab-rule-list').forEach((list) => {
  initSortableList(list, { itemSelector: '.ab-rule-card' });
});

// Drag-and-drop:    grab card, drop above/below target
// Keyboard reorder: Cmd/Ctrl + ArrowUp/Down on a focused rule
import {
  Modal,
  Input,
  Select,
  Button,
  Alert,
  SortableList,
} from "@magicblocksai/ui";

export function EditGuardrailSetDialog({ set, open, onClose, onSave }) {
  const [draft, setDraft] = useState(set);
  return (
    <Modal open={open} onOpenChange={onClose} title="Edit guardrail set">
      <Alert tone="warning">
        <b>This set is used by {set.usedByCount} agents.</b>
        Changes will roll out to every agent using {set.name}
        the next time they reload.
      </Alert>

      <div className="ab-dialog-row-2">
        <Field label="Set name *">
          <Input value={draft.name} />
        </Field>
        <Field label="Version">
          <Select value={draft.versionId} options={set.versions} />
        </Field>
      </div>

      <Field label="Description"><Input value={draft.description} /></Field>
      <Field label="Tags"><TagInput value={draft.tags} /></Field>

      <Field label={`Rules (${draft.rules.length} / ${planLimit})`}>
        <SortableList
          items={draft.rules}
          onReorder={(rules) => setDraft({ …draft, rules })}
          className="ab-rule-list">
          {(rule) => (
            <div className="ab-rule-card">
              <DragHandle />
              <span>{rule.text}</span>
              <RuleMenu rule={rule} />
            </div>
          )}
        </SortableList>
        <Button variant="ghost-dashed" onClick={addRule}>+ New rule</Button>
      </Field>

      <Modal.Foot>
        <Button onClick={onClose}>Cancel</Button>
        <Button tone="accent" onClick={() => onSave(draft)}>
          Save & roll out
        </Button>
      </Modal.Foot>
    </Modal>
  );
}

18.14 Knowledge

The fifth screen under Generalrenamed from “Brain” per Jay's direction. Anything the agent can reference mid-conversation: sales playbooks, knowledge collections, Q&A sets, snippet packs. Unified into a single resource list with type chips instead of the current product's separate methods for playbooks vs collections — one mental model, one drag-reorder priority. Tools & MCP sit in their own list because they're call-out capability, not reference content. Company info + always-on context round out the page.

Why unified: the production today has two different surfaces — a single-select dropdown for Sales Playbook and an on/off + multi-select for Knowledge Base Collections. Operators learn the playbook UI once, then have to learn a different pattern for collections. The unified resource list teaches one row shape (handle + type-chip glyph + name + meta + menu) that every kind of knowledge fits into. Type-coloured glyphs + chips keep them scannable at a glance.

Knowledge page — unified resources + tools + always-on context

.ab-pane-form · .ab-resource-list · .ab-info-grid

Four parts top-to-bottom: Company info (4 fields the agent always knows about the business), Knowledge resources (sortable unified list — drag to set retrieval priority), Tools & MCP (separate list because tools are capability not reference), Always-on context (free-form per-contact priority text the agent treats as known from session start — renamed from “Priority Knowledge”).

Knowledge

Basic ?

Everything the agent can reach for during a conversation. Drag the list to set priority — the agent consults higher items first. More on knowledge ›

Drag to reorder. Top of the list = first to be consulted when the agent needs to answer a question.

Tyrnell Wine Sales Playbook Playbook 6 sections · v4 · updated 3d ago
Wine product catalogue Collection 142 docs · reindexed 1h ago
Brand voice + Hunter Valley story Collection 38 docs · reindexed 2d ago
Common winery FAQs Q&A 28 pairs · last edited yesterday
Tyrnell house phrases Snippet pack 12 snippets

Bind MCP servers + AI tools so the agent can call out mid-conversation — lookups, computations, async checks. Different from knowledge above (which the agent reads).

Tyrnell inventory MCP MCP 3 tools · check_stock · check_availability · recommend_vintage

Free-form context the agent treats as known from session start — no need to ask. Snippet tags {{ }} get expanded with contact data at runtime.

214 / 5000
<div class="ab-pane-form">
  <div class="ab-form-title-row">
    <h2>Knowledge</h2> <span class="ab-mode-chip">Basic</span>
  </div>

  <!-- Company info (4 fields, 2-col grid) -->
  <div class="ab-form-field">
    <label>Company info</label>
    <div class="ab-info-grid">
      <input value="Tyrnell Wines">
      <input value="https://www.tyrnells.com.au">
      <input value="[email protected]">
      <input value="+61 480 022 801">
    </div>
  </div>

  <!-- Unified knowledge resources (sortable) -->
  <div class="ab-form-field">
    <label>Knowledge resources <b>Total 5 · priority order</b></label>
    <p>Drag to reorder. Top = first to be consulted.</p>

    <div class="ab-resource-list">
      <div class="ab-resource-card" tabindex="0">
        <span class="ab-resource-handle"><svg>…</svg></span>
        <span class="ab-resource-glyph is-playbook"><svg>…</svg></span>
        <div class="ab-resource-body">
          <span class="ab-resource-name">Tyrnell Wine Sales Playbook</span>
          <span class="ab-resource-meta">
            <span class="ab-resource-type is-playbook">Playbook</span>
            <span class="ab-resource-meta-sub">6 sections · v4 · updated 3d ago</span>
          </span>
        </div>
        <button class="ab-resource-menu">…</button>
      </div>
      <!-- …collection / qa / snippet rows, same shape with different type chip… -->
    </div>

    <button class="ab-add-resource">+ Add knowledge</button>
  </div>

  <!-- Tools & MCP (separate — call-out capability, not reference) -->
  <div class="ab-form-field">
    <label>Tools & MCP</label>
    <div class="ab-resource-list">
      <div class="ab-resource-card">
        <span class="ab-resource-glyph is-tool"><svg>…wrench</svg></span>
        <div class="ab-resource-body">
          <span class="ab-resource-name">Tyrnell inventory MCP</span>
          <span class="ab-resource-meta">
            <span class="ab-resource-type is-tool">MCP</span>
            <span>3 tools · check_stock · check_availability · …</span>
          </span>
        </div>
      </div>
    </div>
    <button class="ab-add-resource">+ Add MCP or AI tool</button>
  </div>

  <!-- Always-on context (was: Priority Knowledge) -->
  <div class="ab-form-field">
    <label>Always-on context</label>
    <p>Free-form context the agent treats as known from session start.</p>
    <textarea rows="6">### Previously Purchased Wines:…</textarea>
  </div>
</div>
/* New chapter-private chrome for the unified knowledge model:

   .ab-resource-list      sortable vertical stack
   .ab-resource-card      handle / glyph / body / menu — same sortable
                          contract as fact cards (18.11) and rule
                          cards (18.13); 4th consumer of the helper
   .ab-resource-glyph     32×32 chip; .is-playbook / .is-collection /
                          .is-qa / .is-snippet / .is-tool drive tone
                          (each picks a colour family from the
                          conic-gradient brand palette — accent /
                          blue / green / amber / purple)
   .ab-resource-type      9px mono uppercase pill; same colour family
                          as the glyph for instant recognition
   .ab-resource-meta-sub  mono caption inline with the type chip
   .ab-add-resource       dashed-accent button (fills on hover)
   .ab-info-grid          2-col grid for the company info block
   .ab-resources-caption  italicised explainer line above each list. */
import {
  Input,
  Textarea,
  Button,
  SortableList,
  BookIcon,
  FileTextIcon,
  HelpBubble,
  FlaskIcon,
} from "@magicblocksai/ui";

// One row shape, multiple resource types — the type field drives the
// glyph colour family + the chip label. No more "is this a playbook
// or a collection?" branching in the UI.
const RESOURCE_TYPES = {
  playbook:   { label: 'Playbook',   tone: 'green',  icon: BookIcon },
  collection: { label: 'Collection', tone: 'blue',   icon: FileTextIcon },
  qa:         { label: 'Q&A',        tone: 'amber',  icon: HelpBubble },
  snippet:    { label: 'Snippet pack', tone: 'accent', icon: QuoteIcon },
  tool:       { label: 'MCP',        tone: 'purple', icon: FlaskIcon },
};

export function KnowledgePage({ agent, resources, tools, onReorder }) {
  return (
    <div className="ab-pane-form">
      <header className="ab-form-title-row">
        <h2>Knowledge</h2>
        <span className="ab-mode-chip">Basic</span>
      </header>

      <CompanyInfoCard agent={agent} />

      <Field label="Knowledge resources"
             caption="Drag to reorder — top is consulted first.">
        <SortableList items={resources} onReorder={onReorder}
                      className="ab-resource-list">
          {(r) => <ResourceCard resource={r} />}
        </SortableList>
        <Button variant="ghost-dashed">+ Add knowledge</Button>
      </Field>

      <Field label="Tools & MCP"
             caption="Call-out capability — agent runs these mid-chat.">
        <SortableList items={tools} onReorder={…}
                      className="ab-resource-list">
          {(t) => <ResourceCard resource={t} />}
        </SortableList>
        <Button variant="ghost-dashed">+ Add MCP or AI tool</Button>
      </Field>

      <Field label="Always-on context"
             caption="Loaded into the prompt every turn.">
        <Textarea value={agent.alwaysOnContext}
                  maxLength={5000} showCount rows={6} />
      </Field>
    </div>
  );
}

Add knowledge — four resource types

.ab-knowledge-chooser-grid

Opens via “+ Add knowledge”. Four tiles, one per resource type the agent can reference. Each tile communicates what the type IS in a sentence so the operator picks the right shape from the start — no more “wait, which surface do I use for this?”. Picking any tile opens the per-library picker for that type.

<div class="ab-dialog-overlay" role="dialog">
  <div class="ab-dialog">
    <header class="ab-dialog-head">
      <span class="ab-dialog-title">Add knowledge</span>
      <button class="ab-dialog-close">×</button>
    </header>
    <div class="ab-dialog-body">
      <p>Pick the kind of knowledge to attach…</p>
      <div class="ab-knowledge-chooser-grid">
        <button class="ab-knowledge-chooser-tile">
          <span class="ab-knowledge-chooser-glyph is-playbook"><svg>…</svg></span>
          <div>
            <span class="ab-knowledge-chooser-name">Sales Playbook</span>
            <span class="ab-knowledge-chooser-sub">A structured doc…</span>
          </div>
        </button>
        <!-- …Collection / Q&A / Snippet Pack tiles… -->
      </div>
    </div>
  </div>
</div>
/* Chooser dialog reuses the .ab-dialog primitives from earlier
   sections. New: a 2-col grid of "type tile" buttons.

   .ab-knowledge-chooser-grid    2-col grid for 4 tiles
   .ab-knowledge-chooser-tile    icon-left, body-right layout (faster
                                 to scan than the chooser-card pattern
                                 from 18.11 because there are FOUR
                                 options here, not two)
   .ab-knowledge-chooser-glyph   36×36 paper-bg square, type-coloured
                                 .is-playbook / .is-collection / .is-qa /
                                 .is-snippet — same colour family as
                                 the resource-card glyphs (consistency
                                 across the surface). */
import { Modal } from "@magicblocksai/ui";

const ADD_KNOWLEDGE_TILES = [
  { type: 'playbook',   name: 'Sales Playbook',
    sub: 'A structured doc telling the agent how to converse.' },
  { type: 'collection', name: 'Knowledge Collection',
    sub: 'Unstructured docs the agent looks up to answer questions.' },
  { type: 'qa',         name: 'Q&A Set',
    sub: 'Curated question-answer pairs the agent matches first.' },
  { type: 'snippet',    name: 'Snippet Pack',
    sub: 'Reusable text fragments for replies.' },
];

export function AddKnowledgeChooser({ open, onClose, onPick }) {
  return (
    <Modal open={open} onOpenChange={onClose} title="Add knowledge">
      <p>Pick the kind of knowledge to attach…</p>
      <div className="ab-knowledge-chooser-grid">
        {ADD_KNOWLEDGE_TILES.map((tile) => (
          <ChooserTile key={tile.type} {...tile} onPick={onPick} />
        ))}
      </div>
    </Modal>
  );
}

18.15 Contact Transfer

How the agent hands a contact off to a real system — CRM, inbox, Zapier flow, custom webhook. Each handover is a destination + trigger + payload bundle: pick where the data goes (Email / HighLevel / HubSpot / Zapier / Webhook), when to send it (Session End or Goal), and what to map across. Two demos: the configured-handovers list (sortable, reuses the helper from 18.7 / 18.11 / 18.13 / 18.14) and the Create Handover dialog with the rich HubSpot tab showing the kit's 18.3 FieldMapper composition + live payload preview.

Configured handovers — sortable list

.ab-handover-list · .ab-handover-card

Each card carries the destination glyph + name, the trigger badge (Session End vs Goal), and an On/Off status dot. Drag-reorder controls fire order when more than one handover triggers on the same event. The yellow Active toggle (configured per-destination) gates whether a handover actually fires — useful for staging a draft before going live.

Contact Transfer

Advanced ?

Send contact data into your systems via email for contactable sessions (with phone or email), or use webhooks and Zapier for all sessions. More on handovers ›

Drag to reorder when more than one handover triggers on the same event — top of the list fires first.

Notify Sales — New Tyrnell Lead Goal · Lead Captured [email protected] · +2 more Transcript on
Active
Push Deal → HubSpot Sales Pipeline Goal · Lead Captured Pipeline: Sales Stage: Qualified Lead
Active
Internal logging hook Session End POST https://api.charlie.co/leads
Off
<div class="ab-pane-form">
  <h2>Contact Transfer</h2>

  <label>Handovers <b>Total 3 · 2 active</b></label>
  <p>Drag to reorder — top fires first.</p>

  <div class="ab-handover-list">
    <div class="ab-handover-card" tabindex="0">
      <span class="ab-handover-handle"><svg>…</svg></span>
      <span class="ab-handover-glyph is-email"><svg>…</svg></span>
      <div class="ab-handover-body">
        <span class="ab-handover-name">Notify Sales — New Tyrnell Lead</span>
        <span class="ab-handover-meta">
          <span class="ab-handover-trigger is-goal">Goal · Lead Captured</span>
          <span>[email protected] · +2 more</span>
          <span>Transcript on</span>
        </span>
      </div>
      <span class="ab-handover-status">
        <span class="ab-handover-status-dot"></span> Active
      </span>
      <button class="ab-handover-menu">…</button>
    </div>
    <!-- …more handover cards — HubSpot deal-push, internal webhook…  -->
  </div>

  <button class="ab-add-resource">+ New handover</button>
</div>
/* Each card: handle / 40px glyph / body / status / menu.
   Glyph fill colour family per destination type:
     .is-email     blue   #2D6FE3
     .is-hubspot   orange #E15B3D
     .is-webhook   purple #6F4AD9
     .is-zapier    red    #E04400
     .is-highlevel green  #0F8062
   Trigger chip: .is-goal green, default neutral.
   Sortable contract: same .is-dragging / .is-drop-before /
   .is-drop-after as fact cards / rule cards / resource cards. */
import {
  SortableList,
  Button,
  MailIcon,
  GlobeIcon,
} from "@magicblocksai/ui";

const DESTINATION_TYPES = {
  email:     { tone: 'blue',   icon: MailIcon,         label: 'Email' },
  hubspot:   { tone: 'orange', icon: HubspotMarkIcon,  label: 'HubSpot' },
  webhook:   { tone: 'purple', icon: WebhookIcon,      label: 'Webhook' },
  zapier:    { tone: 'red',    icon: ZapierIcon,       label: 'Zapier' },
  highlevel: { tone: 'green',  icon: HighLevelIcon,    label: 'HighLevel' },
};

export function HandoversList({ handovers, onReorder, onEdit, onNew }) {
  return (
    <Field label={`Handovers (${handovers.length} · ${activeCount} active)`}>
      <SortableList items={handovers} onReorder={onReorder}
                    className="ab-handover-list">
        {(h) => <HandoverCard handover={h} onEdit={() => onEdit(h)} />}
      </SortableList>
      <Button variant="ghost-dashed" onClick={onNew}>+ New handover</Button>
    </Field>
  );
}

Create Handover — HubSpot with deal creation

.ab-dest-tabs · .ab-mapper-split · .ab-payload-preview

The richest of the five destination tabs. Three things at once: the destination tabs row with configured-dot indicators, the deal-creation config (pipeline / stage / deal-name rule), and the two-column fields mapper + live payload preview. Operators can see the literal JSON payload before saving — the FUTURE IDEAS “payload preview kills the why-didn't-my-CRM-get-the-right-data support load” principle applied here. Other tabs (Email / Webhook / Zapier / HighLevel) reuse the same dialog shell with their own per-destination config below.

<div class="ab-dialog-overlay" role="dialog">
  <div class="ab-dialog">
    <header class="ab-dialog-head">Create Handover</header>

    <div class="ab-dialog-body">
      <!-- Shared trigger config -->
      <div class="ab-dialog-row-2">
        <Field label="Choose when to transfer">Goal | Session End</Field>
        <Field label="Select from Goal">Lead Captured</Field>
      </div>
      <Field label="Min user messages">1</Field>

      <!-- Destination tabs with .is-configured dot indicator -->
      <nav class="ab-dest-tabs">
        <button class="ab-dest-tab is-email is-configured">Email</button>
        <button class="ab-dest-tab is-highlevel">HighLevel</button>
        <button class="ab-dest-tab is-hubspot is-active is-configured">HubSpot</button>
        <button class="ab-dest-tab is-zapier">Zapier</button>
        <button class="ab-dest-tab is-webhook is-configured">Webhook</button>
      </nav>

      <!-- HubSpot config -->
      <div class="ab-warning-banner">Contactable-session gate…</div>
      <label>Active toggle</label>
      <Field label="Pick connected HubSpot account">…</Field>
      <label>Enable deals creation toggle</label>
      <Field label="Deal name">Tyrnell wine lead — {{user.name}}</Field>
      <div class="ab-dialog-row-2">
        <Field label="Pipeline">Sales</Field>
        <Field label="Deal stage">Qualified Lead</Field>
      </div>

      <!-- Two-column: mapping ← → payload preview -->
      <div class="ab-mapper-split">
        <section class="ab-mapper-side">
          <div>Fields mapping</div>
          <div class="ab-mapper-rows">
            <!-- field-mapper row: source select → target select × remove -->
          </div>
        </section>
        <section>
          <div>Payload preview</div>
          <div class="ab-payload-preview">
            <div class="ab-payload-row">
              <span class="ab-payload-key">phone</span>
              <span class="ab-payload-val">"+61 480 022 801"</span>
            </div>
            <!-- …more rows… -->
          </div>
        </section>
      </div>
    </div>

    <footer class="ab-dialog-foot">
      <button>Cancel</button>
      <button class="is-primary">Create</button>
    </footer>
  </div>
</div>
/* Destination tabs are a horizontal pill row with an active state
   that uses .is-active + per-type colour applied to the glyph. The
   .is-configured class adds a small green dot in the top-right of
   the tab to communicate "this destination has a config saved" —
   so operators can flip between tabs and see at a glance which
   have data without having to open each one.

   The .ab-mapper-split is a 2-col grid (1fr / 1fr) that pairs the
   FieldMapper rows with the live JSON preview. The preview uses
   .ab-payload-row (mono key/value pairs with the value tinted to
   the kit's accent) so the payload reads as "code-like" without
   needing a full CodeBlock primitive. */
import {
  Modal,
  Tabs,
  Select,
  Input,
  Switch,
  Button,
  Alert,
  FieldMapper,
} from "@magicblocksai/ui";  // FieldMapper ships in kit chapter 18.3

export function CreateHandoverDialog({ open, onClose, onCreate }) {
  const [dest, setDest] = useState('hubspot');
  const [draft, setDraft] = useState(INITIAL);
  return (
    <Modal open={open} onOpenChange={onClose} title="Create Handover">
      {/* Shared trigger config */}
      <div className="ab-dialog-row-2">
        <Field label="Choose when to transfer">
          <Select value={draft.trigger} options={['Goal', 'Session End']} />
        </Field>
        {draft.trigger === 'Goal' && (
          <Field label="Select from Goal">
            <Select value={draft.goalId} options={goals} />
          </Field>
        )}
      </div>

      {/* Destination-type tabs */}
      <Tabs value={dest} onValueChange={setDest} items={[
        { id: 'email',     label: 'Email',     configured: draft.email.set },
        { id: 'highlevel', label: 'HighLevel', configured: draft.hl.set },
        { id: 'hubspot',   label: 'HubSpot',   configured: draft.hs.set },
        { id: 'zapier',    label: 'Zapier',    configured: draft.zap.set },
        { id: 'webhook',   label: 'Webhook',   configured: draft.hook.set },
      ]} />

      {dest === 'hubspot' && <HubSpotConfig draft={draft.hs} onChange={…} />}
      {/* …per-destination panels…  */}

      <Modal.Foot>
        <Button onClick={onClose}>Cancel</Button>
        <Button tone="accent" onClick={() => onCreate(draft)}>Create</Button>
      </Modal.Foot>
    </Modal>
  );
}

function HubSpotConfig({ draft, onChange }) {
  return (
    <>
      <Alert tone="warning">Contactable-session gate…</Alert>
      <Switch checked={draft.active} label="Active" />
      <Field label="Pick connected HubSpot account *">
        <Select value={draft.accountId} options={hubspotAccounts} />
      </Field>
      <Switch checked={draft.dealsCreation} label="Enable deals creation" />
      {draft.dealsCreation && (
        <>
          <Field label="Deal name *">
            <Input value={draft.dealName} />
          </Field>
          <div className="ab-dialog-row-2">
            <Field label="Pipeline *">
              <Select value={draft.pipelineId} options={pipelines} />
            </Field>
            <Field label="Deal stage *">
              <Select value={draft.stageId} options={stages} />
            </Field>
          </div>
        </>
      )}
      <div className="ab-mapper-split">
        <FieldMapper                              {/* kit 18.3 */}
          left={magicblocksFields}
          right={hubspotFields}
          value={draft.mappings}
          onChange={…} />
        <PayloadPreview mappings={draft.mappings} />
      </div>
    </>
  );
}

18.16 Channels

The seventh screen under Generalwhere the agent can be reached. Per Jay's “feel very omnichannel, just turn it on” direction, every channel is a row with a pill toggle and the per-channel config expands inline below the toggle when it's on. The production today only ships SMS via Twilio; this design widens the surface to nine channel types — with two new ones Jay called out: Form (the agent kicks off when someone submits a hosted form) and Email (contacts reach the agent at a dedicated inbox address). Operators see every channel they could possibly turn on, can see at a glance which are live, and never leave the page to wire one up.

Channels — turn on what you need

.ab-channel-list · .ab-channel-row

Four channels on with their config visible, five off and collapsed to the row head. The omnichannel banner at the top of the page is the framing device — it makes the “your agent is reachable on n channels” story the headline, not buried in a settings dropdown. Per-channel glyph + chip colour are consistent with the chips on contact-detail / agent-card / handovers (SMS green, Email blue, Form purple, WhatsApp green-bright, Voice amber, etc.) so an operator who's seen any other surface in the platform recognises the channel instantly here.

Channels

Basic ?

Everywhere this agent can be reached. Toggle a channel on, configure it inline, then publish. More on channels ›

Charlie is reachable on 4 of 9 channels
2,341 conversations across all channels this month · web chat carries 68% of the volume.
Web Chat Live Embedded chat widget on tyrnells.com.au. Always-on, hits the widget at the bottom-right.
1,589conv / mo
Live on tyrnells.com.au ·
Appearance Tyrnell Brand — v3 ·
Start block Hook
Open behaviour Proactive after 8s
Last conversation 2m ago
SMS Live Inbound + outbound text. Connected via Twilio · Telnyx also supported.
412conv / mo
Connected number +61 480 022 801
Provider Twilio — Tyrnell Production ·
Inbound start block Hook
TCPA quiet hours 9am — 9pm AEST
Last message 14m ago
Email Live Contacts reach the agent at a dedicated inbox address. Forwards from your existing aliases route here too.
187threads / mo
Agent inbox [email protected] ·
Forwarding from [email protected], [email protected] ·
Conversation mode Threaded — each subject keeps its own thread
Auto-reply latency Send within 60s of receiving
Last email 38m ago
Form Live When someone submits a connected form, the agent picks up the lead and starts a conversation. Web embed or standalone URL.
153conv / mo
Connected forms Tyrnell Wine Club signup ·
Trigger On submission · agent opens chat in-page
Start block Pitch Wines
Fields → Key Facts 5 mapped ·
Last submission 6m ago
WhatsApp Off WhatsApp Business API. Pairs naturally with SMS for international leads.
Voice Private beta Inbound + outbound voice via the agent's persona. Real-time, sub-300ms latency.
API / Webhook Off Bring your own client. POST messages in, stream replies out. For custom apps + headless contexts.
Instagram DM Off DMs to @tyrnells. Requires a connected Meta Business account.
Messenger Off Facebook Messenger inbound. Requires a connected Meta Business account.
<div class="ab-pane-form">
  <h2>Channels</h2>

  <!-- Omnichannel summary banner — top -->
  <div class="ab-omnichannel-banner">
    <div>
      <div class="ab-omnichannel-headline">Charlie is reachable on <em>4 of 9 channels</em></div>
      <div class="ab-omnichannel-sub">2,341 conversations across all channels this month…</div>
    </div>
    <div class="ab-omnichannel-strip">
      <span class="ab-omnichannel-chip is-on"><svg>…web chat</svg></span>
      <span class="ab-omnichannel-chip is-on"><svg>…SMS</svg></span>
      <!-- …7 more chips… -->
    </div>
  </div>

  <!-- Channel rows -->
  <div class="ab-channel-list">
    <div class="ab-channel-row is-on">
      <div class="ab-channel-head">
        <span class="ab-channel-glyph is-web"><svg>…</svg></span>
        <div class="ab-channel-body">
          <span class="ab-channel-name">Web Chat <span class="ab-channel-name-meta">Live</span></span>
          <span class="ab-channel-sub">Embedded chat widget…</span>
        </span>
        <span class="ab-channel-stats"><strong>1,589</strong> conv / mo</span>
        <button class="ab-channel-toggle" aria-pressed="true"></button>
      </div>
      <div class="ab-channel-config">
        <div class="ab-channel-config-grid">
          <!-- 2x2 grid of label + value pairs -->
          <div class="ab-channel-config-field">
            <span class="ab-channel-config-label">Live on</span>
            <span class="ab-channel-config-value"><code>tyrnells.com.au</code></span>
          </div>
          <!-- …more fields… -->
          <div class="ab-channel-config-foot">
            <span>Last conversation 2m ago</span>
            <button>Test in browser</button>
            <button>Copy embed snippet</button>
          </div>
        </div>
      </div>
    </div>

    <!-- …more channel rows… off ones collapse to .ab-channel-head only -->
    <div class="ab-channel-row">
      <div class="ab-channel-head">
        <span class="ab-channel-glyph is-whatsapp"><svg>…</svg></span>
        <div class="ab-channel-body">
          <span class="ab-channel-name">WhatsApp <span class="ab-channel-name-meta">Off</span></span>
          <span class="ab-channel-sub">WhatsApp Business API…</span>
        </span>
        <span class="ab-channel-stats"></span>
        <button class="ab-channel-toggle" aria-pressed="false"></button>
      </div>
    </div>
  </div>
</div>
/* "Just turn it on" omnichannel pattern. Per-channel colour family
   on the glyph: Web Chat = accent (pink), SMS = green, WhatsApp =
   brighter green, Email = blue, Form = purple, Voice = amber,
   Instagram = pink-red, Messenger = blue. Same colours show on the
   .ab-omnichannel-chip summary strip + on the channel row glyph so
   one mental map carries across.

   .ab-channel-row.is-on    accent-tinted bg + visible .ab-channel-config
   .ab-channel-config       slot for per-channel config (2x2 grid)
   .ab-channel-toggle       48x26 pill, success-green when on,
                            "the toggle is the primary action" sizing.

   The summary banner up top is the omnichannel framing device —
   "your agent is reachable on N channels" reads first, settings
   follow. */
import {
  Switch,
  Button,
  MessageSquareIcon,
  SmartphoneIcon,
  MailIcon,
  PhoneIcon,
} from "@magicblocksai/ui";

const CHANNEL_TYPES = [
  { id: 'web',       label: 'Web Chat',       tone: 'accent',  icon: MessageSquareIcon },
  { id: 'sms',       label: 'SMS',            tone: 'green',   icon: SmartphoneIcon },
  { id: 'email',     label: 'Email',          tone: 'blue',    icon: MailIcon },
  { id: 'form',      label: 'Form',           tone: 'purple',  icon: FormIcon },
  { id: 'whatsapp',  label: 'WhatsApp',       tone: 'bright-green' },
  { id: 'voice',     label: 'Voice',          tone: 'amber',   icon: PhoneIcon },
  { id: 'api',       label: 'API / Webhook',  tone: 'neutral' },
  { id: 'instagram', label: 'Instagram DM',   tone: 'pink' },
  { id: 'messenger', label: 'Messenger',      tone: 'blue' },
];

export function ChannelsPage({ agent, channels, onToggle }) {
  return (
    <div className="ab-pane-form">
      <OmnichannelBanner agent={agent} channels={channels} />

      <div className="ab-channel-list">
        {CHANNEL_TYPES.map((type) => {
          const channel = channels[type.id];
          return (
            <ChannelRow
              key={type.id}
              type={type}
              channel={channel}
              onToggle={(on) => onToggle(type.id, on)}
            >
              {channel.on && <ChannelConfig type={type} channel={channel} />}
            </ChannelRow>
          );
        })}
      </div>
    </div>
  );
}

// Per-channel config component picks the right field set:
// Web → domain + appearance + start block + open behaviour
// SMS → number + provider + start block + TCPA quiet hours
// Email → inbox address + forwarding aliases + thread mode + auto-reply latency
// Form → connected forms + trigger + start block + field-to-fact mapping
// Voice → number + voice persona + max call duration + handoff number
// (etc.)

18.17 Design & Go Live

The eighth and final agent-builder screen — the ship-it gateway. Three bands top-to-bottom: (1) a preflight checklist that validates the agent is publishable (persona / facts / journey / guardrails / ≥1 channel), (2) the where-it-lives configuration (appearance / widget style / domains) paired with the embed snippet + live preview, (3) the publish action bar with version-aware CTA. The preflight + publish framing is the design addition over the production today (which has only the middle band, no preflight, no clear publish action) — operators see at a glance whether they're green-light or need to fix something before going live.

Design & Go Live — ready to ship

.ab-publish-preflight · .ab-publish-grid · .ab-publish-bar

All five preflight checks green, the configuration card carries the appearance picker + widget-style chooser + restricted-domains list, the embed card shows the JS snippet + a mini chat-widget preview pinned in the corner. The publish bar at the bottom is version-aware: the current draft is v4, the live version is v3 — one click promotes v4 to live.

Design & Go Live

Advanced ?

Run the preflight, configure where the agent lives, and publish. More on going live ›

Persona Set Charlie · v1 · Creativity 0.3
Key Facts 2 collected Wine Preferences · Wants Secret Specials
Journey 4 blocks Hook → Secret Deals → Pitch → Payment
Guardrails 12 rules Set: Winery House Rules
Channels 4 of 9 live Web Chat · SMS · Email · Form
Where it lives

Pick how the widget looks, which pages it shows on, and which conversation block it opens with.

tyrnells.com.au Verified
shop.tyrnells.com.au Verified
Code to embed paste before </body>
<!-- MagicBlocks · Charlie · Winery Example --> <script id="magicblocks-chatbot-script"> window.MAGICBLOCKS = { workspace: 'ws_charlie_co', experience: 'e_winery_v3', config: { style: 'standard' } }; // loader (function() { var s = document.createElement('script'); s.src = 'https://cdn.magicblocks.ai/widget.js'; s.async = true; document.body.appendChild(s); })(); </script>
Live preview · Tyrnell Brand v3
Hi! Looking for a wine to pair with dinner tonight?
Editing v4 draft All preflight checks green · v3 currently live
<div class="ab-pane-form">
  <h2>Design & Go Live</h2>

  <!-- 1) Preflight checklist — 5-up grid -->
  <div class="ab-publish-preflight">
    <div class="ab-publish-check is-ok">
      <span class="ab-publish-check-head">✓ Persona</span>
      <span class="ab-publish-check-value">Set</span>
      <span class="ab-publish-check-sub">Charlie · v1 · Creativity 0.3</span>
    </div>
    <!-- …4 more checks: Key Facts, Journey, Guardrails, Channels -->
  </div>

  <!-- 2) Config + embed grid -->
  <div class="ab-publish-grid">
    <div class="ab-publish-card">
      <h3>Where it lives</h3>
      <!-- Appearance picker (library-loaded — same .ab-set-picker
           shape as Persona / Guardrails / Knowledge) -->
      <!-- Widget style 2-up cards (Standard / Sidebar) -->
      <!-- Start block select -->
      <!-- Restrict domains list -->
    </div>

    <div class="ab-publish-card">
      <h3>Code to embed</h3>
      <div class="ab-code-block">
        <button class="ab-code-block-copy">Copy</button>
        <script id="magicblocks-chatbot-script">
          window.MAGICBLOCKS = { workspace: 'ws_…', experience: 'e_…' };
          // …loader
        </script>
      </div>
      <!-- Live preview — mini widget pinned bottom-right -->
      <div class="ab-widget-preview">
        <div class="ab-widget-preview-bubble">Hi! Looking for a wine…</div>
        <span class="ab-widget-preview-launcher"><svg></svg></span>
      </div>
    </div>
  </div>

  <!-- 3) Publish bar -->
  <div class="ab-publish-bar">
    <span>Editing <b>v4 draft</b></span>
    <span>● All preflight green · v3 currently live</span>
    <button class="ab-publish-bar-secondary">Save draft</button>
    <button class="ab-publish-bar-primary">⤴ Publish v4</button>
  </div>
</div>
/* The preflight is the design win on this page. Each check is one
   card with 4 visual states:
     .is-ok      success-soft bg + success-text head — green check
     .is-warn    warning-soft bg + warning-text head — yellow !
     .is-block   accent-soft bg + accent head        — pink × ("can't publish")
     (neutral)   paper bg + faint head               — not configured

   Two-column .ab-publish-grid for the config / embed pairing.
   .ab-code-block uses ink background with paper-on-ink syntax colours
   (gold keywords, mint strings, faint comments) — matches the kit's
   codeblk family.

   .ab-widget-preview is a hatched-bg "page" with the launcher bubble
   + proactive-message bubble pinned bottom-right, mirroring the
   actual widget chrome from chapter 17.

   The publish bar sits below the grid as a sticky-feeling action
   row: version meta + status + Save-draft + big green Publish CTA. */
import {
  Button,
  Select,
  Input,
  CodeBlock,
  CopyButton,
  CheckIcon,
  RocketIcon,
} from "@magicblocksai/ui";

const PREFLIGHT_CHECKS = [
  { id: 'persona',    label: 'Persona',    check: agent => !!agent.personaId },
  { id: 'facts',      label: 'Key Facts',  check: agent => agent.facts.length > 0 },
  { id: 'journey',    label: 'Journey',    check: agent => agent.blocks.length > 0 },
  { id: 'guardrails', label: 'Guardrails', check: agent => !!agent.guardrailSetId },
  { id: 'channels',   label: 'Channels',   check: agent => activeChannels(agent).length > 0 },
];

export function DesignGoLivePage({ agent, onPublish, onSaveDraft }) {
  const checks = PREFLIGHT_CHECKS.map(c => ({ ...c, status: c.check(agent) ? 'ok' : 'block' }));
  const canPublish = checks.every(c => c.status === 'ok');
  const embed = useMemo(() => buildEmbedSnippet(agent), [agent]);

  return (
    <div className="ab-pane-form">
      <PreflightStrip checks={checks} />

      <div className="ab-publish-grid">
        <Card title="Where it lives">
          <AppearancePicker value={agent.appearanceId} />
          <WidgetStyleCards value={agent.widgetStyle} />
          <Select value={agent.startBlockId} />
          <DomainList domains={agent.allowedDomains} />
        </Card>

        <Card title="Code to embed">
          <CodeBlock language="html" trailing={<CopyButton value={embed} />}>
            {embed}
          </CodeBlock>
          <WidgetPreview agent={agent} />
        </Card>
      </div>

      <PublishBar
        version={agent.version}
        liveVersion={agent.liveVersion}
        canPublish={canPublish}
        onPublish={onPublish}
        onSaveDraft={onSaveDraft} />
    </div>
  );
}

18.18 Agent frame — header + tabs

The redesigned agent page frame. AgentBuilderHead carries identity (back · name · type · status · version) and the save/publish actions with a quiet “View conversations” link; directly beneath it sits a five-tab LinkTabs row — Overview · Journey · Knowledge · Channels · Settings. Unpublished agents open on Journey (the job is building); live agents open on Overview (the job is watching). The shared shelves — Personas, Snippets, Forms, Goals — live one level up on the Agents HQ (§18.19), so the per-agent row stays five tabs.

Header + tab row

.ab-builder-head · .tabs.ab-frame-tabs

A live website agent on its working copy — type + status chips beside the name, the version pill, then View conversations · Save · Publish. The Journey tab is active. Testing has no header link any more — it lives in the right dock beside Sage.

Acme AssistantwebsiteLiveDraft · working copy ▾
<header class="ab-builder-head">
  <div class="ab-builder-head-title">
    <button type="button" class="ab-builder-head-back" aria-label="Back to agents">‹</button>
    <span class="ab-builder-head-name">Acme Assistant<span class="chip">website</span><span class="chip chip-green">Live</span></span>
    <span class="ab-builder-head-meta"><span class="ab-version">Draft · working copy ▾</span></span>
  </div>
  <div class="ab-builder-head-actions">
    <button type="button" class="ab-h-pill">View conversations</button>
    <button type="button" class="ab-h-pill">Save</button>
    <button type="button" class="ab-h-pill is-primary">Publish</button>
  </div>
</header>
<nav class="tabs tabs-link ab-frame-tabs" aria-label="Agent sections">
  <a class="tab" href="#">Overview</a>
  <a class="tab active" aria-current="page" href="#">Journey</a>
  <a class="tab" href="#">Knowledge</a>
  <a class="tab" href="#">Channels</a>
  <a class="tab" href="#">Settings</a>
</nav>
/* Ships in @magicblocksai/css (components/_shared.css, @surface: operator).
   .ab-frame-tabs adds the paper background + hairline under the head. */
// No wiring needed — LinkTabs drives active state via the consumer's router
// (NavLink applies .active + aria-current="page" automatically).
import { AgentBuilderHead, LinkTabs } from '@magicblocksai/ui';

<>
  <AgentBuilderHead
    name="Acme Assistant"
    typeChip={<><span className="chip">website</span><span className="chip chip-green">Live</span></>}
    version="Draft · working copy ▾"
    onBack={() => nav('/agents')}
    actions={<>
      <button type="button" className="ab-h-pill">View conversations</button>
      <button type="button" className="ab-h-pill">Save</button>
      <button type="button" className="ab-h-pill is-primary">Publish</button>
    </>}
  />
  <LinkTabs
    ariaLabel="Agent sections"
    linkAs={NavLink}
    className="ab-frame-tabs"
    items={[
      { to: '?tab=overview', label: 'Overview' },
      { to: '?tab=journey',  label: 'Journey' },
      { to: '?tab=knowledge', label: 'Knowledge' },
      { to: '?tab=channels', label: 'Channels' },
      { to: '?tab=settings', label: 'Settings' },
    ]}
  />
</>

UsedByChip

.chip · <UsedByChip>

Usage indicator for the shared shelves — plural, singular, and the calm zero state. Blue while in use; neutral “Not used yet” so an unused row reads as an invitation, not an error.

Used by 4 agentsUsed by 1 agentNot used yet
<span class="chip chip-blue">Used by 4 agents</span>
<span class="chip chip-blue">Used by 1 agent</span>
<span class="chip">Not used yet</span>
/* Ships in @magicblocksai/css — reuses .chip + tone variants; no new CSS. */
// No wiring — the label and tone derive from the count.
import { UsedByChip } from '@magicblocksai/ui';

<UsedByChip count={4} />
<UsedByChip count={1} />
<UsedByChip count={0} />

SetupProgressChip

.chip · <SetupProgressChip>

The Overview status band’s setup chip — amber with the plain-language next step while incomplete, green “Setup complete” once every step is done.

Setup 5 of 6 — connect a phone numberSetup complete
<span class="chip chip-amber">Setup 5 of 6 — connect a phone number</span>
<span class="chip chip-green">Setup complete</span>
/* Ships in @magicblocksai/css — reuses .chip + tone variants; no new CSS. */
// No wiring — the label and tone derive from done / total.
import { SetupProgressChip } from '@magicblocksai/ui';

<SetupProgressChip done={5} total={6} nextStep="connect a phone number" />
<SetupProgressChip done={6} total={6} />

Overview cards

.golive-card · .recent-changes

GoLiveCard answers “how do people reach this agent, and how do I go live?” — channel rows with live / not-yet states and a deep link into the Channels tab. RecentChangesList is the accountability card: human and Sage edits to the working copy, timestamped, with Undo on Sage entries.

Where this agent talks
  • Website widgetLive on crefco.com
  • Phone & SMSNot connected yet
Recent changes
  • You added the key fact budget to Discoveryyesterday
  • Sage tightened the Greeting job wording2 days ago
<div class="golive-card">
  <div class="golive-head"><span class="golive-glyph">…rocket svg…</span><span class="golive-title">Where this agent talks</span></div>
  <ul class="golive-channels">
    <li class="golive-channel"><span class="golive-channel-icon">…</span><span class="golive-channel-name">Website widget</span><span class="golive-channel-state is-live">Live on crefco.com</span></li>
    <li class="golive-channel"><span class="golive-channel-icon">…</span><span class="golive-channel-name">Phone &amp; SMS</span><span class="golive-channel-state">Not connected yet</span></li>
  </ul>
  <div class="golive-action"><button class="btn btn-secondary btn-sm">Open Channels</button></div>
</div>

<div class="recent-changes">
  <span class="recent-changes-title">Recent changes</span>
  <ul class="recent-changes-list">
    <li class="recent-change"><span class="recent-change-glyph">…clock svg…</span><span class="recent-change-text">You added the key fact <code class="mono">budget</code> to Discovery</span><span class="recent-change-when">yesterday</span></li>
    <li class="recent-change"><span class="recent-change-glyph is-sage">…wand svg…</span><span class="recent-change-text">Sage tightened the Greeting job wording</span><span class="recent-change-when">2 days ago</span><button type="button" class="recent-change-undo">Undo</button></li>
  </ul>
</div>
/* Ships in @magicblocksai/css (components/_shared.css, @surface: operator) —
   .golive-* and .recent-change* under the 18.9 Agent overview home block. */
// No wiring needed — Undo is a real button; pass onUndo per change.
// Sage may only edit the working copy; publishing stays a human action.
import { GoLiveCard, RecentChangesList, Button, GlobeIcon, PhoneIcon } from '@magicblocksai/ui';

<GoLiveCard
  channels={[
    { icon: <GlobeIcon />, name: 'Website widget', state: 'Live on crefco.com', live: true },
    { icon: <PhoneIcon />, name: 'Phone & SMS', state: 'Not connected yet' },
  ]}
  action={<Button size="sm" variant="secondary">Open Channels</Button>}
/>

<RecentChangesList
  changes={[
    { text: <>You added the key fact <code className="mono">budget</code> to Discovery</>, when: 'yesterday' },
    { text: 'Sage tightened the Greeting job wording', when: '2 days ago', by: 'sage', onUndo: undo },
  ]}
/>

18.19 Agents HQ — shared shelves

The Agents page is the HQ: an All agents tab (the 18.8 list) plus the four shared shelves moved out of Library — Personas · Snippets · Forms · Goals. The shelves are workspace-shared: edit once, every agent that selects the item picks it up. Every row carries a UsedByChip so shared-ness is visible before you edit, and creating from inside an agent opens the same editor in a drawer and auto-selects on save — you are never bounced to another section mid-thought.

Personas shelf

.tabs.tabs-link · .data-table · <UsedByChip>

The HQ tab row with Personas active, then the shelf: name, description, usage, pinned version, last update. Agents pin a published persona version, so editing here never silently changes a live agent.

Name
Description
Used by
Version
Updated
Battery Persona
Warm, concise, signs off with a cheer
Used by 4 agents
v3
2 days ago
Clara — closer
Direct, numbers-first, books the meeting
Used by 1 agent
v5
last week
Spring promo voice
Seasonal campaign tone for outbound
Not used yet
v1
April
<nav class="tabs tabs-link" aria-label="Agents HQ">
  <a class="tab" href="#">All agents</a>
  <a class="tab active" aria-current="page" href="#">Personas</a>
  <a class="tab" href="#">Snippets</a>
  <a class="tab" href="#">Forms</a>
  <a class="tab" href="#">Goals</a>
</nav>
<div class="data-table">
  …header row: Name · Description · Used by · Version · Updated…
  …rows with <span class="chip chip-blue">Used by 4 agents</span> usage cells…
</div>
/* Ships in @magicblocksai/css — .tabs, .data-table and .chip are package classes. */
// No wiring — LinkTabs is router-driven; the table is the shipped DataTable.
import { LinkTabs, DataTable, UsedByChip } from '@magicblocksai/ui';

<LinkTabs ariaLabel="Agents HQ" linkAs={NavLink} items={[
  { to: '/agents', label: 'All agents' },
  { to: '/agents/personas', label: 'Personas' },
  { to: '/agents/snippets', label: 'Snippets' },
  { to: '/agents/forms', label: 'Forms' },
  { to: '/agents/goals', label: 'Goals' },
]} />
<DataTable
  rowId={(p) => p.id}
  columns={[
    { key: 'name', label: 'Name' },
    { key: 'description', label: 'Description' },
    { key: 'usedBy', label: 'Used by', width: '160px', render: (p) => <UsedByChip count={p.usedBy} /> },
    { key: 'version', label: 'Version', width: '80px' },
    { key: 'updated', label: 'Updated', width: '100px' },
  ]}
  rows={personas}
/>

Snippets shelf — the new entity

.data-table · .chip

A snippet is text you can paste anywhere — jobs, action messages, the persona role, priority knowledge — inserted as {{token}} from the {+} picker; variants rotate at runtime so campaigns never sound like a script.

Name
Token
Variants
Used by
Updated
Booking line
{{booking_line}}
3
Used by 2 agents
yesterday
Support number
{{support_number}}
1
Used by 4 agents
2 days ago
Legal footer
{{legal_footer}}
2
Not used yet
May
<div class="data-table">
  …header row: Name · Token · Variants · Used by · Updated…
  …token cells render as <code class="mono">{{booking_line}}</code>…
</div>
/* Ships in @magicblocksai/css — no new classes; tokens use .mono. */
// Variants rotate at runtime — the agent picks one per session, so the
// same snippet never reads identically across a campaign.
import { DataTable, UsedByChip } from '@magicblocksai/ui';

<DataTable
  rowId={(s) => s.id}
  columns={[
    { key: 'name', label: 'Name' },
    { key: 'token', label: 'Token', width: '190px', render: (s) => <code className="mono">{'{{' + s.token + '}}'}</code> },
    { key: 'variants', label: 'Variants', width: '100px' },
    { key: 'usedBy', label: 'Used by', width: '160px', render: (s) => <UsedByChip count={s.usedBy} /> },
    { key: 'updated', label: 'Updated', width: '100px' },
  ]}
  rows={snippets}
/>

Empty shelf

.empty · <EmptyState>

A shelf before its first item — an invitation, not a dead end. The same shipped EmptyState the rest of the app uses.

No snippets yet

Write a line once, reuse it anywhere the {+} picker appears — jobs, messages, the persona role, priority knowledge.

<div class="empty">
  <h3 class="empty-title">No snippets yet</h3>
  <p class="empty-lede">Write a line once, reuse it anywhere the {+} picker appears — jobs, messages, the persona role, priority knowledge.</p>
  <div class="empty-actions"><button type="button" class="btn btn-primary btn-sm">+ New snippet</button></div>
</div>
/* Ships in @magicblocksai/css — the shipped .empty block. */
// No wiring needed.
import { EmptyState, Button } from '@magicblocksai/ui';

<EmptyState
  title="No snippets yet"
  description="Write a line once, reuse it anywhere the {+} picker appears — jobs, messages, the persona role, priority knowledge."
  primaryAction={<Button size="sm">+ New snippet</Button>}
/>

18.20 Journey studio

The redesigned Journey tab — the studio where the block model becomes the hero without changing a single input. The rail is journey-only: a List ⇄ Map toggle, the blocks (start pill, stats, and a needs work coach on empty ones), then two pinned journey-level rows — Key facts and Anytime actions (the renamed Global actions: rules that watch every block). The map is a view, not an editor: solid edges are the curated local routes, dashed edges are the anytime layer, and a block’s card is the route into editing. Inside the block editor, LeadsToStrip and KeyFactBehaviourLine say the model’s two quiet superpowers out loud.

Journey rail — redesigned

.ab-rail-block · .ab-rail-item · start / hint

Blocks first (Greeting carries the start pill; the empty Refinance block reads “0 jobs · needs work” — a coach, not a gate), then Add block · Block library, a divider, and the two pinned journey-level rows. The List ⇄ Map toggle sits on top.

GreetingStart▤ 2⚡ 1
Discovery▤ 3⚡ 2
Refinance0 jobs · needs work

<!-- Blocks (RailBlockItem) — start pill on the entry block, hint on the empty one -->
<div class="ab-rail-block is-active">…<span class="ab-rail-block-name">Greeting</span><span class="ab-rail-block-start">Start</span>…</div>
<div class="ab-rail-block">…<span class="ab-rail-block-name">Refinance</span><span class="ab-rail-block-hint">0 jobs · needs work</span>…</div>
<!-- Then Add block · Block library, a divider, and the pinned journey rows -->
<button class="ab-rail-item">…Key facts<span class="ab-rail-count">7</span></button>
<button class="ab-rail-item">…Anytime actions<span class="ab-rail-count">24</span></button>
/* Ships in @magicblocksai/css — .ab-rail-block-start / .ab-rail-block-hint are new in 4.11.0. */
// Drag-reorder comes from wrapping the blocks in the shipped SortableList.
import { BuilderRail, RailBlockItem, RailItem, SortableList } from '@magicblocksai/ui';

<SortableList items={blocks} rowId={(b) => b.id} onReorder={reorder}
  renderRow={(b) => (
    <RailBlockItem
      name={b.name}
      start={b.id === startBlockId}
      active={b.id === currentId}
      keyFactCount={b.keyFacts.length}
      actionCount={b.actions.length}
      hint={b.jobs.length === 0 ? '0 jobs · needs work' : undefined}
      onSelect={() => select(b.id)}
    />
  )} />
<RailItem icon={<PlusIcon />}>Add block</RailItem>
<RailItem icon={<BookIcon />}>Block library</RailItem>
<RailItem icon={<ChecklistIcon />} count={7}>Key facts</RailItem>
<RailItem icon={<BoltIcon />} count={24}>Anytime actions</RailItem>

Map view

.journey-graph · edges[].variant="dashed"

The promoted flow map behind the toggle. Solid edges are the curated local routes; dashed edges are the anytime layer — live from every block (two representative routes drawn). Hover or focus any edge to read its full rule; the selected block’s card carries the only way in: Open editor →. The map is a view — all editing stays in the panes.

Once every key fact here is collectedfacts collectedWhen the visitor agrees to book a callready for a personAnytime action — when the visitor asks for a person, from any blockwants a humanAnytime action — the /restart command returns to the start block/restartGreetingDiscoveryHandoffOpener DemoRefinance

Greeting

Start

2 key facts · 1 action · leads to Discovery once every key fact here is collected.

── local routes  ·  ⋯ anytime actions

<!-- The shipped JourneyGraph SVG; dashed edges carry data-variant="dashed" -->
<div class="journey-graph">
  <svg class="journey-graph-svg" viewBox="0 0 600 300">
    <line class="journey-graph-edge" …/>
    <line class="journey-graph-edge" data-variant="dashed" …/>
    <g class="journey-node">…</g>
  </svg>
</div>
/* .journey-graph-edge[data-variant="dashed"] { stroke-dasharray: 5 4; opacity: .65; } — new in 4.11.0. */
// Toggle the anytime layer by filtering edges on variant before passing them.
import { JourneyGraph } from '@magicblocksai/ui';

<JourneyGraph
  viewBox="0 0 600 300"
  nodes={blocks.map((b) => ({ id: b.id, kind: 'tool', label: b.name, x: b.x, y: b.y }))}
  edges={[
    { from: 'greeting', to: 'discovery', label: 'facts collected', title: 'Once every key fact here is collected' },
    { from: 'opener', to: 'handoff', label: 'wants a human', title: 'Anytime action — from any block', variant: 'dashed' },
  ]}
  selectedNodeId={selected}
  onSelectedNodeChange={setSelected}
/>

LeadsToStrip

.ab-leads · <LeadsToStrip>

The block editor’s orientation strip — the transition rule as a plain sentence, with Reached from revealed on hover or keyboard focus. The second strip shows a block with no local route — never a dead end, and it says why.

Leads to→ Discoveryonce every key fact here is collected
Reached from2 anytime actions
Leads tono local route yet — anytime actions still apply
<div class="ab-leads" tabindex="0">
  <div class="ab-leads-row">
    <span class="ab-leads-label">Leads to</span>
    <span class="ab-leads-target">→ Discovery</span>
    <span class="ab-leads-rule">once every key fact here is collected</span>
    <span class="ab-leads-action"><button class="ab-h-pill">Edit transitions →</button></span>
  </div>
  <div class="ab-leads-reached"><span class="ab-leads-label">Reached from</span><span class="ab-leads-rule">2 anytime actions</span></div>
</div>
/* .ab-leads-reached is display:none until :hover / :focus-visible / :focus-within. */
// No wiring — the reveal is CSS-only; the strip is focusable when reachedFrom is set.
import { LeadsToStrip } from '@magicblocksai/ui';

<LeadsToStrip
  to="Discovery"
  rule="once every key fact here is collected"
  reachedFrom="2 anytime actions"
  action={<button type="button" className="ab-h-pill">Edit transitions →</button>}
/>
<LeadsToStrip />

KeyFactBehaviourLine

.kf-line · <KeyFactBehaviourLine>

Asked vs listened, said out loud. Pinning a fact to a block as a job is what makes it asked; listenAcrossAllBlocks is what makes it listened for everywhere. Ask-when rules read inline in the info tone.

Asked in Greetinglistened for everywhereNever askedlistened for everywhereAsked in Discoveryonly listened for in DiscoveryOnly asked when loan_intent is Purchase
<span class="kf-line">
  <span class="kf-line-asked">Asked in Greeting</span>
  <span class="kf-line-sep">·</span>
  <span class="kf-line-listened">listened for everywhere</span>
</span>
/* .kf-line-when wraps to its own line in the info tone. */
// No wiring — the sentence derives from askedIn / listensEverywhere.
import { KeyFactBehaviourLine } from '@magicblocksai/ui';

<KeyFactBehaviourLine askedIn={['Greeting']} listensEverywhere />
<KeyFactBehaviourLine listensEverywhere />
<KeyFactBehaviourLine askedIn={['Discovery']}
  onlyAskedWhen={<><code className="mono">loan_intent</code> is Purchase</>} />

Anytime actions — the renamed globals

.action-list · copy pattern

Global actions stop masquerading as a generic list. The pinned journey row opens this workbench under a head that says what they are: rules that watch every block. Rows keep their kind chips; the editor is the same three-step wizard (18.12). Shown as a three-row excerpt of the 24.

<aside class="action-list" aria-label="Anytime actions list">
  <div class="action-list-head"><span>Anytime actions <span class="action-list-count">24</span></span>…</div>
  <p>Rules that watch every block — they can move the conversation from anywhere.</p>
  <button class="action-row has-meta">…</button>
</aside>
/* Ships in @magicblocksai/css — the 18.12 .action-list / .action-row classes, retitled. */
// Same two-pane workbench + three-step wizard as 18.12 — only the framing changes.
import { ActionList } from '@magicblocksai/ui';

<ActionList
  title="Anytime actions"
  description="Rules that watch every block — they can move the conversation from anywhere."
  items={anytimeActions} /* { id, name, meta?, scope? } */
  selectedId={selected}
  onSelect={setSelected}
  onAddAction={addAnytimeAction}
/>

18.21 Templates

Templates at both levels, both copy-on-create — instantiating one never links back to the original. Agent templates: “+ New agent” opens the creation sheet with the MagicBlocks starter set, the workspace’s own templates (“Save as template” in the agent header ⋯ menu), and Start blank. Block templates: the journey rail’s Block library (18.20) opens a drawer of pre-wired starter blocks plus blocks saved via “Save to block library” on any block’s menu.

TemplateCard

.template-card · <TemplateCard>

Selected (accent ring), default with the “Yours” badge, and the dashed Start-blank card. The journey shape reads as a mini block chain; the meta line says what the template ships with.

Mortgage lead qualifierMagicBlocks

Qualifies, answers objections, books a call.

GreetingQualifyPitchBooking
6 key facts · 2 goals
Acme Assistant copyYours

Saved from your live agent last week.

GreetingDiscoveryHandoff
7 key facts · 1 goal
Start blank
<div class="template-card is-selected" role="button" tabindex="0">
  <div class="template-card-head">
    <span class="template-card-title">Mortgage lead qualifier</span>
    <span class="template-card-badge">MagicBlocks</span>
  </div>
  <p class="template-card-desc">Qualifies, answers objections, books a call.</p>
  <div class="template-card-shape"><span class="template-card-block">Greeting<span class="template-card-arrow">→</span></span>…</div>
  <span class="template-card-meta">6 key facts · 2 goals</span>
</div>
<div class="template-card is-blank" role="button" tabindex="0">…Start blank…</div>
/* Ships in @magicblocksai/css (@surface: operator) — .template-card + is-selected /
   is-blank, head / badge / desc / shape / block / arrow / meta. */
// Selection is consumer state; Enter / Space select like any kit row.
import { TemplateCard } from '@magicblocksai/ui';

<TemplateCard
  title="Mortgage lead qualifier"
  description="Qualifies, answers objections, books a call."
  blocks={['Greeting', 'Qualify', 'Pitch', 'Booking']}
  meta="6 key facts · 2 goals"
  badge="MagicBlocks"
  selected
  onSelect={pick}
/>
<TemplateCard title="Start blank" blank onSelect={pickBlank} />

New agent — start from a template

.template-gallery · <TemplateGallery>

The creation sheet: two MagicBlocks starters, a saved workspace template, and Start blank. Picking one and confirming clones its journey, key facts and goals into a fresh draft — unattachable references drop with a needs-attention hint on the new agent’s setup checklist.

<div class="template-gallery" aria-label="Agent templates">
  <div class="template-card is-selected">…</div>
  <div class="template-card">…</div>
  <div class="template-card">…</div>
  <div class="template-card is-blank">…Start blank…</div>
</div>
/* .template-gallery — repeat(auto-fill, minmax(220px, 1fr)) grid. */
// Copy-on-create: confirming clones the workflow; the template never updates.
import { TemplateGallery, TemplateCard } from '@magicblocksai/ui';

<TemplateGallery aria-label="Agent templates">
  {templates.map((t) => (
    <TemplateCard key={t.id} title={t.title} description={t.pitch}
      blocks={t.blocks} meta={t.meta} badge={t.source}
      selected={t.id === selectedId} onSelect={() => setSelectedId(t.id)} />
  ))}
  <TemplateCard title="Start blank" blank onSelect={startBlank} />
</TemplateGallery>

Block library

.block-tpl-row · <BlockLibraryDrawer>

The drawer’s panel content (in production it portal-mounts via Drawer from the journey rail’s Block library link, 18.20). Each starter ships pre-wired jobs, facts and a transition placeholder; Insert copies it into the journey.

MagicBlocks blocks
Greeting1 job · 2 key facts · transition placeholder
Qualify3 jobs · 3 key facts · transition placeholder
Objection handling2 jobs · 1 key fact · transition placeholder
Pricing2 jobs · transition placeholder
Booking1 job · 1 key fact · calendar action stub
Re-engage1 job · idle-nudge anytime action
Human handover1 job · takeover action stub
Your blocks
Warm closeSaved from Acme Assistant
Secret dealsSaved from Winery Example
<div class="block-tpl-list">
  <div class="block-tpl-group">
    <span class="block-tpl-group-label">MagicBlocks blocks</span>
    <div class="block-tpl-row">
      <span class="block-tpl-text"><span class="block-tpl-name">Qualify</span><span class="block-tpl-sum">3 jobs · 3 key facts · transition placeholder</span></span>
      <button class="block-tpl-insert">Insert</button>
    </div>
  </div>
</div>
/* Ships in @magicblocksai/css — .block-tpl-list / -group / -group-label / -row /
   -text / -name / -sum / -insert. */
// Insert copies the block — nothing is shared by reference, so editing an
// inserted block never haunts another journey.
import { BlockLibraryDrawer } from '@magicblocksai/ui';

<BlockLibraryDrawer
  open={open}
  onOpenChange={setOpen}
  templates={[
    { id: 'qualify', name: 'Qualify', summary: '3 jobs · 3 key facts · transition placeholder' },
    { id: 'warm-close', name: 'Warm close', summary: 'Saved from Acme Assistant', source: 'yours' },
  ]}
  onInsert={(id) => insertBlock(id)}
/>

18.22 Channels tab

“Where it talks, and where it hands off” — one tab merging the old General → Channels (18.16), Design & Go Live (18.17) and Contact Transfer (18.15). Three sections: Website widget (appearance, style, domains, embed snippet), Phone & SMS (connected numbers), Hand off to your team (triggers, destinations, field mapping with live payload preview). Channels the agent’s type can’t use stay visible, greyed with a one-line reason — never hidden. The Overview go-live card (18.9) deep-links here.

Tab composition

.ab-ov-card ×3 · composition

A website agent: widget live, numbers empty, handoff wired to HubSpot. The greyed Email row demonstrates the unavailable-channel pattern. Full editing surfaces are the existing 18.15–18.17 demos — this tab is their new shared home.

Website widget

Live on crefco.com

CREFCO appearance · Standard style · 2 allowed domains · opens from the agent’s start block

<script id="magicblocks-chatbot-script">… — copy the embed snippet (18.17)

Phone & SMS

Not connected yet

Connect a Twilio or Telnyx account to pick a number for this agent (18.16).

Hand off to your team

1 handover active

On goal Lead captured → HubSpot (deal created in Sales pipeline) · payload preview before save (18.15)

Email

Not available yet

Channels an agent’s type can’t use stay visible with a reason — never hidden.

<!-- Three .ab-ov-card sections + the greyed unavailable channel -->
<section class="ab-ov-card">…Website widget…</section>
<section class="ab-ov-card">…Phone & SMS…</section>
<section class="ab-ov-card">…Hand off to your team…</section>
<section class="ab-ov-card" style="opacity:.55">…Email — Not available yet…</section>
/* Reuses the shipped .ab-ov-card chrome; no new classes. */
// Section editors are the existing 18.15–18.17 surfaces, mounted per section.
// Composition — the tab stacks the existing editors:
// WidgetStyleEditor + WidgetEmbedSnippet (ch 25), the numbers list (18.16),
// and the handover editor with FieldMapper + PayloadPreview (18.15).

18.23 Settings tab

Everything agent-level that isn’t journey, knowledge or channels — in a deliberate order. Guardrails first (policy + extra rules + the five safety controls, 18.13), surfaced by the Overview card so it’s never buried; then conversation defaults; then about; then the danger zone. Duplicate copies the agent as a new draft — templates (18.21) are the deliberate sharing path.

Tab composition

.ab-ov-card · .danger-zone-block · composition

The order made visible. Full editing surfaces are the existing 18.13 demos for guardrails; conversation defaults keep every old-platform control (start block, primary goal, proactive open, starter questions, failure + end-of-chat messages, memory capture, end-after-inactivity).

Guardrails

✓ Monitor on

CREFCO policy + 2 extra rules · PII control, redaction, rules monitor, jailbreak prevention, moderation (18.13). Per-block overrides stay in each block’s Advanced tab.

Conversation defaults

Starts in Greeting · primary goal Lead captured · proactive open after 5s (desktop) · starter questions on · memory capture on · ends after 15 quiet minutes

About

B2B lead qualifier for inbound website visitors · tags: Sales agent, Mortgage

Danger zone

Pause agent

Stops new conversations; live ones finish gracefully.

Duplicate agent

Copies everything into a new unpublished draft.

Delete agent

Removes the agent and its draft. Sessions are kept.

<!-- Guardrails first, then defaults, about, danger zone -->
<section class="ab-ov-card">…Guardrails…</section>
<section class="ab-ov-card">…Conversation defaults…</section>
<section class="ab-ov-card">…About…</section>
<section class="danger-zone-block">…Pause · Duplicate · Delete…</section>
/* Reuses .ab-ov-card + the shipped .danger-zone-block family (15.15); no new classes. */
// Pause/Delete confirm before acting; Duplicate creates an unpublished draft.
import { DangerZoneBlock, DangerZoneAction, Button } from '@magicblocksai/ui';

<DangerZoneBlock title="Danger zone">
  <DangerZoneAction title="Pause agent"
    description="Stops new conversations; live ones finish gracefully."
    action={<Button variant="secondary">Pause</Button>} />
  <DangerZoneAction title="Duplicate agent"
    description="Copies everything into a new unpublished draft."
    action={<Button variant="secondary">Duplicate</Button>} />
  <DangerZoneAction title="Delete agent"
    description="Removes the agent and its draft. Sessions are kept."
    action={<Button variant="danger">Delete</Button>} />
</DangerZoneBlock>