Skip to main content

Command Palette

Search for a command to run...

Intents in Conversational AI with GenKit Go and Docker Model Runner

Or how to easily implement an intent detection system with locally hosted LLMs.

Published
16 min read
Intents in Conversational AI with GenKit Go and Docker Model Runner

In the field of conversational AI, an intent represents the user's goal or objective when formulating a request. For example, in a Dungeons & Dragons-style role-playing game, a player might express the intent/intention to "launch an attack" or "speak to an NPC".

In a more conventional application, a user might have the intention to "book a flight" or "check their bank balance"... but that's less fun.

Before moving on to implementing an intent detection system with GenKit Go, let's quickly talk about "structured outputs" with LLMs, a key concept for extracting data from a user prompt.

Note: I'm using examples related to role-playing games (RPG) because I'm currently working on a "mini" role-playing game project using GenKit Go and Docker Model Runner. But the concepts presented here are applicable to many other domains.

Prerequisites:

You'll need to load the model we're going to use for the demos:

docker model pull hf.co/menlo/jan-nano-gguf:q4_k_m

For more information about jan-nano, you can check out the model page on Hugging Face: https://huggingface.co/menlo/jan-nano-gguf.

Information: This example from the GenKit Go documentation inspired this article: https://genkit.dev/docs/agentic-patterns/?lang=go#workflow-conditional-routing

Structured Outputs with GenKit Go

GenKit Go makes it very easy to generate structured outputs from prompts by defining Go structures and using the GenerateData method.

For example, in the code below, we define a NonPlayerCharacter structure to allow the model to generate a non-player character (NPC) in a role-playing game.

package main

import (
    "context"
    "fmt"
    "log"
    "strings"

    "github.com/firebase/genkit/go/ai"
    "github.com/firebase/genkit/go/genkit"
    "github.com/firebase/genkit/go/plugins/compat_oai/openai"
    "github.com/openai/openai-go/option"
)

// Structure for final flow output
type NonPlayerCharacter struct {
    Name        string `json:"name"`
    Role        string `json:"role"`
    Race        string `json:"race"`
    Background  string `json:"background"`
    Personality string `json:"personality"`
    Abilities   string `json:"abilities"`
}

func main() {
    ctx := context.Background()

    oaiPlugin := &openai.OpenAI{
        APIKey: "I💙DockerModelRunner",
        Opts: []option.RequestOption{
            option.WithBaseURL("http://localhost:12434/engines/v1/"),
        },
    }

    genKitInstance := genkit.Init(ctx, genkit.WithPlugins(oaiPlugin))

    nonPlayerCharacter, modelResponse, err := genkit.GenerateData[NonPlayerCharacter](ctx, genKitInstance,
        ai.WithModelName("openai/hf.co/menlo/jan-nano-gguf:q4_k_m"), // 1️⃣
        ai.WithSystem("You are the dungeon master of a D&D game."),
        ai.WithPrompt("Generate a D&D NPC Elf name and all its characteristics."),
        ai.WithConfig(map[string]any{"temperature": 0.7}),
    )
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println("Raw model response:", modelResponse.Text())

    fmt.Println(strings.Repeat("=", 50))
    // Display the fields
    fmt.Println("Generated D&D NPC Elf:")
    fmt.Println("Name:", nonPlayerCharacter.Name)
    fmt.Println("Role:", nonPlayerCharacter.Role)
    fmt.Println("Race:", nonPlayerCharacter.Race)
    fmt.Println("Personality:", nonPlayerCharacter.Personality)
    fmt.Println("Abilities:", nonPlayerCharacter.Abilities)
    fmt.Println("Background:", nonPlayerCharacter.Background)

}

(1): when using the openai.OpenAI plugin, you need to prefix the model name with openai/ so that GenKit Go can recognize it correctly.

And when you run this code, you get output like this:

