Every page-like node has a description
Why
A node's Description is what every surface shows when it shows the node without opening it:
search results, the side navigation, catalog cards, the agent's context, and the Open Graph card a
shared link unfurls into. A node without one renders as a bare title. In Teams on 2026-10-26 that
looked like a broken link: the instance name over an empty body (Plugins #4257 fixed the share
card; this page fixes the cause for the node itself).
Before this change one thing wrote descriptions: the Regenerate button in a node's Settings →
Display editor (SettingsLayoutArea.RegenerateDescriptionFromNode → IDescriptionGenerator), from
the name and category alone. Nothing asked for one at creation, and nothing filled the gap afterwards.
…and an icon, chosen from the catalogue
Every page-like node also gets an icon (maintainer, 2026-10-26: "see that all nodes get a proper icon and description from local llm"). The same mechanism does it, in the same call:
- A node is a candidate when its description or its icon is blank (
NodeAppearanceRequest). The fill writes only the blank field(s), each guarded inside the update, so whatever a person wrote since the read wins. - The model does not draw. It chooses one name from core's node-icon catalogue
(
MeshWeaver.Graph.NodeIconCatalog). That catalogue holds about 120 curated Fluent icon names, and core ships an SVG for each, so the app, the cards and link previews all render the choice. The user message carries aWanted:line and, when an icon is wanted, the catalogue asName — meaninglines. Only anIcon:line naming a catalogue entry is stored, in the catalogue's spelling (ParseIcon). Anything else (SVG, a URL, an emoji, an unknown name) is dropped. When only the icon was wanted, that counts as a failed fill. - The sweep's query is
BlankAppearanceFilter: a blank description OR no icon OR an empty icon. It is pinned literally inNodeDescriptionsMeshTestand run on Postgres inBlankDescriptionFilterPgTests. - The manual Generate icon button (
IconGenerator, the NodeInitializer agent) still draws free-form inline SVG for a person who wants a bespoke mark. The automatic fill never does.
Which nodes are guaranteed one
Page-like nodes: what a person navigates to, searches for or shares. NodeDescriptions.IsEligible
is the one rule, and the handler and the sweep both read it:
| Excluded | Why |
|---|---|
a node with no nodeType |
nothing to render it as |
a satellite (IsSatelliteType, or any path segment starting with _, such as _Access, _Policy, _Thread, _Entitlements) |
bookkeeping rows nobody opens as a page |
ThreadMessage, AccessAssignment, Thread |
high-volume rows. A thread already has a speaking name set at its start |
the ledger and usage records (ProviderSpendCharge, ProviderHealth, ModelCreditCharge, ModelCreditGrace, ModelCreditExchangeRate, ModelCreditRateFeedState, TokenUsage) |
records, never pages. A description call writes a ProviderSpendCharge itself, so were it eligible, every call would queue a description for its own charge, a self-sustaining loop of model calls and ledger rows |
ModelProvider |
its content holds the provider's keys (ApiKey, McpApiKey, FunctionKeys), which must never enter a model prompt. [Browsable(false)] hides a property from the editor, not from serialization |
a node that already has a non-blank Description |
never overwritten (see below) |
a node in a source-owned partition: its root has a _GitSync (repo sync), or a Plugins/{root} install record exists (a registry or local-mount package install, which writes no _GitSync; NodeDescriptions.SourceRecordPaths, shared by the fill and the sweep) |
the repo is the source of truth, so a written description would be reverted on the next import or exported as an unreviewed commit. The fix there is in the node file |
The three layers
1. At creation — ask for it
The MCP create tool's description lists description as expected: one or two plain
sentences on what the node is. Agents create most nodes, and an agent that has just written the
content is the best author of its summary. A create without one still succeeds; layers 2 and 3
fill it.
2. Right after creation — the Description Writer
NodeDescriptionPostCreationHandler (an INodePostCreationHandler in MeshWeaver.AI) matches
every eligible node created without a description. Post-creation handlers run before the
create answers, so the handler never waits for a model: it hands the path to
NodeDescriptions and completes at once.
The paths join one serial line per process, in posting order. Each path is stamped with its due time when posted (a grace period of 60 seconds), and the line waits only for whatever is left of that path's grace when it reaches it. So an author or agent that adds the description right after the create costs no model call, a burst of creates neither stacks the delays nor opens a model call each, and nothing is reordered. (Concurrent per-path timers fired out of order on the thread pool, CI run 40299946538.) For each path:
- Re-read the node from its owner (
GetMeshNodeOutcome, as System), never through a query: the index can trail a content edit, and a description written from a stale excerpt is non-blank, so no later fill would correct it. An absent node isMissing; a read the owner did not answer isFailed, neverMissing. The node may have gained a description, been deleted or moved since. - Build the context: name, node type, category, path, and up to 4,000 characters of the node's text content (typed and JSON content read the same).
- Generate through the fixed writer (below).
- Write as System, only if still blank:
GetMeshNodeStream(path).Update(n => blank(n) ? n with { Description = d } : n).
The whole fill (read, partition lookup, model call, write) runs under one 3-minute deadline
(FillTimeout; the model call alone keeps its 2 minutes). A step that never answers ends the fill
Failed, and the serial line moves on to the next path. Before, only the model call had a deadline,
so one stalled read or write held every later fill.
A description someone typed in the meantime wins. The write is inside the serialized unit: the
next fill starts only after this write answered.
A failure (no model, an unlabelled answer, a model call over 2 minutes) is logged with the path and dropped, and the line moves on. Nothing is written, so the node stays blank for layer 3.
IsEligible reads only the node. The repo-synced check needs a read of {root}/_GitSync, so Fill
makes it, and the sweep makes it once per partition root per run.
3. Daily — the sweep
description-sweep is a job on the ops queue (Hosting/Queue/Source/DescriptionSweepJobs.cs,
executor description-sweep, one at a time). A daily schedule event on Hosting/PlatformBuildInbox
enqueues it, the way storage-prune is enqueued, pinned to the enqueuing installation's own
execution queue (DeclarationFor(instance), as ci-artifact-store-prune does): another
installation's queue would describe the wrong mesh, and one-at-a-time holds only within one queue. Nothing runs outside a queue
(Hosting/ActivityExecutionModel). Each run:
- pages through the blank descriptions, newest first, 250 rows a page:
(-description:* OR description:"" OR description:" ") -nodeType:AccessAssignment -nodeType:ModelProvider -nodeType:Thread -nodeType:ThreadMessage partitions:all sort:lastModified-desc; - moves an inclusive
lastModifiedcursor (lastModified:<=…) to the oldest row of each page and skips rows it has already seen. A page whose rows are all skipped (a repo-synced partition, satellites) therefore no longer stalls every run on the same newest window. When a whole page shares ONE timestamp, the next read keeps that cursor and widens its limit by a page until it reaches past the group. The query has no second sort key and no offset, and the store orders a tie arbitrarily, so the earlier strict step past the timestamp skipped every unread row of the group on every run (PR #4264 review); - keeps the eligible rows, drops every partition whose root has a
_GitSync(one read per root per run), and stops atHosting:DescriptionSweep:MaxPerRuncandidates (default 200), the store's end, or 40 pages; - fills them one at a time through the same
NodeDescriptions.Fill; - runs inside one whole-run deadline of 7 minutes, counted from when the run is subscribed. It
bounds the scan (a page read or partition lookup that stalls errors at the deadline, into the job's
retry), every fill and the fill in flight: no page is read and no fill started with less
than 20 seconds left, and a fill still running at the deadline ends
Failed("the run's time budget ran out…"). That leaves 3 minutes of the job's 10-minute lease to record completion, so a slow model never makes a running job look orphaned and reclaimable. A run first renews the job's lease to a full 10 minutes from its own start (RenewedForRun), because a Running job re-adopted after a restart kept its original lease while its budget started afresh; - finishes the job with one line: rows scanned, eligible, one count per outcome, the first failures, and whether the budget or the page cap stopped it.
Blank means null, an empty string or a single space in the query. -description:* alone is
description NOT ILIKE '%' OR description IS NULL on Postgres, which drops a stored empty string
(BlankDescriptionFilterPgTests shows the difference). A quoted query value is a literal and a
wildcard needs an unquoted one, so longer whitespace-only values cannot be written as a query: the
sweep does not find them, and IsEligible (which trims) stays the rule every candidate is held to.
The schedule arms on an installation that runs its own queue. It does not arm on one that reports
to another control instance (Hosting:ControlInbox:Url set), because a job enqueued there would run
on the control instance and describe its nodes. Today that means the sweep runs where the Hosting
package's queue runs. Other instances get layer 2, and layer 3 once their execution queues land
(Hosting/ActivityExecutionModel §4.8). Hosting:DescriptionSweep:Enabled=false turns it off, and
Hosting:DescriptionSweep:Interval changes the cadence (floor: one hour).
Which model reads the content — a fixed call, not an agent
Descriptions are written by one fixed, tool-free model call (NodeDescriptionWriter), not by
running the DescriptionWriter agent through AgentChatClient. The first version did run the agent,
and the review of PR #4264 showed why that is wrong for a fill that runs as System over content
anyone can author:
AgentChatClientroutes on references in the message. An@Agent/Assistantin a page's text took priority over the selected agent and handed a System round to a tool-enabled agent with mesh write tools. That is privilege escalation.@@references are expanded throughMeshPlugin.Getbefore generation, so a page could pull another partition's private content into the request. That is a cross-partition read.- It ran on the default agent's model rather than the utility tier, since initialisation resolved the model before the agent was selected.
- Each call left a live
AgentChatClientsubscribed to the agent and model catalogs.
The writer has none of that machinery. It holds three things: a factory for a bare IChatClient
for the instance's utility tier (DefaultChatClientProvider.TryCreateForTier("utility"), the same
resolution an agent asking for that tier gets, including data residency and the instance ceiling), the
HTTP I/O pool the call runs in (as ChatClientSummarizer), and the shipped instructions of the
built-in DescriptionWriter agent (BuiltInAgentProvider.InstructionsOf, read from the embedded
roster rather than through the mesh, so no catalog or node can replace them). One call sends two
messages: the instructions as the system message, and every node field as literal text in one user
message. No tools (ToolMode.None), no agent selection, no reference expansion. The client lives for
exactly that call and is disposed on success, failure and unsubscribe. Content that reads
@Agent/Assistant or @@Other/Private reaches the model as those characters and does nothing else
(NodeDescriptionWriterTest).
The call's options are resolved like an agent round's: the reasoning effort and the output cap come
from the shipped agent's declarations (reasoningEffort: medium), else the selected model's, else the
utility tier's (ReasoningEffortResolution, OutputCap; the tier's cap is 4,096 tokens), and the
model's declared limit bounds the cap. A fixed 400-token cap with no effort left a reasoning model on
its provider default, where thinking could use the whole budget before any Description: line.
The model client comes from the factory that serves the tier's selection: first the factory that answers
for the selection itself, then the one that answers for its wire id (DefaultChatClientProvider.FactoryFor,
the rule a thread round uses). The client is still created for the selection, so the credential of the
picked node binds. A selection is a node path when its wire id is shared or residency-bound, and a
shape-matching factory (the Anthropic one accepts only claude*) answers only for the bare id.
Each call goes through the provider's daily budget, as a thread round does in ThreadExecution
(NodeDescriptionBudget, AI/ProviderDailyBudget). Before the call, ProviderBudgetGuard.Check runs on
the tier model's node path, resolved through the catalog rather than by the string's shape, because a unique
wire id can itself contain a slash (amazon/nova-micro-v1): a reached limit means no client is created and the fill ends Failed, while an
undetermined spend lets the call run, by the guard's rule. After the call, ProviderBudgetGuard.Charge
records the response's token usage against the same holder, under description:{path}. The charge is
part of the call, so nothing is written for spend that was not recorded.
The excerpt the model is shown drops every content property whose name marks a credential (apiKey,
secret, password, token, functionKeys, connectionString, credential, privateKey, compared
case-insensitively with _ and - removed), on every node type, with everything nested under it.
Only a labelled Description: line is an answer. Chat paths return a missing model or a residency
refusal as ordinary assistant text, and the old first-line fallback stored that text as the description
and hid the node from every later fill. The Settings → Display Regenerate button goes through the
same writer, and it does not keep the fallback either: it writes the generated text straight into
the node, so an error reply would land there too. It shows the error in its dialog instead.
The utility tier is the model. The maintainer's direction (2026-10-25) is that summaries run on the
local model. When the utility tier points at the in-cluster Ollama (http://ollama:11434/v1, core
Architecture/LocalModelService), no node content leaves the cluster. Until then the tier's configured
model is used (decision 2026-10-26: start filling now, switch with the tier). No code changes when the
tier moves.
Never overwritten
A generated description is written only into a blank field and never replaced, by either layer. Regenerating one is a person's action: the Regenerate button in Settings → Display. A description that went stale as the content grew is a known limit of this version.
Where the code is
| Piece | Where |
|---|---|
| Eligibility, context, fill, blank filter | src/MeshWeaver.AI/NodeDescriptions.cs |
| The fixed model call, the strict parse | src/MeshWeaver.AI/NodeDescriptionWriter.cs |
| The Regenerate button's generator | src/MeshWeaver.AI/DescriptionGenerator.cs |
| Post-creation handler | src/MeshWeaver.AI/NodeDescriptionPostCreationHandler.cs |
| The instructions (system message) and output contract | src/MeshWeaver.AI/Data/Agent/DescriptionWriter.md |
The MCP create contract |
src/MeshWeaver.Mcp/McpMeshPlugin.cs → Create |
| The daily sweep | Hosting/Queue/Source/DescriptionSweepJobs.cs |
Not established
- How many eligible nodes lack a description on each instance today. The sweep's first report is that number.
- Whether every bulk path (GitSync import, package install, migrations) runs post-creation handlers. Imports into a synced partition are excluded by the rule above either way. A bulk path that skips the handler is covered by the sweep.
- Whether every production chat-client factory builds a usable bare client for the utility tier
(the image describer relies on the same
DefaultChatClientProviderpath for the default model). If one does not, every fill reportsFailedwith the reason, and the sweep's report shows it on the first run. - Whether every store orders
sort:lastModified-descacross partitions the way the cursor assumes. The fake store in the tests and the Postgres executor do. A store that does not would make the cursor skip rows, never repeat them: the report names how many were scanned.