Insurgently

The API, call by call

What each Hub call does, what it takes and what comes back.

For people building on Model Hub.

This page is the map. The full reference is generated from the code:

  • For reading: the API reference, with every declaration, its signature and its comment.
  • For tools and coding agents: model-hub-api.d.ts, every public declaration in one TypeScript file, and api.json, the same as data.

Both are also in the repo under docs/api/.

Finding the Hub#

CallWhat it does
getHub(app, wanted)Returns { hub }, or { hub: null, reason, message } when the Hub is missing or its API is too old.
onHubReady(app, plugin, wanted, cb)Runs cb(hub) now if the Hub is loaded, and again each time it loads.
isCompatible(actual, wanted)True when an API at actual serves a plugin built for wanted.
hub.versionThe API's version.

Roles and tiers#

CallWhat it does
registerRoles(pluginId, roles)Declares your roles. Harmless to repeat.
roles(pluginId)Your roles as they resolve now: the tier, the models to try, and every limit that applies. tierMissing is set when a role names a tier that doesn't exist.
setRole(pluginId, role, override)Changes one of your roles; null puts it back to your default. Only the owning plugin may.
onRolesChanged(cb)Calls cb when what your roles resolve to may have changed: the user moved a role or gave it models, a tier changed, or providers were switched on or off. Read roles() again in it. Several changes at once call it once. Returns the function that stops it. (0.12)
openRoleEditor(pluginId)Opens the Hub's editor for your roles.
tiers()Every tier as the user has set it: the seven built-in ones, then the user's own. Each has a stable id, a display name and custom.
createTier(pluginId, name, policy)Asks the user to add a tier. The Hub shows a dialog naming your plugin. Resolves to the tier, or null if the user declines.
deleteTier(pluginId, id)Asks the user to delete one of their own tiers. Resolves true or false.

A role definition:

{
  name: 'mine.summarize',       // namespace.name, lowercase
  description: 'Summaries of a note',
  tier: 'quick',                // a tier's id or name; 'balanced' if left out
  reasoning: 'medium',          // optional: off, low, medium or high; left out, thinking is off
  ceilingUsd: 0.02,             // optional: this role's own limit per call
  monthlyBudgetUsd: 2,          // optional: this role's own limit per month
  allowAnyModel: false,         // true for interactive roles where the user picks a model per run
  hosts: 'balanced',             // optional: how OpenRouter picks a host (cheapest, balanced, fastest); the user can change it (0.12)
  pinnedModels: false,          // embedding roles: true lets an index keep a model the role used before (0.12)
}

A role may also carry its own ordered candidates instead of a tier. Store the id that createTier returns; names keep working across renames.

Calls#

Every call takes a role name first. All of them reject with an error whose code is one of the refusal codes.

CallWhat it does
complete(role, messages, opts?)One call. Returns the text, plus tool calls, sources and a structured object when asked for.
stream(role, messages, opts?)The same, with textStream to read as it arrives.
embed(role, values, opts?)Embeddings for a batch of texts.
estimate(role, req)What a call or an agent run would cost, without making it. See below.
decide(role, request, opts?)Typed decisions: yes/no, pick one, or a score. calibrated says whether the probabilities come from a decision model.
batch(items, fn, opts?)Runs many calls with limited concurrency, reporting progress and cost. Stops early on an overcharged call, a spend limit or an abort.

Options on complete and stream:

OptionWhat it does
systemThe system prompt.
maxOutputTokensThe longest reply allowed, not counting thinking: the Hub adds the model's thinking budget when it sends the call. The per-call limit is checked against both.
outputA schema for the reply, which comes back in object: a Standard Schema such as zod's, which the reply is checked against, or a JSON Schema document, which the model is asked to follow but which isn't checked. complete only.
webSearchAsk the provider to search the web.
skillsSkills to apply, by name.
needsCapabilities you need beyond what the options imply.
forWhat the call is about, for the ledger.
signalAn AbortSignal to stop the call.

Options on embed:

OptionWhat it does
kindquery or passage. The Hub adds the model family's prefix: an Instruct: … Query: line for Qwen3 embeddings, query: / passage: for e5, search_query: / search_document: for nomic, the retrieval line for English bge and mxbai. Without kind, texts go as they are.
dimensionsCut each vector to this length and rescale it to unit length. For Matryoshka models such as Qwen3 and OpenAI's text-embedding-3.
instructionThe task for instruction-tuned models, in place of "retrieve relevant passages".

embed returns dimensions and a fingerprint: a hash of the Hub provider, the model, the dimensions and the input form. Which upstream host OpenRouter used is left out, so the fingerprint stays the same from call to call. Vectors with the same fingerprint can be compared. Name your vector files by it, so a model or settings change can't mix rows.

An index that keeps its model#

When the user changes your embedding role, vectors you already stored stop matching new ones. To keep an index on the model it was built with, store an EmbeddingSpec with it and pass it on every call for that index:

