Before implementing inbox-based endpoints, you need to set up a Jazz server worker.
Server workers have Jazz accounts with static credentials. Generate new credentials by running:
npx jazz-run account create --name "Balance Service Worker"
This will output:
JAZZ_WORKER_ACCOUNT=co_zH3i4n2V8X...
JAZZ_WORKER_SECRET=sealerSecret_a5DyE7H9c2...
Store these credentials as environment variables. Handle the Account Secret like any other secret (DB password, API key, etc.)
# .env file
JAZZ_WORKER_ACCOUNT=co_zH3i4n2V8X...
JAZZ_WORKER_SECRET=sealerSecret_a5DyE7H9c2...
import { startWorker } from 'jazz-tools/worker';
const { worker, experimental: { inbox } } = await startWorker({
syncServer: 'wss://cloud.jazz.tools/[email protected]',
// Credentials are automatically read from JAZZ_WORKER_ACCOUNT and JAZZ_WORKER_SECRET
// Or pass them explicitly:
// accountID: process.env.JAZZ_WORKER_ACCOUNT,
// accountSecret: process.env.JAZZ_WORKER_SECRET,
});
console.log("Worker started:", worker.id);
The Jazz inbox system enables RPC-like communication for operations requiring server-side validation, atomic updates, or controlled access. Unlike traditional request/response patterns, Jazz leverages automatic data synchronization for reads while using inbox messages only for writes.
Create a balance system where only the worker can modify balances, but users can subscribe to changes:
import { co, z, Group } from "jazz-tools";
// User balance - readable by owner, writable only by worker
export const UserBalance = co.map({
userId: z.string(),
balance: z.number(),
lastUpdated: z.number(),
});
// User's private root to store balance reference
export const UserPrivateRoot = co.map({
balanceId: z.string(),
});
// Purchase record - immutable audit trail
export const PurchaseRecord = co.map({
userId: z.string(),
itemId: z.string(),
amount: z.number(),
timestamp: z.number(),
balanceAfter: z.number(),
});
// Write operations via inbox
export const BuyItemRequest = co.map({
type: z.literal("buy"),
itemId: z.string(),
amount: z.number(),
});
export const CreateAccountRequest = co.map({
type: z.literal("createAccount"),
initialBalance: z.number(),
});
export const InboxMessage = z.discriminatedUnion("type", [
BuyItemRequest,
CreateAccountRequest,
]);
The worker handles only write operations through the inbox:
inbox.subscribe(
InboxMessage,
async (message, senderAccountID) => {
const senderAccount = await co.account().load(senderAccountID, { loadAs: worker });
if (!senderAccount) return;
switch (message.type) {
case "createAccount": {
// Create balance with restricted permissions
const balanceGroup = Group.create({ owner: worker });
balanceGroup.addMember(senderAccount, "reader");
const balance = UserBalance.create(
{
userId: senderAccountID,
balance: message.initialBalance,
lastUpdated: Date.now(),
},
{ owner: balanceGroup }
);
// Store balance ID in user's private root for easy access
const userRoot = senderAccount.root;
if (userRoot) {
userRoot.balanceId = balance.id;
await userRoot.waitForSync();
}
return balance;
}
case "buy": {
// Load user's balance from their root
const userRoot = senderAccount.root as UserPrivateRoot | undefined;
if (!userRoot?.balanceId) throw new Error("No account found");
const balance = await UserBalance.load(userRoot.balanceId, { loadAs: worker });
if (!balance) throw new Error("Balance not found");
// Validate and update atomically
if (balance.balance < message.amount) {
throw new Error("Insufficient balance");
}
balance.balance -= message.amount;
balance.lastUpdated = Date.now();
// Create audit record
const purchaseGroup = Group.create({ owner: worker });
purchaseGroup.addMember(senderAccount, "reader");
const purchase = PurchaseRecord.create(
{
userId: senderAccountID,
itemId: message.itemId,
amount: message.amount,
timestamp: Date.now(),
balanceAfter: balance.balance,
},
{ owner: purchaseGroup }
);
await Promise.all([
balance.waitForSync(),
purchase.waitForSync()
]);
return purchase;
}
}
},
{ retries: 3 }
);
import { InboxSender, useCoState } from "jazz-tools";
import { UserBalance, BuyItemRequest } from "./schema";
// Initialize once - for write operations
const purchaseService = await InboxSender.load(
WORKER_ACCOUNT_ID,
currentUserAccount
);
// Create account (one-time setup)
const balance = await purchaseService.sendMessage(
CreateAccountRequest.create({
type: "createAccount",
initialBalance: 100,
})
);
// Subscribe to balance changes (real-time updates)
// In React:
const balance = useCoState(UserBalance, currentUserAccount.root?.balanceId);
// In vanilla JS:
const unsubscribe = UserBalance.subscribe(
currentUserAccount.root?.balanceId,
(balance) => {
console.log("Current balance:", balance?.balance);
}
);
// Make purchases through inbox
try {
const purchase = await purchaseService.sendMessage(
BuyItemRequest.create({
type: "buy",
itemId: "item-123",
amount: 25,
})
);
// Balance will auto-update via subscription
} catch (error) {
console.error("Purchase failed:", error.message);
}
- Reads are automatic: Clients subscribe directly to CoValues they have permission to read
- Writes go through inbox: Only operations that modify state need inbox messages
- No "get" methods needed: Jazz's automatic sync eliminates request/response for reads
- Real-time updates: All permitted clients see changes immediately
Instead of request/response, use state transitions:
export const Order = co.map({
status: z.enum(["draft", "processing", "completed", "failed"]),
items: z.array(z.string()),
total: z.number(),
error: z.optional(z.string()),
});
// Client creates order in "draft" state
const order = Order.create({
status: "draft",
items: ["item-1"],
total: 50
});
// Client transitions to "processing"
order.status = "processing";
// Worker subscribes to orders and processes them
Order.subscribe(orderId, (order) => {
if (order?.status === "processing") {
processOrder(order);
}
});
1. Worker-Only Write
const group = Group.create({ owner: worker });
group.addMember(user, "reader");
// User can read but not write
2. Shared Write Access
const group = Group.create({ owner: worker });
group.addMember(user, "writer");
// Both can modify
3. Public Read
group.addMember("everyone", "reader");
// Anyone can read
1. Batch Processing
const batchProcessor = new Map<string, BuyItemRequest[]>();
inbox.subscribe(InboxMessage, async (message, senderID) => {
if (message.type === "buy") {
// Accumulate requests
const batch = batchProcessor.get(senderID) || [];
batch.push(message);
batchProcessor.set(senderID, batch);
// Process when batch is full or on timeout
if (batch.length >= 10) {
await processBatch(senderID, batch);
batchProcessor.delete(senderID);
}
}
});
2. External Service Integration
case "buy": {
// Call external API
const paymentResult = await stripeAPI.charge({
amount: message.amount,
currency: "usd",
});
if (paymentResult.success) {
balance.balance -= message.amount;
balance.paymentId = paymentResult.id;
} else {
throw new Error("Payment failed: " + paymentResult.error);
}
}
3. Rate Limiting with CoMaps
export const RateLimitMap = co.map({
requests: z.record(z.number()), // userId -> timestamp
});
const rateLimits = RateLimitMap.create({}, { owner: worker });
inbox.subscribe(InboxMessage, async (message, senderID) => {
const now = Date.now();
const userRequests = Object.values(rateLimits.requests)
.filter(time => now - time < 60000) // Last minute
.length;
if (userRequests >= 10) {
throw new Error("Rate limit exceeded");
}
rateLimits.requests[senderID] = now;
// ... process message
});
// Integration test
const testWorker = await startWorker({ ... });
const testAccount = await Account.create({ ... });
// Subscribe to balance before making changes
let balanceUpdates = 0;
UserBalance.subscribe(testAccount.root?.balanceId, () => {
balanceUpdates++;
});
// Send purchase request
const sender = await InboxSender.load(testWorker.id, testAccount);
await sender.sendMessage(BuyItemRequest.create({
type: "buy",
itemId: "test-item",
amount: 10,
}));
// Verify real-time update
expect(balanceUpdates).toBeGreaterThan(0);
- Optimistic UI: Update UI immediately, revert on error
- Offline support: Inbox messages queue automatically
- Horizontal scaling: Multiple workers can share inbox processing
- Event sourcing: Purchase records create an immutable audit log
Based on the Jazz documentation, workers are full Jazz accounts that participate in the permission system, making them ideal for controlled server-side operations while maintaining Jazz's real-time collaborative nature.
The Ownership Revolution: Why User-Owned Data Changes Everything
As developers, we've been trained to think of applications as fortresses. The backend sits at the center, a mighty castle protecting all the data, while users are humble visitors who must knock on the API gates and ask permission to view or change anything. "Please, server, may I update my profile?" we code our frontends to plead.
But what if we've been thinking about this backwards all along?
The Traditional Mental Model: Server as Sovereign
In the REST paradigm, the server is the undisputed monarch of data. Every piece of information—from user profiles to shopping carts—lives under the server's dominion. Users don't own their data; they merely have permissions to access certain parts of the kingdom through carefully guarded endpoints.
This creates a peculiar dance: when you want to change your own profile picture, you don't just... change it. You petition the server. You send a request, the server validates your worthiness, checks your credentials, and then—if you're lucky—graciously updates "its" record of "your" data.
The Jazz Mental Model: Users as Owners
jazz.tools and similar modern approaches flip this mental model completely. Here's the radical idea: users actually own their data.
Think about it like the difference between renting and owning a home. In the REST world, you're always a tenant—you can decorate your apartment (update your data), but you need the landlord's permission for every change. In the Jazz world, you own the house. You can renovate whenever you want, and you only interact with city services (the server) for utilities and shared infrastructure.
What This Means for Application Design
This shift is profound. Instead of designing systems where the server is the source of truth for everything, we design systems where:
Imagine a todo app. In REST thinking, the server has a database of all todos, and users have permission to see certain ones. In Jazz thinking, each user has their own todo list that travels with them, and the server just helps coordinate when they want to share a list with someone else.
The Server's New Role
This doesn't mean servers become useless—they evolve into something more elegant. Instead of being data hoarders, servers become:
The server still matters, but it matters in the right ways—managing truly shared resources and application-level concerns, not micromanaging every user's personal data.
Why This Feels So Different
For developers steeped in REST, this feels like learning to write with your other hand. We're so used to thinking "database-first" that "user-first" seems almost reckless. Where's the single source of truth? How do we maintain consistency?
The answer is that consistency and truth become local-first concepts. Each user's view of their own data is always consistent because they own it. Conflicts only arise during sharing and collaboration—exactly where you'd expect them in the real world.
The Path Forward
This isn't just a technical shift—it's a philosophical one. We're moving from applications that treat users as supplicants to applications that treat users as owners. From systems where the server graciously permits access to systems where users graciously share access.
The next time you design an application, try this thought experiment: What if users actually owned their data? What if the server was just there to help them collaborate? What architecture would emerge?
You might be surprised to find it's not only more natural—it's more powerful too.