An agent run is a model that works in steps. It looks at the messages, asks for tools, reads what they return and asks again, until it has an answer. agent() runs that loop for you, so your plugin doesn't write it. Each step is a model call on your role, each tool call goes through the same gate as any other (the user's policy, approvals, the log), and the run's budget covers both. It needs API 0.10, and tools registered as in Register tools from your plugin.
Start a run#
Name one of your roles, a system prompt, the opening messages and the tools the model may use.
const run = hub.agent('mine.coach', {
system: 'You review a draft. Check each claim with screen, then hand in what holds.',
messages: [{ role: 'user', content: draftText }],
tools: ['mine.screen', 'mine.submit'],
finishTools: ['mine.submit'],
budgetUsd: 0.5,
});
const result = await run.result;
if (result.reason === 'finished') use(result.final);
run.result resolves on every end, stopped and error included, and never rejects. Read result.reason:
reason | Meaning |
|---|---|
answered | The model replied with no tool calls. text is the reply. |
finished | A finish tool returned without an error. final is its output. |
step-limit | maxSteps ran out, including a last-step answer while tools are on offer. lastError holds the finish tool's last error, if it had one. |
budget | A call was refused as over budget. |
stopped | stop() or your signal ended it. |
error | A call failed. result.error says why. |
role decides who pays: the ledger credits the plugin that owns the role's namespace, and tool calls are made as that plugin. tier, model and skills work as on stream(). Spend already made stays in the ledger whatever the end.
Finish tools#
Without finish tools, a run ends when the model stops asking for tools, and text is the answer. That suits a chat. When you want structured output, name the tool that hands it in. finishTools is a subset of tools. When one returns without an error, the run ends and that tool's output is final, whole, even if the model was shown a cut copy.
When a finish tool returns an error, the error goes back to the model like any tool result and the loop goes on. That is how a tool says "these three items were dropped, try again". How many tries it allows is the tool's business.
The last step#
maxSteps defaults to 8. On the last step with finish tools, the model is offered only the finish tools, and a message is added: "This is the last step. Call mine_submit now with what you have." If it still doesn't hand in, the run ends step-limit with no final. Without finish tools, the last step offers no tools, so the model answers with what it has.
A third call to a tool with the same input gets the error repeated-call as its result instead of running, so the model can try something else.
Follow progress#
run.events is an async iterable, for a progress view. One consumer may read it; if you don't, you still get result.
for await (const e of run.events) {
if (e.type === 'step-start') status.setText(`Step ${e.step + 1}${e.last ? ' (last)' : ''}`);
if (e.type === 'tool-call') status.setText(`Running ${e.name}`);
if (e.type === 'step-end') cost.setText(`$${e.spentUsd.toFixed(4)} so far`);
if (e.type === 'text') reply.append(e.delta);
}
The events are step-start, text, tool-call, tool-result (with ok and, on a failure, error), step-end and a final end that carries the result.
Stop, and budget#
run.stop() ends the run: the call in flight is aborted and result resolves stopped. Passing a signal does the same. The loop never goes on after a stop.
budgetUsd caps the run's spend on model calls. A call refused as over budget ends the run budget. To share one budget, one tool list and one undo list across several runs or calls, open a run with startRun and pass its id as run. The loop then works in it and leaves it open; budgetUsd is ignored. A run is yours alone: another plugin's run id is refused.
Refusals#
These throw at once, with no run started and no cost:
invalid-requestwhen no plugin has registered the role.invalid-requestwhenrunbelongs to another caller, or a tool intoolsisn't in that run's tool list.invalid-requestwhen a tool intoolsdoesn't exist or isn't offered to plugins.invalid-requestwhen a finish tool isn't intools, ormaxStepsisn't a whole number of 1 or more.invalid-requestwhen this Hub keeps no tool registry.
Everything after the start comes back through result.
If the Hub is reloaded#
A run lives in memory. When the user reloads Obsidian or the Hub, the run is gone and nothing resumes it; start it again. Keep what matters (the draft, the findings you have so far) in your own plugin, and treat a run as one attempt.
Without Obsidian#
The same loop runs in the headless build, for evals and scripts. It takes the plugin id explicitly and has no approval dialog, so a tool set to ask is denied.