const spec: EmbeddingSpec = { provider: 'openrouter', model: 'qwen/qwen3-embedding-8b', dimensions: 1024, form: 'family', instruction: null };
const r = await hub.embed('mine.embed', texts, { spec, kind: 'passage', expectFingerprint: index.fingerprint });
FieldWhat it means
provider, modelThe model exactly, as the Hub's provider and that provider name it.
dimensionsCut vectors to this length; null keeps the model's own.
formfamily: the Hub adds the family's prefix for each call's kind (so kind is required). bare: texts go as given, whatever the kind.
instructionThe task for instruction models in the family form; null is the Hub's default.
  • A spec replaces model, candidates, tier, dimensions and instruction; giving one of them too is invalid-request. There is no fallback: the spec's model runs, or the call is refused.
  • The spec's model may have left the role. It still runs when the role sets allowAnyModel, or sets pinnedModels: true and has embedded with that model before (the ledger says so). Such calls are marked pinned in the ledger. Otherwise it's model-not-allowed, and the message says to add the model to the role.
  • With a spec, fingerprint is v2: and 16 hex characters. It covers the provider, model, dimensions, form and the prefixes themselves, so bare and prefixed vectors never share one. fingerprintV1 is the 0.9–0.11 value for the same vectors, for matching stores made before 0.12.
  • expectFingerprint takes either. A v2: value is checked before anything is sent. A v1 value hashed what served the call, so it is checked after the call. A mismatch rejects with fingerprint-mismatch (details.expected, details.actual, details.formVersion). matched says which one matched, so you can store the v2 value and drop v1.
  • describeEmbedding(spec) gives the fingerprint, the prefixes, the price per million tokens and whether the spec can run now, without a call. When it can't, available.code is unknown-provider, provider-unavailable, unknown-model (a retired model looks like this) or capability-missing, with a sentence to show. Use it at load, and to price a re-embed.

Price a call before making it#

estimate(role, { input | messages, system?, skills?, tools?, maxOutputTokens?, steps? }) prices a call through one of your roles without making it: no request goes out and no ledger row is written. It resolves the role as a call would and takes the first model that could serve it, with the same checks for capabilities, prices and the per-call limit.

  • { ok: true, low, high, usd, model, billing, inputTokens, outputTokens, approximate: true }. low is one step with a short reply. high is every step with a full reply and its thinking room, each step's input grown by the step before. usd is between the two.
  • { ok: false, reason, message } with the code the call itself would get: no-models, unpriced-model, over-ceiling, capability-missing, tool-unknown and so on.
  • For an agent run, pass its maxSteps as steps and the tool names it offers as tools; their descriptions and schemas count as prompt.
  • For many calls of one shape (one per chapter), pass times: the price is multiplied, and inputs don't grow as they do over steps.
  • For text you haven't read yet, pass its size as inputTokens; it is added to the prompt. With times, also pass the longest call's as longestInputTokens: the per-call limit is checked against it, since that call is the one refused at run time.
  • To price a decide() call, pass the request as decision: its own prompt and a 512-token reply.
  • With webSearch: true the model must be one that searches. The provider's search fee isn't known, so the result says so in notCounted.
  • high leaves out tool results unless you pass toolResultTokens: the most tokens of tool results fed back after each step (a tool that returns a 15k-token note, called twice a step: 30 000). It also leaves out a fallback to another model after a failure, and retries.
  • A plan that a budget is checked against should use high, and the run should still carry its budgetUsd as a hard cap.

For search over your own index, bundle the shared retrieval library rather than writing BM25 and fusion yourself: npm install @insurgently/retrieval (https://www.npmjs.com/package/@insurgently/retrieval). | tier, model, candidates | Another tier, one model or a list for this run. Only for roles with allowAnyModel. | | routing | Routing merged into each model's own for this call, such as an OpenRouter price cap. | | hosts | How OpenRouter picks a host for this call: cheapest, balanced (the fastest at or under the listed price) or fastest. Over the role's and the tier's setting. Any role. | | reasoning | How much the model may think on this call (off, low, medium, high), over whatever the role or its models say. Any role. | | run | The id of a run from startRun (it must be your plugin's): the call's cost counts against the run's budget and is recorded with it. | | attributes | Your own labels for the call, such as { collection: 'garden', pass: 2 }, put on its trace span as hub.attr.<key> when the user has traces on, so your logs can be matched to traces. Up to 16; keys in lowercase letters, digits, _ and .; text values up to 200 characters. Malformed ones are refused with invalid-request. They go to the user's collector, so put no note text in them. (0.12) |

What every finished call reports:

FieldWhat it holds
servedModel, servedProviderWho answered.
usageTokens in and out, cached and reasoning tokens.
listedUsdThe cost at listed prices; null when unknown.
billedUsdWhat the provider says it charged, when it says.
overchargedBilled well above listed.
attempts, fellBackEvery model considered, in order, and whether the answer came from a fallback.
budgetsThe monthly budgets that apply, with this month's spend; warn at 80%.
ledgerIdThe call's row in the ledger.

Models, skills and usage#

CallWhat it does
models(filter?)The models the Hub knows, filtered by provider or by capability, for pickers.
skills()The skills the Hub can apply: its own, those plugins registered, and the vault's. Each has files() for its own text files.
registerSkills(pluginId, skills)Registers your built-in skills, each as a folder of text files with a SKILL.md. Replaces what you registered before.
skillsReady()Resolves once the vault's skills have loaded.
onSkillsChanged(cb)Calls cb when skills change. Returns the function that stops it.
usage(filter?)Ledger rows: every call, with tokens and cost.
spend()What has been spent and what is left: each tier's metered spend this month (UTC) against its monthly budget, and a report per provider (a subscription's session and week percent with reset times, OpenRouter's key limit, local tokens, others' month through the Hub). A figure that couldn't be had is in unavailable with why. Ledger rows of Claude subscription calls carry limits.

