JOLT APP DEVELOPMENT GUIDE · 01

Building Chirp

A minimal twitter-style app on Jolt, built with Tauri and React. By the end you will have a running desktop app that borrows the user's identity through a capability-scoped session, publishes signed posts, assembles a timeline from other people's nodes, exchanges follow requests through recipient-controlled ingress, and tests all of it against an in-memory fake. Requires a running Jolt Console and the Jolt SDK.

Every code file on this page is lifted verbatim from sdks/js/guide in the Jolt repository, where it is type-checked and unit-tested against the SDK on every change. If you follow along file by file, you end up with the same app.

Why every Jolt app is social

Chirp has no backend, no user table, and no signup screen, and it does not need them. On Jolt, identity comes from the network: the local daemon holds the user's keys and signs everything Chirp publishes, so a "Chirp account" is just the user's existing Jolt identity wearing a different interface. And distribution comes from the network: anything Chirp publishes is resolvable and fetchable by any other node, and anything other identities publish under Chirp's paths is readable by your app.

That makes Jolt apps social by nature. The moment Chirp writes its first post, that post has a stable address any other app can read, and Chirp can read everyone else's. You are not building a silo with a network attached; you are building a lens over a network that already exists. The flip side is honest too: public publications are public. "Following" in Chirp is not permission to read (nobody needs permission to read public posts); it is a subscription list that decides whose posts your timeline assembles.

Chirp exercises the whole app surface specified in JOLT-RFC-0007: session bootstrap, signed publication, append records and enumeration, encrypted objects, and ingress.

1 · Scaffold a Tauri + React app

Start from the standard Tauri scaffold with the React and TypeScript template:

yarn create tauri-app chirp --template react-ts
cd chirp
yarn
yarn tauri dev

You should see the template window open. Everything Jolt-specific happens in two places: src-tauri/ (one plugin registration) and src/ (the SDK calls). By the end of this guide you will have touched exactly seven files:

chirp/
├── src-tauri/
│   ├── Cargo.toml                    # add one dependency
│   ├── capabilities/default.json     # add one permission
│   └── src/lib.rs                    # add one line
└── src/
    ├── jolt.ts                       # client + session  (section 3)
    ├── chirp.ts                      # posts + timeline  (section 4)
    ├── follows.ts                    # ingress handshake (section 5)
    ├── App.tsx                       # the UI            (section 6)
    └── App.css                       # replace the scaffold's styles

2 · Add jolt-sdk and tauri-plugin-jolt

Add the SDK:

yarn add jolt-sdk

In a desktop shell the webview should not talk to the daemon directly; daemon calls go through audited Rust proxy commands. The tauri-plugin-jolt crate ships those commands so you never write them yourself. One dependency in src-tauri/Cargo.toml, next to the tauri dependencies the template generated:

src-tauri/Cargo.tomltoml
[dependencies]
tauri-plugin-jolt = "0.1"

One line in the builder in src-tauri/src/lib.rs:

src-tauri/src/lib.rsrust
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
    tauri::Builder::default()
        .plugin(tauri_plugin_jolt::init())
        .run(tauri::generate_context!())
        .expect("error while running tauri application");
}

And one permission in src-tauri/capabilities/default.json, next to the defaults the template generated:

src-tauri/capabilities/default.jsonjson
{
  "identifier": "default",
  "windows": ["main"],
  "permissions": ["core:default", "jolt:default"]
}

On the TypeScript side, the Tauri transport pairs with the plugin when you pass { plugin: true }, which you will do in the next section. The plugin reaches the daemon at http://127.0.0.1:9862; set JOLT_DAEMON_URL to override it.

3 · Request a scoped session

Chirp now declares who it is and exactly what it wants to do. Capabilities follow the grammar of RFC 0007: an action, optionally narrowed to a path scope with at most one trailing wildcard. Chirp asks for the full set it will use in this guide, and nothing more; the daemon will refuse any call outside the granted set, and the user can narrow the grant further at approval time.

src/jolt.tsts
import { createJoltClient } from "jolt-sdk";
import { TauriTransport } from "jolt-sdk/transport-tauri";

const TOKEN_KEY = "chirp.session-token";
let token = localStorage.getItem(TOKEN_KEY) ?? "";

export const jolt = createJoltClient({
  transport: new TauriTransport({ plugin: true }),
  getSessionToken: () => token,
});