Raw model response: {
  "name": "Thalorin",
  "role": "Rogue",
  "race": "Elf",
  "background": "Outlander",
  "personality": "Cunning and observant, Thalorin is a master of deception and survival. They are fiercely independent and value their freedom above all else. Thalorin is also deeply curious and enjoys learning about the secrets of the world.",
  "abilities": "Thalorin is a skilled archer and a master of stealth. They are also adept at using magic, particularly in the form of illusion and enchantment spells. Thalorin is known for their ability to read people and situations, making them a valuable companion in any adventure."
}
==================================================
Generated D&D NPC Elf:
Name: Thalorin
Role: Rogue
Race: Elf
Personality: Cunning and observant, Thalorin is a master of deception and survival. They are fiercely independent and value their freedom above all else. Thalorin is also deeply curious and enjoys learning about the secrets of the world.
Abilities: Thalorin is a skilled archer and a master of stealth. They are also adept at using magic, particularly in the form of illusion and enchantment spells. Thalorin is known for their ability to read people and situations, making them a valuable companion in any adventure.
Background: Outlander

Pretty cool, right? And easy to use for plenty of use cases, including intent detection in conversational AI, but we'll come back to that later.

Just remember that GenKit Go's GenerateData method allows you to generate structured data very easily from a prompt and a Go structure. So the important part is:

genkit.GenerateData[NonPlayerCharacter]

Before moving on to intent detection with GenKit Go, let's see how to encapsulate this logic in a GenKit flow. It's a kind of recommended pattern for organizing your code.

Use Genkit Flows, it's better!

But why? Because Genkit flows serve to encapsulate your AI workloads (LLM, RAG, tools, etc.) into typed, observable functions that are easily callable from your apps or backends.

So let's redo the previous example using a Genkit flow.

package main

import (
    "context"
    "fmt"
    "log"
    "strings"

    "github.com/firebase/genkit/go/ai"
    "github.com/firebase/genkit/go/genkit"
    "github.com/firebase/genkit/go/plugins/compat_oai/openai"
    "github.com/openai/openai-go/option"
)

// Structure for flow input
type ChatRequest struct {
    Message string `json:"message"`
}

// Structure for final flow output
type NonPlayerCharacter struct {
    Name        string `json:"name"`
    Role        string `json:"role"`
    Race        string `json:"race"`
    Background  string `json:"background"`
    Personality string `json:"personality"`
    Abilities   string `json:"abilities"`
}

func main() {
    ctx := context.Background()

    oaiPlugin := &openai.OpenAI{
        APIKey: "I💙DockerModelRunner",
        Opts: []option.RequestOption{
            option.WithBaseURL("http://localhost:12434/engines/v1/"),
        },
    }

    genKitInstance := genkit.Init(ctx, genkit.WithPlugins(oaiPlugin))

    nonPlayerCharacterFlow := genkit.DefineFlow(genKitInstance, "npc-flow",
        func(ctx context.Context, input *ChatRequest) (*NonPlayerCharacter, error) {

            nonPlayerCharacter, modelResponse, err := genkit.GenerateData[NonPlayerCharacter](ctx, genKitInstance,
                ai.WithModelName("openai/hf.co/menlo/jan-nano-gguf:q4_k_m"),
                ai.WithSystem("You are the dungeon master of a D&D game."),
                ai.WithPrompt(input.Message),
                ai.WithConfig(map[string]any{"temperature": 0.7}),
            )
            if err != nil {
                return nil, err
            }
            fmt.Println("Raw model response:", modelResponse.Text())
            return nonPlayerCharacter, nil

        })

    // Run the flow:
    npcGenerationResult, err := nonPlayerCharacterFlow.Run(ctx, &ChatRequest{
        Message: "Generate a D&D NPC Elf name and all its characteristics.",
    })
    if err != nil {
        log.Fatal(err)
    }
    fmt.Println(strings.Repeat("=", 50))
    // Display the fields
    fmt.Println("Generated D&D NPC Elf:")
    fmt.Println("Name:", npcGenerationResult.Name)
    fmt.Println("Role:", npcGenerationResult.Role)
    fmt.Println("Race:", npcGenerationResult.Race)
    fmt.Println("Personality:", npcGenerationResult.Personality)
    fmt.Println("Abilities:", npcGenerationResult.Abilities)
    fmt.Println("Background:", npcGenerationResult.Background)

}

