Status: implemented on all three sides — the binding agreement between the
repos below. The trilium-notecast-mcp server (this repo) implements its half,
the Slide Format note in Trilium carries the full #notecast… type definition,
and trilium-notecast-render reads #notecastInstance / #notecastTheme /
#notecastIgnore and ships the seven document types. What is left is
publication, not implementation — see Migration.
Notecast is a family of three Trilium tools that all read and write the same notes but each does exactly one job:
| Repo | Role | Reads | Writes |
|---|---|---|---|
trilium-notecast-mcp (this repo) |
Authoring — an AI assistant creates/modifies typed notes via ETAPI | type definitions | notes of a type |
trilium-presenter-plugin (public) |
Presenting — renders a subtree as an on-screen slide deck | slide notes + themes | — |
trilium-notecast-render |
Rendering — renders a marked note to print/PDF in a chosen theme | notes + themes | — |
The glue between them is a set of Trilium labels sharing the notecast prefix.
notecast is only a label namespace and a family name — it is not required
to appear in every repo's name. The public presenter repo keeps its established
name (it is the presentation specialist) and simply reads #notecast… labels.
The MCP server started life as a slide/presentation tool. Notecast generalises
that: a "type" is no longer only "slide" — it can be expenseReport,
wikiEntry, meetingNote, anything. The author defines a new type by creating
one tagged note; no code change, no redeploy.
Trilium attribute names are [A-Za-z0-9_] only (no : — that is reserved by
Trilium for promoted-attribute definitions like label:foo), so every label is
camelCase under the notecast prefix.
Generic single words (type, theme, css, template, print, node) are
avoided on purpose: Trilium ships built-ins such as #appTheme, #appCss,
#cssClass, #template, #titleTemplate, #viewType, and other plugins on
the public plugin page could grab any bare word. A distinctive prefix is the
only real collision protection. Verified against the TriliumNext attributes doc:
none of the labels below collide with a built-in.
#notecastType=note does not search for the value note. Trilium's search
grammar reads a bare note as one of its own identifiers, and the query then
matches almost every note in the instance — measured against a live instance:
20 hits unquoted, 1 quoted. #notecastType="note" is correct.
This is not a corner case: note is a registered type id (below), so every repo
that looks a type or theme up by value is affected. It shipped in two of them —
create_note(note_type="note") always answered "AMBIGUOUS" and could never
create anything, and the renderer offered a dropdown of ordinary notes instead
of that type's themes. Both now quote.
The value must be a plain token ([A-Za-z0-9_-]+) before it is quoted — quoting
is not an escape mechanism, and a query assembled from arbitrary label text is
not made safe by wrapping it. The MCP validates ids against exactly that shape
already; the renderer falls back to "all themes" for anything else.
A type definition is one note that tells the MCP how to create notes of a given type.
| Label | Meaning | Required |
|---|---|---|
#notecastType=<id> |
Marks the note as a type definition. <id> is the value passed to create_note(note_type=…), e.g. slide, expenseReport. |
yes |
#notecastTargetType=text|code |
Trilium note type of created notes. Default text. |
no |
#notecastMime=<mime> |
MIME for code notes, e.g. text/x-markdown. Ignored for text. |
no |
#notecastApplyLabels=<name> or <name>=<value> |
A label to stamp on every created note. May appear multiple times (one per label). e.g. #notecastApplyLabels=slideType=content. |
no |
#notecastParent=<noteId> |
Default parent for created notes of this type. Falls back to the caller's argument / TRILIUM_DEFAULT_PARENT. |
no |
#notecastPrefix=<branchPrefix> |
Branch prefix for created notes (the old slides used Folie). |
no |
A definition's labels are invisible in the only part of it anyone reads. The content is what Trilium shows, what GitHub renders, and what this server feeds to the model; the labels sit in the attribute panel beside it, and in the source repo they sat in a build script three files away. So the note someone copies to build a type of their own was the one place that never said which labels make it a type at all.
Both output plugins therefore end a definition with an ## Attributes section
naming exactly what the note carries:
## Attributes
| Label | Value |
|---|---|
| `#notecastType` | `itTip` |
| `#notecastTargetType` | `code` |
| `#notecastMime` | `text/x-markdown` |A label carried bare is written with an em dash in the value column.
The MCP does not read this section — it reads the labels, as it always has, and a definition without one works exactly as before. The convention is for the human building the next type. What keeps it honest is that in both plugins the build parses this table and stamps the labels from it: the section cannot drift from the labels, because there is nothing left for it to drift from.
#notecastApplyLabels is one fixed value per type, which leaves a type whose
id does not distinguish its variants unreachable: slide has three of them —
title, content, chapter — the definition stamps slideType=content, and
before labels there was no way to author the other two. Every deck created
through the MCP therefore had no title slide until someone edited the label by
hand in Trilium, and the MCP had no tool to do that either.
create_note(..., labels={"slideType": "title"}) closes that. The mapping is
applied after #notecastApplyLabels, so a name given there replaces the
type's default rather than sitting beside it — two slideType labels on one
note would make the rendered layout depend on attribute order.
Note what this is not: the presenter's position-based fallback (first slide in
a deck becomes title) is deliberately dead, because the same note then rendered
as a title slide in a deck and as a content slide on its own. Naming the type on
every note is the fix for that; labels is what lets the MCP name it too.
Two labels are refused, and nothing is created when they appear:
| Label | Why |
|---|---|
#notecastInstance |
the renderer resolves a note's type from it; a wrong value silently offers the wrong themes |
#notecastType |
would make the created note a second definition of that type, and the ambiguity guard then refuses every later create_note for it |
Names are validated against [A-Za-z0-9_]+ — Trilium's own attribute grammar.
The note's content is the authoring format — the prose the model must
follow (structure, conventions, voice). It is injected live into the
create_note / update_note tool descriptions, exactly as the old
#presenterSlideFormat content was injected into create_slide.
A theme is one note carrying a CSS stylesheet for a type.
| Element | Meaning |
|---|---|
#notecastTheme=<typeId> |
Marks the note as a theme and binds it to a type. |
| note title | The theme's name, shown in the renderer's picker — e.g. A4 Note, US Letter Note. |
| note content | The CSS. |
Themes are not shared between the presenter and the renderer — each keeps its own label namespace:
| Label | Owner | Shape |
|---|---|---|
#notecastTheme=<typeId> |
trilium-notecast-render |
one note; its content is the print CSS |
#presenterTheme |
trilium-presenter-plugin |
a container note holding several CSS notes (Base / Title / Content / Handout / Chapter) plus SVG backgrounds |
The two shapes genuinely differ, and forcing the presenter's multi-CSS structure
into a single note would lose the split it depends on. The presenter predates
Notecast and keeps its established namespace; notecast was introduced to give
the new pieces a common family name, not to rename what already worked. So
#notecastTheme is in practice the print/render label, and there is no
medium marker on it — a type simply has a set of named themes and the medium
lives in the name.
Consequence, accepted deliberately: the presenter's themes are invisible to the renderer and vice versa. That is the intended separation, not a gap to close.
The MCP does not touch themes at all — neither namespace, not creating, not
reading. (It may of course set an ordinary #notecastApplyLabels if a type wants
a default theme stamped, but it has no theme-specific logic.)
#presenterTheme above is one of these; the other is #slideIgnore.
| Label | Owner | Meaning |
|---|---|---|
#slideIgnore |
trilium-presenter-plugin |
keep a note out of the presentation — bare on the note itself, or #slideIgnore=subtree for its whole branch |
Both sit outside the notecast namespace because they predate it, and neither
the MCP nor the renderer reads either one.
For #slideIgnore that has a visible consequence, so it is written down rather
than left implicit: a branch hidden from the presentation is still printed.
The renderer's subtree print is deliberately type-agnostic — one code path for
every Notecast type — and it reads none of the presenter's labels. A "Handouts"
or "Notes" folder parked next to the slides therefore stays off screen and lands
on paper.
The expectation that it would not comes from the presenter's own handout, which
did honour the label. That handout is gone; printing belongs to the renderer
now. That left the renderer with no way to exclude anything at all, which has
since been decided — and not by teaching it this label: print exclusion is
#notecastIgnore, below.
| Label | Owner | Shape |
|---|---|---|
#notecastIgnore |
trilium-notecast-render |
keep a note out of a subtree print — bare on the note itself, or #notecastIgnore=subtree for its whole branch |
Shape-identical to #slideIgnore on purpose, and deliberately not the same
label. Sharing one would get the paradigm case backwards: the canonical
#slideIgnore note is a "Handouts" or "Notes" folder parked beside the slides,
kept off screen precisely because it belongs on paper. A renderer honouring
#slideIgnore would suppress exactly the branch the print job exists for.
"Not a slide" and "not for print" are different statements about a note, so they
get different labels — and a note that means both simply carries both.
Three rules govern the walk:
- Bare removes the note, not its descendants. Its children are still walked and printed. That is what makes the label usable on a container: a folder can be kept off paper without hiding what it holds.
=subtreeremoves the branch, the note included. Any other value reads as bare, matching the presenter.- The note Print was pressed on is exempt. Its own
#notecastIgnoredoes not apply to itself, only to what hangs below it. The renderer's root is chosen by hand — a note selected, a button pressed — where the presenter's is whatever deck it was opened on. An explicit act beats a passive marker, and the alternative is answering a deliberate Print with "Nothing to print", which reads as a broken plugin rather than as a label doing its job. For the same reason a single-note print never consults the label at all.
The MCP does not read it. It can of course stamp it through #notecastApplyLabels
like any other label, but it has no logic of its own for it.
#notecastType=<id> marks a type definition. #notecastInstance=<id> marks
an instance — a note of that type. create_note stamps
#notecastInstance=<note_type> on every note it creates, so instance-side
consumers (the render plugin) can resolve a note's type: select a note → its
#notecastInstance fixes the type → offer that type's #notecastTheme=<id>
themes.
It is deliberately a different label than #notecastType: if instances
carried #notecastType, the MCP's definition lookup (#notecastType=<id>, which
must resolve to exactly one note) would collide with every instance and always
report "ambiguous". Notes not created by the MCP (hand-authored) simply lack the
marker; the renderer then falls back to offering all themes.
An image belongs to the note that shows it: the MCP attaches it to that note as
a Trilium attachment with role=image, and the attachment's title is the
file name. Nothing about this is carried by a #notecast… label — the binding
is the title, and that is what makes it a shared agreement rather than an
implementation detail of one repo.
The resolution rule, binding on every consumer of a markdown-typed note: a
reference target that is a bare file name — no scheme, no slash — is looked up
against the titles of the note's own attachments and replaced with that
attachment's URL. Both output plugins implement it: trilium-presenter-plugin
in processSlideContent, trilium-notecast-render in attachmentUrls /
resolveImageSrc.
Both of them render into a window opened from a blob: URL, which does not
share Trilium's origin, so the resolved URL has to be made absolute. A
relative api/attachments/… survives every test that inspects the HTML and then
404s in the actual output — including in text notes, where the URL was never
built by the plugin at all but came from Trilium.
Two consequences the MCP enforces when attaching, both to keep the mapping decidable:
- File names carry no path. A target containing a slash is not resolved by
the presenter, so a path-shaped name would attach without error and then
silently never render.
attach_imagerefuses it. - A file name is unique within its note. Two attachments with the same title
make "which image does this reference mean" undecidable, and the answer would
depend on enumeration order.
attach_imagerefuses the second one — the same clarity guard as the duplicate-type refusal, for the same reason.
The reference form depends on #notecastTargetType, and the two are not
interchangeable:
| Target type | Reference in the note content | Resolved by |
|---|---|---|
code (markdown) |
 |
