Seed Protocol
Guides

Use Seed in Node and Electron

Run the SDK in Node scripts, servers and Electron apps: the working directory, ESM and CommonJS, where data lives on disk, ending scripts, and what's different from the browser.

The SDK runs on Node as well as in the browser, with the same client, Item and query APIs. On Node it keeps its database in a libSQL file and its files on disk, which makes it useful for scripts that seed or export data, servers that read a user's published content, tests, and Electron apps.

You'll need:

  • Node 20.19 or a later 20.x, or Node 22.12 through 24 (the SDK's engines field is ^20.19.0 || >=22.12.0 <=24)
  • The blog schema from Your first schema, saved as seed.schema.json in the project root

Install

npm install @seedprotocol/sdk

Node needs no ZenFS, Vite plugin or cross-origin headers.

ESM or CommonJS

Both work. ES modules are the package's own format, so name your files .mjs or set "type": "module" in package.json if you can. From CommonJS, require works too:

scripts/count.cjs
const { client, Item } = require('@seedprotocol/sdk')

There's no separate CommonJS build: require loads the same ES module files through Node's require(esm) support, which is why the SDK needs Node 20.19+ or 22.12+. Code that mixes require and import shares one client.

In TypeScript, the SDK's declarations resolve under "moduleResolution": "bundler", "nodenext" and "node16". Keep skipLibCheck on: without it, TypeScript reports errors inside drizzle-orm (Entry points).

The working directory

With an absolute filesDir, client.init writes nothing to the working directory, so any working directory works, including a read-only one. A relative filesDir, the default '.seed' included, resolves under <cwd>/.seed (see Choose where the data lives), which the SDK creates when it opens the database. Then the working directory has to be writable, or client.init rejects:

Error: EACCES: permission denied, mkdir '/…/.seed/blog-data'

If something else picks the working directory (a process manager, a container, Electron), pass an absolute filesDir. The SDK finds its database migrations in its own installed package.

The loader hook, for @seedprotocol/publish

The SDK itself needs no flags. @seedprotocol/publish still does: it imports the EAS SDK, which does import { isEqual } from 'lodash', and Node rejects that:

SyntaxError: The requested module 'lodash' does not provide an export named 'isEqual'

In a process that loads publish, register the SDK's hook, which points that one import at lodash-es. On the command line:

node --import @seedprotocol/sdk/node-eas-lodash scripts/publish.mjs

Or in code, before publish loads. Node resolves every static import of a module before running any of them, so load publish with a dynamic import() after the hook:

import '@seedprotocol/sdk/node-eas-lodash'

const { initPublish } = await import('@seedprotocol/publish')

NODE_OPTIONS="--import @seedprotocol/sdk/node-eas-lodash" works too, for tools that start Node for you.

Choose where the data lives

filesDir decides where the database (<filesDir>/db/seed.db) and files go. Without it, everything goes in <cwd>/.seed. A relative filesDir such as 'blog-data' ends up under <cwd>/.seed too, so the data moves with the working directory; for servers and desktop apps, pass an absolute path such as '/var/lib/my-app'. filesDir and endpoints has the resolution rules, including the exception for a single-segment absolute path that doesn't exist.

To put the database file somewhere else, set dbConfig.dbUrl to its path; the SDK creates the folder if it doesn't exist. dbConfig.outDir is the folder the SDK copies its migrations to and runs them from, and also holds the database when dbUrl isn't set. Stored files and the schema copy stay under filesDir.

Configure the client

client.init takes the same options as in the browser. Only schema is required, and a schema given as a path is resolved from the working directory.

await client.init({
  config: { filesDir: '/var/lib/my-app', schema },
  addresses: { owned: ['0x…'] }, // optional: whose published items to sync
})
  • One client per process. Init runs once per config. A second call with the same config resolves immediately and ignores its addresses; client.setAddresses changes them. A call with a different config throws client.init was already called with a different config…. To change the config, await client.stop() first.
  • Addresses are kept between starts. An init without addresses keeps the stored ones and logs a one-time warning; addresses: [] clears them. See Addresses.
  • New drafts get the owned address when there's only one. With one owned address set, a script needs nothing else to own the drafts it creates. With several, register a publisher with setGetPublisherForNewSeeds before creating items, as Addresses shows; otherwise new drafts have no publisher, which leaves them editable until client.claimUnpublishedDrafts(address) gives them one. Drafts created before any owned address was set also stay without a publisher until claimed.
  • One process per database. The locks that keep browser tabs from stepping on each other are browser-only. Two Node processes opening the same filesDir aren't coordinated: each runs its own syncs, and the last write wins.

Everything in Client and configuration applies, including config.eas and the EAS_ENDPOINT/DEBUG environment variables. Turn on the SDK's logs with DEBUG=seedSdk:*. A schema that can't be loaded makes client.init reject with Could not load config.schema… and the reason.

On Node the SDK sends EAS requests through NodeEasClient from @seedprotocol/eas, which keeps its own connection pool with idle connections open for 30 seconds. To use fetch's global dispatcher instead, install @seedprotocol/eas at the SDK's version and, after client.init, call EasClient.configure(new NodeEasClient({ dispatcher: false })), with EasClient from @seedprotocol/sdk and NodeEasClient from @seedprotocol/eas/node.

Read and write

The Item API works as in the browser. Two habits matter more in a script, which tends to exit as soon as its last line runs:

  • Wait for saves. Assigning a property starts a save in the background. await the property's save() before you read the database another way or exit.
  • Stop the client. The process exits on its own once its code has finished, unless something is still running: an open liveQuery subscription polls every second and keeps it alive. await client.stop() when the script is done with the client; it completes every open subscription, which ends its poll.

Live queries poll

On Node, BaseDb.liveQuery re-runs its query every second and emits when the result changes. It only accepts a Drizzle query builder; an sql tag function throws SQL tag functions are not supported in node liveQuery stub implementation. See Querying.

Complete example

This script creates an author if there isn't one, adds a post, edits it, lists every post and watches the database for a moment.

scripts/blog.mjs
// Run from the project root, where seed.schema.json is:
//   node scripts/blog.mjs "My first post"
import { readFile } from 'node:fs/promises'
import { client, Item, BaseDb, seeds } from '@seedprotocol/sdk'

const schema = JSON.parse(await readFile('seed.schema.json', 'utf8'))

await client.init({ config: { schema } })

// Reuse the first author, or create one.
const authors = await Item.all('Author', false, { waitForReady: true })
const author =
  authors[0] ?? (await Item.create({ modelName: 'Author', name: 'Ada', bio: 'Writes things' }))

// Create a post that points at the author.
const title = process.argv[2] ?? 'Hello from Node'
const values = { modelName: 'Post', title, tags: ['node'], author: author.seedLocalId }
const post = await Item.create(values)

// Edit it, and wait for the save to finish before moving on.
post.summary = `Written at ${new Date().toISOString()}`
await post.properties.find((p) => p.propertyName === 'summary')?.save()

// List every local post.
const posts = await Item.all('Post', false, { waitForReady: true })
for (const p of posts) {
  console.log(`${p.seedLocalId}  ${p.title}  (${p.summary ?? 'no summary'})`)
}

// Watch the seeds table. On Node this re-runs the query every second.
const db = BaseDb.getAppDb()
const subscription = BaseDb.liveQuery(db.select().from(seeds)).subscribe((rows) => {
  console.log(`${rows.length} items in the database`)
})

setTimeout(async () => {
  subscription.unsubscribe()
  await client.stop()
}, 1500)

Run it twice:

node scripts/blog.mjs "First"
node scripts/blog.mjs "Second"

The second run prints both posts, newest first, and the item count (an author and two posts):

BdrSODlTmT  Second  (Written at 2026-10-16T12:13:06.452Z)
JGdk22hIDM  First  (Written at 2026-10-16T12:12:58.782Z)
3 items in the database

Each run takes several seconds. The database is now at .seed/db/seed.db, next to a copy of the schema the SDK wrote.

Item.all without includeEas: true lists local drafts only. Once you sync published items, pass includeEas: true to see them too.

Serve data from a Node server

Start the client once, before the server accepts requests, and share it across requests:

scripts/server.mjs
import http from 'node:http'
import { readFile } from 'node:fs/promises'
import { client, Item } from '@seedprotocol/sdk'

const schema = JSON.parse(await readFile('seed.schema.json', 'utf8'))

// Start the client once, before accepting requests.
await client.init({
  config: { filesDir: process.env.SEED_DATA_DIR, schema },
  addresses: process.env.SEED_ADDRESS ? { owned: [process.env.SEED_ADDRESS] } : undefined,
})

const server = http.createServer(async (req, res) => {
  if (req.method === 'GET' && req.url === '/posts') {
    const posts = await Item.all('Post', false, { includeEas: true, waitForReady: true })
    const body = posts.map((p) => ({ id: p.seedUid ?? p.seedLocalId, title: p.title, summary: p.summary }))
    res.writeHead(200, { 'content-type': 'application/json' })
    res.end(JSON.stringify(body))
    return
  }
  res.writeHead(404).end()
})

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

process.on('SIGINT', async () => {
  server.close()
  await client.stop()
  process.exit(0)
})

Without SEED_DATA_DIR the data goes in <cwd>/.seed; in production, set it to an absolute path. With SEED_ADDRESS set, the start-up sync pulls in that account's published posts. If that start-up sync fails, for example because the indexer can't be reached, the SDK retries it on a backing-off timer (see Offline retries). Nothing else syncs on its own, so call client.syncFromEas() on a timer, or from an admin endpoint, if the server should pick up new posts. That call isn't retried for you: it throws when the indexer can't be reached, so catch it and try again later. Syncing across devices covers sync triggers. For serving published records to other apps, see Serving a feed.

What's different on Node

FeatureOn Node
DatabaselibSQL file at <filesDir>/db/seed.db, or at dbConfig.dbUrl
Live queriesPoll every second; Drizzle query builders only
Loaded items hearing other writesNo change feed. Loaded items are read again after each EAS sync; writes from other processes, or your own SQL, reach them only when they're loaded again
Multi-tab coordinationNone. The process is always the leader
EAS syncWorks, including the start-up and address-change syncs. A failed start-up or automatic sync is retried on a timer
Downloading files of synced itemsDoesn't run. Synced items' images, files and HTML bodies aren't fetched from Arweave
FileManager.downloadAllFiles, resizeImage, resizeAllImagesThrow Not implemented
ensureImageLocalReturns { status: 'unsupported' }
Files you save through Image, File and Html propertiesWritten to disk under filesDir
Interrupted publishes (@seedprotocol/publish)Restored once the publish manager starts: on await PublishManager.ready() or the first item.publish(). It doesn't start on its own

Electron

The SDK picks its platform when client.init runs. In Electron it treats any context where it can see Node's process as Node: the main process, utility processes, and renderers with nodeIntegration. A renderer without Node access (the default: context isolation, no Node integration) is treated as a browser.

Keep the SDK in the main process (or a utility process) and let the renderer reach it over IPC. The main process is a normal Node environment, so everything above applies, and one process owns the database.

The one thing that needs care is filesDir. A packaged app's working directory is often / or a read-only app bundle, where a relative filesDir can't be created. Pass an absolute path in the app's data folder:

main/seed.mjs
import path from 'node:path'
import { client } from '@seedprotocol/sdk'

// Call from app.whenReady(), with app.getPath('userData').
export async function startSeed(userData, schema) {
  // An absolute filesDir, so nothing is written to the working directory,
  // which is often read-only in a packaged app.
  await client.init({ config: { filesDir: path.join(userData, 'seed'), schema } })
  return client
}

The SDK needs no loader hook. If the main process also loads @seedprotocol/publish, import @seedprotocol/sdk/node-eas-lodash first and load publish with a dynamic import(), as in The loader hook. Electron bundles its own Node version, so check process.versions.node in yours: it has to be in the SDK's engines range, and require needs 20.19+ or 22.12+. @libsql/client loads a native binary, so make sure your packager keeps *.node files outside the asar archive (most do by default).

Renderer with Node integration

It works the same way as the main process, with the same filesDir advice. It isn't recommended: each renderer with Node integration opens the database itself, uncoordinated, and after a sync the SDK tries the browser's file download path, which the Node file manager doesn't implement, so synced files aren't downloaded (the error goes to the debug log).

Renderer as a browser

A renderer without Node access runs the browser setup: SQLite in OPFS, ZenFS and the Vite plugin, as in Add Seed to your app. The page needs to be cross-origin isolated, as it does on the web; without that, client.init rejects with DatabaseNotPersistentError. The Vite plugin sets the COOP and COEP headers in development; in a packaged app, your app has to send them with the renderer's page itself. The data then lives in the renderer's OPFS storage, which only that renderer's origin can reach.

Troubleshooting

ErrorCauseFix
The requested module 'lodash' does not provide an export named 'isEqual'@seedprotocol/publish was loaded without the loader hook, or imported statically next to itUse --import @seedprotocol/sdk/node-eas-lodash, or import the hook and then await import('@seedprotocol/publish')
client.init rejects with EACCES: permission denied, mkdir '<cwd>/.seed/…'filesDir is relative and the working directory isn't writablePass an absolute filesDir
require() of an ES module failsNode is older than 20.19 (or 22.12 on the 22 line)Upgrade Node, or use import
client.init was already called with a different config…A second client.init with a different config, for example after a hot reload edited itawait client.stop() before initialising with the new config, or restart the process
Could not load config.schema from "…": …The schema file is missing or invalidFix the schema; the message gives the reason
SQL tag functions are not supported in node liveQuery stub implementationBaseDb.liveQuery was given an sql tag functionPass a Drizzle query builder
The script never exitsA liveQuery subscription is still openawait client.stop(), which completes it, or call its unsubscribe()
An assignment throws Cannot set <property> on <Model> <seedLocalId>: this Item instance was unloaded. …, or a property's save() throws … this ItemProperty instance was unloaded. …The script kept an item that nothing holds for longer than the grace period (entityCache.unloadGraceMs, 60 seconds by default), or kept it across client.stop(). err.name is 'EntityUnloadedError'Look the item up again. To keep one across long work, look it up with hold: true and call unload() when done (unload)
An assignment doesn't stick, the property shows its old value, and the save throws OwnershipErrorThe item isn't owned: its publisher is an address that isn't in the owned addressesAdd its publisher to addresses.owned if that address is yours; otherwise the item is read-only here

On this page