So I've defined a flow named npc-flow (the name must be unique) that takes as input a ChatRequest structure (you define the structure yourself) containing the user's message and returns a NonPlayerCharacter structure with the generated NPC's characteristics.

When you run this code, you get output similar to the previous example, but this time using a Genkit flow to encapsulate the data generation logic.

I want to speak to a Dwarf!

Or how to prepare an agent/NPC "routing" system by detecting user intents with GenKit Go.

I'd like, for a role-playing game project, to detect if a user wants to speak to a specific NPC (non-player character) from a list of known NPCs. If the user mentions a known NPC, I want to extract this information in structured form.

So this time, we'll define an Intent structure to represent the user's intention:

// Structure for final flow output
type Intent struct {
    Action    string `json:"intent"`
    Character string `json:"name"`
    Known     bool   `json:"known"`
}

And finally, create a Genkit flow to detect this intent from the user message.

Here's the complete code:

package main

import (
    "context"
    "fmt"
    "os"

    "github.com/firebase/genkit/go/ai"
    "github.com/firebase/genkit/go/genkit"
    oai "github.com/firebase/genkit/go/plugins/compat_oai/openai"
    "github.com/openai/openai-go"
    "github.com/openai/openai-go/option"
)

// Structure for flow input
type FlowRequest struct {
    UserMessage string `json:"message"`
}

// Structure for final flow output
type Intent struct {
    Action    string `json:"intent"`
    Character string `json:"name"`
    Known     bool   `json:"known"`
}

func main() {

    ctx := context.Background()

    engineURL := os.Getenv("MODEL_RUNNER_BASE_URL")
    if engineURL == "" {
        engineURL = "http://localhost:12434/engines/llama.cpp/v1"
    }

    oaiPlugin := &oai.OpenAI{
        APIKey: "I💙DockerModelRunner",
        Opts: []option.RequestOption{
            option.WithBaseURL(engineURL),
        },
    }

    genKitInstance := genkit.Init(ctx, genkit.WithPlugins(oaiPlugin))

    intentFlow := genkit.DefineFlow(genKitInstance, "routing-flow",
        func(ctx context.Context, input *FlowRequest) (Intent, error) {

            modelId := "openai/hf.co/menlo/jan-nano-gguf:q4_k_m"
            systemInstructions := `
            You are helping the dungeon master of a D&D game.
            Detect if the user want to speak to one of the following NPCs:
            Thrain (dwarf blacksmith),
            Liora (elven mage),
            Galdor (human rogue),
            Elara (halfling ranger),
            Shesepankh (tiefling warlock).

            If the user's message does not explicitly mention wanting to speak to one of these NPCs, respond with:
            action: speak
            character: <NPC name>
            known: false

            Otherwise, respond with:
            action: speak
            character: <NPC name>
            Where <NPC name> is the name of the NPC the user wants to speak to: Thrain, Liora, Galdor, Elara, or Shesepankh.
            known: true
            `

            intent, _, err := genkit.GenerateData[Intent](ctx, genKitInstance,
                ai.WithModelName(modelId),
                ai.WithSystem(systemInstructions),
                ai.WithPrompt(input.UserMessage),
                ai.WithConfig(openai.ChatCompletionNewParams{
                    Temperature: openai.Float(0.0),
                    TopP:        openai.Float(0.9),
                }),
            )
            if err != nil {
                return Intent{}, err
            }

            return *intent, nil
        })

    testMessages := []string{
        "I want to chat with Thrain and learn about his blacksmith skills.",
        "I want to meet a dwarf blacksmith.",
        "I want to speak about spells and magic.",
        "I want to speak to Bob Morane.",
    }

    for _, message := range testMessages {
        intent, err := intentFlow.Run(ctx, &FlowRequest{
            UserMessage: message,
        })
        if err != nil {
            fmt.Printf("😡 Error running intent flow: %v\n", err)
            return
        }
        if intent.Known == false {
            fmt.Println("🙀 NPC", intent.Character, "not recognized!")
            continue
        }
        fmt.Println("🙂 Detected Intent: action ->", intent.Action, "character ->", intent.Character)
    }

}

