M6d.Cortex
0.6.0
See the version list below for details.
dotnet add package M6d.Cortex --version 0.6.0
NuGet\Install-Package M6d.Cortex -Version 0.6.0
<PackageReference Include="M6d.Cortex" Version="0.6.0" />
<PackageVersion Include="M6d.Cortex" Version="0.6.0" />
<PackageReference Include="M6d.Cortex" />
paket add M6d.Cortex --version 0.6.0
#r "nuget: M6d.Cortex, 0.6.0"
#:package M6d.Cortex@0.6.0
#addin nuget:?package=M6d.Cortex&version=0.6.0
#tool nuget:?package=M6d.Cortex&version=0.6.0
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 } }
-> { 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
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;dayOfWeekis 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.notkeeps 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.
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")
.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 and column Describe, which apply to the projected members.
Exclude, Describe, 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.
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.
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) or a description
past its limit 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 filter value that does not fit its column (1.5 on an integer
column, a date that does not parse), 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, and a wrong
server key answers forbidden under 401.
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.
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 identically.
| 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.EntityFrameworkCore.Relational (>= 10.0.12)
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.14.0 | 0 | 11/3/2026 |
| 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 |