Skip to content

Lexicon Authoring Overview

llms.txt lists every page for an agent

A lexicon is a plugin that adds one platform to chant, such as CloudFormation or Kubernetes. It contributes the platform’s types, a serializer, and the rules that check the output. You can write one for any platform or internal tool.

LexiconPlugin entry point connected to three capability clusters: Synthesis (types, serializer), Quality (lint rules, post-synth checks), and Integration (LSP, MCP, Skills) LexiconPlugin entry point connected to three capability clusters: Synthesis (types, serializer), Quality (lint rules, post-synth checks), and Integration (LSP, MCP, Skills)
LexiconPlugin capability layers

You’re in the right place if you want to:

  • Add support for a new cloud provider (GCP, Azure, Kubernetes)
  • Create an internal lexicon for your organization’s infrastructure patterns
  • Extend chant with custom resource types and lint rules

If you’re using an existing lexicon (like AWS CloudFormation), see the User Guide instead.

Every lexicon exports a LexiconPlugin object:

import type { LexiconPlugin } from "@intentius/chant";
export const myPlugin: LexiconPlugin = {
name: "my-lexicon",
serializer: mySerializer,
// ... lifecycle methods
};
FieldTypeDescription
namestringThe resolution key. Core imports @intentius/chant-lexicon-<name> and users write lexicons: ["<name>"] (packages/core/src/cli/plugins.ts)
serializerSerializerSerializer for build output

Four methods, all four required. isLexiconPlugin() in packages/core/src/lexicon.ts accepts an object only when name, serializer and these four are present, so a plugin missing one is rejected at load rather than at first use.

MethodSignatureDescription
generate(options?: { verbose?: boolean }) => Promise<void>Generate lexicon artifacts (types, registry, runtime index) from the upstream spec
validate(options?: { verbose?: boolean }) => Promise<void>Validate generated artifacts
coverage(options?: { verbose?: boolean; minOverall?: number }) => Promise<void>Analyze spec coverage across resource dimensions
package(options?: { verbose?: boolean; force?: boolean }) => Promise<void>Package the lexicon into a distributable bundle

