ThinkAgentKit.AspNetCore.Conformance
0.3.0
dotnet add package ThinkAgentKit.AspNetCore.Conformance --version 0.3.0
NuGet\Install-Package ThinkAgentKit.AspNetCore.Conformance -Version 0.3.0
<PackageReference Include="ThinkAgentKit.AspNetCore.Conformance" Version="0.3.0" />
<PackageVersion Include="ThinkAgentKit.AspNetCore.Conformance" Version="0.3.0" />
<PackageReference Include="ThinkAgentKit.AspNetCore.Conformance" />
paket add ThinkAgentKit.AspNetCore.Conformance --version 0.3.0
#r "nuget: ThinkAgentKit.AspNetCore.Conformance, 0.3.0"
#:package ThinkAgentKit.AspNetCore.Conformance@0.3.0
#addin nuget:?package=ThinkAgentKit.AspNetCore.Conformance&version=0.3.0
#tool nuget:?package=ThinkAgentKit.AspNetCore.Conformance&version=0.3.0
ThinkAgentKit for ASP.NET Core
ThinkAgentKit.AspNetCore is the host half of a ThinkAgentKit deployment. It
owns the security boundary a browser and a Cloudflare Worker meet at, and it
owns the tool capabilities a remote agent may invoke.
What it provides
- Connection grants. A short-lived ES256 token bound to exactly one route, one purpose, and one single-use identifier, published for verification through a rotating JWKS document.
- Authority leases. An opaque, revocable, hashed-at-rest credential the Worker stores in connection state. The browser never sees one.
- A tool kernel. Typed C# descriptors that are the single source of truth for names, schemas, permissions, approval metadata, timeouts, and idempotency, plus a canonical manifest and its hash.
Registration
builder.Services.AddThinkAgentKit(builder.Configuration);
// The four seams a host application must supply.
builder.Services.AddSingleton<IThinkAuthorizationService, MyAuthorizationService>();
builder.Services.AddSingleton<IThinkSigningKeyProvider, MySigningKeys>();
builder.Services.AddSingleton<IGrantRedemptionStore, MyRedemptionStore>();
builder.Services.AddSingleton<IAuthorityLeaseStore, MyLeaseStore>();
builder.Services.AddThinkToolCatalog(MyTools.CreateCatalog());
var app = builder.Build();
app.UseAuthentication();
app.UseAuthorization();
app.MapThinkAgentKit();
ThinkAgentKit:LeaseHashSalt and ThinkAgentKit:WorkloadCredential are
required secrets with no source-code default; startup fails without them.
Threat boundaries
| Boundary | What is trusted | What fails closed |
|---|---|---|
| Browser to host | The authenticated session, an allowed origin, and a double-submit antiforgery token | Any cross-site request, any route or project the current user cannot access, any lifetime beyond policy |
| Browser to Worker | Nothing. The Worker verifies the grant against published keys | Signature, time, purpose, route, protocol version, origin, or a replayed identifier |
| Worker to host | The Worker's own workload credential, plus a lease the host resolves server-side | An unknown, revoked, expired, or route-mismatched lease; a membership that has since been removed; a manifest hash that is not the active one |
| Approval | The verified identity recorded when the action was proposed | Anyone other than the requester, and any authority that is no longer current at execution time |
Claims and invocation envelopes are provenance, never authorization: every tool call re-asks the application whether this principal may still act on this project. Error responses are bounded stable codes, and no token, lease, cookie, or handler detail is ever logged or forwarded to the model.
Model messages on tool failures
An IThinkToolHandler.ExecuteAsync handler returns ToolInvocationResponse.
The original Failure(code, detail = null) signature remains available for
compiled handlers. Opt in to guidance written for the model with the explicit
three-argument overload; pass detail: null when no diagnostic detail is needed:
return ToolInvocationResponse.Failure(
ThinkErrorCodes.ToolInputInvalid,
detail: "Host-only diagnostic information",
modelMessage: "9 channels requested, the limit is 8 — split them across several requests.");
A Model message is optional plain text written for the model and must contain no
internals or secrets. detail remains host-only and never reaches the model. The
kit maps U+2028/U+2029 to \n, strips other control characters plus
U+202A–U+202E, U+2066–U+2069 and U+FEFF, and replaces lone surrogates with U+FFFD.
Emoji ZWJ sequences are preserved. It trims the text and caps it at 500 UTF-16
code units including …, trimming trailing whitespace after the cut and preserving
surrogate pairs. An
empty result is omitted. The Worker repeats this sanitation on the untrusted wire.
The model receives { ok: false, code, message }: the code's fixed sentence,
then one space and the Model message when present. Transport failures and
Worker-created refusals have only the fixed sentence.
Protocol version stays 1: modelMessage is an additive optional invocation
response field; existing consumers ignore it and responses without it keep their
existing behavior. The protocol's exact-version checks remain unchanged.
Artifacts
A tool that produces a figure, a result table or a download saves it as an Artifact on the host and returns only a reference. The bytes never travel in tool output - the model sees the reference, small, and a browser fetches the bytes from the artifact endpoint under its own session.
// Optional seam: where Artifacts are kept. Without one, saving throws and the
// endpoint knows no ids. The reference adapters live in ThinkAgentKit.AspNetCore.Testing;
// registered this way their size bound is ThinkArtifactOptions.MaxArtifactBytes.
builder.Services.AddDiskThinkArtifactStore(
Path.Combine(builder.Environment.ContentRootPath, "artifacts"));
// Optional policy; these are the defaults.
builder.Services.Configure<ThinkArtifactOptions>(options =>
{
options.MaxArtifactBytes = 10 * 1024 * 1024;
// image/png, image/svg+xml, text/csv, application/json, text/plain
options.AllowedContentTypes.Add("image/jpeg");
});
In a handler:
var chart = await context.SaveArtifactAsync(svgBytes, "image/svg+xml", "Pump rates", cancellationToken);
var table = await context.SaveArtifactAsync(csvStream, "text/csv", "Pump rates", cancellationToken);
return ToolInvocationResponse.Success(new { chart, table });
// or, for one file, the reference itself: Success(chart), with
// OutputSchema = ThinkSchema.ArtifactRef(). The Worker hands it to the model
// and the browser as { ok: true, id, contentType, title, bytes }.
and in the descriptor, so the output contract (and the generated zod module) says so:
OutputSchema = ThinkSchema.Object(
new Dictionary<string, ThinkSchema>
{
["chart"] = ThinkSchema.ArtifactRef("The chart, as an SVG image."),
["table"] = ThinkSchema.ArtifactRef()
},
["chart", "table"])
The public surface:
| API | What it is |
|---|---|
ThinkToolExecutionContext.SaveArtifactAsync(ReadOnlyMemory<byte> or Stream content, string contentType, string title, CancellationToken) |
Saves under the call's tenant, project and chat route, taken from the verified Lease - never from tool input or the envelope's conversation id - and answers a ThinkArtifactRef. |
ThinkArtifactRef(string Id, string ContentType, string Title, long Bytes) |
What an output carries. Serializes as { id, contentType, title, bytes }. |
IThinkArtifactStore |
SaveAsync(record, stream), FindAsync(id), OpenReadAsync(id). Stores keep records and never decide access; ThinkArtifactStoreConformance asserts the duties the endpoint relies on (exact tenant/project/route round-trip, ordinal ids, malformed ids answer nothing, exactly record.Bytes bytes or a refusal). |
ThinkArtifactOptions |
MaxArtifactBytes (default 10 MiB, at most 100 MiB) and AllowedContentTypes. HTML, XHTML, every XML type but SVG (text/xml, application/xml, text/xsl, any +xml), script types and multipart/x-mixed-replace are refused at startup. |
ThinkSchema.ArtifactRef(description) |
A closed object of the four fields, for output schemas. It flows into the manifest and the generated zod module like any object schema. |
InMemoryThinkArtifactStore, DiskThinkArtifactStore |
Reference adapters (.Testing), registered with AddInMemoryThinkArtifactStore() / AddDiskThinkArtifactStore(root). Both refuse an Artifact over their MaxArtifactBytes and keep the newest MaxArtifactsPerChat (50) per chat, evicting that chat's oldest, also under concurrent saves. Registered through those extensions the size bound is ThinkArtifactOptions.MaxArtifactBytes; constructed by hand it is 10 MiB unless given ThinkArtifactStoreLimits.From(options), and its refusal says so. Both verify the content length against record.Bytes. The disk store keeps {id}.bin beside {id}.json under one private root (never serve it as static files), for one process: a save that fails or is cancelled leaves nothing, and each save sweeps bytes no metadata names, so a crash cannot grow it unboundedly. Its retention reads every metadata file on each save. Neither bounds the number of chats; use them for development and tests. |
Saving refuses, with ThinkArtifactException, a content type outside the
allow-list, content over the limit and an empty title; titles are stripped of
control and bidi characters and capped at 200 characters. Ids carry 192 random
bits, base64url-encoded.
The artifact endpoint and its security model
MapThinkAgentKit() serves GET /api/think/artifacts/{id}. The request names
the chat the Artifact belongs to with the grant request's route fields:
?projectId=…&agentClass=…&agentInstance=…&path=…. Checks run in this order,
and every refusal before the lookup is decided without looking at the id:
- Cross-site protection: the double-submit antiforgery token is required (a
navigation, an
<img>or a form cannot send it), a presentOriginmust be allowed, andSec-Fetch-Sitemust not be cross-site -403 grant_cross_site. - The session -
401 grant_unauthenticated. - The route is canonical and of a supported class -
400 grant_invalid_route. - Live project authority,
IThinkAuthorizationService.AuthorizeRouteAsyncfor the named project and route -403with its code (grant_forbidden). A member removed a moment ago is refused here with a still-valid cookie. - The id resolves, and the Artifact's stored tenant equals the reader's and
its project and route equal the request's. An unknown id and another
tenant's, project's or chat's Artifact all answer one identical
404 not_found.
The parity is of status and body. It is not timing parity: a lookup that finds a record does a little more work than one that does not. Ids carry 192 random bits, so the endpoint is not a useful oracle, but do not read "identical refusals" as constant time.
A served Artifact carries Content-Type from the store,
X-Content-Type-Options: nosniff, Content-Disposition: attachment with a
sanitized ASCII filename, Content-Security-Policy: default-src 'none'; frame-ancestors 'none'; sandbox, and Cache-Control: no-store - not merely
private, because authority is decided live on every read and a copy in the
browser's HTTP cache would outlive a revocation or a sign-out. Even an SVG that
carries script is therefore inert as a document; the Angular tak-artifact
shows images only through an <img>, where SVG never runs script. A file a
person downloads is saved as an application/octet-stream blob; opening that
saved file later from disk happens outside the kit and outside the page's
origin, as with any other download.
Running the tests
Install Node 22 and run npm ci at the repository root first. The full host test
suite compiles generated TypeScript and verifies Zod and model JSON schemas using
the installed typescript, zod and ai packages. The NuGet publishing workflow
installs these prerequisites before running the same tests.
dotnet test # every host test
dotnet test --filter FullyQualifiedName~Security # one suite
Generating the Worker contract
npm run generate:host-tools # writes the manifest, Worker module and browser companion
npm run check:host-tools # regenerates twice and fails on any difference
An unsupported C# schema shape fails generation rather than emitting an
untyped wrapper. A descriptor's output schema, when it declares one, is emitted
beside its input schema as outputSchema (artifact references included); the
Worker runtime does not use it. Regenerate after upgrading the generator. Schema descriptions, including property and nested descriptions,
are emitted as Zod .describe(...) metadata and retained in model JSON schemas.
Tool and schema descriptions use escaped JSON string literals in TypeScript.
Tool presentation metadata
Set optional init-only properties on a descriptor alongside its model-facing
Description, for example DisplayName = "Fetch project facts" and
IconKey = "search". A display name must be nonblank, single-line and at most
120 UTF-16 units, without control characters, bidi embedding/isolate controls or
U+FEFF. An icon key must match [a-z][a-z0-9._-]* and contain 1–64
characters. Catalog construction rejects invalid values with the tool/property
identified in the error. Null omits the field; descriptors setting neither
property retain their exact prior canonical manifest bytes and hash.
The manifest and generated host-tools.ts carry those values. The generator also
emits an import-free host-tools.display.ts exporting hostToolDisplay for every
host tool and HOST_TOOL_MANIFEST_HASH. Consumer CLI commands can route the
companion with --display-out ui/src/app/generated/host-tools.display.ts; pass the
same option to host-tools check. Keep the .NET generator and Worker CLI versions
matched and commit all generated artifacts. Bind the companion map to Angular
[toolDisplay], and map icon keys to your icon set using [toolIconTemplate].
The kit provides a gear fallback, not an icon library.
Handlers may return an own top-level string summary (declare it in an output
schema when that schema is closed). Successful default cards render it as text,
even without display metadata. Angular prefers the bounded output and falls back
to raw output only when that value is nullish. It reads an 8,192 UTF-16-unit prefix,
strips bidi embedding/isolate controls and U+FEFF, folds whitespace to one line
and caps at 200 units including the ellipsis without
splitting surrogate pairs. Do not put HTML in the field expecting it to render.
These properties leave existing constructors and accessors intact. As members of a record they participate in value equality, hashing and printing; consumers comparing or snapshotting descriptors should account for populated display values. Metadata changes the manifest hash when present, so regenerate/deploy host and Worker together. Protocol version remains 1.
Package installation
ThinkAgentKit connects an ASP.NET Core host to the ThinkAgentKit Cloudflare Worker and Angular libraries.
- ThinkAgentKit.AspNetCore supplies host integration, security contracts and tool descriptors.
- ThinkAgentKit.AspNetCore.Testing supplies reference adapters and test helpers without a test framework dependency.
- ThinkAgentKit.AspNetCore.Conformance supplies xUnit suites for host implementations.
- ThinkAgentKit.ToolManifest is a local .NET tool that generates the Worker contract from a compiled host catalog.
Install the runtime with dotnet add package ThinkAgentKit.AspNetCore --version 0.3.0.
Install the generator with dotnet tool install ThinkAgentKit.ToolManifest --local --version 0.3.0 after creating a tool manifest with dotnet new tool-manifest.
Run the generator with dotnet tool run think-agent-kit-manifest -- <output-directory> <host-assembly> <Namespace.Type.CatalogMember>.
Use matching versions of the generator, host package and Worker package.
Requires .NET 10. Source and integration documentation: https://github.com/Polycrest-Labs/ThinkAgentKit
Licensed under MIT. Copyright (c) 2026 Polycrest Labs.
| Product | Versions Compatible and additional computed target framework versions. |
|---|---|
| .NET | net10.0 is compatible. net10.0-android was computed. net10.0-browser was computed. net10.0-ios was computed. net10.0-maccatalyst was computed. net10.0-macos was computed. net10.0-tvos was computed. net10.0-windows was computed. |
-
net10.0
- Microsoft.IdentityModel.JsonWebTokens (>= 8.23.0)
- ThinkAgentKit.AspNetCore.Testing (>= 0.3.0)
- xunit.v3.assert (>= 4.0.1)
- xunit.v3.extensibility.core (>= 4.0.1)
NuGet packages
This package is not used by any NuGet packages.
GitHub repositories
This package is not used by any popular GitHub repositories.