✋✋✋ You must note 1 EXTREMELY important thing:

It's the use of json:"intent" next to Action in the Intent structure. This will help structure the LLM's reasoning so it fills the Action field correctly.

type Intent struct {
    Action    string `json:"intent"`
    Character string `json:"name"`
    Known     bool   `json:"known"`
}

You must note 3 other important things:

  • You must define a clear system prompt to guide the model in intent detection (so to "fill" the fields of the Intent structure).

  • You must use a low temperature (0.0): temperature controls the degree of randomness in token choice by the LLM. In intent detection, we generally look for robust and repeatable classification, so a low temperature (≈ 0–0.3) allows predicting the same intent for the same message much more often. However, too low a temperature can make the model too rigid and unable to handle variations in user messages, so feel free to test to find the right balance.

  • I used TopP at 0.9 experimentally, to keep some diversity (help detect an "unclear" intent) while avoiding very improbable tokens. Again, do tests to see what works best for your use case.

Now, when you run this code, you get output like this:

🙂 Detected Intent: action -> speak character -> Thrain
🙂 Detected Intent: action -> speak character -> Thrain
🙂 Detected Intent: action -> speak character -> Liora
🙀 NPC Bob Morane not recognized!

Note: You can also help the model by using JSON Schema enumerations for the Character field in the Intent structure, like this:

type Intent struct {
    Action    string `json:"intent"`
    Character string `json:"name" jsonschema_enum:"Thrain,Liora,Galdor,Elara,Shesepankh"`
    Known     bool   `json:"known"`
}

In this example, we ran the flow several times with different user messages. The model correctly detected the intention to speak to Thrain and Liora, but didn't recognize "Bob Morane" as a known NPC. But would it be possible to do this all at once? That is, by running the flow only once to detect multiple intents in a single user message? The answer is yes, by using []Intent instead of Intent.

I want to speak to a Dwarf, twice! And also to a Mage!

Or how to detect multiple intents in a single user message with GenKit Go.

So I modified the flow as follows (using []Intent instead of Intent):

intentFlow := genkit.DefineFlow(genKitInstance, "routing-flow",
    func(ctx context.Context, input *FlowRequest) ([]Intent, error) {

        modelId := "openai/hf.co/menlo/jan-nano-gguf:q4_k_m"

        systemInstructions := <use previous system instructions>

        intents, _, err := genkit.GenerateData[[]Intent](ctx, genKitInstance,
            ai.WithModelName(modelId),
            ai.WithSystem(systemInstructions),
            ai.WithPrompt(input.UserMessage),
            ai.WithConfig(openai.ChatCompletionNewParams{
                Temperature: openai.Float(0.0),
                TopP:        openai.Float(0.9),
            }),
        )
        if err != nil {
            return []Intent{}, err
        }

        return *intents, nil
    })

And I ran it like this:

intents, err := intentFlow.Run(ctx, &FlowRequest{
    UserMessage: `
    I want to chat with Thrain and learn about his blacksmith skills.
    I want to meet a dwarf blacksmith.
    I want to speak about spells and magic.
    I want to speak to Bob Morane.
    `,
})
if err != nil {
    fmt.Printf("😡 Error running intent flow: %v\n", err)
    return
}
for _, intent := range intents {
    if intent.Known == false {
        fmt.Println("🙀 NPC", intent.Character, "not recognized!")
        continue
    }
    fmt.Println("🙂 Detected Intent: action ->", intent.Action, "character ->", intent.Character)
}

