Building Durable AI Workflows with Structured Outputs in Go

Learn how to create robust, long-running AI workflows with structured outputs using Inferable and Go.

Nadeesha Cabral on 05-03-2025

While there's been an explosion of AI libraries and frameworks, many lack the infrastructure required for building robust, long-running workflows with structured outputs. This is where Inferable comes in - a powerful platform for creating durable AI workflows with minimal boilerplate.

First thing first - Inferable is not a framwork or a library. We are an opinionated platform for building AI workflows. We provide a REST API for building AI-native workflows and a client library for Go to make it easier to wrangle the API.

In this post, we'll walk through creating a simple yet powerful AI workflow in Go that processes restaurant menu websites and extracts structured data.

Workflows with Structured Outputs

When working with LLMs, getting reliable structured data is a significant challenge. Free-form text responses are unpredictable, making it difficult to build applications that can reliably parse and use the output. Structured outputs solve this problem by constraining the model to produce data in a specific format that your application can easily work with.

But all LLM providers don't support structured outputs. For some models, you might have to resort to doing some "prompt engineering" like: "PLEASE RETURN THE OUTPUT IN THE FOLLOWING JSON FORMAT, AND ONLY RETURN THE JSON, NO OTHER TEXT". This is not reliable and is error prone.

Inferable makes this straightforward with built-in support for structured outputs across different language models, handling retries and validation automatically. We have a model-agnostic approach to structured outputs that works with any LLM using a minimal schema definition. (More on this in a future post.)

Prerequisites

Before we begin, make sure you have:

  1. Go installed (1.18 or newer)
  2. Basic familiarity with Go programming
  3. An Inferable API key (you can create an account to get one)

Setting Up the Project

Let's start by creating a new Go project and installing the necessary packages:

mkdir menu-extractor
cd menu-extractor
go mod init menu-extractor
go get github.com/inferablehq/inferable/sdk-go

Creating Our First Workflow

The goal of our workflow is simple: given a URL to a restaurant website, extract a structured representation of their menu items and opening hours.

Let's create a file called main.go:

package main

import (
	"fmt"
	"io/ioutil"
	"net/http"
	"os"
	"time"

	inferable "github.com/inferablehq/inferable/sdk-go"
)

// Define our input and output structures
type WorkflowInput struct {
	ExecutionId string `json:"executionId"`
	URL         string `json:"url"`
}

type MenuItem struct {
	Name        string  `json:"name"`
	Description string  `json:"description,omitempty"`
	Price       float64 `json:"price"`
	Category    string  `json:"category,omitempty"`
}

type OpeningHours struct {
	Monday    string `json:"monday,omitempty"`
	Tuesday   string `json:"tuesday,omitempty"`
	Wednesday string `json:"wednesday,omitempty"`
	Thursday  string `json:"thursday,omitempty"`
	Friday    string `json:"friday,omitempty"`
	Saturday  string `json:"saturday,omitempty"`
	Sunday    string `json:"sunday,omitempty"`
}

type WorkflowOutput struct {
	MenuItems      []MenuItem   `json:"menuItems"`
	Hours          OpeningHours `json:"hours"`
	RestaurantName string       `json:"restaurantName"`
}

