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 Apps → MyFirstApp → Approve:

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); });