export const CAPABILITIES = [
  "publish:/chirp/*", // write posts and the follow list
  "publish:encrypted:/chirp/*", // keep the sender's copy of ingress objects
  "resolve:public", // resolve .jolt addresses
  "fetch:public", // fetch content by content id
  "enumerate:self:/chirp/*", // list our own append records
  "enumerate:any:/chirp/*", // list other identities' /chirp/ records
  "ingress:send", // deliver follow requests
  "ingress:read", // list and open our pending inbox
  "ingress:decide", // accept or reject inbox envelopes
];

export async function connect(): Promise<string> {
  const status = await jolt.getStatus();
  if (token) {
    try {
      const session = await jolt.getCurrentSession();
      if (session.status === "active") return status.identity_address;
    } catch {
      token = ""; // stored token was revoked or expired; ask again
    }
  }

  const request = await jolt.requestSession({
    appId: "chirp.example",
    appName: "Chirp",
    appOrigin: "tauri://chirp.example",
    identity: status.identity_address,
    capabilities: CAPABILITIES,
  });

  for (;;) {
    const s = await jolt.getSessionRequestStatus(request.request_id);
    if (s.status === "rejected") {
      throw new Error("Chirp's session request was rejected in Jolt Console.");
    }
    if (s.session_token) {
      token = s.session_token;
      localStorage.setItem(TOKEN_KEY, token);
      return status.identity_address;
    }
    await new Promise((resolve) => setTimeout(resolve, 1000));
  }
}

Run the app and call connect(). The request now sits pending on the daemon: open Jolt Console → Apps, find the pending "Chirp" request, review the capability list, and approve it. The poll loop picks up the token and Chirp is connected. The token is a bearer secret scoped to exactly these capabilities; if the user revokes the session in Console, every call starts failing with JoltApiError and connect() will request a fresh session on next launch.

4 · Publish chirps, assemble timelines

A chirp is an append record: publishAppend writes coexisting records that never overwrite each other, which is exactly what a feed of posts wants (and it keeps concurrent devices safe). Each chirp gets its own path under /chirp/posts/, and readers list them back with enumerate, never with resolve. The follow list is the opposite kind of data: a singleton settings-like object at /chirp/follows, updated with publishJson where last-writer-wins is fine. It is published under your identity, so any Chirp instance on any device sees the same list.

src/chirp.tsts
import { makeId } from "jolt-sdk";
import type { JoltAppendSdk, JoltSdk } from "jolt-sdk";

export type Chirp = {
  kind: "chirp";
  id: string;
  text: string;
  postedAt: string; // ISO 8601
};

export function decodeChirp(value: unknown): Chirp | null {
  if (typeof value !== "object" || value === null) return null;
  const v = value as Record<string, unknown>;
  return v.kind === "chirp" &&
    typeof v.id === "string" &&
    typeof v.text === "string" &&
    typeof v.postedAt === "string"
    ? { kind: "chirp", id: v.id, text: v.text, postedAt: v.postedAt }
    : null;
}

export async function postChirp(
  jolt: JoltAppendSdk,
  text: string,
  now: () => string = () => new Date().toISOString()
) {
  const id = makeId("chirp");
  const chirp: Chirp = { kind: "chirp", id, text, postedAt: now() };
  return jolt.publishAppend(`/chirp/posts/${id}`, chirp);
}

export type TimelineEntry = { author: string; chirp: Chirp };

export async function loadTimeline(
  jolt: JoltSdk & JoltAppendSdk,
  identities: string[]
): Promise<TimelineEntry[]> {
  const entries: TimelineEntry[] = [];
  for (const identity of identities) {
    const records = await jolt.enumerate(identity, "/chirp/posts/");
    for (const record of records) {
      const read = await jolt.readContent(
        record.contentId,
        { identity, path: record.path },
        record.deviceSequence,
        decodeChirp
      );
      if (read) entries.push({ author: identity, chirp: read.value });
    }
  }
  return entries.sort((a, b) => b.chirp.postedAt.localeCompare(a.chirp.postedAt));
}

export type Follows = { kind: "chirp.follows"; identities: string[] };

export function decodeFollows(value: unknown): Follows | null {
  if (typeof value !== "object" || value === null) return null;
  const v = value as Record<string, unknown>;
  return v.kind === "chirp.follows" &&
    Array.isArray(v.identities) &&
    v.identities.every((entry) => typeof entry === "string")
    ? { kind: "chirp.follows", identities: v.identities as string[] }
    : null;
}