func main() {
	// Initialize the Inferable client
	client, err := inferable.New(inferable.InferableOptions{
		APISecret: os.Getenv("INFERABLE_API_SECRET"),
	})
	if err != nil {
		fmt.Printf("Error initializing Inferable client: %v\n", err)
		return
	}

	// Create a workflow
	workflow := client.Workflows.Create(inferable.WorkflowConfig{
		Name:        "menu-extractor",
		InputSchema: WorkflowInput{},
	})

	// Define workflow logic (version 1)
	workflow.Version(1).Define(func(ctx inferable.WorkflowContext, input WorkflowInput) (interface{}, error) {
		// Log the start of the workflow
		ctx.Log("info", map[string]interface{}{
			"message": "Starting menu extraction",
			"url":     input.URL,
		})

		// Fetch the content of the webpage
		webContent, err := fetchWebpage(input.URL)
		if err != nil {
			return nil, fmt.Errorf("error fetching webpage: %w", err)
		}

		// Use LLM to extract structured data
		result, err := ctx.LLM.Structured(inferable.StructuredInput{
			Input:  webContent,
			Schema: WorkflowOutput{},
		})

		if err != nil {
			return nil, fmt.Errorf("error extracting structured data: %w", err)
		}

		// Return the extracted data
		return result, nil
	})

	// Start listening for workflow executions
	fmt.Println("Starting to listen for menu-extractor workflow executions...")
	err = workflow.Listen()
	if err != nil {
		fmt.Printf("Error listening for workflow executions: %v\n", err)
		return
	}

	// Trigger the workflow after a countdown
	triggerWorkflowWithCountdown(client, 3)

	// Keep the program running
	select {}
}

// triggerWorkflowWithCountdown triggers the workflow after a countdown
func triggerWorkflowWithCountdown(client *inferable.Inferable, seconds int) {
	// Add a countdown before triggering
	fmt.Printf("Triggering workflow in:\n")
	for i := seconds; i > 0; i-- {
		fmt.Printf("%d...\n", i)
		time.Sleep(1 * time.Second)
	}
	fmt.Println("Triggering workflow now!")

	err := client.Workflows.Trigger("menu-extractor", "unique-execution-id", map[string]interface{}{
		"url": "https://a.inferable.ai/menu.txt",
	})
	if err != nil {
		fmt.Printf("Error triggering workflow: %v\n", err)
		return
	}
	fmt.Println("Workflow triggered successfully")
}

// Helper function to fetch webpage content
func fetchWebpage(url string) (string, error) {
	resp, err := http.Get(url)
	if err != nil {
		return "", err
	}
	defer resp.Body.Close()

	body, err := ioutil.ReadAll(resp.Body)
	if err != nil {
		return "", err
	}

	return string(body), nil
}

This code sets up a workflow that:

  1. Takes a URL as input
  2. Fetches the content of the webpage
  3. Uses an LLM to extract menu items and opening hours in a structured format
  4. Returns the structured data
  5. Includes a built-in trigger function with a countdown

How Structured Output Works

The magic happens in this part:

result, err := ctx.LLM.Structured(inferable.StructuredInput{
    Input: webContent,
    Schema: WorkflowOutput{},
})

Here, we're telling the LLM to:

  • Parse the webpage content we fetched
  • Return the data in the format defined by our WorkflowOutput struct

Inferable handles all the complex work of:

  • Converting our Go struct to a JSON schema the LLM can understand
  • Passing this schema to the LLM with appropriate prompting
  • Validating that the LLM's response conforms to our schema
  • Automatically retrying if the response doesn't match our schema
  • Parsing the response back into our Go struct

Making Our Workflow More Robust

Let's enhance our workflow to make it more resilient. We'll add:

  1. Caching to avoid redundant web requests
  2. Better error handling
  3. Fallback strategies

Here's how we can modify our workflow definition:

workflow.Version(1).Define(func(ctx inferable.WorkflowContext, input WorkflowInput) (interface{}, error) {
    // Log the start of the workflow
    ctx.Log("info", map[string]interface{}{
        "message": "Starting menu extraction",
        "url":     input.URL,
    })

    // Avoid redundant web requests, in case the workflow is retried
    webContent, err := ctx.Memo("fetch_"+input.URL, func() (interface{}, error) {
        content, err := fetchWebpage(input.URL)
        if err != nil {
            return "", fmt.Errorf("error fetching webpage: %w", err)
        }
        return content, nil
    })
    
    if err != nil {
        return nil, fmt.Errorf("error in memoized fetch: %w", err)
    }

    // Convert interface{} to string
    contentStr, ok := webContent.(string)
    if !ok {
        return nil, fmt.Errorf("unexpected type in memo result")
    }

    // Use LLM to extract structured data
    result, err := ctx.LLM.Structured(inferable.StructuredInput{
        Input:  contentStr,
        Schema: WorkflowOutput{},
    })

    if err != nil {
        // Log the error
        ctx.Log("error", map[string]interface{}{
            "message": "Error extracting structured data",
            "error":   err.Error(),
        })
        
        // Try a fallback strategy with simpler requirements
        ctx.Log("info", map[string]interface{}{
            "message": "Attempting fallback extraction with simpler schema",
        })
        
        result, err = ctx.LLM.Structured(inferable.StructuredInput{
            Input: contentStr,
            Schema: struct {
                RestaurantName string      `json:"restaurantName"`
                MenuItems      []MenuItem  `json:"menuItems"`
            }{},
        })
        
        if err != nil {
            return nil, fmt.Errorf("all extraction attempts failed: %w", err)
        }
    }

    // Log success
    ctx.Log("info", map[string]interface{}{
        "message": "Successfully extracted menu data",
    })

    // Return the extracted data
    return result, nil
})

