Skip to Content
DocumentationGetting StartedYour First Workflow

Your First Workflow

In this tutorial, you’ll build a Meeting Notes Optimizer — a workflow that takes messy meeting notes from a user, sends them to an LLM for structuring, and lets the user review and confirm the result.

You’ll learn how to:

  • Create custom documents with Zod schemas
  • Build a workflow with user interaction (human-in-the-loop)
  • Use AI to generate structured output
  • Register everything in a module and workspace

Prerequisites

  • A running Loopstack project (Quick Start)
  • An Anthropic API key in your .env file

Step 0: Add the Claude Module

This workflow uses the @loopstack/claude-module package for AI-powered document generation. Install it from the registry:

npm install @loopstack/claude-module

Step 1: Create the Input Document

The input document holds the raw meeting notes the user will edit.

Create src/documents/meeting-notes-document.ts:

import { z } from 'zod'; import { Document } from '@loopstack/common'; export const MeetingNotesDocumentSchema = z.object({ text: z.string(), }); @Document({ schema: MeetingNotesDocumentSchema, uiConfig: __dirname + '/meeting-notes-document.yaml', }) export class MeetingNotesDocument { text: string; }

Create src/documents/meeting-notes-document.yaml:

type: document ui: widgets: - widget: form options: properties: text: title: Meeting Notes widget: textarea rows: 10 actions: - type: button transition: userResponse label: 'Optimize'

The button’s transition: userResponse will trigger the workflow method named userResponse.

Step 2: Create the Output Document

The output document holds the AI-structured result.

Create src/documents/optimized-notes-document.ts:

import { z } from 'zod'; import { Document } from '@loopstack/common'; export const OptimizedMeetingNotesDocumentSchema = z.object({ date: z.string(), summary: z.string(), participants: z.array(z.string()), decisions: z.array(z.string()), actionItems: z.array(z.string()), }); @Document({ schema: OptimizedMeetingNotesDocumentSchema, uiConfig: __dirname + '/optimized-notes-document.yaml', }) export class OptimizedNotesDocument { date: string; summary: string; participants: string[]; decisions: string[]; actionItems: string[]; }

Create src/documents/optimized-notes-document.yaml:

type: document ui: widgets: - widget: form options: order: [date, summary, participants, decisions, actionItems] properties: date: title: Date summary: title: Summary widget: textarea participants: title: Participants collapsed: true items: title: Participant decisions: title: Decisions collapsed: true items: title: Decision actionItems: title: Action Items collapsed: true items: title: Action Item actions: - type: button transition: confirm label: 'Confirm'

Step 3: Create the Workflow

The workflow orchestrates the full flow: show form → wait for user → call AI → show result → wait for confirmation.

Create src/workflows/meeting-notes.workflow.ts:

import { z } from 'zod'; import { ClaudeGenerateDocument } from '@loopstack/claude-module'; import { BaseWorkflow, Final, Initial, InjectTool, Transition, Workflow } from '@loopstack/common'; import { MeetingNotesDocument, MeetingNotesDocumentSchema } from '../documents/meeting-notes-document'; import { OptimizedNotesDocument, OptimizedMeetingNotesDocumentSchema } from '../documents/optimized-notes-document'; @Workflow({ uiConfig: __dirname + '/meeting-notes.ui.yaml', schema: z.object({ inputText: z .string() .default( '- meeting 1.1.2025\n- budget: need 2 cut costs sarah said\n- hire new person?? --> marketing\n- vendor pricing - follow up needed by anna', ), }), }) export class MeetingNotesWorkflow extends BaseWorkflow { @InjectTool() claudeGenerateDocument: ClaudeGenerateDocument; meetingNotes?: z.infer<typeof MeetingNotesDocumentSchema>; optimizedNotes?: z.infer<typeof OptimizedMeetingNotesDocumentSchema>; @Initial({ to: 'waiting_for_response' }) async createForm() { const args = this.ctx.args as { inputText: string }; await this.repository.save( MeetingNotesDocument, { text: `Unstructured Notes:\n\n${args.inputText}` }, { id: 'input' }, ); } @Transition({ from: 'waiting_for_response', to: 'response_received', wait: true }) async userResponse() { const payload = this.ctx.runtime.transition!.payload as z.infer<typeof MeetingNotesDocumentSchema>; const result = await this.repository.save(MeetingNotesDocument, payload, { id: 'input' }); this.meetingNotes = result.content as z.infer<typeof MeetingNotesDocumentSchema>; } @Transition({ from: 'response_received', to: 'notes_optimized' }) async optimizeNotes() { await this.claudeGenerateDocument.call({ claude: { model: 'claude-sonnet-4-6' }, response: { id: 'final', document: OptimizedNotesDocument }, prompt: `Extract all information from the provided meeting notes into the structured document.\n\n<Meeting Notes>\n${this.meetingNotes?.text}\n</Meeting Notes>`, }); } @Final({ from: 'notes_optimized', wait: true }) async confirm() { const payload = this.ctx.runtime.transition!.payload as z.infer<typeof OptimizedMeetingNotesDocumentSchema>; await this.repository.save(OptimizedNotesDocument, payload, { id: 'final' }); } }

Create src/workflows/meeting-notes.ui.yaml:

title: 'Meeting Notes Optimizer' description: 'Transform messy meeting notes into structured, professional format' ui: widgets: - widget: form options: properties: inputText: title: Meeting Notes widget: textarea rows: 6

Step 4: Create the Module

Create src/meeting-notes.module.ts:

import { Module } from '@nestjs/common'; import { ClaudeModule } from '@loopstack/claude-module'; import { LoopCoreModule } from '@loopstack/core'; import { MeetingNotesWorkflow } from './workflows/meeting-notes.workflow'; @Module({ imports: [LoopCoreModule, ClaudeModule], providers: [MeetingNotesWorkflow], exports: [MeetingNotesWorkflow], }) export class MeetingNotesModule {}

Notice that documents are not listed as providers — they are plain DTOs.

Step 5: Create the Workspace

Create src/default.workspace.ts:

import { Injectable } from '@nestjs/common'; import { InjectWorkflow, Workspace, WorkspaceInterface } from '@loopstack/common'; import { MeetingNotesWorkflow } from './workflows/meeting-notes.workflow'; @Injectable() @Workspace({ config: { title: 'My Workspace' }, }) export class DefaultWorkspace implements WorkspaceInterface { @InjectWorkflow() meetingNotesWorkflow: MeetingNotesWorkflow; }

Step 6: Register in AppModule

Update your src/app.module.ts:

import { Module } from '@nestjs/common'; import { DefaultWorkspace } from './default.workspace'; import { MeetingNotesModule } from './meeting-notes.module'; @Module({ imports: [MeetingNotesModule], providers: [DefaultWorkspace], }) export class AppModule {}

Step 7: Run It

npm run start

Open http://localhost:5173  in your browser. You should see:

  1. Your workspace in the sidebar
  2. Click the workflow to create a new run
  3. The meeting notes form appears — edit and click Optimize
  4. The AI structures your notes into the optimized format
  5. Review the result and click Confirm

What You Built

Your workflow follows this state machine:

start → waiting_for_response → response_received → notes_optimized → end (user edits form) (AI processes) (user confirms)

Each arrow is a transition method in your workflow class. The wait: true transitions pause for user interaction.

Registry References

The complete working example for this tutorial:

Next Steps

Last updated on