export async function loadFollows(jolt: JoltSdk, me: string): Promise<string[]> {
  const current = await jolt.read({ identity: me, path: "/chirp/follows" }, decodeFollows);
  return current?.value.identities ?? [];
}

export async function follow(jolt: JoltSdk, me: string, them: string) {
  const identities = new Set(await loadFollows(jolt, me));
  identities.add(them);
  await jolt.publishJson("/chirp/follows", {
    kind: "chirp.follows",
    identities: [...identities].sort(),
  });
}

Note the shape of these functions: each takes the narrow interface it needs (JoltAppendSdk, JoltSdk) rather than the whole client, and the clock is injectable. Every client sub-interface (JoltSdk, JoltAppendSdk, JoltEncryptedSdk, JoltIngressSdk) is intentionally small so features declare exactly the capability they use; this pays off in section 7.

Reads are tolerant: in loadTimeline, a record that is missing, unreachable, or not a valid chirp simply comes back null from readContent and is skipped, so one bad record never breaks the feed.

5 · Follow requests over ingress

Following someone requires no permission: their posts are public, and follow() above is enough to read them. What ingress adds is the social handshake, telling someone you exist without spam. On Jolt, one identity cannot write into another identity's state; the only way to hand an object to someone else is the recipient-controlled ingress door: the sender encrypts an object to the recipient and delivers it to the recipient's daemon, where it waits in a pending queue until the recipient's app opens it and decides.