A skill your plugin registers is used only when a call names it; it can't be always on. A vault skill with the same name overrides yours.

Offer a skill to MCP clients#

Since API 0.7. Add mcp-prompt: true to a skill's SKILL.md frontmatter and the Hub's MCP server offers it as a prompt of the same name, with your description and one optional argument, focus. The prompt is one user message: the skills it extends (root first), its body, each of its files under ## <path>, then ## Task and the caller's focus. It is cut at 100 000 characters, with the cut stated. A skill whose parent isn't loaded is not offered, and the Tools page says why. The user can turn all prompts off on the Tools page, and a vault copy of your skill is offered only if the user marked it. skills() shows the mark as mcpPrompt.

Write such a skill for an agent that has your tools: name the tools it should call, and say what focus may hold.

Tools and runs#

Since API 0.6. The guide is Register tools from your plugin.

CallWhat it does
registerTools(pluginId, tools, { undo })Registers your tools, each { name, description, input, effect, handler } with an optional undoHandle. Replaces what you registered before. undo takes a handle a write returned and puts the change back.
unregisterTools(pluginId)Removes your tools. Your namespace stays yours.
tools()Every registered tool, with its owner and the policy now in force.
callTool(pluginId, name, input, { run, signal })Runs a tool through the user's settings. Without run it is a run of its own. pluginId names the caller in the approval dialog and the log; it grants nothing.
startRun(pluginId, { budgetUsd, tools })Opens a run, owned by your plugin: a spending limit for model calls and, if tools is given, the only tools it may call.
run(pluginId, runId)The run as it is now: what it has spent and the undo handles of its writes. undefined if it has ended or isn't yours.
endRun(pluginId, runId)Ends a run and stops its calls in flight. A run belongs to the plugin that started it; another caller's run is refused.
undo(owner, handle)Puts back a write. Resolves to what it put back.
toolCalls(filter?)Rows of the tool-call log. Arguments are a hash only.
onToolsChanged(cb)Calls cb when tools are registered or removed. Returns the function that stops it.

Agent runs#

Since API 0.10. The guide is Run a tool loop from your plugin.

CallWhat it does
agent(role, { system, messages, tools, finishTools, budgetUsd, run, maxSteps, tier, model, skills, signal })Runs a tool loop on one of your roles. Returns at once with { id, events, result, stop() }; result resolves on every end. Refusals (another caller's run, an unknown tool, a finish tool not in tools, no tool registry) throw before anything starts.

Edits#

Since API 0.9. Change a user's notes safely: preview first, apply all or nothing, undo one op.

const p = await hub.previewEdits(this.manifest.id, [
  { path: 'Books/Dune.md', ops: [{ oldText: 'status: reading', newText: 'status: read' }] },
]);
// p.files[i].ops[j]: where each op matched, how ('exact', 'normalized', 'fuzzy'), and its risk flags
const r = await hub.applyEdits(this.manifest.id, p.id);   // or { ops: [{ path, op }] } for some
await hub.undoEdit(this.manifest.id, r.undo[0].handle);
  • Ops, not rewrites. { oldText, newText, replaceAll? }. The Hub finds oldText exactly, then with quotes, dashes and spaces normalized, then (for ops of 24 characters or more) by a strict fuzzy match. Each placed op says which. The note's own characters, line ends and BOM are kept.
  • All or nothing. A preview with an op that can't be placed is refused. An apply checks every range in every file first; if one changed since the preview, nothing is written. If a write fails part-way, the files already written are put back.
  • One op, one step. In an open note each op is one editor transaction, so the user's Cmd+Z undoes one. Each op also gets a snapshot (kept 30 days, 20 MB in all) and an undo handle; undoEdit refuses if the text it wrote has changed since.
  • Risk flags. Each op is checked for changed numbers, dates, links and negations. Pass riskChecks to add your own, such as character names.
  • Excluded files read as missing.
  • For pure work without the vault (validating revision cards, building anchors, cut checks), bundle the shared text library: npm install @insurgently/text (https://www.npmjs.com/package/@insurgently/text).
Package
@insurgently/model-hub-api
Plugin id
model-hub
Needs
Obsidian 1.13.1, desktop
License
MIT