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.
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.
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:
| Event | Text | Account proposal |
|---|---|---|
| User | Show my current account transactions | Not created yet |
| Proposal tool | I can read your current account. Shall I use that account? | Pending |
| User | Help me contact support instead | Previous account question interrupted |
| Controlled assistant event | Would you like the support contact details? | No fresh account question |
| User | Yes | Answers 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:
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)- Refuse to reuse a user event from before or at proposal creation.
- Expire the proposal after another user turn, even when the latest reply says yes and its timestamp is later.
- 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.
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 words | Proposed account | Demo action | Reads before confirmation | Final outcome |
|---|---|---|---|---|
| Show my current account transactions | Current | Confirm | None | Current transactions |
| Show my savings account transactions | Savings | Confirm | None | Savings transactions |
| Show my account transactions | Current | Cancel | None | No 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.
| Component | Pinned version or identity | Role |
|---|---|---|
| Rasa Pro | 3.21.0.dev5 | Conversation and ordinary Mantle tools |
| TypeSafe SDK | 0.7.4 | Typed request and response transport |
| Laya runtime | 0.4.3 | Local decision inference |
| English weights | convaiinnovations/laya@7b928d828b7b0e022f929d9bd2e44165aa270148 | One 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:
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"}]}- Refuse a proposal created against an earlier account-store revision.
- Refuse an account absent from the offered snapshot.
- 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.
| Question | What 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.