Toolforge Docs

Writer Agent

Build an AI-powered writing agent with multi-step workflows, user feedback loops, and image generation.

In this example, we'll build an AI-powered writing agent that demonstrates how to:

  • Define multi-step agent workflows
  • Use context to maintain state across steps
  • Implement feedback loops with branching logic
  • Integrate AI models for text and image generation
  • Collect user input at various stages

Use Case

Content creators often need assistance with SEO-optimized writing. This agent helps users:

  1. Generate topic ideas and select one
  2. Write content based on the selected idea
  3. Refine content based on feedback
  4. Generate a complementary image

Step 1: Create the Agent Structure

Start by defining the agent with its context schema. The context holds state that persists across steps:

tools/writer/writer.ts
import { defineAgent } from '@toolforge-js/sdk/components'
import * as z from 'zod'

export default defineAgent({
  name: 'Writer Agent',
  description: 'An agent that helps in writing tasks',
  contextSchema: z.object({
    topic: z.string().describe('The topic to write about'),
    wordCount: z
      .number()
      .optional()
      .default(500)
      .describe('The desired word count for the writing task'),
    idea: z.string().optional().describe('The idea selected for writing'),
    content: z.string().optional().describe('The main content written'),
    isUserSatisfied: z
      .boolean()
      .optional()
      .describe('Indicates if the user is satisfied with the output')
      .default(true),
    feedbackFromUser: z
      .string()
      .optional()
      .describe('Feedback provided by the user for refinement'),
    iterationCount: z.number().default(0).describe('Number of iterations done'),
    maxIterations: z
      .number()
      .optional()
      .default(3)
      .describe('Maximum number of iterations allowed'),
  }),
  steps: {
    // We'll add steps here
  },
  workflow: (builder) => {
    // We'll define the workflow here
  },
})

The contextSchema defines all state that the agent needs to track. Each step can read and update this context.

Step 2: Add Bootstrap Function

The bootstrap function runs before the workflow starts. Use it to collect initial input:

tools/writer/writer.ts
export default defineAgent({
  name: 'Writer Agent',
  description: 'An agent that helps in writing tasks',
  contextSchema: z.object({
    // ... schema defined above
  }),
  steps: {
    // We'll add steps here
  },
  workflow: (builder) => {
    // We'll define the workflow here
  },
  bootstrap: async ({ io }) => {
    const topic = await io.textInput({
      label: 'Enter the topic you want to write about', 
    }) 
    const wordCount = await io.numberInput({
      label: 'Enter the desired word count', 
      validationSchema: z.number().min(100).max(5000), 
      defaultValue: 500, 
    }) 
    return { topic, wordCount } 
  }, 
})

The returned object from bootstrap populates the initial context values.

Step 3: Idea Generation Step

Add the first step that generates SEO-friendly title ideas using AI:

tools/writer/writer.ts
import { google } from '@ai-sdk/google'
import { generateObject } from 'ai'

export default defineAgent({
  // ... config
  steps: {
    ideaGeneration: {
      name: 'Idea Generation', 
      description: 'Generates ideas based on the given topic', 
      handler: async ({ updateContext, context, io }) => {
        let userWantsRegeneration = true
        let ideaText = ''
        while (userWantsRegeneration) {
          const { object } = await generateObject({
            model: google('gemini-2.5-flash'), 
            schema: z.object({
              ideas: z.string().array().describe('List of ideas'), 
            }), 
            messages: [
              {
                role: 'system', 
                content: 'You are an expert SEO content writer...', 
              }, 
              {
                role: 'user', 
                content: `Generate a list of SEO-friendly titles for: "${context.topic}"`, 
              }, 
            ], 
          }) 
          const selectedIdea = await io.selectInput({
            label: 'Select an idea to proceed with', 
            options: [
              ...object.ideas.map((idea, index) => ({
                label: idea, 
                value: index.toString(), 
              })), 
              { value: 'None', label: 'None of the above, regenerate ideas' }, 
            ], 
            mode: 'radio-card', 
          }) 
          if (selectedIdea === 'None') {
            continue
          } else {
            userWantsRegeneration = false
            ideaText = object.ideas[parseInt(selectedIdea, 10)] 
          } 
        } 
        updateContext({ idea: ideaText }) 
        return `Generating content based on the idea: ${ideaText}`
      }, 
    }, 
  },
  // ...
})

