# JFM (JIRA-Flavored Markdown) Specification
## Overview
JFM provides bidirectional conversion between Markdown and Atlassian Document
Format (ADF), enabling JIRA Cloud issues and Confluence Cloud pages to be
read, edited, and updated as local markdown files.
For *why* this layer exists — its advantages over editing raw ADF JSON
directly — see [Why JFM?](../why-jfm.md). This document covers the syntax
itself.
## JFM Document Format
A JFM document consists of YAML frontmatter followed by a markdown body,
separated by `---` delimiters. The `type` field in the frontmatter
discriminates between JIRA and Confluence content.
### JIRA Issue
```markdown
---
type: jira
instance: https://myorg.atlassian.net
key: PROJ-123
summary: Issue title here
status: In Progress
issue_type: Story
assignee: Alice Smith
priority: High
labels:
- backend
- auth
---
Markdown body content describing the issue.
```
### Confluence Page
```markdown
---
type: confluence
instance: https://myorg.atlassian.net
page_id: "12345"
title: Architecture Overview
space_key: ENG
status: current
version: 7
---
Page body content here.
```
### JIRA Frontmatter Fields
| `type` | Yes | Always `"jira"` |
| `instance` | Yes | Atlassian Cloud instance URL (informational; see note below) |
| `key` | No | JIRA issue key (e.g., `PROJ-123`). Absent when creating a new issue. |
| `project` | No | Project key (e.g., `PROJ`). Used for issue creation when `key` is absent. |
| `summary` | Yes | Issue title/summary |
| `status` | No | Issue status (read-only from JIRA) |
| `issue_type` | No | Issue type (Bug, Story, Task, etc.) |
| `assignee` | No | Assigned user display name |
| `priority` | No | Issue priority level |
| `labels` | No | List of issue labels |
### Confluence Frontmatter Fields
| `type` | Yes | Always `"confluence"` |
| `instance` | Yes | Atlassian instance URL (informational; see note) |
| `page_id` | No | Confluence page ID. Absent when creating a new page. |
| `title` | Yes | Page title |
| `space_key` | Yes | Space key (e.g., `ENG`) |
| `status` | No | Page status (`"current"` or `"draft"`) |
| `version` | No | Page version number (for optimistic locking) |
| `parent_id` | No | Parent page ID |
> **Note on `instance`:** This field records the Atlassian instance URL but is
> **not** used to route requests — the target instance is resolved from
> `omni-dev atlassian jira create --instance <URL>` (when given), otherwise from
> the `ATLASSIAN_INSTANCE_URL` environment variable / `settings.json`. It is
> required by the canonical format above (the read → edit → write round-trip,
> and `read` populates it), but **`jira create` is lenient**: it treats every
> frontmatter field — `instance` included — as optional, filling any gaps from
> CLI flags, and even accepts a body with no frontmatter block at all.
### Creating from Frontmatter
The create surfaces resolve their target from the document frontmatter, so the
*read → edit → create* round-trip works: read an existing issue/page, edit the
body and/or frontmatter, and send the whole document back as a create.
- **CLI:** `omni-dev atlassian {jira,confluence} create <file>` reads the
frontmatter directly. For JIRA the project is taken from `project:`, or
derived from the prefix of `key:` (e.g. `PROJ` from `PROJ-123`).
- **MCP:** the `jira_create` / `confluence_create` tools take an optional
**`document`** parameter holding the full JFM document (the output of
`jira_read` / `confluence_read`). Its body becomes the description/page body;
`project`/`summary`/`issue_type`/`labels`/`custom_fields` (JIRA) or
`space_key`/`title`/`parent_id` (Confluence) come from its frontmatter. Passing
both `document` and `description`/`content` is an error.
**Precedence:** an explicit flag (`--project`, `--space`, …) or MCP parameter
(`project`, `space_key`, …) **overrides** the matching frontmatter field. When an
override shadows a value that was present in the frontmatter, a `warning:` is
emitted — on **stderr** for the CLI, and **prepended in-band** to the tool result
for the MCP tools (whose callers cannot see stderr) — so the agent knows its
frontmatter value was ignored.
### Issue Key Validation
Issue keys must match the pattern `^[A-Z][A-Z0-9]+-\d+$`:
- Starts with an uppercase letter
- Followed by uppercase letters or digits
- A hyphen
- One or more digits
### Parsing Rules
- Frontmatter must begin at the first line with exactly `---`
- Frontmatter ends at the next `---` on its own line
- The body may safely contain `---` (only the first occurrence after the
opening delimiter closes the frontmatter)
- Empty body is valid
- Trailing newlines are preserved
- Optional fields omitted from YAML when `None` or empty
## Atlassian Document Format (ADF)
ADF is JIRA's native rich-text format. JFM converts between markdown and
ADF v1.
### ADF Structure
```json
{
"version": 1,
"type": "doc",
"content": [
{
"type": "paragraph",
"content": [
{ "type": "text", "text": "Hello " },
{ "type": "text", "text": "world", "marks": [{ "type": "strong" }] }
]
}
]
}
```
### Supported Block Nodes
| `heading` | `# H1` through `###### H6` |
| `paragraph` | Plain text |
| `codeBlock` | Fenced code blocks (`` ``` ``) |
| `bulletList` | `- item` or `* item` |
| `orderedList` | `1. item` |
| `taskList` | `- [ ] todo` / `- [x] done` |
| `blockquote` | `> text` |
| `rule` | `---`, `***`, or `___` |
| `table` | Pipe tables or `::::table` directive (see below) |
| `mediaSingle` | `{attrs}` with optional `:::caption` block |
| `mediaInline` | `:media-inline[]{attrs}` inline directive |
| `blockCard` | `::card[url]{attrs}` leaf directive |
| `embedCard` | `::embed[url]{attrs}` leaf directive |
| `panel` | `:::panel{type=info}` container directive |
| `expand` | `:::expand{title=...}` container directive |
| `nestedExpand` | `:::nested-expand{title=...}` container directive |
| `layoutSection` | `::::layout` with `:::column` children |
| `decisionList` | `:::decisions` with `- <> item` children |
| `extension` | `::extension{attrs}` leaf directive |
| `bodiedExtension` | `:::extension{attrs}` container directive |
### Supported Inline Nodes
| `text` | Plain text (with marks applied) |
| `hardBreak` | `\` + newline |
| `emoji` | `:name:{shortName=... id=... text=...}` |
| `status` | `:status[text]{color=... style=... localId=...}` |
| `date` | `:date[YYYY-MM-DD]{timestamp=EPOCHMS}` |
| `mention` | `:mention[Name]{id=... userType=... accessLevel=...}`|
| `inlineCard` | `:card[url]{localId=...}` |
| `placeholder` | `:placeholder[text]` |
| `mediaInline` | `:media-inline[]{type=... id=... collection=...}` |
| `inlineExtension` | `:extension[fallback]{type=... key=...}` |
### Supported Marks
| `strong` | `**bold**` |
| `em` | `*italic*` |
| `code` | `` `code` `` |
| `strike` | `~~strikethrough~~` |
| `link` | `[text](url)` |
| `underline` | `[text]{underline}` |
| `textColor` | `:span[text]{color=#rrggbb}` |
| `backgroundColor` | `:span[text]{bg=#rrggbb}` |
| `subsup` | `:span[text]{sub}` or `:span[text]{sup}` |
| `annotation` | `[text]{annotation-id=... annotation-type=...}` |
| `alignment` | Trailing block attr: `{align=center}` |
| `indentation` | Trailing block attr: `{indent=N}` |
| `breakout` | Trailing block attr: `{breakout=wide breakoutWidth=N}` |
| `border` | On media/table cells: `border-color=#hex border-size=N` |
> **Inline `code` in headings.** ADF's `heading` content model forbids the
> `code` mark (a heading styles its own text, and Atlassian renders no
> inline-code styling on headings). A heading authored with backticks —
> e.g. `` ### `GET /api/services/example` `` — has its `code` mark stripped
> during JFM→ADF conversion, keeping the text as plain, and a warning is
> emitted naming the heading. The conversion is intentionally lossy in the
> safe direction: without stripping, the document would be rejected by the
> mark validator at write time (issue #1005).
> **Mark combinations.** ADF models inline text marks as two
> mutually-exclusive groups: a *monospace* run (`code`, optionally with
> `link`/`annotation`) and a *styled* run (`strong`, `em`, `strike`,
> `underline`, `subsup`, `textColor`, `backgroundColor`, optionally with
> `link`/`annotation`). Marks from the two groups cannot apply to the same
> text — most commonly, `code` cannot combine with `strong`, `em`,
> `textColor`, etc. So `` **`text`** `` (bold + monospace) is invalid ADF,
> even though each mark is individually legal. The API rejects such a
> document as an opaque `INVALID_INPUT`; the mark validator catches it at
> write time (and under `--dry-run`) with a `cannot be combined with`
> message naming the two conflicting marks (issue #1047). The groups are
> transcribed from the `code_inline_node` / `formatted_text_inline_node`
> variants in the pinned `@atlaskit/adf-schema`.
### Unsupported Node Handling
ADF nodes that cannot be represented in markdown are serialized as fenced
code blocks with language `adf-unsupported`:
````markdown
```adf-unsupported
{"type":"unknownNode","attrs":{"key":"value"}}
```
````
On conversion back to ADF, these blocks are deserialized and restored to
their original ADF structure, enabling lossless round-trips for unsupported
content.
## Content Model Constraints
ADF uses a strict content model: each container node permits only a specific
set of child node types, and each parent's content sequence is constrained by
quantifiers (`?`, `*`, `+`, `{n}`, `{m,n}`). Atlassian's APIs reject
documents that violate the model, often as an opaque HTTP 500 with no
indication of which nesting was at fault. JFM directives parse permissively —
`:::expand` inside `:::panel` produces well-formed ADF, but the API will
refuse it.
### Source of truth
The full content model for every container node is encoded in
[`src/atlassian/adf_schema/mod.rs`](../../src/atlassian/adf_schema/mod.rs),
transcribed faithfully from the upstream `@atlaskit/adf-schema` npm package
per [ADR-0023](../adrs/adr-0023.md). The pinned upstream version is recorded
in the `SCHEMA_VERSION` and `UPSTREAM_TARBALL_SHA256` constants in that
module. Treat the module as authoritative; the prose below is illustrative.
Public helpers expose the model:
- `adf_schema::allowed_children(parent)` — returns the union of allowed
direct children for a parent node type, or `None` for leaf / unknown
types.
- `adf_schema::content_model(parent)` — returns the full sequence of
quantified content terms for a parent (preserves ordering and arity).
- `adf_schema::permits_child(parent, child)` — `true` if `child` is permitted
as a direct child of `parent`. Permissive on unknown parents (returns
`true`) so that future Atlassian node types do not break round-trips.
- `adf_schema::validate_document(&doc)` — depth-first walker that returns
every nesting **and** arity violation in document order, with
`parent_type`, `child_type` (or quantifier diagnostic), and an index path
from the document root.
### Enforcement on writes
The validator is wired into every JFM-driven write path so violations abort
locally with a clear diagnosis instead of producing an opaque HTTP 500:
- `adf_validated::ValidatedAdfDocument::try_new` is the only constructor for
the `ValidatedAdfDocument` newtype that the Confluence and JIRA write APIs
accept, making "I forgot to validate" a compile error.
- `omni-dev confluence write` and `omni-dev confluence create` (and their
MCP tool equivalents) print every violation via the dry-run helper before
any network call.
- On HTTP 500 from a Confluence write that did pass local validation, the
client re-runs `validate_document` against the submitted body and attaches
the first violation (with a hint from `adf_hints::hint_for`) to the error
via `AtlassianError::ApiRequestFailedWithDiagnosis`.
### Common pitfalls
These illustrate the kinds of constraint the schema encodes; they are not an
exhaustive list. Consult the schema module for the full set.
- **`panel`** does not permit `expand`, `nestedExpand`, `panel`,
`bodiedExtension`, `blockquote`, `layoutSection`, or `table`. Its content
is paragraphs, headings, lists (bullet, ordered, decision, task), code
blocks, media, rules, extensions, and block cards.
- **`expand`** does not permit another `expand`, but **does** permit
`nestedExpand` as a child. It also does not permit `bodiedExtension` or
`layoutSection`.
- **`nestedExpand`** has a tighter content model than `expand`: it does not
permit `expand`, `nestedExpand`, `table`, `blockCard`, `embedCard`, or
`bodiedExtension`. It **does** permit `panel` and `blockquote`.
- **`tableCell`** and **`tableHeader`** permit `nestedExpand` but **not**
`expand`. They also do not permit nested `table` or `layoutSection`. Use
`:::nested-expand` instead of `:::expand` inside table cells.
- **`blockquote`** is restrictive: it permits paragraphs, lists (bullet,
ordered), code blocks, media, and extensions only. It does not permit
headings, tables, panels, expands, decision lists, task lists, or further
blockquotes.
- **`listItem`** permits paragraphs, code blocks, media, extensions, and
nested lists (bullet, ordered, task). It does not permit headings,
blockquotes, panels, expands, decision lists, tables, or layout sections.
- **`layoutSection`** permits only `layoutColumn` children — layout sections
cannot be nested directly. Use multiple `:::column`s within a single
`::::layout` instead.
- **`decisionItem`** and **`taskItem`** are inline-only — they cannot
contain block content.
### Workarounds
When the desired nesting is rejected, common rewrites are:
- **`expand` inside `panel`**: invert the nesting (place the panel inside
the expand), or render the two as siblings.
- **`expand` inside a table cell**: use `:::nested-expand` instead.
- **List, decision, or task list inside `> blockquote`**: render the quoted
text as a paragraph and place the list as a sibling block.
- **Nested layout sections**: collapse to a single `::::layout` with
multiple `:::column` children.
- **Rich blocks (expand, panel, layout) inside a table cell**: keep them as
siblings of the table rather than embedding them.
### Forward-compatibility notes
- `unsupportedBlock` and `unsupportedInline` (the runtime preservation
wrappers behind the `adf-unsupported` fenced block) are accepted under any
parent by the validator, regardless of the parent's allowed-children set,
and count toward the parent's arity. This preserves the round-trip
guarantee from [ADR-0020](../adrs/adr-0020.md) for nodes the snapshot
does not yet model.
- Unknown parent node types are treated permissively: their subtrees are
not walked. A future Atlassian node type therefore does not become a
validation failure until its content model is added to the schema.
### Coverage and limits
As of `SCHEMA_VERSION 56.0.9-2026-06-30`, the validator covers:
- Allowed-children sets for every container node type.
- Per-term quantifiers and content-term sequences (e.g. empty `bulletList`,
two-`media` `mediaSingle`, or a `layoutSection` with one column are all
reported as `AdfSchemaViolation::Arity`).
- Per-context mark allow-lists (which marks may apply to which nodes — e.g.
`code` is rejected on `heading`) and per-mark attribute schemas, reported as
`AdfSchemaViolation::DisallowedMark` / `InvalidMarkAttr`.
- Node attribute-value schemas (allowed values for `panel.panelType`,
`status.color`, `heading.level`, etc.), reported as
`AdfSchemaViolation::MissingAttr` / `InvalidAttr`.
### Nesting depth limit
JFM → ADF conversion caps nesting at **64 levels** (issue #1130):
- **Block containers** (blockquotes, lists, panels, expands, layout columns,
directive-table cells) past the cap fail the conversion with a clean
"maximum depth" error.
- **Inline nesting** (emphasis, strikethrough, link text, bracketed spans,
`:span` directives) past the cap degrades gracefully: the remaining run is
emitted as literal text.
The opposite ADF → JFM direction is independently bounded by serde_json's
128-level recursion limit when the ADF JSON is parsed. Realistic documents
nest well under 20 levels; the cap exists to stop pathological input from
overflowing the stack.
## Generic Directive System
JFM uses the CommonMark Generic Directives proposal to represent ADF-specific
constructs that have no native markdown equivalent. Three directive levels
are supported:
### Inline Directives
Syntax: `:name[content]{attrs}`
Used for inline semantic elements within text:
```markdown
The status is :status[In Progress]{color=blue} and assigned to
:mention[Alice]{id=abc123}.
The deadline is :date[2026-04-15].
Click the :placeholder[Type something...] field to begin.
See :media-inline[]{type=file id=UUID collection=NAME} for details.
```
- Content in `[...]` is **required**
- Attributes in `{...}` are optional
- Name must be alphabetic characters and hyphens
### Leaf Block Directives
Syntax: `::name[content]{attrs}`
Used for standalone block-level elements:
```markdown
::card[https://example.com/page]{width=80}
```
- Exactly two colons (not three)
- Content in `[...]` is optional
- Must occupy its own line
### Container Directives
Syntax: `:::name{attrs}` ... `:::`
Used for block-level containers wrapping other content:
```markdown
:::panel{type=info}
This is an informational panel with **rich** content.
- Item one
- Item two
:::
```
- Three or more colons to open
- Closed by matching colon count with no name
- Content between open/close is parsed as markdown
- Attributes are optional
### Attribute Syntax
Attributes follow Pandoc-style `{key=value flag}` syntax:
```
{type=info} # simple key-value
{color="bright red"} # quoted value with spaces
{bg=#DEEBFF numbered} # mixed key-value and flag
{title="Click to expand"} # quoted string
{params='{"jql":"project=PROJ"}'} # single-quoted JSON value
```
- Keys: alphanumeric, hyphens, underscores
- Values: unquoted (stop at whitespace/`}`) or quoted (single/double)
- Flags: bare words treated as boolean true
- Round-trip safe: `parse -> render -> parse` preserves structure
## Markdown to ADF Conversion
The converter uses a line-oriented parser that processes blocks in order:
1. Headings (`# ` through `###### `)
2. Horizontal rules (`---`, `***`, `___`)
3. Container directives (`:::name{attrs}` ... `:::`)
4. Fenced code blocks (`` ``` ``)
5. Tables (pipe-delimited with separator row)
6. Blockquotes (`> `)
7. Lists (`- `, `* `, `1. `, `- [ ] `, `- [x] `)
8. Leaf directives (`::name[content]{attrs}`)
9. Images (``)
10. Paragraphs (default fallback)
Inline content within paragraphs is parsed for:
- Bold, italic, code, strikethrough
- Links and bare URLs
- Inline directives (status, date, mention, emoji)
- Bracketed spans with attributes (`[text]{color=red}`, `[text]{annotation-id=...}`)
### ADF to Markdown
Block nodes are rendered to their markdown equivalents. Inline nodes
have marks applied (bold, italic, etc.) and semantic nodes render as
directives.
### Block Attributes
Block-level attributes can follow a block on a separate line:
```markdown
# Section Title
{align=center breakout=wide}
```
Supported attributes: `align`, `indent`, `breakout`.
### Inline Attribute Marks
Bracketed spans `[text]{attrs}` represent inline marks that have no native
markdown syntax. Multiple attributes can be combined in a single span.
#### Underline
```markdown
[underlined text]{underline}
```
#### Annotation (Inline Comments)
Confluence inline comments attach an `annotation` mark to highlighted text.
The mark links the text span to a comment thread stored in Confluence's
comment system. JFM preserves these marks for round-trip fidelity:
```markdown
[highlighted text]{annotation-id="abc123" annotation-type=inlineComment}
```
- `annotation-id`: the annotation identifier (required)
- `annotation-type`: the annotation type, typically `inlineComment` (required)
- Annotations can coexist with other marks (bold, italic, etc.):
`[**bold comment**]{annotation-id="abc123" annotation-type=inlineComment}`
## Table Rendering Modes
Tables use one of two rendering modes depending on cell complexity:
### Pipe Tables (GFM)
Used when all cells contain simple inline content (single paragraph, no hard
breaks, no cell-level marks, no paragraph localIds) and the first row has at
least one `tableHeader`:
```markdown
| cell | cell |
```
### Directive Tables
Used when any cell contains complex content (multiple paragraphs, hard breaks,
code blocks, nested lists, border marks, or paragraph-level localIds):
```markdown
::::table{layout=default}
:::tr
:::th{colspan=2}
Header spanning two columns
:::
:::
:::tr
:::td{border-color=#091e42 border-size=2}
Cell with border mark
:::
:::td
Simple cell
:::
:::
::::
```
Table-level attributes include `layout`, `width`, `numbered`/`numbered=false`,
and `isNumberColumnEnabled`.
## Media Nodes
### `mediaSingle` with Image
File-hosted media:
```markdown
![alt](){type=file id=UUID collection=NAME width=N height=N}
```
The `occurrenceKey` attribute is preserved when present on the ADF `media`
node:
```markdown
![alt](){type=file id=UUID collection=NAME occurrenceKey=KEY width=N height=N}
```
External media:
```markdown
{layout=center width=600}
```
### `mediaSingle` with Caption
A `:::caption` block immediately following the image line attaches a caption
to the `mediaSingle` node:
```markdown
![alt](){type=file id=UUID collection=NAME}
:::caption{localId=abc123}
Caption text with **formatting**
:::
```
The caption's `localId` is optional.
### `mediaInline`
Inline media uses the `:media-inline` directive:
```markdown
Text with :media-inline[]{type=file id=UUID collection=NAME} embedded.
```
For external inline media:
```markdown
Text with :media-inline[]{type=external url=https://example.com/file.pdf alt=document} here.
```
### Border Mark on Media
The `border` mark on a media node is expressed as additional attributes on the
image:
```markdown
![alt](){type=file id=UUID collection=NAME border-color=#091e4224 border-size=2}
```
When parsing, `border-color` defaults to `#000000` and `border-size` defaults
to `1` when only one is present.
## `localId` Preservation
Many ADF nodes carry a `localId` attribute used by JIRA and Confluence for
task item state tracking, inline comment anchoring, and other stateful
features. JFM preserves these for round-trip fidelity.
### Syntax
For directive-based nodes, `localId` appears as an attribute:
```markdown
:::expand{title="Details" localId=abc-123}
Content here
:::
```
For standard markdown nodes (headings, paragraphs), `localId` appears on a
trailing block-attributes line:
```markdown
# Section Title
{localId=abc-123}
```
For list items, `localId` is appended inline to avoid misattribution to the
parent list node:
```markdown
- Item text {localId=item-id paraLocalId=para-id}
```
The `paraLocalId` attribute preserves the localId of a `paragraph` wrapper
inside a `taskItem` when the original ADF used paragraph children rather than
direct inline content.
### Suppression
- Null UUIDs (`00000000-0000-0000-0000-000000000000`) and empty strings are
suppressed during rendering.
- The `strip_local_ids` render option omits all localIds for clean display
output where round-trip fidelity is not needed.
### Special Cases
- `expand` and `nestedExpand` store `localId` as a top-level ADF field
(`node.local_id`) rather than inside `attrs`. JFM renders it in the
directive attributes alongside `title` and `params`.
- `listItem` nodes with a `mediaSingle` first child preserve their `localId`
in the trailing inline attributes.
## Text Escaping for Round-Trip Safety
Plain text that would be reinterpreted by the markdown parser on the return
trip is escaped during ADF-to-markdown rendering. Each escape targets a
specific ambiguity:
| Pattern | Escape | Prevents |
|------------------------|---------------------------|-------------------------------------------|
| `*` in text | `\*` | Spurious bold/italic |
| `` ` `` in text | `` \` `` | Spurious code spans |
| `[` `]` in link text | `\[` `\]` | Link syntax ambiguity |
| `http://` `https://` | `\http://` | Auto-link / inlineCard detection |
| `:name:` in text | `\:name:` | Emoji shortcode parsing |
| Trailing double-spaces | `\ ` (escaped last space) | `hardBreak` misinterpretation |
| `\` in text | `\\` | Silent backslash consumption |
| Literal newline in text| `\n` (two characters) | Paragraph splitting |
| `N. ` at line start | `N\. ` | Ordered list re-parsing (in continuations)|
| `- ` at line start | `\- ` | Bullet list re-parsing (in continuations) |
Escaping is applied only outside code spans and fenced code blocks, where the
markdown parser would otherwise reinterpret the content.
## Authentication
- **Method**: HTTP Basic Auth (base64-encoded `email:api_token`)
- **Credential sources** (checked in order):
1. Environment variables
2. `~/.omni-dev/settings.json` `env` map
- **Required keys**:
- `ATLASSIAN_INSTANCE_URL`
- `ATLASSIAN_EMAIL`
- `ATLASSIAN_API_TOKEN`
- Same credentials serve both JIRA and Confluence (same Atlassian instance)
## Error Types
| Error | Cause |
|------------------------|------------------------------------------------|
| `CredentialsNotFound` | No credentials configured |
| `ApiRequestFailed` | HTTP error from API (includes status + body) |
| `InvalidDocument` | JFM parse error (bad YAML, missing delimiters) |
| `ConversionError` | ADF conversion failure |