Skip to content

Display Resolver

Display resolution turns stored message content into what the user sees: macro expansion, format transforms, and regex display scripts. By default the host runs this on the backend and the frontend fetches the result. An extension can register a frontend resolver to do that work in the browser instead, for chats it owns. This removes a backend round trip per render and lets you run your own resolution engine client side.

ctx.display is optional. Feature-detect it before use.

export function setup(ctx: SpindleFrontendContext) {
  if (!ctx.display) return // host build without the display hook

  const unregister = ctx.display.registerResolver(myResolver)

  return () => unregister()
}

Ownership

The host consults your resolver only for chats it owns, and uses its own backend resolution for everything else. You own a chat by setting display_owner: true in your own namespaced character data, alongside whatever else you store there:

// backend, when you write per-character state
await characters.update(characterId, {
  extensions: { my_extension: { ...myData, display_owner: true } },
}, userId)

A chat is owned when its character's extensions["my_extension"].display_owner is true and your extension is enabled. Storing data under your namespace without the flag does not claim display, so set it only on characters you actually render. To stop owning a character, set it false.

Vanilla chats, and chats whose character only carries other extensions' data, are never routed to you.

ctx.display.registerResolver(resolver)

Register your resolver. Returns an unregister function. The resolver implements four methods the host calls while rendering messages.

const myResolver: SpindleDisplayResolver = {
  ready: (chatId) => true,
  resolveBody: async ({ content, context }) => ({
    content: transform(content),
    touchedVars: ['chat:mood'],
  }),
  resolveTemplates: async ({ templates, context }) => ({ resolved: { /* key: value */ } }),
  applyScripts: async ({ content, scripts, context }) => ({ content: applied }),
}
Method Purpose
resolveBody Transform a full message body (macro expansion, format passes)
resolveTemplates Pre-resolve a batch of named templates, such as regex find/replace strings
applyScripts Run the chat's display regex scripts over the content
ready(chatId) Return whether your resolver can handle this chat right now

Return null from any resolve method to let the host resolve that one with its own path (only for chats you do not own; for owned chats a null return shows raw, never a backend fallback).

For a chat you own, the host calls a resolve method only once ready(chatId) returns true. While it is false (your bundle still loading, snapshot not yet arrived) the host shows raw content and does not fall back to backend resolution. Return true as soon as you can resolve the chat, and call invalidate when your readiness or state changes so the host re-renders.

Context

Each resolve method receives a context:

Field Type Description
chatId string? Chat being rendered
characterId string? Active character
personaId string? Active persona
messageId string? Message being rendered
messageIndex number? Position of the message in the chat
role string? Message role
isUser boolean Whether the message is from the user
depth number Message depth
dynamicMacros Record<string, string>? Per-call macro values supplied by the host

The method-specific arguments alongside context are: content for resolveBody, templates (a Record<key, string>) for resolveTemplates, and content plus scripts for applyScripts.

Result

resolveBody and applyScripts return:

Field Type Description
content string The resolved output
touchedVars string[]? Variables this result depends on, as scope:name (e.g. chat:mood, global:lang)
cacheable boolean? Set false to never cache this result. Defaults to cacheable

resolveTemplates returns the same information keyed per template: resolved (Record<key, string>), and optional touchedVars (Record<key, string[]>) and cacheable (Record<key, boolean>).

Caching and invalidation

The host caches your results so it does not call you on every render, using touchedVars as the dependency key. When a variable changes, only cached entries that listed it are dropped. Mark a result cacheable: false if it depends on something that is not a variable (time, randomness).

When your own state changes, tell the host which entries to drop:

// Drop cached resolutions that depend on these variables
ctx.display.invalidate(['chat:mood', 'global:lang'])

// Drop all cached display resolutions for the active chat
ctx.display.invalidate(['*'])

invalidate takes a list of scope:name strings, or ['*'] to clear everything. Use the wholesale form after a change you cannot express as a small set of variables, such as switching the chat your extension owns.

The hook is frontend only and requires no permission.