DEV Community

Cover image for Intent-driven UIs: an Angular workbench your AI assistant operates
Norbert Rosenwinkel
Norbert Rosenwinkel

Posted on

Intent-driven UIs: an Angular workbench your AI assistant operates

Over the last months I have heard the same wish from my customers again and again. They want to click less in the UI and have an AI assistant do more of the work. They want to say what they intend, without knowing the UI well. The assistant carries it out, asks when something is missing, and asks for approval when something should not just happen. I have started calling this intent-driven UIs, and I think a good part of business software is heading there.

The assistant panel after one sentence: three tool calls, their results, and the answer

This tutorial shows one way to build such a thing, as inspiration for your own ideas. We build a small support inbox as a desktop-like workbench in Angular, with a list in the sidebar and a tab per ticket. Then we let an assistant work with it. One rule holds throughout, and it is the reason I am comfortable shipping something like this: the assistant can do nothing the person at the keyboard could not also do by hand.

What we build, and what we count

The inbox has six tickets. The workflow we measure: find the ticket about the blank invoice PDF, assign it to Dana, and reply that the fix ships on Monday.

Path Clicks Typed
By hand 5 (ticket, assignee select, the option, reply field, send) one reply
With the assistant 2, one of them the approval of the reply one sentence

That is one workflow in one small example, and I am not turning it into a percentage. It shows the shape of the thing. You need Node, the Angular CLI and an API key from OpenRouter. The model is free there; a free key allows about fifty requests a day, and one run costs three to five.

Who provides what

AG-UI is an open protocol between an application and an agent. It describes the tool calling you know from function calling at OpenAI, as a format between frontend and agent. One round, a run in the protocol, goes like this: the application sends the conversation and the list of allowed tools. The agent answers with a stream of events: text for the user, tool calls, their results, and at the end "finished".

Three layers meet in the code, and keeping them apart makes the rest easy:

Layer Package What it provides
The protocol @ag-ui/core vocabulary only: the event types, the shape of a tool and of a tool result
The adapter @loomweaver/ag-ui the logic on the workbench side: list() offers the workbench's commands as tools, receive(event) runs a tool call and answers
The agent your product talks to the language model and translates in both directions; the generator writes a placeholder

So the platform does not ship a finished assistant. It ships the workbench, the adapter and a generator that writes a starting point into your project. From there on it is your code. If you want the longer version: Whose code is which.

Step 1: scaffold the workbench

ng new assistant-workbench --style=css --ssr=false && cd assistant-workbench

npx @loomweaver/cli init --title "Assistant Workbench" --weaver tickets
npx @loomweaver/cli weaver --id assistant --agent --out src/assistant
npm install

npm start
Enter fullscreen mode Exit fullscreen mode

The shell behind these commands is LoomWeaver, an open-source plugin platform for Angular workbenches, and my own project. I use it because it brings the adapter from the table. Without it, the middle of this tutorial would be a tool registry and a dispatcher.

init installs the platform into the fresh application, replaces the bootstrap wiring with a composition root, and scaffolds a first plugin, tickets. The second command adds the assistant plugin; it records two packages in package.json and says so, which is why npm install runs once more.

After npm start you have a workbench with panes, tabs and a command palette. The tickets plugin has an example command and a view. The assistant plugin has three files under src/assistant/src/lib/agent/: the connection assistant-agent.ts, the panel assistant-agent-panel.ts and the placeholder assistant-agent-source.ts. The placeholder speaks only the events, with no model and no network, so the whole path runs before you have connected anything. Click through it once before you read on.

The panel sits on the right because the plugin registers it that way. docks names the region, padded asks for some air, because the workbench insets nothing on its own:

Generated · src/assistant/src/lib/plugin/assistant.plugin.ts

ctx.registerSurface({
  id: 'assistant.agent',
  title: 'assistant.agent.title',
  icon: 'assistant',
  docks: ['right-panel'],
  padded: true,
  component: AssistantAgentPanel,
});
Enter fullscreen mode Exit fullscreen mode

If you want it on the left, change docks. Which regions exist is declared in src/app/app.config.ts, the file where the whole product is composed; more under Reaching the pane edges.

Step 2: two additions to the app

The Content-Security-Policy in src/index.html allows the app's own origin only. The assistant calls OpenRouter from the browser, so it is added:

Changed · src/index.html

connect-src 'self' https://openrouter.ai;
Enter fullscreen mode Exit fullscreen mode

And the icon bar on the far left, the rail, which so far holds only the tickets icon, gets the workbench's settings gear: an entry in app.config.ts that triggers the built-in command shell.openSettings. Why we want it shows up when we try things out.

Changed · src/app/app.config.ts

...provideRailItems({
  id: 'workbench.settings',
  rail: 'primary',
  icon: 'settings',
  title: 'settings.title',
  anchor: 'bottom',
  order: 20,
  command: 'shell.openSettings',
}),
Enter fullscreen mode Exit fullscreen mode

