Import a rule set
What the importer actually understands today
POST /import/claude-md is a deterministic, model-free scanner — not a
full Markdown parser, and not aware of any particular file's structure. It
tries two paths, in this order:
- The numbered-rule format first: a
## 0. ...section containing### R<n> — Titel (P<n>)headings. If this path finds at least one rule, only these matches are used —regel_textis then the first imperative sentence of the first prose paragraph under that heading (metadata paragraphs like**SoT:** ...or**Erzwingungsgrad:** ...are skipped). An### R<n>header whose title has no(P<n>)suffix (e.g. a retired-but-reserved number like R12) still produces a proposal, but is also flagged inunklar. - Otherwise — the common case — the freeform fallback: only when path
1 finds nothing (no
## 0.section, or no### R<n>heading inside it) does the same call look for exactly two free shapes: - A heading (
#through####) followed by a prose paragraph: any sentence in that paragraph that starts with an imperative marker becomes a candidate. - A bullet point or numbered list item whose text starts with an imperative marker.
The markers are fixed and case-insensitive — English: Always, Never,
Do not, Don't, Use, Prefer, Must, Avoid; German: Immer,
Nie, Niemals, Kein, Keine, Nutze, Verwende, Vermeide,
Muss. Code blocks (```) and table rows (lines starting with |)
are never a source.
Each proposal carries the original wording, not a rewrite: regel_text is
exactly the matched sentence or bullet text (or, on the numbered-rule path,
the first imperative sentence), with only Markdown formatting (**bold**,
`code`, [links](url)) stripped. titel is the heading's own title
on the numbered-rule path, and on the freeform path the same text as
regel_text, shortened to its first 8 words for a readable Sache title;
sache is a kebab-slug derived from that title, de-duplicated (-2, -3,
…) if two proposals would otherwise collide.
Honest limits
- The special-case handling for the numbered-rule format only applies
inside a
## 0. ...section.### R<n> — Titelheadings outside such a section (or without the(P<n>)convention at all) are not picked up by that path — either the## 0.wrapper is needed, or the freeform fallback kicks in, which knows no special case for the rule number: it ends up in the title whenever the prose sentence itself happens to start with an imperative marker. Neither the heading text nor a rule number is ever used as a proposal by itself — a heading with no marker-led sentence underneath it (freeform path) or no prose paragraph at all (numbered-rule path) produces nothing. On the freeform path, manyKernsatzparagraphs don't happen to open with a recognized marker word (they describe the rule instead of commanding it), and those do not become proposals. As of 2026-09-25 they are no longer silently skipped either:unklarnames them by count (see below). Treat the proposal list as a partial catch on a file like that — how many places fell outside it is now in the response. (A separate, offline tool carries the same two-path logic — but it isn't reachable through the API.) - Marker matching on the freeform path is purely lexical, at the very
start of the sentence or bullet — it doesn't know what the rest of the
sentence says.
"Use of tabs is discouraged in this repository."starts withUse, so it becomes a proposal even though it isn't phrased as an instruction to follow. Read everyregel_textbefore applying it. - Prose without a marker at the start never becomes a rule, no matter how clearly it states a policy. Either rephrase the source with a leading marker, or write the Aussage by hand afterward (see Set and supersede a rule).
- A bullet whose marker isn't at the start (e.g. "See the docs — you
must always check them first") is not silently dropped: it's listed in
unklaras a near-miss, so review that list before assuming the proposals are the whole story.
What unklar tells you — two kinds of entry
As of 2026-09-25 the list holds nothing back. It contains, in this order:
- Individual near-misses, one per line with its line number — a place that was almost a rule (the marker just wasn't at the start).
- One counted line per reason, with a total and the first example plus its line number, covering everything that was not read as a rule at all: quote blocks, tables, bullets with no marker anywhere, paragraphs with no heading above them, and sentences under a heading that don't open with a marker.
An example, measured against COMPASS' own CLAUDE.md (189 lines):
7 proposals
4 unklar:
9 lines were not read as a rule: in quote blocks (> ...) …
8 lines were not read as a rule: in tables …
8 places were not read as a rule: bullets without an imperative marker …
60 places were not read as a rule: sentences under a heading that don't
start with an imperative marker …
Before, the same 7 proposals came with 0 unklar. Counted rather than listed
one by one, because nobody reads 85 lines — if you need a specific place, the
first line number per reason is there.
art: the same scan, a different write
art (form field, default claude-md) is claude-md | agents-md |
cursor-rules. All three run through the identical scan above — only what
apply later writes differs (see below). Use agents-md/cursor-rules when
importing those files instead of a CLAUDE.md.
Step 1 — preview
curl -sS -X POST "$COMPASS_URL/import/claude-md" \
-H "X-Compass-Key: $COMPASS_KEY_RO" \
-F 'text=## Security
- Always encrypt data at rest.
- Never log a password in plain text.'
Nothing is written. The response:
{
"neu": [
{"sache": "always-encrypt-data-at-rest", "titel": "Always encrypt data at rest",
"frage": "Was gilt für 'Always encrypt data at rest'?",
"regel_text": "Always encrypt data at rest.", "quelle_zeile": 3},
{"sache": "never-log-a-password-in-plain-text", "titel": "Never log a password in plain text",
"frage": "Was gilt für 'Never log a password in plain text'?",
"regel_text": "Never log a password in plain text.", "quelle_zeile": 4}
],
"unveraendert": [],
"weicht_ab": [],
"unklar": [],
"art": "claude-md"
}
Four groups, and they are the point of this step. The preview reads what you already have and says, for each proposal, how it relates to what already holds:
| Group | Meaning | What apply does with it |
|---|---|---|
neu |
no statement of this kind holds for that Sache yet | create it |
unveraendert |
the same wording already holds | nothing — it is not written twice |
weicht_ab |
a different wording holds | the holding statement is superseded (ersetzt) |
unklar |
the scanner was not confident | nothing, shown only |
Entries in unveraendert and weicht_ab also carry geltende with id,
text, gilt_seit and quelle — you see before applying what exactly
would be superseded.
A second import of the same file is therefore no longer the same worklist as the first.
Read unklar before applying anything — it names what the scanner saw but
wasn't confident enough to turn into a proposal.
When the file is not a rule file at all
The file path (-F "file=@…") checks two things before parsing, and reads
nothing if either fails:
| Case | Response |
|---|---|
| larger than 1 MB | 422 with feld: "file", byte (the measured size) and grenze |
| not a text file | 422 with feld: "file" and byte — the byte offset where it breaks (not a character index) |
"Not a text file" means: either the byte at that offset is not valid UTF-8, or there is a NUL byte there (a UTF-16 file is valid UTF-8 in stretches and still not a text file).
Nothing is guessed: no second attempt with a different encoding, no truncation to the limit. A wrongly guessed encoding would write a word like "naïve" as "na?ve" into your rulebook, and a silently mangled rule costs more than a rejected file.
One limit, so it surprises nobody:
sacheis derived from the title. For bullet points the bullet is the title — rewording a bullet rule produces a new slug, so the proposal shows up asneu, notweicht_ab, and the old rule keeps holding. Only where the title is separate from the rule text (headings,### R<n> — Title) does the comparison recognise a rewording.
Step 2 — review, then apply
POST /import/claude-md/apply writes both the Sache and its statement —
a regel for art=claude-md, a fakt for art=agents-md/art=cursor-rules
(see below). Pass the proposals straight through as items:
curl -sS -X POST "$COMPASS_URL/import/claude-md/apply" \
-H "X-Compass-Key: $COMPASS_KEY_RW" -H "Content-Type: application/json" \
-d '{"items": [
{"sache": "always-encrypt-data-at-rest", "titel": "Always encrypt data at rest",
"frage": "Was gilt für '"'"'Always encrypt data at rest'"'"'?",
"regel_text": "Always encrypt data at rest.", "quelle_zeile": 3}
]}'
Or select by slug instead, sending the same original text so the preview
can be regenerated and filtered server-side — useful when you don't want to
copy the full proposal objects around:
curl -sS -X POST "$COMPASS_URL/import/claude-md/apply" \
-H "X-Compass-Key: $COMPASS_KEY_RW" -H "Content-Type: application/json" \
-d '{"auswahl": ["always-encrypt-data-at-rest"], "text": "## Security\n\n- Always encrypt data at rest.\n- Never log a password in plain text.\n"}'
items or (auswahl + text) is required — 422 without either, 422 if
auswahl names a slug the regenerated preview doesn't contain.
The response:
{
"geschrieben": [{"sache": "always-encrypt-data-at-rest", "aussage_id": 101}],
"unveraendert": [],
"abgeloest": [],
"konflikt": [],
"quelle_ref_widerspruch": []
}
- The Sache is created (or reused unchanged if it already exists, idempotent
on slug, same as
POST /sachen). - The statement's
quelleis"<Dateiname>:<Zeile>"— the filename comes from the request'squellefield, defaulting perarttoCLAUDE.md/AGENTS.md/cursor-rules.mdc;quelle_refis"import:<art>:<Dateiname>:<Zeile>". - If the same wording already holds, nothing is written — the slug moves
into
unveraendert. That covers both ways COMPASS notices: applying the same proposal again (idempotent onquelle_ref) and the same rule text arriving under a different origin. - If the wording differs, the holding statement is superseded (only for
art=claude-md, i.e. aregel): the new statement points at the old one viaersetzt, and the entry appears inabgeloest: [{sache, aussage_id, loest_ab}]. Nothing is deleted — the old statement keeps its span of validity and stays visible in the history. The preview said this would happen, asweicht_ab. - The earlier behaviour (write nothing, report
konflikt) held until 2026-09-24.konfliktremains for the race: if someone else writes a holding rule for the same Sache between preview and apply, it is reported rather than guessed. The samequelle_refwith a differentregel_textthan before writes nothing either; it's reported inquelle_ref_widerspruch: [{sache, quelle_ref, bestehend}]instead — never a silent overwrite. - If the Sache already has a holding rule under a different
quelle_ref(e.g. another line of the same file, or an earlier manualPOST /aussagen), it is not replaced: the entry goes tokonflikt: [{sache, grund, geltende}], nothing written. This only applies toart=claude-md— onlyregelstatements are subject to the one-current-rule invariant.
Row-level security applies as everywhere: every row belongs to the tenant of
the presented key; a different tenant sees nothing, even with an identical
sache slug (the primary key is (kunde_slug, slug)).
Importing AGENTS.md or Cursor rules
Same two calls, art=agents-md or art=cursor-rules:
curl -sS -X POST "$COMPASS_URL/import/claude-md" \
-H "X-Compass-Key: $COMPASS_KEY_RO" \
-F "file=@AGENTS.md" -F "art=agents-md"
curl -sS -X POST "$COMPASS_URL/import/claude-md/apply" \
-H "X-Compass-Key: $COMPASS_KEY_RW" -H "Content-Type: application/json" \
-d '{"art": "agents-md", "items": [ ...from the preview... ]}'
The statement written here is a fakt (herkunft=gesagt), not a
regel — AGENTS.md and .cursor/rules/*.mdc both declare themselves
subordinate to CLAUDE.md ("Warum fakt, nicht regel"), and a second
regel on the same Sache would collide with the
one-current-rule invariant and be rejected. This means an art=agents-md
import next to an existing art=claude-md rule on the same Sache never
conflicts — they're different Aussage kinds. For .cursor/rules/*.mdc, run
both calls once per file.
Importing decisions instead of headings
If what you have is a log of dated decisions rather than a rule document,
POST /import/decisions is the better fit: it takes
{items: [{z, quote, context, decided_at, sache?, ersetzt_z?}]}, is
idempotent on the z number, and reports any conflict in a widerspruch
list rather than silently skipping or overwriting it. A decision whose
sache is missing or unknown is still recorded, as an event rather than a
statement, so nothing from the log is lost. This is a bruecke route — a
bruecke-scoped key works here even though it can't read or write most of
the rest of the API.
Assistant preview (status: prepared)
For text the deterministic scanner above does NOT catch (prose without an
imperative marker, sentences that describe a rule instead of commanding it),
there's a second, optional path: POST /assistent/vorschlaege
{text, art?: claude-md|agents-md|cursor-rules|prosa}. It runs the same
scanner as /import/claude-md first — anything the scanner already
recognizes gets no model call — and only sends the remainder to a
language model, if one is configured.
This path is prepared today, not enabled by default. The provider
choice is deliberately open: Anthropic (a current Haiku model) OR a local,
OpenAI-compatible model (e.g. Ollama) — the default is off. GET
/assistent/status → {anbieter, bereit, grund, verbrauch_heute, deckel}
shows whether any provider is active; without configuration bereit=false
with a plain-text reason (e.g. "COMPASS_MODELL_ANBIETER ist 'aus'
(Standard)"), and POST /assistent/vorschlaege then answers 503 —
unless the scanner already covered everything, in which case the response
stays 200 even with no provider configured.
Every model-produced suggestion additionally carries woertlich (only
true when regel_text is actually a substring of the original text —
checked server-side, never taken from the model unverified), an optional
bereich, and an optional pruefweg: {modul, parameter} (only with one of
the three modules that actually exist:
github_ruleset/ci_status/konfig_wert). This path writes nothing
either — adoption still goes through the same POST /import/claude-md/apply
as above; a suggestion here is deliberately shaped to work as an items
entry as-is (drop the extra fields first). A daily cap per customer
(COMPASS_MODELL_DECKEL_AUFRUFE_TAG/COMPASS_MODELL_DECKEL_TOKEN_TAG,
default 200 calls/400000 tokens) keeps an enabled provider from running over
budget unnoticed — exceeded → 429. The REST reference, section "Assistent",
shows the exact suggestion shape.
What this guide doesn't cover
There is no MCP tool for any import route — all four (/import/claude-md,
/import/claude-md/apply, /import/decisions, /assistent/vorschlaege)
are REST-only today.