OpenKnowledge
Content rules

Overview

A non-blocking linting layer over your project's markdown — problems as you write, a Problems panel, `ok lint` for CI, and advisories for AI agents. Enable it per project.

Content rules are a lightweight linting layer over your project's markdown. Enable a linter for a project and it checks your markdown as you write. Findings are non-blocking: they surface as warnings, never stop a save, and never gate an agent's edit.

Three linters ship today. markdownlint is the standard engine for markdown style — hard tabs, heading increments, list markers, and the rest. Frontmatter schemas validates each doc's frontmatter against standard JSON Schema files. OKF checks portability to the Open Knowledge Format. The system is pluggable and more linters are planned.

This page covers what's the same whatever linter runs; each linter's page covers its own rules and configuration.

Where problems show up

  • Source mode — wavy underlines on the offending range, markers in the lint gutter, hover tooltips, and (for auto-fixable rules) an inline Fix action.
  • WYSIWYG — the block a problem falls in is marked, so issues stay visible without raw markdown lines to underline.
  • The Problems panel — a tab in the document panel on the right, with a live count badge and two scopes:
    • This doc — live diagnostics for the open document, in both WYSIWYG and source mode. Click a problem to jump to it: source mode lands on the exact line and column, WYSIWYG scrolls to the block it falls in.
    • Project — an on-demand audit of every in-scope document. It runs when you first open the scope and on the refresh button — never in the background. Results group per file with error/warning totals; configuration problems (a malformed config file, a broken extends) surface at the top. Clicking a problem opens the offending doc at that position.
  • The file sidebar — a file with problems has its row tinted (red for errors, amber for warnings) and carries a count badge. The badge is a control: click it, or tab to it and press Enter or Space, and the file opens with the Problems panel showing that file's problems in This doc scope. Keyboard activation moves focus into the panel; clicking preserves pointer focus. Set validation.fileTreeIndicators: false in the project's config.yml to turn the tint and the badge off (see the configuration reference). A missing tint has one other cause: a log.md whose broken links the reserved-log policy below leaves out.

Skill documents are excluded from link checking: files in a skills folder (.claude/skills, .agents/skills, .github/skills, and the like) and the skill documents you open from the Skills panel. A skill's links routinely name files it creates only when it runs, so they report as broken by construction. What goes quiet is the Problems panel in both scopes, and with it the squiggles and gutter marks the panel drives. What stays is the link styling the editor resolves on its own as you type, so a wiki link to a file the skill has not created yet still renders as unresolved, and the Links panel still lists it under Missing. In short: the skill's Problems tab reads clean while the link itself may still look broken in the text. links({ kind: "dead" }) and GET /api/dead-links list them too.

The exclusion is scoped to the skills folder itself, not to the dot-directory containing it, so ordinary documents that happen to live under a dot directory (.github/CI_RUNBOOK.md, a .changeset entry) keep their link findings. So do folder templates under .ok/templates, which you author like any document. A skills folder you added yourself at a visible path, such as team/skills, is ordinary content and is still checked. Links from your other documents to a skill still validate normally.

A reserved log.md or log.mdx is left out the same way, at any depth, with one difference: this one is a switch you own. It is Ignore broken links in log.md under Settings ▸ This project ▸ Preferences ▸ Content rules, on by default, and shared with the project as validation.suppressLogLinkAdvisories. What goes quiet is the Problems panel in both scopes, the sidebar tint and badge, the editor's lint diagnostics, ok audit, and the advisory an agent gets back from a write or edit. What stays is the raw link state: the Links panel, the editor's unresolved-link styling, links({ kind: "dead" }), and GET /api/dead-links all still list those links. In both scopes the Problems panel carries a policy note with the number of findings left out, so an empty queue is not read as a clean log. The stem has to be lowercase, so LOG.md and catalog.md keep their findings, but the extension's case is not what decides, so log.MD is a reserved log too, and it is the file the link is written in that counts, not the file it points at. The ok audit section covers what the CLI and MCP audits disclose.