Step 3: turn an operation into a command

The domain is an object with a signal holding six tickets and the operations list, get, assign, reply and setStatus. It lives in src/tickets/src/lib/tickets/ticket-store.ts, it is in the example, and it is deliberately dull.

The interesting part is how the operations are registered. A command is the thing a button, a shortcut, a palette entry and a menu item all point at together. We replace the generator's example command with five. Here is the one that opens a ticket:

Changed · src/tickets/src/lib/plugin/tickets.plugin.ts

ctx.registerCommand({
  id: 'tickets.open',
  title: 'tickets.open.title',
  description: 'tickets.open.description',
  arguments: [
    { name: 'number', kind: 'text', required: true, description: 'tickets.open.number' },
  ],
  answers: 'tickets.open.answers',
  callable: true,
  run: (_context, args) => {
    const opened = ticketActions.open(String(args?.['number']));
    return { ...opened, replies: opened.replies.map((reply) => reply.text) };
  },
});
Enter fullscreen mode Exit fullscreen mode

Three fields do the work for the assistant, and the adapter turns them into the tool:

  • arguments becomes the JSON schema. The workbench checks every call against it before run runs.
  • answers makes the return value the answer. Without it the assistant would be blind to what it just did.
  • callable: true opens the command to callers other than its own buttons, the assistant included. It is off by default, on purpose.

The description is the text the model reads when it chooses between tools. The one for tickets.list reads:

Lists the support tickets with number, customer, subject, status and assignee. Use it to find a ticket when only its topic is known.

That sentence turns "the ticket about the blank invoice PDF" into T-1041 without a search. Whether you have thought of everything is what npx @loomweaver/cli validate-commands --dir src/tickets tells you. It lists every command and names where an agent would have to guess. More under Callable commands.

Step 4: a list in the sidebar, a tab per ticket

This is what makes it a workbench. Instead of one page we register two surfaces: the list, in the left sidebar, and the ticket, as a tab with its own address tickets/:number.

Changed · src/tickets/src/lib/plugin/tickets.plugin.ts

ctx.registerSurface({ id: 'tickets', title: 'tickets.title', icon: 'tickets',
  component: TicketListView, docks: ['left-panel'] });

ctx.registerSurface({ id: 'tickets.ticket', title: 'tickets.ticket.title', icon: 'tickets',
  component: TicketView, routable: { path: 'tickets/:number' } });
Enter fullscreen mode Exit fullscreen mode

Opening a ticket means opening a tab at that path. The click in the list and the tickets.open command go through the same function, so the two cannot drift apart:

New · src/tickets/src/lib/plugin/tickets-actions.ts

open(number: string, options: { preview?: boolean } = {}): Ticket {
  const ticket = ticketStore.get(number);
  ctx?.openContentTab({
    path: `tickets/${ticket.number}`,
    title: ticket.number,
    titleIsLiteral: true,
    icon: 'tickets',
    preview: options.preview ?? false,
  });
  return ticket;
},
Enter fullscreen mode Exit fullscreen mode

A click opens a preview that the next click reuses, a double click keeps the tab. The ticket view in src/tickets/src/lib/views/ticket-view.ts reads its number from the route; its buttons call the same store functions the commands call. There is no second code path for the assistant. More under Opening tabs from code.

Step 5: human in the loop, which command needs a person

The generated code has a checkpoint that runs before every tool call the assistant makes. This is the human-in-the-loop moment: for certain commands it shows the person at the keyboard a confirmation dialog. If they say no, the command does not run, and the assistant learns that it was declined. We change only one line: which commands need that confirmation.

Changed · src/assistant/src/lib/agent/assistant-agent.ts

const CONSEQUENTIAL = new Set(['tickets.reply']);
Enter fullscreen mode Exit fullscreen mode

The confirmation dialog:

A test for what belongs in that set: would you want it to happen while you were looking away? Sending, deleting, publishing, yes. Opening and listing, no, because asking about everything trains people to click the question away.

Step 6: the agent

We replace assistant-agent-source.ts entirely but keep the placeholder's shape: a function that gives off event after event while it works, an async generator in TypeScript. The panel hands it the request:

New · src/assistant/src/lib/agent/assistant-agent-source.ts

export interface AgentRequest {
  readonly runId: string;
  readonly prompt: string;
  readonly tools: readonly Tool[];
  readonly key: string;
  readonly receive: (event: BaseEvent) => Promise<ToolMessage | null>;
}
Enter fullscreen mode Exit fullscreen mode

tools are the tools from list(), asked for again on every round. receive is the door into the workbench: every event goes in there, and for a completed tool call the result comes back. The loop below is the usual tool-calling loop. Ask the model. If it wants to call tools, run them and return the results. Repeat until it answers in words.

