A tool is something your plugin can do on request: list shelves, add a book to one. You register tools with Model Hub; the user decides which run freely, which ask and which are off; and every call, from the Hub's panel, another plugin or an agent outside Obsidian over MCP, goes through the same gate and is written to the same log. Your plugin writes the handler. It doesn't write a permission dialog, a log or an MCP server.
For what the user sees, read Tools and approvals. The calls are in The API, call by call and need API 0.6.
Register your tools#
A tool has a name, a description, a schema for its input, an effect and a handler. A write tool also says how to find its undo handle in its result. Since API 0.9 a tool can also declare whether a call may be sent again (replay) and how a write is undone (undo).
import type { HubTool } from './model-hub-api'; // from model-hub-api.d.ts
// `shelves` is your plugin's own shelf store.
function makeTools(shelves: Shelves): HubTool[] {
return [
{
name: 'books.list_shelves',
description: 'List the shelves, with how many books each holds',
effect: 'read',
replay: 'safe',
input: { type: 'object', properties: {} },
handler: async () => shelves.map((s) => ({ name: s.name, books: s.count })),
},
{
name: 'books.add_to_shelf',
description: 'Add a book to a shelf',
effect: 'write',
replay: 'unsafe', // a second call adds the book again
undo: { kind: 'handle' },
input: {
type: 'object',
properties: {
shelf: { type: 'string', description: 'The shelf’s name' },
book: { type: 'string', description: 'The book note’s path' },
},
required: ['shelf', 'book'],
},
handler: async (input, ctx) => {
const added = await shelves.add(String(input.shelf), String(input.book), ctx.signal);
return { added: added.id, shelf: input.shelf };
},
// The handle the plugin needs to put this back, taken from the result.
undoHandle: (result) => (result as { added?: string }).added ?? null,
},
];
}
// In your plugin's onload, where `this` is the plugin:
onHubReady(this.app, this, '0.6.0', (hub) => {
hub.registerTools(this.manifest.id, makeTools(this.shelves), {
undo: async (handle) => {
const name = await this.shelves.remove(handle);
return `Took “${name}” off its shelf`;
},
});
});
What the Hub does with this:
- Names are
namespace.namein lowercase: the namespace starts with a letter and holds letters, digits and hyphens; the name starts with a letter and holds letters, digits and underscores. The first plugin to register a namespace owns it, across restarts. Another plugin using it getstool-name-taken. - Registering again replaces your earlier tools, so register inside
onHubReady, which runs on every Hub reload. Your tools go when your plugin unloads.unregisterToolsremoves them earlier. inputis a small subset of JSON Schema:type,properties,required,items,enum,minimumanddescription. Input that doesn't match is refused astool-invalid-inputbefore your handler runs, so the handler can trust the shape.effectdecides the user's default: reads run freely, writes ask. Mark a toolreadonly if it changes nothing.replaysays whether a call may be sent again with the same input:safewhen a second call changes nothing more (every read, a write that sets a value),unsafewhen it acts again (an append, a message sent).undosays how a write is taken back:{ kind: 'handle' }with anundoHandle, or{ kind: 'none', reason: '…' }when it can't be, with the reason in words ("a sent message can't be recalled"). Reads leave it out.replayandundoare optional for your tools. The Hub's own tools must declare them: a unit test fails on any Hub tool that leaves one out or contradicts itself (undo: { kind: 'handle' }without anundoHandle,nonewithout a reason, a read with an undo). Declaring them now costs a line per tool and keeps your tools ready for the catalog.undoHandleturns a write's result into a string, ornullfor "nothing to undo". When it returns one and you passed anundotoregisterTools, the user gets an Undo notice; without anundothe handle is dropped. The Hub then calls theundoyou passed toregisterToolswith that handle, once, and shows the sentence you return. Oneundofunction serves all your tools.handlergets the input and a context with therun, theclient(who asked) and asignal. Watch the signal in long work: the Hub stops a tool after 60 seconds and when a run ends. Reject with an error to fail the call; the caller seestool-failed.- Results over 100 000 characters of JSON are cut. The call's
truncatedis thentrue, andresultholds the first 100 000 characters astext, withoriginalCharsfor the full length andcutsaying how much was left out. The Hub's own chat panel caps results at 25 000, so page large results (anoffsetorcursorin your input). - Empty results. Return
{ empty: true, message }when there is nothing to show, and say why. The call'soutcomeis thenempty; null,''and[]count as empty too. - Error text and the log. A handler's error message goes in the tool-call log and back to the caller. Keep note text and other arguments out of it.
Call tools#
callTool runs a tool by name through the user's settings. The first argument names the caller in the approval dialog and the log. It grants nothing.
try {
const r = await hub.callTool(this.manifest.id, 'books.add_to_shelf', { shelf: 'Winter', book: 'Books/Dune.md' });
// r.result is what the handler returned, r.truncated says whether it was cut,
// r.undo is the handle, when the write has one.
} catch (e) {
new Notice((e as Error).message); // e.code: 'tool-denied', 'tool-failed', 'tool-timeout' …
}
hub.tools() lists every registered tool with its owner and the policy now in force; hub.onToolsChanged(cb) tells you when that changes. hub.toolCalls({ run, plugin, tool, since, until }) reads the log. Rows hold the arguments as a hash only.
If a call is refused, tool-denied has details.reason of policy (the setting or the run's tool list), user (declined) or no-answer (no reply in 2 minutes). The full list of codes is in Refusal codes.
Share a run#
With no run, callTool makes a one-call run of its own. For several calls that belong together, open a run, pass its id, and end it.
const run = hub.startRun(this.manifest.id, { budgetUsd: 0.5, tools: ['books.list_shelves', 'books.add_to_shelf'] });
try {
const shelves = await hub.callTool(this.manifest.id, 'books.list_shelves', {}, { run: run.id });
const added = await hub.callTool(this.manifest.id, 'books.add_to_shelf', input, { run: run.id });
} finally {
hub.endRun(this.manifest.id, run.id);
}
A run is one piece of work. It shares three things across its calls:
- A budget.
budgetUsdlimits what the model calls made with this run's id may spend. There is none unless you set one. Passrun.idto the model calls too (complete,stream) so their spend counts against it. - A tool list. If you pass
tools, the run may call only those, and anything else istool-denied. - Grants and undo. If the user chooses Allow for this run on a write, later calls to that tool in the run don't ask again.
startRunreturns the run as it starts, sorun.spentUsdandrun.undoare a snapshot; callhub.run(this.manifest.id, run.id)to read the current spend and the undo handles of the writes so far.budgetUsdmust be 0 or more.
A run belongs to the plugin that started it. Another caller who learns its id (ids show in toolCalls and usage) can't call tools with it, make model calls under it, read it or end it: they get invalid-request. Model calls with run must be for a role your plugin owns.
Ending a run stops its calls in flight with aborted. hub.undo(owner, handle) puts a write back from code, for instance in your own Undo all button. It calls the same undo you registered and is logged.
What you don't build#
- The permission settings and the approval dialog. The user's Tools tab covers every plugin.
- The log. Each call is a row in
tool-calls.jsonl, and a trace span if the user turned traces on. - The MCP server. When the user switches it on, your tools are served there under the same rules. See Use Model Hub from Claude Code, Claude Desktop or Codex. Tools that read the vault aren't served by Model Hub at all: the agents that connect already read files themselves.