Skip to content
RasaGet a free licence
Guides for AI teams

Guide · AI product engineer

Choose a bank account with a local model and Rasa

Run a local model to suggest an account, and have Rasa ask the user to confirm it before showing transactions.

by Rod Rivera

About 5 minutes

Key takeaways (3)
  • Run the pinned local Laya model inside an ordinary Mantle tool.
  • Ask the user to confirm the suggested account before showing transactions.
  • Expire the account request when the conversation changes topic.

A customer asks to see transactions from their current account. This tutorial uses a local Laya model to suggest the account and Rasa to ask for confirmation before reading it. The account permissions remain under application control.

The confirmation must answer the account question. In our test, the conversation changes to support contact details before the user says “Yes”. The first tool accepts that reply and reads an account. The revised tool expires the earlier proposal instead.

The controlled replay returns fictional transactions with the earlier tool’s timestamp check. The revised tool expires the proposal instead. Both runs use the same user events, SDK choice and account permissions.

From the companion’s Mantle directory, run make demo-confirmation after the locked install below. Its literal output is:

Changed-topic fixture: account request -> account question -> support request -> support question -> Yes
previous_timestamp_only: transactions; account_reads=["A-CURRENT"]
next_user_turn: proposal_expired; account_reads=[]
Positive fixture: account request -> account question -> Yes
next_user_turn_positive: transactions; account_reads=["A-CURRENT"]

This is a controlled fixture using actual Rasa event objects and decorated tools, scripted SDK replies and fictional records. The previous tool code is reconstructed in memory and fingerprinted against its public revision. The replay leaves source files and the Rasa engine unchanged. It reproduces a defect in our tutorial integration and checks its fix.

Jacaranda Bank is fictional. This exercise requires named-account confirmation before a read; that policy is a teaching assumption.

You will build the revised version with an open-source local Laya model behind ordinary Mantle tools. Hosted Jev remains an optional backend. Rasa needs its issued licence and configured writer-provider key; the local decision model needs no provider key. Mantle’s existing orchestrator still chooses tool calls and writes replies.

The same changed-topic reply, before and after

Avoid: Previous timestamp-only tool

previous_timestamp_only: transactions; account_reads=["A-CURRENT"]

The reply came later, so the previous tool reads the account despite the changed topic.

Prefer: Next-user-turn check

next_user_turn: proposal_expired; account_reads=[]

Another user turn intervened, so the revised tool expires the proposal without reading.

Check which question the reply follows

These are the changed-topic fixture’s events, in order:

EventTextAccount proposal
UserShow my current account transactionsNot created yet
Proposal toolI can read your current account. Shall I use that account?Pending
UserHelp me contact support insteadPrevious account question interrupted
Controlled assistant eventWould you like the support contact details?No fresh account question
UserYesAnswers after the support question

The replay compares the earlier timestamp-only tool with the next-user-turn check. Its control binds the same events to both read logs. It also fingerprints the earlier tool against its public source. The positive case confirms that a yes on the next user turn still reads.

The timestamp comparison remains useful: it refuses a reply that predates the proposal. The new check also counts user turns since that proposal. More than one means the question has lost its immediate reply window. It removes the local proposal and requests cancellation from the worker. The user must start a fresh one.

The read tool takes only injected context. The model supplies no account, owner or confirmation argument. Here is the complete function:

A later reply must also be the next user turn
async def read_confirmed_transactions(context: ToolContext = None) -> ToolResult:
    key = sender()
    proposal = PROPOSALS.get(key)
    user = last_user(context)
    if proposal is None:
        return ToolResult(llm_response={"status": "needs_account_proposal"})
    token, owner, created = proposal
    if user is None or user.timestamp <= created:
        return ToolResult(llm_response={"status": "needs_confirmation"})
    later_users = [event for event in context.events
                   if isinstance(event, UserUttered) and event.timestamp > created]
    if len(later_users) != 1:
        del PROPOSALS[key]
        await asyncio.to_thread(request_worker, "/cancel",
                                {"owner": owner, "conversation": key, "token": token})
        return ToolResult(llm_response={"status": "proposal_expired"})
    text = (user.text or "").strip().lower().strip(".!?")
    if text in {"no", "no thanks", "cancel"}:
        path = "/cancel"
    elif text in {"yes", "yes please", "confirm"}:
        path = "/confirm"
    else:
        return ToolResult(llm_response={"status": "needs_confirmation"})
    del PROPOSALS[key]
    result = await asyncio.to_thread(request_worker, path,
                                    {"owner": owner, "conversation": key, "token": token})
    return ToolResult(llm_response=result)
  1. Refuse to reuse a user event from before or at proposal creation.
  2. Expire the proposal after another user turn, even when the latest reply says yes and its timestamp is later.
  3. Consume the confirmed local proposal before the read request; a transport failure requires a fresh proposal.

If that cancellation request fails, the tool still reports proposal_expired. Its local token is gone, but the worker can retain the token until replacement or restart. The cancellation controls check both cases, including refusal of a later worker confirmation after a successful cancellation. A real service needs acknowledged, durable cancellation.

