28 KiB
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:
-
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. -
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 inchat.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:
- Token load (
API.loadTokens()) - Profile resolution (
API.getProfile()) - Theme init (
Theme.init()) - EventBus connection (
Events.connect()) - Pipeline engine init (empty filter chains)
- UserMenu hydration (absorbs cs15 band-aid)
- Emits
sw:readyevent ondocument
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.js → sendMessage() + 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.js → UI.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 answobject with expected shape- Double-init returns same instance (idempotency)
sw.useris populated after initsw.isAdminreflects actual rolesw:readyevent fires ondocument
REST client:
sw.api.get('/api/v1/health')returns health objectsw.api.get('/api/v1/channels')returns{ data: [...] }envelopesw.api.post('/api/v1/channels', {...})creates and returns channelsw.api.del('/api/v1/channels/:id')deletes (204 or JSON)- Auth header injection (no manual token management)
Events:
sw.on()receives events emitted bysw.emit()sw.once()fires exactly oncesw.off()stops delivery- WebSocket events propagate through
sw.on()
Theme:
sw.theme.currentreturns 'dark' or 'light'sw.theme.set('dark')changes resolved themesw.theme.on('change', fn)fires on toggle
Pipe registration:
sw.pipe.pre(10, fn)registers without errorsw.pipe.stream(50, fn)registers without errorsw.pipe.render(50, fn)registers without errorsw.pipe.pre(10, fn, { scope: { channelType: ['direct'] } })registers with scopesw.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 insw.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
directchannel → filter does NOT run (counter stays 0) - Send message on a
workflowchannel → 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,scopeper 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: 30–35 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
-
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.
-
Pre-send filters run on regenerate.
regenerateMessage()runs the same pre-send chain assendMessage(). ThePreSendContextincludesregenerate: trueandregenerateMessageId: stringso 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. -
Per-channel filter scoping ships in v0.28.5. Filters declare an optional
scopeobject 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. -
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 toctx.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() |