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 path a documentation file takes: written next to the library's source, packed into the .nupkg, extracted into the consuming repository's .claude/skills/ folder by an MSBuild target, and read from disk by the agent.

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, not skills.targets. NuGet only auto-imports the one that matches. The install folder is a separate name (memory-introspect, the skill's name:), set by a property inside the targets.
  • buildTransitive/, not build/. 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 the PackageReference and walks up from there.
  • NoDefaultExcludes. NuGet silently drops dot-prefixed files, so .skills-version never made it into the package until we turned that off.
  • PackagePath="skills\", not PackagePath="skills\.skills-version". NuGet reads the dot-segment as a folder and appends the source filename, producing skills/.skills-version/.skills-version.
  • <None Remove> before <None Include>. Without it the SDK's default None items win, %(RecursiveDir) comes back empty, and references/ flattens into the root.
  • Walk up for .claude rather than using $(SolutionDir). That property is only defined when building through a .sln, and breaks under dotnet 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.

Read next

Articles on context graphs, enterprise search and industrial AI

Connected knowledge for AI systems