This repository has been archived on 2026-04-03. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
core/docs/DESIGN-0.28.5.md
2026-03-14 22:51:50 +00:00

28 KiB
Raw Blame History

DESIGN — v0.28.5: Frontend SDK + Pipe/Filter Pipeline

Status: Draft Scope: switchboard-sdk.js, pipe/filter pipeline, extension compat shims, ICD sdk test tier Depends on: v0.28.4 (security tier), v0.28.3 (frontend decomposition — sb.js, IIFE→module, sb.call()) Informs: v0.28.7 (unified packaging — manifest "pipes" key), v0.29.0+ (Starlark pipeline stages) Current version: 0.28.4


Overview

The platform has 47 JS files, 15+ globals (API, Events, UI, App, Extensions, ChatPane, Theme, etc.), and a partially-decomposed frontend (Phase 4 ES modules, sb.register()/sb.ns() action registry). Surface authors and extension authors must hunt through multiple files, understand load order, and hold implicit coupling in their heads.

v0.28.5 delivers two things:

  1. switchboard-sdk.js (the SDK) — a single-object composition layer (sw) over existing globals. One import, one init call, coherent API. Does not rewrite internals; wraps and delegates.

  2. Pipe/filter pipeline — three composable, priority-ordered transform stages that formalize the ad-hoc extension hooks into a real pipeline: pre-send → post-receive (stream) → post-render. Extensions register filters; the SDK wires them. Replaces ctx.renderers.register() and the implicit hook points in chat.js / ui-core.js / ui-format.js.


Design Principles

Wrap, don't rewrite. The SDK delegates to API, Events, UI, App, Theme, Extensions, ChatPane. It does not duplicate their logic. If API.streamCompletion() changes, the SDK's sw.api.post() still works because it calls through. This means v0.28.5 doesn't risk destabilizing the 531 passing CI tests.

The pipeline is the extension model. The existing six hooks in DESIGN-SURFACES.md (Stream Processing, Post-Render, Tool Bridge, etc.) are replaced by three pipe stages plus the existing tool bridge. The old ctx.renderers.register() becomes a compat shim that registers into sw.pipe.render(). New extensions use sw.pipe.* directly.

Filters are pure transforms. A filter receives a context object and returns it (possibly modified). Side effects are allowed (logging, metrics) but the contract is input→output. This makes filters testable in isolation.

Priority ordering is explicit. Every filter declares a numeric priority. Lower runs first. Ties break by registration order. The SDK enforces this and exposes introspection (sw.pipe.list()).

Error isolation is mandatory. One filter throwing must not break the chain. The SDK catches, logs, records the failure in telemetry, and continues with the next filter. A filter can explicitly halt the chain by returning null (pre-send: message suppressed) or a { halt: true } signal.


SDK API Reference

Boot

// Idempotent. Safe to call multiple times (returns same instance).
// Typically called by the surface boot script after DOMContentLoaded.
const sw = Switchboard.init({ mount: '#app' });

init() performs:

  1. Token load (API.loadTokens())
  2. Profile resolution (API.getProfile())
  3. Theme init (Theme.init())
  4. EventBus connection (Events.connect())
  5. Pipeline engine init (empty filter chains)
  6. UserMenu hydration (absorbs cs15 band-aid)
  7. Emits sw:ready event on document

If already initialized, returns the existing sw instance. The mount option is reserved for future component auto-mounting but is not used in v0.28.5 (surfaces manage their own DOM).

Identity

sw.user          // { id, username, display_name, email, role, avatar }
sw.isAdmin       // boolean (shorthand for sw.user.role === 'admin')

Read-only. Resolved during init(). Updates on profile.updated event.

Delegates to: API.user, API.isAdmin.

REST Client

const channels = await sw.api.get('/api/v1/channels');
const created  = await sw.api.post('/api/v1/channels', { title, model });
const updated  = await sw.api.put('/api/v1/channels/123', { title });
const deleted  = await sw.api.del('/api/v1/channels/123');

// Streaming (returns raw Response for SSE consumption)
const resp = await sw.api.stream('/api/v1/chat/completions', body, signal);

All methods auto-prefix BASE_PATH, inject auth headers, handle 401 refresh, and parse JSON. sw.api.stream() returns the raw Response for SSE reading (same contract as API.streamCompletion() but generic).

Delegates to: API._get(), API._post(), API._put(), API._delete(), API._fetch().

