chatbox

The chatbox.example microservice demonstrates the framework’s LLM tool-calling features end-to-end. It plays a double role:

  • A mock LLM provider that implements the Turn contract — the same contract that the real claudellm, chatgptllm, geminillm, and litellm provider microservices expose. Because chatbox speaks the provider interface, no API key is required to exercise the full tool-calling loop.
  • An interactive demo web UI at //chatbox.example/demo that submits messages to llm.core’s Chat endpoint, with a provider dropdown for switching between the chatbox mock and the real providers when they are configured.

Where creditflow is the on-ramp for the workflow engine, chatbox is the on-ramp for the LLM service.

Depends On

  • llm.core — the orchestrator that fetches OpenAPI documents, runs the tool-calling loop, and dispatches tool calls over the bus.
  • calculator.example — exposes Arithmetic as the tool that chatbox routes math questions to.
  • (Optional) claudellm.core, chatgptllm.core, geminillm.core — needed only if you want the demo dropdown’s real-provider options to work. Each needs a valid APIKey in config.local.yaml.

Try It

With the examples app running, open the demo:

http://localhost:8080/chatbox.example/demo

  • Ask a math-shaped question (e.g. “What is 6 times 7?”). The chatbox emits a tool call, llm.core invokes calculator.example/arithmetic over the bus, and the chatbox formats the result.

  • Ask anything else and the chatbox returns a canned “I only understand math questions” response.

  • Switch the provider dropdown to Claude / ChatGPT / Gemini to drive the same UI against a real LLM. Each real provider needs its API key in config.local.yaml (git-ignored) under the matching hostname — if the key isn’t configured, the request fails with an auth error from the upstream provider:

    # config.local.yaml
    claude.llm.core:
      APIKey: sk-ant-your-key-here
    chatgpt.llm.core:
      APIKey: sk-your-openai-key
    gemini.llm.core:
      APIKey: your-gemini-key

    Only the providers you want to exercise need a key — the chatbox mock option works with no keys configured at all.

To use chatbox as a provider from your own microservice code, pass its hostname directly to Chat:

itemsOut, usage, resolvedProvider, err := llmapi.NewClient(svc).Chat(
    ctx,
    "chatbox.example",   // provider
    "",                  // model (chatbox ignores this)
    items,
    toolURLs,
    nil,
)

Provider selection is per-call — there is no global ProviderHostname config to flip.

Code Walkthrough

Source: exampleservices/chatbox/service.go.

The service struct is bare — no member state is needed; every Turn invocation is stateless and Demo is a thin HTTP wrapper:

type Service struct {
    *Intermediate // IMPORTANT: Do not remove
}

OnStartup and OnShutdown are empty. The interesting code is two handlers and two package-level helpers.

Wiring (intermediate.go)

service.go only holds the hand-written handler bodies. The actual registration of endpoints with the underlying connector happens in the generated intermediate.go — produced by the code generator alongside the catalog file manifest.yaml. The constructor is the one entry point a calling app touches:

func NewService() *Service {
    svc := &Service{}
    svc.Intermediate = NewIntermediate(svc)
    return svc
}

NewIntermediate binds the two endpoint subscriptions:

svc.Subscribe(
    "Turn", svc.doTurn,
    sub.At(chatboxapi.Turn.Method, chatboxapi.Turn.Route),
    sub.Description(`Turn executes a single LLM turn using the chatbox demo provider.
It pattern-matches math questions and generates tool calls to the calculator.`),
    sub.Function(chatboxapi.TurnIn{}, chatboxapi.TurnOut{}),
)

svc.Subscribe(
    "Demo", svc.Demo,
    sub.At(chatboxapi.Demo.Method, chatboxapi.Demo.Route),
    sub.Description(`Demo serves the interactive demo page for the chatbox.`),
    sub.Web(),
)

Turn is wired as a sub.Function(...) — the framework parses and serializes the JSON wire format using the generated TurnIn/TurnOut types so the hand-written handler in service.go sees plain Go arguments. Demo is wired as a sub.Web() — the handler gets the raw http.ResponseWriter / *http.Request because it’s serving an HTML page and a form post. The same Subscribe mechanism underpins both shapes.

Pattern Matching Helpers

