Message Content Processor¶
Permission required: chat_mutation
Message content processors run before a user-initiated message write reaches the database. They can transform content and extra so the stored row and every WebSocket subscriber see the transformed version on first paint.
spindle.registerMessageContentProcessor(async (ctx) => {
if (!ctx.content.includes('{{my_macro}}')) return
return {
content: ctx.content.replaceAll('{{my_macro}}', 'resolved value'),
}
}, 50) // priority: lower runs first (default: 100)
Use this when the transform belongs on the stored message itself, not just on the in-flight LLM call.
Parameters¶
| Param | Type | Description |
|---|---|---|
handler |
(ctx: MessageContentProcessorCtx) => Promise<MessageContentProcessorResult \| void> |
Receives the about-to-be-committed content; returns a patch, or void to pass through |
priority |
number |
Optional. Lower values run first. Default:100 |
Context Object¶
interface MessageContentProcessorCtx {
chatId: string
messageId?: string // undefined for "create"
content: string
extra?: Record<string, unknown> // populated with { role, is_user } for "render"
origin: "create" | "update" | "swipe_add" | "swipe_update" | "render"
swipeIndex?: number // set for "swipe_update"
userId: string // pass to operator-scoped Spindle calls
}
Origin values¶
| Origin | Route | Notes |
|---|---|---|
"create" |
POST /api/v1/chats/:chatId/messages |
New message row. |
"create" |
POST /chats, POST /group, POST /:id/members/:characterId |
Auto-inserted greeting. The chain fires after insert; a follow-up update is written if the handler modifies content or extra. |
"update" |
PUT /api/v1/chats/:chatId/messages/:id |
Edits an existing message's content or extra. |
"swipe_add" |
POST /api/v1/chats/:chatId/messages/:id/swipe (with content) |
Appends a new swipe. |
"swipe_update" |
PUT /api/v1/chats/:chatId/messages/:id/swipe/:idx |
Rewrites the swipe at swipeIndex. |
"render" |
POST /api/v1/chats/:chatId/display-preprocess |
Per-render transform for display only. Output feeds the display-regex pass and final paint. Does not write to the message row. |
Returned extra is ignored on swipe origins: swipes share the parent message's extra, which addSwipe and updateSwipe cannot patch. Only content is honored.
Returned extra is also ignored on render. There is no row to mutate, so only content is honored.
Cycling through existing swipes ({direction: "left"|"right"}) does not fire the hook; no content changes.
Render origin¶
render is a non-persisting origin. The frontend calls POST /api/v1/chats/:chatId/display-preprocess once per visible message, the chain runs in the same priority order as write-time origins, and the returned content feeds into the display-regex pass before the host's richHtmlSanitizer paints it.
The transformed content is visible only on the rendered message. It is invisible to:
spindle.chat.getMessages()and any other read of the stored row- write-time origins (
create,update,swipe_add,swipe_update) - chat memory embeddings, prompt assembly, and exports
Use render for per-render rewrites that depend on transient or per-message context (chat-var values, the message's own position, etc.) and would pollute history if persisted. Use a write-time origin when the transform belongs on the stored message itself.
The render context populates extra with hints from the calling frontend:
| Field | Type | Description |
|---|---|---|
extra.role |
string |
"user", "assistant", or "system". |
extra.is_user |
boolean |
Mirror of role === "user". |
messageId is set on the context when the rendered message has one. The route accepts an optional messageIndex body field that lands on extra.messageIndex if supplied by the caller.
Return Value¶
interface MessageContentProcessorResult {
content?: string
extra?: Record<string, unknown>
}
Return undefined or void to pass through. Otherwise:
content: if present, replaces the content for downstream processors and the database write.extra: if present, shallow-merges into the existingextra(omitted keys preserved, included keys overwrite).
Composition Order¶
Multiple processors run in priority order (lower first, default 100), with registration order as the tie-breaker. Each processor sees the previous one's returned patch.
Timeout¶
Each processor runs inside a 10-second wall-clock budget. On timeout or thrown error: the chain logs the failure and forwards the previous content to the next handler. The write still proceeds.
Users notice the wait
The processor fires before the database write. Every millisecond of handler work is a millisecond of visible latency on send, edit, or swipe. Keep the hook tight.
Message Content Processor vs Interceptor vs Context Handler¶
| Hook | When it fires | What it changes |
|---|---|---|
| Context Handler | Before prompt assembly | The generation context |
| Interceptor | After assembly, before LLM call | The outgoing message array |
| Message Content Processor | Before the message row is written to the database | The content and extra of a user-initiated message write |