The complete tool file also binds each saved token to the actual Rasa sender. The worker checks its owner and conversation before the account store checks current eligibility.

User asks for transactions Local Laya proposes an account Rasa asks about the named account Next user reply confirms or cancels Python checks proposal and ownership Read fictional rows, or refuse
FigureFrom proposed account to checked read

This small rule defines a reply window, not delivery acknowledgement. A production service also needs question-delivery handling, time-based expiry and durable atomic proposal state. Test those contracts with the channel you use.

Start with what the model actually did

The local demo runs the real pinned Laya model, followed by application confirmation or cancellation. These are the saved outcomes from three independent examples, with model identity preserved:

User wordsProposed accountDemo actionReads before confirmationFinal outcome
Show my current account transactionsCurrentConfirmNoneCurrent transactions
Show my savings account transactionsSavingsConfirmNoneSavings transactions
Show my account transactionsCurrentCancelNoneNo transactions

This small exercise checks the runnable integration. Assess accuracy, latency and confidence calibration on a separately labelled evaluation for your own task. The demo supplies the confirm/cancel actions in code; the Rasa conversation below uses actual user messages instead.

Laya supplies open-source code and open weights under Apache-2.0. Our adapter loads one pinned English checkpoint and asks one Choice question. It does not implement a general decision API. Hosted Jev is an optional backend later in the walkthrough.

Get the matching source

Use Python 3.12 and uv. Fetch the exact tutorial revision into a new checkout; fetching the commit also works when it is not on the default branch:

git clone https://github.com/RasaHQ/rasa-community-resources.git jacaranda-local-decision
cd jacaranda-local-decision
git fetch --depth=1 origin 1b0b0ff432b11faecba84fea9cb31d6b5d4291be
git checkout --detach 1b0b0ff432b11faecba84fea9cb31d6b5d4291be
cd tutorials/jev-with-rasa/mantle
uv sync --prerelease=allow --locked
make demo
make test

The companion README and locks contain the complete setup. make demo uses scripted HTTP replies through the real TypeSafe SDK. These controls check wire decoding and application checks, without invoking Jev or Laya. The archive-load test skips until licensed training creates an archive.

After training, make test passed 53 controls. Its recorded output excerpt is:

Ran 53 tests in 11.675s

OK

The dated tutorial receipt and portable native log bind the command and source revision to the original and public log hashes. They cover malformed responses, stale accounts, separate conversations, confirmation, cancellation, client shutdown, tampered model files and archive loading. See the verification record and tests. Keep scripted controls separate from actual model examples when assessing your own assistant.

Run one local model, with one cache

Keep Laya’s dependencies in a separate environment. In the first terminal, from the checkout root:

cd tutorials/jev-with-rasa/local-laya
uv sync --locked
.venv/bin/python serve_local.py --cache-dir ./model-cache

The first start downloads about 846 MB of logical file data. The artifact manifest pins the Hugging Face revision and verifies five SHA-256 hashes before loading. The model loads once, uses two CPU threads, and listens on loopback port 5031. Its adapter accepts this example’s account Choice question with up to twenty candidates.

ComponentPinned version or identityRole
Rasa Pro3.21.0.dev5Conversation and ordinary Mantle tools
TypeSafe SDK0.7.4Typed request and response transport
Laya runtime0.4.3Local decision inference
English weightsconvaiinnovations/laya@7b928d828b7b0e022f929d9bd2e44165aa270148One pinned checkpoint

The model setup links to the upstream code, model and runtime. Reuse one cache instead of downloading a copy for each worktree. Keep model files and environments out of source copies and backups.

In a second terminal, from the checkout root:

cd tutorials/jev-with-rasa/mantle
make demo-local
make worker-local

demo-local reproduces the three actual-model examples above. worker-local keeps the proposal worker on loopback port 5029. It uses the SDK against our local adapter; this mode reads no TypeSafe key and sends no hosted Jev request. Leave both terminals running for the conversation.

Let Rasa ask, then inspect the actual reply

In a third terminal, from the checkout root, enter the Mantle directory. Supply your issued Rasa licence and configured writer-provider key through the environment. The example variable file lists their names with empty values; the commands do not automatically load it. Follow the quickstart for licence setup.

cd tutorials/jev-with-rasa/mantle
make validate
make train
make run

The REST assistant listens on loopback port 5030. Send these messages in order:

curl -s http://127.0.0.1:5030/webhooks/rest/webhook \
  -H 'Content-Type: application/json' \
  -d '{"sender":"local-laya-live-a","message":"Use demo 111111"}'
curl -s http://127.0.0.1:5030/webhooks/rest/webhook \
  -H 'Content-Type: application/json' \
  -d '{"sender":"local-laya-live-a","message":"Show my current account transactions"}'
curl -s http://127.0.0.1:5030/webhooks/rest/webhook \
  -H 'Content-Type: application/json' \
  -d '{"sender":"local-laya-live-a","message":"Yes"}'

The licensed REST run on 11 October 2026 used the pinned local Laya checkpoint and the configured writer, gpt-5.5-2026-04-23. The proposal tool sent this fixed question through context.send; it was not writer-generated text. This is one recorded REST response item, formatted for inspection with its original field names and text:

