Seed Protocol
Guides

Read published Seed data from another app or a server

List and fetch published items as JSON with @seedprotocol/query, read an item at an earlier Version or several at once, choose between the remote EAS source and the local SQLite source, and drop to @seedprotocol/eas for raw attestations.

Anything published with Seed Protocol is public: the record is on EAS and the content is on Arweave. Any app can read it, not just the one that published it. This guide reads published Post items as JSON from a Node script or server with @seedprotocol/query, which doesn't need the SDK's local database, a wallet or a schema file.

Reach for this when you're building a static site, an SSR page, a JSON API or a second app on top of content someone else's app published.

You'll need:

  • Node. @seedprotocol/query has a browser build too, but the examples here run in Node.
  • Some published items. The examples read post items from OP Sepolia, the testnet Seed Protocol runs on, where other apps have already published posts. To publish your own, see Publishing your first item.

Install

npm install @seedprotocol/query

That's the only package you need for reading. It brings @seedprotocol/eas (attestation reads) and @seedprotocol/arweave (content reads) with it.

List a model's published items

queryBySchema takes a model's EAS schema name and returns its published items, newest first:

list-posts.ts
import { queryBySchema } from '@seedprotocol/query'

const { items } = await queryBySchema('post', { limit: 20 })

for (const post of items) {
  console.log(post.seedUid, post.attester, new Date(post.timeCreated * 1000).toISOString())
  console.log('  title:', post.data.title)
}

There's no setup call. The first read registers EAS clients, and on Node an Arweave client, if none are registered yet; in a page that runs the SDK it uses the SDK's. Rich-text bodies are fetched from each read gateway in turn, arweave.net first, skipping one that failed a read in the last 60 seconds. A 404 from a gateway that doesn't have the body yet isn't a failed read: the next gateway is asked, and the first stays in use. A body none of them returns comes back as its gateway URL instead of its text. Arweave and gateways gives the gateway order and how to change it.

The schema name is the model's easName (exported by @seedprotocol/sdk and @seedprotocol/eas): Post is published under post, a BlogPost model under blog_post, and HTMLPage under html_page. SDKs before 0.7.4 published acronyms without the split (htmlpage), so items of such a model can sit under both names. Names are case-sensitive, so queryBySchema('Post') returns nothing. The name belongs to the model, not to your app's schema, so post returns every app's posts, not only yours. The data model explains why.