So I have a single message, a single call to the flow, and I get a list of detected intents.

And there at runtime, small disappointment, I get:

🙂 Detected Intent: action -> speak character -> Thrain
🙂 Detected Intent: action -> speak character -> Liora
🙀 NPC Bob Morane not recognized!

The model wasn't able to correctly interpret the phrase "I want to meet a dwarf blacksmith." as an intention to speak to Thrain.

So I "slightly" modified the system prompt by adding:

if the user want to speak to a dwarf blacksmith, they mean Thrain.
if the user want to speak to an elven mage, they mean Liora.
if the user want to speak to a human rogue, they mean Galdor.
if the user want to speak to a halfling ranger, they mean Elara.
if the user want to speak to a tiefling warlock, they mean Shesepankh.

So the complete flow becomes:

intentFlow := genkit.DefineFlow(genKitInstance, "routing-flow",
    func(ctx context.Context, input *FlowRequest) ([]Intent, error) {

        modelId := "openai/hf.co/menlo/jan-nano-gguf:q4_k_m"

        systemInstructions := `
        You are helping the dungeon master of a D&D game.
        Detect if the user want to speak to one of the following NPCs:
        Thrain (dwarf blacksmith),
        Liora (elven mage),
        Galdor (human rogue),
        Elara (halfling ranger),
        Shesepankh (tiefling warlock).

        if the user want to speak to a dwarf blacksmith, they mean Thrain.
        if the user want to speak to an elven mage, they mean Liora.
        if the user want to speak to a human rogue, they mean Galdor.
        if the user want to speak to a halfling ranger, they mean Elara.
        if the user want to speak to a tiefling warlock, they mean Shesepankh.

        If the user's message does not explicitly mention wanting to speak to one of these NPCs, respond with:
        action: speak
        character: <NPC name>
        known: false

        Otherwise, respond with:
        action: speak
        character: <NPC name>
        Where <NPC name> is the name of the NPC the user wants to speak to: Thrain, Liora, Galdor, Elara, or Shesepankh.
        known: true
        `

        intents, _, err := genkit.GenerateData[[]Intent](ctx, genKitInstance,
            ai.WithModelName(modelId),
            ai.WithSystem(systemInstructions),
            ai.WithPrompt(input.UserMessage),
            ai.WithConfig(openai.ChatCompletionNewParams{
                Temperature: openai.Float(0.0),
                TopP:        openai.Float(0.9),
            }),
        )
        if err != nil {
            return []Intent{}, err
        }

        return *intents, nil
    })

And this time, at runtime, 🎉 I indeed get:

🙂 Detected Intent: action -> speak character -> Thrain
🙂 Detected Intent: action -> speak character -> Thrain
🙂 Detected Intent: action -> speak character -> Liora
🙀 NPC Bob Morane not recognized!

So up to this point, if we had to summarize what we've seen, with GenKit Go and Docker Model Runner, it's very easy to set up an intent detection system in conversational AI, even with locally hosted models. By using Go structures to define intents and GenKit flows to encapsulate the logic, you can create flexible systems to interpret user messages.

I want to speak to NPCs and assign them tasks!

  • Or how to detect more complex intents with GenKit Go.

  • ✋ Completely experimental, I just wanted to test this.

I'd like to be able to tell my "LLM Agent" something like this:

I want to chat with Shesepankh about dark magic.
    Then Shesepankh needs to research forbidden spells.
I must speak with Elara about our next adventure.
    Then Elara needs to scout the northern woods.
We need to speak with Liora about ancient artifacts.
    Liora walks to the ancient ruins.
    Liora prepares protective charms.

And have the model detect 2 types of intents:

  1. Speak to an NPC ("speak" intent)

  2. Assign a task to an NPC ("task" intent)

