Unlocking Granular Control: Per-Step AI Models and Personalities in Our Workflow Engine
Dive into how we empowered users with per-step control over AI models and personas in our workflow engine, tackling tricky UI challenges and refining the user experience along the way.
In the world of AI-driven applications, "one size fits all" rarely cuts it. While a global setting for an AI model or a specific persona might work for many tasks, true power often lies in granularity. What if one step in your complex workflow needs a highly creative model, while the next demands a precise, factual one? Or perhaps a step should adopt a "friendly assistant" persona, only for the next to switch to a "rigorous editor"?
This was the challenge we set out to solve: empowering our users with per-step control over their AI providers, models, and even the very personality guiding each interaction within a workflow. Our goal was to add this crucial flexibility and then iron out the inevitable UX kinks that arise from such powerful new features.
The Quest for Granular Control
Our journey began with a clear objective: integrate per-step model/provider selection and persona overrides directly into our workflow steps. This meant touching both the database schema and the deepest parts of our workflow execution engine, all while crafting an intuitive frontend experience.
Laying the Backend Foundations
The first step, as often is the case, was a database schema modification. We introduced an optional personaId (a UUID) to our WorkflowStep model in prisma/schema.prisma, linking it to our existing Persona model. This simple addition was the key to allowing individual steps to reference their own specific personas.
// prisma/schema.prisma
model WorkflowStep {
id String @id @default(uuid())
// ... other step fields ...
personaId String? @map("persona_id")
persona Persona? @relation(fields: [personaId], references: [id])
}
model Persona {
id String @id @default(uuid())
// ... other persona fields ...
workflowSteps WorkflowStep[] // Reverse relation
}
After updating the schema and running npm run db:push && npm run db:generate, our workflow_steps table gained its new persona_id column, and our Prisma client was ready with the WorkflowStep.personaId field.
Next, we extended our backend logic:
- Update Mutations: The
steps.updatetRPC input was modified to acceptpersonaId, allowing users to save their per-step persona choices. - Duplication Logic: When duplicating a workflow or a step, we ensured the
personaIdwas correctly carried through, using Prisma'spersona: { connect: { id } }syntax for efficient relation management. - The Execution Engine: The most critical backend change was within
src/server/services/workflow-engine.ts. We modified theexecuteStep()function to intelligently load the per-step persona from the database if set, overriding any workflow-level persona configured for the entire process. This is where the rubber meets the road, ensuring the chosen persona truly influences the LLM output for that specific step.
Bringing it to Life: The Frontend Experience
With the backend ready, our focus shifted to the user interface. We needed to present these new controls clearly and intuitively within each workflow step.
- Provider Picker Integration: For per-step model selection, we integrated our
ProviderPickercomponent directly into the step headers. This allows users to quickly switch between AI providers and models for a given step when the workflow is in a pending or paused state. - Persona Dropdown: Below the provider picker, within the expanded body of each step, we added a
<select>dropdown specifically for persona selection. This dropdown not only lists available personas but also includes a truncated description, helping users quickly identify the right personality for the job. To ensure all options were available, we removed a previous gating mechanism on ourpersonas.listquery.
Lessons from the Trenches: Overcoming UX Challenges
No feature implementation is without its bumps. We encountered a couple of particularly interesting UX challenges that led to valuable insights.
The Case of the Nested Interactive Elements
The Problem: Our initial thought for the ProviderPicker was to nest it directly inside the step header's toggle <button>. It seemed like a clean way to keep related controls together.
The Reality: This immediately led to invalid HTML (you can't nest interactive elements like a <select> inside a <button>) and, more importantly, click propagation nightmares. Clicking the provider picker would often inadvertently trigger the step's expand/collapse functionality.
The Solution: We restructured the step header. Instead of a single button containing everything, we opted for a <div> wrapper. This wrapper now houses a dedicated toggle <button> on the left for expanding/collapsing the step, and the ProviderPicker on the right. Crucially, we added onClick={(e) => e.stopPropagation()} to the ProviderPicker's wrapper to prevent its clicks from bubbling up and interfering with the step's toggle.
// Simplified example of the structural change
// BEFORE:
// <button onClick={toggleStepExpansion}>
// Step Title
// <ProviderPicker /> // INVALID!
// </button>
// AFTER:
// <div className="flex justify-between items-center">
// <button onClick={toggleStepExpansion}>
// Step Title
// </button>
// <div onClick={(e) => e.stopPropagation()}>
// <ProviderPicker />
// </div>
// </div>
The Elusive Persona Selection: A Tale of Optimism
The Problem: Users reported that after selecting a persona from the new dropdown, the UI would revert to the previous selection almost immediately. The change wasn't "sticking." Our initial implementation was binding the select value directly to (step as any).personaId, which, besides being a type safety anti-pattern, didn't account for the asynchronous nature of a server update.
The Solution: This was a classic case for optimistic UI updates. We introduced a local state, personaOverrides: Record<string, string | null>, to immediately reflect the user's selection in the UI. When a user picks a persona, we update this local state, making the UI feel instant. The actual server update then happens in the background. Once the server responds successfully and our data is refetched, we clear this optimistic state, allowing the UI to bind to the now-persisted server state.
Additionally, we cleaned up the type casting, replacing (step as any).personaId with the properly typed step.personaId after ensuring our Prisma client was correctly regenerated. This improved code clarity and prevented potential runtime errors.
Refinement and Polish
Beyond the major features and challenges, we also implemented several smaller but impactful refinements:
- Model Name Visibility: We removed the
compactprop from theProviderPickerin the step headers, ensuring the selected model's name is always visible, improving clarity at a glance. - Persona Descriptions: Added a truncated description after the persona name in the dropdown options, providing more context for users choosing a personality.
What's Next?
With these features now live, our immediate next steps involve thorough manual verification:
- Confirming that switching providers for a failed Anthropic step and re-running works as expected.
- Verifying that per-step persona selections persist across sessions and genuinely influence the LLM's output.
- Ensuring that workflow-level personas still function correctly when no per-step override is specified.
- Checking that completed or running workflows display read-only provider information, preventing accidental changes.
- Considering the mobile user experience, as the
ProviderPickeris currently hidden on smaller screens and will need a suitable fallback.
Empowering users with this level of granular control over their AI workflows opens up a world of possibilities for experimentation, fine-tuning, and achieving highly specific outcomes. It's a significant step forward in making our platform even more flexible and powerful.