This release is mostly about two things: you can now follow what happens in your vault as a live stream of events, and plugins that extend this one can do a lot more than add routes and simple tools. There are also three small fixes, none of which were reported from the wild.
At a glance
Features
- Event streams — find out when a note changes without polling for it
- Sub-resources under a note — an extension couldn't serve anything under
/vault/ - Full MCP results, resources, and prompts — an extension's tool could only return JSON text
- Extension routes in the OpenAPI spec — an extension's routes were invisible to docs and generated clients
- Extension events — an extension's own events can be streamed too
/openapi.json— the spec as JSON, alongside the YAML
Bug fixes
- Public routes could sit under the plugin's own prefixes — ahead of the API key check
- Two smaller fixes
Features
Event streams: the only way to find out a note had changed was to ask again
The problem: The API could tell you what a note looked like right now, and nothing else. If you wanted to react to a note being created, renamed, or having its frontmatter changed, you polled: list or search on a timer, compare against what you saw last time, and accept that anything which happened and un-happened between two polls was invisible to you.
What's new: You can follow one of Obsidian's own events as a Server-Sent Events stream. It takes two steps, because a browser's EventSource can only make a GET, and a GET has no body to carry a filter:
# 1. Subscribe to notes under journal/ being modified
curl -X POST -H "Authorization: Bearer <your-api-key>" \
-H "Content-Type: application/vnd.olrapi.jsonlogic+json" \
-d '{"glob": ["journal/*", {"var": "path"}]}' \
https://127.0.0.1:27124/events/vault/modify/
# 2. Follow the stream, using the "url" from the response
curl -N "<url>"The filter is optional, and it's the same JsonLogic that /search/ takes, glob and regexp included. With signed URLs turned on the URL that comes back is signed, so whatever follows the stream doesn't need your API key. For agents, the events_get_listener_url MCP tool does both steps and hands back a ready-to-run curl command.
These are the events that can be streamed:
| Emitter | Events |
|---|---|
vault
| create, modify, delete, rename
|
metadataCache
| changed, deleted, resolve, resolved
|
workspace
| file-open, active-leaf-change, layout-change
|
Each message carries the path and the file's NoteJson (the same shape /search/ evaluates), plus a few fields specific to the event, like oldPath on a rename. Note content is sent only when your filter reads file.content. Events whose payloads are keystrokes, clipboard data, or UI objects (editor-change, editor-paste, the menu events, and so on) are left out on purpose.
Some limits worth knowing about:
- Nothing is replayed: each message's
idis<epoch>-<counter>, and a new epoch or a gap in the counter means you missed something. It doesn't get re-sent. - Expiry only stops new streams: a stream URL expires after the signed-URL lifetime (or
?ttl=<seconds>), but a stream opened before then stays open. - 16 streams at once: that's the cap across all subscriptions.
- A stream URL is a capability: anyone holding one sees the path and metadata of every event its filter matches, so treat it like the notes themselves.
If what you want is "a note's frontmatter changed", use metadataCache changed rather than vault modify. The latter fires before Obsidian has re-read the file's metadata.
Sub-resources under a note: an extension couldn't serve anything under /vault/
The problem: Routes added with addRoute are mounted after the plugin's own, so a request for /vault/Notes/draft.md/comments/ was claimed by the /vault/* handler, which treated comments as an unknown target type and answered with an error before the extension's router ever ran. An extension that wanted to hang something off of a note had to put it somewhere else and carry the note's path some other way.
What's new: From extension API version 3, addVaultSubresource(name) returns a router that serves /vault/<note>/<name>/... for every note, and /active/<name>/... for the active one:
const comments = api.addVaultSubresource("comments");
comments.get("/:id", (req, res) => {
const { vaultFile } = req as VaultSubresourceRequest;
// GET /vault/Notes/draft.md/comments/a1f3 arrives here as GET /a1f3
});The plugin resolves the note before your router runs, so your router only ever sees notes that exist, and a request it doesn't answer carries on to the plugin's own handlers. Requests need the API key; a signed URL never reaches a sub-resource. heading, block, and frontmatter are reserved, and a name can only be held by one extension at a time.
Full MCP results, resources, and prompts: an extension's tool could only return JSON text
The problem: Whatever an extension's addMcpTool callback returned was JSON-encoded into a single text block. That ruled out returning an image, returning structuredContent with an outputSchema, and returning an isError result that tells the model a call failed in a way it can recover from. Tools were also the only thing an extension could register: no resources, no prompts.
What's new: From extension API version 3, addMcpTool also accepts a definition object, and that form's callback returns the complete MCP result, which reaches the client unchanged:
api.addMcpTool({
name: "comments_count",
description: "Count the comments on a note",
inputSchema: { path: z.string() },
outputSchema: { count: z.number() },
callback: async ({ path }) => {
const count = await countComments(path as string);
return {
content: [{ type: "text", text: `${count} comments` }],
structuredContent: { count },
};
},
});addMcpResource, addMcpResourceTemplate, and addMcpPrompt cover the rest. Clients that are already connected are told when these lists change. The original four-argument addMcpTool works exactly as it did.
Extension routes in the OpenAPI spec: an extension's routes were invisible to anything built from the spec
The problem: /openapi.yaml was a file baked in at build time. An extension's routes never appeared in it, so they were also missing from the docs viewer, from generated clients, and from the openapi-spec resource an MCP client reads to learn the API.
What's new: From extension API version 3, addOpenApiDescription takes the paths, components, and tags an extension's routes need and merges them into the published spec. Each path an extension contributes is marked with an x-obsidian-extension field naming it, so you can tell the plugin's operations from an extension's. A description that declares a path, component, or tag which already exists is refused rather than merged over the top of the existing one.
The plugin can't check a description against the routes an extension actually registered, so the description is only as accurate as its author made it.
Extension events: an extension's own events can be streamed too
What's new: From extension API version 3, addStreamableEvent adds one of an extension's events to the event streams above, under the extension's plugin id: POST /events/<plugin id>/<event>/. The extension supplies the function that turns the event's arguments into what the stream sends, so what gets exposed is the extension's decision.
/openapi.json: the spec as JSON
What's new: /openapi.json serves the same document as /openapi.yaml, for tools that would rather not parse YAML. Like the YAML, it needs no API key.
Bug fixes
Public routes could sit under the plugin's own prefixes
The problem: Routes added with addPublicRoute are answered before the API key is checked. That's the point of them, but nothing stopped an extension from registering one at /vault/*, or at a pattern like /:anything/* that matches it, and such a route would have answered requests for the plugin's own endpoints with no authentication at all. Only a handful of exact paths were refused.
I haven't seen an extension do this. It would have taken an installed plugin to do it, and an installed plugin can already do whatever it likes, so this is a guard against a mistake more than against an attack.
What's fixed: addPublicRoute now throws for a path under any prefix the plugin serves (/vault/, /active/, /search/, /commands/, /events/, /mcp/, /open/, /tags/), in any letter case, and for a path whose first segment is a pattern rather than a literal.
You are affected if your extension registers a public route whose first segment is a parameter, wildcard, or group, or which has a | outside of a group. It'll now throw when it registers. Start the path with a literal segment, such as your plugin's id.
Not affected: authenticated routes added with addRoute, vault sub-resources, and public routes that already start with a segment of their own.
Also fixed
- An extension could register an MCP tool named
vault_get_download_url,vault_get_upload_url, orevents_get_listener_urlwhile signed URLs were turned off, and turning them on afterward would fail. Those names are now reserved whatever the setting is. This was found in review, and hasn't been seen in the wild. (#369) - The
EventsandTagstags were used by operations in the OpenAPI spec but missing from its list of tags, so a docs viewer that groups by declared tags could misplace those sections. (#368)