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:

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:

  1. 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 is Missing; a read the owner did not answer is Failed, never Missing. The node may have gained a description, been deleted or moved since.
  2. 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).
  3. Generate through the fixed writer (below).
  4. 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:

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:

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