banksupport
The banksupport.example microservice is the suite’s authentication + structured-output showcase. A signed-in bank customer asks a natural-language question — “What’s my balance?”, “How much did I spend on groceries last month?” — and an LLM agent answers by calling the customer’s own Balance and Transactions endpoints as tools, returning a structured verdict: advice text, a card-block flag, and a 0–10 risk score.
Two teaching points sit on top of the agent shape that weather already establishes. First, the tools are actor-scoped: each reads the signed-in customer’s identity from the verified token, never from a tool argument, so the model can only ever read the caller’s own data. Second, the agent produces typed structured output — not free text — by instructing the model to end with a strict JSON object and parsing it.
Depends On
foreman.core— the workflow engine. The agent is a durableSupportworkflow, and itsRunSupporttask runsllm.core’sChatLoopas a subgraph, which the Foreman dispatches. This is why banksupport depends on the Foreman rather than onllm.coredirectly.bearer.token.core— mints the customer’s bearer token at login, mirroring the login example.- A real LLM provider key. The agent uses
llmapi.ProviderAnyandllmapi.ModelDefaultand needs a genuine provider to do tool-calling and produce the structured verdict; the simulated chatbox provider does not tool-call. The login and data flows work without a key.
Try It
With the examples app running and a provider key configured, sign in at:
http://localhost:8080/banksupport.example/login
There are two demo customers: alice, who runs a healthy surplus, and bob, who spends beyond his income and trends into overdraft. Any username works only if it matches one of those two; there is no password. On success the service mints a customer bearer token, sets it as the Authorization cookie, and redirects to the console at /demo, where you can ask banking questions in natural language. Ask bob “Am I in trouble?” to exercise the agent’s risk-and-block path.
The demo accounts and their transaction histories are an in-memory store (populate.go), built once at startup and read-only afterward — fixture data, not real persistence. Each account is seeded with several months of synthesized history: fixed obligations (salary, rent, utilities) are constant, while discretionary streams (groceries, dining, transport) are jittered ±20% so the ledger reads like a real statement.
That seeding is deterministic, hashed from the username, and the determinism is load-bearing rather than merely reproducible: every replica of banksupport builds an identical store, so a customer’s unicast requests — which round-robin across replicas — always see the same balance and history. True randomness would let replicas disagree and give the same customer different answers on successive calls. It also keeps bob reliably overdrawn, which is what exercises the risk path.
Confused-Deputy Protection
Balance and Transactions take no customer-id argument. Each reads the username from the verified actor claim on the request and scopes its lookup to that account:
func (svc *Service) accountFor(ctx context.Context) (*demoAccount, error) {
var actor banksupportapi.Actor
_, err := frame.Of(ctx).ParseActor(&actor)
if err != nil {
return nil, errors.Trace(err)
}
if actor.Subject == "" {
return nil, errors.New("no customer identity in token", http.StatusForbidden)
}
acct := svc.accounts[actor.Subject]
if acct == nil {
return nil, errors.New("no account for customer '%s'", actor.Subject, http.StatusNotFound)
}
return acct, nil
}This is the whole point of exposing a data-backed endpoint as an LLM tool: because the identity comes from the verified claim and never from tool input, the model — or any caller — can only ever read the signed-in customer’s own data. The endpoints carry requiredClaims: roles.customer, which additionally guarantees there is an authenticated customer and makes the tools disappear from the OpenAPI tool list for anyone who is not one.
func (svc *Service) Balance(ctx context.Context) (balanceCents int, holder string, err error) {
acct, err := svc.accountFor(ctx)
if err != nil {
return 0, "", errors.Trace(err)
}
return acct.balanceCents, acct.holder, nil
}The Agent Is a Durable Workflow
Support is a workflow, not a synchronous llmapi.Chat call, and that is deliberate. A multi-turn tool-calling conversation — the model reasons, calls Balance / Transactions, reasons again — routinely outlasts a single request’s time budget. Chat runs the whole loop under one shrinking deadline, so a mid-loop provider call gets whatever budget is left and times out. Support’s one task, RunSupport, instead runs ChatLoop as a subgraph, so every turn is its own durable Foreman step with a fresh per-step budget — the framework’s prescribed way to use an LLM inside a workflow.
Support is a single-node graph. Its source (SUPPORT.mmd):
%%{init: {'themeVariables': {'lineColor': '#32a7c1', 'edgeLabelBackground': '#e5f4f3', 'textColor': '#434343', 'titleColor': '#32a7c1', 'clusterTextColor': '#32a7c1'}}}%%
graph LR
classDef task fill:#32a7c1,color:#f4f2ef,stroke:#32a7c1
classDef term fill:#e5f4f3,color:#434343,stroke:#32a7c1
_title{{"support"}}:::term --> _start
_start(( )):::term --> t0
t0["RunSupport"]:::task
t0 --> _end(( )):::termfunc (svc *Service) Support(ctx context.Context) (graph *workflow.Graph, err error) {
graph = workflow.NewGraph("Support")
graph.SetEndpoint("RunSupport", banksupportapi.RunSupport.URL())
graph.AddTransition("RunSupport", workflow.END)
return graph, nil
}Code Walkthrough
Source: exampleservices/banksupport/service.go.
RunSupport — the Agent Task and Structured Output
RunSupport builds the conversation, names Balance and Transactions as tools, and runs ChatLoop as a subgraph. The LLM APIs have no native response-schema option, so structured output is achieved by instructing the model to end with a strict JSON object and parsing it:
func (svc *Service) RunSupport(ctx context.Context, flow *workflow.Flow, query string) (advice string, blockCard bool, risk int, err error) {
// ... system prompt: use the balance/transactions tools, then reply with ONLY a JSON object
// of the form {"advice": ..., "blockCard": <bool>, "risk": <0-10>} ...
items := []llmapi.Item{
llmapi.NewMessage("system", systemPrompt).AsItem(),
llmapi.NewMessage("user", query).AsItem(),
}
tools := []string{
banksupportapi.Balance.URL(),
banksupportapi.Transactions.URL(),
}
result, _, yield, err := llmapi.NewSubgraph(flow).ChatLoop(ctx, llmapi.ProviderAny, llmapi.ModelDefault, items, tools, nil)
if yield {
return "", false, 0, nil
}
if err != nil {
return "", false, 0, errors.Trace(err)
}
answer := llmapi.LastAssistantMessage(result)
out := parseVerdict(answer)
return out.Advice, out.BlockCard, out.Risk, nil
}parseVerdict is deliberately tolerant — it strips code fences and surrounding prose, extracts the outermost {...}, clamps the risk to 0–10, and falls back to treating the whole reply as advice if no valid object is found. Prompt-and-parse is the portable way to get typed output over the bus today.
Demo and DemoStatus — Launch, Then Long-Poll
The Demo POST is only the trigger: it foreman.Creates the Support workflow and returns immediately with the flow key — well within the ingress budget — rather than blocking on the agent’s tool-calling loop. As with flightbooking, the demo owns the foremanapi dependency because it is the triggering UI, not a workflow provider.
The browser then learns the result by polling DemoStatus, which is a long-poll, not a fixed-interval poll:
func (svc *Service) DemoStatus(ctx context.Context, flowKey string) (status string, advice string, blockCard bool, risk int, errorMsg string, err error) {
// ...
var out banksupportapi.SupportOut
outcome, err := foremanapi.NewClient(svc).PollAndParse(ctx, flowKey, &out)
if err != nil {
return "", "", false, 0, "", errors.Trace(err)
}
if !outcome.Stopped() {
return workflow.StatusRunning, "", false, 0, "", nil
}
if outcome.Status == workflow.StatusCompleted {
return outcome.Status, out.Advice, out.BlockCard, out.Risk, "", nil
}
return outcome.Status, "", false, 0, outcome.Error, nil
}It delegates to the Foreman’s Poll (via PollAndParse): Poll waits up to the request’s time budget for the flow to stop, but unlike Await it does not treat a timeout as an error — a still-running flow comes back as a non-error running outcome, so the page can re-fetch immediately with no client-side delay. A completed flow’s verdict is delivered the instant it finishes; an in-flight one holds the connection efficiently instead of hammering. The launch-then-poll split is needed precisely because the agent’s tool-calling loop routinely outlasts a single request’s budget — a workflow whose tasks finish within one request could instead Await synchronously in the web handler, as the creditflow demo does, but an LLM flow cannot.
Login and the 401 Redirect
Login validates a hardcoded demo customer, mints a bearer token via bearertokenapi.Mint, sets it as the Authorization cookie, and redirects to /demo. The app wires a middleware.ErrorPageRedirect scoped to the /banksupport.example/ path prefix, so an unauthenticated hit on a gated page bounces to /login rather than returning a bare 401 — the same ingress-middleware pattern the login example introduces.
See Also
- LLM tooling — how a plain endpoint becomes a tool, and the authorization boundary that makes actor-scoping safe.
- LLM integration — the
ChatandChatLoopAPI and the[]Itemconversation model. - Login example — the JWT actor-claims and ingress-redirect pattern banksupport builds on.
- Weather example — the canonical single-node agent, without authentication or structured output.
- Flight booking example — the other durable-agent example, showcasing human-in-the-loop Interrupt/Resume.
- Foreman core service — the engine that dispatches the
ChatLoopsubgraph and backs the long-poll. coreservices/llm— the orchestrator that runs the tool-calling loop.