Every optional member of LexiconPlugin. lexicon-doc-coverage.test.ts parses the interface and asserts this table lists all of them, so a member added without a row here fails the build (chant #1347). The table had previously drifted to 16 of roughly 30, losing the whole observation family beyond the two best-known readers.

Lint and audit

MemberReturnsDescription
lintRules()LintRule[]Imperative lint rules
declarativeRules()RuleSpec[]Declarative lint rules (compiled via rule()). No shipped lexicon uses this — see Write Lint Rules
propertyClassNames()string[]Class names whose instances are property-kind declarables (createProperty). The core COR001, COR004 and COR009 heuristics leave them out, since they live inside the resource that holds them (#2957). Grafana implements it for its panels, rows, queries and variables
postSynthChecks()PostSynthCheck[]Post-build validation checks
activityContracts()ActivityContract[]Args and return schemas for the Op activities this lexicon implements, so another lexicon’s post-synth check can validate a step that calls one (#2101). Rarely needed: the ordinary route is exporting them from @intentius/chant-lexicon-<name>/op/activity-contracts, which loadActivityContracts resolves by convention, the same way loadActivities resolves the implementations. This member is for a lexicon whose contracts are not reachable at that subpath
activities()Record<string, ActivityFn>The Op activity implementations, keyed by the name a step’s fn uses (#2520). Read only for a lexicon declared by module path in chant.config.ts, which has no op/activities subpath. A package-backed lexicon leaves it out
coverageReport()Promise<{ unaccountedKinds?: string[] }>Offline kind accounting from committed snapshots. chant dev check-lexicon fails when a lexicon that declares it leaves any upstream kind unaccounted (chant #1330)
auditCatalog()Record<string, RuleMeta>Title, tier, fix kind and authority for this lexicon’s checks, for chant audit — see Post-Synth Checks
auditEntities(content)Map<string, Declarable> | Promise<Map<string, Declarable>>Parse standalone manifest content into the entity graph so entity-reading checks fire on chant audit (parse-to-graph). May be async for a lexicon whose parser inherently is (e.g. a wasm-backed HCL parser). Must tolerate malformed input
lintPresets()Record<string, string[]>Preset name mapped to the post-synth check ids that preset enables, e.g. recommended/all. A project selects one via lint.presets (chant #2113). See Post-Synth Checks

Language surface

MemberReturnsDescription
intrinsics()IntrinsicDef[]Lexicon-specific intrinsic functions
pseudoParameters()string[]Pseudo-parameter names
completionProvider()CompletionItem[]LSP completions for resource/property names
hoverProvider()HoverInfo | undefinedLSP hover information for resource types
codeActionProvider()CodeAction[]LSP code actions. No shipped lexicon uses this
symbolResolvers()SymbolResolver[]Resolve path#symbol in this lexicon’s languages for chant workspace graph --intent, such as a table in a .sql file, for a file in a member that configures the lexicon (#3313). See Symbol resolvers

Import and migration

MemberReturnsDescription
detectTemplate(data)booleanDetect whether a parsed template belongs to this lexicon
templateParser()TemplateParserParser for importing external templates
parserOptions()ParserOptionSpec[]Options templateParser takes, set with chant import --parser-option
templateGenerator()TypeScriptGeneratorGenerator for converting IR to TypeScript
embeddedImporters()EmbeddedContentImporter[]Import this lexicon’s content where another lexicon’s resource holds it: otel collector configs in a k8s ConfigMap, prometheus rule groups in a PrometheusRule and alertmanager.yml in a ConfigMap, grafana dashboards in a ConfigMap. Core finds the owner at run time, so the host needs no dependency on it (#2962). See Implementing Import
agentConfigImporter()AgentConfigImporterRe-express local agent configuration (skills, MCP servers, instruction files) discovered by chant audit --agents as this lexicon’s resources — fountain implements it for Agent/Environment
migrationSource(from)MigrationSource | undefinedTranslate another lexicon’s format into this one — gitlab and forgejo implement migrationSource("github")

Observation — see Implementing Observation

MemberReturnsDescription
describeResources()DescribeResourcesResultWhat this estate manages, keyed by chant entity name. The thin read
observeResourcesDeep()DeepObservationResultThe full live property tree per entity, for property-level drift
deepNormalizationHooksDeepNormalizationHooksWhich fields are server-populated or order-insensitive, applied to both the live and declared trees
healthTypesreadonly string[]The entity types observeHealth() reads, as data, so core picks the entities without calling it (chant #4177)
observeHealth()HealthReadResultWhether each declared service of those types is up, for chant lifecycle health. A failed read is unobserved, never unhealthy
predictBehaviour()BehaviourResultCost per hour, headroom, an error-rate expectation, a resilience verdict and a right-size hint per entity, at a stated traffic level, every figure carrying its engine, version, tolerance and modeled/validated basis. The one member of this family that reports a prediction rather than a reading, and the type keeps the two apart: money exists only as a per-hour rate for an imagined hour, the options cannot carry a credential, and a missing, unreachable, unfunded or throttled engine returns a named refusal instead of an empty report. Options mirror observeResourcesDeep()’s plus traffic and edges, the latter carrying IREdge so the declared and live paths hand the engine the same graph shape (chant #2356)
behaviourKindsBehaviourKindsWhat this lexicon’s own entity types are worth to a behaviour engine: mapped rows giving an engine kind and the declared property its size is read from, unmapped rows stating that a real type carries no rate and why, or nothingPriced saying it once for the whole substrate — a CI workflow is not an estate. Rows rather than a method, because core does the predicting and a lexicon only says what its types mean; a type this lexicon claims and has no row for is reported unknown-type, the one verdict that is a defect rather than a decision. Where rows are keyed on something other than the entity type — terraform’s every resource block arrives as Terraform::Resource — resolveType names the key (chant #2382)
observeDependencies()DependencyObservationWhat the estate relies on but does not declare — a shared subnet, another team’s network
classifyDisruption()Record<string, DisruptionVerdict>Whether applying a pending update replaces the resource or mutates it in place, from the spec this lexicon compiled. Every degradation is unknown
ambientKinds()string[]Kinds this lexicon can enumerate beyond the declared estate, so a caller knows --ambient is relevant without paying for a scan
observeAmbient()Record<string, ResourceMetadata>Resources of a managed kind that exist while being neither declared nor referenced — the unattached security group
describeStackStatus()StackStatusObservation | nullWhether one deploy unit is present and healthy, by deployed name. For multi-stack component projects
describeIdentity()DescribeIdentityResultThe principal chant would act as here and the scope it resolves to, from the substrate’s own self-query. Read-only, and never a credential — see chant lifecycle whoami
teardownOwned()TeardownEnumerationWhat this lexicon would delete for one marker identity (stack + env) — enumeration only; unreadable kinds report as holes
executeTeardown()TeardownExecutionDelete the handed-over teardown candidates, one outcome per candidate (deleted / failed / not-prunable / retained with reason) — core retries failures once
listArtifacts()Record<string, ArtifactMetadata>Runtime artifacts rather than 1:1 resources — Helm releases, Docker containers
ownershipChannelOwnershipChannelWhere this lexicon can stamp and read chant’s ownership marker, per read path
referenceCatalogReferenceCatalogHow observed resources reference each other, so chant graph --live can rebuild edges from a bag of nodes
enrichLiveAttrs()Record<string, Record<string, unknown>>Extra node attributes when describeResources metadata is too thin to carry references
exportResources()ExportedTemplateFull-fidelity config read from a live API — see Implementing Live Export
subscribeChanges()ChangeSubscriptionSubscribe to the substrate’s own change stream so chant operator can wake a tick early. onChange takes no arguments, so a notification can never become an observation; only implement it where the stream needs nothing deployed into the observed account. The verdict table in the operator guide records where that holds

Project surface

MemberReturnsDescription
initTemplates(template?)InitTemplateSetSource file templates for chant init scaffolding
skills()SkillDefinition[]AI assistant skills
docs()Promise<void>Generate documentation pages
mcpTools()McpToolContribution[]MCP tool contributions — see LSP & MCP Providers
mcpResources()McpResourceContribution[]MCP resource contributions
composites()CompositeEntry[]The composites the lexicon exports, as static data: name, description, the resource kinds each bundles, and its parameters. The MCP composites tool, the chant://composites resource and search read it. npm run generate:composite-catalogs writes it to src/composites/catalog.ts
commands()CommandGroupA CLI verb group mounted under chant <name> <verb> — core dispatches to it wholesale and knows nothing about what’s inside. <name> cannot be one of core’s own command words or workspace, which core reserves; a plugin that tries fails to load with an error naming the word
changedUnits(ctx)Promise<string[]>The units a change touched, from the changed file paths, for a lexicon whose units are not chant source. The terraform lexicon answers with the terraform.roots whose directory, local modules or var files changed. chant components fan-out --base and the pull-request loop read it (#3183)
emulatorEmulatorCapability or a list of themLocal emulator(s) chant emulator boots — see Declaring a Local Emulator
generateComponentPipeline()ComponentPipelineResultTurn the component graph into CI YAML. CI-provider lexicons only (gitlab, github, forgejo)
generateOpWavesPipeline()OpWavesPipelineResultRender an op waves spec (ordered waves of Op runs, each behind one gate) as CI YAML. CI-provider lexicons only (gitlab, github, forgejo)
generateOpPipeline()OpPipelineResultTurn a set of scheduled Ops into cron-triggered CI YAML, one file per Op. CI-provider lexicons only (gitlab, github, forgejo)
opRuntimeOpRuntimeProviderHost Op runs: chant run <op> --on <this lexicon> starts, watches, cancels and wakes runs through it. Core ships the built-in local runtime and selects it when no --on is passed
buildRoots()Promise<BuildRootContribution>Render config-declared build roots that are not typed chant source into entities, merged into the build before partitioning — k8s implements it for k8s.kustomize.roots, and sql reads the .sql files in the build’s source directory (ctx.sourceDir, listed with findSourceFiles from @intentius/chant/discovery/files)
projectCodegen()ProjectCodegenGenerate typed code into the project from sources its config declares, written by chant generate and checked for drift by every build. k8s implements it for k8s.crds, helm for helm.charts
graphMeta(entities)Record<string, unknown> | undefinedWhole-read facts for chant graph --format ir to put under meta. The otel lexicon answers { collector }, the topology of the project’s collector config, which chant workspace graph lists as collectors
graphEdges(entities){ references, pipelines? } | undefinedEdges the lexicon’s entities write as strings, resolved to entity keys: each { from, to, kind }, or dangling with the string as written when nothing declared matches. pipelines maps a workflow or pipeline name to its entities. The github, forgejo and gitlab lexicons report CI needs, uses:, trigger:, include: and extends this way. See chant graph
workspaceOutputs(entities, unread?){ outputs, gaps? } | undefinedThe names this lexicon’s declarations expose as workspace outputs, so another member’s link can target them without an output(...). The sql lexicon reports each table as <schema>.<table> and each column as <schema>.<table>.<column>. chant workspace check calls it offline on the folded source, and a file that did not fold makes the list a gap, so a link to a name outside it is unresolved rather than missing. See Workspace outputs (#3931)
configSchemaLexiconConfigSchema (a ZodObject)The shape of this lexicon’s own chant.config.ts namespace — see Owning a config namespace
upstreamPinUpstreamPinHow this lexicon pins its upstream schema and where to look for a newer one, for self-upgrade tooling
upstreamPins(UpstreamPin & { label: string })[]More pins, for a lexicon that pins several upstreams (sql: one Postgres server per major). Each needs a unique label
init()void | Promise<void>Called once when the plugin loads. No shipped lexicon uses this

A lexicon may own the top-level chant.config.ts key named after it, such as k8s.profiles, fountain.profiles or forgejo.runnerLabels. Declare its shape and core validates the namespace at load:

src/config.ts
import { z } from "zod";
import type { ChantConfig } from "@intentius/chant/config";
export const myConfigSchema = z.strictObject({
endpoint: z.string().optional(),
profiles: z.record(z.string(), z.strictObject({ region: z.string() })).optional(),
});
export type MyConfig = z.infer<typeof myConfigSchema>;
declare module "@intentius/chant/config" {
interface ChantConfig {
mylexicon?: MyConfig;
}
}
/** Compile-time proof that the augmentation above reached `ChantConfig`. */
export type MyConfigNamespace = NonNullable<ChantConfig["mylexicon"]>;
src/plugin.ts
import { myConfigSchema } from "./config";
export const myPlugin: LexiconPlugin = {
name: "mylexicon",
serializer,
configSchema: myConfigSchema,
// ...
};

lexicons/forgejo/src/config.ts is the shipped example of exactly this file.

One schema, three consumers: core validates against it, the exported type is inferred from it, and that inferred type is what augments ChantConfig. The runtime rule and the compile-time one cannot disagree because there is only one of them.

This fixes two bugs (chant #1344):

  • An unknown key inside a declared namespace is now an error. The project config schema is .passthrough(), so forgejo: { runnerLabel: ... }, a typo for runnerLabels, was accepted and ignored, leaving the dialect on its defaults with nothing said.
  • satisfies ChantConfig compiles. ChantConfig is a closed interface, so a config carrying a lexicon key was error TS2353 until the lexicon augmented it. The augmentation applies only when the config file has the package in its program, which a bare import "@intentius/chant-lexicon-mylexicon" guarantees. Forgetting it is a compile error, which is the safe way to fail.

Use z.strictObject for nested objects. validateLexiconConfig() (packages/core/src/lexicon-config.ts) calls schema.strict() on the top level of your namespace, and one level is all that reaches, so a typo in profiles.prod.regoin lives deeper than the strictness core adds for you.

Declaring nothing keeps the old passthrough for your namespace, so this is opt-in. chant dev check-lexicon warns when a lexicon reads its own namespace without declaring one.

Users register lexicons in their project config:

import type { ChantConfig } from "@intentius/chant";
export default {
lexicons: ["my-lexicon"],
} satisfies ChantConfig;

The CLI resolves lexicons by looking for @intentius/chant-lexicon-{name} packages in node_modules.

Each step depends on the one before it. Everything else needs steps 1 through 3.

#StepWhat it produces
1Scaffoldchant init lexicon <name> writes the project, including src/plugin.ts and src/serializer.ts
2Implement Generatesrc/spec/fetch.ts, src/spec/parse.ts and src/codegen/generate.ts, which fill src/generated/
3Create a Serializersrc/serializer.ts, turning the evaluated entities into your target format
4Write Lint Rulessrc/lint/rules/, imperative or declarative
5Post-Synth Checkssrc/lint/post-synth/, plus the auditCatalog() metadata behind chant audit
6Testingplugin.test.ts and serializer.test.ts, both required by chant dev check-lexicon
7LSP & MCP Providerssrc/lsp/completions.ts and src/lsp/hover.ts, registered on the plugin
8Skillssrc/skills/, the AI agent skill files
9Package & Publishdist/, via packagePipeline and writeBundleSpec
10CI & Distributionchant dev onboard wires CI, Docker smoke tests, and npm publishing

Verify the result against the Completeness Checklist, which is the tier list chant dev check-lexicon prints.

import type { LexiconPlugin, Serializer, Declarable } from "@intentius/chant";
const serializer: Serializer = {
name: "acme",
rulePrefix: "ACM",
serialize(entities: Map<string, Declarable>): string {
const manifests: Array<Record<string, unknown>> = [];
for (const [name, entity] of entities) {
manifests.push({
apiVersion: "v1",
kind: entity.entityType,
metadata: { name },
});
}
return JSON.stringify(manifests, null, 2);
},
};
export const acmePlugin: LexiconPlugin = {
name: "acme",
serializer,
async generate() {
// TODO: Fetch upstream schemas and run generatePipeline
throw new Error("Not yet implemented");
},
async validate() {
// TODO: Validate generated artifacts
console.error("All checks passed.");
},
async coverage() {
// TODO: Analyze spec coverage
console.error("Coverage analysis not yet implemented.");
},
async package() {
// TODO: Run packagePipeline to produce a distributable bundle
throw new Error("Not yet implemented");
},
};

Only the four lifecycle methods above are required, so this compiles without coverageReport and without any observation or LSP member. It reads only entity.entityType; a serializer that emits authored properties reads entity.props, which is the subject of Create a Serializer.

Publish it as @intentius/chant-lexicon-acme. The package name must follow that pattern. loadPlugin() in packages/core/src/cli/plugins.ts imports @intentius/chant-lexicon-<name> for each entry in the project’s lexicons array, so a package published under any other name will not resolve.

These architecture pages cover the internals a lexicon author needs:

chant