Shipping agent documentation inside the NuGet package
Rafael
Here is a line of C# that does not compile:
var pos = Float.Position.TopLeft;
It is wrong for a reason nobody can derive from the type signatures. Tesserae
apps open with using static Tesserae.UI;, which imports a factory method called Float,
and a method hides a same-named type. The compiler answers CS0119, "is a method, which is not
valid in the given context". The fix is to qualify it (Tesserae.Float.Position.TopLeft), and
the same applies to Dialog.Response, OmniBox.Config, SaveButton.State and a handful of
others.
A coding agent will write that line. So will a new developer. The difference is that the developer hits the error once, curses, and remembers. The agent starts every session having never seen it.
That is the actual problem with an LLM and a library it doesn't know well: not that it refuses to write code, but that it writes confident, plausible, wrong code, and you pay for the round trip. Training data is the wrong place to fix it, too. Even for a library a model has absorbed, what it absorbed is a version, from some point in the past, and it has no way to tell which one you referenced.
What we ship instead
Tesserae and Memory.Introspect both carry their own documentation, written for the agent, and install it into your repository when you build:
$ dotnet add package Memory.Introspect
$ dotnet build
Skills memory-introspect updated → 26.9.5430 in /src/myapp/.claude/skills/memory-introspect/
.claude/skills/memory-introspect/
SKILL.md # what the library is, a decision table, the reference index
references/
getting-started.md trace-collect.md profiles.md
cpu-sampling.md allocation-tracing.md allocation-call-stacks.md
gc-dump.md process-dump.md diagnostics-endpoint.md
… api-reference.md troubleshooting.md
.skills-version # 26.9.5430
Nothing to configure, and nothing to install beyond the package you already wanted. If your
project has no .claude folder anywhere above it, the whole mechanism sits out.
Why this shape and not a README
A skill is a folder with a SKILL.md
whose front matter carries a name and a description. That front matter is the entire
interface:
---
name: memory-introspect
description: Capture .nettrace traces, CPU sampling profiles, allocation reports, .gcdump heap
graphs and .dmp process dumps from .NET code with the Memory.Introspect NuGet package — the
in-process equivalent of dotnet-trace, dotnet-gcdump and dotnet-dump. Use when adding
self-profiling, diagnostics endpoints, leak hunting, allocation analysis or CI performance
capture to a .NET app, or when looking up the library's API. Per-capture-type references
live in references/.
---
Those 63 words are the only part always in context. The body of SKILL.md (890 words) is read
when the description matches what the agent is doing. The 20 files under references/ are read
one at a time, by name, when a specific question needs them.
The ratio is what makes this work at all. Tesserae's skill is 163 reference files, 67,500 words, 520 KB of Markdown (one page per component, plus the cross-cutting topics) behind a 53-word description. No context window holds that, and none has to. The root page is deliberately not a tutorial; it is a decision table and an index, and its last instruction is how to pick a reference:
To find a reference, match what you are trying to do against the index above; each file opens with the API signature, then the options that matter, then a working example.
A README can't do this. It is one blob, you read all of it or none of it, and the one on nuget.org isn't in the consumer's working tree where an agent is looking. An MCP server can do it, but now the consumer runs a process, and the package owns a network dependency. Files on disk have neither problem.
The two files that move it
The first is the .csproj, which packs the payload and stamps the version into it:
<ItemGroup>
<None Remove="skills\**" />
<None Include="skills\**" Pack="true"
PackagePath="skills\%(RecursiveDir)%(Filename)%(Extension)" />
<None Remove="buildTransitive\Memory.Introspect.targets" />
<None Include="buildTransitive\Memory.Introspect.targets" Pack="true"
PackagePath="buildTransitive\Memory.Introspect.targets" />
</ItemGroup>
<PropertyGroup>
<NoDefaultExcludes>true</NoDefaultExcludes>
</PropertyGroup>
<Target Name="_WriteSkillsVersion" BeforeTargets="Build;_GetPackageFiles">
<PropertyGroup>
<_SkillsVersionFile>$(IntermediateOutputPath).skills-version</_SkillsVersionFile>
</PropertyGroup>
<WriteLinesToFile File="$(_SkillsVersionFile)" Lines="$(PackageVersion)" Overwrite="true" />
<ItemGroup>
<None Include="$(_SkillsVersionFile)" Pack="true" PackagePath="skills\" />
</ItemGroup>
</Target>
The second is buildTransitive/Memory.Introspect.targets, which NuGet imports into every
referencing project automatically. It finds the .claude folder, compares versions, and copies:
<Target Name="_InstallClaudeSkills" AfterTargets="Build"
Condition="'$(DesignTimeBuild)' != 'true'">
<FindClaudeDir StartDirectory="$(MSBuildProjectDirectory)">
<Output TaskParameter="ClaudeDirectory" PropertyName="_ClaudeDir" />
</FindClaudeDir>
…
<RemoveDir Condition="'$(_NeedsInstall)' == 'true'" Directories="$(_SkillsTargetDir)" />
<ItemGroup Condition="'$(_NeedsInstall)' == 'true'">
<_SkillFiles Include="$(_SkillsSourceDir)**\*" />
</ItemGroup>
<Copy Condition="'$(_NeedsInstall)' == 'true' and '@(_SkillFiles)' != ''"
SourceFiles="@(_SkillFiles)"
DestinationFiles="@(_SkillFiles->'$(_SkillsTargetDir)%(RecursiveDir)%(Filename)%(Extension)')"
OverwriteReadOnlyFiles="true" />
</Target>
FindClaudeDir is a RoslynCodeTaskFactory inline task, seven lines of C# that walk up from
the project directory looking for a .claude folder.
The parts that cost a build or two
Most of the work was in MSBuild and NuGet edge cases, none of which are guessable:
- The targets file must be named after the
PackageId.Memory.Introspect.targets, notskills.targets. NuGet only auto-imports the one that matches. The install folder is a separate name (memory-introspect, the skill'sname:), set by a property inside the targets. buildTransitive/, notbuild/. A project that references a library that references Memory.Introspect gets the skill too. We verified this with a three-project chain: the target runs in the project holding thePackageReferenceand walks up from there.NoDefaultExcludes. NuGet silently drops dot-prefixed files, so.skills-versionnever made it into the package until we turned that off.PackagePath="skills\", notPackagePath="skills\.skills-version". NuGet reads the dot-segment as a folder and appends the source filename, producingskills/.skills-version/.skills-version.<None Remove>before<None Include>. Without it the SDK's defaultNoneitems win,%(RecursiveDir)comes back empty, andreferences/flattens into the root.- Walk up for
.clauderather than using$(SolutionDir). That property is only defined when building through a.sln, and breaks underdotnet build SomeProject.csproj. DesignTimeBuild != true. Otherwise the IDE churns your working tree every time it refreshes IntelliSense.- Delete before copying. The version marker gates the whole operation, and the folder is wiped first, so a reference file removed upstream actually disappears downstream. Same version in and out means the target does nothing, so incremental builds stay quiet.
The result is a 34-entry .nupkg, 22 of them documentation:
$ unzip -Z1 Memory.Introspect.26.9.5430.nupkg | sort
buildTransitive/Memory.Introspect.targets
lib/net10.0/Memory.Introspect.dll
lib/net6.0/Memory.Introspect.dll
…
skills/.skills-version
skills/SKILL.md
skills/references/allocation-call-stacks.md
skills/references/allocation-tracing.md
…
Keeping it true
A skill is documentation, so it rots exactly like documentation. There is no clever fix for
that, only a rule, and both repos put it in their CLAUDE.md next to the build instructions:
change the public surface and update the skill in the same commit. New component, new
references/<slug>.md and a line in the index. Renamed method or changed default, fix the
reference and the tables that quote it. Removed component, delete the page and the links
pointing at it.
Because the extracted folder is a build output, it gets treated as one: the Tesserae repository
gitignores .claude/skills/tesserae/, since its own test project consumes the package and would
otherwise stamp a copy over the source.
What this doesn't solve
.claude/skills/ is Claude Code's convention. An agent that reads AGENTS.md and nothing else
will walk straight past it. It is the mirror image of
anthropics/claude-code#6235, which asked
Claude Code to read the AGENTS.md layout the rest of the field has been converging on. Nothing
in the two files above is Claude-specific, though: the target copies a folder to a path, and the
path is a property, so a second layout for the non-Anthropic harnesses is a few more lines rather
than a redesign. That is what we intend to add as the conventions settle, so that referencing the
package gets you the documentation whichever agent you happen to be running.
The copy also only happens on Build, not Restore, so the first build after adding a package
is the one that installs the docs. And nothing here verifies that the prose matches the code: the
mechanism delivers whatever we wrote, correct or not.
What it does solve is the delivery problem. The documentation lives next to the code it
describes, goes through code review with it, and arrives in the consuming repository as a file on
disk: no server, no network call, no prompt to paste. And because the version marker gates the
copy, the pages sitting in your .claude/skills/ are the ones that shipped with the package
version you actually reference. Upgrade, build, and the docs move with the binaries.
Both are MIT-licensed, and the plumbing is one 87-line targets file plus twenty-odd lines
of .csproj that you can copy as-is:
memory-introspect and
tesserae.
Referenced by
Read next
Articles on context graphs, enterprise search and industrial AI