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
Turncontract — the same contract that the realclaudellm,chatgptllm,geminillm, andlitellmprovider 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/demothat submits messages tollm.core’sChatendpoint, 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— exposesArithmeticas 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 validAPIKeyinconfig.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.coreinvokescalculator.example/arithmeticover 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-keyOnly 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
providerform field defaults toHostname(the chatbox’s ownchatbox.example). That is the round trip the demo is designed to show: chatbox’sDemocallsllm.core, which calls chatbox’sTurn, which emits a tool call forcalculator.example/arithmetic, whichllm.coredispatches over the bus, then feeds the result back toTurnfor the final answer. Every hop is a normal Microbus call with the actor JWT propagating end-to-end. - The tools list is a
[]stringof canonical Microbus endpoint URLs — produced bycalculatorapi.Arithmetic.URL().llm.coreresolves 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
ChatandChatLoopAPI 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.