Build an Intent-Driven NPC¶
Use a small labelled knowledge base and a
ClassifyIntent →
IntentToInstruction →
Generate pipeline to give an NPC
distinct, tone-appropriate replies for recognised player intents (insult,
compliment, joke, …) while falling back gracefully to a general response
for anything unclassified.
No labelled examples to hand?
ClassifyIntentLLM classifies
from a one-sentence description per intent instead of a labelled corpus,
and needs no embedding model. Same NPC, no knowledge base:
Classify intent without a knowledge base.
Prerequisites
- A connected session — see Connect and Manage a Session. The session does not choose an inference engine; each model runs on the engine its catalog entry names.
- A language model and an embedding model available
(e.g.
"Llama 3.2 3B Instruct (Q4_K_M)"and"All-MiniLM-L6-v2 (Q4_K_M)"). Add an embedding model tomodels.jsonwithmodel_type: "embedding"if you do not have one yet.
Overview¶
flowchart LR
classify["ClassifyIntent\nclassify"]
pick["IntentToInstruction\npick"]
compose["Transform\ncompose"]
respond["Generate\nrespond"]
classify -- "found" --> pick
classify -- "not_found" --> compose
pick -- "default" --> compose
compose -- "default" --> respond
respond -- "default" --> END
The ClassifyIntent node embeds the user's message, searches a small KB
of labelled example phrases (5–10 per intent is enough), and attaches an
IntentionComponent on "found". IntentToInstruction looks up that
label in a Map string storage to retrieve a tone directive, attaches it
as an instruction slot.
A Transform node then folds that directive above the user message via
Mustache into its own slot, and Generate consumes it via input — this
is the pattern for fully replacing what the model sees (the old
in_place_of_user placement). If the intent is unknown ("not_found"),
Transform still runs (with an empty {{#instructions}} block), so
Generate always reads from the same compose slot — the NPC answers
naturally without any override.
Step 1 — prepare the intent knowledge base¶
Create a guard_intents/ folder under the server's storage root and put
two files in it. Relative storage paths in this how-to, such as
guard_intents/guard_intents.json, resolve against that root.
guard_intents.kb.json — labelled example phrases (5 per intent):
[
{ "id": "insult_1", "text": "You are an absolute idiot, move aside.", "metadata": { "intent": "insult" } },
{ "id": "insult_2", "text": "Out of my way, you worthless fool.", "metadata": { "intent": "insult" } },
{ "id": "insult_3", "text": "What a pathetic excuse for a guard.", "metadata": { "intent": "insult" } },
{ "id": "insult_4", "text": "You are dumber than a bag of rocks.", "metadata": { "intent": "insult" } },
{ "id": "insult_5", "text": "Move, you incompetent buffoon.", "metadata": { "intent": "insult" } },
{ "id": "pleasing_1", "text": "Good evening, kind sir, may I please pass?", "metadata": { "intent": "pleasing" } },
{ "id": "pleasing_2", "text": "I humbly request entry into the city, good guard.", "metadata": { "intent": "pleasing" } },
{ "id": "pleasing_3", "text": "You look like a fine guardian. Might you let me through?", "metadata": { "intent": "pleasing" } },
{ "id": "pleasing_4", "text": "Thank you for your service. Could I trouble you for passage?", "metadata": { "intent": "pleasing" } },
{ "id": "pleasing_5", "text": "I appreciate your diligence. Would it be possible to enter?", "metadata": { "intent": "pleasing" } },
{ "id": "joking_1", "text": "Why did the orc cross the road? To get to the other fortress!", "metadata": { "intent": "joking" } },
{ "id": "joking_2", "text": "A paladin, a rogue, and a wizard walk into a tavern… ouch.", "metadata": { "intent": "joking" } },
{ "id": "joking_3", "text": "How many guards does it take to open a gate?", "metadata": { "intent": "joking" } },
{ "id": "joking_4", "text": "Knock knock. Who's there? A very impatient traveller.", "metadata": { "intent": "joking" } },
{ "id": "joking_5", "text": "I've got a joke about a magic door but I'm not sure it will open.", "metadata": { "intent": "joking" } }
]
guard_intents.json — the config pointing at it:
{
"version": 1,
"embedding_model": "All-MiniLM-L6-v2 (Q4_K_M)",
"records_file": "guard_intents.kb.json",
"index_file": "guard_intents.usearch",
"fields": [
{ "name": "intent", "type": "string", "default": "" }
]
}
The fields array is required — ClassifyIntent validates that the
field referenced by metadata_field exists and is string-typed. On
first run the server embeds every record and writes guard_intents.usearch
to disk; subsequent runs skip the embedding pass.
Step 2 — create the embedded string storage¶
This step is optional: the ClassifyIntent node loads the KB from its
path at agent creation. Creating it up front builds the index before the
first agent needs it and reports the record count. A storage created from
a config file is found by its path, so the node in step 4 names the same
guard_intents/guard_intents.json, not the name passed here.
Step 3 — create the instruction map¶
The instruction map is a Map-kind string storage: keys are intent
labels, values are tone directives fed to the model as invisible
instructions.
client.create_keyed_string_storage(
name="guard_instructions",
keys= ["insult", "pleasing", "joking"],
values=[
"The traveller has insulted you. Respond curtly and insult them back — short and sharp.",
"The traveller is being polite. Stay grumpy but show grudging satisfaction. One sentence.",
'The traveller told a joke. Permit yourself one brief laugh (e.g. "Ha.") then immediately refocus on duty.',
],
)
client.CreateStringStorageKeyed(
"guard_instructions",
/*keys=*/ {"insult", "pleasing", "joking"},
/*values=*/ {
"The traveller has insulted you. Respond curtly and insult them back — short and sharp.",
"The traveller is being polite. Stay grumpy but show grudging satisfaction. One sentence.",
"The traveller told a joke. Permit yourself one brief laugh then refocus on duty.",
},
::Tryll::StringStorageKind_Map);
await TryllClient.Instance.RequestCreateKeyedStringStorageAsync(
"guard_instructions",
new List<string> { "insult", "pleasing", "joking" },
new List<string> {
"The traveller has insulted you. Respond curtly and insult them back — short and sharp.",
"The traveller is being polite. Stay grumpy but show grudging satisfaction. One sentence.",
"The traveller told a joke. Permit yourself one brief laugh then refocus on duty.",
},
Tryll.StringStorageKind.Map);
auto* Subsystem = GetGameInstance()->GetSubsystem<UTryllSubsystem>();
Subsystem->RequestCreateStringStorageKeyed(
TEXT("guard_instructions"),
/*Keys=*/ { TEXT("insult"), TEXT("pleasing"), TEXT("joking") },
/*Values=*/ {
TEXT("The traveller has insulted you. Respond curtly and insult them back — short and sharp."),
TEXT("The traveller is being polite. Stay grumpy but show grudging satisfaction. One sentence."),
TEXT("The traveller told a joke. Permit yourself one brief laugh then refocus on duty."),
},
/*Kind=*/ 1); // 1 = Map
Or reference a Map file by path
Instead of creating the storage over the wire, you can drop a .json Map
file (an array of { "id": "<key>", "text": "<value>" } records) under your
storage root and point IntentToInstruction.string_storage at its relative
path — the server loads it at agent-creation time. .json → Map,
.mm.json → Multimap, .txt → List. See the
String Storage reference.
Step 4 — build the graph¶
The Transform node (compose) renders the tone directive (if found) as
a hidden system-style line above the user message into its own slot, and
the Generate node (respond) consumes that slot via input="compose".
When no intent was matched the {{#instructions}} block is empty and the
model answers freely.
from tryll_client.graph import (
GraphDescription, ClassifyIntentParams, IntentToInstructionParams,
TransformParams, GenerateParams,
)
SYSTEM_PROMPT = (
"You are roleplaying as Aldric, a grumpy gate guard. "
"Stay in character at all times. Use short sentences."
)
TEMPLATE = (
"{{#instructions}}[Tone directive: {{text}}]\n{{/instructions}}"
"Traveller says: {{user_message}}"
)
graph = (
GraphDescription()
.add_node("classify", ClassifyIntentParams(
embedded_string_storage="guard_intents/guard_intents.json",
metadata_field="intent",
threshold=0.6,
found_exit="pick",
not_found_exit="compose",
))
.add_node("pick", IntentToInstructionParams(
string_storage="guard_instructions",
output_name="tone",
default_exit="compose",
))
.add_node("compose", TransformParams(
template=TEMPLATE,
default_exit="respond",
))
.add_node("respond", GenerateParams(
system_prompt=SYSTEM_PROMPT,
input="compose",
# default_exit is "" (END) by default.
))
.set_start_node("classify")
.set_default_model_name("Llama 3.2 3B Instruct (Q4_K_M)")
)
agent = client.create_agent(graph)
using namespace Tryll::Client;
using namespace Tryll::NodeParams;
constexpr const char* kSystemPrompt =
"You are roleplaying as Aldric, a grumpy gate guard. "
"Stay in character at all times. Use short sentences.";
constexpr const char* kTemplate =
"{{#instructions}}[Tone directive: {{text}}]\n{{/instructions}}"
"Traveller says: {{user_message}}";
ClassifyIntentParamsT classifyParams;
classifyParams.embedded_string_storage = "guard_intents/guard_intents.json";
classifyParams.metadata_field = "intent";
classifyParams.threshold = 0.6f;
classifyParams.found_exit = "pick";
classifyParams.not_found_exit = "compose";
IntentToInstructionParamsT pickParams;
pickParams.string_storage = "guard_instructions";
pickParams.output_name = "tone";
pickParams.default_exit = "compose";
TransformParamsT composeParams;
composeParams.template_ = kTemplate;
composeParams.default_exit = "respond";
GenerateParamsT respondParams;
respondParams.system_prompt = kSystemPrompt;
respondParams.input = "compose";
// respondParams.default_exit is "" (END) by default.
GraphDescription graph;
graph.AddClassifyIntent("classify", std::move(classifyParams))
.AddIntentToInstruction("pick", std::move(pickParams))
.AddTransform("compose", std::move(composeParams))
.AddGenerate("respond", std::move(respondParams))
.SetStartNode("classify")
.SetDefaultModelName("Llama 3.2 3B Instruct (Q4_K_M)");
auto agent = client.CreateAgent(graph);
const string systemPrompt =
"You are roleplaying as Aldric, a grumpy gate guard. " +
"Stay in character at all times. Use short sentences.";
const string template =
"{{#instructions}}[Tone directive: {{text}}]\n{{/instructions}}" +
"Traveller says: {{user_message}}";
var graph = new TryllGraphBuilder()
.AddClassifyIntent("classify", new TryllClassifyIntentParams
{
EmbeddedStringStorage = "guard_intents/guard_intents.json",
MetadataField = "intent",
Threshold = 0.6f,
FoundExit = "pick",
NotFoundExit = "compose",
})
.AddIntentToInstruction("pick", new TryllIntentToInstructionParams
{
StringStorage = "guard_instructions",
OutputName = "tone",
DefaultExit = "compose",
})
.AddTransform("compose", new TryllTransformParams
{
Template = template,
DefaultExit = "respond",
})
.AddGenerate("respond", new TryllGenerateParams
{
SystemPrompt = systemPrompt,
Input = "compose",
// DefaultExit is "" (END) by default.
})
.SetStartNode("classify")
.SetDefaultModelName("Llama 3.2 3B Instruct (Q4_K_M)")
.Build();
var (agent, error) = await TryllClient.Instance.RequestCreateAgentAsync(graph);
agent.SetOnAnswerText((nodeName, text, isDelta, isFinal) => { /* stream text */ });
agent.SetOnTurnComplete((status, debugInfo, tokens) => { /* turn ended */ });
#include "Generated/TryllGraphBuilder.Nodes.h"
#include "Generated/TryllNodeParamsFactory.h"
UTryllClassifyIntentParams* ClassifyP = UTryllNodeParamsFactory::MakeClassifyIntentParams(this);
ClassifyP->EmbeddedStringStorage.Path = TEXT("guard_intents/guard_intents.json");
ClassifyP->MetadataField = TEXT("intent");
ClassifyP->Threshold = 0.6f;
ClassifyP->FoundExit = TEXT("pick");
ClassifyP->NotFoundExit = TEXT("compose");
UTryllIntentToInstructionParams* PickP = UTryllNodeParamsFactory::MakeIntentToInstructionParams(this);
PickP->StringStorage.Path = TEXT("guard_instructions");
PickP->OutputName = TEXT("tone");
PickP->DefaultExit = TEXT("compose");
UTryllTransformParams* ComposeP = UTryllNodeParamsFactory::MakeTransformParams(this);
ComposeP->Template = TEXT("{{#instructions}}[Tone directive: {{text}}]\n{{/instructions}}Traveller says: {{user_message}}");
ComposeP->DefaultExit = TEXT("respond");
UTryllGenerateParams* RespondP = UTryllNodeParamsFactory::MakeGenerateParams(this);
RespondP->bOverrideSystemPrompt = true;
RespondP->SystemPrompt = TEXT("You are roleplaying as Aldric, a grumpy gate guard. Stay in character at all times. Use short sentences.");
RespondP->bOverrideInput = true;
RespondP->Input = TEXT("compose");
// RespondP->DefaultExit is TEXT("") (END) by default.
FTryllGraphDescription Graph = FTryllGraphBuilder()
.AddNode(TEXT("classify"), ClassifyP)
.AddNode(TEXT("pick"), PickP)
.AddNode(TEXT("compose"), ComposeP)
.AddNode(TEXT("respond"), RespondP)
.SetStartNode(TEXT("classify"))
.SetDefaultModelName(TEXT("Llama 3.2 3B Instruct (Q4_K_M)"))
.Build();
Step 5 — send messages and observe intents¶
# insult → found → pick → respond (with insult directive)
print(agent.send_message("Move aside, you dull-witted oaf."))
# pleasing → found → pick → respond (with polite directive)
print(agent.send_message("Good day to you, honourable guard."))
# joking → found → pick → respond (with joke directive)
print(agent.send_message("Why did the troll apply to be a guard? He heard it was rocky!"))
# neutral → not_found → respond (general answer, no directive)
print(agent.send_message("How far is it to the next town?"))
Verify it worked¶
With enable_diagnostics = True on the agent, each
TurnComplete.debug_info JSON has one entry per node in its nodes
array (see Turn Diagnostics JSON).
For the insult, the classify and pick entries include (trimmed to the
keys that matter here):
[
{
"_type": "ClassifyIntentNode",
"name": "classify",
"exit_route": "found",
"diagnostics": {
"output": {
"classification": {
"winner": {
"distance": 0.21,
"id": "insult_5",
"intent": "insult",
"text": "Move, you incompetent buffoon."
}
}
},
"parameters": { "metadata_field": "intent", "threshold": 0.6 }
}
},
{
"_type": "IntentToInstructionNode",
"name": "pick",
"exit_route": "default",
"diagnostics": {
"output": {
"found": true,
"intent": "insult",
"text": "The traveller has insulted you. Respond curtly and insult them back — short and sharp."
},
"parameters": { "output_name": "tone", "string_storage": "guard_instructions" }
}
}
]
For a neutral message the classify entry takes not_found, has no
winner, and names the reason instead. pick does not run, so it has no
entry:
{
"name": "classify",
"exit_route": "not_found",
"diagnostics": {
"output": {
"classification": {
"not_found_reason": "below_threshold",
"rejected_distance": 0.74,
"rejected_intent": "joking"
}
}
}
}
With log_level set to "debug" in server-config.json, the server log
shows the classification and the directive lookup:
[debug] [ClassifyIntentNode 'classify'] Classified 'Move aside, you dull-witted oaf.' as 'insult' (distance=0.21)
[debug] [IntentToInstructionNode 'pick'] Resolved intent 'insult' → instruction 'The traveller has insulted you. Respond curtly and insult th'
Common pitfalls¶
- Threshold too tight. Start with
0.6. If clearly-labelled phrases fall through tonot_found, widen to0.7and add more diverse examples to the KB. - Too few examples per intent. Five phrases per intent is a minimum. More variation (formal/informal, long/short) improves classification.
- Missing embedding model in KB config. The
*.jsonconfig must declareembedding_model. Preload that model beforeCreateAgent— graph creation does not auto-download embedding models for file-backed storages. - Wrong metadata field type.
ClassifyIntentrejects fields that are notstring-typed at agent-creation time. Check yourfieldsarray in the config. not_foundpath not wired. An emptynot_found_exitis valid and routes to END, so an unclassified message ends the turn with no reply. Wirenot_foundtocomposeas above to keep the general answer. Only an exit that names a node missing from the graph fails agent creation (error 3003).- Instruction never rendered. Ensure the
Transformnode's template contains{{#instructions}}…{{/instructions}}or{{slot.tone}}. Without it, the instruction is attached but never inserted into the prompt.
Next steps¶
- Add more intents — extend the KB with new example phrases and add a matching key/value pair to the instruction map.
- Tune threshold at runtime —
thresholdis mutable, so you can change theclassifynode's params while the agent is running. - Filter by metadata — combine
metadata_fieldclassification with afilterparam to restrict which records compete during search (e.g. filter to a specific NPC or scene).
Related¶
- How-to: Classify intent without a knowledge base
— the
ClassifyIntentLLMalternative to this recipe - Reference: Classify Intent node
- Reference: Intent to Instruction node
- Reference: String Storage
- Reference: Embedded String Storage
- How-to: Use Mustache Templates
- How-to: Change Agent Parameters at Runtime
- Concept: Workflows, graphs, and nodes