# 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](https://github.com/drewpoling2/prongs/blob/main/spec/draft/schema.ts) are the source of truth: they are the ones `@prongsjs/core` compiles against. [schema.json](https://github.com/drewpoling2/prongs/blob/main/spec/draft/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](https://github.com/drewpoling2/prongs/tree/main/spec/draft/conformance) test an implementation against it, and the [changelog](https://www.prongs.dev/docs/spec/changelog.md) 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:

```text
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).

```ts
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:

```text
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.

```ts
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 use `action.delete` and never `control.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 accept `undefined` for either side. It defines how `refs` compose (§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.

```ts
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 `Text` accepts `heading.*`, `text.*` and `feedback.*`, and passing `action.save` to it is a type error.
- Tooling SHOULD report code that bypasses the vocabulary, such as a styled raw `<button>` or a hand-written `Response.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 `keywords` better 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:

```json
{
  "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](https://github.com/drewpoling2/prongs/blob/main/spec/draft/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/](https://github.com/drewpoling2/prongs/tree/main/spec/draft/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, `notFor` and 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-intent` attributes 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](https://www.prongs.dev/docs/spec/changelog.md) 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`.
