JOLT APP DEVELOPMENT GUIDE · 01
Build Chirp with the Data SDK
Chirp is a small social app. You can choose a nickname, publish a post, follow another Jolt identity, see who you follow beside the timeline, edit your own posts, and undo a deletion. There is no application server, account table, path design, decoder, session code, or network polling to write.
This is a complete application tutorial. Every TypeScript file shown here is compiled and tested against the public SDK in Jolt's repository. Follow it from the top and you will finish with a working desktop app, not an isolated API example.
Before you start: Chirp requires Jolt 0.4.0 or newer. Jolt 0.3.22 does not yet provide the live Data SDK behavior used by this tutorial. This guide must remain on the development site until 0.4.0 is released.
1 · Create the app
Start with Tauri's React and TypeScript template:
yarn create tauri-app chirp --template react-ts
cd chirp
yarn
yarn add jolt-sdk
Schema Classes use TypeScript decorators. Add this option to the generated
tsconfig.json:
{
"compilerOptions": {
"experimentalDecorators": true
}
}2 · Let the desktop app talk to Jolt
The webview should not make direct requests to the local daemon. Add Jolt's
small Tauri plugin to src-tauri/Cargo.toml:
[dependencies]
tauri-plugin-jolt = "0.1"Register it in the builder Tauri generated:
#[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 Chirp");
}Then allow the plugin in src-tauri/capabilities/default.json:
{
"identifier": "default",
"windows": ["main"],
"permissions": ["core:default", "jolt:default"]
}That is all the connection plumbing Chirp owns. Chirp.connect() will find
Jolt, check that the required Data SDK behavior is available, request the exact
permissions declared by the app, reuse an existing approval, and wait when a
new approval is required.
3 · Describe Chirp's data
Create src/chirp.ts:
import {
App,
Collection,
Document,
Field,
Read,
Schema,
} from "jolt-sdk/data";
@Schema({ version: 1 })
export class Post {
@Field.string()
text!: string;
@Field.dateTime()
postedAt!: Date;
}
@Schema({ version: 1 })
export class Following {
@Field.array(Field.identity)
identities!: string[];
}
@Schema({ version: 1 })
export class Profile {
@Field.string()
nickname!: string;
}
export const Posts = Collection.create(Post, {
access: {
read: Read.AnyIdentity,
create: true,
update: true,
delete: true,
restore: true,
},
});
export const FollowingDocument = Document.create(Following, {
access: {
read: Read.OwnIdentity,
create: true,
update: true,
},
});
export const ProfileDocument = Document.create(Profile, {
access: {
read: Read.AnyIdentity,
create: true,
update: true,
},
});
export const Chirp = App.create({
id: "chirp.example",
name: "Chirp",
namespace: "chirp",
data: {
posts: Posts,
following: FollowingDocument,
profile: ProfileDocument,
},
});
export type ChirpApplication = Awaited<ReturnType<typeof Chirp.connect>>;
export type ChirpPost = Awaited<ReturnType<ChirpApplication["posts"]["create"]>>;
export type DeletedChirpPost = Awaited<ReturnType<ChirpPost["delete"]>>;Post, Profile, and Following are ordinary classes and runtime schemas at
the same time. Collection.create gives every post a stable reference. The
public profile Document stores one nickname, while the private following
Document stores one identity list for the signed-in person.
The access declarations are also the complete permission declaration:
- anyone may read posts;
- anyone may read a Chirp nickname, but only its identity may change it;
- the local identity may create, edit, delete, and restore its posts; and
- only the local identity may read or change its following list.
App.create derives the paths and session request. There are no path prefixes,
capability strings, revision tokens, mutation IDs, or decoders in application
code. Automatic conflict handling is used unless the application explicitly
chooses a different policy.
4 · Add profiles and remember who the user follows
Create src/profiles.ts:
import type { ChirpApplication } from "./chirp";
export type ChirpProfile = {
readonly identity: string;
readonly nickname?: string;
};
export async function saveNickname(
chirp: ChirpApplication,
nickname: string,
) {
const trimmed = nickname.trim();
if (!trimmed) throw new Error("Enter a nickname");
const profile = await chirp.profile.getOrCreate({ nickname: trimmed });
return profile.value.nickname === trimmed
? profile
: profile.update({ nickname: trimmed });
}
export async function getProfiles(
chirp: ChirpApplication,
identities: readonly string[],
): Promise<ReadonlyMap<string, ChirpProfile>> {
const profiles = await Promise.all(
[...new Set(identities)].map(async (identity) => {
const item = identity === chirp.identity
? await chirp.profile.get()
: await chirp.profile.for(identity).get();
const profile: ChirpProfile = item.isPresent()
? { identity, nickname: item.value.nickname }
: { identity };
return [identity, profile] as const;
}),
);
return new Map(profiles);
}saveNickname() creates the local profile once and updates the same typed
Document afterward. getProfiles() reads each public profile through its Jolt
identity. A nickname is friendly and deliberately non-unique, so Chirp always
keeps the canonical .jolt identity next to it on posts and in the Following
list. Missing profiles simply fall back to that identity.
Create src/following.ts:
import type { ChirpApplication } from "./chirp";
export type FollowingItem = Awaited<
ReturnType<ChirpApplication["following"]["getOrCreate"]>
>;
export async function getFollowing(
chirp: ChirpApplication,
): Promise<FollowingItem> {
return chirp.following.getOrCreate({ identities: [] });
}
export async function follow(
chirp: ChirpApplication,
identity: string,
): Promise<FollowingItem> {
const following = await getFollowing(chirp);
if (following.value.identities.includes(identity)) return following;
return following.update({
identities: [...following.value.identities, identity],
});
}getOrCreate means a new Chirp user starts with an empty list while a returning
user receives the Document already stored under their Jolt identity. Updating
returns a new immutable Item, so React can replace its old state directly.
5 · Build the live timeline
Chirp's timeline reads the signed-in person's posts and the posts of every identity they follow. Each identity gets one cache-first Data Subscription.
Think of a subscription as one local, verified window onto one person's Posts Collection. It does not expose networking to Chirp. Jolt discovers that person's nodes, verifies their signed records, retains the last good view, and refreshes it in the background.
The Change Stream has a deliberate order:
- It begins with a Snapshot containing the complete Last Verified View. Chirp can render that immediately, even while the other person is offline.
- Later Changed events patch that view with verified additions, edits, deletions, and restores.
- ResyncRequired means Chirp missed part of the stream, so it asks the subscription for a fresh complete view instead of guessing.
Create src/timeline.ts:
import {
ChangeType,
Subscription,
type DataChangeStream,
type DataSubscriptionChange,
type DataSubscription,
} from "jolt-sdk/data";
import type { ChirpApplication, Post } from "./chirp";
type PostSubscription = DataSubscription<Post>;
export type TimelinePost = Awaited<ReturnType<PostSubscription["get"]>>[number];
export type TimelineSnapshot = {
readonly posts: readonly TimelinePost[];
readonly error: unknown;
};
type TimelineListener = (snapshot: TimelineSnapshot) => void;
type TimelineSource = {
readonly subscription: PostSubscription;
readonly stream: DataChangeStream<Post>;
items: Map<string, TimelinePost>;
};
export function postKey(post: Pick<TimelinePost, "ref">): string {
return `${post.ref.identity}${post.ref.path}`;
}
function itemsByRef(items: readonly TimelinePost[]): Map<string, TimelinePost> {
return new Map(items.map(item => [postKey(item), item]));
}
export class Timeline {
private readonly sources = new Map<string, TimelineSource>();
private readonly listeners = new Set<TimelineListener>();
private snapshot: TimelineSnapshot = Object.freeze({
posts: Object.freeze([]),
error: null,
});
private closed = false;
private constructor(private readonly posts: ChirpApplication["posts"]) {}
static async open(
posts: ChirpApplication["posts"],
identities: readonly string[],
): Promise<Timeline> {
const timeline = new Timeline(posts);
try {
for (const identity of new Set(identities)) await timeline.add(identity);
return timeline;
} catch (error) {
await timeline.close();
throw error;
}
}
getSnapshot = (): TimelineSnapshot => this.snapshot;
subscribe(listener: TimelineListener): () => void {
this.listeners.add(listener);
return () => this.listeners.delete(listener);
}
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
await Promise.all([...this.sources.values()].map(source => source.stream.cancel()));
this.listeners.clear();
}
private async add(identity: string): Promise<void> {
const subscription = await Subscription.create(this.posts.for(identity));
const source: TimelineSource = {
subscription,
stream: subscription.changes(),
items: new Map(),
};
this.sources.set(identity, source);
// A Change Stream always begins with Jolt's retained verified Snapshot.
// Await it before listening for deltas so an older parallel read can never
// replace a newer post that has already arrived through the stream.
const changes = source.stream[Symbol.asyncIterator]();
const initial = await changes.next();
if (initial.done || initial.value.type !== ChangeType.Snapshot) {
throw new Error("Data Subscription did not begin with a Snapshot");
}
source.items = itemsByRef(initial.value.items);
this.publish();
void this.watch(source, changes).catch(error => this.fail(error));
}
private async refreshSource(source: TimelineSource): Promise<void> {
source.items = itemsByRef(await source.subscription.get());
this.publish();
}
private async watch(
source: TimelineSource,
changes: AsyncIterator<DataSubscriptionChange<Post>>,
): Promise<void> {
while (true) {
const event = await changes.next();
if (event.done) return;
const change = event.value;
if (this.closed) return;
switch (change.type) {
case ChangeType.Snapshot:
source.items = itemsByRef(change.items);
this.publish();
break;
case ChangeType.Changed:
for (const item of change.items) source.items.set(postKey(item), item);
for (const ref of change.removed) {
source.items.delete(postKey({ ref }));
}
this.publish();
break;
case ChangeType.ResyncRequired:
await this.refreshSource(source);
break;
case ChangeType.State:
break;
case ChangeType.Cancelled:
case ChangeType.Revoked:
// A terminal stream can no longer keep this person's view current.
source.items.clear();
this.publish();
return;
}
}
}
private publish(): void {
const posts = [...this.sources.values()]
.flatMap(source => [...source.items.values()])
.sort((left, right) => right.value.postedAt.getTime() - left.value.postedAt.getTime());
this.snapshot = Object.freeze({
posts: Object.freeze(posts),
error: this.snapshot.error,
});
for (const listener of this.listeners) listener(this.snapshot);
}
private fail(error: unknown): void {
this.snapshot = Object.freeze({ ...this.snapshot, error });
for (const listener of this.listeners) listener(this.snapshot);
}
}Timeline.open() waits for that first Snapshot before it returns. This avoids
racing an older view read against a newer streamed change. One Map holds the
current Items for each followed identity; publish() combines those Maps and
sorts their typed postedAt values for React. The exhaustive switch makes
resynchronization and terminal events explicit instead of turning them into
strings for the UI to guess about.
Keep the React boundary small with src/use-timeline.ts:
import { useEffect, useState } from "react";
import type { ChirpApplication } from "./chirp";
import { Timeline, type TimelineSnapshot } from "./timeline";
const emptyTimeline: TimelineSnapshot = Object.freeze({
posts: Object.freeze([]),
error: null,
});
export function useTimeline(
chirp: ChirpApplication | null,
identities: readonly string[],
): TimelineSnapshot {
const [snapshot, setSnapshot] = useState(emptyTimeline);
useEffect(() => {
let timeline: Timeline | undefined;
let unsubscribe: (() => void) | undefined;
let cancelled = false;
if (chirp === null) {
setSnapshot(emptyTimeline);
return;
}
void Timeline.open(chirp.posts, identities)
.then((opened) => {
if (cancelled) return opened.close();
timeline = opened;
setSnapshot(opened.getSnapshot());
unsubscribe = opened.subscribe(setSnapshot);
})
.catch((error) => {
if (!cancelled) setSnapshot({ posts: [], error });
});
return () => {
cancelled = true;
unsubscribe?.();
void timeline?.close();
};
}, [chirp, identities]);
return snapshot;
}Opening, cancelling, and replacing timeline sources now follows the normal React effect lifecycle. The component receives one immutable snapshot.
To keep this tutorial small, changing the follow list closes and rebuilds its few subscriptions. A large production feed would reconcile long-lived subscriptions instead; Chirp deliberately does not teach that optimisation.
6 · Add a post card
Create src/PostCard.tsx:
import { useEffect, useState } from "react";
import type { TimelinePost } from "./timeline";
import type { ChirpProfile } from "./profiles";
type PostCardProps = {
post: TimelinePost;
profile?: ChirpProfile;
ownPost: boolean;
onDelete(post: TimelinePost): Promise<void>;
onUpdate(post: TimelinePost, text: string): Promise<void>;
};
export function PostCard({
post,
profile,
ownPost,
onDelete,
onUpdate,
}: PostCardProps) {
const [editing, setEditing] = useState(false);
const [text, setText] = useState(post.value.text);
useEffect(() => {
if (!editing) setText(post.value.text);
}, [editing, post.value.text]);
const save = async () => {
const nextText = text.trim();
if (!nextText) return;
await onUpdate(post, nextText);
setEditing(false);
};
return (
<article className="post">
<header className="post__meta">
<div className="post__author">
<strong>{profile?.nickname ?? post.ref.identity}</strong>
{profile?.nickname && <span>{post.ref.identity}</span>}
</div>
<time dateTime={post.value.postedAt.toISOString()}>
{post.value.postedAt.toLocaleString()}
</time>
</header>
{editing ? (
<form
className="post__editor"
onSubmit={(event) => {
event.preventDefault();
void save();
}}
>
<textarea value={text} onChange={event => setText(event.target.value)} />
<div className="post__actions">
<button type="button" className="button button--quiet" onClick={() => setEditing(false)}>
Cancel
</button>
<button type="submit" className="button">Save</button>
</div>
</form>
) : (
<p className="post__text">{post.value.text}</p>
)}
{ownPost && !editing && (
<footer className="post__actions">
<button type="button" className="button button--quiet" onClick={() => setEditing(true)}>
Edit
</button>
<button type="button" className="button button--danger" onClick={() => void onDelete(post)}>
Delete
</button>
</footer>
)}
</article>
);
}Remote posts are read-only. Each card uses the separately loaded public profile
for its friendly nickname and keeps the post reference's Jolt identity visible
underneath. Chirp shows edit and delete controls only when that reference
belongs to chirp.identity. The callback receives the stable post reference;
the application asks its local Collection for the current Item before mutating
it.
7 · Build the screen
Replace the generated src/App.tsx:
import { useEffect, useMemo, useState } from "react";
import { AppIncompatibleError } from "jolt-sdk/data";
import {
Chirp,
type ChirpApplication,
type ChirpPost,
type DeletedChirpPost,
} from "./chirp";
import { follow, getFollowing, type FollowingItem } from "./following";
import { PostCard } from "./PostCard";
import {
getProfiles,
saveNickname,
type ChirpProfile,
} from "./profiles";
import { postKey, type TimelinePost } from "./timeline";
import { useTimeline } from "./use-timeline";
import "./App.css";
type DeletedPost = {
deleted: DeletedChirpPost;
previous: ChirpPost;
};
type StartupFailure = {
title: string;
message: string;
};
export function describeStartupFailure(error: unknown): StartupFailure {
if (error instanceof AppIncompatibleError) {
return {
title: "Chirp needs a newer Jolt",
message:
"Update Jolt Console, then choose Check again. Chirp stopped before requesting access or changing data.",
};
}
return {
title: "Chirp could not start",
message: error instanceof Error ? error.message : "Please try again.",
};
}
export default function App() {
const [chirp, setChirp] = useState<ChirpApplication | null>(null);
const [following, setFollowing] = useState<FollowingItem | null>(null);
const [profiles, setProfiles] = useState<ReadonlyMap<string, ChirpProfile>>(
() => new Map(),
);
const [nickname, setNickname] = useState("");
const [draft, setDraft] = useState("");
const [friend, setFriend] = useState("");
const [deleted, setDeleted] = useState<DeletedPost | null>(null);
const [error, setError] = useState<unknown>(null);
const [connectionAttempt, setConnectionAttempt] = useState(0);
useEffect(() => {
let cancelled = false;
setError(null);
void Chirp.connect()
.then(async (connected) => ({ connected, following: await getFollowing(connected) }))
.then((connection) => {
if (cancelled) return;
setChirp(connection.connected);
setFollowing(connection.following);
})
.catch(error => {
if (!cancelled) setError(error);
});
return () => { cancelled = true; };
}, [connectionAttempt]);
const identities = useMemo(
() => chirp === null
? []
: [chirp.identity, ...(following?.value.identities ?? [])],
[chirp, following],
);
const timeline = useTimeline(chirp, identities);
useEffect(() => {
if (chirp === null) return;
let cancelled = false;
void getProfiles(chirp, identities)
.then((loaded) => {
if (cancelled) return;
setProfiles(loaded);
setNickname(current => current || (loaded.get(chirp.identity)?.nickname ?? ""));
})
.catch(profileError => {
if (!cancelled) setError(profileError);
});
return () => { cancelled = true; };
}, [chirp, identities, timeline.posts]);
const run = async (action: () => Promise<void>) => {
setError(null);
try {
await action();
} catch (actionError) {
setError(actionError);
}
};
const createPost = async () => {
if (chirp === null || !draft.trim()) return;
await chirp.posts.create({ text: draft.trim(), postedAt: new Date() });
setDraft("");
};
const addFriend = async () => {
if (chirp === null || !friend.trim()) return;
setFollowing(await follow(chirp, friend.trim()));
setFriend("");
};
const updateNickname = async () => {
if (chirp === null) return;
const saved = await saveNickname(chirp, nickname);
setProfiles(current => new Map(current).set(chirp.identity, {
identity: chirp.identity,
nickname: saved.value.nickname,
}));
};
const updatePost = async (post: TimelinePost, text: string) => {
if (chirp === null) return;
const current = await chirp.posts.get(post.ref);
if (current.isPresent()) await current.update({ text });
};
const deletePost = async (post: TimelinePost) => {
if (chirp === null) return;
const current = await chirp.posts.get(post.ref);
if (!current.isPresent()) return;
setDeleted({ deleted: await current.delete(), previous: current });
};
const restorePost = async () => {
if (deleted === null) return;
await deleted.deleted.restore(deleted.previous.value);
setDeleted(null);
};
if (chirp === null && error !== null) {
const failure = describeStartupFailure(error);
return (
<main className="chirp-shell chirp-shell--centered">
<p className="eyebrow">Chirp could not meet Jolt</p>
<h1>{failure.title}</h1>
<p>{failure.message}</p>
<button
className="button"
type="button"
onClick={() => setConnectionAttempt(attempt => attempt + 1)}
>
Check again
</button>
</main>
);
}
if (timeline.error !== null) {
return <main className="chirp-shell"><p className="notice notice--error">{String(timeline.error)}</p></main>;
}
if (chirp === null || following === null) {
return (
<main className="chirp-shell chirp-shell--centered">
<p className="eyebrow">Chirp is meeting Jolt</p>
<h1>Approve Chirp in Jolt Console</h1>
</main>
);
}
return (
<main className="chirp-shell">
<header className="masthead">
<div>
<p className="eyebrow">A small social app on Jolt</p>
<h1>Chirp<span>.</span></h1>
</div>
<p className="identity">{chirp.identity}</p>
</header>
{error !== null && (
<aside className="notice notice--error" role="alert">
<span>{String(error)}</span>
<button type="button" onClick={() => setError(null)}>Dismiss</button>
</aside>
)}
<section className="workspace">
<div className="compose-column">
<form
className="profile-form paper"
onSubmit={(event) => {
event.preventDefault();
void run(updateNickname);
}}
>
<label htmlFor="nickname">Your nickname</label>
<div>
<input
id="nickname"
value={nickname}
maxLength={40}
placeholder="Alice"
onChange={event => setNickname(event.target.value)}
/>
<button className="button" type="submit">Save</button>
</div>
</form>
<form
className="composer paper"
onSubmit={(event) => {
event.preventDefault();
void run(createPost);
}}
>
<label htmlFor="chirp-text">What do you want to say?</label>
<textarea
id="chirp-text"
value={draft}
maxLength={280}
placeholder="A thought worth sharing…"
onChange={event => setDraft(event.target.value)}
/>
<div className="composer__footer">
<span>{draft.length}/280</span>
<button className="button" type="submit">Publish chirp</button>
</div>
</form>
<form
className="follow-form paper"
onSubmit={(event) => {
event.preventDefault();
void run(addFriend);
}}
>
<label htmlFor="friend">Follow a Jolt identity</label>
<div>
<input
id="friend"
value={friend}
placeholder="alice.jolt"
onChange={event => setFriend(event.target.value)}
/>
<button className="button button--ink" type="submit">Follow</button>
</div>
</form>
<section className="following-list paper" aria-labelledby="following-heading">
<header>
<h2 id="following-heading">Following</h2>
<span>{following.value.identities.length}</span>
</header>
{following.value.identities.length === 0 ? (
<p>People you follow will appear here.</p>
) : (
<ul>
{following.value.identities.map((identity) => {
const profile = profiles.get(identity);
return (
<li key={identity}>
<strong>{profile?.nickname ?? identity}</strong>
{profile?.nickname && <span>{identity}</span>}
</li>
);
})}
</ul>
)}
</section>
</div>
<section className="timeline" aria-labelledby="timeline-heading">
<header className="timeline__heading">
<div>
<p className="eyebrow">Your network</p>
<h2 id="timeline-heading">Latest chirps</h2>
</div>
<span>{timeline.posts.length} posts</span>
</header>
{timeline.posts.length === 0 ? (
<div className="empty paper">
<p>It is quiet here.</p>
<span>Publish something or follow a friend.</span>
</div>
) : timeline.posts.map(post => (
<PostCard
key={postKey(post)}
post={post}
profile={profiles.get(post.ref.identity)}
ownPost={post.ref.identity === chirp.identity}
onUpdate={(post, text) => run(() => updatePost(post, text))}
onDelete={post => run(() => deletePost(post))}
/>
))}
</section>
</section>
{deleted !== null && (
<aside className="undo" role="status">
<span>Chirp deleted.</span>
<button type="button" onClick={() => void run(restorePost)}>Undo</button>
</aside>
)}
</main>
);
}This is the whole product flow:
Chirp.connect()connects and exposes the local Jolt identity.getFollowing()loads the user's saved follows.getProfiles()loads public nicknames without replacing canonical IDs.useTimeline()opens subscriptions for the user and their friends.- The profile form and composer save typed Documents and Posts.
- Following somebody updates one typed Document and the sidebar.
- Edit, delete, and restore call methods on typed Items.
Each button action uses the small run() helper. It clears the previous
message, runs the typed operation, and shows a dismissible error without
throwing away the rest of the screen. Connection and timeline startup errors
still stop the app because Chirp cannot work without those foundations.
The startup boundary catches AppIncompatibleError by type. Instead of showing
SDK terminology, Chirp asks the person to update Jolt and offers Check again.
Compatibility is checked before approval or data access, so this failure cannot
partly create the application.
Present Items carry State.Present; deleted Items carry State.Deleted and
offer restore(...) only when the Resource declaration allows it.
There is deliberately no transport setup, compatibility Feature map, App Session Capability list, content identifier, or manual refresh loop in this component.
Replace src/App.css as well:
:root {
color: #17342f;
background: #f4efe3;
font-family: system-ui, sans-serif;
}
* { box-sizing: border-box; }
body { margin: 0; min-width: 320px; }
button, input, textarea { font: inherit; }
h1, h2, p { margin-top: 0; }
.chirp-shell {
width: min(64rem, calc(100% - 2rem));
margin: 0 auto;
padding: 2rem 0 4rem;
}
.chirp-shell--centered {
min-height: 100vh;
display: grid;
place-content: center;
text-align: center;
}
.masthead,
.timeline__heading,
.composer__footer,
.post__meta,
.post__actions,
.follow-form > div,
.profile-form > div,
.following-list > header,
.notice,
.undo {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
}
.masthead { margin-bottom: 2rem; border-bottom: 2px solid; }
h1 { margin-bottom: 0.5rem; font-size: 3rem; }
h1 span, .eyebrow { color: #d84f38; }
.eyebrow { margin-bottom: 0.35rem; font-size: 0.8rem; }
.identity { overflow-wrap: anywhere; font-size: 0.8rem; }
.workspace {
display: grid;
grid-template-columns: minmax(15rem, 1fr) minmax(0, 2fr);
gap: 2rem;
align-items: start;
}
.paper, .notice {
padding: 1rem;
border: 1px solid #78918c;
background: #fff;
}
.composer, .follow-form, .following-list { margin-top: 1rem; }
label { display: block; margin-bottom: 0.5rem; font-weight: 600; }
textarea, input { width: 100%; padding: 0.7rem; border: 1px solid #78918c; }
textarea { min-height: 8rem; resize: vertical; }
.composer__footer { margin-top: 0.75rem; }
.follow-form > div input, .profile-form > div input { flex: 1; }
.button {
padding: 0.6rem 0.8rem;
border: 1px solid #17342f;
color: #17342f;
background: #ffd84a;
cursor: pointer;
}
.button--ink { color: #fff; background: #17342f; }
.button--quiet, .button--danger { background: transparent; }
.button--danger, .notice--error { color: #a72d1e; }
.timeline__heading { align-items: end; }
.post { padding: 1rem 0; border-top: 1px solid #78918c; }
.post__meta { font-size: 0.8rem; }
.post__author, .following-list li { display: grid; gap: 0.2rem; }
.post__author span, .following-list li span { overflow-wrap: anywhere; color: #526b66; }
.post__text { margin: 1rem 0; font-size: 1.2rem; line-height: 1.5; }
.post__actions { justify-content: flex-end; }
.post__editor textarea { min-height: 5rem; margin-top: 0.75rem; }
.empty { text-align: center; }
.following-list h2 { margin: 0; font-size: 1rem; }
.following-list ul { display: grid; gap: 0.8rem; margin: 1rem 0 0; padding: 0; list-style: none; }
.following-list p { margin: 1rem 0 0; color: #526b66; }
.notice { margin-bottom: 1rem; }
.notice button, .undo button { border: 0; color: inherit; background: transparent; cursor: pointer; text-decoration: underline; }
.undo { position: fixed; right: 1rem; bottom: 1rem; padding: 0.8rem 1rem; color: #fff; background: #17342f; }
.undo button { color: #ffd84a; }
@media (max-width: 700px) {
.masthead { align-items: start; }
.workspace { grid-template-columns: 1fr; }
}The scaffold's existing src/main.tsx already renders <App />, so there are
no other frontend files to change.
8 · Run Chirp
Confirm Jolt Console is version 0.4.0 or newer, start it, then run:
yarn tauri dev
The first launch waits at Approve Chirp in Jolt Console. Open Jolt Console, review the generated request, and approve it. Chirp then shows the identity owned by that Jolt installation.
Save a nickname and publish a post. Close and reopen Chirp: the profile, post, and following Document remain under the same identity, and the timeline starts from its retained verified view rather than waiting for the network.
9 · Test Alice and Bob without two daemons
Install Vitest:
yarn add --dev vitest
Create src/chirp.test.ts:
import { describe, expect, it } from "vitest";
import { State } from "jolt-sdk/data";
import { Chirp, Post } from "./chirp";
import { follow } from "./following";
import { getProfiles, saveNickname } from "./profiles";
import { Timeline } from "./timeline";
describe("beginner Chirp Data SDK example", () => {
it("persists the identities Alice follows", async () => {
const alice = Chirp.test({ identity: "alice.jolt" });
const following = await follow(alice, "bob.jolt");
expect(following.value.identities).toEqual(["bob.jolt"]);
expect((await alice.following.get()).isPresent()).toBe(true);
});
it("shares a nickname without hiding its canonical Jolt identity", async () => {
const world = Chirp.testWorld();
const alice = world.as("alice.jolt");
const bob = world.as("bob.jolt");
await saveNickname(alice, "Alice");
await saveNickname(alice, "Alice W.");
const profiles = await getProfiles(bob, ["alice.jolt"]);
expect(profiles.get("alice.jolt")).toEqual({
identity: "alice.jolt",
nickname: "Alice W.",
});
});
it("keeps followed identities readable when they have no nickname", async () => {
const world = Chirp.testWorld();
const alice = world.as("alice.jolt");
const bob = world.as("bob.jolt");
await follow(alice, "bob.jolt");
const profiles = await getProfiles(alice, ["bob.jolt"]);
expect(profiles.get("bob.jolt")).toEqual({ identity: "bob.jolt" });
});
it("shows Alice's new post in Bob's open timeline", async () => {
const world = Chirp.testWorld();
const alice = world.as("alice.jolt");
const bob = world.as("bob.jolt");
const timeline = await Timeline.open(bob.posts, ["alice.jolt"]);
const changed = new Promise<void>((resolve) => {
timeline.subscribe((snapshot) => {
if (snapshot.posts.some(post => post.value.text === "Hello, Bob!")) resolve();
});
});
await alice.posts.create({
text: "Hello, Bob!",
postedAt: new Date("2026-08-29T09:00:00.000Z"),
});
await changed;
expect(timeline.getSnapshot().posts[0]?.value.text).toBe("Hello, Bob!");
await timeline.close();
});
it("creates, edits, deletes, and restores Alice's post", async () => {
const chirp = Chirp.test({ identity: "alice.jolt" });
const postedAt = new Date("2026-08-28T12:00:00.000Z");
const created = await chirp.posts.create({ text: "Hello!", postedAt });
const updated = await created.update({ text: "Hello, everyone!" });
const deleted = await updated.delete();
const restored = await deleted.restore({ text: updated.value.text, postedAt });
expect(restored.state).toBe(State.Present);
expect(restored.value).toBeInstanceOf(Post);
expect(restored.value).toEqual({
text: "Hello, everyone!",
postedAt,
});
});
it("loads Alice's existing posts after Bob follows her", async () => {
const world = Chirp.testWorld();
const alice = world.as("alice.jolt");
const bob = world.as("bob.jolt");
await alice.posts.create({
text: "First chirp",
postedAt: new Date("2026-08-29T08:00:00.000Z"),
});
await alice.posts.create({
text: "Second chirp",
postedAt: new Date("2026-08-29T09:00:00.000Z"),
});
const following = await follow(bob, "alice.jolt");
const timeline = await Timeline.open(bob.posts, [
bob.identity,
...following.value.identities,
]);
expect(timeline.getSnapshot().posts.map(post => post.value.text)).toEqual([
"Second chirp",
"First chirp",
]);
await timeline.close();
});
});Chirp.test() gives one isolated typed app. Chirp.testWorld() gives Alice and
Bob two identity-bound views of shared deterministic state. The tests use the
same public profiles, private following list, posts, Item mutations, Data
Subscription, and Change Stream interfaces as the desktop application; no
daemon or network is needed.
Run them:
yarn vitest run
10 · Run it with a friend
To see the real network path, run Chirp on two laptops that can discover one another through Jolt:
- Alice and Bob each start Jolt Console and Chirp.
- Each approves Chirp's generated request.
- Alice saves a nickname and publishes a chirp.
- Bob enters Alice's
.joltidentity in the follow form; Alice appears in his Following sidebar by nickname and canonical identity. - Alice's existing posts appear from Bob's retained verified view, and later verified changes arrive through the same timeline subscription.
- Alice can follow Bob in the same way.
Following does not grant read permission: Chirp posts are public. It records which public identities Bob wants in his timeline. Jolt remains responsible for signed storage, provider discovery, verification, caching, and bounded refresh.
11 · Where to go next
The beginner app is complete. Reach for these only when a real requirement appears:
- Data SDK fundamentals explains Schema Classes, Resources, Apps, Items, access, and testing without the React screen around them.
- Item mutations explains immutable Item snapshots, lifecycle states, and expected failure types outside the React UI.
- Schema migrations shows how to upgrade older stored values without adding historical model classes to the application.
- Manual conflicts expose concurrent alternatives instead of using the automatic defaults.
- Data Subscriptions explain retained remote views, freshness, Change Streams, and cleanup without React.
- Data SDK testing separates fast in-memory app tests from the smaller set of checks that require real Jolt daemons.
- Content References identify one exact immutable content version. Normal
relationships use stable logical
Refvalues. - bulk mutations perform independent itemwise operations with indexed partial-success results; they are not transactions.