Toolforge Docs
DocsConceptsAgent

Agent

Orchestrate multi-step AI workflows with human-in-the-loop interactions.

What is an Agent?

An Agent is a multi-step workflow orchestrator in Tool Forge. While a Tool executes a single operation, an Agent coordinates multiple steps in a defined sequence—with the ability to branch, loop, and incorporate human feedback at any point.

Tool Forge makes it uniquely easy to build AI workflows with human-in-the-loop because of its native IO system. Each step in your agent can pause, collect user input, display results, and continue—all without leaving the workflow.

Agent vs Tool

AspectToolAgent
StructureSingle handler functionMultiple steps with workflow
StateNo built-in stateContext (shared state across steps)
FlowLinear executionSequential, parallel, and conditional flows
Use CaseSimple operationsComplex multi-step workflows

Agent Structure

Every agent is defined using the defineAgent() function:

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

export default defineAgent({
  name: 'My Agent',
  description: 'Description of what this agent does',
  contextSchema: z.object({
    // Define your agent's state structure
  }),
  steps: {
    // Define individual steps
  },
  workflow: (builder) => {
    // Define how steps connect
  },
  bootstrap: async ({ io }) => {
    // Optional: Initialize context before workflow starts
  },
})

Configuration Options

PropertyTypeRequiredDescription
namestringYesDisplay name in the dashboard
descriptionstringNoBrief description of the agent's purpose
contextSchemaz.ZodTypeYesZod schema defining the context structure
stepsRecord<string, AgentStep>YesObject containing all step definitions
workflowfunctionYesFunction that defines step connections
bootstrapfunctionNoInitialization function to set up initial context

Context: The Agent's State

The context is the shared state that flows through your agent. Think of it as the agent's memory—each step can read from it and update it, allowing information to flow between steps.

Defining Context

Use Zod to define a strongly-typed context schema:

contextSchema: z.object({
  // Required fields
  topic: z.string().describe('The topic to write about'),

  // Optional fields with defaults
  wordCount: z.number().default(500),

  // Fields populated by steps
  generatedContent: z.string().optional(),
  userFeedback: z.string().optional(),

  // Control flow fields
  isApproved: z.boolean().default(false),
  iterationCount: z.number().default(0),
}),

The context schema is validated at runtime. If a step tries to set invalid data, the agent will throw an error with a clear message.

Reading Context

Each step receives the current context:

handler: async ({ context }) => {
  // Access context values
  const topic = context.topic
  const wordCount = context.wordCount

  // Use in your logic
  const result = await generateContent(topic, wordCount)
  return result
}

Updating Context

Use the updateContext function to modify the context. It accepts either a partial object or an updater function:

handler: async ({ context, updateContext }) => {
  // Option 1: Partial object (merges with existing context)
  updateContext({
    generatedContent: 'New content here',
    isApproved: false,
  })

  // Option 2: Updater function (access previous values)
  updateContext((prev) => ({
    iterationCount: prev.iterationCount + 1,
    lastUpdated: new Date().toISOString(),
  }))
}

Context updates are validated against your schema. Always ensure your updates match the expected types.

Steps

Steps are the individual units of work in an agent. Each step has a handler function that executes when the step is reached in the workflow.

Defining Steps

steps: {
  ideaGeneration: {
    name: 'Idea Generation',           // Optional display name
    description: 'Generates ideas',    // Optional description
    handler: async ({ context, updateContext, io, block, signal, metadata }) => {
      // Step logic here
    },
  },

  writing: {
    name: 'Writing',
    handler: async ({ context, updateContext }) => {
      // Another step
    },
  },
},

Step Handler Arguments

Each step handler receives the same arguments as a Tool handler, plus context management:

ArgumentDescription
contextCurrent state of the agent (read-only snapshot)
updateContextFunction to update the agent's context
ioIO methods for user interaction
blockBlock outputs for rich displays
signalAbortSignal for handling cancellation
metadataStep and agent metadata (sessionId, stepName, etc.)

Human-in-the-Loop Steps

This is where Tool Forge shines. Steps can pause and wait for user input using IO methods:

feedbackIncorporation: {
  name: 'Get Feedback',
  handler: async ({ io, updateContext }) => {
    // Show content and ask for approval
    const isApproved = await io.confirm({
      title: 'Are you satisfied with the content?',
      okButtonLabel: 'Yes, continue',
      cancelButtonLabel: 'No, provide feedback',
    })

    if (isApproved) {
      updateContext({ isApproved: true })
      return 'User approved the content'
    }

    // Collect detailed feedback
    const feedback = await io.textInput({
      label: 'What would you like to change?',
      multiline: true,
    })

    updateContext({
      userFeedback: feedback,
      isApproved: false,
    })

    return `Feedback received: ${feedback}`
  },
},