Each item is a SeedRecord: seedUid (the item's permanent ID), schemaName, attester (the account that made the Seed attestation), timeCreated (Unix seconds) and data, the newest value of each property on the latest Version. Revoked items, items with no Version from their attester, and items whose Versions were all revoked are left out. Two things about data catch people out:

  • Each property is there under its EAS name (published_at), plus a camelCase copy (publishedAt) when that's different.
  • Relations come back under their schema key and are expanded into the related item's values: the attested author_id comes back as author, and a list's author_identity_ids as authors, an array of objects. Pass relations: 'uids' to keep the raw IDs and skip reading the related items; the keys stay the same. A relation is a bytes32 or bytes32[] property, so a Text external_id keeps its name (external_id and externalId); only an attestation with no type is picked by its name, ending in _id or _ids. A list's key keeps only the first word of its EAS name (co_author_identity_ids comes back as cos) unless you give query your models with setQueryModels, which brings back the schema key (co_authors and coAuthors). SeedRecord has the details.

Every app that publishes a post decides its own properties, so don't assume a field is there. Check typeof post.data.title === 'string' before you use it.

Page through results

limit defaults to 100, and skip moves to the next page. Both count the Seeds queryBySchema lists, before it leaves out the ones with no live Version, so a page can come back shorter than limit, even empty, while later pages still have items. A short page of items doesn't mean you've reached the end. listed is the number of Seeds listed for the page, and listed < limit means there's nothing after it. listed === limit doesn't promise more: when the last page is exactly full, the next one comes back with listed: 0.

all-posts.ts
import { queryBySchema, type SeedRecord } from '@seedprotocol/query'

const pageSize = 20
const posts: SeedRecord[] = []

for (let skip = 0; ; skip += pageSize) {
  const { items, listed } = await queryBySchema('post', { limit: pageSize, skip })
  posts.push(...items)
  if (listed < pageSize) break
}

console.log(posts.length, 'posts')

To find a Seed by the start of its UID, pass uidPrefix. Matches come back newest first:

import { queryBySchema } from '@seedprotocol/query'

const { items: matches } = await queryBySchema('post', { uidPrefix: 'fd8c50ca' })

uidPrefix is for short links: a URL like /posts/fd8c50ca can find the full UID. Fewer than 4 hex digits, or anything that isn't hex, returns no items.

Read one item and its history

getSeed reads one item by its Seed UID, in any case. It returns null when there's no such Seed, when the Seed has no Version from its attester, or when every Version was revoked and you asked for data.

read-post.ts
import { getSeed } from '@seedprotocol/query'

const seedUid = process.argv[2]!
const post = await getSeed(seedUid, {
  include: 'data+changelog',
  changelog: { granularity: 'version', limit: 10 },
})

if (!post) {
  console.log('Not found, or every version was revoked')
} else {
  console.log(post.data.title)
  for (const entry of post.changelog ?? []) {
    if (entry.type === 'version') {
      console.log(new Date(entry.at * 1000).toISOString(), entry.changedKeys)
    }
  }
}

include is 'data' by default. 'data+changelog' adds changelog, and 'changelog' returns only the history, with an empty data. With granularity: 'version' each entry compares one Version's values with the previous one (before, after, changedKeys). With granularity: 'property' there's one entry per property attestation that changed a value (previousValue, nextValue), which also shows edits published as patches to the same Version. Changelog values are flat: relations aren't expanded and Arweave text isn't fetched.

Read an item as it was

getSeed reads the latest Version unless you pin one. Pass versionUid to read a particular Version, or asOf (Unix seconds) for the newest Version created by then, with only the property attestations made by then:

read-version.ts
import { getSeed } from '@seedprotocol/query'

const seedUid = '0x6e195761cf9fce0a81ad24e23dbafce37e2b2d2099357d634acafebc174778f6'

const latest = await getSeed(seedUid)
const asPublished = await getSeed(seedUid, {
  versionUid: '0xd76fcd99122c77e9f1f31e1dc16b4dfa66b8137533a04488f8e71b8ea928773d',
})
const lastWeek = await getSeed(seedUid, { asOf: Math.floor(Date.now() / 1000) - 7 * 24 * 60 * 60 })

console.log(latest?.versionUid, latest?.data.title)
console.log(asPublished?.versionUid, asPublished?.data.title)
console.log(lastWeek?.versionUid)

A pin with no matching Version returns null: a revoked Version, a Version of another Seed, or one created after asOf. A versionUid alone includes edits published later as patches to that Version. Related items are read at their latest Version. Pinned reads has the full rules.

Read several items at once

getSeeds reads a list of Seed UIDs, of any models, in one set of indexer requests instead of one per item, and returns a Map keyed by each UID as you passed it. It matches UIDs in any case. UIDs that getSeed would return null for are left out of the map. pins pins any of them:

read-several.ts
import { getSeeds } from '@seedprotocol/query'

const first = '0x6e195761cf9fce0a81ad24e23dbafce37e2b2d2099357d634acafebc174778f6'
const second = '0x2a34bcc7d384ee1a0999affae1b4712cb538ee0a1e237de3b748d3dba2395a53'

const posts = await getSeeds([first, second], {
  pins: new Map([[first, { versionUid: '0xd76fcd99122c77e9f1f31e1dc16b4dfa66b8137533a04488f8e71b8ea928773d' }]]),
})

for (const [seedUid, post] of posts) {
  console.log(seedUid, post.versionUid, post.data.title)
}

getSeeds has no changelog; use getSeed for that. getSeeds lists its options.

Only one publisher's items

Pass attesters to list only Seeds made by those accounts. Addresses match in any case, and the filter applies before limit and skip, so pages are full:

import { queryBySchema } from '@seedprotocol/query'

const publisher = '0x1234567890abcdef1234567890abcdef12345678'

const { items: fromPublisher } = await queryBySchema('post', { attesters: [publisher], limit: 20 })

That filters Seeds. Within a Seed, query counts only the Versions made by the Seed's attester, and only the property values made by the Version's attester, so another account can't change a Seed's values in query results by attesting against it. The same goes for getSeed's changelog.

Choose a source: remote or local

By default, query reads from EAS (source: 'remote'). An app that also runs the SDK can pass source: 'local' to read the published items already synced into its SQLite database, without network requests, or source: 'auto' to read locally and fall back to EAS. Choosing a source has the full table and the fallback rules. The SDK registers its database as the local source when client.init finishes:

read-local.ts
import { client } from '@seedprotocol/sdk'
import { queryBySchema } from '@seedprotocol/query'
import { readFile } from 'node:fs/promises'

const schema = JSON.parse(await readFile('seed.schema.json', 'utf8'))
await client.init({ config: { filesDir: 'data', schema } })

// Published items already synced into this app's database
const local = await queryBySchema('post', { source: 'local' })
// The local database when it has the collection, otherwise EAS
const either = await queryBySchema('post', { source: 'auto' })

In an app that already uses the SDK, getPublishedSeedRecord(seedUid) from @seedprotocol/sdk is a shortcut for getSeed with source: 'local'; pass { source: 'auto' } to fall back to EAS.

The local database only holds what EAS sync brought in: items published by the client's owned and watched addresses. To read another publisher's items locally, add their address as a watched address and let a sync run; see Sync a user's data to another device. Before client.init finishes, source: 'local' throws [query] source: "local" requires registerLocalQuerySource(...) (e.g. SDK DB ready).

Caching

Remote reads are cached in memory and on disk, under ./cache in the working directory by default. On a cache hit, query asks EAS whether anything the entry depends on has changed and only re-assembles what did. Pass cache: false to skip the cache for one call. The first page of a remote queryBySchema also returns an etag you can pass on as an HTTP ETag. Records from a cached read are shared with later callers, so copy one (structuredClone(post.data)) before you change it. Caching covers the TTL and the CACHE_* environment variables.

Read from another chain or indexer

Reads go to the OP Sepolia EAS indexer unless something says otherwise. In a process without the SDK or @seedprotocol/publish, set EAS_CHAIN_ID to read another chain or EAS_ENDPOINT to read your own indexer; Chain and endpoint gives the full resolution order. Seed's contracts are only deployed on OP Sepolia, so other chains have no Seed data yet.

Raw attestations with @seedprotocol/eas

@seedprotocol/query builds on @seedprotocol/eas, the low-level client for the EAS indexer. Use eas directly when you want the attestations themselves rather than assembled JSON: to list one account's Seeds in a single request, check revocation, or read Versions and property attestations.

seeds-by-publisher.ts
import { registerNodeEasPlatform } from '@seedprotocol/eas/node'
import { getEasSchemaUidForExactDefinition, getSeedsFromSchemaUids } from '@seedprotocol/eas'

registerNodeEasPlatform()

// A model's Seed schema is `bytes32 <EAS name>`
const postSchemaUid = await getEasSchemaUidForExactDefinition('bytes32 post')
if (!postSchemaUid) throw new Error('No Post schema on this chain')

const seeds = await getSeedsFromSchemaUids({
  schemaUids: [postSchemaUid],
  addresses: ['0x1234567890abcdef1234567890abcdef12345678'],
})
for (const seed of seeds) {
  console.log(seed.id, seed.timeCreated)
}

Unlike query, eas doesn't register its clients for you: call registerNodeEasPlatform() first, or the schema lookup returns undefined. getSeedsFromSchemaUids leaves revoked Seeds out unless you pass excludeRevoked: false. Pass a Seed's UID to getSeed from @seedprotocol/query to get its values.

Complete example

A small JSON API in front of published posts: GET /posts lists them and GET /posts/:seedUid returns one.

server.ts
import { createServer } from 'node:http'
import { getSeed, queryBySchema } from '@seedprotocol/query'

const server = createServer(async (req, res) => {
  const url = new URL(req.url ?? '/', 'http://localhost')
  const [, collection, seedUid] = url.pathname.split('/')

  if (collection !== 'posts') {
    res.writeHead(404).end()
    return
  }

  if (seedUid) {
    const post = await getSeed(seedUid)
    if (!post) {
      res.writeHead(404).end()
      return
    }
    res.writeHead(200, { 'Content-Type': 'application/json' })
    res.end(JSON.stringify(post))
    return
  }

  const limit = Math.min(Number(url.searchParams.get('limit') ?? 20), 100)
  const skip = Number(url.searchParams.get('skip') ?? 0)
  const { items, etag } = await queryBySchema('post', { limit, skip })

  res.writeHead(200, {
    'Content-Type': 'application/json',
    ...(etag ? { ETag: etag } : {}),
  })
  res.end(JSON.stringify(items))
})

server.listen(3001, () => console.log('http://localhost:3001/posts'))

Troubleshooting

  • No items for a model you know is published. Check the schema name: the model's easName, lowercase (post, not Post; html_page or, for items published before 0.7.4, htmlpage). Then check the chain: a process that sets EAS_CHAIN_ID, or an SDK configured for another chain, reads a different indexer (see Read from another chain or indexer).
  • TypeError: Cannot assign to read only property. You changed a record from a cached read. Outside NODE_ENV=production those are frozen; in production the write succeeds and changes what later callers get. Copy it first (see Frozen records).
  • A new edit doesn't show up. The EAS indexer can take a little while to pick up a new attestation. If it's on easscan and query still returns the old value, pass cache: false or delete the CACHE_DIR folder.
  • [query] [processItemProperty] empty decodedDataJson in the console. Query logs this for property attestations it can't decode, and skips them. The rest of the item still loads.
  • DuplicateEasModuleError or DuplicateArweaveModuleError. Two copies of @seedprotocol/eas or @seedprotocol/arweave are loaded. See Troubleshooting on the query reference for the causes and the check.
  • A cache folder appeared in your project. That's the disk cache. Add it to .gitignore, or set CACHE_DIR.

On this page