Specification
Prongs Intent Spec
Draft 0.1 · 2026-10-17 · implemented by @prongsjs/core 0.2
This document defines the format of an intent, what a profile must provide, how a registry is validated and how an intent resolves. It covers the format, not any vocabulary: the starter intents that ship with the UI, errors and logs profiles (action.delete, conflict.duplicate, audit.created and so on) are opinionated defaults you are expected to change, and they are not part of this spec.
The types in schema.ts are the source of truth: they are the ones @prongsjs/core compiles against. schema.json is a JSON Schema for a registry exported as JSON, for tools that do not run TypeScript. Where this document and schema.ts disagree, schema.ts wins. Conformance fixtures test an implementation against it, and the changelog lists what changed between versions.
The words MUST, SHOULD and MAY are used as in RFC 2119.
Terms
| Term | Meaning |
|---|---|
| Intent | A named choice in a codebase: what something is for, what it resolves to, and when not to use it. |
| Id | An intent's name, like action.delete. |
| Domain | The first part of an id (action). Every domain belongs to one profile. |
| Binding | What an intent resolves to: classes, an HTTP status, a log level. Its shape belongs to the profile. |
| Profile | The code that gives bindings in its domains a meaning, and checks them. |
| Registry | A set of intents plus the profiles that own their domains. |
| Call site | A place in code that names an intent: <Action intent="action.delete">, fail('conflict.duplicate'). |
| Internal domain | A domain whose intents exist to be referenced by other intents, never named at a call site. |
| Agent spec | The document compiled from a registry for agents to read (PRONGS.md). |
| Tooling | Programs that read a registry and the code that uses it: a checker, an agent spec compiler, an exporter. |
Principles
- Named by purpose. An id and its description say what something is for, never what it looks like. Looks live in bindings and internal domains, where they can change without renaming call sites.
- Choosable from the code. An agent or a person should be able to pick the right intent from facts in front of them, a handler name or a label, without asking anyone.
- Exceptions are data. When not to use an intent, and what to use instead, is part of the intent (
notFor), so it reaches agents and checks alike. - The format, not the vocabulary. The spec fixes how intents are written and resolved. Which intents a project has is that project's decision.
- Small at run time. What ships with an app is the registry and a merge function. Everything that reads source code stays in tooling.
1. Intents
An intent is a named choice inside a codebase: what it is for, what it resolves to, when not to use it, and how to check it was followed.
1.1 Ids
An id is a domain and a meaning, with an optional variant:
domain.meaning[.variant] ^[a-z]+(\.[a-z]+){1,2}$Examples: action.delete, conflict.duplicate, layout.terminal.bar. The first part is the intent's domain. Ids are lowercase letters only, so they can be written, searched and typed without escaping.
Domains are not namespaced: a registry is one project's vocabulary, and the profiles in it MUST NOT claim the same domain (§3). A profile published for others SHOULD document the domains it claims, and SHOULD pick names specific enough that it can sit next to other published profiles.
1.2 Fields
| Field | Type | Required | Meaning |
|---|---|---|---|
kind | "primitive" | "semantic" | yes | A primitive holds a binding of its own and references nothing. A semantic intent builds on others through refs. |
description | string | yes | One sentence on what the intent is for, not what it looks like or does internally. Selection runs on it. |
refs | string[] | semantic only | Intents this one builds on, in order. |
binding | object | no | What the intent resolves to. Its shape is owned by the profile. |
notFor | { context, reason?, instead? }[] | no | When not to use this intent, and which intent to use instead. |
keywords | string[] | no | Words in code that point to this intent: handler names, labels, prop names. |
checks | { type, ... }[] | no | Checks for this intent's call sites. type is defined by the profile. |
status | "stable" | "beta" | "experimental" | "deprecated" | no | How settled the intent is (§1.4). |
evidence | object | no | Experimental. Written by tooling (for example trial results), never by hand. Its shape is not fixed in this draft. |
meta | object | no | Data the spec does not define, under prefixed keys (§1.5). |
type Intent<B = unknown, C extends Check = Check> = {
kind: 'primitive' | 'semantic';
description: string;
refs?: readonly string[];
binding?: B;
notFor?: readonly { context: string; reason?: string; instead?: string }[];
keywords?: readonly string[];
checks?: readonly C[];
status?: 'stable' | 'beta' | 'experimental' | 'deprecated';
evidence?: Readonly<Record<string, unknown>>;
meta?: Readonly<Record<string, unknown>>;
};
type Check = { type: string } & Record<string, unknown>;1.3 Writing a description
A description SHOULD let someone pick the intent from facts in the code or content, without a judgement of taste. "Removes something for good: a project, an account" can be checked against the code; "the red button" cannot.
1.4 Status
An intent without a status is stable.
| Status | Meaning | What tooling does |
|---|---|---|
stable | Its id and meaning will not change without going through deprecated first. | Nothing special. |
beta | Its meaning is settled, but its binding or notFor may still change. | The agent spec SHOULD mark it. |
experimental | It may change meaning or be removed. | The agent spec SHOULD mark it. |
deprecated | Kept so existing code still works. New code should not use it. | The agent spec SHOULD mark it, and checks SHOULD warn at every call site and suggest the replacement. |
A deprecated intent SHOULD have a notFor entry with an instead, naming what to use. Validation warns when it does not (§3).
1.5 Meta
meta holds data the spec does not define, for teams and tools: an owner, a design link, a ticket. Every key MUST carry a reverse-DNS prefix and a name, so two tools never claim the same key:
com.example/owner ^[a-z][a-z0-9-]*(\.[a-z][a-z0-9-]*)+/[A-Za-z0-9]([A-Za-z0-9._-]*[A-Za-z0-9])?$- Values MAY be any JSON value.
- Prefixes whose second label is
prongs(dev.prongs/,io.prongs/) are reserved for keys this spec defines. This draft defines none. - Tooling MUST ignore keys it does not know, and resolution (§4) MUST NOT read
meta.
2. Profiles
A profile gives meaning to the bindings of the domains it owns. Core knows no domains: UI, errors, logs, and anything else plug in as profiles.
type Profile<B = unknown> = {
name: string;
domains: readonly string[]; // id prefixes this profile owns
internalDomains?: readonly string[]; // owned domains usable only as refs targets
validate?(id: string, intent: Intent<B>, defs: IntentDefs): ProfileIssue[];
validateAll?(defs: IntentDefs): { id: string; message: string; severity: 'error' | 'warning' }[];
merge(base: B | undefined, next: B | undefined): B;
};
type ProfileIssue = string | { message: string; severity: 'error' | 'warning' };domains: every domain MUST be owned by exactly one profile in a registry.internalDomains: intents in these domains MUST NOT be used at call sites; they exist to be referenced. In the UI profile the look domains (control,tone,arrange, …) are internal, so call sites useaction.deleteand nevercontrol.destructive.validate: checks one intent's binding and refs. A plain string is an error.validateAll: checks the vocabulary as a whole, for example that neighbouring intents contrast with each other.merge: layers one binding over another. It MUST acceptundefinedfor either side. It defines howrefscompose (§4).
The runtime part of a profile ships with the app, so it SHOULD stay small. Tooling, such as finding usages in source files and rules, lives in a separate object that only the CLI loads.
3. Registries
A registry is a map of id → intent plus the profiles that own its domains. A registry is valid when it has no errors; warnings are reported but allowed.
Validation MUST report every issue rather than stop at the first. Each issue names the intent (or, for profile problems, the domain) it is about, a severity, a message, and a code from the tables below. Messages are free text; codes are stable, so tools and the conformance fixtures compare codes, not wording.
3.1 Errors
| Code | When |
|---|---|
id-grammar | An id does not match the grammar in §1.1. |
no-profile | No profile owns an id's domain. |
domain-conflict | Two profiles claim the same domain. Reported on the domain. |
internal-domain-not-owned | A profile lists an internal domain it does not own. Reported on the domain. |
description-missing | description is missing, empty or only whitespace. |
primitive-refs | A primitive has refs. |
semantic-no-refs | A semantic intent has no refs. |
unknown-ref | A ref points to an intent that is not in the registry. |
cross-profile-ref | A ref points to an intent owned by a different profile. |
unknown-instead | notFor[].instead points to an intent that is not in the registry. |
ref-cycle | refs form a cycle. Reported once per cycle, on its first intent in definition order. |
meta-key | A meta key lacks the prefix in §1.5. |
profile | The owning profile's validate or validateAll returns an error. |
3.2 Warnings
| Code | When |
|---|---|
duplicate-keyword | Two intents in one domain list the same keyword. Reported on the second; keep it on the one it means. |
deprecated-no-instead | A deprecated intent has no notFor entry with an instead. |
meta-reserved | A meta key uses a reserved prefix that the spec does not define. |
profile | The owning profile's checks return a warning, such as the UI profile's contrast rule: in a domain with more than two intents, every intent needs a notFor link to or from a neighbour. |
3.3 When to validate
Implementations SHOULD validate when a registry is created in development, and MAY skip validation in production builds.
4. Resolution
Resolving an intent gives its effective binding. For an intent I owned by profile P:
- Start from
undefined. - For each ref in
I.refs, in order, resolve it and merge the result in:binding = P.merge(binding, resolve(ref)). - Merge
I's own binding last:binding = P.merge(binding, I.binding).
Later refs override earlier ones, and an intent's own binding overrides all its refs. Resolution MUST fail on an unknown id or a cycle, whether or not the registry was validated. It reads only refs and binding: status, evidence and meta never change a result. Implementations MAY cache results, since a registry does not change at run time.
type ResolvedIntent<B = unknown> = { id: string; profile: string; binding: B };5. Usage
These rules apply to code that uses a registry, not to the registry itself. They are what prongs check enforces.
- Call sites MUST NOT use an intent in an internal domain.
- A component or helper SHOULD accept only the domains it renders or handles. The UI profile's
Textacceptsheading.*,text.*andfeedback.*, and passingaction.saveto it is a type error. - Tooling SHOULD report code that bypasses the vocabulary, such as a styled raw
<button>or a hand-writtenResponse.json({ error }, { status: 400 }), and suggest the intent to use. - When the words in the code (a handler name, a label) match another intent's
keywordsbetter than the one used, tooling SHOULD warn. - A call site that uses a deprecated intent SHOULD get a warning that names the replacement from its
notFor.
5.1 Findings
A checker that reports findings to other programs SHOULD give each one this shape, so editors, CI and agent hooks can read any checker the same way:
| Field | Type | Meaning |
|---|---|---|
rule | string | The rule that found it, like internal-intent. Stable, like issue codes. |
severity | "error" | "warning" | Errors fail the check; warnings do not. |
path, line, column | string, number, number | Where it is: a path relative to the project root, with lines and columns counted from 1. |
message | string | What is wrong, for a person or an agent. |
snippet | string | The source line, trimmed. A baseline matches on it, so it MUST NOT contain line numbers. |
intent | string | The intent involved, if any. |
suggestions | { text, intent?, command? }[] | Fixes to offer: an intent to use instead, or a command to run. |
prongs check --json prints { "violations": [ … ] } in this shape. When it runs as an agent hook, it reads the edited file from the hook's payload and answers in that agent's own format; the hook formats belong to the agents, not to this spec.
6. Agent spec
An implementation SHOULD compile a registry into a document agents read (in Prongs, PRONGS.md). It SHOULD list, for every call-site intent, its id and description first, grouped by what consumes it, and then its notFor guidance. Intents that are not stable SHOULD carry their status next to their id. Internal intents MAY be listed separately as reference.
The output SHOULD be deterministic: the same registry and options give the same bytes, with intents in definition order. That lets CI fail when the committed document is out of date (prongs spec --check) and keeps diffs to what changed.
7. Registry export
A registry MAY be exported as JSON for tools in other languages. An export is an object with the spec version, the profiles (their names and domains, not their code) and the intents by id, in definition order:
{
"spec": "draft",
"profiles": [{ "name": "ui", "domains": ["action", "control"], "internalDomains": ["control"] }],
"intents": {
"control.destructive": { "kind": "primitive", "description": "…", "binding": { "classes": "…" } },
"action.delete": { "kind": "semantic", "description": "…", "refs": ["control.destructive"] }
}
}schema.json describes this shape. It checks the format only: whether a binding makes sense is up to its profile, so binding is any object. In Prongs, prongs export writes it.
A reader SHOULD check spec before anything else. It MUST NOT treat an export from a version it does not know as one it does without saying so: it SHOULD warn, and MAY go on to read the fields it knows, ignoring the rest.
8. At run time
Intents are not only a build-time idea: the same names SHOULD survive into what the app produces, so analytics, tests, logs and API clients can work by purpose. A profile SHOULD carry the intent's id into its output:
- UI elements carry it as
data-intent="action.delete". A project MAY turn this off in production. It MUST NOT be used for styling; looks come from the registry. - Error responses carry it as a machine-readable code:
{ "ok": false, "error": "…", "code": "conflict.duplicate" }. - Log entries carry it as a field:
{ "intent": "audit.deleted", "level": "info", … }.
9. Conformance
An implementation conforms to this draft when it passes every case in conformance/: validation must report the listed issue codes and severities, and resolution must give the listed bindings or fail where listed. The fixtures are JSON, so an implementation in any language can run them. Their README describes the format and the reference profile they assume.
@prongsjs/core runs them on every test run. A behaviour this document describes that the fixtures do not cover is a missing fixture, and a fixture that contradicts this document is a bug in one of them.
10. Security
A registry is code. Loading one, as prongs check, prongs spec and every tool built on them do, runs it with the permissions of whoever runs the tool.
- Treat a registry like any other source file in review. Tooling MUST NOT load a registry from somewhere the user did not point it at.
- Descriptions,
notForand keywords are written into the agent spec, which agents follow. A change to them is a change to agent instructions, and SHOULD be reviewed as one. Tooling SHOULD NOT copy registry text into an agent's context from sources the project does not control. - Agent hooks run on every edit an agent makes. Installing one SHOULD be an explicit step, and a hook SHOULD only read the files it was given.
data-intentattributes and error codes expose intent names to anyone who reads the page or the response. Names SHOULD NOT contain anything secret; a project that considers them sensitive SHOULD turn the attribute off in production.
11. Versioning
This is a draft. Frozen versions will be published by date (for example 2026-11-01) and never change; the draft is where changes land first, and the changelog records each one. The spec's dates are independent of package versions: a release of @prongsjs/core says which spec version it implements.
A change to this spec is breaking when a registry, export or implementation that conformed before no longer does: a new error, a removed or renamed field, a field that becomes required, or a changed meaning. Adding optional fields, warnings or SHOULDs is not. Breaking changes only happen in a new dated version.
A change to a registry is breaking for the code that uses it when it removes or renames an intent, moves it to an internal domain, or narrows what it is for. Adding intents, notFor entries or keywords is not. A registry SHOULD mark an intent deprecated, with an instead, for at least one release before removing it, so checks can walk call sites to the replacement.
Planned before the first dated version:
- The shape of
evidence, agreed together with the tooling that writes it. - Composed intents (recipes with slots), as a third
kind.