Workflow Builder

The workflow builder defines how steps connect and execute. It provides two methods for creating flows:

  • flow(from, to) — Direct transition between steps
  • branch(from, condition, targetMap) — Conditional routing based on context values

Linear Flow: flow()

Use flow() to create direct transitions between steps:

workflow: (builder) => {
  return builder
    .flow('START', 'step1') // START → step1
    .flow('step1', 'step2') // step1 → step2
    .flow('step2', 'END') // step2 → END
}

Special Nodes

Every workflow has two special nodes:

  • START - The entry point (must have outgoing edges, no incoming edges)
  • END - The exit point (must have incoming edges, no outgoing edges)

Conditional Branching: branch()

Use branch() to route to different steps based on context values.

Parameters:

ParameterTypeDescription
fromstringThe step (or START) from which to branch
condition(context) => stringA function that receives the current context and returns a key from the targetMap
targetMapRecord<string, string>An object mapping condition return values to step names (or END)

Why targetMap?

You might wonder why the condition function returns a key that maps to a step, rather than returning the step name directly. This design enables compile-time graph validation. By declaring all possible targets upfront in targetMap, Tool Forge can validate your workflow structure before execution—ensuring all referenced steps exist, detecting unreachable nodes, and verifying that END is reachable from every branch path.

workflow: (builder) => {
  return builder
    .flow('START', 'generateContent')
    .flow('generateContent', 'reviewContent')
    .branch(
      'reviewContent',
      // Condition function - returns a key from targetMap
      (context) => (context.isApproved ? 'APPROVED' : 'NEEDS_REVISION'),
      // Target map - maps keys to step names
      {
        APPROVED: 'publishContent',
        NEEDS_REVISION: 'generateContent', // Loop back!
      },
    )
    .flow('publishContent', 'END')
}

Creating Loops

Branches can point back to earlier steps, creating loops for iterative refinement:

.branch(
  'feedbackStep',
  (context) => {
    if (context.isUserSatisfied) return 'DONE'
    if (context.iterationCount > context.maxIterations) return 'DONE'
    return 'RETRY'
  },
  {
    DONE: 'finalStep',
    RETRY: 'generationStep',  // Loop back for another iteration
  }
)

Always include an exit condition in loops to prevent infinite execution. The agent has a built-in maximum iteration limit (1000 by default).

Parallel Flows

You can create parallel execution paths by adding multiple flows from the same step:

workflow: (builder) => {
  return builder
    .flow('START', 'fetchUserData')
    .flow('START', 'fetchProductData') // Runs in parallel with above
    .flow('fetchUserData', 'processData')
    .flow('fetchProductData', 'processData')
    .flow('processData', 'END')
}

Bootstrap Function

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

  • Collect initial inputs from the user
  • Set up the initial context
  • Perform any pre-workflow setup
bootstrap: async ({ io, block }) => {
  const topic = await io.textInput({
    label: 'What topic would you like to write about?',
  })

  const wordCount = await io.numberInput({
    label: 'Target word count',
    defaultValue: 500,
    validationSchema: z.number().min(100).max(5000),
  })

  // Return partial context - merged with schema defaults
  return { topic, wordCount }
},

The returned object is merged with the context schema's defaults to create the initial context.

Complete Example: Writer Agent

Here's a complete agent that demonstrates all concepts—context, steps, branching, loops, and human-in-the-loop:

import { defineAgent } from '@toolforge-js/sdk/components'
import { generateText } from 'ai'
import * as z from 'zod'