the output plugin, against attachment titles |
text (HTML) |
<img src="api/attachments/<attachmentId>/image/<title>"> |
nobody — Trilium renders the URL as-is |
attach_image returns the correct form for the note it attached to, so the
authoring model does not have to know this table. It does mean the reference in
a text note contains a concrete attachment ID and does not survive the
image being replaced, whereas the markdown form does.
Themes are unaffected: images are content, and no #notecastTheme or
#presenterTheme note is involved.
Generalised from the current _fetch_slide_format machinery. To resolve a type
X the server searches #notecastType=X:
- exactly one hit → use its content as the format and read its mechanics
labels. Cache with a short TTL to avoid hammering ETAPI during a
tools/listburst. - zero hits / ETAPI unreachable → a loud "DO NOT GUESS" notice, never a built-in fallback format. A missing type must never silently masquerade as a real one.
- more than one hit → refuse for that type with a loud "type
Xdefined more than once" error.
That last rule is not a trust boundary — by decision there is none. Anyone
who can tag a note defines a type; the author is responsible for what they tag.
It is a clarity guard: two notes claiming the same type id make the format
undecidable, and a clear error beats silently picking the wrong instructions.
There is no per-type pinning env var (the old TRILIUM_SLIDE_FORMAT_NOTE_ID is
removed) — pinning every type by ID would defeat the "just tag a note" workflow.
The presenter-specific tools collapse into generic, type-driven CRUD. The MCP authors notes; it does not present or render, so the presenter-mirroring traversal is gone.
| New | Replaces | Notes |
|---|---|---|
create_note(note_type, title, content, parent?, labels?) |
create_slide / create_presentation |
Reads the type definition, sets #notecastApplyLabels, target type, mime, prefix. labels overrides those defaults for one note — see "Per-note labels" below. |
update_note(note_id, content?, title?) |
update_slide |
|
attach_image(note_id, filename, data_base64, alt?, mime?) |
— (new) | Attaches an image to an existing note and returns the reference to embed. See "Images" above. |
get_note(note_id) |
get_slide |
|
list_children(parent_note_id) |
list_presentations |
Plain navigation for authoring — not the presenter traversal. |
clone_node(note_id, target_parent_id) |
clone_slide |
Kept generic; mostly the user's job, but cheap and useful. |
move_node(branch_id, new_position) |
move_slide |
Kept generic (reordering after authoring). new_position is a 0-based index among the siblings; the whole sibling set is renumbered so that it is one. |
delete_note(note_id) |
delete_note |
Unchanged. |
get_note_info(note_id) |
get_note_info |
Unchanged. |
search_notes(query, limit) |
search_notes |
Unchanged. |
list_note_types() |
— (new) | Discovery: enumerates #notecastType ids + titles so the assistant knows valid note_type values. |
Removed: list_slides and the whole collectSlides traversal
(_collect_slides, _sorted_child_branches) — presenting is the presenter's
job. slideType stops being special: for the slide type it is just one
#notecastApplyLabels=slideType=… entry.
create_note / update_note descriptions carry the available type ids and
their formats, fetched live at tools/list time (same mechanism as today).
Scaling caveat: with many types this description grows. If it ever becomes a
problem, the fallback is per-type tools (create_expenseReport, …) generated
dynamically — deliberately deferred; the single generic tool is the default.
The importable seed notes (type definitions + themes) are not shipped by the MCP. Ownership follows the output:
- The output plugin that renders a type owns and ships that type's full seed
— its single
#notecastTypeformat note and the theme notes it renders with — as that plugin's own import zip. The presenter ships#notecastType=slideplus its#presenterThemescreen themes and is thus self-sufficient standalone; the renderer ships#notecastTheme=<typeId>print themes. Each plugin seeds the namespace it reads (see "Theme definition" above). - The MCP ships no definitions. It is a pure engine; with no type notes,
create_notecorrectly refuses ("do not guess"). A user gets types by importing an output plugin's zip or by tagging their own note. - The presenter presents, full stop. All printing lives in the renderer as
print themes — including a print theme for
slide(a deck-as-handout), if wanted. Screen themes = presenter, print themes = renderer. In principle one could build a print theme "for" the presenter, but by this split any such print output is a renderer concern, not the presenter's. - The ambiguity guard enforces the clean division: the format note per type
must be unique → exactly one owning plugin per type; themes are additive
→ many
#notecastTheme=<sameType>notes may coexist across repos and media.
Because two repos must never claim the same id as a #notecastType format note,
type ids are a shared, reserved vocabulary. Register each canonical id here as it
is introduced:
| Type id | Owning plugin | Target | Notes |
|---|---|---|---|
slide |
trilium-presenter-plugin |
code / text/x-markdown |
screen themes in the presenter under #presenterTheme; print theme in the renderer under #notecastTheme=slide |
note |
trilium-notecast-render |
text (HTML) |
a short captured thought |
kbEntry |
trilium-notecast-render |
code / text/x-markdown |
a durable reference article |
meetingNote |
trilium-notecast-render |
code / text/x-markdown |
minutes of one meeting |
checklist |
trilium-notecast-render |
code / text/x-markdown |
steps to tick off on paper |
itTip |
trilium-notecast-render |
code / text/x-markdown |
one problem, one fix, one page |
letter |
trilium-notecast-render |
text (HTML) |
formal letter, window-envelope geometry |
handout |
trilium-notecast-render |
code / text/x-markdown |
course material to take home; the one type written to run past a single sheet |
The renderer owns the general-purpose document types because it is the general-
purpose output: anything can be printed, whereas slide only means something to
a presenter. A type belongs to whichever plugin gives it a visible form.
- Done: the Slide Format note carries
#notecastType=slideplus the mechanics labels (#notecastTargetType=code,#notecastMime=text/x-markdown,#notecastApplyLabels=slideType=content,#notecastPrefix=Folie), applied bytrilium-presenter-plugin/migrate-slide-type-to-notecast.py. The migration is additive: the legacy#presenterSlideFormatlabel is still on the note and can be dropped once the MCP is verified end to end. - Done: the presenter's import zip carries the new labels. The exported
meta.jsonthat would have had to be refreshed by hand is gone from both plugin repos —build-zip.pynow declares the note tree and emits the archive and its metadata together, so the labels cannot drift out of a release again. - Themes: deliberately not migrated, and never will be. The presenter's
on-screen themes stay on
#presenterTheme;#notecastThemebelongs to the renderer. This is settled — see "Theme definition" above for the reasoning. - Done locally, not yet published: the legacy mini MCP server (no Docker)
that used to sit in
trilium-presenter-plugin/mcp/has been removed there and its docs point at this repo — but those commits are unpushed. The public presenter repo is still on its 2026-06-27 state and does bundle the mini server. It resolves itself when the presenter repo is pushed, which is planned for the same round in which this repo andtrilium-notecast-rendergo public.
Unchanged: TRILIUM_URL, TRILIUM_API_KEY, TRILIUM_DEFAULT_PARENT, and all
transport/auth vars. Removed: TRILIUM_SLIDE_FORMAT_NOTE_ID (no pinning).