This guide assumes you know nothing about Intuition. By the end you will have created a real comment on the knowledge graph, and — more importantly — you'll understand why each step exists, so every other write (notes, reviews, reactions) becomes obvious: they're all the same journey with a different vocabulary word.

Correction notice: older examples (and the retired reply predicate) showed (comment) → reply → (parent). That predicate was retired and never minted. The ratified shape is the reverse: (parent) → has-comment → (comment). This guide is the current truth; the spec lives in .planning/backlog/stacks-canonical-bootstrap-iid/triple-structure-spec.md.


0. The picture#

A comment on Intuition is exactly two things:

text
┌──────────────────┐      has-comment       ┌──────────────────────┐
│   parent atom    │ ─────────────────────▶ │    comment atom      │
│ (the thing being │      (a predicate      │ (a small JSON doc:   │
│   commented on)  │      from the ratified │  author, body,       │
│                  │      vocabulary)       │  parentId)           │
└──────────────────┘                        └──────────────────────┘
        subject                predicate              object
        └────────────────── one TRIPLE ──────────────────┘
  1. A comment atom — a small JSON document holding the comment text.
  2. A triple — the statement "this parent has the comment that atom."

Everything below is just making those two things exist, correctly.

1. The five words you need#

Word What it means The one thing to remember
Atom The smallest unit in the graph: a person, a URL, a document, a word. Just bytes. An atom is its bytes.
Atom ID A 0x… hash derived from the atom's exact bytes (calculateAtomId(data)). Change one byte → different atom. There is no "update."
Triple A three-part statement: subject → predicate → object. Each part is an atom ID. Triples are how atoms mean anything.
Predicate The verb of a triple (has-comment, follow, listed-in). Predicates are themselves atoms. You never invent predicates; you use the ratified vocabulary.
Term set The governed registry the predicates live in. Identity comes from (set URL, term code) — never from a display name. Import the IDs; never hand-compute or hardcode them.

One consequence worth internalizing before you write anything: because IDs are derived from exact bytes, identity is deterministic. Two developers on two continents building the same comment document get the same atom ID. That's the whole trick — and it's why "exact bytes" will come up in every step below.

2. Step 1 — Meet has-comment (your first term set entry)#

The predicates the Stacks app uses live in a ratified term set — a governed vocabulary published at https://intuition.systems/predicates. Each entry has a permanent kebab-case termCode. The one for comments is has-comment:

has-comment"The subject has the object comment." subject: item | claim | post | stack | perspective | comment item object: comment content item Covers root comments and nested replies (a reply is a comment on a comment).

How its on-chain identity is derived (you never do this by hand — the package does it — but seeing it once demystifies everything):

