diff --git a/README.md b/README.md
index 51e4258..d0db4d4 100644
--- a/README.md
+++ b/README.md
@@ -71,6 +71,8 @@ Inside a paragraph, list item, or header, the following inline formatting elemen
 
 **Variables** are written as `_x_` and are translated to `<var>x</var>`. Variables cannot contain whitespace or other formatting characters.
 
+**Fields** are written as `[[f]]` and are translated as `<var class="field">[[f]]</var>`. Field names must match regular expression `/^[a-zA-Z0-9_]+$/`.
+
 **Values** are written as `*x*` and are translated to `<emu-val>x</emu-val>`. Values cannot contain asterisks.
 
 **Code** is written as `` `x` `` and is translated to `<code>x</code>`. Code cannot contain backticks.
diff --git a/src/emitter.ts b/src/emitter.ts
index 11d313a..590203c 100644
--- a/src/emitter.ts
+++ b/src/emitter.ts
@@ -7,6 +7,7 @@ import type {
   TagNode,
   UnderscoreNode,
   StarNode,
+  DoubleBracketsNode,
   OrderedListItemNode,
   UnorderedListItemNode,
   OrderedListNode,
@@ -72,6 +73,9 @@ export class Emitter {
       case 'tilde':
         this.emitTilde(node);
         break;
+      case 'double-brackets':
+        this.emitFieldOrSlot(node);
+        break;
       case 'comment':
       case 'tag':
       case 'opaqueTag':
@@ -125,6 +129,10 @@ export class Emitter {
     this.str += `<var>${node.contents}</var>`;
   }
 
+  emitFieldOrSlot(node: DoubleBracketsNode) {
+    this.str += `<var class="field">[[${node.contents}]]</var>`;
+  }
+
   emitTag(tag: OpaqueTagNode | CommentNode | TagNode) {
     this.str += tag.contents;
   }
@@ -159,9 +167,9 @@ export class Emitter {
     this.str += '>' + pipe.nonTerminal + '</emu-nt>';
   }
 
-  wrapFragment(wrapping: string, fragment: Node[]) {
-    this.str += `<${wrapping}>`;
+  wrapFragment(tagName: string, fragment: Node[], attrs: string = '') {
+    this.str += `<${tagName}${attrs}>`;
     this.emitFragment(fragment);
-    this.str += `</${wrapping}>`;
+    this.str += `</${tagName}>`;
   }
 }
diff --git a/src/node-types.ts b/src/node-types.ts
index 253a8f8..b056df7 100644
--- a/src/node-types.ts
+++ b/src/node-types.ts
@@ -47,6 +47,12 @@ export type WhitespaceToken = {
   location: LocationRange;
 };
 
+export type DoubleBracketsToken = {
+  name: 'double-brackets';
+  contents: string;
+  location: LocationRange;
+};
+
 export type TextToken = {
   name: 'text';
   contents: string;
@@ -96,6 +102,7 @@ export type Token =
   | ParabreakToken
   | LinebreakToken
   | WhitespaceToken
+  | DoubleBracketsToken
   | TextToken
   | CommentToken
   | TagToken
@@ -168,6 +175,12 @@ export type PipeNode = {
   location: LocationRange;
 };
 
+export type DoubleBracketsNode = {
+  name: 'double-brackets';
+  contents: string;
+  location: LocationRange;
+};
+
 export type FormatNode = StarNode | UnderscoreNode | TickNode | TildeNode | PipeNode;
 
 export type UnorderedListNode = {
@@ -201,7 +214,13 @@ export type OrderedListItemNode = {
   location: LocationRange;
 };
 
-export type FragmentNode = TextNode | FormatNode | CommentNode | TagNode | OpaqueTagNode;
+export type FragmentNode =
+  | TextNode
+  | FormatNode
+  | CommentNode
+  | TagNode
+  | OpaqueTagNode
+  | DoubleBracketsNode;
 
 export type ListNode = UnorderedListNode | OrderedListNode;
 
@@ -211,6 +230,7 @@ export type Node =
   | CommentNode
   | AlgorithmNode
   | TextNode
+  | DoubleBracketsNode
   | StarNode
   | UnderscoreNode
   | TickNode
diff --git a/src/parser.ts b/src/parser.ts
index 7a109d6..55f622e 100644
--- a/src/parser.ts
+++ b/src/parser.ts
@@ -180,7 +180,12 @@ export class Parser {
             frag = frag.concat(f);
           }
         }
-      } else if (tok.name === 'comment' || tok.name === 'tag' || tok.name === 'opaqueTag') {
+      } else if (
+        tok.name === 'comment' ||
+        tok.name === 'tag' ||
+        tok.name === 'opaqueTag' ||
+        tok.name === 'double-brackets'
+      ) {
         frag.push(tok);
         this._t.next();
       } else if (isList(tok)) {
@@ -241,7 +246,12 @@ export class Parser {
         lastRealTok = lastWsTok;
       }
 
-      if (tok.name === 'opaqueTag' || tok.name === 'comment' || tok.name === 'tag') {
+      if (
+        tok.name === 'opaqueTag' ||
+        tok.name === 'comment' ||
+        tok.name === 'tag' ||
+        tok.name === 'double-brackets'
+      ) {
         break;
       }
 
@@ -289,6 +299,7 @@ export class Parser {
     opts: ParseFragmentOpts
   ): (TextNode | CommentNode | TagNode | FormatNode)[] {
     const startTok = this._t.next() as FormatToken;
+    const start = this.getPos(startTok);
     let contents: (TextNode | CommentNode | TagNode)[] = [];
 
     if (format === 'underscore') {
@@ -300,7 +311,6 @@ export class Parser {
     }
 
     const nextTok = this._t.peek();
-    const start = this.getPos(startTok);
 
     // fragment ended but we don't have a close format. Convert this node into a text node.
     if (nextTok.name !== format) {
diff --git a/src/tokenizer.ts b/src/tokenizer.ts
index 8262a19..2522f64 100644
--- a/src/tokenizer.ts
+++ b/src/tokenizer.ts
@@ -1,5 +1,6 @@
 import type { Unlocated, Token, AttrToken, Position } from './node-types';
 
+const fieldOrSlotRegexp = /^\[\[[a-zA-Z0-9_]+\]\]/;
 const tagRegexp = /^<[/!]?(\w[\w-]*)(\s+\w[\w-]*(\s*=\s*("[^"]*"|'[^']*'|[^><"'=`]+))?)*\s*>/;
 const commentRegexp = /^<!--[\w\W]*?-->/;
 const attrRegexp = /^\[ *[\w-]+ *= *"(?:[^"\\\x00-\x1F]|\\["\\/bfnrt]|\\u[a-fA-F]{4})*" *(?:, *[\w-]+ *= *"(?:[^"\\\x00-\x1F]|\\["\\/bfnrt]|\\u[a-fA-F]{4})*" *)*] /;
@@ -79,6 +80,13 @@ export class Tokenizer {
       if (chr === '\\') {
         out += this.scanEscape();
       } else if (isChars(chr)) {
+        out += chr;
+        this.pos++;
+      } else if (chr === '[') {
+        if (this.tryScanFieldOrSlot()) {
+          break;
+        }
+
         out += chr;
         this.pos++;
       } else if (chr === '<') {
@@ -115,13 +123,20 @@ export class Tokenizer {
     return this.str.slice(start, this.pos);
   }
 
-  // does not actually consume the tag
-  // you should manually `this.pos += tag[0].length;` if you end up consuming it
-  tryScanTag() {
-    if (this.str[this.pos] !== '<') {
+  // does not actually consume the field/slot
+  // you should manually `this.pos += result.length;` if you end up consuming it
+  tryScanFieldOrSlot() {
+    const match = this.str.slice(this.pos).match(fieldOrSlotRegexp);
+    if (!match) {
       return;
     }
 
+    return match[0];
+  }
+
+  // does not actually consume the tag
+  // you should manually `this.pos += tag[0].length;` if you end up consuming it
+  tryScanTag() {
     const match = this.str.slice(this.pos).match(tagRegexp);
     if (!match) {
       return;
@@ -297,6 +312,16 @@ export class Tokenizer {
           } else if (isChars(chr)) {
             this.enqueue({ name: 'text', contents: this.scanChars() }, start);
             return;
+          } else if (chr === '[') {
+            const fieldOrSlot = this.tryScanFieldOrSlot();
+            if (fieldOrSlot) {
+              this.pos += fieldOrSlot.length;
+              this.enqueue({ name: 'double-brackets', contents: fieldOrSlot.slice(2, -2) }, start);
+              return;
+            }
+
+            // didn't find a valid field/slot, so fall back to text.
+            this.enqueue({ name: 'text', contents: this.scanChars() }, start);
           } else if (chr === '<') {
             if (
               this.str[this.pos + 1] === '!' &&
@@ -432,7 +457,7 @@ function isWhitespace(chr: string) {
 }
 
 function isChars(chr: string) {
-  return !isFormat(chr) && chr !== '\n' && chr !== ' ' && chr !== '\t';
+  return !isFormat(chr) && chr !== '\n' && chr !== ' ' && chr !== '\t' && chr !== '[';
 }
 
 function isFormat(chr: string) {
diff --git a/src/visitor.ts b/src/visitor.ts
index 0ccca49..9c36179 100644
--- a/src/visitor.ts
+++ b/src/visitor.ts
@@ -6,6 +6,7 @@ const childKeys = {
   comment: [],
   algorithm: ['contents'],
   text: [],
+  'double-brackets': [],
   star: ['contents'],
   underscore: [],
   tick: ['contents'],
diff --git a/test/cases/formats-in-text.fragment.ecmarkdown b/test/cases/formats-in-text.fragment.ecmarkdown
index 974e424..8d79b54 100644
--- a/test/cases/formats-in-text.fragment.ecmarkdown
+++ b/test/cases/formats-in-text.fragment.ecmarkdown
@@ -1 +1 @@
-*star*s s*tars* _var_s v_ars_ `tick`s t`icks` |pipe|s p|ipes| ~tilde~s t~ildes~
+*star*s s*tars* _var_s v_ars_ `tick`s t`icks` |pipe|s p|ipes| ~tilde~s t~ildes~ a.[[b]] a.[[B]] a.[b] a.[[&lt;_b_>]] a.[[%b%]]
diff --git a/test/cases/formats-in-text.fragment.html b/test/cases/formats-in-text.fragment.html
index 6a42d0b..1992ebc 100644
--- a/test/cases/formats-in-text.fragment.html
+++ b/test/cases/formats-in-text.fragment.html
@@ -1 +1 @@
-<emu-val>star</emu-val>s s*tars* <var>var</var>s v_ars_ <code>tick</code>s t<code>icks</code> <emu-nt>pipe</emu-nt>s p|ipes| <emu-const>tilde</emu-const>s t~ildes~
+<emu-val>star</emu-val>s s*tars* <var>var</var>s v_ars_ <code>tick</code>s t<code>icks</code> <emu-nt>pipe</emu-nt>s p|ipes| <emu-const>tilde</emu-const>s t~ildes~ a.<var class="field">[[b]]</var> a.<var class="field">[[B]]</var> a.[b] a.[[&lt;<var>b</var>>]] a.[[%b%]]
diff --git a/test/cases/iterator-close.html b/test/cases/iterator-close.html
index 381bfb6..32108d3 100644
--- a/test/cases/iterator-close.html
+++ b/test/cases/iterator-close.html
@@ -5,7 +5,7 @@
   <li>ReturnIfAbrupt(<var>hasReturn</var>).<ol>
       <li>If <var>hasReturn</var> is <emu-val>true</emu-val>, then<ol>
           <li>Let <var>innerResult</var> be Invoke(<var>iterator</var>, <code>"return"</code>, ( )).</li>
-          <li>If <var>completion</var>.[[type]] is not <emu-const>throw</emu-const> and <var>innerResult</var>.[[type]] is <emu-const>throw</emu-const>, then<ol>
+          <li>If <var>completion</var>.<var class="field">[[type]]</var> is not <emu-const>throw</emu-const> and <var>innerResult</var>.<var class="field">[[type]]</var> is <emu-const>throw</emu-const>, then<ol>
               <li>Return <var>innerResult</var>.</li>
             </ol>
           </li>