
Articles
|
Agentic AI in Vue: Pinia-Based Architecture in Directus
How Directus built agentic AI into its Vue app: a Pinia-based tool store, foreground vs. background execution, and the tradeoffs worth planning for.

Rijk van Zanten
CTO, Co-Founder

This blog post is adapted from Directus CTO / Co-Founder Rijk van Zanten's talk Guiding Agentic AI with Vue and Pinia which you can watch here:
For the past year or so, we’ve been trying to get AI to be genuinely useful inside Directus. This is a tough challenge, as there aren’t a lot of common design patterns out there yet that are proven to be valuable in day to day usage.
Most AI integrations I see in apps follow the same pattern: the user prompts with a task, the LLM executes a server tool, the UI refreshes, and the task is done. It works, but leaves a lot of UX benefits on the table.
This post covers the architecture we landed on at Directus for going further: letting an AI agent actually do things on the client side of your app, rather than relying on behind-the-scenes execution exclusively.
Our approach to building this relies on registering individual tools directly from Vue components into a global Pinia store, which is used as the source of truth of what client tools are and what context is available in the current app state.
Think in terms of ‘a third interface’
First things first: when you add agentic capabilities to your app, you're effectively building a third interface. We’ve historically thought primarily in a 2-way split. We have the client side GUI for humans, and a (set of) APIs for programmatic access. It’s easy to mistake LLM access as just another API interface, after all the mechanism is quite simple: a tool is just a function with a name and some input parameters, the LLM picks and asks to execute the tool. MCP feels quite similar to GraphQL in that regard.
However, LLMs require more intentional design and should therefore be treated as a “third interface”. It sits between the very human UX flow driven GUIs and pure static definitions of many APIs. LLMs — especially when used from within a product — need a lot more guidance and handholding in order to be effective.
Foreground vs background interactions
Before getting into actual implementation, there's a design decision that shapes our implementation details: is this a background interaction, or a foreground one?
Background agents work autonomously. You give them a task and they run. This is currently the most common pattern and is suitable for many tasks. "Import all the CSV files in this folder" is something you want to happen quietly in the background. Just do it, and let me know when it's done.
Foreground interactions are different however. The user is right there, actively watching the chat doing its thing. For those types of interactions, showing the work matters as much as doing it.
For example, when a user says "create a new to-do", you have two options. You can either have the AI calls a server-side tool which creates the item directly in the database and refresh the UI, or you can have the AI trigger a set of tools on the client side to walk the user through what’s happening. The first option is great as a “shortcut” for common tasks that you can allow the user to skip or automate away, and is often the right starting point for implementing tools. It does however hide away the details of how something is done, and what actually happens behind the scenes. The second approach is way more educational. The user learns the workflow while the agent is executing it. They understand what just happened, as they saw it happen in real time. That flow builds a lot of trust.
You can push this approach even further with a virtual “agent cursor” that explicitly moves to the elements that are used in the flow. Have it click buttons, type in inputs, etc. You might think that that’s a lot slower than actually doing the work — and depending on the work it might be — but you can use the virtual interaction as a loading state. LLMs (at time of writing at least) are still rather slow to go through the reasoning steps to figure out what tools to call, so having something happen on the screen in the meantime is a great way to keep the user engaged. Users are patient when something is visibly happening, they’re not patient when the UI just freezes for a while. (I did a talk a while back about making apps feel fast even when they aren't. Same principle applies here.)
That all being said, both modes are valid! There’s a time and place for either method. Even better: there's no reason to pick just one. Give the agent both options, and let it choose based on what the user asked for.
Actually building it: Vue + Pinia
The plumbing to get this going in your app can be surprisingly simple. It’s a three-parter:
Step one: a Pinia tool store. You need some global state to collect the tools and context that should be presented to the LLM based on your current app state. In our case, that’s just a simple Pinia store with some util functions to register and deregister tools and context.
Step two: a defineTool composable. A bit of DX nicety that makes it trivially easy to register tools from within the Vue components that should execute the handlers. We can rely on the Vue lifecycle to automatically register and deregister these tools based on the mounted state of the component. If a button isn't visible in your app, the AI can't press it. Component unmounts, tool disappears, AI is no longer confused by it. It's similar in spirit to defineShortcut in Nuxt. It gives us the same DX ergonics.
Step three: pass tools to the LLM. In Directus we’re using the Vercel AI SDK. You pass the tools and context from the Pinia store in the request body, and when a tool call comes in, you scan the store to confirm it still exists before executing it.
That's the basic plumbing, not too complicated. The hard part is making those tools actually good. 😄
How you make those tools actually good
Naming and granularity. This was mistake number one for us. At first, we were tempted to avoid context bloat by reducing the total number of tools we’re making available to the model. However, that instinct was wrong. Models perform much better with specific, well-defined tools than one big Swiss Army knife. That did mean we had to pivot to some other approach to avoiding tool context bloat. In Directus, we ended up going with a tool catalog approach. We maintain a searchable store of available tools, and only make a set of meta-tools available to the model: search, describe, and execute. The LLM can then use those meta-tools to search and learn about specific tools and execute them. This allows us to only load the full tool definition and input schema into the context when it’s needed rather than at all times.
Descriptions do the heavy lifting. The input schema tells the model what shape the data needs to be, but in practice, the description is what actually changes behavior. Write it like documentation for a very junior developer who takes everything at face value. Everything you tell it is the only thing it has ever known. Be accurate and specific, but watch for unnecessary language and pleasantries.
Context is spatial awareness. Without it, the model has tools but no idea what's happening on screen. Like giving someone a TV remote with a blindfold on. Update your Pinia store to also track app state that's relevant to the current user: selected items, active filters, current view. If a user says "delete my selected to-dos," the model needs to know which ones are selected. Seems obvious until you build it without that and nothing works.
The trust spectrum
Just because the AI can call a tool doesn't mean it should do so silently every time. There's a spectrum worth being intentional about. We treat that in 3 levels of risk: read-only, mutating, or destructive. For tools that exclusively read data without side effects, we can auto-execute the tools and continue immediately. For tools that mutate some data we auto-execute, as long as there’s a clear and easy to access undo step for the user. Tools that have side effects that can’t be undone need to be explicitly allowed by the user.
The system prompt is interface design
The system prompt is where you tell the model who it is, how to behave, which tools to prefer, and when. Think of it as an onboarding document for a new hire. You wouldn't drop someone at a desk with 40 buttons and say "figure it out." You'd explain the app, the workflows, when to use which one.
For my demo app, the system prompt does a few specific things: tells the agent to prefer walking through the UI when creating, editing, or deleting (so the user can see it happen), to use direct API calls only when the user explicitly asks for background execution, and to always confirm before deleting anything.
That last instruction is a suggestion, not a rule. Treat your system prompt like client-side form validation: useful, worth having, but don't rely on it exclusively.
This kind of prompt engineering is, to me, interface design. We've spent decades designing interfaces in pixels and components. Now there's a design surface that's just English prose. You're writing behavioral specifications. The same skills transfer: user empathy, clarity of intent, and a lot of iteration.
The tradeoffs you’ll need to plan for
Tool explosion. A complex app might have dozens of tools. Hook up third-party MCP servers and you might have thousands. Models degrade with too many options, just like humans do. Handle it the same way you'd handle overwhelming UI.
Latency and choreography. LLMs are still slow. Every server-side tool call in a foreground interaction creates visible waiting. The virtual cursor trick I mentioned is one answer. Choreograph thoughtfully.
Destructive operations. The AI can now do real things in your app, including things that can't be undone. Confirmation steps, undo support, scoped permissions, defensive programming. All of it.
Determinism (or lack of it). The same prompt won't always produce the same tool calls. Your UI needs to handle whatever the model decides to do on a given day. Defensive programming applies double here.
Testing. Honest answer: We haven't fully solved this one. The tools themselves are just functions and you can unit test those normally. The tricky part is the integration layer with the model. Two options so far: snapshot testing based on mocked model responses (works until the model updates, which is constantly), or behavioral testing where you assert on end state rather than which tools got called. The second is more resilient but still flaky, and expensive to run in CI.
The uncomfortable truth is that we're shipping software with a probabilistic component. It's unfamiliar, but it's not that different from designing for human users. We do probabilistic testing for human behavior all the time.
What to take home
The Vue component lifecycle is a natural fit for agentic tool scoping. Mount to register, unmount to deregister. The framework handles a lot of the hard parts for you.
Design your tools the way you design component APIs. Same care, same specificity.
Start with server-side tools because they're cheaper and easier to implement. Add client-side UI tools where they've earned their complexity.
Chat is just the beginning. Nothing about the Pinia store architecture cares where the trigger comes from. Right-click a table row. Hit a toolbar button labeled "auto-categorize." The underlying plumbing is identical, and you can drive the agent without the user ever seeing a chat interface.
And please, for the love of everything: do not build your own CMS. We've been doing it for a decade so you don't have to. Trust me on this one.