Memory vocabulary
A memory vocabulary declares the entity classes formed memory is classified
against, and the relation types it is expected to use. Declaring one lets a query
ask for "every Person this scope knows about" without enumerating entities by
name.
The vocabulary steers what memory formation generates. It never gates what the platform accepts and never narrows what a query may ask for.
PUT /v1/memory/vocabulary
{
"vocabulary": {
"entityClasses": [
{
"name": "Person",
"description": "an individual human being, including colleagues"
},
{
"name": "Organization",
"description": "a company, employer, school or agency"
}
],
"relationTypes": [
{
"name": "WORKS_AT",
"description": "employment of a person by an organization",
"signatures": [
{"sourceClass": "Person", "targetClass": "Organization"}
]
}
]
}
}
Descriptions are not documentation
A class description is supplied to the extraction model beside the class name, and
it is the primary signal separating classes whose names do not speak for
themselves. Person, Contact and Party are indistinguishable to a model given
only their names; their descriptions are what make the difference.
A vocabulary declared without descriptions is accepted and classifies on names alone. It will simply classify less accurately, for reasons that are invisible in the declaration and hard to debug from the output.
Descriptions are also by far the largest part of a rendered vocabulary, so the description bound rather than the class count is what governs the added cost.
Declaring at two levels
A vocabulary is declared at the enterprise level, where it is the default for every scope in that enterprise, or at the scope level, where it replaces the enterprise default for that scope alone.
# Enterprise level: omit scopeId.
PUT /v1/memory/vocabulary
GET /v1/memory/vocabulary
DELETE /v1/memory/vocabulary
# Scope level: specify scopeId.
PUT /v1/memory/vocabulary
{"scopeId": "bot.support", "vocabulary": {...}}
GET /v1/memory/vocabulary?scopeId=bot.support
DELETE /v1/memory/vocabulary?scopeId=bot.support
On PUT, scopeId belongs in the request body. A scopeId query parameter
on PUT is ignored, so the declaration would land at the enterprise level.
Agent scopes and subject scopes share one identifier space, so a scope-level declaration addresses both kinds by the same mechanism and applies to each identically.
A scope-level vocabulary replaces the enterprise one outright. It does not merge: a class is removed by omitting it, so the effective declaration is always readable from one document.
Declaring is the opt-in, and absent is not empty
There is no separate switch. A vocabulary's existence is the opt-in, and three states are distinguishable:
| Scope-level declaration | What resolves |
|---|---|
| Absent | The enterprise vocabulary, which may itself be absent |
| Present, with classes | That vocabulary, replacing the enterprise default |
| Present, with no classes | None: classification is off for this scope |
So declaring an empty vocabulary is not the same as removing one. An empty declaration turns classification off for that scope; a removal restores inheritance from the enterprise.
Because the two are different, they are different operations:
- Declaring always carries a vocabulary. Omitting the field is rejected rather than read as either an empty declaration or a removal, so a client that builds its request incrementally cannot silently destroy the vocabulary it meant to leave alone.
- Removing is
DELETE. - Reading returns both answers, so you can tell them apart:
declaration is what THIS level declares, and resolved is what formation will
actually use here. A scope that inherits returns no declaration and a populated
resolved. A scope that declares an empty vocabulary returns a declaration with
no classes and no resolved at all.
Managing a vocabulary from the CLI
jnh vocabulary (short form jnh vocab) exposes the same three operations. The level is chosen by --scope: omitting it targets the enterprise level, exactly as omitting scopeId does on the REST route.
# Declare the enterprise default.
jnh vocabulary declare \
--class Person="an individual human being, including colleagues" \
--class Organization="a company, employer, school or agency" \
--relation WORKS_AT="employment of a person by an organization" \
--signature WORKS_AT=Person:Organization
# Declare a scope-level override from a file, which is where a real
# vocabulary belongs. The file carries the PUT body as JSON or YAML, with or
# without the `vocabulary` wrapper.
jnh vocabulary declare --scope bot.support --from-file support-vocab.yaml
# Read what a scope declares and what resolves for it.
jnh vocabulary get --scope bot.support
# Disable classification for that scope (declares an empty vocabulary).
jnh vocabulary declare --scope bot.support --none
# Restore inheritance from the enterprise default.
jnh vocabulary remove --scope bot.support
A class or relation flag is written as NAME=DESCRIPTION. The separator is required even when the description is empty (--class Person=), because a description is an input to classification rather than documentation. Signatures use NAME=SOURCE:TARGET; a signature must name a relation type declared by a --relation on the same command line.
Because a declaration replaces rather than merges, declare reads the current declaration first and prints what the new one adds, drops and re-describes, then asks for confirmation. Pass --yes in scripts, or --dry-run to see the diff and the request without sending anything.
The read distinguishes the three states that a list of classes cannot:
$ jnh vocabulary get --scope bot.support
LEVEL scope bot.support
DECLARATION none: this scope inherits the enterprise default
RESOLVES the enterprise default
A scope that declared an empty vocabulary reports empty: classification is deliberately OFF at this level instead, so "inherits the default" and "classifies nothing" are never shown alike. -o json returns the response verbatim, where the presence of declaration carries the same distinction; -o name lists the resolved entity class names.
Declaring and removing are management-class, so jnh refuses them locally when the session authenticates with an API key. Reading is not, and works with a key holding agent.vocabulary:read.
An entity's class is assign-once
The first write that gives a node a class fixes it. A later write supplying a different class succeeds, changes nothing, and reports no error: the node keeps the class it already had, while its other fields are replaced as usual.
A node carrying no class is still classifiable, so a later write can give one to a node that has none.
This is deliberate, and it is the one exception to graph nodes being create-or-replace. Two extractions disagreeing about an entity's class is model disagreement rather than a change in the world: it carries no valid time, so it cannot be recorded as supersession the way a fact's meaning can, and a graph node holds no validity window in which the previous class would stay queryable. Last-write-wins would discard a classification with no history to recover it from.
To change a stored class, write a different node.
An entity that fits no declared class is stored with no class, never under a
placeholder such as Entity. A placeholder would consume the single write the
class accepts and leave the node permanently unclassifiable, even after the
vocabulary improves.
It steers formation. It gates nothing
A vocabulary never causes a request to be refused, a candidate to be dropped, or a stored value to be rejected:
- A direct commit carrying
nodeTypeis not validated against any vocabulary. A caller writing to the graph may use any class. - A query filtering on a class is not validated against any vocabulary. Because a class is assign-once and a vocabulary is replaceable, a scope legitimately holds classes its current vocabulary no longer declares, and those entities stay reachable.
- Declaring, editing or removing a vocabulary changes no memory that already exists. Nothing is retyped and nothing is removed.
Relation types are guidance, not a closed set. A relationship whose type matches none of them is recorded exactly as extracted. The asymmetry with entity classes is deliberate: an entity that fits no class has a correct outcome available (leave it unclassified), while a relationship that fits no declared type has none, and refusing it would discard a fact the conversation actually supported.
Signatures are likewise expectations, not constraints. Nothing validates an extracted relationship against them.
Filtering on a class
A class is a first-class column on the graph section's filter list, alongside
NodeId and Label:
POST /v1/agents/{agentInstanceId}/memory:query
{
"graph": {
"start": {
"filters": [{"key": "NodeType", "value": "Person"}]
},
"limit": 50
}
}
Every node returned by a traversal or an inspect listing carries its class. A node with no class is returned exactly as it was before classes existed, with no placeholder.
From the CLI, jnh scopes memory inspect <scope-id> --graph displays node class in a CLASS column beside LABEL. The column appears only when at least one node in the listing carries a class; --fields nodeType selects the field regardless.
To filter a traversal by class, specify NodeType as a node filter in the --graph-json query document (for example, {"start": {"filters": [{"key": "NodeType", "value": "Person"}]}}).
Who may declare one
Declaring or removing a vocabulary requires agent.vocabulary:manage, which is
a management-class permission (see Roles and access control). An API key cannot declare a vocabulary at either
level, because declaring one changes how formation behaves for every scope it
resolves for, and an agent should not be able to retune the classification of its
own memory.
Reading requires only agent.vocabulary:read, which is not management-class:
learning what classifies a scope's memory is a different act from changing it.
Naming a scope additionally requires selector reach over it, in the namespace its kind selects: agent selectors for an agent workspace, subject selectors for a subject scope, which are granted independently. The permission does not substitute for the reach, nor the reach for the permission. A caller without reach is answered as though the scope did not exist, so a refusal never confirms that one is there. The enterprise level names no scope and requires no reach.
Bounds
A vocabulary is bounded on every axis: the number of entity classes, the number of relation types, the signatures per relation type, the length of every name, and the length of every description. A declaration exceeding any bound is rejected and nothing is written, so a rejected declaration always leaves the previous one intact.
A declaration is also rejected with INVALID_ARGUMENT when it names the same
entity class or relation type twice, or when a signature names a source or target
class that the same declaration does not declare.
| Property | Limit |
|---|---|
| Maximum entity classes | 32 |
| Maximum relation types | 32 |
| Maximum signatures per relation type | 6 |
| Maximum name length | 64 characters |
| Maximum description length | 200 characters |
These bounds exist because the resolved vocabulary is supplied to the extraction model on every formation, so an unbounded vocabulary would make every formation slower and more expensive. They are not storage limits.
Class and relation names are restricted to letters, digits, _, . and -, for
the same reason a metadata key is restricted: a name is rendered into the
extraction prompt, and a name able to alter the structure of what it is rendered
into is a hazard regardless of intent. Descriptions may contain any text; line
breaks within one are flattened so a description cannot escape its section.
When a change takes effect
A declaration, edit or removal applies to the next formation that starts after it succeeds. A formation already running keeps the vocabulary it started with.
A formation that ran just before a new declaration arrived writes its entities unclassified, and because unclassified means no class rather than a placeholder, every one of those entities remains eligible to be classified by any later assertion. Nothing durable is lost.