Plugin System¶
claude-code-log exposes a plugin system that lets third-party
packages rewrite parsed message content with their own typed
subclasses and render them through their own format/title methods,
without modifying core. This page is the as-built reference for
plugin authors writing a new plugin, and for maintainers of the
plugin machinery itself.
For the design discussion that led here, see the RFC at
work/tool-renderer-plugins.md;
that doc captures the alternatives considered. This page documents
what shipped.
1. What a plugin does¶
The pipeline (full overview in
application_model.md) reads JSONL transcript
entries, passes them through the factories/
layer to build typed MessageContent instances, then dispatches to a
renderer that emits HTML, Markdown, or JSON.
A plugin inserts itself between the factory output and the renderer dispatch. It can:
- Match a candidate
MessageContentby its class (anapplies_toMRO filter) — e.g. everyToolUseMessage, or everyUserTextMessage. - Inspect the candidate (e.g. check
tool_name, regex the text). - Return a replacement
MessageContent— typically a plugin-defined subclass — that carries its ownformat_markdown/format_html/titlemethods.
Two motivating use cases drove the design:
- MCP tool rendering. A specific MCP tool name (e.g.
mcp__plugin_clmail_clmail__communicate) deserves prettier output than the generic JSON-dump fallback. A plugin specializes the genericToolUseMessageinto a plugin-defined subclass with bespokeformat_markdown. - Hook-style demotion. A
UserTextMessagewhose body matches a marker (e.g.[hook] ...) gets reclassified into a typed wrapper so it can render compactly or be hidden at low detail levels.
Plugins are discovered through entry points, so just pip install
ing a plugin package wires it in — no edit to claude-code-log
itself.
2. Quick start: write your first plugin¶
The fastest path is to copy the reference plugin at
test/_plugins/clmail/ and edit it. That
package is the layer-4 test fixture for the plugin-system test suite
AND the canonical author example — the two roles are intentionally
combined so the doc cannot drift from working code.
Steps:
- Copy the layout. A plugin is a normal Python package with one
declarative addition in
pyproject.toml. Minimum tree:
my_plugin/
├── pyproject.toml
└── src/my_plugin/
├── __init__.py
└── transformers/
├── __init__.py
└── <one file per transformer>.py
- Declare the entry points. In
pyproject.toml:
[project.entry-points."claude_code_log.plugins"]
my_thing = "my_plugin.transformers.thing:MyTransformer"
The key on the left is a stable identifier the loader logs at
startup; the value on the right is module:ClassName. The class
must satisfy the MessageTransformer
Protocol (next section).
- Write the transformer. A
MessageTransformerdeclares threeClassVars plus atransformmethod:
from typing import ClassVar, Optional
from claude_code_log.factories.priorities import TOOL_INPUT_GENERIC
from claude_code_log.models import (
MessageContent, MessageMeta, ToolUseMessage,
)
class MyTransformer:
name: ClassVar[str] = "my-plugin.my-thing"
priority: ClassVar[int] = TOOL_INPUT_GENERIC - 500
applies_to: ClassVar[tuple[type[MessageContent], ...]] = (
ToolUseMessage,
)
def transform(
self,
content: MessageContent,
meta: MessageMeta,
) -> Optional[MessageContent]:
if not isinstance(content, ToolUseMessage):
return None # defensive narrowing
if content.tool_name != "mcp__my_server__my_tool":
return None
return MyToolMessage( # plugin-defined subclass; see §4
meta=content.meta,
input=content.input,
tool_use_id=content.tool_use_id,
tool_name=content.tool_name,
skill_body=content.skill_body,
)
- Write the message subclass. Inherit from the matched type so
the runtime contract accepts
your return value, then add
format_markdown/format_html/titlemethods. See §4.
Field-copy patterns for the common parent classes (constructor
signatures spelled out so the keyword-only-with-None-default
fields aren't easy to miss):
# ToolUseMessage — copying every parent field including the
# optional `skill_body` (kw-only, defaults to None; carries the
# Skill-tool slash-command body when present per issue #93).
return MyToolUseSubclass(
meta=content.meta,
input=content.input,
tool_use_id=content.tool_use_id,
tool_name=content.tool_name,
skill_body=content.skill_body, # keep — None is the common case
)
# ToolResultMessage — `is_error` / `tool_name` / `file_path` all
# default to False/None but must be copied to preserve the
# surrounding context (error styling, downstream tool grouping,
# Read/Edit/Write file backlinks).
return MyToolResultSubclass(
meta=content.meta,
tool_use_id=content.tool_use_id,
output=content.output,
is_error=content.is_error,
tool_name=content.tool_name,
file_path=content.file_path,
)
-
Install and run.
pip install -e .against your plugin package; the nextclaude-code-loginvocation discovers it. -
Test it. See §9 for layer-by-layer coverage suggestions.
The reference plugin demonstrates three branches of the contract:
hook_demotion.py— rewrite aUserTextMessagebased on text-prefix match.tool_communicate.py— rewrite aToolUseMessagebased ontool_name.tool_communicate_result.py— rewrite aToolResultMessage, demonstrating the long-Markdown-body collapsible-rendering pattern via the publicrender_markdown_collapsiblehelper.
Read all three before writing your own; together they cover ~95 % of the shapes a real plugin needs.
3. The MessageTransformer Protocol¶
Defined in claude_code_log/plugins.py.
Three required ClassVar attributes plus one method:
| Attribute / method | Type | Purpose |
|---|---|---|
name |
ClassVar[str] |
Stable identifier surfaced in startup logs and collision warnings. Convention: "<package>.<thing>". |
priority |
ClassVar[int] |
Sort key for the transformer chain. Lower runs first. Use the constants in factories/priorities.py to position yourself relative to other plugins. |
applies_to |
ClassVar[tuple[type[MessageContent], ...]] |
The MRO filter: this transformer is asked only about candidates that are instances (via isinstance) of one of these classes. |
transform(content, meta) |
(MessageContent, MessageMeta) -> Optional[MessageContent] |
Inspect content; return a replacement, or None to pass through. |
The Protocol is runtime_checkable, but runtime_checkable only
verifies methods. The loader explicitly validates the three
ClassVars — missing or malformed metadata triggers a WARNING log
and the plugin is silently dropped (the rest of claude-code-log
keeps working).
The class does NOT need to inherit from MessageTransformer. Any
class matching the structural shape is accepted, which keeps plugins
free of an import-time dependency on the Protocol object.
4. Class-side format / title methods¶
Plugin-defined MessageContent subclasses carry their own render
methods on the class itself (rather than on the renderer). The
renderer's dispatcher consults them after the renderer's own
format_<ClassName> methods (see §5).
from dataclasses import dataclass
from typing import ClassVar, Optional
from claude_code_log.models import DetailLevel, ToolUseMessage
@dataclass
class MyToolMessage(ToolUseMessage):
"""Plugin-defined subclass; carries its own render methods."""
detail_visibility: ClassVar[DetailLevel] = DetailLevel.LOW
def format_markdown(self, _renderer, _message) -> str:
action = (self.input.input or {}).get("action", "?")
return f"_(my plugin) action={action}_"
def format_html(self, _renderer, _message) -> Optional[str]:
return None # fall back to mistune(format_markdown)
def title(self, _renderer, _message) -> Optional[str]:
return "✉ my plugin"
Signature contract for each method:
| Method | Signature | Return | Notes |
|---|---|---|---|
format_markdown |
(self, renderer, message) -> str |
Markdown source string. | Define this whenever your class produces meaningful Markdown. Drives both Markdown output AND HTML output (via mistune) unless format_html is also defined. |
format_html |
(self, renderer, message) -> str |
Raw HTML string (real string — no None sentinel). | Define this ONLY when you need HTML different from mistune-of-format_markdown. The dispatcher synthesizes that fallback automatically when format_html is absent. |
title |
(self, renderer, message) -> Optional[str] |
Heading text or None. |
Return None for "headless" (inline) messages. Return "" (empty string, not None) to suppress the heading explicitly — the dispatcher distinguishes the two. |
format_html is opt-in. If your plugin class defines only
format_markdown, the HtmlRenderer dispatcher automatically
synthesizes HTML by running the Markdown through mistune and
wrapping the result in <div class="markdown">…</div>. You do NOT
need to write a render_markdown(self.format_markdown(...)) shim
— that's the dispatcher's job.
Define format_html only when you need HTML that differs from the
synthesized fallback (e.g. a collapsible <details> block for long
bodies, custom DOM structure, embedded SVG). When you do, return a
real string. There is no None-as-sentinel: returning None would
render as the literal string "None" in the card body (and may
raise a type error). The reference plugin's tool_communicate_result.py
shows the explicit-format_html pattern for a collapsible long-body
case; tool_communicate.py and hook_demotion.py show the absent-
format_html-let-the-synthesizer-handle-it pattern.
Error-shaped results. Set is_error=True on a ToolResultMessage
subclass replacement to inherit the host's standard error chrome
(🚨 emoji, red .tool_result.error CSS class). The mechanism is
wired in html/utils.py: isinstance(content, ToolResultMessage)
and content.is_error triggers both. Bash errors use the same
primitive — no custom plugin styling needed.
The dispatcher looks up these methods on each MRO node's __dict__
explicitly (not via getattr/inheritance). That means: a class
opts in by defining the method ON the class itself. Inheriting
format_markdown from a parent does NOT auto-enable dispatch for
the subclass; the subclass must define its own or the MRO walk
moves to the next ancestor.
4.1 Plugin-facing helpers¶
Two helpers are re-exported from claude_code_log.plugins for use
in format_html / format_markdown methods. The re-export is the
stable plugin API; the underlying implementation in
claude_code_log/html/utils.py may move or be renamed.
| Helper | Signature | Use when |
|---|---|---|
render_markdown(text) |
(str) -> str |
You need Markdown→HTML inside a custom format_html (e.g. embedding a Markdown fragment in a richer HTML scaffold). |
render_markdown_collapsible(raw_content, css_class, *, line_threshold=20, preview_line_count=5) |
(str, str, int, int) -> str |
Long Markdown bodies (mail bodies, agent responses, multi-paragraph result text). Returns inline <div class="{css_class} markdown">…</div> for short content, a collapsible <details> with preview + full body for content exceeding line_threshold. |
The reference plugin's
tool_communicate_result.py
shows the collapsible helper in use; the inline-vs-collapsed
threshold + preview length are both tunable per call.
Add to claude_code_log.plugins.__all__ only on concrete plugin-author
demand — every entry is an API commitment. Open an issue if a helper
you need isn't exposed.
5. Dispatch resolution order¶
Renderer._dispatch_format and _dispatch_title (both in
renderer.py) walk
type(obj).__mro__, asking two questions at each node:
| Strategy | Lookup | Caller signature |
|---|---|---|
| 1. Renderer-side | getattr(self, f"format_{cls.__name__}", None) |
method(obj, message) |
| 2. Class-side | cls.__dict__.get(method_attr) where method_attr = f"format_{self._class_dispatch_format}" |
method(obj, self, message) |
Strategy 1 wins per MRO node — the existing format_BashInput,
format_ToolUseMessage, etc. on HtmlRenderer/MarkdownRenderer
keep working unchanged. Strategy 2 is what plugins use.
_class_dispatch_format is "markdown" on the base Renderer and
overridden to "html" on HtmlRenderer. That's how the HTML
renderer picks up your class-side format_html while the Markdown
renderer ignores it and picks up format_markdown.
To shadow a built-in renderer method from a plugin, define the class-side method on the plugin subclass — the MRO walk visits the plugin subclass before the built-in's renderer-side method, so Strategy 1 at the plugin subclass's name (which the renderer doesn't have) fails, Strategy 2 on the plugin subclass hits, and the dispatcher never reaches the parent's renderer-side method.
title_content (the entry point for message headings) delegates to
_dispatch_title for the same reason — without delegation, a
title_ToolUseMessage on the base renderer would shadow your
class-side title() at the top level.
5.1 HtmlRenderer extension: actual-class precedence + Markdown synthesis¶
HtmlRenderer._dispatch_format overrides the base walk with two
additional rules, applied to the actual class (type(obj)) before
the standard MRO walk runs:
format_htmlon the actual class wins outright. Iftype(obj).__dict__["format_html"]exists, use it. Skip the MRO walk entirely — a plugin author who wroteformat_htmlon their subclass owns the HTML rendering.format_markdownon the actual class triggers synthesis. Ifformat_htmlis absent butformat_markdownis defined on the actual class, the dispatcher renders the Markdown through mistune and wraps the result in<div class="markdown">…</div>. Skip the MRO walk — the synthesized output is the answer.- Otherwise, defer to the base walk. This finds renderer-side
format_<ClassName>methods for built-in content classes and class-side methods on ancestors via the normal MRO.
The actual-class precedence is the key behavioural difference from
the base dispatcher: a plugin subclass of UserTextMessage that
defines format_markdown (but not format_html) gets its Markdown
promoted to HTML via synthesis even though the base renderer has
format_UserTextMessage that would normally win the MRO walk. The
intent: when a plugin author wrote format_markdown on a subclass,
they meant their Markdown to drive rendering, not for the parent
class's built-in renderer behaviour to take over.
6. detail_visibility¶
claude-code-log filters messages per the --detail flag. Levels
in order of decreasing verbosity:
Your plugin class declares a ClassVar[DetailLevel] to opt into
class-based visibility:
Semantics: monotone-down. The message is visible iff the current detail level is at least as verbose as the declared minimum. With the ordering above:
| Declared | Visible at |
|---|---|
FULL |
FULL only |
HIGH |
FULL, HIGH |
LOW |
FULL, HIGH, LOW |
MINIMAL |
FULL, HIGH, LOW, MINIMAL |
USER_ONLY |
all levels |
The order is pinned in a _DETAIL_ORDER map next to DetailLevel in
models.py (so a future reorder of the enum can't silently flip
semantics), guarded by a module-load assertion that every
DetailLevel value is mapped. The predicate itself lives on each
content class as MessageContent.visible_at(detail) and consults the
class-side detail_visibility ClassVar via DetailLevel.includes.
Opt-in nature. Most built-in MessageContent classes declare their
own detail_visibility (e.g. ToolUseMessage = LOW, SystemMessage =
FULL), and a plugin class subclassing such a built-in inherits the
parent's threshold through normal ClassVar inheritance unless it
declares its own. A handful of built-ins (UserTextMessage,
TeammateMessage, TaskNotificationMessage, SessionHeaderMessage)
do not declare a threshold and fall through to the base predicate's
"visible when unset" default — a plugin subclassing one of those
inherits no threshold and is likewise visible-by-default unless it
declares its own. The same applies transitively: UserSteeringMessage
subclasses UserTextMessage, neither declares, so UserSteeringMessage
also lands on the default-visible path. Declaring your own opts you out
of the orthogonal _LOW_KEEP_TOOLS tool-name allowlist (for
ToolUseMessage / ToolResultMessage subclasses) — your declared
visibility is authoritative.
Practical guide. Pick based on user-perceived value:
FULLonly — debug/dev signal that clutters normal viewing.HIGH— interesting but optional; user has opted into detail.LOW— should appear in the default summary view (the typical choice for tool-rendering plugins; bypasses the_LOW_KEEP_TOOLSallowlist that core would otherwise check).MINIMAL— essential context (sparingly).USER_ONLY— visible even in user-only views (almost never the right choice for a tool/hook plugin; reserved for user-originated content).
HIGH vs FULL for hook-style content — the two reference
plugins make different choices here, deliberately:
hook_demotion.py(this repo's test plugin) usesFULL— surfaces only in the most-verbose view. Right when the hook notification is pure noise reduction for typical reviewers.- A real-world plugin (e.g. for clmail-style hook notifications a
reviewer wants to see when they fired) typically picks
HIGH— surfaces inHIGHandFULL, hidden atLOWand below. Right when the hook firing itself is signal worth keeping in the detail view.
The rule of thumb: ask "would a reviewer skimming at HIGH want
to know this happened?" If yes, pick HIGH. If only at the
debug-the-transcript level, pick FULL.
7. Runtime contract enforcement¶
apply_transformers (in plugins.py) enforces two contracts at
runtime; both surface as WARNING logs and pass-through:
-
Exception safety. If
transform()raises, the exception is logged and the candidate falls through to the next transformer. A buggy plugin cannot crash the whole conversion. -
Return-type enforcement. The replacement must satisfy
isinstance(replacement, transformer.applies_to). AUserTextMessage-targeting transformer returning aSystemMessage(or worse, a string / dict) is rejected with a warning. In practice, this means your replacement class must subclass one of theapplies_totypes — not sit as a sibling.
The reference TestHookNotificationMessage is a UserTextMessage
subclass (not a bare MessageContent sibling) for exactly this
reason. The inherited items field stays empty if your class
carries the parsed data in dedicated fields.
transform() returning None means "not my case"; the dispatcher
moves to the next matching transformer. This is the right return
value for a "specific tool name" filter pattern (see
tool_communicate.py).
8. Discovery and ordering¶
8.1 Entry-point group¶
Plugins are discovered via the entry-point group:
[project.entry-points."claude_code_log.plugins"]
my_thing = "my_plugin.transformers.thing:MyTransformer"
The loader (load_transformers in plugins.py) is process-scoped
and cached. Tests call reset_cache() to force re-discovery.
8.2 Priority ordering¶
Transformers are sorted by (priority, __module__, __qualname__):
- Primary key:
priority(int). Lower runs earlier. The built-in priority constants infactories/priorities.pydescribe notional positions on a numeric scale. Plugins position themselves relative to these without core renumbering:
COMMAND_MESSAGE = 100
LOCAL_COMMAND_OUTPUT = 200
BASH_INPUT_OUTPUT = 300
TEAMMATE_MESSAGE = 400
TASK_NOTIFICATION = 500
HOOK_NOTIFICATION = 600
SLASH_COMMAND_ISMETA = 700
TEXT_FALLBACK = 1000
TOOL_INPUT_GENERIC = 5000
TOOL_OUTPUT_GENERIC = 5100
Gaps of 100 leave room for plugin insertion. Use the constant
(TOOL_INPUT_GENERIC - 500) rather than a literal so a future
core renumber stays consistent.
- Tie-breakers:
__module__,__qualname__. Deterministic cross-environment ordering when two plugins land at the same priority but in different packages. A(priority, applies_to)collision still triggers aWARNINGso you can detect overlap.
Convention for multi-transformer plugins. When a single plugin
ships several transformers that share an applies_to (e.g. a
plugin covering five MCP tools, all matching ToolUseMessage at
TOOL_INPUT_GENERIC - 500), they will collide with each other on
the (priority, applies_to) tie and emit warnings at startup. Two
ways to silence the self-collision:
- Per-tool offset. Give each transformer in the plugin a
small single-digit offset off the base:
priority = TOOL_INPUT_GENERIC - 504,- 503,- 502,- 501,- 500. The offset range stays narrow enough that the plugin still sits in a coherent "slot" relative to built-ins / other plugins, but each transformer is uniquely ordered against the others in the same plugin. - Narrow
applies_to. If the transformers actually match disjoint subsets (one targets ToolUseMessage, another ToolResultMessage), the tie disappears naturally — same priority is fine.
The per-tool-offset pattern is the right answer when all your
transformers genuinely share applies_to and only differ in the
tool_name they narrow to inside transform().
Important caveat about v1 semantics. In v1, plugin transformers
run as a post-classification pass: the built-in factory chain
classifies every entry first, then the priority-ordered plugin
list runs. So the priority ordering applies among plugins, not
against the built-in classifiers (which have already finished by
the time your plugin sees a candidate). The RFC's "interleaved with
built-in detectors" framing is a v2 consideration; v1's
post-classification scope covers every documented use case (clmail
hook-demotion, MCP tool rendering) because plugins always operate
on a candidate the built-in chain has classified (typically as
UserTextMessage or generic ToolUseMessage).
8.3 First non-None wins¶
apply_transformers walks the priority-sorted list, asks each
matching transformer (via applies_to), and returns the first
non-None reply. A transformer that returns None for a candidate
lets the next matching transformer try — this is the natural way to
say "specific filter inside a broad applies_to".
9. Testing your plugin¶
The plugin system ships with a four-layer test strategy in
test/test_plugin_system.py; your
own plugin should follow the same shape:
| Layer | What it covers | How to write yours |
|---|---|---|
| 1. Loader unit | Validator rejects malformed metadata; sort and tie-break warnings | Usually skip for a normal plugin — the core tests cover this. |
| 2. Dispatch matrix | Renderer-side vs class-side resolution; HTML vs Markdown output | Skip unless your plugin does something exotic with the dispatcher. |
| 3. Transformer integration | End-to-end: real MessageContent through your transform() and class-side render methods |
Always write this. Drive your transformer with hand-built MessageMeta.empty() candidates; assert the replacement is an instance of your subclass and that the render methods return the expected text. |
| 4. Text-equivalence | If your plugin reads UserTextMessage.items, assert that the joined text matches what the factory's extract_text_content produces |
Recommended for any plugin keying on user text — protects you against future core refactors that sneak normalization between extraction and the items list. |
For an installable test plugin (your own or a fixture in your own repo), declare it as an editable dev-dependency and reset the loader cache in a test fixture:
@pytest.fixture(autouse=True)
def _reset_plugin_cache():
from claude_code_log.plugins import reset_cache
reset_cache()
yield
reset_cache()
To inject a plugin directly (bypassing entry-point discovery — useful for exception-safety tests):
Always reset the cache in a try/finally or via the autouse fixture
to avoid leaking state across tests.
10. Common patterns and pitfalls¶
Defensive narrowing in transform. Even though applies_to
filters the dispatch, write if not isinstance(content, MyType):
return None as the first line. It costs nothing, makes the body's
type-narrowing explicit to readers and to mypy/pyright, and survives
a future plugin author copy-pasting your code with the wrong
applies_to.
format_html returning None. Most plugins should return None
to fall back to mistune-rendered Markdown. Write a custom
format_html only when the Markdown formulation can't capture what
you want (e.g. embedded SVG, complex tables that mistune mangles).
Don't escape Markdown manually for code spans. Backslashes do not
escape backticks inside inline code spans (CommonMark explicit). If
you embed user input in `...`, count the longest backtick run
in the value and use a fence one tick longer. See _inline_code in
markdown/renderer.py for the helper.
Inheriting from a built-in is mandatory for return-type
enforcement. A sibling MessageContent subclass will be rejected
by apply_transformers. If you don't need the parent class's
fields, set them with defaults (items: list = field(default_factory=list))
and ignore them in your render methods — that's what
TestHookNotificationMessage does.
Priority constants, not literals. Hard-coded 400 looks fine
until core renumbers TEAMMATE_MESSAGE to 350 and your plugin
silently changes order. Import from factories.priorities.
Cache invalidation in tests. Every test that adds, removes, or
modifies plugins (including injecting directly via
_cached_transformers) must reset_cache() afterward — process-wide
state otherwise leaks across tests.
detail_visibility is checked via hasattr for the LOW keep-list
opt-out. This means inheriting detail_visibility from a future
core-migrated parent class behaves the same as declaring it
yourself: the keep-list is bypassed. Usually what you want; mention
it if you're debugging a "why is my plugin visible at LOW even though
the tool isn't in _LOW_KEEP_TOOLS?" question.
Markdown-shaped HTML and the .markdown CSS scope. The
dispatcher returns your format_html output unmodified; the host
template wraps it in <div class="content">…</div>, not
<div class="content markdown">…</div>. So host theme rules scoped
under .markdown (table borders, code-block backgrounds, <pre>
overflow, list spacing) won't fire on your output unless the wrap
carries the class. Two ways to opt in:
has_markdown = Trueon the subclass (preferred when yourformat_htmlemits Markdown-shaped content end-to-end). The host template athtml/templates/transcript.htmlreadsmessage.content.has_markdownand flips themarkdownclass onto the wrapping<div class='content'>automatically:
@dataclass
class MyMarkdownShapedMessage(ToolResultMessage):
@property
def has_markdown(self) -> bool:
return True # → <div class='content markdown'>
Mirrors what built-ins (AwaySummaryMessage, TeammateMessage,
AssistantTextMessage) already do — no plugin-author divergence.
See tool_communicate_result.py in the reference plugin for a
worked example.
- Wrap inline (
<div class="markdown">…</div>) when only part of your output is Markdown-shaped or when you need fine-grained scope control:
render_markdown_collapsible already wraps for you (its short-
content branch emits <div class="{css_class} markdown">); only
the bare render_markdown path needs one of the two recipes above.
Note on the synthesis path. When you DON'T define format_html
and the HtmlRenderer dispatch synthesizes HTML from your
format_markdown (see §5.1),
the synthesizer always wraps the mistune output in
<div class="markdown">. You don't need to set has_markdown = True
for that path — it's implicit in the synthesis. has_markdown only
matters when you implement format_html yourself and want the host
template's outer <div class='content'> wrapper to pick up the
.markdown class.
Don't combine has_markdown = True with the synthesis path. If
your class has no format_html (so synthesis fires AND wraps in
<div class="markdown">) AND you also set has_markdown = True,
the host template flips the markdown class onto its outer
<div class='content'> wrapper — you end up with the synthesizer's
<div class="markdown"> nested inside <div class="content markdown">.
Benign for CSS (selectors don't care about depth) but visible in
the DOM, surprising on view-source, and harmlessly heavier. Rule
of thumb: has_markdown = True is the right opt-in only for
classes with an explicit format_html whose return value does
NOT already wrap. Synthesis classes leave has_markdown alone.
11. Reference¶
| Surface | Location |
|---|---|
| Protocol + loader + dispatch | claude_code_log/plugins.py |
| Priority constants | claude_code_log/factories/priorities.py |
| Renderer dispatch | Renderer._dispatch_format, Renderer._dispatch_title, HtmlRenderer._class_dispatch_format in renderer.py / html/renderer.py |
| Visibility predicate | MessageContent.visible_at, DetailLevel.includes, _DETAIL_ORDER in models.py; _filter_template_by_detail (post-render driver) in renderer.py |
| Reference plugin (canonical example) | test/_plugins/clmail/ + README.md |
| Test suite (four layers) | test/test_plugin_system.py |
| Design discussion | work/tool-renderer-plugins.md |
12. v2 directions (informational)¶
Out of scope for v1; mentioned here so contributors don't keep re-rediscovering them:
- Interleaved dispatch. Let plugins run between built-in
detectors (e.g. before the generic
TextFallbackclassifier), so a plugin can claim aUserTextMessagebefore the built-in chain has decided. Needs a redesign of the factory loop to call into the plugin chain at each detector boundary. - Renderer-side plugin extension. Today only
MessageContentsubclasses participate; a v2 plugin could contribute renderer-sideformat_<X>methods for an existing core class without subclassing. Lower priority — class-side dispatch already covers 90 % of the use cases. - Priority namespacing. A
priority: ClassVar[int]is global; large plugin ecosystems may want per-plugin priority namespaces with explicit ordering hints (e.g.before=other_plugin). Not needed at current scale.