Skip to content

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.

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.

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.

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.

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.

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.

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.

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.

  • Intents are not actions. An NLPAction has its own execute; reaching an action is an explicit mapping via invokeAction, 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} and Turn 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.