Key patterns in this step:

  • Uses generateObject with a Zod schema for structured AI output
  • Implements a regeneration loop if user isn't satisfied
  • Uses selectInput with mode: 'radio-card' for visual selection
  • Updates context with updateContext() to store the selected idea

Step 4: Writing Step

Add the content generation step that writes based on the selected idea:

tools/writer/writer.ts
import { generateText, type ModelMessage } from 'ai'

export default defineAgent({
  // ... config
  steps: {
    ideaGeneration: {
      // ... previous step
    },
    writing: {
      name: 'Writing', 
      description: 'Generates the main content based on the idea and topic', 
      handler: async ({ updateContext, context }) => {
        const messages: ModelMessage[] = [
          {
            role: 'system', 
            content: 'You are an expert SEO content writer...', 
          }, 
        ] 
        if (!context.isUserSatisfied) {
          // Refine based on previous feedback
          messages.push({
            role: 'user', 
            content: `Refine the content based on feedback: "${context.feedbackFromUser}"...`, 
          }) 
        } else {
          // Generate fresh content
          messages.push({
            role: 'user', 
            content: `Write SEO-optimized content for: "${context.idea}"...`, 
          }) 
        } 
        const { text } = await generateText({
          model: google('gemini-2.5-flash'), 
          messages, 
        }) 
        updateContext({ content: text }) 
        return text 
      }, 
    }, 
  },
  // ...
})

This step reads context to determine if it's generating fresh content or refining based on feedback.

Step 5: Feedback Incorporation Step

Add a step that asks for user feedback and enables the refinement loop:

tools/writer/writer.ts
export default defineAgent({
  // ... config
  steps: {
    // ... previous steps
    feedbackIncorporation: {
      name: 'Feedback Incorporation', 
      description: 'Incorporates user feedback to refine the generated content', 
      handler: async ({ io, updateContext }) => {
        const isOK = await io.confirm({
          title: 'Are you satisfied with the content?', 
          okButtonLabel: 'Yes', 
          cancelButtonLabel: 'No, I want to provide feedback', 
        }) 
        if (isOK) {
          updateContext({ isUserSatisfied: true }) 
          return 'User is satisfied with the content.'
        } 
        const feedback = await io.textInput({
          label: 'Please provide your feedback', 
          validationSchema: z.string(), 
        }) 
        updateContext((prev) => ({
          feedbackFromUser: feedback, 
          isUserSatisfied: false, 
          iterationCount: prev.iterationCount + 1, 
        })) 
        return `User Feedback: ${feedback}`
      }, 
    }, 
  },
  // ...
})

Updater Function

updateContext accepts either an object or a function. Use the function form (prev) => ({...}) when you need to reference previous context values.

Step 6: Image Generation Step

Add the final step that generates a complementary image:

tools/writer/writer.ts
export default defineAgent({
  // ... config
  steps: {
    // ... previous steps
    generateImage: {
      name: 'Generate Image', 
      description: 'Generates an image based on the content written', 
      handler: async ({ context, io, block }) => {
        let generateAgain = true
        while (generateAgain) {
          const result = await generateText({
            model: google('gemini-2.5-flash-image-preview'), 
            messages: [
              {
                role: 'system', 
                content: 'You are an expert visual content creator...', 
              }, 
              {
                role: 'user', 
                content: `Create an image for: "${context.topic}"...`, 
              }, 
            ], 
          }) 
          for (const file of result.files) {
            if (file.mediaType.startsWith('image/')) {
              await io.message({
                title: 'Generated Image', 
                message: block.image({
                  url: `data:image/png;base64,${file.base64}`, 
                }), 
              }) 
            } 
          } 
          generateAgain = !(await io.confirm({
            title: 'Are you satisfied with the generated image?', 
            okButtonLabel: 'Yes', 
            cancelButtonLabel: 'No', 
          })) 
        } 
      }, 
    }, 
  },
  // ...
})

This step:

  • Uses an image generation model
  • Displays the image using block.image() inside io.message()
  • Allows regeneration if user isn't satisfied

Step 7: Define the Workflow

Connect all steps with the workflow builder, including branching logic:

