Match spoken phrases
A provider’s actions are called by whatever you build on top — a script, an automation, an app, an agent. This guide is about one more way in: letting a provider declare the phrasings it answers to, so that “turn off the kitchen light” reaches it directly.
That matters when you are building something conversational. An assistant would normally hand the phrase to a model and let it choose an action and compose the arguments, which is flexible and costs a model call every time. Declaring the phrasing outright makes the common, always-worded-the-same requests free — and the reason it is only a few lines to add is that the catalogue of actions already exists, built for everything else you do with it.
Deliberately narrow. It handles requests that are unambiguous by construction; everything else falls through to the model as normal, and a provider that declares no phrasings loses nothing.
Declare a slot
Section titled “Declare a slot”A slot is a fillable position in a phrase — the room in “turn on the kitchen light”. It is an object rather than a name, and it knows both how to fetch its own values and when they have changed.
import { type SlotValue, slot } from '@grundlag/core';
const room = slot({ getValues: async ({ services }): Promise<SlotValue<Room>[]> => { const rooms = await provider.getRooms(); return rooms.map((entry) => ({ value: entry, text: entry.name, aliases: entry.aliases, })); },});value is your own object, not a string. A match hands it straight back, so nothing has to
resolve "the kitchen" into a room a second time.
text is the name a speaker would use, and aliases are the other ways they might say it.
Both are matched; which one hit is reported back as the matched text.
Values are never cached
Section titled “Values are never cached”getValues is called whenever the engine builds its corpus, and the engine holds the result
only until something says otherwise. When your underlying data changes, say so:
await provider.createRoom({ name: 'Study' });room.markUpdated();That queues a retrain. Updates are debounced and coalesced, so marking a dozen slots stale in the same tick costs one rebuild — a provider reconnecting and refreshing everything is the expected case, not an abusive one. The previously trained model keeps answering while the new one builds.
For a bespoke source, extend Slot instead of calling slot(); the base class supplies the
id and the updated signal, and you supply getValues.
Declare the phrasings
Section titled “Declare the phrasings”An utterance is a tagged template with slots interpolated into it. Write one per phrasing — several per intent is normal and improves matching.
import { defineNLPAction, utterance } from '@grundlag/core';
const lightsOn = defineNLPAction({ name: 'Turn lights on', utterances: [ utterance`Turn on the ${room} light`, utterance`${room} lights on`, utterance`Switch on the lights in the ${room}`, ], execute: async ({ entities, services }) => { const rooms = entities.get(room); await provider.turnOn(rooms.map((entry) => entry.id)); },});entities.get(room) is a Room[] — the slot object carries the type of its own values, so
passing the slot is what makes the lookup typed. No cast, and no name to keep in sync. Every
lookup answers an array, since one phrase can fill a slot more than once; an unfilled slot
answers [].
There is no id to choose. defineNLPAction generates one, which is what keeps two providers
that both think of something as “turn lights on” from colliding — nothing owns a namespace,
and the name is purely for display. Ids are regenerated on every start, so anything that
outlives the process should key on name.
Register them on the instance during setup, alongside actions and entities:
const instance = new ProviderInstance('home-assistant');instance.nlpActions.register(lightsOn, lightsOff);Because slots are created per instance, two instances of the same provider type train on their own values — the summer house’s rooms do not leak into the flat’s.
Reaching an existing action
Section titled “Reaching an existing action”An intent’s execute is its own code, not an action in disguise. But when an action really is
the right target, invokeAction builds the execute for you:
import { defineNLPAction, invokeAction, utterance } from '@grundlag/core';
const lightsOn = defineNLPAction({ name: 'Turn lights on', utterances: [utterance`Turn on the ${room} light`], execute: invokeAction(turnOnLight, ({ entities }) => ({ rooms: entities.get(room).map((entry) => entry.id), })),});The mapping stays at the call site rather than living on the action, because it is per-intent:
the same action can be reached by several intents that fill it differently. Its result is
validated against the action’s own input schema, so a mapping that drifts fails loudly with
an ActionInputValidationError naming the field rather than reaching execute malformed.
The action’s return value is discarded — an intent has no output channel yet. Write execute
by hand when you need it.
Optional slots, and why there aren’t any
Section titled “Optional slots, and why there aren’t any”A slot can only be left unfilled if the words around it still form a sentence, and usually they don’t. Rather than a slot that may or may not be there, write the second phrasing:
utterances: [utterance`Turn on the ${room} light`, utterance`Turn on the lights`],The two are genuinely different phrases, and training on both matches better than trying to express one as a hole in the other.
Repeating a slot
Section titled “Repeating a slot”The same slot may appear more than once in a phrase, and a single phrase may fill one slot several times over — “the kitchen and the hallway lights”. Matches are always reported as an array per slot for that reason.
Classify, then decide, then execute
Section titled “Classify, then decide, then execute”Recognising a phrase and acting on it are two calls, so that whether a match is good enough is a decision you make rather than one the host makes for you:
const { match } = await classify('turn off the kitchen light');
if (match && match.score >= 0.95) { await execute({ intent: match.intent });}score sits on the match; intent is the executable part, handed back untouched. Confidence
is weighed once, at that boundary, which is why execute neither wants nor accepts a score.
Over HTTP these are POST /api/nlp/classify and
POST /api/nlp/execute.
Treat the score as corpus-relative
Section titled “Treat the score as corpus-relative”score is the classifier’s confidence, and it is not calibrated — the same number means
different things for different corpora. A host with two slot-bearing intents scores unrelated
input around 0.5; adding one short, slot-free intent pulls that up past 0.9, because a
phrase matching nothing still has to land somewhere and lands on whichever intent the network
leans toward.
So do not carry a threshold between hosts, and do not assume the 0.8 default is safe for
yours. Try phrases you would expect to be rejected, see where they land, and set the floor
above them. Where a wrong action is expensive, confirm with the user instead of raising the
number.
Limits worth knowing
Section titled “Limits worth knowing”- Intents are not actions. An
NLPActionhas its ownexecute; reaching an action is an explicit mapping viainvokeAction, not something either type does for the other. Extracted spans and schema-validated arguments are different enough that folding one into the other costs more than it saves. - Phrasings must differ in their words, not just their slots. The classifier sees only an
utterance’s literal words — which slot sits in a gap contributes nothing to recognising the
intent.
Turn on the ${room}andTurn on the ${device}are the same sentence to it, so neither wins and the request falls through to the agent. This applies between two of your own intents as readily as between two providers, and it is worth knowing that two instances of the same provider type collide with each other by default. - English only for now — the engine trains one locale, defaulting to
en.