// mathPattern matches questions like "what is 6 * 7", "how much is 10 + 3", "calculate 100 / 4"
var mathPattern = regexp.MustCompile(`(?i)(?:what is|how much is|calculate|compute|what's|whats)\s+(\d+)\s*([\+\-\*\/x]|plus|minus|times|multiplied by|divided by|over)\s*(\d+)`)

// opMap normalizes English operator names to symbols.
var opMap = map[string]string{
    "+": "+", "-": "-", "*": "*", "/": "/", "x": "*",
    "plus": "+", "minus": "-", "times": "*", "multiplied by": "*", "divided by": "/", "over": "/",
}

These let chatbox recognize phrasings like “how much is 10 plus 3” or “calculate 100 over 4” and normalize them to a symbolic operator. A real LLM would do this with its model; chatbox uses a regex so the demo runs offline.

Turn

Turn is the LLM-provider contract. llm.core calls it once per round-trip in the tool-calling loop, passing the conversation so far and the tools the actor is authorized to invoke. The chatbox returns either a final natural-language content, or one or more toolCalls for llm.core to dispatch.

func (svc *Service) Turn(ctx context.Context, model string, items []llmapi.Item, tools []llmapi.Tool, options *llmapi.TurnOptions) (outItems []llmapi.Item, stopReason string, usage llmapi.Usage, err error) {
    usage = llmapi.Usage{Model: model, Turns: 1}

    reply := func(text, stop string) ([]llmapi.Item, string, llmapi.Usage, error) {
        return []llmapi.Item{llmapi.NewMessage("assistant", text).AsItem()}, stop, usage, nil
    }

    if len(items) == 0 {
        return reply("I'm the Chatbox demo. Ask me a math question!", llmapi.StopReasonEndTurn)
    }

    last := items[len(items)-1]

    // If the last item is a tool result, format it as a response
    if last.ToolResult != nil {
        var result map[string]any
        json.Unmarshal([]byte(last.ToolResult.Output), &result)
        if r, ok := result["result"]; ok {
            return reply(fmt.Sprintf("The answer is %v.", r), llmapi.StopReasonEndTurn)
        }
        return reply(fmt.Sprintf("The result is: %s", last.ToolResult.Output), llmapi.StopReasonEndTurn)
    }

A conversation is an ordered []llmapi.Item log, where each item is exactly one of a message, a tool call, a tool result, or a reasoning block. The handler inspects the last item and branches on which variant it carries. A real LLM provider takes the entire conversation as context; chatbox’s last-item switch is a simplification that’s enough to drive the demo — but the item shapes are the same as a real provider, which is the point of the exercise. A tool_result item (last.ToolResult != nil) means llm.core has just returned the result of a previous tool call; chatbox parses the Output field and produces the final answer. The reply helper wraps the assistant’s text into a single-item slice and pairs it with a stopReason.

    if last.Message != nil && last.Message.Role == "user" {
        matches := mathPattern.FindStringSubmatch(last.Message.Content)
        if matches != nil {
            x, _ := strconv.Atoi(matches[1])
            opStr := strings.TrimSpace(strings.ToLower(matches[2]))
            y, _ := strconv.Atoi(matches[3])
            op, ok := opMap[opStr]
            if !ok {
                op = opStr
            }

            // Check if we have a calculator tool available
            var calcTool *llmapi.Tool
            for i := range tools {
                if strings.Contains(strings.ToLower(tools[i].Name), "arithmetic") ||
                    strings.Contains(strings.ToLower(tools[i].Name), "calculator") {
                    calcTool = &tools[i]
                    break
                }
            }

            if calcTool != nil {
                args, _ := json.Marshal(map[string]any{"x": x, "op": op, "y": y})
                return []llmapi.Item{
                    llmapi.NewMessage("assistant", fmt.Sprintf("I'll use the calculator to compute %d %s %d.", x, op, y)).AsItem(),
                    llmapi.ToolCall{ID: "chatbox_1", Name: calcTool.Name, Arguments: args}.AsItem(),
                }, llmapi.StopReasonToolUse, usage, nil
            }

A user-role message that is math-shaped is the headline case. Chatbox scans tools for an entry whose name contains "arithmetic" or "calculator". If it finds one, it returns two items — an assistant message plus a tool_call item carrying JSON arguments matching the Arithmetic endpoint’s signature — with stopReason tool_use. llm.core dispatches that call over the bus and re-invokes Turn with a tool_result item appended to the conversation, completing the loop.

            // No calculator tool - do the math ourselves
            var answer int
            switch op {
            case "+":
                answer = x + y
            case "-":
                answer = x - y
            case "*":
                answer = x * y
            case "/":
                if y != 0 {
                    answer = x / y
                } else {
                    return reply("Cannot divide by zero.", llmapi.StopReasonEndTurn)
                }
            }
            return reply(fmt.Sprintf("%d %s %d = %d", x, op, y, answer), llmapi.StopReasonEndTurn)
        }

        // No pattern matched
        return reply("I don't understand. I'm the Chatbox demo and I can only answer math questions like \"What is 6 times 7?\"", llmapi.StopReasonEndTurn)
    }

    return reply("I don't understand that message.", llmapi.StopReasonEndTurn)
}