export default defineAgent({
  name: 'Writer Agent',
  description: 'An AI writing assistant with human feedback loops',

  // Define the agent's state
  contextSchema: z.object({
    topic: z.string(),
    wordCount: z.number().default(500),
    selectedIdea: z.string().optional(),
    content: z.string().optional(),
    isUserSatisfied: z.boolean().default(false),
    userFeedback: z.string().optional(),
    iterationCount: z.number().default(0),
    maxIterations: z.number().default(3),
  }),

  steps: {
    ideaGeneration: {
      name: 'Idea Generation',
      description: 'Generate and select a writing idea',
      handler: async ({ context, updateContext, io }) => {
        // Generate ideas using AI
        const ideas = await generateIdeas(context.topic)

        // Human-in-the-loop: Let user select
        const selected = await io.selectInput({
          label: 'Select an idea to write about',
          options: ideas.map((idea, i) => ({
            label: idea,
            value: i.toString(),
          })),
        })

        updateContext({ selectedIdea: ideas[parseInt(selected)] })
        return `Selected: ${ideas[parseInt(selected)]}`
      },
    },

    writing: {
      name: 'Writing',
      handler: async ({ context, updateContext }) => {
        const content = await generateText({
          // ... AI configuration
          prompt: context.userFeedback
            ? `Revise based on: ${context.userFeedback}`
            : `Write about: ${context.selectedIdea}`,
        })

        updateContext({ content: content.text })
        return content.text
      },
    },

    feedbackIncorporation: {
      name: 'Get Feedback',
      handler: async ({ io, updateContext }) => {
        const isOK = await io.confirm({
          title: 'Are you satisfied with the content?',
        })

        if (isOK) {
          updateContext({ isUserSatisfied: true })
          return 'User approved!'
        }

        const feedback = await io.textInput({
          label: 'What would you like to change?',
        })

        updateContext((prev) => ({
          userFeedback: feedback,
          isUserSatisfied: false,
          iterationCount: prev.iterationCount + 1,
        }))

        return `Feedback: ${feedback}`
      },
    },

    generateImage: {
      name: 'Generate Image',
      handler: async ({ context, block, io }) => {
        // Generate and display image
        const image = await generateImage(context.content)
        await io.message({
          title: 'Generated Image',
          message: block.image({ url: image.url }),
        })
      },
    },
  },

  // Define the workflow
  workflow: (builder) => {
    return builder
      .flow('START', 'ideaGeneration')
      .flow('ideaGeneration', 'writing')
      .flow('writing', 'feedbackIncorporation')
      .branch(
        'feedbackIncorporation',
        (context) =>
          context.isUserSatisfied ||
          context.iterationCount >= context.maxIterations
            ? 'DONE'
            : 'REVISE',
        { DONE: 'generateImage', REVISE: 'writing' },
      )
      .flow('generateImage', 'END')
  },

  // Initialize context before workflow starts
  bootstrap: async ({ io }) => {
    const topic = await io.textInput({
      label: 'What topic would you like to write about?',
    })
    const wordCount = await io.numberInput({
      label: 'Target word count',
      defaultValue: 500,
    })
    return { topic, wordCount }
  },
})

Workflow Validation

The workflow builder automatically validates your workflow for common errors:

ValidationDescription
START existsWorkflow must begin with START
END existsWorkflow must terminate at END
START has outgoing edgesSTART must connect to at least one step
END has no outgoing edgesEND cannot connect to other steps
No orphan nodesAll steps must be reachable from START
END is reachableAt least one path must lead to END
Branch minimum targetsBranches must have at least 2 targets
No mixed edge typesA step cannot have both flow and branch edges

Agent Metadata

Each step receives metadata about the current execution:

handler: async ({ metadata }) => {
  console.log(metadata.sessionId) // Unique session ID
  console.log(metadata.runnerId) // Runner executing this agent
  console.log(metadata.agentId) // Agent identifier
  console.log(metadata.stepName) // Current step name
  console.log(metadata.stepDescription) // Current step description
}

Best Practices

1. Design Context for Flow Control

Include fields specifically for controlling workflow branching:

contextSchema: z.object({
  // Data fields
  inputData: z.string(),
  result: z.string().optional(),

  // Flow control fields
  isComplete: z.boolean().default(false),
  needsRetry: z.boolean().default(false),
  errorMessage: z.string().optional(),
})

2. Keep Steps Focused

Each step should do one thing well:

// ✅ Good: Focused steps
steps: {
  validateInput: { /* ... */ },
  processData: { /* ... */ },
  formatOutput: { /* ... */ },
}

// ❌ Bad: Monolithic step
steps: {
  doEverything: { /* validate + process + format */ },
}

3. Handle Edge Cases in Branches

Always account for unexpected states:

.branch(
  'checkStatus',
  (context) => {
    if (context.status === 'success') return 'SUCCESS'
    if (context.status === 'retry') return 'RETRY'
    return 'ERROR'  // Default fallback
  },
  { SUCCESS: 'complete', RETRY: 'process', ERROR: 'handleError' }
)

4. Use Bootstrap for Required Inputs

Collect essential data before the workflow starts:

bootstrap: async ({ io }) => {
  // These are required for the workflow to function
  const requiredField = await io.textInput({
    label: 'Required field',
    validationSchema: z.string().min(1),
  })
  return { requiredField }
}

5. Limit Loop Iterations

Prevent infinite loops with counter checks:

contextSchema: z.object({
  iterationCount: z.number().default(0),
  maxIterations: z.number().default(5),
}),
  // In branch condition
  (context) =>
    context.iterationCount >= context.maxIterations ? 'EXIT' : 'CONTINUE'

Next Steps

  • Tools - Learn about single-operation tools
  • IO Methods - Explore all available input/output methods
  • Runner - Understand how agents execute
  • Quick Start - Build your first project

On this page