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
replypredicate) 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:
┌──────────────────┐ 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 ──────────────────┘
- A comment atom — a small JSON document holding the comment text.
- 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 itemobject:comment content itemCovers 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):
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:
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:
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:
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:
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:
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:
- Check existence first. Atom creation is not idempotent on-chain — look up whether the atom ID already exists (indexer/API read) and skip creation if so. Same comment bytes from another user = same atom; that's a feature.
- The parent and author atoms must also exist. In the app they virtually always already do (you're commenting on something); if you're building standalone, create them the same way first.
6. Step 5 — Create the triple (direction matters!)#
Now connect them. The ratified direction is parent first:
(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.
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:
- The atom resolves: the atom ID exists on-chain, and (IPFS path) fetching
comment.ipfsUrithrough a gateway returns your exact document. - The triple exists:
calculateTripleId(parent, hasComment, comment)is registered on-chain. - A reader shows it: the indexer has picked it up and the comment renders under its parent.
8. The whole journey, end to end#
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#
- A reply is just this guide again with the parent being a comment atom:
(comment A) → has-comment → (comment B). No new concepts. - A like on a comment:
(comment) → has-reaction → (canonical 👍 atom)— same journey, different vocabulary word, and the object is a shared canonical emoji atom rather than a new document. - Notes and reviews: identical shape with
has-note/has-reviewand theNote/Reviewclassifications from@0xintuition/stacks/content. - Conviction: users don't re-create a triple to agree with it — they deposit on its market (and the app writes a statement row). One triple, one market, many voices.
For the full predicate catalog and shapes, see the data-structure packages and Intuition documentation.