Agent tools — letting an AG-UI agent drive the workbench
This is a guide, not the contract. What the platform guarantees is specified under
openspec/specs/. For this page:commands·platform-composition. Where this page and a specification disagree, the specification is right, and that is a defect in this page: change the behaviour there, then explain it here.
AG-UI is the protocol between a user-facing application and an agentic
backend. @loomweaver/ag-ui is the adapter: it describes the workbench’s own commands to an agent as tools,
and runs what the agent asks for through the same seam every other trigger runs through.
The rule underneath it is the one from callable commands: an agent reaches what the user could have reached, and nothing more. This package adds no way past that.
This page is the adapter, function by function. For the path from a generator command to a product an agent drives, read driving your product with an AG-UI agent first.
The whole integration
import { commandTools } from '@loomweaver/ag-ui';
const tools = commandTools(ctx);
// when a run starts — ask again each time, never keep the answeragent.runAgent({ tools: tools.list(), threadId, runId, messages, context: [] });
// for every event the run producesconst answer = await tools.receive(event);if (answer) { messages.push(answer);}
// once the run is over, answer every call the agent left openfor (let left = await tools.flush(); left; left = await tools.flush()) { messages.push(left);}receive answers a message when an event completed a call, and null otherwise. Events that are not
part of a tool call are ignored, so you can hand it everything without filtering first. The event that
ends the run answers only the first call still open; flush answers the next one each time it is
called, and null once none is left.
What you get, and what you do not
The package is headless. It brings no transport, no user interface and no agent: you open the connection, you draw the conversation, and you decide what the agent is. It has no Angular dependency and no observables: you hand it one event at a time, which is also why it is easy to test.
@ag-ui/core and @loomweaver/plugin-sdk are peer dependencies, so a weaver that also builds its own agent
with @ag-ui/client resolves one copy of each rather than two.
Ask for the list each run
tools.list(); // readonly Tool[]list() reads ctx.invocableCommands() at the moment you call it. Do not keep the answer. What a
plugin may reach changes as plugins load and unload and as the session changes, and a list kept from
earlier promises actions that are no longer there.
Each entry is already narrowed by everything that would refuse the invocation. The command declared
itself callable, the session meets its access, and it belongs in this window. For a command your
plugin did not register, your plugin also holds the automation capability.
The pieces, if you want less than the loop
| Export | What it is |
|---|---|
commandTools(ctx, options?) | The whole integration: the list, the loop and the hook. Returns CommandTools. |
CommandTools | list(), receive(event) and flush(). flush answers one call an agent left open, or null once none is left; the event that ends a run answers only the first. |
CommandAccess | The slice of a plugin context this touches: invocableCommands and invokeCommand. Your ctx satisfies it; it is narrower on purpose, because it says exactly what an agent reaches through here. |
toolFor(command) / toolsFor(commands) | The mapping alone, if you assemble the request yourself. An InvocableCommand becomes a protocol Tool, with the declared arguments as JSON Schema. |
readArguments(json) | Reads the JSON an agent streamed into arguments the workbench can be handed, or null where it cannot. |
resultFor(toolCallId, outcome) | Turns a CommandOutcome into the message that goes back, if you ran the call yourself. |
The hook: confirming, declining, answering
A weaver may sit in front of every call. This is where a confirmation before a heavy step belongs, and where a product’s own policy goes. Most products want the same one, so it ships:
import { commandTools, consentPolicy } from '@loomweaver/ag-ui';
const tools = commandTools(ctx, { before: consentPolicy(() => ctx.ui.confirm({ message: 'agent.confirm' })),});consentPolicy(confirm, wording?) acts on what each command states about an agent’s word, which the
next section describes:
| The command states | What the policy does |
|---|---|
never | Declines the call and tells the agent so. Nobody is asked. |
allow, or nothing | Lets the call through. Nobody is asked. |
ask | Asks through your confirm. A yes is remembered for as long as the policy lives, so it is asked once. |
ask-always | Asks through your confirm on every call. Nothing is remembered. |
A no declines the call and tells the agent so. confirm receives the pending call, so it can name
the command. wording takes { never, refused }, what the agent is told in each case, where you want
your own words.
The memory lives in the policy, so create one per connection and two connections never share an answer. Nothing outlives the connection: remembering consent beyond it carries privacy weight, and a product that wants it writes its own hook. Applying the policy is your choice. A connection without it asks nothing and decides nothing, exactly as before.
Where you need something else, write the hook yourself:
const policy = consentPolicy(() => ctx.ui.confirm({ message: 'agent.confirm' }));const tools = commandTools(ctx, { before: async (call) => { if (call.commandId === 'notes.export') { return { decision: 'answer', content: 'Exports are run from the menu.' }; } return policy(call); },});before receives a PendingToolCall holding the toolCallId, the commandId, the assembled
args and the command’s own agentConsent, and answers a ToolDecision:
| Decision | What happens |
|---|---|
{ decision: 'run' } | The call goes to the workbench. This is what happens when you supply no hook at all. |
{ decision: 'decline', reason } | The workbench is never asked. The agent is told the call did not run, and why. |
{ decision: 'answer', content } | You serve the call yourself. The agent gets your content as the result. |
It may be asynchronous, which is what a confirmation needs.
A decision can only narrow. Letting a call through does not make it reachable: the workbench refuses what it always refused, whatever was decided here. The hook is a place to say no, not a way to say yes to something the user could not have done.
CommandToolOptions is the shape you pass; today it carries only before.
What the hook decides from
call.agentConsent is what the command declared about an agent’s word: allow, ask,
ask-always, never, or undefined where the command says nothing. It is read off the workbench’s
own account at the moment of the call, so it is never a list you keep. undefined also covers a
command that is not reachable at all. That refusal is the workbench’s to make, and it makes it as it
always has.
Declare it beside the command, not here:
ctx.registerCommand({ id: 'notes.deleteAll', title: 'notes.deleteAll', description: 'Deletes every note in the notebook.', callable: true, agentConsent: 'ask-always', run: () => store.clear(),});The platform states it and enforces nothing. No dialog is shown for you, no answer is
remembered, and no invocation is refused on this account: a command declaring ask-always still
runs when it is invoked. Acting on the statement is yours, which is what this hook is for, and that
includes never. consentPolicy is that hook written once, and it acts only where you pass it.
Leaving callable off is what the platform enforces, and it closes the command to every caller but
the plugin that registered it. The connection holds the agent to the same boundary: it runs a call
only where the command is among the tools it offers at that moment, and refuses anything else,
saying why. Your own unopened commands are not offered, so an agent cannot reach them through your
ctx either. To give one to the agent, open it with callable: true.
Each tool is named after its command in the form the common model interfaces accept: letters,
digits, underscores and hyphens, at most 64 of them, with each dot spelled as two underscores. So
notes.open is offered as notes__open. A call comes back under that name, and the hook and the
answer speak of the command by its own id.
toolFor carries the statement into the tool’s metadata under the same name, so an agent host
that treats a consequential tool differently reads it with the tool. A command that says nothing
carries no metadata at all.
What the agent gets back
The workbench answers one of three things, and all three cross faithfully, because the protocol has
one error field and its own reason for having it: without it, a tool that failed cannot be told from
one that succeeded.
| Outcome | Result |
|---|---|
| answered | The value as content. A command that declares no answer reports plainly that it ran, rather than returning an empty string an agent would read as a failure and retry. |
| refused | An empty content and an error saying the command did not run. |
| failed | An empty content and an error saying the command ran and failed. |
The wording keeps the distinction the workbench preserved, so an agent can tell “you may not” from “it broke” and choose differently.
What the agent never learns
The refusal wording is the same for every reason a command cannot be reached: no such command, not
callable, the session does not qualify, the wrong window, no grant. That is deliberate, because
telling them apart would let an agent map what is installed by calling ids and reading the reason
back.
Streamed and chunked calls
A call arrives as a start, a stream of argument deltas, and an end. Calls in that form may interleave,
because every event carries its own toolCallId, and the adapter keeps them apart.
The convenience form, where one chunk event carries whichever of those fields it has, is inherently sequential: a chunk naming a new call closes the one before it. Both forms end up in the same place.
Arguments that do not arrive as readable JSON are refused before the workbench is asked. Arguments that read fine but do not match what the command declared are refused by the workbench, which is where that check belongs.