Events

const unsub = sw.on('chat.message.*', (payload, meta) => { ... });
sw.once('workflow.completed', (payload) => { ... });
sw.emit('custom.event', { data: 42 });
sw.off('chat.message.*', handler);

Thin passthrough to Events.on/once/off/emit. No behavior change. The SDK does not add any event namespacing — extensions already namespace by convention (ext.{id}.{event}).

Delegates to: Events.

Components

// Drop-in ChatPane — creates the component in the container
const pane = sw.chat(document.getElementById('myChat'), {
    channelId: 'abc123',
    standalone: true,
});
pane.renderMessages(messages);
pane.destroy();

// Drop-in notes panel
const notes = sw.notes(document.getElementById('myNotes'), {
    projectId: 'proj-1',
});

sw.chat() wraps ChatPane.create() with SDK-aware defaults (auto-wires pipe filters into the pane's stream/render path). sw.notes() wraps the notes panel initialization from notes.js.

Delegates to: ChatPane.create(), notes init functions.

UI Primitives

sw.toast('Saved', 'success');           // UI.toast()
const ok = await sw.confirm('Delete?'); // showConfirm()
sw.modal.open(contentEl);               // openModal()
sw.modal.close();                       // closeModal()

Delegates to: UI.toast(), showConfirm(), openModal(), closeModal().

Theme

sw.theme.current       // 'dark' | 'light' (resolved, never 'system')
sw.theme.mode          // 'dark' | 'light' | 'system' (user preference)
sw.theme.set('dark');  // Theme.set()
sw.theme.on('change', (resolved) => { ... });

sw.theme.on('change', fn) subscribes to the theme.changed EventBus event and fires with the resolved theme string.

Delegates to: Theme.get(), Theme.resolved(), Theme.set(), Events.on('theme.changed', ...).


Pipe/Filter Pipeline

Architecture

User types message
        │
        ▼
┌─────────────────┐
│  PRE-SEND chain │   sw.pipe.pre(priority, fn)
│  filter₁ → ··· → filterₙ
└────────┬────────┘
         │ context: { message, channel, attachments, model, persona, metadata }
         ▼
   API.streamCompletion()
         │
         ▼ SSE chunks
┌─────────────────┐
│  STREAM chain   │   sw.pipe.stream(priority, fn)
│  filter₁ → ··· → filterₙ    (per chunk)
└────────┬────────┘
         │ context: { chunk, accumulated, channel, model, tokens }
         ▼
   formatMessage() → DOMPurify
         │
         ▼ rendered HTML
┌─────────────────┐
│  RENDER chain   │   sw.pipe.render(priority, fn)
│  filter₁ → ··· → filterₙ
└────────┬────────┘
         │ context: { element, message, channel }
         ▼
   DOM inserted

Registration API

// Pre-send: transform message before it hits the server
sw.pipe.pre(10, (ctx) => {
    // Inject KB context
    ctx.metadata.kb_chunks = getRelevantChunks(ctx.message);
    return ctx;
});

// Pre-send with scope: only run on workflow channels
sw.pipe.pre(20, (ctx) => {
    // Auto-tag workflow messages with stage metadata
    ctx.metadata.workflow_stage = ctx.channel.stage;
    return ctx;
}, { scope: { channelType: ['workflow'] } });

// Post-receive: transform each SSE chunk during streaming
sw.pipe.stream(50, (ctx) => {
    // Token counting
    tokenCounter.add(ctx.chunk);
    ctx.tokens = tokenCounter.total;
    return ctx;
});

// Post-render: transform rendered DOM
sw.pipe.render(50, (ctx) => {
    // Replace mermaid code blocks with rendered diagrams
    ctx.element.querySelectorAll('pre code.language-mermaid').forEach(el => {
        renderMermaid(el);
    });
    return ctx;
});

// Post-render scoped to DMs only
sw.pipe.render(60, (ctx) => {
    // Highlight @mentions differently in DMs
    highlightDmMentions(ctx.element);
    return ctx;
}, { scope: { channelType: ['dm'] } });

// Introspection
sw.pipe.list();
// → { pre: [{priority, source, scope, avgMs}], stream: [...], render: [...] }

Filter Contract

// Type definitions (for documentation — runtime is plain JS)

interface PreSendContext {
    message: string;              // User's text (mutable)
    channel: { id: string; type: string; title: string };
    attachments: string[];        // File IDs
    model: string;                // Selected model ID
    persona: string | null;       // Persona ID if active
    metadata: Record<string, any>; // Extensible metadata bag
    regenerate: boolean;          // true if this is a regeneration
    regenerateMessageId: string | null; // message ID being regenerated
}

interface StreamContext {
    chunk: string;                // Current SSE delta text
    accumulated: string;          // Full response so far
    channel: { id: string; type: string };
    model: string;
    event: string;                // SSE event type (content, tool_use, etc.)
    tokens: { input: number; output: number }; // Running count
}

interface RenderContext {
    element: HTMLElement;          // The message container DOM node (mutable)
    message: { role: string; content: string; id?: string };
    channel: { id: string; type: string };
}

// Filter scope — restricts which channels a filter runs on.
// Omit or pass null for "all channels" (default).
interface FilterScope {
    channelType?: string[];       // ['direct','dm','group','channel','workflow','service']
}

// Registration options (third argument to sw.pipe.pre/stream/render)
interface FilterOpts {
    scope?: FilterScope;          // Channel type restriction
    source?: string;              // Label for introspection (defaults to extension ID or 'platform')
}

// All filters are synchronous. Return context to continue, null to halt.
type PipeFilter<T> = (ctx: T) => T | null;

Halt Semantics

Pre-send: Returning null suppresses the message entirely. The SDK calls UI.toast('Message blocked by extension', 'warning') and does not call API.streamCompletion(). Use case: content policy filter.

Stream: Returning null drops the current chunk (not displayed). The accumulated buffer is unchanged. Use case: real-time content filter that strips sensitive data from the stream.

Render: Returning null skips remaining render filters for this message. The DOM stays as-is after the last successful filter. Unusual but available for guard-rail extensions.

Timing Telemetry

The pipeline engine records per-filter execution time (monotonic clock). Available via sw.pipe.list() and emitted on pipe.stats EventBus event every 60 seconds (if any filters ran). Format:

{
    "pre":    [{ "source": "kb-inject", "priority": 10, "calls": 47, "avgMs": 2.3, "errors": 0 }],
    "stream": [{ "source": "token-counter", "priority": 50, "calls": 12840, "avgMs": 0.1, "errors": 0 }],
    "render": [{ "source": "mermaid", "priority": 50, "calls": 47, "avgMs": 15.7, "errors": 1 }]
}

Filter Scoping

Filters declare an optional scope at registration time. The pipeline engine checks scope before invoking a filter — scoped-out filters are skipped with zero overhead (no function call, no timing entry).

// Only run on workflow and service channels
sw.pipe.pre(10, myFilter, { scope: { channelType: ['workflow', 'service'] } });

// Only run on direct (1:1 AI) chats
sw.pipe.stream(50, tokenCounter, { scope: { channelType: ['direct'] } });

// No scope = runs on everything (default)
sw.pipe.render(50, mermaidRenderer);

Channel types (from the channels table type column): direct, dm, group, channel, workflow, service.

Scope evaluation: Before each filter call, the engine reads ctx.channel.type and checks against the filter's scope.channelType array. If the type is not in the array, the filter is skipped. If the filter has no scope (or scope is null), it always runs.

Introspection: sw.pipe.list() includes the scope object for each filter so extensions can inspect which filters apply to their channel type.

Manifest mapping (v0.28.7): The "pipes" manifest key will support the same scope object:

{
    "pipes": [
        {
            "stage": "pre",
            "priority": 10,
            "handler": "filters.kbInject",
            "scope": { "channelType": ["direct", "dm", "group"] }
        }
    ]
}

Integration Points

Pre-send: chat.jssendMessage() + regenerateMessage()

sendMessage() — current flow (chat.js line ~659):

text = ChatInput.getValue()
→ build body
→ API.streamCompletion(channelId, text, model, ...)

sendMessage() — new flow:

text = ChatInput.getValue()
→ build PreSendContext { message, channel, ..., regenerate: false }
→ sw.pipe._runPre(context)         // returns modified context or null
→ if null: toast + return
→ API.streamCompletion(ctx.channel.id, ctx.message, ctx.model, ...)

regenerateMessage() — current flow (chat.js line ~835):

messageId from clicked button
→ API.streamRegenerate(channelId, messageId, model, ...)

regenerateMessage() — new flow:

messageId from clicked button
→ build PreSendContext { message: '', channel, ..., regenerate: true, regenerateMessageId: messageId }
→ sw.pipe._runPre(context)
→ if null: toast + return
→ API.streamRegenerate(ctx.channel.id, messageId, ctx.model, ...)

Both paths share the same filter chain. The regenerate flag and regenerateMessageId let filters distinguish — a content policy filter runs on both, a KB inject filter might skip regeneration (the original context is already in the conversation), a logging filter records both.

The sendMessage() function gains ~10 lines. regenerateMessage() gains ~8 lines. Extracted helper: _buildPreSendContext(opts) constructs the context object for both callers.

Metadata passthrough: The metadata bag on PreSendContext maps to the completion request body. Pre-send filters can inject KB chunks, persona instructions, or custom fields that the backend completion handler reads from the request. KB auto-inject (v0.28.6) will be the first platform-level filter using this mechanism.

Post-receive (stream): ui-core.jsUI.streamResponse()

Current flow (ui-core.js line ~1060):

SSE reader loop:
  parse chunk → extract delta
  content += delta
  activeContentEl.innerHTML = formatMessage(content)

New flow:

SSE reader loop:
  parse chunk → extract delta
  streamCtx = { chunk: delta, accumulated: content, ... }
  streamCtx = sw.pipe._runStream(streamCtx)
  if streamCtx is null: continue (chunk dropped)
  content = streamCtx.accumulated     // filter may have mutated
  activeContentEl.innerHTML = formatMessage(content)

The pipe runs synchronously per chunk (filters that need async should buffer internally). The accumulated field lets filters see the full response so far (for context-dependent transforms like citation injection that need to see paragraph boundaries).

Performance: Stream filters run on every SSE chunk (potentially hundreds per response). The telemetry tracks per-filter avgMs. Filters exceeding 5ms average get a console warning. The pipeline engine skips disabled filters without allocation.

Post-render: ui-format.js + ui-core.js

Current flow:

formatMessage(content)               // marked.js + DOMPurify
→ Extensions.runBlockRenderers()     // inside formatMessage, per code block
→ el.innerHTML = html
→ runExtensionPostRender(el)         // Extensions.runPostRenderers()

New flow:

formatMessage(content)               // unchanged
→ el.innerHTML = html
→ renderCtx = { element: el, message, channel }
→ sw.pipe._runRender(renderCtx)      // subsumes runExtensionPostRender

The render pipe replaces both Extensions.runBlockRenderers() and Extensions.runPostRenderers(). The distinction between "block" and "post" renderer types collapses into a single render stage — filters operate on the full message DOM and can target code blocks, inline elements, or the entire container.

Block renderer compat: Extensions that register block renderers via the old ctx.renderers.register(name, { type: 'block', ... }) API get automatically shimmed into sw.pipe.render() filters. The shim wraps the old (lang, code, container) signature into the new (renderCtx) => ... contract. Priority is preserved.


Backward Compatibility

Extension Context Shim

The existing Extensions._buildContext() returns a ctx object with ctx.renderers.register(), ctx.tools.handle(), ctx.events.*, ctx.ui.*, ctx.storage.*, and ctx.api.*.

All of these continue to work. The shim layer:

Old API Shim behavior
ctx.renderers.register(name, { type: 'block', ... }) Wraps into sw.pipe.render() filter with matching priority
ctx.renderers.register(name, { type: 'post', ... }) Wraps into sw.pipe.render() filter
ctx.renderers.register(name, { type: 'inline', ... }) Wraps into sw.pipe.render() filter (inline → DOM manipulation)
ctx.tools.handle(name, fn) Unchanged — tool bridge is orthogonal to pipes
ctx.events.* Unchanged — passthrough to Events
ctx.ui.* Unchanged — passthrough to UI / showConfirm / Theme
ctx.storage.* Unchanged — scoped localStorage
ctx.api.* Unchanged — passthrough to API._fetch
ctx.surfaces.* Unchanged (already stub/warn for removed features)

The shim is invisible to existing extensions. They register the same way; the pipeline engine sees their filters alongside new sw.pipe.* filters and orders everything by priority.

Global Availability

sb.js continues to work. window.API, window.Events, window.UI, etc. remain available. The SDK is additive — it does not remove globals or break direct access. Phase 5 (ES module import/export migration) is when globals start going away, and the SDK becomes the only entry point.

Load Order

base.html:
  sb.js              ← action registry (unchanged)
  app-state.js       ← App, fetchModels (unchanged)
  api.js             ← API (unchanged)
  events.js          ← Events (unchanged)
  ui-primitives.js   ← Theme, showConfirm, etc. (unchanged)
  ui-format.js       ← formatMessage (patched: render pipe integration)
  ui-core.js         ← UI (patched: stream pipe integration)
  switchboard-sdk.js ← NEW: Switchboard object, pipe engine
  extensions.js      ← Extensions (patched: renderer shim)
  ... surface-specific JS ...
  chat.js            ← sendMessage (patched: pre-send pipe)
  app.js             ← init (patched: calls Switchboard.init)

switchboard-sdk.js loads after all primitives/globals it wraps and before extensions.js (so the shim layer is ready when extensions register renderers). app.js init() calls Switchboard.init() as part of its boot sequence; the SDK handles idempotency.


File Inventory

New Files

File Purpose
src/js/switchboard-sdk.js SDK object, pipe engine, compat shims
surfaces/icd-test-runner/js/tier-sdk.js ICD runner sdk test tier
docs/DESIGN-0.28.5.md This document (archived after merge)

Modified Files

File Change
src/js/chat.js sendMessage(): pre-send pipe insertion
src/js/ui-core.js streamResponse(): stream pipe insertion
src/js/ui-format.js runExtensionPostRender(): delegates to render pipe
src/js/extensions.js _registerRenderer(): shim into sw.pipe.render()
src/js/app.js init(): call Switchboard.init() during boot
src/templates/base.html Add <script> for switchboard-sdk.js
surfaces/icd-test-runner/js/main.js Register sdk tier
surfaces/icd-test-runner/manifest.json Include tier-sdk.js

No Changes

All other 40+ JS files are untouched. The SDK wraps them; it does not modify their internals (except the five patched files above).


ICD Test Tier: sdk

New tier in the ICD runner. Tests the SDK contract from the browser, running against the live platform.

Test Categories

Boot:

  • Switchboard.init() returns an sw object with expected shape
  • Double-init returns same instance (idempotency)
  • sw.user is populated after init
  • sw.isAdmin reflects actual role
  • sw:ready event fires on document

REST client:

  • sw.api.get('/api/v1/health') returns health object
  • sw.api.get('/api/v1/channels') returns { data: [...] } envelope
  • sw.api.post('/api/v1/channels', {...}) creates and returns channel
  • sw.api.del('/api/v1/channels/:id') deletes (204 or JSON)
  • Auth header injection (no manual token management)

Events:

  • sw.on() receives events emitted by sw.emit()
  • sw.once() fires exactly once
  • sw.off() stops delivery
  • WebSocket events propagate through sw.on()

Theme:

  • sw.theme.current returns 'dark' or 'light'
  • sw.theme.set('dark') changes resolved theme
  • sw.theme.on('change', fn) fires on toggle

Pipe registration:

  • sw.pipe.pre(10, fn) registers without error
  • sw.pipe.stream(50, fn) registers without error
  • sw.pipe.render(50, fn) registers without error
  • sw.pipe.pre(10, fn, { scope: { channelType: ['direct'] } }) registers with scope
  • sw.pipe.list() returns registered filters in priority order with scope
  • Duplicate registration (same source+priority) warns but succeeds

Pipe execution — pre-send:

  • Register pre-send filter that appends metadata
  • Send message → filter runs → metadata present in request (verify via round-trip: message persisted, reload, check)
  • Register halt filter (returns null) → message not sent, toast shown
  • Multiple filters → run in priority order
  • Filter throws → error logged, next filter runs, message still sends
  • Regenerate: filter runs with ctx.regenerate === true

Pipe execution — render:

  • Register render filter that adds a class to message elements
  • Send message → response rendered → class present in DOM
  • Priority ordering: low-priority filter sees high-priority filter's changes
  • Extension compat: ctx.renderers.register('test', { type: 'post', ... }) → filter appears in sw.pipe.list() render chain

Pipe execution — stream:

  • Register stream filter that counts chunks
  • Send message → stream completes → filter's counter > 0
  • Stream filter that modifies accumulated → rendered content reflects change

Pipe scoping:

  • Register filter scoped to channelType: ['workflow']
  • Send message on a direct channel → filter does NOT run (counter stays 0)
  • Send message on a workflow channel → filter runs (counter increments)
  • Unscoped filter runs on all channel types
  • sw.pipe.list() shows scope for scoped filters, null for unscoped

Introspection:

  • sw.pipe.list() returns timing stats after filters have run
  • Stats include calls, avgMs, errors, scope per filter

Test Infrastructure

The sdk tier runs after crud (needs a channel to send messages into) and before security (SDK tests assume valid auth). It creates its own test channels (at least one direct, one workflow for scope tests), registers ephemeral filters, sends messages, and verifies DOM/API state.

Estimated test count: 3035 tests.


Changeset Plan

CS1 — SDK Core + Docs

Files: switchboard-sdk.js, DESIGN-0.28.5.md, base.html Scope: SDK object with identity, REST client, events, theme, UI primitives, component wrappers. No pipe engine yet. app.js calls Switchboard.init(). All existing tests stay green — no behavior change.

Validates: SDK loads, wraps globals, sw:ready fires, sw.api.* works, sw.theme.* works. Manual verification: existing surfaces boot normally with the new script tag.

CS2 — Pipe Engine + Integration

Files: switchboard-sdk.js (pipe engine added), chat.js, ui-core.js, ui-format.js, extensions.js Scope: Pipeline execution engine, three filter chains, integration into sendMessage/streamResponse/runExtensionPostRender, extension renderer compat shim.

Validates: Pipe registration works, filters run in order, existing block renderers (mermaid, katex, csv, diff) still render via compat shim. CI green on both PG and SQLite.

Breaking change: runExtensionPostRender() now delegates to the pipe engine. If an extension relied on Extensions.runPostRenderers() being called directly (not through runExtensionPostRender), it would need updating. In practice, all callsites go through runExtensionPostRender.

CS3 — ICD Runner SDK Tier

Files: tier-sdk.js, main.js, manifest.json, fixtures.js (new fixtures for SDK tests) Scope: Full sdk test tier. Delivered as complete .surface archive (installable via POST /admin/surfaces/install).

Validates: All SDK contract tests pass against live platform.

CS4 — VERSION + CHANGELOG + ROADMAP

Files: VERSION, CHANGELOG.md, ROADMAP.md Scope: Version bump to 0.28.5, changelog entry, roadmap checkboxes.


Resolved Decisions

  1. Stream filters are sync-only in v0.28.5. Filters that need async (e.g., external API calls per chunk) must buffer internally and flush on their own schedule. Async stream filter support revisited if real usage demands it. Rationale: backpressure risk on fast SSE streams is not worth the complexity for MVP.

  2. Pre-send filters run on regenerate. regenerateMessage() runs the same pre-send chain as sendMessage(). The PreSendContext includes regenerate: true and regenerateMessageId: string so filters can distinguish. Content policy, KB inject, and all other pre-send filters apply consistently — a filter that only guards new messages but not regenerations would be a security gap.

  3. Per-channel filter scoping ships in v0.28.5. Filters declare an optional scope object at registration time that restricts which channel types they run on. The pipeline engine checks scope before invoking the filter — scoped-out filters have zero overhead (no call, no timing entry). See "Filter Scoping" section below.

  4. KB auto-inject ships as a pipe filter in v0.28.6. The platform registers a built-in pre-send filter (_kb-auto-inject, priority 5) that queries bound knowledge bases, runs top-K chunk retrieval, and prepends context to ctx.metadata.kb_context. This is the first real validation that the pipeline handles non-trivial platform-level transforms, not just extension toys.


Appendix: Existing Extension Hooks → Pipe Mapping

DESIGN-SURFACES Hook Current Implementation Pipe Equivalent
Hook 2: Stream Processing Not implemented (spec only) sw.pipe.stream()
Hook 3: Post-Render ctx.renderers.register(type:'post')Extensions.runPostRenderers() sw.pipe.render()
Hook 3: Block Render ctx.renderers.register(type:'block')Extensions.runBlockRenderers() sw.pipe.render() (filter targets code blocks in DOM)
Hook 3: Inline Render ctx.renderers.register(type:'inline') → not wired sw.pipe.render() (filter modifies HTML string)
Hook 5: Tool Bridge ctx.tools.handle()Events.on('tool.call.*') Unchanged — not a pipe stage
Hook 6: Surface Injection Not implemented (spec only) Not a pipe stage — future SDK feature
(new) Pre-send Did not exist sw.pipe.pre()