M6d.Cortex 0.12.0

There is a newer version of this package available.
See the version list below for details.
dotnet add package M6d.Cortex --version 0.12.0
                    
NuGet\Install-Package M6d.Cortex -Version 0.12.0
                    
This command is intended to be used within the Package Manager Console in Visual Studio, as it uses the NuGet module's version of Install-Package.
<PackageReference Include="M6d.Cortex" Version="0.12.0" />
                    
For projects that support PackageReference, copy this XML node into the project file to reference the package.
<PackageVersion Include="M6d.Cortex" Version="0.12.0" />
                    
Directory.Packages.props
<PackageReference Include="M6d.Cortex" />
                    
Project file
For projects that support Central Package Management (CPM), copy this XML node into the solution Directory.Packages.props file to version the package.
paket add M6d.Cortex --version 0.12.0
                    
#r "nuget: M6d.Cortex, 0.12.0"
                    
#r directive can be used in F# Interactive and Polyglot Notebooks. Copy this into the interactive tool or source code of the script to reference the package.
#:package M6d.Cortex@0.12.0
                    
#:package directive can be used in C# file-based apps starting in .NET 10 preview 4. Copy this into a .cs file before any lines of code to reference the package.
#addin nuget:?package=M6d.Cortex&version=0.12.0
                    
Install as a Cake Addin
#tool nuget:?package=M6d.Cortex&version=0.12.0
                    
Install as a Cake Tool

M6d.Cortex

Cortex for ASP.NET Core, in three parts. M6d.Cortex.Datasets turns the application into a Cortex dataset host: you name the tables the agent may read and the rows each user may see; the package derives the columns and relations from your EF Core model, serves the manifest, and runs every query Cortex forwards, under the user's identity, inside the cap you set. M6d.Cortex.Server makes the application a server Cortex calls: the variables it fills, the native tools it runs, and optionally end-user token verification and approval events (Server). Datasets and the server answer a refusal in one error envelope (Errors). M6d.Cortex.Triggers is the client that fires Cortex triggers from the application and reads their firings back (Triggers).

GET  /api/datasets/          -> { version, datasets: [{ name, description, columns, relations, maxRows }] }
POST /api/datasets/{name}    <- { query, context: { threadId, requestId?, timeZone? } }
                             -> { rows, rowCount, truncated } | { error: { kind, detail } }

The wire protocol is defined once, in internal/contracts/src/datasets/datasets.ts; this package implements it the same way the TypeScript reference host in @m6d/cortex does, and the conformance fixtures in datasets.fixtures.json pin every shape's rows, and the queries both refuse, for both.

What a query can ask