text
iid      = int:termset:<hash-of-the-set-URL>:has-comment    ← the IID string
atomData = the iid itself (the atom's exact bytes)
atomId   = calculateAtomId(atomData)                        ← the 0x… you use in triples

In code, you just import it:

ts
import { VOCABULARY_PREDICATES } from '@0xintuition/stacks/vocabulary'

const hasComment = VOCABULARY_PREDICATES.hasComment

hasComment.termCode  // 'has-comment'
hasComment.iid       // 'int:termset:c12a1c17…:has-comment'
hasComment.atomId    // '0x…'  ← this goes in your triple

The has-comment predicate atom already exists on-chain in every bootstrapped environment (the bootstrap plan installs the entire vocabulary), so you never create it — you only reference its atomId.

⚠️ Never hardcode a predicate ID, copy one from a database, or use anything from legacy-ids.ts. The import is the source of truth.

3. Step 2 — Build the comment object (the Comment classification)#

You don't hand-write the comment's JSON. The package defines a Comment content classification — a template that declares exactly which fields a comment has (author, body, parentId), validates your input, and normalizes the output so the bytes are deterministic:

ts
import { calculateAtomId } from '@0xintuition/ids'
import { createCommentContent } from '@0xintuition/stacks/content'

// Whatever you're commenting on — an item, claim, post, stack, or another
// comment. Here: an item atom for a URL.
const parentAtomId = calculateAtomId('https://arxiv.org/abs/1706.03762') as `0x${string}`

// The author is also an atom (e.g. a did:pkh identity for a wallet).
const authorAtomId = calculateAtomId('did:pkh:eip155:1:0x1234…abcd') as `0x${string}`

const comment = await createCommentContent({
	author: authorAtomId,
	body: 'Canonical background reading — belongs in every AI stack.',
	parentId: parentAtomId,
})

What you get back is everything the rest of the journey needs, precomputed:

ts
comment.document            // { '@context': …, '@type': 'Comment', author, body, parentId }
comment.normalizedDocument  // the EXACT canonical bytes (sorted keys, trimmed)
comment.atomId              // atom ID if you store the JSON bytes inline on-chain
comment.ipfsUri             // 'ipfs://…' — the CID those bytes will have on IPFS
comment.ipfsAtomId          // atom ID if you store the ipfs:// URI on-chain instead

Why a classification instead of freeform JSON? Two reasons. Determinism: createCommentContent sorts and normalizes fields, so the same input always produces the same bytes → the same atom ID (the invariant from §1). Legibility: every reader in the ecosystem — the app, indexers, other developers — recognizes @type: 'Comment' and knows exactly which fields to expect.

4. Step 3 — Choose the comment's identity, and pin to IPFS#

Notice you got two possible atom IDs back. That's a real choice:

Option Atom's on-chain bytes Atom ID to use Trade-off
Inline the full normalized JSON comment.atomId Self-contained; fine for short content
IPFS (what the app uses) the string ipfs://<cid> comment.ipfsAtomId Small fixed-size on-chain footprint; content fetched from IPFS

Pick one and never mix them — they are two different atoms with two different IDs. A triple pointing at comment.atomId and a triple pointing at comment.ipfsAtomId are unrelated statements.

If you choose IPFS (recommended), there's a step people always forget: comment.ipfsUri is computed — it's the CID your bytes will have — but nothing is on the IPFS network until you pin it:

ts
import { createPinataClientFromEnv } from '@0xintuition/ipfs-pinata'

const pinata = createPinataClientFromEnv() // needs PINATA_JWT in your env

const pinned = await pinata.uploadJson({ json: comment.document })

// Sanity check — deterministic CIDs mean these MUST match:
if (`ipfs://${pinned.cid}` !== comment.ipfsUri) {
	throw new Error('Pinned CID does not match computed URI — do not proceed.')
}

If you skip pinning, your atom still works (the ID is valid), but nobody can fetch the comment text — you've published a pointer to nothing.

5. Step 4 — Create the comment atom on-chain#

The atom becomes real when the MultiVault contract registers its exact bytes. Creation costs a fee — read it from the contract, never hardcode it:

ts
import {
	multiVaultCreateAtoms,
	multiVaultGetAtomCost,
} from '@0xintuition/protocol'
import { stringToHex } from 'viem'

const atomCost = await multiVaultGetAtomCost(config) // config: your WriteConfig

// The bytes MUST be exactly the identity you chose in Step 3:
const atomData = stringToHex(comment.ipfsUri) // IPFS path
// const atomData = stringToHex(comment.normalizedDocument) // inline path

await multiVaultCreateAtoms(config, {
	args: [[atomData], [atomCost]],
	value: atomCost,
})

Two production notes:

6. Step 5 — Create the triple (direction matters!)#

Now connect them. The ratified direction is parent first:

text
(parent) → has-comment → (comment)     ✅ ratified
(comment) → reply → (parent)           ❌ retired shape — never use

Memory hook: read it aloud — "this post has [a] comment: that one." The one-to-many rule from the spec: one post has many comments, so the "one" (the post) is the subject.

ts
import { calculateTripleId } from '@0xintuition/ids'
import {
	multiVaultCreateTriples,
	multiVaultGetTripleCost,
} from '@0xintuition/protocol'
import { VOCABULARY_PREDICATES } from '@0xintuition/stacks/vocabulary'

const hasCommentId = VOCABULARY_PREDICATES.hasComment.atomId
const commentAtomId = comment.ipfsAtomId // must match the identity you created!

// Deterministic, like atom IDs — computable before the transaction:
const tripleId = calculateTripleId(parentAtomId, hasCommentId, commentAtomId)

const tripleCost = await multiVaultGetTripleCost(config)

await multiVaultCreateTriples(config, {
	args: [[parentAtomId], [hasCommentId], [commentAtomId], [tripleCost]],
	value: tripleCost,
})

As with atoms: check whether tripleId already exists before creating.

Who wrote the comment? Notice the commenter is not in the triple — the triple is the claim itself; attribution comes from the author field inside the comment document, from the statement row the app writes, and from on-chain deposits on the triple. This "claimant is not in the triple" rule is what makes one triple = one shared market.

7. Step 6 — Verify#

You're done when all three are true:

  1. The atom resolves: the atom ID exists on-chain, and (IPFS path) fetching comment.ipfsUri through a gateway returns your exact document.
  2. The triple exists: calculateTripleId(parent, hasComment, comment) is registered on-chain.
  3. A reader shows it: the indexer has picked it up and the comment renders under its parent.

8. The whole journey, end to end#

ts
import { calculateAtomId, calculateTripleId } from '@0xintuition/ids'
import { createPinataClientFromEnv } from '@0xintuition/ipfs-pinata'
import {
	multiVaultCreateAtoms,
	multiVaultCreateTriples,
	multiVaultGetAtomCost,
	multiVaultGetTripleCost,
} from '@0xintuition/protocol'
import { createCommentContent } from '@0xintuition/stacks/content'
import { VOCABULARY_PREDICATES } from '@0xintuition/stacks/vocabulary'
import { stringToHex } from 'viem'

// 1. The predicate — imported, never computed (already on-chain via bootstrap)
const hasCommentId = VOCABULARY_PREDICATES.hasComment.atomId

// 2. The comment object — built through the Comment classification
const parentAtomId = calculateAtomId('https://arxiv.org/abs/1706.03762') as `0x${string}`
const authorAtomId = calculateAtomId('did:pkh:eip155:1:0x1234…abcd') as `0x${string}`

const comment = await createCommentContent({
	author: authorAtomId,
	body: 'Canonical background reading — belongs in every AI stack.',
	parentId: parentAtomId,
})

// 3. Pin the document so the IPFS identity resolves
const pinata = createPinataClientFromEnv()
const pinned = await pinata.uploadJson({ json: comment.document })
if (`ipfs://${pinned.cid}` !== comment.ipfsUri) throw new Error('CID mismatch')

// 4. Create the comment atom (skip if it already exists)
const atomCost = await multiVaultGetAtomCost(config)
await multiVaultCreateAtoms(config, {
	args: [[stringToHex(comment.ipfsUri)], [atomCost]],
	value: atomCost,
})

// 5. Create the triple: (parent) → has-comment → (comment)
const tripleId = calculateTripleId(parentAtomId, hasCommentId, comment.ipfsAtomId)
const tripleCost = await multiVaultGetTripleCost(config)
await multiVaultCreateTriples(config, {
	args: [[parentAtomId], [hasCommentId], [comment.ipfsAtomId], [tripleCost]],
	value: tripleCost,
})

// 6. Done: `tripleId` is the comment's permanent address in the graph.

9. Common mistakes (each one has bitten someone)#

Mistake Symptom Rule that prevents it
Using the retired reply predicate or the flipped direction Triple exists but no reader ever shows your comment Ratified shape only: (parent) → has-comment → (comment) (§6)
Mixing atomId and ipfsAtomId Atom created with one identity, triple pointing at the other — orphan statement about an atom that doesn't exist Choose one identity in §4, use it everywhere
Forgetting to pin Comment renders as unresolvable/empty content Pin, then assert the CID matches comment.ipfsUri (§4)
Editing bytes after computing IDs (re-serializing JSON, trimming, re-ordering keys) On-chain atom ID ≠ computed ID; nothing links up Only ever use normalizedDocument / ipfsUri verbatim
Hardcoding predicate IDs Works until an environment is bootstrapped fresh, then silently wrong Always import from @0xintuition/stacks/vocabulary
Hardcoding costs Reverts when the contract's cost changes Read multiVaultGetAtomCost / multiVaultGetTripleCost per transaction
Re-creating existing atoms/triples Wasted gas or revert Read before write; determinism means you can compute every ID up front

10. Where this takes you next#

For the full predicate catalog and shapes, see the data-structure packages and Intuition documentation.