tools/writer/writer.ts
export default defineAgent({
  // ... config and steps
  workflow: (builder) => {
    return builder
      .flow('START', 'ideaGeneration') 
      .flow('ideaGeneration', 'writing') 
      .flow('writing', 'feedbackIncorporation') 
      .branch(
        'feedbackIncorporation', 
        (
          context, 
        ) =>
          context.isUserSatisfied ||
          context.iterationCount > context.maxIterations 
            ? 'GENERATE_IMAGE'
            : 'WRITE', 
        { GENERATE_IMAGE: 'generateImage', WRITE: 'writing' }, 
      ) 
      .flow('generateImage', 'END') 
  },
})

The workflow:

  1. Starts with idea generation
  2. Flows to writing
  3. Gets feedback
  4. Branches: If satisfied or max iterations reached → generate image, otherwise → go back to writing
  5. Ends after image generation

Complete Code

Here's the final implementation:

tools/writer/writer.ts
import { google } from '@ai-sdk/google'
import { defineAgent } from '@toolforge-js/sdk/components'
import { generateObject, generateText, type ModelMessage } from 'ai'
import * as z from 'zod'

export default defineAgent({
  name: 'Writer Agent',
  description: 'An agent that helps in writing tasks',
  contextSchema: z.object({
    topic: z.string().describe('The topic to write about'),
    wordCount: z
      .number()
      .optional()
      .default(500)
      .describe('The desired word count for the writing task'),
    idea: z.string().optional().describe('The idea selected for writing'),
    content: z.string().optional().describe('The main content written'),
    isUserSatisfied: z
      .boolean()
      .optional()
      .describe('Indicates if the user is satisfied with the output')
      .default(true),
    feedbackFromUser: z
      .string()
      .optional()
      .describe('Feedback provided by the user for refinement'),
    iterationCount: z.number().default(0).describe('Number of iterations done'),
    maxIterations: z
      .number()
      .optional()
      .default(3)
      .describe('Maximum number of iterations allowed'),
  }),
  steps: {
    ideaGeneration: {
      name: 'Idea Generation',
      description: 'Generates ideas based on the given topic',
      handler: async ({ updateContext, context, io }) => {
        let userWantsRegeneration = true
        let ideaText = ''

        while (userWantsRegeneration) {
          const { object } = await generateObject({
            model: google('gemini-2.5-flash'),
            schema: z.object({
              ideas: z.string().array().describe('List of ideas'),
            }),
            messages: [
              {
                role: 'system',
                content:
                  'You are an expert SEO content writer specializing in creating compelling, search-engine optimized titles.',
              },
              {
                role: 'user',
                content: `Generate a list of SEO-friendly titles for the topic: "${context.topic}". Each title should be:
- Between 50-60 characters for optimal search results
- Include relevant keywords naturally
- Be compelling and click-worthy
- Follow SEO best practices
- Target specific search intent`,
              },
            ],
          })
          const selectedIdea = await io.selectInput({
            label: 'Select an idea to proceed with',
            options: [
              ...object.ideas.map((idea, index) => ({
                label: idea,
                value: index.toString(),
              })),
              { value: 'None', label: 'None of the above, regenerate ideas' },
            ],
            mode: 'radio-card',
          })
          if (selectedIdea === 'None') {
            continue
          } else {
            userWantsRegeneration = false
            ideaText = object.ideas[parseInt(selectedIdea, 10)]
          }
        }
        updateContext({ idea: ideaText })
        return `Generating content based on the idea: ${ideaText}`
      },
    },
    writing: {
      name: 'Writing',
      description: 'Generates the main content based on the idea and topic',
      handler: async ({ updateContext, context }) => {
        const messages: ModelMessage[] = [
          {
            role: 'system',
            content:
              'You are an expert SEO content writer specializing in creating engaging, search-engine optimized articles that rank well and provide value to readers.',
          },
        ]

        if (!context.isUserSatisfied) {
          messages.push({
            role: 'user',
            content: `Refine the SEO-optimized content based on the user feedback. Create content that:
- Uses the title: "${context.idea}"
- Covers the topic: "${context.topic}" 
- Targets approximately ${context.wordCount} words
- Incorporates this feedback: "${context.feedbackFromUser}"
- Maintains proper SEO structure (headings, subheadings, keywords)
- Includes relevant keywords naturally throughout
- Has engaging introduction and conclusion
- Uses short paragraphs for readability`,
          })
        } else {
          messages.push({
            role: 'user',
            content: `Write SEO-optimized content using the title: "${context.idea}" for the topic: "${context.topic}". 
Target approximately ${context.wordCount} words and ensure the content:
- Uses proper heading structure (H1, H2, H3)
- Includes relevant keywords naturally throughout
- Has an engaging introduction that hooks readers
- Provides valuable, actionable information
- Uses short paragraphs and bullet points for readability
- Includes a compelling conclusion
- Optimizes for search intent while maintaining quality`,
          })
        }

        const { text } = await generateText({
          model: google('gemini-2.5-flash'),
          messages,
        })
        updateContext({ content: text })
        return text
      },
    },
    feedbackIncorporation: {
      name: 'Feedback Incorporation',
      description: 'Incorporates user feedback to refine the generated content',
      handler: async ({ io, updateContext }) => {
        const isOK = await io.confirm({
          title: 'Are you satisfied with the content?',
          okButtonLabel: 'Yes',
          cancelButtonLabel: 'No, I want to provide feedback',
        })
        if (isOK) {
          updateContext({ isUserSatisfied: true })
          return 'User is satisfied with the content.'
        }
        const feedback = await io.textInput({
          label: 'Please provide your feedback',
          validationSchema: z.string(),
        })
        updateContext((prev) => ({
          feedbackFromUser: feedback,
          isUserSatisfied: false,
          iterationCount: prev.iterationCount + 1,
        }))
        return `User Feedback: ${feedback}`
      },
    },
    generateImage: {
      name: 'Generate Image',
      description: 'Generates an image based on the content written',
      handler: async ({ context, io, block }) => {
        let generateAgain = true

        while (generateAgain) {
          const result = await generateText({
            model: google('gemini-2.5-flash-image-preview'),
            messages: [
              {
                role: 'system',
                content:
                  'You are an expert visual content creator specializing in generating compelling images that enhance written content and improve engagement.',
              },
              {
                role: 'user',
                content: `Create a detailed image based on this content about "${context.topic}" with the title "${context.idea}". 

The image should:
- Visually represent the main theme and key concepts from the content
- Be professional and suitable for SEO content
- Include relevant visual elements that support the article's message
- Be engaging and help illustrate the content's value
- Match the tone and style appropriate for the topic

Content to visualize: "${context.content?.substring(0, 500)}..."

Generate an image that would complement this content and make it more engaging for readers.`,
              },
            ],
          })

          for (const file of result.files) {
            if (file.mediaType.startsWith('image/')) {
              await io.message({
                title: 'Generated Image',
                message: block.image({
                  url: `data:image/png;base64,${file.base64}`,
                }),
              })
            }
          }
          generateAgain = !(await io.confirm({
            title: 'Are you satisfied with the generated image?',
            okButtonLabel: 'Yes',
            cancelButtonLabel: 'No',
          }))
        }
      },
    },
  },
  workflow: (builder) => {
    return builder
      .flow('START', 'ideaGeneration')
      .flow('ideaGeneration', 'writing')
      .flow('writing', 'feedbackIncorporation')
      .branch(
        'feedbackIncorporation',
        (context) =>
          context.isUserSatisfied ||
          context.iterationCount > context.maxIterations
            ? 'GENERATE_IMAGE'
            : 'WRITE',
        { GENERATE_IMAGE: 'generateImage', WRITE: 'writing' },
      )
      .flow('generateImage', 'END')
  },
  bootstrap: async ({ io }) => {
    const topic = await io.textInput({
      label: 'Enter the topic you want to write about',
    })
    const wordCount = await io.numberInput({
      label: 'Enter the desired word count',
      validationSchema: z.number().min(100).max(5000),
      defaultValue: 500,
    })
    return { topic, wordCount }
  },
})

Key Takeaways

  • Context Schema: Define all agent state upfront with Zod for type safety
  • Bootstrap: Use bootstrap to collect initial input before workflow starts
  • Step Handlers: Each step receives context, updateContext, io, and block
  • Workflow Builder: Chain .flow() for linear steps and .branch() for conditional logic
  • Feedback Loops: Use branching to loop back to previous steps based on conditions
  • AI Integration: Use Vercel AI SDK's generateText and generateObject for AI calls

On this page