nyxcore-systems
9 min read

Beyond Single-Output: Building Multi-Alternative Steps in Our Workflow Engine

We tackled a common challenge in AI-powered workflows: the need for design alternatives. Here's how we engineered a multi-output step selection system, pausing for user choice, across our entire stack.

workflow-engineLLMPrismaTypeScripttRPCNext.jsfullstackdevelopment-processlessons-learnedUX-design

Building a workflow engine is a journey of anticipating user needs, especially when integrating the unpredictable yet powerful nature of Large Language Models (LLMs). One of the most common pieces of feedback we've received, and a challenge we've felt ourselves, is the "one-and-done" nature of LLM outputs. While powerful, the first generation isn't always the best generation, and often, users want to explore alternatives before committing to a path.

That's why our latest development sprint focused on a critical enhancement: multi-output step selection. Imagine a workflow where a step, instead of just producing one result, generates several distinct alternatives, pauses, and lets the user pick the best one to continue the pipeline. This isn't just a feature; it's a paradigm shift for creative and iterative workflows.

This post dives into the technical journey of bringing this vision to life, from database schema to user interface, including the inevitable bumps and valuable lessons learned along the way.

The Challenge: Embracing Variability

Our goal was clear: implement a system where a workflow step could generate N alternative outputs, present them to the user, and then proceed with the chosen alternative. This required a full-stack overhaul, touching every layer of our application.

The core idea was to empower users. When an LLM step like "Design Features" runs, it might generate three distinct approaches. Instead of forcing the user to regenerate or manually edit, we now offer them as a curated selection, complete with previews and context.

A Full-Stack Symphony: From Schema to UI

The implementation spanned all six layers of our application: the database schema, the workflow engine's core logic, our tRPC router, shared constants, the workflow builder UI, and the execution UI.

1. Data Model: Storing the Choices

First, we needed to update our WorkflowStep model in prisma/schema.prisma to accommodate the new state:

prisma
model WorkflowStep {
  // ... other fields
  generateCount   Int?    // How many alternatives to generate (if applicable)
  alternatives    Json?   // Array of generated alternatives (JSON blob)
  selectedIndex   Int?    // Index of the user's chosen alternative
  // ...
}
  • generateCount: An optional integer to specify how many variations an LLM step should produce.
  • alternatives: A Json field to store the array of generated outputs. This is flexible, allowing us to store not just the content but also metadata like token count, estimated cost, and a label.
  • selectedIndex: Once a user picks, this field records which alternative was chosen.

2. The Engine's New Rhythm: Generate, Pause, Resume

The runWorkflow() function, the heart of our engine, underwent significant changes.

  • Variation Strategies: We introduced VARIATION_STRATEGIES constants (e.g., Balanced, Conservative, Ambitious). These aren't just labels; they come with temperature offsets and prompt suffixes that subtly guide the LLM to produce diverse yet relevant outputs. When generateCount is specified, the engine now iterates N times, calling executeStep() for each variation, applying a different strategy, and storing the results.
  • Pausing for Selection: After generating all alternatives, the workflow pauses, changing its event type to "alternatives_ready".
  • Resume Handling: The engine now checks if a step has alternatives and a selectedIndex. If so, it uses the content of the selected alternative as the step's final output, ensuring downstream steps receive the correct data via our {{steps.StepName.content}} templating.
  • Resume Guard: A crucial addition: we block workflow resumption if the current step has unselected alternatives, preventing accidental progression without user input.

3. API & Router: The selectAlternative Mutation

Our src/server/trpc/routers/workflows.ts file gained a new mutation: selectAlternative. This mutation:

  • Validates user ownership and ensures the selectedIndex is within bounds.
  • Updates the WorkflowStep with the chosen selectedIndex.
  • Sets the step's final output based on the selected alternative's content.

This mutation is the bridge between the user's choice in the UI and the engine's ability to resume.

4. UI/UX: Making Choices Intuitive

The most visible changes landed in our Next.js frontend, enhancing both the workflow builder and the execution view.

  • Builder Configuration: In the workflow builder, we added generateCount? to our StepTemplate interface and ensured it flows through our create and duplicate mutations. For specific LLM steps like "Extension Features" and "Features," we pre-set generateCount: 3 as a sensible default.
  • Execution UI - Alternative Selection Panel:
    • Toggle Buttons: For LLM steps, a simple 1x/2x/3x toggle now appears in the SortableStepCard header, allowing users to dynamically adjust generateCount.
    • [Nx] Badge: A clear [3x] badge in the step header immediately indicates a multi-output step.
    • Radio-style Cards: When alternatives are generated, a dedicated selection panel appears. Each alternative is presented as a card, showing a label, estimated tokens, cost, and a preview of its content. These are radio-style, making selection intuitive.
    • Sticky "Continue" Button: A prominent, sticky "Continue with [Label]" button appears, chaining the selectAlternative mutation with the workflow resume mutation for a seamless experience.
    • Post-selection "Other Alternatives": After selection, the unchosen alternatives collapse into a "Other alternatives" section, still accessible with copy buttons for reference.

5. Enhancing Prompt Visibility & Control

Beyond the multi-output feature, we also rolled out significant improvements to how users interact with prompts:

  • Collapsible PROMPT + system Section: Every step now features a collapsible section displaying both the system and user prompts in monospace blocks, offering full transparency into the LLM's instructions.
  • Inline Prompt Editing: Users can now directly edit the system and user prompts within these sections using textareas. A steps.update mutation handles saving these changes, allowing for dynamic prompt iteration mid-workflow. This is a game-changer for debugging and fine-tuning.

Lessons Learned: Navigating the Technical Minefield

No significant feature implementation is without its challenges. Here are a few "gotchas" we encountered and how we overcame them:

