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:
- Generate topic ideas and select one
- Write content based on the selected idea
- Refine content based on feedback
- 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:
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:
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:
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
generateObjectwith a Zod schema for structured AI output - Implements a regeneration loop if user isn't satisfied
- Uses
selectInputwithmode: '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:
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:
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:
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()insideio.message() - Allows regeneration if user isn't satisfied
Step 7: Define the Workflow
Connect all steps with the workflow builder, including branching logic:
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:
- Starts with idea generation
- Flows to writing
- Gets feedback
- Branches: If satisfied or max iterations reached → generate image, otherwise → go back to writing
- Ends after image generation
Complete Code
Here's the final implementation:
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
bootstrapto collect initial input before workflow starts - Step Handlers: Each step receives
context,updateContext,io, andblock - 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
generateTextandgenerateObjectfor AI calls
Related Documentation
- Agent Concept - Understanding agents and workflows
- textInput - Text input collection
- numberInput - Numeric input
- selectInput - Selection with options
- confirm - Confirmation dialogs
- message - Displaying information
- image - Image display block