🤔⏳💡🤓 So I modified the Intent structure to include a Topic field (the conversation or task topic) and an Initiator field (who initiates the action: the user or the NPC):

type Intent struct {
    Action    string `json:"intent"`
    Character string `json:"character"`
    Topic     string `json:"topic"`
    Initiator string `json:"initiator"` // "player" or "npc"
}

And I modified the system prompt to guide the model in detecting these two types of intents:

systemInstructions := `
You are helping the dungeon master of a D&D game.
Detect if the user want to speak (chat/talk/meet) to one of the following NPCs:
Thrain (dwarf blacksmith),
Liora (elven mage),
Galdor (human rogue),
Elara (halfling ranger),
Shesepankh (tiefling warlock).

if the user want to speak to a dwarf blacksmith, they mean Thrain.
if the user want to speak to an elven mage, they mean Liora.
if the user want to speak to a human rogue, they mean Galdor.
if the user want to speak to a halfling ranger, they mean Elara.
if the user want to speak to a tiefling warlock, they mean Shesepankh.

If the user's message does not explicitly mention wanting to speak to one of these NPCs, respond with:
action: speak
character: <NPC name>
topic: <topic>
initiator: player (user)

Otherwise, respond with:
action: speak
character: <NPC name>
topic: <topic>
initiator: player (user)
Where <NPC name> is the name of the NPC the user wants to speak to: Thrain, Liora, Galdor, Elara, or Shesepankh.

Detect if the user wants to assign a task (perform/do/prepare/help/fix/scout/research/walk) to the NPC.

NPCs available:
- Thrain (dwarf blacksmith)
- Liora (elven mage)
- Galdor (human rogue)
- Elara (halfling ranger)
- Shesepankh (tiefling warlock)

Respond with:
action: task
character: <NPC name>
topic: <task>
initiator: npc
Where <NPC name> is the name of the NPC the user wants to speak to: Thrain, Liora, Galdor, Elara, or Shesepankh.
`

So the complete code becomes:

package main

import (
    "context"
    "fmt"
    "os"

    "github.com/firebase/genkit/go/ai"
    "github.com/firebase/genkit/go/genkit"
    oai "github.com/firebase/genkit/go/plugins/compat_oai/openai"
    "github.com/openai/openai-go"
    "github.com/openai/openai-go/option"
)

// Structure for flow input
type FlowRequest struct {
    UserMessage string `json:"message"`
}

// Structure for final flow output
type Intent struct {
    Action    string `json:"intent"`
    Character string `json:"character"`
    Topic     string `json:"topic"`
    Initiator string `json:"initiator"` // "player" or "npc"
}