{"recipient_id": "local-laya-live-a", "text": "I can read your current account. Shall I use that account?"}

The next actual user message was “Yes”.

After that reply, the assistant returned the fictional DEMO-101 transaction at Aster Reading for −£42.18. The tracker placed the proposal tool before the later user message, and the confirmed-read tool after it. This is one recorded text conversation, not a voice or production qualification. Replies can vary with the writer model; inspect tool events as well as the prose.

Make confirmation an application fact

The Mantle tools take no model-supplied account, owner or confirmation argument. The proposal tool reads the newest actual UserUttered event. It binds the proposal to that sender and sends the named question through context.send.

The read tool requires the next user event after the one that created the proposal. It accepts a small explicit yes/no vocabulary. A correction asks again; cancelling consumes the proposal without reading. Extend and test the vocabulary for each language you intend to support.

A later yes after another user turn expires the proposal. Start a fresh named proposal rather than reusing the earlier question.

Demo codes and REST sender labels do not authenticate customers. These unauthenticated development services contain only fictional records and stay on loopback. A real service needs authenticated channel identity and an authorised data service. Its proposal and binding state must survive restarts atomically; this example’s in-memory state resets when a process stops.

The proposal service checks the token, owner and conversation before consuming a proposal. Confirmation makes no new decision-model call. A replacement proposal invalidates the previous token; a consumed token cannot read twice.

That still leaves current eligibility. The read itself holds the account-store lock around these checks and the fictional result:

Check current state at the read
def read(self, snapshot, choice):
    with self.lock:
        if self.revision != snapshot.revision:
            return {"status": "stale_options"}
        if choice not in dict(snapshot.accounts):
            return {"status": "choice_not_offered"}
        owner, label, active = self.records[choice]
        if owner != snapshot.owner or not active:
            return {"status": "account_unavailable"}
        self.reads.append(choice)
        return {"status": "transactions", "account": choice, "label": label,
                "transactions": [{"reference": "DEMO-101", "merchant": "Aster Reading",
                                  "currency": "GBP", "amount": "-42.18"}]}
  1. Refuse a proposal created against an earlier account-store revision.
  2. Refuse an account absent from the offered snapshot.
  3. Check the current owner and active state before recording the read.

The tests retire an account after proposing it, then confirm: the read returns stale_options and records no account read. The store uses one global revision, so another demo’s account change can also invalidate the proposal. A real data service can use a narrower version with an atomic eligibility check.

Separate decoding from the choice you offered

The SDK can decode a Choice label without checking this request’s dynamic options. make demo deliberately returns another demo’s account in a well-shaped wire reply. The actual decoder accepts the label; our selector rejects it before reading. This fixture checks handling of an unexpected response, not hosted Jev behaviour.

Inspect the deliberately inconsistent scripted reply

The fixture offers demo A’s account keys but returns demo B’s key. Its selected label also disagrees with its probability distribution. The application rejects it.

{
  "model": "jev-1.13.0",
  "usage": { "input_tokens": null, "output_tokens": null },
  "answers": {
    "account": {
      "type": "choice",
      "choice": "B-CURRENT",
      "confidence": 1.0,
      "probabilities": {
        "A-CURRENT": 1.0,
        "A-SAVINGS": 0.0,
        "ask_customer": 0.0
      }
    }
  }
}

The selector checks the resolved model, offered keys and numerical distribution. Those checks handle unexpected replies. An offered choice can still be the wrong interpretation, which is why the conversation asks about the named proposal before reading.

Try hosted Jev later, without changing the conversation

Stop worker-local with Ctrl-C before switching backends. Supply TYPESAFE_API_KEY in that terminal’s environment, then run:

make worker-live

This optional mode sends paid requests to TypeSafe using jev-1.13.0. See the Choice reference, model reference and synchronous client. No hosted Jev qualification is included here; it needs a separate real-service run.

The worker retains one SDK client and closes it on shutdown. Its HTTP timeout is two seconds and retries are disabled. The worker is single-threaded: queued requests can wait longer than the HTTP call timeout. Plan a whole-request deadline and concurrency before deployment.

Assess the whole task

Each proposal makes one decision request; confirmation reuses the saved result. Mantle can make further writer and tool calls. Count the complete conversation when assessing cost, and keep missing provider usage as unknown.

QuestionWhat to record
Did the user get the intended account?Requested words, named proposal, correction or confirmation, actual read
Did permission remain current?Snapshot revision, read status and account-store reads
Did the answer preserve the facts?Account label, merchant, amount, currency and reference
What did the split cost?Decision and writer requests, token usage, retries, local runtime and memory
When was the answer useful?Confirmation question and substantive answer times, separately

Keep measured runs and billing evidence private. The public companion supplies the code, fictional fixtures and locks to reproduce this tutorial. It makes no comparative cost or speed claim.

Ctrl-C stops the three foreground services. Keep one model cache and the locks; retire completed environments rather than copying them into another worktree. The tools and memory tutorial explains how to plan the state scopes used by a larger Mantle assistant.