Aventara is configured by one object, aventaraConfig, exported from src/aventara.config.ts. It is a plain value: it holds no adapter and opens no connection. Start from the generated file and add to it:
import { aventaraResources } from './generated/aventara/resources.generated.js';
export const aventaraConfig = {
api: '/api',
// Everything the adapter offers is exposed. List what to hide under
// `exemptions`, or set `exposed: false` and list what to expose instead.
// See: https://aventara.razor34.dev/framework/docs/exposure
exposed: true,
// Every Resource you define under src/resources/ (see "One Resource per file" below).
resources: aventaraResources.root,
} as const;Keep the as const: it keeps your Resource, field and operation names as literals, so TypeScript can check them against your model. A misspelled Resource or field name is a compile error.
The adapter is joined to the configuration where the framework is created. In a Nest project that is src/app.module.ts:
AventaraModule.forRootAsync({
imports: [PrismaModule],
inject: [PrismaService],
useFactory: async (prisma: PrismaService) => ({
config: {
...aventaraConfig,
adapter: await createPrismaAdapter({ ...adapter, client: prisma }),
},
}),
}),adapter is the default export of src/aventara.adapter.ts (Adapter below). Keeping it out of the configuration is what lets aventara-build run the configuration at build time without a database client (Deploying).
Each part has a task guide:
| To... | Read |
|---|---|
| Decide what each layer exposes | Exposure |
| Hide a field, make it write-only or read-only | Exposing and hiding fields |
Require sign-in, answer A4000/A4001 | Authentication and guards |
Check rows, fields and writes per principal with a policy engine (A4002) | Policies |
| Transform, audit, wrap or rewrite errors | Pipelines |
| Call other Resources, run SQL, write an outbox from a hook | The pipeline context |
| Check a value's length, range or format | Field validators |
| Add virtual fields, hash a password, compute a slug | Computed fields |
Give a Json field a type that is checked on every write | JSON fields |
| Tune limits | Limits and safety |
| Receive the real error behind a failure | Request IDs and diagnostics |
| Ship it | Deploying |
The shape
The root holds the keys only it can hold, and six layer keys. application and client are overlays that hold the same six layer keys and nothing else. agent holds them plus extends (below).
type AvConfig = {
// Root only
api: string; // where the protocol is mounted, e.g. "/api"
mcp?: string | { path: string; authorization?: { servers: string[]; verify } }; // turns MCP on
adapter: AvAdapter; // from createPrismaAdapter({ ...description, client })
prebuilt?: AvPrebuiltContracts; // Contracts compiled ahead of time, for serverless and edge cold starts
policy?: { engine: AvPolicyEngine; principal: (facts) => unknown }; // a policy engine for clients and agents
diagnostics?: AvDiagnosticsSink; // the real error behind every A3xxx answer
misuse?: AvMisuseSink; // server-side calls refused as unreadable or not plain data
application?: Layer; // overlay for your server-side code
client?: Layer; // overlay for remote callers
agent?: Layer & { extends?: "client" | "application" | "root" }; // AI agents over MCP
// The six layer keys: at the root, in application, in client and in agent
exposed?: boolean; // true: exemptions hide; false: exemptions are all that is exposed
exemptions?: AvConfigExemptions;
transactionsStyle?: "none" | "interactive";
limits?: Partial<AvContractLimits>;
pipelines?: AvPipeline; // stages around every operation of the layer
resources?: { [Resource: string]: AvConfigResource }; // everything per Resource
};Everything about one Resource lives under resources.<Resource>:
resources: {
Post: {
pipelines: { /* every Post operation */ },
operations: {
create: {
pipelines: { /* every create variant */ },
forceTransaction: true, // a plain create runs as a one-step transaction
one: { pipelines: { /* create.one only */ } },
},
},
transactions: { pipelines: { /* once per transaction with a top-level Post step */ } },
fields: {
title: {
definition: { /* root only: describe or add the field */ },
pipelines: { validators: [], pipes: [], compute: {}, guards: [] },
},
},
},
},Every name in the tree must exist: a Resource the model does not have (COMPILER_UNKNOWN_RESOURCE), an operation the adapter does not offer (COMPILER_UNAVAILABLE_OPERATION) or a field that is neither in the schema nor defined at the root (COMPILER_UNKNOWN_FIELD) stops startup with its path. Configuring a level does not expose it: what exists is decided by exposure alone.
Root and layer overrides
The root is the core layer. application and client are overlays on it: an application call sees the root plus application, a remote call the root plus client, never both. Each layer key combines with the root by its own rule:
| Key | Root and overlay |
|---|---|
exposed + exemptions | Narrowing only. The overlay starts from the root's view and can hide more; it can never bring back what the root hid. See Exposure. |
transactionsStyle | Override: the overlay's value when it sets one, otherwise the root's. |
limits | Override per limit: each limit the overlay sets wins; the others come from the root. |
pipelines, and pipelines at every level of resources | Additive: at each level the root's stages run, then the overlay's. Nothing removes a stage. |
field validators | Additive: the root's checks and the overlay's both apply to that origin. |
field compute | Chained: the root's stage runs, then the overlay's, which receives the root's result. |
field definition | Root only. In an overlay it is refused (CONFIG_FORBIDDEN_IN_OVERLAY). |
forceTransaction | Any layer: the operation is forced when the root or the operation's overlay sets it. |
The agent layer follows the same rules with the layer it extends in the root's place: it narrows that layer's view, and its stages run after that layer's chain.
An override wins when it is present, not when it is truthy: false overrides. An explicit undefined is refused; leave the key out instead.
Keys that moved
Configuration written for 1.0.0-rc.0 is refused at startup, with a CONFIG_MOVED_PROPERTY message that names the new location. In a configuration TypeScript checks against your model, the old key is a type error first:
| Before | Now |
|---|---|
restrictions | exposed + exemptions |
fields: { Post: { url: { ... } } } | resources: { Post: { fields: { url: { definition: { ... } } } } } |
behaviors: { Post: { fields: { slug: { create } } } } | resources: { Post: { fields: { slug: { pipelines: { compute: { create } } } } } } |
transactions: "none" | transactionsStyle: "none" |
entrypoint: "/api" | api: "/api" |
pipelines: { before, after } | pipelines: { hooks: { before, after } } |
The full migration is in Upgrading.
Adapter
The adapter comes in two steps. describePrismaAdapter states the adapter's facts from the discovery artifact that aventara:prepare generates. It takes no client and connects to nothing, so aventara-build can read it. The scaffold writes it to src/aventara.adapter.ts:
// src/aventara.adapter.ts
import { describePrismaAdapter } from "@aventara/prisma7-adapter";
import { discovery } from "./generated/aventara/discovery.artifact.js";
export default describePrismaAdapter({
discovery,
provider: "postgresql", // "sqlite" | "postgresql", declared, never sniffed
driver: "@prisma/adapter-pg", // or "@prisma/adapter-better-sqlite3"
});createPrismaAdapter builds the adapter from that description and the Prisma client you construct (with a driver adapter). It still checks the client against the artifact:
import adapter from "./aventara.adapter.js";
await createPrismaAdapter({
...adapter,
client: prisma,
transactionOptions: { timeout: 10_000, isolationLevel: "Serializable" }, // optional
});transactionOptions (maxWait, timeout, isolationLevel) belong to the adapter and are not part of Aventara's public configuration. The discovery artifact is regenerated by npm run aventara:prepare; run that whenever your schema changes.
Mount path: api
api is where the protocol is mounted ("/api", "/v1/data"). Routes are relative to it: GET /api/_contract, POST /api/_resources/User/find/many. It alone decides the mount point; Nest's global prefix does not move it. See NestJS host. Before rc.1 the key was entrypoint; that name is now refused with CONFIG_MOVED_PROPERTY ("entrypoint moved in rc.1: use api"). A frontend's entrypoint in framework.client.ts is a different setting, the server's full URL, and keeps its name.
The agent layer and MCP
mcp turns MCP on. Give it the path the MCP endpoint is mounted at, written like api:
api: "/api",
mcp: "/mcp",To require OAuth, give the path and an authorization: the authorization servers your callers get a token from (absolute https URLs, at least one), and verify, which checks one bearer token and resolves the caller, or null for a bad token:
mcp: {
path: "/mcp",
authorization: {
servers: ["https://auth.example.com"],
verify: async (token: string) => (await sessions.find(token)) ?? null,
},
},The path cannot be api itself or sit inside its _ routes. Each mistake stops startup with its path (mcp.path, mcp.authorization.servers[0], ...). The resolved endpoint is framework.mcp. @aventara/mcp serves it, mounted by AventaraModule: see MCP for the tools, the contract resource, authorization and connecting a client.
The tools an agent sees come from the agent layer. It holds the six layer keys, plus extends, the layer it starts from: "client" (the default), "application" or "root". Without an agent key, agents see exactly what the client layer exposes.
agent: {
extends: "client", // the default
exposed: true,
exemptions: {
resources: { User: { operations: { delete: true } } }, // agents never delete users
},
pipelines: { guards: [(ctx: AvContext) => ctx.$state.user !== undefined] },
},The guard is annotated (import type { AvContext } from "@aventara/core") because an inline stage in the as const configuration that aventara init writes has no contextual type (TS7006, see The pipeline context).
- It only narrows. The agent starts from the view of the layer it extends and can hide more, never less. With
exposed: falseit lists what to keep, and naming something its parent hid stops startup, naming the parent:field "User.passwordHash" is hidden by client, the layer agent extends, and the agent layer only narrows that view; expose it in client, or extend a layer that exposes it. - Stages add up along the chain. For an agent call the root's stages run, then those of the layer it extends (none for
"root"), then the agent's, level by level. Every stage seesctx.$origin === "agent". limitsandtransactionsStyleare the parent's unless the agent sets its own.- Its own contract. The layer compiles to
framework.contracts.agent, with its own hash. An agent that exposes exactly what the client exposes has the client's hash.
agent works without mcp: the layer and its contract exist, and nothing serves them until MCP is on. To serve it, see MCP.
Exposure
exposed and exemptions decide what each layer exposes. With exposed: true (the root's default) everything the adapter offers is exposed and exemptions lists what to hide; with exposed: false nothing is exposed and exemptions lists what to expose:
// Root: hidden from every origin.
exemptions: {
resources: {
User: { fields: { passwordHash: ["select", "filter", "order"] } }, // write-only
},
},
// Remote callers: no deletes on User, no internal notes.
client: {
exposed: true,
exemptions: {
resources: {
User: { operations: { delete: true }, fields: { internalNotes: true } },
},
},
},A hidden field disappears everywhere in that layer: inputs, outputs, filters, ordering, identifiers and nested projections. The served contract is the client layer, so a client exemption also removes the field or method from the generated frontend client; regenerate it afterwards. The whole grammar, the startup checks and the Pick style are on Exposure.
One Resource per file: defineResource
A Resource's configuration can live in its own module, its root block and its overlay blocks together, the way route modules split a router. In a scaffolded project you do not spread them by hand: aventara-build finds every defineResource result exported from src/resources/**/*.resource.ts and writes each layer's resources tree (see the generated tree below). defineResource<Model>()(name, rootBlock, { application?, client? }) returns { root, application?, client? }, each { [name]: block }. The root block is typed exactly as resources.<name> is typed in place: field names are checked against your model, stages are typed for the Resource, and a key that has no place there is a type error at the key. Each overlay block is typed as application.resources.<name> or client.resources.<name> is in place, against the root block: it can name a field the root block defines, it takes no definition, and a key that has no place in an overlay is a type error.
// src/resources/post.resource.ts
import { defineResource } from "@aventara/core";
import { AvZ } from "@aventara/core/avz";
import { discovery } from "../generated/aventara/discovery.artifact.js";
type Model = typeof discovery.model;
export const post = defineResource<Model>()(
"Post",
{
fields: {
title: {
pipelines: {
pipes: [(value) => (typeof value === "string" ? value.trim() : value)],
// /\S/ rather than minLength(1): the client checks " " before the trim, the server after it.
validators: [AvZ.regex(/\S/), AvZ.maxLength(200)],
},
},
},
},
{
// Remote callers only: they must send a token.
client: { pipelines: { guards: [(ctx) => Boolean(ctx.$transport?.headers["authorization"])] } },
},
);The title validators are chosen to give the same answer before and after the trim, because the generated client checks the value as written and the server checks it after the pipe. See Pipes that normalize.
The generated Resource tree
aventara-build writes the spreads you would otherwise write by hand to resources.generated.ts, beside the discovery artifact, and the configuration takes each tree once:
// src/aventara.config.ts
import { aventaraResources } from "./generated/aventara/resources.generated.js";
export const aventaraConfig = {
api: "/api",
exposed: true,
resources: aventaraResources.root,
client: { resources: aventaraResources.client },
} as const;root is always in the tree. application, client and agent are there only when some Resource gives that layer a block, so a layer you gave no block needs no line (user above has only root). A new Resource file needs no edit to the configuration. The build reads the files through TypeScript and never runs them. Two files that define the same Resource stop the build and name both; with hand-written spreads, the later one silently won. The glob is src/resources/**/*.resource.ts.
The tree is the same expression you would write, so the configuration is the same as written inline: the same contracts and the same ClientContract hash. If you prefer the spreads by hand, resources: { ...post.root, ...user.root } still works. Stages written inside a defineResource block need no type annotation.
Pipelines
Pipelines are server-side stages that run around operations: guards (allow or refuse), pipes (transform arguments), hooks (before / after), interceptors (wrap execution) and filters (rewrite errors). They go at the layer (pipelines), on a Resource, a family or a variant, and a field has its own validators, pipes, compute and write guards. They never appear in a contract. Every operation runs them in one order: guards, pipes, validators, interceptors, before hooks, the operation, after hooks, then the interceptors unwind; filters handle a failure (Pipelines).
import { AvError, type AvGuard } from "@aventara/core";
// Refuse every write from remote callers that carries no Authorization header.
const requireSignIn: AvGuard = (ctx) => {
if (ctx.$operation.family !== "find" && !ctx.$transport?.headers["authorization"]) {
throw new AvError("A4000", "Sign in first.");
}
return true;
};
export const aventaraConfig = {
api: "/api",
client: {
pipelines: { guards: [requireSignIn] }, // remote callers only
},
} as const;A caller that sends no Authorization header gets A4000 (HTTP 401); the generated client throws AvAuthError.
A stage receives a ctx with $operation ({ resource, family, variant }), $args (frozen: only a pipe changes the arguments, by returning new ones), $origin ("application", "client" or "agent"), $requestId, $contract, $state (the request's own writable object, such as the signed-in user) and, for remote requests, $transport.headers (lower-cased names; repeated headers joined). The rest of ctx calls the framework: ctx.<Resource>.<family>.<variant>(args), ctx.$sql and ctx.$transaction (The pipeline context). Guards return true/undefined to allow, false to deny with A4001 (FORBIDDEN), or throw an AvError with the code you choose (A4000 unauthenticated, A4001 forbidden, A4002 policy denied). A thrown A2004 may carry issues ({ path, code, message }); the path must name a member of the request's arguments, such as ["arguments", "data", "email"], and each message reaches the caller as written when it is non-blank and at most 1,000 characters (Pipelines).
Authentication and authorization belong in pipelines. Nest guards, interceptors and pipes never run on Aventara routes. See Authentication and guards, Pipelines and NestJS host.
Typing note: annotate a stage with its exported type (
AvGuard,AvPipe, ... as above) when you write it in a configuration that is anas constobject (the scaffold'saventaraConfig), or the parameter has no contextual type (Parameter 'ctx' implicitly has an 'any' type). The same goes for thediagnosticsfunction: annotate it(d: AvDiagnostic). Stages written inline increateFramework({ ... })or in adefineResourceblock are typed from where they sit. See Pipelines.
Limits
Limits bound what a caller may ask for:
| Limit | Default | What it bounds |
|---|---|---|
maxRequestBytes | 1048576 | Decoded request body size (A2010). |
maxNestingDepth | 12 | Nested projection, filter and relation depth (A2009 / V1013). |
maxListLimit | 250 | The limit of a list or nested relation (A2009 / V1014). |
maxTransactionOperations | 20 | Steps in one transaction plan (A2009 / V1014). |
maxBooleanNodes | 50 | Nodes in a where boolean tree (A2009 / V1015). |
maxJsonDepth | 64 | Levels of nesting inside a Json field's value (A2009 / V1021). |
limits: { maxListLimit: 100 },
client: { limits: { maxListLimit: 25 } },Client limits are part of the client contract, so changing them changes its hash. See Limits and safety.
Transactions
transactionsStyle: "interactive" (what the Prisma adapter offers by default) turns on POST /_transactions, the client's avClient.tx / avClient.transaction, and forceTransaction. "none" turns them off for a layer:
client: { transactionsStyle: "none" },forceTransaction: true on an operation family or variant runs a plain call of it as a one-step transaction, so its after hooks commit with the write (Pipelines). Under an effective transactionsStyle: "none" it is a startup error. See Transactions.
Fields: definitions and compute
resources.<Resource>.fields.<field>.definition describes a field, at the root only. It adds a scalar field your database does not have (a virtual field, with type, nullable, list, lifecycle: ["VIRTUAL"], capabilities and optionally a format), or gives a Json field the adapter discovered its shape (json). On a field the adapter discovered you may restate its own type, nullable and list, nothing else.
pipelines.compute attaches server-side code that computes a field's value on create, update or read: a hashed password, a slug, a display name. See Computed fields, Field validators and JSON fields.
Diagnostics
diagnostics is root-only: one function that receives the raw error behind every unplanned failure, with the request id. See Request IDs and diagnostics. Its companion misuse, also root-only and optional, receives one event for every server-side call refused because its input could not be read or was not plain data (Server-side usage).