Helps set up C# library projects that provide nodes to vvvv gamma — project directory structure, Initialization.cs with AssemblyInitializer, service registration via RegisterService, IResourceProvider factories, ImportAsIs namespace/category configuration, .csproj setup, and dynamic node factories via RegisterNodeFactory. Use when creating a new library project, registering services or node factories, configuring ImportAsIs parameters, or setting up .csproj.
A node library is a project that provides multiple nodes to vvvv gamma as a distributable package. This skill covers the project-level concerns: directory structure, naming conventions, category organization, service registration, and node factories.
For writing individual node classes (ProcessNode, Update, pins, change detection), see vvvv-custom-nodes. For consuming services inside node constructors (IFrameClock, Game, logging), see vvvv-custom-nodes/services.md.
Creating your own library vs. contributing to one you don't own are different tasks. This SKILL.md and its design/publishing references cover creating and distributing a package. To change or submit a PR to an existing/upstream library (fork → branch → PR workflow, editable source packages, the .vl diff problem), see contributing.md.
vvvv recognizes a directory as a library when the folder name, .vl file, and .nuspec all share the same name:
VL.MyLibrary/ # Folder name = package name
├── VL.MyLibrary.vl # .vl document — MUST match folder name
├── VL.MyLibrary.nuspec # NuGet spec — MUST match folder name
├── lib/
│ └── net8.0/ # Compiled DLLs go here
│ └── VL.MyLibrary.dll
├── src/
│ ├── Initialization.cs # [assembly:] attributes + AssemblyInitializer
│ ├── Nodes/
│ │ ├── MyProcessNode.cs # [ProcessNode] classes
│ │ └── MyOperations.cs # Static methods (stateless nodes)
│ ├── Services/
│ │ └── MyService.cs # Per-app singletons
│ └── VL.MyLibrary.csproj
├── shaders/ # Optional: SDSL shaders (auto-discovered)
│ └── MyEffect_TextureFX.sdsl
└── help/ # Optional: .vl help patches
└── HowTo Use MyNode.vl
Critical conventions:
.vl file, and .nuspec must be identical (e.g., all VL.MyLibrary).csproj must output DLLs to lib/net8.0/ relative to the package root.vl file within a package should reference a .csproj — this forces the package into editable modeThe .csproj must compile into the library's lib/net8.0/ folder:
<PropertyGroup>
<TargetFramework>net8.0</TargetFramework>
<OutputPath>..\..\lib\net8.0\</OutputPath>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
</PropertyGroup>
A type becomes a node in vvvv's node browser when two conditions are both true:
public (and lives in an imported assembly).[assembly: ImportAsIs] / [assembly: ImportNamespace] declaration, OR the type is listed by an [assembly: ImportType] declaration.If either condition is false, the type is invisible to vvvv. Importing is opt-in by namespace, not by type accessibility alone. A public class in a namespace nobody imports is just as hidden from the node browser as an internal class.
When a type IS imported, vvvv generates nodes from its full public surface:
[ProcessNode] does NOT gate node visibility. It is purely lifecycle sugar — it tells vvvv "this is a stateful class with an Update() method, manage one instance per node, call Update() each frame". A plain public class Foo { public Foo() {} public int Bar(int x) => x; } in an imported namespace becomes a node browser entry exactly the same as one decorated with [ProcessNode]. The attribute affects how the node is invoked, not whether it appears.
Implication for library design: the primary lever for "what shows up in the node browser" is which namespaces you import, not which types are public. But note that importing is recursive — declaring one root namespace pulls in every namespace nested below it, so a .Internal sub-namespace is not a hiding place. See Excluding helpers from the node browser for the four levers that actually work.
Source: VL.StandardLibs ImportAsIsAttribute, Gray Book — Writing nodes using C#.
Every node library needs assembly-level attributes. Combine in one file:
using VL.Core;
using VL.Core.CompilerServices;
using VL.Core.Import;
// Required: tells vvvv to scan this assembly for nodes
[assembly: ImportAsIs(Namespace = "MyCompany.MyLibrary", Category = "MyLibrary")]
// Optional: register services before any node runs
[assembly: AssemblyInitializer(typeof(MyCompany.MyLibrary.Initialization))]
namespace MyCompany.MyLibrary;
public sealed class Initialization : AssemblyInitializer<Initialization>
{
public override void Configure(AppHost appHost)
{
var services = appHost.Services;
// Register per-app singletons (created lazily on first access)
services.RegisterService<MyService>(serviceProvider =>
{
return new MyService(serviceProvider);
});
}
}
vvvv provides three assembly-level attributes for declaring what becomes a node. Pick based on how much control you need.
[assembly: ImportAsIs] — single, namespace-rooted[assembly: ImportAsIs(Namespace = "VL.MyLib", Category = "MyLib")]
| Property | Behaviour |
|---|---|
| AllowMultiple | false — at most ONE per assembly |
| Scope | All public types in Namespace (and its children) |
| Category | Category parameter is the root; sub-namespaces below Namespace extend it |
Both parameters are optional; all four combinations are legal and each does something different:
[assembly: ImportAsIs] // the scaffolded default:
// nothing stripped, no root →
// VL.MyLib.Particles ⇒ "VL.MyLib.Particles"
[assembly: ImportAsIs(Namespace = "VL.MyLib")] // strip only →
// VL.MyLib.Particles ⇒ "Particles" (top level!)
[assembly: ImportAsIs(Category = "MyLib")] // root only →
// VL.MyLib.Particles ⇒ "MyLib.VL.MyLib.Particles"
[assembly: ImportAsIs(Namespace = "VL.MyLib", Category = "MyLib")] // ✅ strip + root →
// VL.MyLib.Particles ⇒ "MyLib.Particles"
The last form is what you almost always want. Use ImportAsIs when the whole library lives
under one root namespace and you want one root category. You cannot stack two ImportAsIs
to split sub-namespaces into different categories — it is AllowMultiple = false.
[assembly: ImportNamespace] — per-namespace, multi-use// ⚠️ Order matters — first declaration wins. Specific before general.
[assembly: ImportNamespace("VL.MyLib.Renderers", Category = "MyLib.Rendering")]
[assembly: ImportNamespace("VL.MyLib.Resources", Category = "MyLib.Resources")]
[assembly: ImportNamespace("VL.MyLib.Experimental", Category = "MyLib.Experimental")]
[assembly: ImportNamespace("VL.MyLib", Category = "MyLib")] // catch-all, LAST
| Property | Behaviour |
|---|---|
| AllowMultiple | true — declare as many as you need |
| Scope | Public types in that namespace and every namespace nested below it (recursive) |
| Resolution | First declaration wins, not longest prefix — declare specific before general |
Use when one library has multiple sub-namespaces and you want each to land in a distinct category — without polluting the browser with C# folder names. This is the right tool for multi-category libraries.
[assembly: ImportType] — per-type, hand-picked[assembly: ImportType(typeof(MyRenderer), Category = "MyLib.Rendering")]
[assembly: ImportType(typeof(MyResource), Category = "MyLib.Resources", Name = "Resource")]
| Property | Behaviour |
|---|---|
| AllowMultiple | true — declare as many as you need |
| Scope | Only the listed types — nothing else from the assembly is auto-imported |
| Use with | Either alone (no ImportAsIs/ImportNamespace) for closed-list libraries, or alongside the namespace attributes to override category/name for specific types |
Use for surgical control — e.g. when you want to expose only a curated subset of a large internal codebase, or to force one outlier into a different category than its namespace siblings.
| Library shape | Recommended attribute(s) |
|---|---|
| One namespace, one category, all public types are intentional | [assembly: ImportAsIs(Namespace, Category)] |
| One library, several distinct sub-categories | One [assembly: ImportNamespace] per sub-namespace |
| Curated set of nodes, lots of public helpers you don't want exposed | [assembly: ImportType] per node, no ImportAsIs |
| Mostly auto-imported, a few outliers | [assembly: ImportAsIs] + [assembly: ImportType] overrides |
This is the single most common way a library's node browser gets polluted, and the mistake is easy to make because the intent reads as obviously correct.
ImportFromNamespace in AssemblySymbolSource.cs
walks the namespace tree until it finds the node whose full name equals the declared
namespace, then calls ImportAll on it — and ImportAll recurses through every nested
namespace and nested type below that point.
[assembly: ImportNamespace("VL.MyLib", Category = "MyLib")]
namespace VL.MyLib; // → category "MyLib"
namespace VL.MyLib.Internal; // → category "MyLib.Internal" ← STILL IMPORTED
namespace VL.MyLib.Internal.Gpu; // → category "MyLib.Internal.Gpu" ← STILL IMPORTED
Putting helpers in a sub-namespace hides nothing. It only moves them into a sub-category, where the user still finds them by typing into the NodeBrowser search box — along with every public member of every one of those types.
A sibling namespace (VL.MyLibInternals) genuinely is not imported by
ImportNamespace("VL.MyLib")… except IsMatch is ns.StartsWith(Namespace) with no
boundary check, so ImportNamespace("VL.Foo") still false-matches VL.FooBar when
computing the category. Sibling-namespace partitioning is fragile. Use one of the four
real levers below instead.
Pick by who else needs the type.
internal — the type never enters VL at allImportAll bails on its first line:
if (s.DeclaredAccessibility != Accessibility.Public)
return;
An internal type has no node, no category, no member nodes, no IOBox, no serialisation
surface. There is nothing about vvvv left to reason about. This is the strongest and cheapest
lever — reach for it first.
If a test or examples assembly needs the type, keep it internal and add
[assembly: InternalsVisibleTo("MyLib.Tests")]. VL stays blind; C# can still see it.
[Smell(...)] — public in C#, filtered in the browserVL.Core.Import.SmellAttribute (AttributeTargets.All) is the per-type / per-member
opt-out. The type stays public and stays imported — it can still be a pin type, still flow
through links, still be referenced cross-assembly — it just isn't offered in the NodeBrowser.
using VL.Core; // SymbolSmell
using VL.Core.Import; // SmellAttribute
[ProcessNode]
[Smell(SymbolSmell.Internal)]
public sealed class SkiaRendererNode : IDisposable { /* ... */ }
That is real VL.StandardLibs code (VL.Skia/src/SkiaRendererNode.cs). See
Aspects from C# below for the full flag list.
⚠️ Import
SmellAttributefromVL.Core.Import. The identically-namedVL.Core.CompilerServices.SmellAttributeis[Obsolete(error: true)]and exists only for binary compatibility — using it is a compile error.
A category segment named Internal, Hidden, Advanced, Experimental, Obsolete or
Adaptive applies that aspect to everything in the category and is then stripped from the
resolved category name. Smell is inherited by every symbol in the category
(ImportedSymbol.GetSmell() starts from ParentCategory.Smell), so one declaration covers
an arbitrary number of types:
[assembly: ImportNamespace("VL.MyLib.Plumbing", Category = "MyLib.Internal")]
// every public type under VL.MyLib.Plumbing → category "MyLib", smell Internal
Because the sub-namespace tail is appended to Category and then keyword-parsed, a plain
C# namespace segment works just as well — no attribute needed:
[assembly: ImportNamespace("VL.MyLib", Category = "MyLib")]
namespace VL.MyLib.Particles; // → "MyLib.Particles", normal
namespace VL.MyLib.Particles.Advanced; // → "MyLib.Particles", Advanced aspect
namespace VL.MyLib.Internal; // → "MyLib", Internal aspect
This makes the C# folder layout and the vvvv category tree the same artefact, which is the most maintainable arrangement for a large library.
ImportType-only — closed allow-listSkip ImportAsIs/ImportNamespace entirely and list each user-facing type. Everything not
listed stays invisible regardless of namespace or accessibility.
[assembly: ImportType(typeof(Renderer), Category = "MyLib")]
[assembly: ImportType(typeof(Settings), Category = "MyLib")]
// Nothing else is imported.
Best for a small curated surface over a large internal codebase. Costs one line per node.
| Situation | Lever |
|---|---|
| Nothing outside this assembly needs it | internal (1) |
| Tests / examples need it, users must never see it | internal + [InternalsVisibleTo] (1) |
| It's a legitimate pin type or cross-assembly API, just not browsable | [Smell(SymbolSmell.Internal)] (2) |
| A whole namespace of plumbing | aspect keyword in the category / namespace (3) |
| Big codebase, tiny node surface | ImportType-only (4) |
Priority order (highest first):
[ProcessNode(Category = "X")] / [Category("X")] on the class itself — wins outright.[assembly: ImportType(typeof(T), Category = "X", NamespacePrefixToStrip = "...")] — per-type override.[assembly: ImportNamespace("X.Sub", Category = "Y")] — for types under X.Sub, if this attribute is the one that imported them (see first-wins below).[assembly: ImportAsIs(Namespace = "X", Category = "Y")] — the single whole-library default. It runs before every ImportNamespace, so it claims everything under X first.SymbolSmell.External. Aspect keywords are not parsed in this path.The two whole-library forms, for reference:
// Explicit — the recommended default. Strips "VL.MyLib", roots everything under "MyLib".
[assembly: ImportAsIs(Namespace = "VL.MyLib", Category = "MyLib")]
// Bare — what vvvv's own project template scaffolds. Nothing stripped, no category root:
// a class in VL.MyLib.Particles lands in the top-level category "VL.MyLib.Particles".
[assembly: ImportAsIs]
DirectImportSymbolSource imports in a fixed order and passes the already-imported list as an
exclusion set, so the first attribute to claim a type owns its category — later attributes
silently no-op on it:
if (importAsIsAttribute != null)
ImportFromNamespace(builder, …, importAsIsAttribute.Namespace, importAttribute: importAsIsAttribute);
foreach (var a in importNamespaceAttributes) // ← declaration order
ImportFromNamespace(builder, …, a.Namespace, alreadyImported: builder, a);
foreach (var a in importTypeAttributes)
ImportAll(builder, …, alreadyImported: builder, a);
// ImportAll, first two lines:
if (s.DeclaredAccessibility != Accessibility.Public) return;
if (alreadyImported != null && alreadyImported.Any(c => …Equals(c.Symbol, s))) return;
Order is: ImportAsIs → ImportNamespace in declaration order → ImportType.
Therefore: declare the most specific ImportNamespace FIRST. A broad root declaration
placed above narrower ones swallows everything and turns the narrower lines into dead code:
// ❌ BROKEN — the root line imports everything recursively; lines 2-3 never fire.
[assembly: ImportNamespace("VL.MyLib", Category = "MyLib")]
[assembly: ImportNamespace("VL.MyLib.Plumbing", Category = "MyLib.Internal")] // dead
[assembly: ImportNamespace("VL.MyLib.Config", Category = "MyLib.Settings")] // dead
// ✅ CORRECT — specific first, catch-all last.
[assembly: ImportNamespace("VL.MyLib.Plumbing", Category = "MyLib.Internal")]
[assembly: ImportNamespace("VL.MyLib.Config", Category = "MyLib.Settings")]
[assembly: ImportNamespace("VL.MyLib", Category = "MyLib")]
This failure is silent — no warning, no error. The symptom is sub-namespace names showing up
as node browser folders you thought you had remapped. To confirm where a type actually
landed, drop it in a patch, save, and read LastCategoryFullName out of the .vl XML.
For levels 3 and 4 (ImportAsIs / ImportNamespace), the resulting category is computed by GetCategory(typeNamespace) in VL.Core/src/Import/ImportAsIsAttribute.cs:
// Pseudocode of the actual VL.Core implementation:
root = Category ?? "";
if (typeNamespace == "") return root;
if (Namespace == "") cat = typeNamespace;
else if (typeNamespace.Length > Namespace.Length)
cat = typeNamespace.Substring(Namespace.Length + 1);
else /* typeNamespace == Namespace */ cat = "";
if (cat == "") return root;
if (root == "") return cat;
return $"{root}.{cat}";
What this means in practice — the non-obvious consequence:
| [assembly: ImportAsIs(...)] | C# namespace | vvvv category | Surprise? |
|---|---|---|---|
| Namespace = "VL.MyLib", Category = "MyLib" | VL.MyLib | MyLib | no |
| Namespace = "VL.MyLib", Category = "MyLib" | VL.MyLib.Particles | MyLib.Particles | no |
| Namespace = "VL.MyLib" (no Category) | VL.MyLib | "" (root) | yes — empty/root |
| Namespace = "VL.MyLib" (no Category) | VL.MyLib.Particles | Particles | yes — top-level |
| Namespace = "VL.MyLib" (no Category) | VL.MyLib.Internal.Helpers | Internal.Helpers | yes — top-level "Internal" leak! |
Without Category=, the prefix is just stripped — there is no fallback that prepends the last segment of Namespace. So [ImportAsIs(Namespace = "VL.MyLib")] with classes in VL.MyLib.Config puts them at top-level Config, NOT MyLib.Config. This is the most common surprise — always set Category explicitly unless you really want top-level pollution from sub-namespaces.
When debugging "why did my node end up at top-level Helpers instead of MyLib.Helpers?", check: did you forget the Category = parameter on ImportAsIs?
[Smell] and category keywordsAspects control NodeBrowser visibility and node status. The Gray Book documents them for patched symbols; from C# there are two ways to apply the same thing.
VL.Core.SymbolSmell is a [Flags] enum:
| Flag | Value | Effect |
|---|---|---|
| Default | 0 | normal |
| Obsolete | 1 | deprecated; kept for backwards compatibility |
| Experimental | 2 | unstable / WIP |
| Advanced | 4 | hidden from the NodeBrowser until the user enables the Advanced filter |
| Hidden | 8 | never offered in the NodeBrowser |
| Internal | 16 | only available inside the defining document / library |
| Adaptive | 512 | enrolls the node in adaptive type dispatch |
Advanced is the one library authors reach for most: the Gray Book's rationale is that
"any library developer provides a few super cool nodes and types that 90% of all users of a
library should use" — everything else goes Advanced rather than being deleted.
[Smell(SymbolSmell.X)] on the symbolusing VL.Core;
using VL.Core.Import;
[Smell(SymbolSmell.Experimental)]
public sealed class FormBoundsNotification { /* ... */ }
Applies to types, methods, properties — AttributeUsage(AttributeTargets.All).
A [Smell] on a type does not cascade to its members; set it per symbol, or use a
category (way 2) to cover a whole group.
Symbols.ToSmell recognises exactly these segment names, case-sensitive:
| Segment | Aspect |
|---|---|
| Internal | SymbolSmell.Internal |
| Advanced | SymbolSmell.Advanced |
| Experimental | SymbolSmell.Experimental |
| Obsolete | SymbolSmell.Obsolete |
| Hidden | SymbolSmell.Hidden |
| Adaptive | SymbolSmell.Adaptive |
The matching segment is removed from the resolved category, so MyLib.Particles.Advanced and
MyLib.Advanced.Particles both resolve to category MyLib.Particles with the Advanced
aspect. Smell is inherited: ImportedSymbol.GetSmell() starts from ParentCategory.Smell and
ORs the symbol's own [Smell] on top — so one keyword covers every type in the category.
⚠️ Keyword parsing only happens for attribute-supplied categories. In
GetCategoryAndSmellForFullName(name, isAssemblyCategory), the keyword loop is inside the
else branch —
Search for places (restaurants, cafes, etc.) via Google Places API proxy on localhost.
Interact with GitHub using the `gh` CLI. Use `gh issue`, `gh pr`, `gh run`, and `gh api` for issues, PRs, CI runs, and advanced queries.
Create or update AgentSkills. Use when designing, structuring, or packaging skills with scripts, references, and assets.
Start voice calls via the OpenClaw voice-call plugin.
Notion API for creating and managing pages, databases, and blocks.
Gemini CLI for one-shot Q&A, summaries, and generation.
Category:developer