Lesson 1: Prisma's Json Type and TypeScript Type Safety

  • Problem: We tried to pass an array of Record<string, unknown> (our alternatives array) directly to a Prisma update operation for a Json field. TypeScript rightfully complained: Record<string, unknown>[] is not assignable to Prisma.InputJsonValue.
  • Attempted Solution: Initially, we tried various type assertions, but the core issue was understanding what InputJsonValue truly expects.
  • Workaround/Solution: The Json type in Prisma's schema is flexible, but its TypeScript representation Prisma.InputJsonValue is stricter. The simplest, cleanest solution was to explicitly cast the array: alternatives as unknown as Prisma.InputJsonValue. This, combined with importing Prisma from @prisma/client, resolved the TS error and allowed the data to be stored correctly.
  • Takeaway: When dealing with Prisma's Json type, remember that while it's schema-flexible, TypeScript requires explicit handling. unknown as Prisma.InputJsonValue is often the necessary bridge.

Lesson 2: The Stale Prisma Client

  • Problem: After adding generateCount to our WorkflowStep model, subsequent attempts to create new workflows via our tRPC API failed with an Unknown argument 'generateCount' error.
  • Attempted Solution: We double-checked the schema, the API input, everything seemed correct.
  • Workaround/Solution: The issue wasn't in our code but in the generated Prisma client. It was "stale" from a previous session and hadn't picked up the new field. A quick npx prisma generate followed by a dev server restart brought everything back into sync.
  • Takeaway: Always remember to regenerate your Prisma client (npx prisma generate) after any schema changes, and restart your development server to ensure the updated client is loaded. This is a common pitfall!

Lesson 3: Playwright Scrolling in Confined Containers

  • Problem: While writing Playwright tests for the new UI, we tried to take full-page screenshots on a mobile viewport using window.scrollTo(). The scroll didn't seem to take effect, leading to clipped screenshots.
  • Attempted Solution: Various window.scrollTo combinations, waiting for scroll, etc.
  • Workaround/Solution: Our dashboard uses a custom scrollable container, not the window itself, for its main content area. window.scrollTo() only affects the browser window's scrollbar. The solution involved:
    1. Using fullPage: true for screenshots where possible to capture the entire render tree regardless of scroll.
    2. For elements that needed to be in view, we simulated user interaction by clicking expansion buttons to reveal hidden content, instead of programmatic scrolling.
  • Takeaway: Be mindful of custom scrollable containers in your application. window.scrollTo() won't work if your content scrolls within a div with overflow: auto. Target the specific container or simulate user interaction to bring elements into view for testing.

What's Next?

With the core multi-alternative system in place, our immediate next steps involve thorough end-to-end testing and some exciting future enhancements:

  • Comprehensive Testing: Verify the entire flow: creation, generation of alternatives, selection on both desktop and mobile, and correct content propagation to downstream steps.
  • Prompt Editing on Completed Steps: Consider adding the prompt edit button to completed steps, enabling re-run scenarios with modified instructions.
  • Accurate Cost Estimation: Our estimateWorkflowCost function currently doesn't account for the generateCount multiplier. Updating this will provide more accurate cost projections for users.

This sprint has been a testament to the power of full-stack development. By integrating database, backend logic, and a responsive UI, we've delivered a feature that significantly enhances the flexibility and user experience of our LLM-powered workflows. The ability to explore, select, and refine outputs is no longer a workaround but a core part of our engine.


json
{"thingsDone":[
    "Implemented multi-output step selection (design alternatives) for workflow engine.",
    "Added generateCount, alternatives, selectedIndex fields to WorkflowStep model (Prisma).",
    "Introduced VARIATION_STRATEGIES for diverse LLM outputs.",
    "Extended WorkflowEvent type for 'alternatives_generating' and 'alternatives_ready'.",
    "Developed alternatives generation loop in runWorkflow() with strategy-modified prompts.",
    "Added resume handling for selected alternatives.",
    "Created selectAlternative tRPC mutation for user selection.",
    "Implemented resume guard to block progression on unselected alternatives.",
    "Updated create/duplicate mutations to pass generateCount.",
    "Added generateCount to StepTemplate and StepConfig interfaces/helpers.",
    "Integrated 1x/2x/3x toggle buttons in SortableStepCard for LLM steps.",
    "Developed alternatives selection panel with radio-style cards, previews, and metadata.",
    "Added sticky 'Continue with [Label]' button for seamless selection and resume.",
    "Included post-selection 'Other alternatives' collapsible section.",
    "Added [Nx] badge in step headers for multi-output steps.",
    "Implemented collapsible PROMPT + system section per step.",
    "Enabled inline prompt editing (system/user prompts) with save/cancel functionality.",
    "Fixed Prisma client regeneration issues.",
    "Resolved TypeScript errors related to Prisma.InputJsonValue and default configs."
],"pains":[
    "TypeScript error when assigning Record<string, unknown>[] to Prisma.InputJsonValue.",
    "Stale Prisma client causing 'Unknown argument' errors after schema changes.",
    "Playwright window.scrollTo() failing in custom scrollable containers."
],"successes":[
    "Successfully implemented a complex full-stack feature across 6 architectural layers.",
    "Improved user experience by allowing choice and control over LLM outputs.",
    "Enhanced developer and user transparency with inline prompt viewing and editing.",
    "Overcame key technical hurdles with Prisma typing and client synchronization.",
    "Delivered a robust foundation for future iterative AI workflows."
],"techStack":[
    "Prisma (ORM)",
    "PostgreSQL (Database)",
    "TypeScript (Language)",
    "tRPC (API Layer)",
    "Next.js (Frontend Framework)",
    "React (UI Library)",
    "Playwright (Testing)"
]}