sendObject does the sender's half in one call: it encrypt-publishes the object at the given path (that is the sender's own copy, under publish:encrypted:/chirp/*), then delivers the envelope to the recipient's daemon. On the receiving side, listFollowRequests lists the pending queue, opens each envelope to see what it is, and classifies it; the transport layer does not know or care what a "follow request" is, classifying payloads is the app's job. Envelopes that are not Chirp objects are left alone for whatever app they belong to, and envelopes whose claimed sender does not match the envelope's actual sender are rejected on sight. Deciding is deliberately left to the UI: accept and reject are one SDK call each.

src/follows.tsts
import { makeId } from "jolt-sdk";
import type { JoltIngressSdk } from "jolt-sdk";

export type FollowRequest = {
  kind: "chirp.follow-request";
  from: string;
  note?: string;
};

export function decodeFollowRequest(value: unknown): FollowRequest | null {
  if (typeof value !== "object" || value === null) return null;
  const v = value as Record<string, unknown>;
  return v.kind === "chirp.follow-request" && typeof v.from === "string"
    ? {
        kind: "chirp.follow-request",
        from: v.from,
        note: typeof v.note === "string" ? v.note : undefined,
      }
    : null;
}

export async function sendFollowRequest(
  jolt: JoltIngressSdk,
  me: string,
  them: string,
  note?: string
) {
  const request: FollowRequest = { kind: "chirp.follow-request", from: me, note };
  await jolt.sendObject(them, `/chirp/outbox/${makeId("follow")}`, request);
}

export type PendingFollow = { ingressId: string; request: FollowRequest };

export async function listFollowRequests(jolt: JoltIngressSdk): Promise<PendingFollow[]> {
  const pending: PendingFollow[] = [];
  for (const record of await jolt.listPendingIngress()) {
    const payload = await jolt.openIngress(record.ingress_id);
    const request = decodeFollowRequest(payload);
    if (!request) continue; // not a Chirp object; leave it pending for its app
    if (request.from !== record.sender_identity) {
      await jolt.rejectIngress(record.ingress_id); // claimed sender must match the envelope
      continue;
    }
    pending.push({ ingressId: record.ingress_id, request });
  }
  return pending;
}

6 · The UI

One file ties it together. App.tsx connects on mount, then renders four things: a composer that calls postChirp, a follow form that subscribes and says hello, the pending follow requests with accept and ignore buttons, and the timeline. Every action ends by re-running refresh, so the UI is always a projection of daemon state; when Bob accepts Alice's request, Chirp accepts the envelope and follows back, so nothing lands in Bob's world without Bob's daemon holding it at the door first.

src/App.tsxtsx
import { useCallback, useEffect, useState } from "react";

import { connect, jolt } from "./jolt";
import { follow, loadFollows, loadTimeline, postChirp } from "./chirp";
import type { TimelineEntry } from "./chirp";
import { listFollowRequests, sendFollowRequest } from "./follows";
import type { PendingFollow } from "./follows";
import "./App.css";

export default function App() {
  const [me, setMe] = useState<string | null>(null);
  const [timeline, setTimeline] = useState<TimelineEntry[]>([]);
  const [inbox, setInbox] = useState<PendingFollow[]>([]);
  const [draft, setDraft] = useState("");
  const [friend, setFriend] = useState("");
  const [error, setError] = useState<string | null>(null);

  const refresh = useCallback(async (identity: string) => {
    const follows = await loadFollows(jolt, identity);
    setTimeline(await loadTimeline(jolt, [identity, ...follows]));
    setInbox(await listFollowRequests(jolt));
  }, []);

  useEffect(() => {
    connect()
      .then(async (identity) => {
        setMe(identity);
        await refresh(identity);
      })
      .catch((cause) => setError(String(cause)));
  }, [refresh]);

  if (error) return <main className="chirp"><p className="error">{error}</p></main>;
  if (!me) return <main className="chirp"><p>Waiting for approval in Jolt Console</p></main>;

  const run = (action: () => Promise<void>) =>
    action().then(() => refresh(me)).catch((cause) => setError(String(cause)));

  return (
    <main className="chirp">
      <header>
        <h1>Chirp</h1>
        <p className="identity">{me}</p>
      </header>

      <form
        onSubmit={(event) => {
          event.preventDefault();
          if (!draft.trim()) return;
          run(async () => {
            await postChirp(jolt, draft.trim());
            setDraft("");
          });
        }}
      >
        <textarea
          value={draft}
          onChange={(event) => setDraft(event.target.value)}
          placeholder="What's happening on the network?"
          maxLength={280}
        />
        <button type="submit">Chirp</button>
      </form>

      <form
        onSubmit={(event) => {
          event.preventDefault();
          if (!friend.trim()) return;
          run(async () => {
            const them = friend.trim();
            await follow(jolt, me, them); // their posts are public; just subscribe
            await sendFollowRequest(jolt, me, them, "chirp?"); // and say hello
            setFriend("");
          });
        }}
      >
        <input
          value={friend}
          onChange={(event) => setFriend(event.target.value)}
          placeholder="somebody.jolt"
        />
        <button type="submit">Follow</button>
      </form>

      {inbox.length > 0 && (
        <section>
          <h2>Follow requests</h2>
          {inbox.map((pending) => (
            <article key={pending.ingressId} className="request">
              <span>
                <strong>{pending.request.from}</strong> {pending.request.note ?? ""}
              </span>
              <button onClick={() => run(async () => {
                await jolt.acceptIngress(pending.ingressId);
                await follow(jolt, me, pending.request.from); // follow back
              })}>
                Accept
              </button>
              <button onClick={() => run(() => jolt.rejectIngress(pending.ingressId))}>
                Ignore
              </button>
            </article>
          ))}
        </section>
      )}

      <section>
        <h2>Timeline</h2>
        {timeline.length === 0 && <p>No chirps yet. Write the first one.</p>}
        {timeline.map((entry) => (
          <article key={entry.chirp.id}>
            <header>
              <strong>{entry.author}</strong>
              <time dateTime={entry.chirp.postedAt}>
                {new Date(entry.chirp.postedAt).toLocaleString()}
              </time>
            </header>
            <p>{entry.chirp.text}</p>
          </article>
        ))}
      </section>
    </main>
  );
}

Replace the scaffold's src/App.css with a small stylesheet (the scaffold's main.tsx already renders <App />, so no other file changes):

src/App.csscss
.chirp {
  max-width: 36rem;
  margin: 0 auto;
  padding: 2rem 1rem;
  font-family: system-ui, sans-serif;
  text-align: left;
}

.chirp .identity {
  font-family: monospace;
  color: #4b8;
}

.chirp form {
  display: flex;
  gap: 0.5rem;
  margin: 1rem 0;
}

.chirp textarea,
.chirp input {
  flex: 1;
  padding: 0.5rem;
  font: inherit;
}

.chirp article {
  border-top: 1px solid #ccc3;
  padding: 0.75rem 0;
}

.chirp article header {
  display: flex;
  justify-content: space-between;
  font-size: 0.85rem;
}

.chirp .request {
  display: flex;
  gap: 0.5rem;
  align-items: center;
  justify-content: space-between;
}

.chirp .error {
  color: #c66;
}

Run yarn tauri dev again. Approve the session in Jolt Console when the window says it is waiting, and post your first chirp.

7 · Test it all with the fake

Because every Chirp function takes a client interface instead of reaching for a global, all of the flows above run against createFakeJolt: a deterministic in-memory implementation of the full JoltClient with no daemon and no network. Publishes land in an in-memory store, enumeration lists them back, sends are recorded, and deliverIngress injects incoming envelopes as if a remote sender delivered them. Add vitest (yarn add -D vitest) and drop this next to the code:

src/chirp.test.tsts
import { describe, expect, it } from "vitest";
import { createFakeJolt } from "jolt-sdk/testing";

import { follow, loadFollows, loadTimeline, postChirp } from "./chirp";
import { listFollowRequests, sendFollowRequest } from "./follows";

describe("chirp", () => {
  it("publishes chirps and projects a timeline, newest first", async () => {
    const { client, identity } = createFakeJolt("alice.jolt");
    await postChirp(client, "first!", () => "2026-08-05T10:00:00Z");
    await postChirp(client, "second!", () => "2026-08-05T11:00:00Z");

    const timeline = await loadTimeline(client, [identity]);
    expect(timeline.map((entry) => entry.chirp.text)).toEqual(["second!", "first!"]);
    expect(timeline.every((entry) => entry.author === "alice.jolt")).toBe(true);
  });

  it("records follow requests on the sender's side", async () => {
    const { client, sent } = createFakeJolt("alice.jolt");
    await sendFollowRequest(client, "alice.jolt", "bob.jolt", "hi!");

    expect(sent).toHaveLength(1);
    expect(sent[0]?.recipient).toBe("bob.jolt");
    expect(sent[0]?.body).toMatchObject({ kind: "chirp.follow-request", from: "alice.jolt" });
  });

  it("lists a pending follow request, accepts it, and follows back", async () => {
    const { client, identity, deliverIngress } = createFakeJolt("bob.jolt");
    deliverIngress({
      sender: "alice.jolt",
      body: { kind: "chirp.follow-request", from: "alice.jolt" },
    });

    const pending = await listFollowRequests(client);
    expect(pending.map((entry) => entry.request.from)).toEqual(["alice.jolt"]);

    const accepted = pending[0]!;
    await client.acceptIngress(accepted.ingressId);
    await follow(client, identity, accepted.request.from);

    expect(await client.listPendingIngress()).toHaveLength(0);
    expect(await loadFollows(client, identity)).toEqual(["alice.jolt"]);
  });

  it("rejects envelopes whose claimed sender does not match", async () => {
    const { client, deliverIngress } = createFakeJolt("bob.jolt");
    deliverIngress({
      sender: "mallory.jolt",
      body: { kind: "chirp.follow-request", from: "alice.jolt" },
    });

    expect(await listFollowRequests(client)).toHaveLength(0);
    expect(await client.listPendingIngress()).toHaveLength(0);
  });
});

These tests exercise Chirp's schemas and flows, not cryptography: the fake simulates encryption by recording recipients and storing plaintext, which is exactly the right level for app tests. The same code runs unchanged against the real daemon because createFakeJolt satisfies JoltClient and every sub-interface.

8 · Run it with a friend

The whole point of a Jolt app is that two installs of it form a network with no server in between. To see Chirp actually be social you need a second identity, either a friend running Jolt Console on their machine or your own second machine.

  1. Both sides launch Jolt Console, run Chirp with yarn tauri dev, and approve the session request.
  2. Swap .jolt addresses (each of you sees your own at the top of the Chirp window).
  3. Each of you posts a chirp.
  4. You type their address into the follow form. Two things happen at once: their existing posts appear in your timeline on the next refresh, because following is just reading public records, and a follow request lands at their daemon's door.
  5. Their Chirp shows "Follow requests" with your address. When they hit Accept, Chirp follows back, and both timelines now interleave both authors, newest first.

If the other side is offline, nothing breaks: chirps are served by whichever nodes hold them (the author's devices, and relays if the author pins there), and the follow request waits in the daemon's ingress queue and retries. Delivery, retries, and queue persistence are the daemon's job, not yours.

Where to go next