In both scopes, each row tags the validator that produced it in uppercase — for example MARKDOWNLINT, FRONTMATTER, or LINKS — next to the rule code, and a finding that repeats collapses into one row with an instance count you can expand to reach the individual lines.

The panel is also available in single-file sessions (ok <file>).

Enabling a linter

Open Settings ▸ This project ▸ Plugins and turn on a linter; open editors react live. Each linter has its own toggle, off until you enable it. The choice is saved to the project's config.yml (see the configuration reference), so committing it shares the setting with every collaborator — the whole-project equivalent of a committed lint config.

From the command line

ok lint runs content rules headlessly, with the same config resolution as the editor:

ok lint                 # audit the whole project
ok lint guides/         # scope to a folder
ok lint guides/intro.md # or a single file
ok lint --fix           # apply auto-fixes in place
ok lint --json          # structured JSON output
ok lint --errors-only   # exit non-zero only on error-severity problems

The exit code is non-zero when any problem is found. Findings are warnings unless your .markdownlint.* promotes a rule to "error", so --errors-only gates CI on just the rules you chose to enforce. Only markdownlint rules can be promoted — frontmatter findings are always warnings, so --errors-only never gates on them.

What it returns

The text report lists each finding as line:column (1-based), severity, message, and a composed source/code id naming the linter and the violated rule:

docs/guide.md
  7:5     warning  Hard tabs: Column: 5  markdownlint/MD010
  1:1     warning  Frontmatter property "owner" is required  frontmatter/required
  2:1     warning  Frontmatter property "status" must be one of: draft, review, published (got "shipped")  frontmatter/enum

3 problems (0 errors, 3 warnings) across 1 file.
Checks run: markdownlint, frontmatter.

Configuration problems (a malformed config file, a broken schema) print as ! lines after the findings — they describe your setup, not a document. --json emits the same data as a machine-readable object:

{
  "contentDir": "/path/to/project",
  "files": [
    {
      "file": "docs/guide.md",
      "fixed": false,
      "diagnostics": [
        {
          "range": { "start": { "line": 6, "character": 4 }, "end": { "line": 6, "character": 5 } },
          "severity": "warning",
          "source": "markdownlint",
          "code": "MD010",
          "message": "Hard tabs: Column: 5",
          "fixes": [
            { "range": { "start": { "line": 6, "character": 4 }, "end": { "line": 6, "character": 5 } }, "newText": " " }
          ]
        },
        {
          "range": { "start": { "line": 0, "character": 0 }, "end": { "line": 0, "character": 3 } },
          "severity": "warning",
          "source": "frontmatter",
          "code": "required",
          "message": "Frontmatter property \"owner\" is required"
        },
        {
          "range": { "start": { "line": 1, "character": 0 }, "end": { "line": 1, "character": 15 } },
          "severity": "warning",
          "source": "frontmatter",
          "code": "enum",
          "message": "Frontmatter property \"status\" must be one of: draft, review, published (got \"shipped\")"
        }
      ]
    }
  ],
  "warnings": [],
  "fileCount": 1,
  "errorCount": 0,
  "warningCount": 3,
  "fixedCount": 0,
  "ran": ["markdownlint", "frontmatter"]
}

Conventions to know:

  • JSON ranges are 0-based and end-exclusive (LSP-aligned); the text report displays 1-based positions.
  • fixes appears only on auto-fixable findings — its presence is how tooling knows --fix would resolve the problem.
  • fixed is true on each file --fix rewrote, and the top-level fixedCount counts files, not problems.
  • The top-level warnings array carries configuration or runtime degradation problems.
  • The top-level ran array names the enabled source families selected for the run. A family absent from ran was not checked. An explicit [] means no checks were selected at all. It does not mean the document was checked and found clean.

With --fix, fixable findings are applied in place, fixed files are marked (fixed), and the report lists what remains:

docs/guide.md (fixed)
  1:1     warning  Frontmatter property "owner" is required  frontmatter/required
  2:1     warning  Frontmatter property "status" must be one of: draft, review, published (got "shipped")  frontmatter/enum