The changes include:

  • Using ctx.Memo to cache web requests, ensuring we only fetch each URL once even if the workflow is retried
  • Adding more detailed error logging
  • Implementing a fallback strategy with a simpler schema if the full extraction fails

Running the Workflow

To run our workflow:

  1. Set your Inferable API key:

    export INFERABLE_API_SECRET=your_api_key_here
    
  2. Build and run the application:

    go build
    ./menu-extractor
    

This starts a service that listens for workflow executions and automatically triggers the workflow with a countdown. The workflow will be triggered with the URL "https://a.inferable.ai/menu.txt".

Enhancing with an Agent

For more complex scenarios, we might want to use an agent that can take multiple actions to extract the information.

Most scenarios can be handled with the structured output approach, but agents are useful when you need to take additional actions, or the input is unbounded. For example, you might want to follow links on the page to extract more information, or translate the content to extract information in a different language.

Let's update our workflow to use an agent that can follow links on the page to extract more information:

workflow.Version(2).Define(func(ctx inferable.WorkflowContext, input WorkflowInput) (interface{}, error) {
    // Register tools for the agent to use
    workflow.Tools.Register(inferable.WorkflowTool{
        Name: "fetchWebpage",
        InputSchema: struct {
            URL string `json:"url"`
        }{},
        Func: func(input struct {
            URL string `json:"url"`
        }, ctxInput inferable.ContextInput) (struct {
            Content string `json:"content"`
        }, error) {
            content, err := fetchWebpage(input.URL)
            return struct {
                Content string `json:"content"`
            }{
                Content: content,
            }, err
        },
    })

    // Use an agent to extract the menu information
    result, interrupt, err := ctx.Agents.React(inferable.ReactAgentConfig{
        Name:        "menuExtractor",
        Instructions: "You are a menu extraction agent. Your goal is to extract menu items and opening hours from restaurant websites. If there are other links on the page, you should extract them as well.",
        Input:       fmt.Sprintf("Extract menu and hours from: %s", input.URL),
        Tools:       []string{"fetchWebpage"},
        Schema:      WorkflowOutput{},
    })

    if err != nil {
        return nil, fmt.Errorf("agent execution failed: %w", err)
    }

    // Handle any interruptions (like if human approval is needed)
    if interrupt != nil {
        return interrupt, nil
    }

    return result, nil
})

This approach gives the LLM more flexibility by allowing it to call tools (like fetchWebpage) on its own as needed, rather than having us hardcode the sequence of operations.

Benefits of This Approach

By using Inferable for structured outputs and workflow management, we gain several advantages:

  1. Durability: Workflows can be paused, resumed, and retried automatically
  2. Structured data: We get properly typed Go structs instead of unpredictable text
  3. Separation of concerns: The workflow definition is clean and focused on business logic
  4. Observability: Built-in logging and monitoring
  5. Scalability: The workflow can run on any machine with access to the Inferable API
Written by Inferable Team

Ready to build your own AI solutions? Inferable makes it easy to create powerful AI workflows, agents, and automations without wrestling with complex LLM APIs.

Try Inferable Free