If no calculator tool is in scope, chatbox falls back to computing the answer itself — useful when calling Turn directly in tests without setting up the calculator service. Unrecognized questions get a canned reminder of what the demo does.

The architectural rule: provider microservices are leaves in the call graph. llm.core is the orchestrator that drives the tool-calling loop; a provider’s job is to produce a single turn’s response (final content or tool calls) and return. A provider that called llm.core’s Chat from within Turn would be inverting that relationship — and if it routed back to itself, it would loop outright.

Demo

Demo is the user-facing side of the example. On GET, it serves the chat UI template; on POST, it forwards the user’s message into llm.core and returns the resulting conversation as JSON.

func (svc *Service) Demo(w http.ResponseWriter, r *http.Request) (err error) {
    if r.Method == "GET" {
        err = svc.WriteResTemplate(w, "demo.html", nil)
        return errors.Trace(err)
    }

    // POST: process the chat message via the LLM service
    if err = r.ParseForm(); err != nil {
        return errors.Trace(err)
    }
    userMessage := r.FormValue("message")
    if userMessage == "" {
        w.WriteHeader(http.StatusBadRequest)
        w.Write([]byte("Missing message"))
        return nil
    }
    provider := r.FormValue("provider")
    if provider == "" {
        provider = Hostname
    }
    model := r.FormValue("model")
    if model == "" {
        model = "chatbox-default"
    }

    // Call the LLM service's Chat endpoint with the calculator as a tool.
    items := []llmapi.Item{llmapi.NewMessage("user", userMessage).AsItem()}
    tools := []string{calculatorapi.Arithmetic.URL()}
    result, _, _, err := llmapi.NewClient(svc).Chat(r.Context(), provider, model, items, tools, nil)
    if err != nil {
        w.WriteHeader(http.StatusInternalServerError)
        w.Write([]byte(err.Error()))
        return nil
    }

    // Return the conversation as JSON
    w.Header().Set("Content-Type", "application/json")
    json.NewEncoder(w).Encode(result)
    return nil
}

Two details are worth dwelling on:

  • The provider form field defaults to Hostname (the chatbox’s own chatbox.example). That is the round trip the demo is designed to show: chatbox’s Demo calls llm.core, which calls chatbox’s Turn, which emits a tool call for calculator.example/arithmetic, which llm.core dispatches over the bus, then feeds the result back to Turn for the final answer. Every hop is a normal Microbus call with the actor JWT propagating end-to-end.
  • The tools list is a []string of canonical Microbus endpoint URLs — produced by calculatorapi.Arithmetic.URL(). llm.core resolves each URL to a JSON Schema by fetching the host’s :888/openapi.json. There is no separate tool registration step on the calculator side; any functional, web, or workflow endpoint on the bus is tool-eligible as long as the actor is authorized to invoke it.

See Also

  • LLM tooling — the conceptual model: OpenAPI as the tool description, internal vs external delivery paths, authorization.
  • LLM integration — the Chat and ChatLoop API reference with provider switching and mocking.
  • coreservices/llm — the orchestrator’s package reference and configuration.
  • Calculator example — the tool that chatbox routes math questions to.
  • Credit flow example — the workflow-engine counterpart to this LLM-engine demo.
  • Bank support example — tool-calling taken further: actor-scoped tools gated by authentication, driven by a real provider, producing typed structured output.