2 problems (0 errors, 2 warnings) across 1 file.
Fixed 1 file.
Checks run: markdownlint, frontmatter.

Every linter ships off, so a project that has not enabled one closes its report with No checks ran. in place of the Checks run: line. The ✓ No problems above it then means only that nothing was checked. Turn a linter on under Settings ▸ This project ▸ Plugins.

ok audit widens the same report to the full validation plane — content-rule problems and broken internal links, each finding tagged with its source:

ok audit                 # audit the whole project (lint + links)
ok audit guides/         # scope to a folder or a single file
ok audit --json          # the full structured diagnostic plane
ok audit --errors-only   # exit non-zero only on error-severity problems

Unlike ok lint, ok audit needs the project's server running (ok start or OK Desktop) — the links validator reads the live backlink index. There's no --fix because the audit is read-only (lint fixes go through ok lint --fix, link repairs are content edits).

--json returns the same per-file grouping as ok lint --json, with two differences: the audit never writes, so there's no contentDir, fixed, or fixedCount; and a links finding carries linkTarget — the unresolved target verbatim, so tooling never parses it back out of the message:

{
  "files": [
    {
      "file": "docs/guide.md",
      "diagnostics": [
        {
          "range": { "start": { "line": 11, "character": 0 }, "end": { "line": 11, "character": 0 } },
          "severity": "warning",
          "source": "links",
          "code": "dead-link",
          "message": "Link target \"guides/setup\" does not resolve to an existing document.",
          "linkTarget": "guides/setup"
        }
      ]
    }
  ],
  "warnings": [],
  "fileCount": 1,
  "errorCount": 0,
  "warningCount": 1,
  "ran": ["markdownlint", "frontmatter", "links"]
}

The audit's top-level ran array reports the selected source families: enabled document linters plus links unless link validation is off. OKF's document and project-tree checks share the public okf family, so ran reports selection, not the number of internal validators or a pass verdict.

Broken links are warnings by default. The project's validation.links setting — Settings ▸ This project ▸ Preferences ▸ Content rules — decides both whether they appear and at what severity: warning (the default), error to gate CI on them with --errors-only, or off to drop them from the plane entirely. Content-rule findings keep their own severities, so --errors-only covers both planes at once. Skill documents are the one exception: their link findings are always excluded, whatever this setting says, because a skill's links name files it creates only at runtime.

