Lexicon Authoring Overview
A lexicon is a plugin that teaches chant one operational area. It contributes the types for that area, a serializer, and the rules that check the result. You can write one for any infrastructure platform, internal tool, or custom domain.
When You Need This Section
Section titled “When You Need This Section”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.
The LexiconPlugin Interface
Section titled “The LexiconPlugin Interface”Every lexicon exports a LexiconPlugin object:
import type { LexiconPlugin } from "@intentius/chant";
export const myPlugin: LexiconPlugin = { name: "my-lexicon", serializer: mySerializer, // ... lifecycle methods};Required fields
Section titled “Required fields”| Field | Type | Description |
|---|---|---|
name | string | The resolution key. Core imports @intentius/chant-lexicon-<name> and users write lexicons: ["<name>"] (packages/core/src/cli/plugins.ts) |
serializer | Serializer | Serializer for build output |
Required lifecycle methods
Section titled “Required lifecycle methods”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.
| Method | Signature | Description |
|---|---|---|
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 |
Optional members
Section titled “Optional members”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
| Member | Returns | Description |
|---|---|---|
lintRules() | LintRule[] | Imperative lint rules |
declarativeRules() | RuleSpec[] | Declarative lint rules (compiled via rule()). No shipped lexicon uses this — see Write Lint Rules |
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 |
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
| Member | Returns | Description |
|---|---|---|
intrinsics() | IntrinsicDef[] | Lexicon-specific intrinsic functions |
pseudoParameters() | string[] | Pseudo-parameter names |
completionProvider() | CompletionItem[] | LSP completions for resource/property names |
hoverProvider() | HoverInfo | undefined | LSP hover information for resource types |
codeActionProvider() | CodeAction[] | LSP code actions. No shipped lexicon uses this |
Import and migration
| Member | Returns | Description |
|---|---|---|
detectTemplate(data) | boolean | Detect whether a parsed template belongs to this lexicon |
templateParser() | TemplateParser | Parser for importing external templates |
templateGenerator() | TypeScriptGenerator | Generator for converting IR to TypeScript |
agentConfigImporter() | AgentConfigImporter | Re-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 | undefined | Translate another lexicon’s format into this one — gitlab and forgejo implement migrationSource("github") |
Observation — see Implementing Observation
| Member | Returns | Description |
|---|---|---|
describeResources() | DescribeResourcesResult | What this estate manages, keyed by chant entity name. The thin read |
observeResourcesDeep() | DeepObservationResult | The full live property tree per entity, for property-level drift |
deepNormalizationHooks | DeepNormalizationHooks | Which fields are server-populated or order-insensitive, applied to both the live and declared trees |
observeDependencies() | DependencyObservation | What 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 | null | Whether one deploy unit is present and healthy, by deployed name. For multi-stack component projects |
describeIdentity() | DescribeIdentityResult | The 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() | TeardownEnumeration | What this lexicon would delete for one marker identity (stack + env) — enumeration only; unreadable kinds report as holes |
executeTeardown() | TeardownExecution | Delete 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 |
ownershipChannel | OwnershipChannel | Where this lexicon can stamp and read chant’s ownership marker, per read path |
referenceCatalog | ReferenceCatalog | How 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() | ExportedTemplate | Full-fidelity config read from a live API — see Implementing Live Export |
subscribeChanges() | ChangeSubscription | Subscribe 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
| Member | Returns | Description |
|---|---|---|
initTemplates(template?) | InitTemplateSet | Source 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 |
commands() | CommandGroup | A CLI verb group mounted under chant <name> <verb> — core dispatches to it wholesale and knows nothing about what’s inside |
emulator | EmulatorCapability or a list of them | Local emulator(s) chant emulator boots — see Declaring a Local Emulator |
generateComponentPipeline() | ComponentPipelineResult | Turn the component graph into CI YAML. CI-provider lexicons only (gitlab, github, forgejo) |
generateOpPipeline() | OpPipelineResult | Turn a set of scheduled Ops into cron-triggered CI YAML, one file per Op. CI-provider lexicons only (gitlab, github, forgejo) |
opRuntime | OpRuntimeProvider | Host 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 |
configSchema | LexiconConfigSchema (a ZodObject) | The shape of this lexicon’s own chant.config.ts namespace — see Owning a config namespace |
upstreamPin | UpstreamPin | How this lexicon pins its upstream schema and where to look for a newer one, for self-upgrade tooling |
init() | void | Promise<void> | Called once when the plugin loads. No shipped lexicon uses this |
Owning a config namespace
Section titled “Owning a config namespace”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:
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"]>;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.
Two things this fixes, both of which had bitten (chant #1344):
- An unknown key inside a declared namespace is now an error. The project
config schema is
.passthrough(), soforgejo: { runnerLabel: ... }, a typo forrunnerLabels, was accepted and ignored, leaving the dialect on its defaults with nothing said. satisfies ChantConfigcompiles.ChantConfigis a closed interface, so a config carrying a lexicon key waserror TS2353until the lexicon augmented it. The augmentation applies only when the config file has the package in its program, which a bareimport "@intentius/chant-lexicon-mylexicon"guarantees. Forgetting it is a compile error rather than silence, which is the safe direction.
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.
Registering in chant.config.ts
Section titled “Registering in chant.config.ts”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.
Authoring Workflow
Section titled “Authoring Workflow”Each step depends on the one before it. Steps 1 through 3 are the ones nothing else can proceed without.
| # | Step | What it produces |
|---|---|---|
| 1 | Scaffold | chant init lexicon <name> writes the project, including src/plugin.ts and src/serializer.ts |
| 2 | Implement Generate | src/spec/fetch.ts, src/spec/parse.ts and src/codegen/generate.ts, which fill src/generated/ |
| 3 | Create a Serializer | src/serializer.ts, turning the evaluated entities into your target format |
| 4 | Write Lint Rules | src/lint/rules/, imperative or declarative |
| 5 | Post-Synth Checks | src/lint/post-synth/, plus the auditCatalog() metadata behind chant audit |
| 6 | Testing | plugin.test.ts and serializer.test.ts, both required by chant dev check-lexicon |
| 7 | LSP & MCP Providers | src/lsp/completions.ts and src/lsp/hover.ts, registered on the plugin |
| 8 | Skills | src/skills/, the AI agent skill files |
| 9 | Package & Publish | dist/, via packagePipeline and writeBundleSpec |
| 10 | CI & Distribution | chant 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.
Example: Minimal Custom Lexicon
Section titled “Example: Minimal Custom Lexicon”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 is not free
form: 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.
Background Reading
Section titled “Background Reading”These architecture pages cover the internals a lexicon author needs to understand:
- Core Type System —
Declarable,ResourceType, and the evaluator’s type model - Lexicon Registry — how chant discovers, loads, and validates lexicon plugins
- Serializer Architecture — the serialization contract your plugin must implement