func main() {

    ctx := context.Background()

    engineURL := os.Getenv("MODEL_RUNNER_BASE_URL")
    if engineURL == "" {
        engineURL = "http://localhost:12434/engines/llama.cpp/v1"
    }

    oaiPlugin := &oai.OpenAI{
        APIKey: "I💙DockerModelRunner",
        Opts: []option.RequestOption{
            option.WithBaseURL(engineURL),
        },
    }

    genKitInstance := genkit.Init(ctx, genkit.WithPlugins(oaiPlugin))

    intentFlow := genkit.DefineFlow(genKitInstance, "routing-flow",
        func(ctx context.Context, input *FlowRequest) ([]Intent, error) {

            modelId := "openai/hf.co/menlo/jan-nano-gguf:q4_k_m"
            systemInstructions := `
            You are helping the dungeon master of a D&D game.
            Detect if the user want to speak (chat/talk/meet) to one of the following NPCs:
            Thrain (dwarf blacksmith),
            Liora (elven mage),
            Galdor (human rogue),
            Elara (halfling ranger),
            Shesepankh (tiefling warlock).

            if the user want to speak to a dwarf blacksmith, they mean Thrain.
            if the user want to speak to an elven mage, they mean Liora.
            if the user want to speak to a human rogue, they mean Galdor.
            if the user want to speak to a halfling ranger, they mean Elara.
            if the user want to speak to a tiefling warlock, they mean Shesepankh.

            If the user's message does not explicitly mention wanting to speak to one of these NPCs, respond with:
            action: speak
            character: <NPC name>
            topic: <topic>
            initiator: player (user)

            Otherwise, respond with:
            action: speak
            character: <NPC name>
            topic: <topic>
            initiator: player (user)
            Where <NPC name> is the name of the NPC the user wants to speak to: Thrain, Liora, Galdor, Elara, or Shesepankh.

            Detect if the user wants to assign a task (perform/do/prepare/help/fix/scout/research/walk) to the NPC.

            NPCs available:
            - Thrain (dwarf blacksmith)
            - Liora (elven mage)
            - Galdor (human rogue)
            - Elara (halfling ranger)
            - Shesepankh (tiefling warlock)

            Respond with:
            action: task
            character: <NPC name>
            topic: <task>
            initiator: npc
            Where <NPC name> is the name of the NPC the user wants to speak to: Thrain, Liora, Galdor, Elara, or Shesepankh.
            `

            intents, _, err := genkit.GenerateData[[]Intent](ctx, genKitInstance,
                ai.WithModelName(modelId),
                ai.WithSystem(systemInstructions),
                ai.WithPrompt(input.UserMessage),
                ai.WithConfig(openai.ChatCompletionNewParams{
                    Temperature: openai.Float(0.0),
                    TopP:        openai.Float(0.9),
                }),
            )
            if err != nil {
                return []Intent{}, err
            }

            return *intents, nil
        })

    intents, err := intentFlow.Run(ctx, &FlowRequest{
        UserMessage: `
        I want to chat with Shesepankh about dark magic.
            Then Shesepankh needs to research forbidden spells.
        I must speak with Elara about our next adventure.
            Then Elara needs to scout the northern woods.
        We need to speak with Liora about ancient artifacts.
            Liora walks to the ancient ruins.
            Liora prepares protective charms.
        `,
    })
    if err != nil {
        fmt.Printf("Error running intent flow: %v\n", err)
        return
    }
    for idx, intent := range intents {

        switch intent.Action {
        case "speak":
            fmt.Printf("%d [SPEAK] %s wants to talk with %s about: %s\n",
                idx, intent.Initiator, intent.Character, intent.Topic)
        case "task":
            fmt.Printf("%d   - [TASK] %s will %s\n",
                idx, intent.Character, intent.Topic)
        default:
            fmt.Printf("%d [UNKNOWN] action: %s, character: %s, topic: %s, initiator: %s\n",
                idx, intent.Action, intent.Character, intent.Topic, intent.Initiator)
        }
    }
}

And at runtime, I get:

0 [SPEAK] player wants to talk with Shesepankh about: dark magic
1   - [TASK] Shesepankh will research forbidden spells
2 [SPEAK] player wants to talk with Elara about: next adventure
3   - [TASK] Elara will scout the northern woods
4 [SPEAK] player wants to talk with Liora about: ancient artifacts
5   - [TASK] Liora will walk to the ancient ruins
6   - [TASK] Liora will prepare protective charms

🤩 So you see that our "Dungeon Master Agent" was able to detect the intents to speak to an NPC as well as the tasks assigned to each NPC, by extracting the relevant information into a well-defined Intent structure.

In general, handling complex intents isn't necessarily the strong suit of small LLMs, but I had already detected that the jan-nano model despite its small size (1.3GB) was pretty good at "function calling", so I suspected it would handle this reasonably well too. And this argues for using lightweight local models for specific tasks, especially when you can guide the model well with clear prompts and well-defined output structures.

🚀 Happy experimenting with GenKit Go and Docker Model Runner!

You can find the source code for the examples presented in this article at: https://codeberg.org/k33g-blog/genkit-go/src/branch/main/2025-12-03-intents/samples