Octwin · Conversational engine
No menu. No press 1 for English. No extra AI call to work it out. The language is read from the customer's own words, on the message that arrives — and it can only ever be a language your pack actually speaks.
What the customer sees
Below is one shipped pack — Binaa Building Supplies, a Riyadh building-materials wholesaler — answering three customers who each said good morning in their own language. Nobody chose a language. Nobody was asked.
Look at what moved, not just the words. The agent's own sentence is translated — but so is the menu underneath it, both lines of every row, and the direction of the whole bubble. That is the platform drawing the same list three ways, not the model improvising a translation.
Worth knowing what this pack's default is: Binaa is configured
default_settings.locale: ar. The English pane is therefore not
the pack falling back — it is detection overriding the pack's own
default, on the first message, before the customer had done anything but
say hello.
How it decides
Every inbound message runs through the same two questions: what language is this?, and then is that a language this pack can actually serve? The second question is the one that keeps the feature honest.
The detector is a local statistical model — not a call to a large language model, and not an extra round-trip. It returns a language and a real probability, and anything at or above the gate counts as confident. It is then clamped to your pack's roster: a language you ship no words for is discarded rather than stored, because storing it would point every translated string at copy that does not exist.
The detector, on four real strings
Those last two rows are the honest part of this page. A bare greeting is weak evidence in any script. مرحبا is five letters and the model called it Persian; had this pack shipped Persian, it would have answered in Persian. A greeting is a coin-flip; a sentence is not. Everything in the next section exists because of that.
One more rule, easy to miss and load-bearing: a button tap is never evidence. Button and list titles are text your bot wrote, in the language it already chose — detecting on one would make the current language confirm itself forever. Only the customer's own prose is ever read.
When it changes its mind
A wrong switch is visible to the customer; a decline is not. So the platform is quick to answer in a new language and deliberately slow to record one.
| What is stored on the contact | What happens on a confident detection |
|---|---|
| nothing yet | Adopt it at once — there is nothing to contradict. This is every pane above. |
| the same language | Confirmed. Any half-formed candidate is cleared. |
| a different language, seen once before |
This is the second sighting. Adopt it — the customer really has changed language. |
| a different language, seen for the first time |
Answer this turn in the new language, but do not store it yet. One mis-read costs a turn's wording and nothing permanent. |
| pinned by a person | Detection is skipped entirely. See below. |
That fourth row is the whole design in one line. An English product name inside an Arabic sentence, an order reference like ACC-2291, a one-word ok — none of them can flip a customer who has been speaking Arabic for a week.
“Twice” means twice among confident readings, not twice in a row. A tap, a photo, or an unreadable reply between two Arabic sentences does not break the streak, because those turns never reach the detector at all. Most readers assume the opposite.
The customer stays in control
/lang
is built into the platform. It works on every pack, with no YAML and no
redeploy — including on a pack somebody else published.
On the record
Three values, and they answer the only question an operator ever asks about a conversation in the wrong language: who decided this?
/lang, or a flow that had a real reason to. Detection will not touch it again until someone says /lang auto.
“The detector never ran” and “the detector declined” look identical from
outside — and they have opposite fixes. One is /lang auto;
the other is a message with more words in it. So the timeline records which
of the two happened, rather than leaving a blank.
For pack authors
If your pack speaks one language, there is nothing to do — say nothing and everything behaves as it always has. If it speaks more than one, this is the line worth adding today.
supported_languages: is optional, and it is a claim the platform
checks in both directions against the locale files you actually ship.
Declare a language you have no file for, or ship a file for a language you did
not declare, and the deploy is refused.
# manifest.yaml — binaa-supply supported_channels: [whatsapp, web] supported_languages: [ar, en, ur] default_settings: locale: ar # the fallback, not the answer — detection overrides it
Why declare it at all when the files are right there on disk? Because intent is not on disk. A pack that shipped Arabic and English and then lost its last English file drops to one language and looks, byte for byte, exactly like a pack that was always Arabic. The declaration is what makes the difference visible.
A pack shipping two or more languages must ship the same copy in each — the same keys, interpolating the same placeholders. A missing translation now fails the deploy.
This is a gate because the runtime is forgiving, and forgiveness here is
indistinguishable from correctness. A key missing from
locale.ur.yaml does not error — it silently falls through to
another language your pack ships. So an Urdu customer reads one Arabic line in
the middle of an Urdu card, and nothing anywhere reports a problem. A pack
shipping a single language is never nagged about any of this.
set_language exists for the cases that genuinely need it: a
compliance rule, or a CRM record that already knows. It is the exception, not
the pattern — a language menu asks the customer what they already answered
by typing.
What makes it safe is that it cannot store a language your pack does not ship. A refusal is a port you can handle, not a silent write — so you can tell the customer what you do speak:
- do: set_language args: language: '$tap.lang' # a tag you ship, or "auto" to un-pin outputs: ok: - do: … unsupported: # carries { requested, supported } — say what you DO speak - do: …
The previous way of doing this validated nothing: any string became
explicit, detection was then blocked forever, every translated
string missed, and the customer read your default while the record claimed
something else. Silent, permanent, and untested — which is why the old path
is gone.
If you do one thing
Add supported_languages: to your manifest and deploy. The check
runs in both directions, so it is the fastest way to find a language you
meant to ship and didn't — the failure that otherwise reaches a
customer as a blank line, or a sentence in a language they don't read.
Then look at the contacts on a live project. A wall of unset on a multilingual pack means your customers are sending greetings, not sentences — and greetings, as the panes above show, are the one input this feature is honestly bad at.