Insurgently

Register tools from your plugin

Offer what your plugin can do as tools, call tools, run several calls under one budget and undo a write.

For people building on Model Hub.

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.name in 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 gets tool-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. unregisterTools removes them earlier.
  • input is a small subset of JSON Schema: type, properties, required, items, enum, minimum and description. Input that doesn't match is refused as tool-invalid-input before your handler runs, so the handler can trust the shape.
  • effect decides the user's default: reads run freely, writes ask. Mark a tool read only if it changes nothing.
  • replay says whether a call may be sent again with the same input: safe when a second call changes nothing more (every read, a write that sets a value), unsafe when it acts again (an append, a message sent).
  • undo says how a write is taken back: { kind: 'handle' } with an undoHandle, 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.
  • replay and undo are 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 an undoHandle, none without a reason, a read with an undo). Declaring them now costs a line per tool and keeps your tools ready for the catalog.
  • undoHandle turns a write's result into a string, or null for "nothing to undo". When it returns one and you passed an undo to registerTools, the user gets an Undo notice; without an undo the handle is dropped. The Hub then calls the undo you passed to registerTools with that handle, once, and shows the sentence you return. One undo function serves all your tools.
  • handler gets the input and a context with the run, the client (who asked) and a signal. 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 sees tool-failed.
  • Results over 100 000 characters of JSON are cut. The call's truncated is then true, and result holds the first 100 000 characters as text, with originalChars for the full length and cut saying how much was left out. The Hub's own chat panel caps results at 25 000, so page large results (an offset or cursor in your input).
  • Empty results. Return { empty: true, message } when there is nothing to show, and say why. The call's outcome is then empty; 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. budgetUsd limits what the model calls made with this run's id may spend. There is none unless you set one. Pass run.id to 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 is tool-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. startRun returns the run as it starts, so run.spentUsd and run.undo are a snapshot; call hub.run(this.manifest.id, run.id) to read the current spend and the undo handles of the writes so far. budgetUsd must 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.
Package
@insurgently/model-hub-api
Plugin id
model-hub
Needs
Obsidian 1.13.1, desktop
License
MIT