A query names a dataset, joins related datasets by relation name (any depth, through for many-to-many) and reaches their columns as relation.column. Everything else is built from two shapes:

  • An operand is a column reference, a number, a text or boolean literal { value }, arithmetic { op, left, right } (division is exact), a date truncated to a period { fn: "dateTrunc", unit, of } (answered as the ISO date of the period's first day, weeks starting Monday) or one of its parts { fn: "datePart", unit, of } (an integer; dayOfWeek is 1 for Monday to 7 for Sunday), a scalar function { fn, args } (lower, upper, trim, length, abs, round, coalesce), or { case: [{ when, then }], else } whose branches share one type.
  • A filter is a typed condition over an operand (the right side a literal or { col: operand }), or { any }, { all }, { not } over filters. not keeps SQL's null semantics: a row whose comparison is unknown is left out either way.

With those, a query selects columns or named values { expr, as }, filters rows, groupBys columns or named values, aggregates (count, countDistinct, sum, avg, min, max over an operand, each with an optional filter over its own rows), filters the grouped rows with having, answers a window on every row (the same aggregates but countDistinct over the answered rows sharing the row's partitionBy values, all rows when empty: SQL's fn(col) OVER (PARTITION BY ...)), computes named values per row from the row's own names and the window aliases, filters the finished rows with qualify (SQL's QUALIFY, where a window or computed alias may appear; having refuses them), keeps the first n rows of each partition with rank (SQL's RANK() OVER (PARTITION BY ... ORDER BY ...) <= n), then sorts, offsets (only with a sort) and limits. Row keys are the column reference (department.name), the group key's or select value's alias, the aggregate alias, the window alias or the computed alias.

This package translates each of these into query expressions your EF Core provider runs in the database: dates as year, month and day arithmetic over the column, functions as the string and Math methods providers know, rank as a correlated count of the rows that sort ahead within the partition (so rows tied on every rank sort key share a rank and all stay, as RANK keeps them; name a unique column last to keep exactly n), a window as the same correlated subquery with the window's aggregate over the answered rows of the partition (an avg is taken over doubles, the number the wire carries), and compute as the aggregate and window expressions substituted into the arithmetic. qualify drops rows before rank numbers them.

A query can carry the IANA time zone of the user it runs for (context.timeZone). A DateTime column holds an instant in UTC, and the host reads it on that zone's clock, at the zone's offset now. A bare date in a filter is the user's midnight. A month or weekday is the user's. A DateTime answers with the offset, as in 2024-03-31T14:00:00-10:00. Only a period or part shifts the column in SQL, so a filter or sort on it keeps its index. A DateOnly, a DateTimeOffset and a date kept as text stay as stored.

Install

dotnet add package M6d.Cortex

Targets net10.0. Depends on Microsoft.EntityFrameworkCore and the Microsoft.AspNetCore.App shared framework; bring your own database provider.

Register and expose

using M6d.Cortex.Datasets;

builder.Services.AddCortexDatasets(datasets =>
{
    datasets.Expose("customers", (AppDbContext db) => db.Customers)
        .Describe("Everyone who has ordered.");

    datasets.Expose("orders", (AppDbContext db, ICurrentUser user) =>
            db.Orders.Where(order => order.TenantId == user.TenantId))
        .Describe("One row per order: who bought what, for how much, and when.")
        .Describe(order => order.Total, "Order total after quantity")
        .Values(order => order.Status, "pending", "shipped", "cancelled")
        .Exclude(order => order.InternalNotes)
        .WithMaxRows(1000);
});

var app = builder.Build();
app.MapCortexDatasets("/api/datasets");

Expose takes a delegate whose parameters are resolved from the request's service provider on every call, so a scoped DbContext and your own current-user service both work. The DbContext parameter is also where the EF Core model is read from when the routes are mapped, so the delegate itself never runs outside a request (the IServiceProvider overload has no such parameter and runs once at startup instead). The row type is inferred from the delegate's return; the manifest lists that entity's scalar properties as columns (string, number, boolean, date; binary, enum and nested-object properties are left out) and its navigations as relations, camelCased (CustomerId becomes customerId, Customer becomes customer).

The Where is your access rule. It runs before anything the query adds, so a caller can only ever narrow the rows you scoped, never widen them; a sort in the query replaces any order the scope carried. Throw CortexForbiddenException from the delegate to refuse a read outright.

Expose each entity as a dataset once. Two datasets that expose the same entity without a projection make every relation to it ambiguous, so MapCortexDatasets refuses to start; project one of them instead.

Relations

A reference navigation becomes relation: { dataset, on: [local, remote] } over its foreign key; a collection navigation the same, seen from the principal side; a skip navigation (many-to-many) gets a through naming the join entity. A relation appears only when the target entity is exposed as a dataset and both key columns are exposed. Queries reach related columns as customer.name; a path a reference names is joined whether or not join lists it. Any depth works (pet.tags.name), and collection joins flatten the rows the way a LEFT JOIN would.

Project

Replace the entity with a DTO when the table has more than the agent should see, or when you want computed columns:

datasets.Expose("orders", (AppDbContext db, ICurrentUser user) => db.Orders.Where(...))
    .Project(order => new OrderRow
    {
        Id = order.Id,
        CustomerId = order.CustomerId,
        Customer = order.Customer,
        Amount = order.Total * order.Quantity,
    })
    .Describe(row => row.Amount, "Total times quantity");

The DTO's scalar properties are the columns. A property assigned straight from a navigation (Customer = order.Customer) stays a relation, as long as the foreign key it joins on is projected too (CustomerId here). Write the projection as an object initializer (or an anonymous type); a constructor call hides which member each value feeds and is refused. Call Project before Exclude, column Describe and Values, which apply to the projected members.

Exclude, Describe, Values, WithMaxRows

Exclude(o => o.Secret) drops a column or a relation from the manifest, so no query can name it. Describe("...") sets the dataset description the model reads in the catalog; Describe(o => o.Total, "...") describes one column. Values(o => o.Status, "pending", "shipped") lists every value a string column can hold: the model reads them, and a query comparing the column to any other value is refused. WithMaxRows(n) caps a query's answer (default 500, at most 5000); the host clamps whatever limit the caller sent and answers truncated: true when more rows matched.

Definitions are checked against the contract when the routes are mapped: names match ^[a-zA-Z][a-zA-Z0-9_]*$ (at most 64 characters), a description is at most 500 characters, a column description 200, a dataset has 1 to 200 columns, and only a string column lists values, 1 to 100 of them, each 1 to 200 characters. A definition outside those limits fails startup with the reason.

Who is asking

Cortex sends the end user's token only when the host's registration forwards it, under the header the registration names. The host reads X-End-User-Token; change the header or strip a prefix through the options:

datasets.Options.UserTokenHeader = "Authorization";
datasets.Options.UserTokenPrefix = "Bearer ";
datasets.Options.QueryTimeout = TimeSpan.FromSeconds(10);

An ICortexUserResolver turns that token into the ClaimsPrincipal the request runs as; the package sets HttpContext.User to it before your expose delegate runs, so a current-user service over IHttpContextAccessor sees the right caller. There is no silent default: MapCortexDatasets fails at startup until a resolver is registered. Behind a gateway that already verified the token you can opt into the unverified one, which reads the JWT subject without checking the signature into the sub and name-identifier claims:

builder.Services.AddCortexDatasets(datasets =>
{
    datasets.UseUnverifiedJwtSubject();
    // ...
});

Anywhere else, register your own:

using M6d.Cortex; // CortexForbiddenException

public sealed class VerifyingResolver(JwtValidator jwt) : ICortexUserResolver
{
    public async ValueTask<ClaimsPrincipal> ResolveAsync(
        string? token, HttpContext httpContext, CancellationToken cancellationToken)
    {
        if (token is null) throw new CortexForbiddenException("An end-user token is required");
        return await jwt.ValidateAsync(token, cancellationToken)
            ?? throw new CortexForbiddenException("The end-user token is not valid");
    }
}

builder.Services.AddSingleton<ICortexUserResolver, VerifyingResolver>();

A null token means no header came (a headless run under a service credential); return an unauthenticated principal or refuse, as your rules say.

Registering the host in Cortex

In Cortex, add a dataset host with the URL you mapped (https://your-app.example/api/datasets) and the credential it should call you with (a service API key header, for instance). Cortex reads the manifest and lets you pick which of its datasets agents may query, which are pinned into every turn, and which agents see the host. From then on it runs their queries against POST {url}/{name} with the user's token in the identity header. Protect the mapped group the way you protect any service endpoint:

app.MapCortexDatasets("/api/datasets").RequireAuthorization("cortex-service");

Server

M6d.Cortex.Server serves the server protocol (internal/contracts/src/server/index.ts) the way defineServer in @m6d/cortex does, with the same JSON and error kinds:

GET  /api/cortex/                           -> { version, variables, tools, verify, events }
POST /api/cortex/variables                  <- { agent, thread, user, trigger? }      -> { values }
POST /api/cortex/tools/{name}               <- { input, context }                     -> { output } | { error: { kind, detail } }
POST /api/cortex/verify                     <- { token }                              -> { sub, claims } | { sub: null }
POST /api/cortex/events/approval_requested  <- the event                              -> 204
using M6d.Cortex.Server;

builder.Services.AddCortexServer(builder.Configuration, server => server
    .Variable("userName", "Display name of the signed-in user", "Sara",
        (request, httpContext) => ValueTask.FromResult(request.User?.Id ?? "guest"))
    .Tool("findOrder", "Find an order by its number.", readOnly: true,
        async (FindOrder input, ToolContext context, HttpContext httpContext) =>
            await httpContext.RequestServices.GetRequiredService<AppDbContext>()
                .Orders.SingleOrDefaultAsync(order => order.Number == input.Number))
    .Tool("refund", "Refund an order.", readOnly: false, RefundAsync,
        requiresApproval: true)   // every call waits for the end user's approval; the input cannot have an approvalTitle property
    .Verify((token, httpContext) => VerifyAsync(token))     // optional: agents whose auth is this server
    .OnApprovalRequested((approvalRequested, httpContext) => NotifyAsync(approvalRequested)));

var app = builder.Build();
app.MapCortexServer("/api/cortex");

public sealed record FindOrder([property: Description("The order number, such as SO-1042")] string Number);

Every route answers 401 unless the bearer is the server key, compared in fixed time before any of your code runs. The key is ServerKey in the Cortex configuration section (pass a section name for another); a blank one fails at MapCortexServer. /verify and /events/approval_requested exist only when declared, and the manifest says which.

A variable's type follows its resolver: string, bool or any number. A tool's input and output schemas are the JSON schemas of its input and output types, with [Description] attributes as descriptions; an input that does not deserialize is refused before the tool runs (Errors). Handlers get the request's HttpContext for scoped services and RequestAborted; the run's token (X-End-User-Token), when it has one, is request.Token or context.Token. Variables resolve one at a time, so they can share a scoped DbContext. A name Cortex would refuse (not an identifier, a built-in tool or variable, declared twice), a description past its limit, an approval tool whose input leaves no room for the approvalTitle Cortex adds (a property or required field of that name, on the input or a derived type of a polymorphic one, or a closed sub-schema), or a 101st variable or 501st tool throws while registering.

In Cortex, register the server with the URL you mapped and the same key; Sync reads the manifest.

Errors

Datasets and the server refuse a request the same way, with the envelope { error: { kind, detail } } under the kind's HTTP status:

Kind Status When
invalid_request 400 The request does not fit the manifest, or its body does not parse
unsupported 400 The request is valid but the application cannot serve it
forbidden 403 Your code throws CortexForbiddenException
timeout 504 A query outlives QueryTimeout, or a server call is cancelled
internal 500 Anything else

Anything a dataset query gets wrong against the manifest answers invalid_request with the same message Cortex produces. So does a body that is not JSON, a query nested past 32 levels, a key spelled in another case or an explicit null where a key may be left out, a filter value that does not fit its column (1.5 on an integer column, a date that does not parse), a query whose expression would pass 50,000 nodes once translated, and a tool input that does not deserialize (a missing or null required property, a number in quotes). The package reads a date value without an offset as UTC. An unknown dataset answers invalid_request; an unknown tool answers it under 404, a server call whose body passes 4 MiB or a dataset query whose body passes 256 KiB under 413, and a wrong server key answers forbidden under 401. A server call for an agent name or a user id Cortex never sends is invalid_request before your code runs.

To refuse from your own code, throw a CortexException (in M6d.Cortex) from an expose delegate, a user resolver, or a variable, tool, verifier or event handler. Cortex gets its kind and message. CortexForbiddenException and CortexUnsupportedException cover the common two kinds; internal is not one you can throw. Any other exception is logged and answered as internal with a fixed detail, so its message never leaves the process.

Triggers

M6d.Cortex.Triggers is the caller's side of a Cortex trigger: the trigger's secret as the bearer, 2xx and 422 parsed into one Firing record, as is the 409 of a firing dropped with reason approval_pending because a call on its single or keyed thread still awaits a decision; anything else is thrown as CortexTriggerException with its status (null when the body, not the status, was the problem). Secrets are keyed by trigger name under one configuration section, so no trigger is special to the client. Triggers fired with a shared inbound credential take its value once, as TriggerSecret; a trigger with a non-blank entry under TriggerSecrets uses its own.

{
    "Cortex": {
        "Url": "https://engine.cortex.example.com",
        "Agent": "reviewer",
        "TriggerSecret": "…",
        "TriggerSecrets": { "pr-opened": "cortex_trigger_…" }
    }
}
services.AddCortexTriggers(configuration); // the "Cortex" section; pass a name for another

public sealed class ReviewJob(ICortexTriggers triggers)
{
    public async Task Run(object pullRequest, CancellationToken cancellationToken)
    {
        if (!triggers.CanFire("pr-opened")) return; // no URL or no secret configured

        var firing = await triggers.Fire("pr-opened", pullRequest, TimeSpan.FromSeconds(10), cancellationToken);
        if (firing.Outcome == FiringOutcome.AwaitingApproval)
        {
            // The run parked on a tool; decide each call and the run resumes under the same wait.
            var call = firing.Approvals[0];
            firing = await triggers.Answer("pr-opened", firing.Id, call.ToolCallId, approved: true, TimeSpan.FromSeconds(10), cancellationToken);
        }

        if (firing.IsRunning)
        {
            // Outlasted the wait: read it back later, until it ends.
            firing = await triggers.Read("pr-opened", firing.Id, cancellationToken);
        }

        if (firing.Outcome == FiringOutcome.Completed && firing.Output is { } output) { /* the validated answer, or the run's last text */ }
    }
}

Fire and Answer hold the request up to the wait, then 30 s more for the answer; Read has the 30 s alone. A Firing carries the outcome (Started, AwaitingApproval, Completed, Failed, Rejected, Dropped), the Output once completed (the validated answer, or the run's last text as a JSON string when the trigger declares no output schema), the Reason a failure, drop or rejection names, and the Approvals a parked run waits on, from a fire's answer and from a Read alike. Url is the engine's public origin (ENGINE_PUBLIC_URL) and must be an absolute http or https URL (validated at startup). A secret's key must spell the trigger name exactly, since the name goes into the route. An empty or blank secret counts as none, so a placeholder in configuration leaves the trigger unfired and never sends an empty bearer.

Management API

M6d.Cortex.Management calls Cortex's management API with a management key: every procedure is a typed method in its router's group, management.<Router>.<Procedure>(input, cancellationToken), generated from the procedures of the Cortex release this package ships with. A query is sent as a GET with its input on the query string, a mutation as a POST with its input as the JSON body. @m6d/cortex has the same client for TypeScript, held to the same requests and refusals.

{
    "Cortex": {
        "Management": { "Url": "https://cortex.example.com", "Key": "cortex_mgmt_…" }
    }
}
services.AddCortexManagement(configuration); // the "Cortex:Management" section; pass a name for another
// Optional: the IANA zone a day range is read in, asked on every call, else UTC days.
services.AddHttpContextAccessor();
services.Configure<CortexManagementOptions>(options =>
    options.TimeZone = provider =>
        provider.GetRequiredService<IHttpContextAccessor>().HttpContext?.Request.Headers["timezone"].ToString()
);

public sealed class AssistantSettings(ICortexManagement management)
{
    public async Task SaveGreeting(Guid agentId, string english, CancellationToken cancellationToken)
    {
        var agent = await management.Agents.Get(new() { Id = agentId }, cancellationToken);
        try
        {
            // Each setting sent is replaced whole, so the other locales go along;
            // the settings left out keep their values.
            await management.Agents.SaveSettings(
                new()
                {
                    Id = agentId,
                    Greeting = new Dictionary<string, string>(agent.Greeting) { ["en"] = english },
                },
                cancellationToken
            );
        }
        catch (CortexManagementException refused) when (refused.Status == HttpStatusCode.Forbidden)
        {
            // refused.Code "FORBIDDEN", refused.Message "read_only",
            // refused.Detail { "error": "read_only", "fields": [...] }, refused.Refusal: Cortex's sentence
        }
    }
}

An input's optional field is an OptionalField<T>: left unset it is never sent, so a patch changes only what it sets, and set to null it is sent as null (Auth = null clears an agent's auth, where leaving Auth out keeps it). A union told apart by a field (an auth's kind, a firing's outcome) is an abstract record with one record per case; any other open value is an object? you send or a JsonElement Cortex answers. A refusal throws CortexManagementException with the status, the tRPC code, the refusal's literal as its message, what it names as Detail, and its sentence as Refusal. Url, the console's origin as an absolute http or https URL, and Key are required: either missing fails at startup. Every call sends the request's CultureInfo.CurrentUICulture as Accept-Language, so Cortex words its refusals in the caller's language, and TimeZone's answer as X-Cortex-Timezone.

Development

The package is driven through bun like the rest of the repo. scripts/dotnet.ts finds the SDK, refuses one other than the version global.json pins (10.0.300), and runs dotnet with telemetry and the banner off.

bun run --filter @m6d/cortex-dotnet build    # dotnet build, Release
bun run --filter @m6d/cortex-dotnet test     # dotnet test over SQLite in-memory
bun run --filter @m6d/cortex-dotnet lint     # csharpier check
bun run --filter @m6d/cortex-dotnet format   # csharpier format
bun run --filter @m6d/cortex-dotnet check    # build + lint
bun run --filter @m6d/cortex-dotnet pack     # nupkg into artifacts/

Every warning is an error. Directory.Build.props turns on nullable reference types, AnalysisLevel latest-all, EnforceCodeStyleInBuild, and XML docs; .editorconfig raises StyleCop, Sonar, Roslynator and Meziantou rules to error along with the naming and language style rules, and lists the rules that are off with a one-line reason each (formatting belongs to CSharpier, private members need no XML docs, a few rules fight records and primary constructors). Formatting is CSharpier's, through the tool manifest in .config/dotnet-tools.json; the root lint-staged config formats staged .cs files with it.

The tests run the dataset host on ASP.NET Core's test server over SQLite in-memory and include the shared conformance fixtures from internal/contracts/src/datasets/datasets.fixtures.json, which every dataset host implementation answers, and refuses, identically.

Product 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. 
Compatible target framework(s)
Included target framework(s) (in package)
Learn more about Target Frameworks and .NET Standard.

NuGet packages

This package is not used by any NuGet packages.

GitHub repositories

This package is not used by any popular GitHub repositories.

Version Downloads Last Updated
0.13.0 42 11/2/2026
0.12.0 56 10/29/2026
0.11.0 78 10/28/2026
0.10.0 86 10/28/2026
0.9.0 81 10/27/2026
0.8.0 95 10/25/2026
0.7.0 94 10/25/2026
0.6.0 88 10/22/2026
0.5.0 88 10/21/2026
0.4.0 97 10/14/2026
0.3.0 96 10/12/2026
0.2.1 93 10/11/2026
0.2.0 102 10/11/2026
0.1.0 106 10/3/2026