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 durable Support workflow, and its RunSupport task runs llm.core’s ChatLoop as a subgraph, which the Foreman dispatches. This is why banksupport depends on the Foreman rather than on llm.core directly.
  • bearer.token.core — mints the customer’s bearer token at login, mirroring the login example.
  • A real LLM provider key. The agent uses llmapi.ProviderAny and llmapi.ModelDefault and 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(( )):::term
func (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 Chat and ChatLoop API and the []Item conversation 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 ChatLoop subgraph and backs the long-poll.
  • coreservices/llm — the orchestrator that runs the tool-calling loop.