M6d.Cortex
0.12.0
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
<PackageReference Include="M6d.Cortex" Version="0.12.0" />
<PackageVersion Include="M6d.Cortex" Version="0.12.0" />
<PackageReference Include="M6d.Cortex" />
paket add M6d.Cortex --version 0.12.0
#r "nuget: M6d.Cortex, 0.12.0"
#:package M6d.Cortex@0.12.0
#addin nuget:?package=M6d.Cortex&version=0.12.0
#tool nuget:?package=M6d.Cortex&version=0.12.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, 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;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.
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 | 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.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 |