Skip to content

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.

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
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

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

Import and migration

MemberReturnsDescription
detectTemplate(data)booleanDetect whether a parsed template belongs to this lexicon
templateParser()TemplateParserParser for importing external templates
templateGenerator()TypeScriptGeneratorGenerator for converting IR to TypeScript
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
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
commands()CommandGroupA CLI verb group mounted under chant <name> <verb> — core dispatches to it wholesale and knows nothing about what’s inside
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)
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
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
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.

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(), 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 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.

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. Steps 1 through 3 are the ones nothing else can proceed without.

#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 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.

These architecture pages cover the internals a lexicon author needs to understand: