Skip to content

Build your first Wish app

Prefer Rust? The same tutorial exists for the Rust lane: Build your first Wish app (Rust).

A walkthrough from npm init to a working note-taking program that uses identity, document storage, the local query path — and finishes with two instances syncing peer-to-peer, because sync is the payoff the rest builds toward. No UI; that builds cleanly on top. When you're done, apps/notes is the same pattern grown into a small real app (backend daemon + React frontend) — copy it as your starting point.

What you'll build

A one-file Node script that:

  1. Connects to wish-core
  2. Asks the Wish Dashboard for an identity grant
  3. Opens a document store rendering into a SQLite table you query directly
  4. Writes a note document
  5. Reads notes back with plain SQL
  6. Syncs notes with a second instance of itself — same identity, zero sync-specific schema work

Prerequisites

  • Node.js 22 or later.

  • The Wish desktop app, installed and running. Two reasons:

    1. It runs wish-core for you and exposes a Unix socket at ~/.wish/core.sock — your app connects through that.
    2. It includes the Dashboard, where you'll approve the identity grant.

    Download from the home page if you don't have it. (Building from the wish-stack repo works too: just dev wish.)

If you don't yet have an identity in Wish, open the Dashboard and create one first (any name — "Alice," your name, whatever).

Set up the project

bash
mkdir my-first-wish-app
cd my-first-wish-app
npm init -y
npm install @wishcore/wish-sdk @wishcore/document-store better-sqlite3 tsx
npm install -D @types/node @types/better-sqlite3

(@wishcore/document-store is the Rust-backed store, via napi — you import createStore from it directly in Step 3. tsx runs TypeScript directly, no build step.)

Developing inside the wish-stack repo?

To build against your local checkout instead of the registry, install both as file: deps:

bash
npm install file:<wish-stack>/wish/bindings/node/wish-sdk \
            file:<wish-stack>/wish/bindings/node/document-store

Install both explicitly — npm won't follow the SDK's own file: dep on @wishcore/document-store transitively. (apps/notes/backend/package.json shows the pattern.)

Everything lives in one index.ts. We wrap the program in an async main() (the store and connection are async APIs) and run it with npx tsx:

typescript
async function main() {
    // each step fills this in
}

main().catch((e) => { console.error(e); process.exit(1); });

Step 1 — Connect to wish-core

typescript
import { RpcApp } from '@wishcore/wish-sdk';

const APP_NAME = process.env.INSTANCE_NAME ?? 'MyFirstApp';

async function main() {
    const app = new RpcApp({
        name: APP_NAME,
        // Honors WISH_SOCKET; defaults to ~/.wish/core.sock.
        coreUnixSocket: process.env.WISH_SOCKET,
        protocols: {},
    });
    await app.whenReady;

    console.log('Connected. Identities:', app.identities.map(i => i.name));
    process.exit(0);
}

main().catch((e) => { console.error(e); process.exit(1); });

(APP_NAME comes from an env var so you can launch a second instance later under a different name — wish-core allows one live connection per app name.)

Run it:

bash
npx tsx index.ts
Connected. Identities: []

The empty array is correct — Wish apps don't own identities; they're granted by the user via the Dashboard. Open the Dashboard's Apps page and you'll see MyFirstApp registered, with nothing granted.

(Already ran this before? A previously granted app keeps its grant — you'll see your identity here instead of []. That's persistence, not a bug.)

The process.exit(0) is deliberate: RpcApp keeps the process alive (a real app wants that — peers, sync, and signals live in the running process), but Steps 1–5 are one-shot, so we exit explicitly. Step 6 drops it.

One connection per app name

wish-core allows one live connection per app name. If a previous run is still alive (you forgot process.exit, or Ctrl-C didn't take), the next run hangs silently waiting for the old registration to clear. If a run seems stuck before printing anything, check for a leftover process.

Step 2 — Request an identity

Ask the Dashboard for an identity if we don't have one:

typescript
async function main() {
    const app = new RpcApp({
        name: APP_NAME,
        coreUnixSocket: process.env.WISH_SOCKET,
        protocols: {},
    });
    await app.whenReady;

    if (app.identities.length === 0) {
        console.log('No identity granted yet — approve in the Wish Dashboard.');
        await app.requestIdentity();
    }
    const me = app.identities[0];
    console.log('Acting as:', me.name);
    process.exit(0);
}

Run it. The script pauses at requestIdentity(). In the Dashboard, go to AppsMyFirstAppApprove:

Acting as: Alice

You now have an Identity:

  • uid — a 32-byte Buffer, the user's identity hash. The primary key for everything from here on.
  • name — what the user called it.
  • privkey: true — your own identity (vs a contact's).

Step 3 — Open a document store

The document store is the database. It's content-addressed and signs every write — the engine is Rust (the same store the flagship apps run on), exposed to Node through the @wishcore/document-store package.

The store owns the canonical log (every signed write, in its own SQLite file). What you read from is a rendered view: a renderHandler the store calls on every accepted write — local or synced from a peer — to materialise documents into a table you query. createSqliteRenderHandler is the built-in default: it gives every document type its own table, created on first write:

sql
CREATE TABLE note (              -- table per type, named after the type
    hash      BLOB PRIMARY KEY,  -- document root hash
    uid       BLOB,              -- owning/authoring identity
    luid      BLOB,              -- which of YOUR identities holds this copy
    parent    BLOB,              -- parent document (the built-in hierarchy)
    createdAt INTEGER,           -- genesis time
    updatedAt INTEGER,           -- latest edit time
    content   TEXT NOT NULL      -- the full document as JSON
)

Standard fields are real columns (sort/filter with plain SQL); your own fields live in content, read with SQLite's json_extract. You create the better-sqlite3 database and own it — the handler only writes:

typescript
import { createStore, createSqliteRenderHandler } from '@wishcore/document-store';
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';

const DATA_DIR = process.env.DATA_DIR ?? './data';

// ... inside main():

mkdirSync(DATA_DIR, { recursive: true });

// Your database — the handler writes into it, your reads query it.
const db = new Database(`${DATA_DIR}/rendered.db`);

const store = await createStore({
    storage: `${DATA_DIR}/ds.sqlite`,
    renderHandler: createSqliteRenderHandler(db),
});
await store.registerType('note');

When you outgrow the default — your own columns, indexes, a different database — pass your own renderHandler closure instead; that's the "own your read model" pattern in the Document Store guide. Today the default carries us all the way to sync.

It's easier than you think — a custom handler

A renderHandler is just a closure called with a batch of ops on every accepted write. Design whatever table you want and keep it in lockstep — here's the default's hand-rolled equivalent, with your own real columns instead of a JSON blob:

typescript
const db = new Database(`${DATA_DIR}/rendered.db`);
db.exec(`CREATE TABLE IF NOT EXISTS note (
    hash BLOB PRIMARY KEY, title TEXT, body TEXT, created INTEGER
)`);

const store = await createStore({
    storage: `${DATA_DIR}/ds.sqlite`,
    renderHandler: async (ops) => {
        for (const op of ops) {
            // `op` is a union — narrow on `op.type` first.
            if (op.type === 'upsert' && op.docType === 'note') {
                const c = op.content as any;
                db.prepare(
                    'INSERT OR REPLACE INTO note (hash, title, body, created) VALUES (?, ?, ?, ?)'
                ).run(op.hash, c.title, c.body, c.createdAt);
            } else if (op.type === 'delete' && op.docType === 'note') {
                db.prepare('DELETE FROM note WHERE hash = ?').run(op.hash);
            }
        }
    },
});

Reads then hit real columns — SELECT title FROM note ORDER BY created DESC — no json_extract. (Types with membership/temporal array fields emit temporalInsert/temporalArchive ops too; the Document Store guide covers those.)

Step 4 — Write a note

typescript
const [errors, hash] = await store.add('note', {
    uid: me.uid,
    title: 'Hello, Wish',
    body: 'This is my first document.',
    share: { self: true },
});

if (errors) {
    console.error('Write failed:', errors);
    process.exit(1);
}
console.log('Wrote note:', hash!.toString('hex').slice(0, 12) + '…');

A few things going on:

  • add(type, doc) — first arg is the type name; second is a plain object. Reserved fields (uid, share, optionally write, parent) sit alongside your own (title, body).
  • uid: me.uid — the document is owned by you. Required.
  • share: { self: true } — the sharing policy: "syncs to my own other devices, nobody else's." Without a share field the document is local-only and won't even sync between your own devices.
  • [errors, hash] — the tuple convention. errors is null on success; hash is the content hash (a Buffer).

Run it:

Wrote note: 7704d38cc475…

Step 5 — Read notes back

Reads don't go through the store at all — you query the rendered table the handler keeps in lockstep. Standard fields are columns; your own fields come out of the JSON:

typescript
const rows = db.prepare(
    "SELECT json_extract(content, '$.title') AS title FROM note ORDER BY updatedAt DESC"
).all() as { title: string }[];

console.log(`Notes (${rows.length}):`);
for (const row of rows) console.log(`  • ${row.title}`);
bash
npx tsx index.ts
Wrote note: 7704d38cc475…
Notes (1):
  • Hello, Wish

Run it again — Notes (2):. No ORM, no query API — updatedAt is a real column (every document gets createdAt/updatedAt automatically), and your own fields are one json_extract away.

Step 6 — Sync

Everything so far was one instance. Now the payoff: run a second instance and the notes converge — through the same render handler you already have. Synced documents arrive exactly like local writes.

Three changes turn the script into a syncing app.

First, a sync protocol. SyncProtocol serves your documents to peers (respecting every share policy — that's why share: { self: true } mattered), pulls theirs, and rides on a named protocol — the app-to-app channel peers find each other on. It needs the store, and RpcApp needs the protocol at construction, so the store now comes before the RpcApp call:

typescript
import { RpcApp, SyncProtocol } from '@wishcore/wish-sdk';

// build the store first (as in Step 3), then:
const sync = new SyncProtocol({
    store: store as any,
    // get_roots: which trees do I want pulled? For self-shared notes the
    // direct-share discovery handles it, so [] is enough here.
    getRoots: async () => [],
    // Fires after each pull cycle — re-list when something arrived.
    onSync: () => printNotes(),
});

const app = new RpcApp({
    name: APP_NAME,
    coreUnixSocket: process.env.WISH_SOCKET,
    protocols: { notes: sync },   // was {} — now we speak `notes`
});

Second, pull the read into a small printNotes() (so onSync can call it), and tell peers after a local write:

typescript
function printNotes() {
    const rows = db.prepare(
        "SELECT json_extract(content, '$.title') AS title FROM note ORDER BY updatedAt DESC"
    ).all() as { title: string }[];
    console.log(`Notes (${rows.length}):`);
    for (const r of rows) console.log(`  • ${r.title}`);
}

// after store.add(...):
sync.notifyPeers();

Third, stay alive — drop the process.exit(0). RpcApp keeps the process running, which is what serves and pulls from peers; onSync re-lists as notes arrive.

The full program is at the bottom of the page. Once it's assembled:

Run two instances

Terminal A:

bash
npx tsx index.ts

Terminal B — different name, different data dir, same identity:

bash
INSTANCE_NAME=MySecondApp DATA_DIR=./data2 npx tsx index.ts

The Dashboard pops a second request — grant the same identity (sync with share: { self: true } is "my own devices and instances"; both must act as the same person). Then both terminals converge:

Notes (2):
  • Hello from MySecondApp
  • Hello from MyFirstApp

That's the model: no server, signed envelopes pulled peer-to-peer, rendered into the table you've queried since Step 3.

If the peers don't see each other

Grant-change signaling has known rough edges. If the second note doesn't appear within a few seconds of granting, restart both instances — they re-login with the grants in place, which is the reliable path. The kernel and Dashboard can stay up.

What just happened

You wrote a Wish app. It connected to wish-core through a Unix socket, requested an identity from the Dashboard (the human gatekeeper for which apps act as you), opened the document store with a rendered SQLite table, wrote signed content-addressed documents, read them back with plain SQL — and then synced them between two instances with a SyncProtocol and a share policy. No server anywhere. The same script on two machines with the same identity does the same thing over the network.

Sharp edges you just bumped into

This page tries to be honest about the rough spots:

  • One connection per app name — a stale process makes the next run hang silently. Check for leftovers.
  • Optional vs. required share policy — no share means local-only, even between your own devices.
  • Grant-change signaling — restart instances if a fresh grant doesn't produce a peer (see the tip above).
  • The SDK is published (@wishcore/wish-sdk 0.5.0+, the Rust/napi build); prebuilt binaries for macOS + Linux (x64/arm64), no toolchain to install.

None of these are blockers; all are tracked.

What's next

  • apps/notes — this pattern as a real app: a long-running backend daemon, a React frontend over createWebRpc, generic document.add/edit/save endpoints. Copy it as your template.
  • Document Store guide — the database underneath, in depth: schemas, write rules, edits, temporal history.
  • Reason — the flagship app (source, apps/reason/). Rust/Tauri, same primitives.
  • Building in Rust instead? The same tutorial exists for the Rust lane.

The whole program, in one block:

typescript
import { RpcApp, SyncProtocol } from '@wishcore/wish-sdk';
import { createStore, createSqliteRenderHandler } from '@wishcore/document-store';
import Database from 'better-sqlite3';
import { mkdirSync } from 'node:fs';

async function main() {
    const APP_NAME = process.env.INSTANCE_NAME ?? 'MyFirstApp';
    const DATA_DIR = process.env.DATA_DIR ?? './data';

    mkdirSync(DATA_DIR, { recursive: true });

    // Your database — the render handler writes into it, your reads query it.
    const db = new Database(`${DATA_DIR}/rendered.db`);

    function printNotes() {
        const rows = db.prepare(
            "SELECT json_extract(content, '$.title') AS title FROM note ORDER BY updatedAt DESC"
        ).all() as { title: string }[];
        console.log(`Notes (${rows.length}):`);
        for (const r of rows) console.log(`  • ${r.title}`);
    }

    // The store: canonical log in ds.sqlite; the default handler renders
    // each type into its own table in rendered.db.
    const store = await createStore({
        storage: `${DATA_DIR}/ds.sqlite`,
        renderHandler: createSqliteRenderHandler(db),
    });
    await store.registerType('note');

    // Sync: serve our shared docs to peers, pull theirs; re-list on arrival.
    const sync = new SyncProtocol({
        store: store as any,
        getRoots: async () => [],
        onSync: () => printNotes(),
    });

    // Connect — `notes` is the protocol peers find each other on.
    const app = new RpcApp({
        name: APP_NAME,
        coreUnixSocket: process.env.WISH_SOCKET, // defaults to ~/.wish/core.sock
        protocols: { notes: sync },
    });
    await app.whenReady;

    if (app.identities.length === 0) {
        console.log('No identity granted yet — approve in the Wish Dashboard.');
        await app.requestIdentity();
    }
    const me = app.identities[0];
    console.log('Acting as:', me.name);

    const [errors, hash] = await store.add('note', {
        uid: me.uid,
        title: `Hello from ${APP_NAME}`,
        body: 'This note syncs.',
        share: { self: true },
    });
    if (errors) { console.error('Write failed:', errors); process.exit(1); }
    console.log('Wrote note:', hash!.toString('hex').slice(0, 12) + '…');
    sync.notifyPeers();

    printNotes();
    console.log('Running — syncing with peers (Ctrl-C to quit).');
}

main().catch((e) => { console.error(e); process.exit(1); });