A reserved log is the other exclusion, and unlike skills it is a switch you own. Ignore broken links in log.md (validation.suppressLogLinkAdvisories in the project's .ok/config.yml — see the configuration reference), next to the severity control and on by default, is described under Where problems show up, along with the matching rules. ok audit and MCP audit both disclose the withheld count and warn that the result is filtered, each in the wording its audience needs. The terminal names both remedies, validation.suppressLogLinkAdvisories: false in .ok/config.yml and the Settings ▸ This project ▸ Preferences ▸ Content rules toggle. An agent is told the setting is not its to change, and to point you at that same Settings path if you ask to see the withheld findings. A log is an append-only history: its entries reference the past and pages nobody has written yet, so those links are expected rather than repair work, and an agent told to fix broken links would otherwise rewrite the history to clear them. Turn the switch off and every finding comes straight back, with no restart.

AI agents

Agents get the same signal you see, across three surfaces. See the MCP reference for the full tool list.

The lint tool

Lints a single document, or audits the project when document is omitted (path scopes it to a folder or file). fix: true — which requires document — auto-fixes fixable rules in place, attributed and live in the preview, the same result as the editor's Fix action.

A single-document call returns a readable summary. Configuration problems appear under a Lint incomplete warning block, and the closing hint tells the agent whether fix: true would help:

docs/guide.md: 2 warnings
  ⚠ line 1 frontmatter/required: Frontmatter property "owner" is required
  ⚠ line 2 frontmatter/enum: Frontmatter property "status" must be one of: draft, review, published (got "shipped")
Lint incomplete — 1 warning (findings may be partial):
  ⚠ frontmatter schema .ok/schemas/missing.schema.json: cannot read (ENOENT: no such file or directory, …)
None are auto-fixable — these need content edits via `edit`/`write`.
Checks run: frontmatter.

With no linter enabled the report closes with No checks ran. instead, the same string ok lint prints.

The structured content is close to ok lint --json, with four differences:

ok lint --jsonMCP lint
Project pathcontentDircwd
fixedCount countsfiles rewrittenproblems resolved
Cap fieldsomittedFileCount, per-file omittedDiagnosticCount, omittedWarningCount
Fix-mode keysdiagnosticsArePreFix, reLintFailure (only when the post-write re-lint could not report)

Everything else matches: files[].diagnostics with 0-based range, severity, source, code, message, plus errorCount, warningCount, fileCount on an audit, configuration or degradation warnings, and ran for the selected source families. Audit output is capped project-wide at 10 warnings, plus 10 files × 10 diagnostics per file — the text channel marks the remainder with "… and N more", the structured channel with the three cap fields above. Counts always reflect the full scan, and re-running with a narrower path recovers the detail. With fix: true the summary reports what was applied and what remains: Fixed 1 problem in docs/guide.md. followed by the unfixable findings.

When the fix landed but the post-write re-lint could not report, the response carries diagnosticsArePreFix: true and explains itself in reLintFailure: reason is the machine-readable discriminant (re-lint-threw or source-went-blind) and message is the prose. The diagnostics and counts are then the PRE-fix set and fixedCount is 0, so re-run lint to see what remains rather than reading 0 as nothing fixed.

The audit tool

The agent-side ok audit — content-rule problems and broken links in one read-only call, grouped by file, same 10 × 10 + 10-warning cap with omittedWarningCount when warnings are dropped, no fix shape. Its text and structured results both report ran. If the reserved-log policy withholds one or more link findings, brokenLinkSuppression: { reason: "reserved-log-policy", count: N } makes that filtered result explicit without exposing paths or hrefs; the text result says the same. If a selected validator degrades, its family remains in ran and the warnings channel explains why; a partial degradation may still have contributed findings.

Write responses

Every write response carries validation findings for the document it touched, on two channels. Both nest under document in the structured content, and both are advisory: a finding never blocks the write.

"document": {
  "brokenLinks": [
    { "href": "./guides/setup", "resolvedTo": "guides/setup", "reason": "no-such-doc" }
  ],
  "warnings": [
    {
      "kind": "lint-violation",
      "source": "frontmatter",
      "code": "enum",
      "message": "Frontmatter property \"status\" must be one of: draft, review, published (got \"shipped\")",
      "severity": "warning",
      "line": 2,
      "column": 1
    }
  ]
}

warnings carries up to 10 findings across the whole validation plane — lint violations and broken links alike, honoring the project's validation.links setting and, for a reserved log, the Ignore broken links in log.md switch. Positions are 1-based (line/column), ready to echo back to a human, and a links finding adds linkTarget. The field is present only when the write produced findings.

brokenLinks is the dedicated link channel, and unlike warnings it is always present. An empty array is the positive "every outbound link resolves" confirmation, which saves a separate links({ kind: "dead" }) round-trip, as long as no brokenLinkSuppression sits beside it. Each entry names the href exactly as authored, so an agent can grep for it. reason is no-such-doc (resolved to a docName that doesn't exist), no-such-file (a linked asset or source file missing from disk), or unresolvable (an empty href, or a relative path escaping the content root); resolvedTo is null for unresolvable.

brokenLinkSuppression is present only when the project's reserved-log policy kept findings out of brokenLinks, which is what makes an empty array conditional rather than a clean bill of health:

"document": {
  "brokenLinks": [],
  "brokenLinkSuppression": { "reason": "reserved-log-policy", "count": 2 }
}

reason names the policy and count is how many advisory entries it withheld. No href or path comes back, because none of them is repair work: the write landed in a reserved log, whose entries are meant to keep pointing at what has been moved or has not been written yet. Read links({ kind: "dead" }) when you want to audit that raw state deliberately, and do not rewrite a log's history to clear it.

See also