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
.envfile
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-moduleStep 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: 6Step 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 startOpen http://localhost:5173 in your browser. You should see:
- Your workspace in the sidebar
- Click the workflow to create a new run
- The meeting notes form appears — edit and click Optimize
- The AI structures your notes into the optimized format
- 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:
- meeting-notes-example-workflow — Full meeting notes optimizer with input document, AI-generated structured output, and user confirmation
Next Steps
- Creating Workflows — Deep dive into all workflow features
- AI Text Generation — Simple LLM prompts
- Chat Flows — Multi-turn conversational workflows