async function* ask(history, request, fetchLike): AsyncGenerator<BaseEvent> {
  yield event(EventType.RUN_STARTED, { threadId: 'assistant', runId: request.runId });
  history.push({ role: 'user', content: request.prompt });
  try {
    for (let round = 0; round < MAX_ROUNDS; round++) {
      const message = await complete(fetchLike, request.key, history, request.tools);
      history.push(message);
      if (message.content) {
        yield* say(`${request.runId}.${round}`, message.content);
      }
      const calls = message.tool_calls ?? [];
      if (calls.length === 0) {
        break;
      }
      for (const call of calls) {
        const answer = yield* relay(call, request.receive);
        history.push({ role: 'tool', tool_call_id: call.id, content: answer.error ?? answer.content });
      }
    }
    yield event(EventType.RUN_FINISHED, { threadId: 'assistant', runId: request.runId });
  } catch (failure) {
    yield event(EventType.RUN_ERROR, { message: describe(failure) });
  }
}
Enter fullscreen mode Exit fullscreen mode

complete is one HTTP request to OpenRouter, in the same format as OpenAI's: the conversation plus the tools, translated into its function format. One detail: function names there may not contain dots, so tickets.open travels as tickets__open.

relay translates back. One tool call from the model becomes the protocol's three events, start, arguments, end, and each goes to receive:

const start = event(EventType.TOOL_CALL_START, { toolCallId, toolCallName: commandId(call.function.name) });
yield start; await receive(start);
const args = event(EventType.TOOL_CALL_ARGS, { toolCallId, delta: call.function.arguments || '{}' });
yield args; await receive(args);
const end = event(EventType.TOOL_CALL_END, { toolCallId });
yield end; const answer = await receive(end);
Enter fullscreen mode Exit fullscreen mode

On the end event, the adapter first runs the human-in-the-loop check from step 5, then the command. Back comes the answer for the model: the return value, a refusal or a failure, worded so the model can tell "you may not" from "it broke". Nowhere here is a list of tools or a switch that routes calls; the adapter behind receive does that.

Step 7: the panel

The generator also gave the assistant an example command with a shortcut, a view with a route and a rail item, so that the rail shows something on the first serve. They go now, and the test that pinned the example command goes with them, because the panel is everything this plugin contributes.

The generated panel offered one button per tool. We give it a text field for the sentence and a field for the key, which stays in local storage (openrouter-key.ts) and goes to OpenRouter only. The send method builds the request from step 6:

Changed · src/assistant/src/lib/agent/assistant-agent-panel.ts

const request = {
  runId: `run-${++this.runs}`,
  prompt,
  tools: offered,
  key,
  receive: (event: BaseEvent) => this.receive(tools.receive(event)),
};
for await (const event of this.agent.ask(request)) {
  this.draw(event);
}
Enter fullscreen mode Exit fullscreen mode

The model is one constant, dots-studio/dots-3-note-preview:free. Any free model on OpenRouter with tool calling will do, and you will need another one sooner or later, because the free ones come and go; the list is filtered and the example says so where the constant is.

Try it

Paste the key into the panel and type:

Open the ticket about the blank invoice PDF, assign it to Dana and reply that the fix ships on Monday.

The panel shows what the model does: list the tickets, find T-1041, assign it, and at the reply the workbench asks you. Say yes, and the ticket's tab comes to the front, with Dana and the reply. Say no, and the workbench was never asked. For me the whole thing took under ten seconds. Sometimes the model opens the ticket first, sometimes not; the order belongs to the model, only the boundary is yours.

Then take something away from it. Gear, Permissions, switch off "Run actions other plugins added" under Assistant, close the dialog, click into the input: "0 tools offered".

The assistant's permissions:

Ask it something now, and it can do nothing. We did not build that. The adapter asks on every run what the plugin may reach, and the workbench answers with what you left it. More under Permissions.

The boundary, and what changes for production

The checkpoint from step 5 can only say no, never allow more. A yes only means the product has no objection. After that the workbench checks what it always checks: whether the command exists, whether it is callable, whether the signed-in person would be allowed to run it. And every refusal reads the same to the model, so an assistant cannot learn what is installed by asking. Details under What the agent never learns.

For production, nothing in the workbench changes. The key in the browser is fine for a tutorial and not for a product. There the agent runs on your own server, in whatever stack, and the ready-made client @ag-ui/client fetches its events into the same receive() loop. Panel, connection and commands stay as they are. The frontend does not care where the model lives.

The code

The example lives in the LoomWeaver repository, and you can fetch it on its own:

npx degit yesbert/loomweaver/examples/assistant-workbench#tutorial-intent-driven assistant-workbench
cd assistant-workbench && npm install && npm start
Enter fullscreen mode Exit fullscreen mode

The live demo has an assistant panel too, and the guide to AG-UI agents covers what is missing here.

I would be curious what intent-driven UIs look like in your domain, and where the line between "just do it" and "ask me first" falls for you. That line is, I think, the actual design work.

Top comments (0)