Chapter 1 of 10
One booking
A salon's bookings as a Copilotz plugin: a collection, an Action, a View and a resource. Sam books a slot, and the record says he did.
On this page 11
The pain
A booking system looks small: a list of times, and who took each. Then a second way in arrives. The staff's page books one way, the chatbot another, an integration a third. Each checks a little differently. One day two customers hold the same Tuesday, and nobody can say who booked it.
The problem
You want one place that says what a booking is and how one is made. Every caller goes through it, and every booking remembers who made it. And the salon's hours should be settings, not code.
The solution
A Copilotz app is a runtime made of plugins, and a plugin is plain objects. The salon's first plugin, bookings, has four:
- a collection,
booking: documents with a JSON Schema; - an Action,
booking.make: the one way to make a booking; - a View,
bookings: what anyone sees, here as text; - a resource kind,
slots: the salon's times, which the app sets.
The plugin
plugins/bookings/mod.ts puts them together:
export const bookings: Plugin = definePlugin({
name: "bookings",
collections: {
booking: {
schema: {
type: "object",
properties: { slot: { type: "string" }, label: { type: "string" } },
required: ["slot", "label"],
},
},
},
actions: { "booking.make": makeAction },
views: { bookings: bookingsView },
// The salon's times: the app sets its own.
resources: { slots: {} },
});The Action
An Action has a name, an input schema, who may call it, and what it does (actions.ts):
export const makeAction = defineAction({
description: "Books a free slot",
input: {
type: "object",
properties: { slot: { type: "string", minLength: 1 } },
required: ["slot"],
additionalProperties: false,
},
// Who may call it: people.
allow: people,
run: async (ctx, input) => {
const label = slotsOf(ctx)[input.slot];
if (!label) throw failure("not_found", `There's no slot "${input.slot}".`);
const bookings = ctx.db.collection("booking");
if (await bookings.findOne({ slot: input.slot })) throw failure("taken", `${label} is taken.`);
const { insertedId } = await bookings.insertOne({ slot: input.slot, label });
return { booking: insertedId, label };
},
});allowsays who may call it, whichever way the call comes in.peoplelets in people, and an agent working for one of them (chapter 4).ctx.dbreads and writes collections with the MongoDB API.- A refusal is a
failure: a code callers act on, and a message people read. slotsOf(ctx)reads theslotsresource, whatever the app set it to.
Every write records an event in the same commit as the write. You don't log anything yourself.
The View
A View reads, and returns what to show (views.ts):
export const bookingsView = (_: unknown, ctx: ViewContext<unknown>): string => {
const order = Object.keys(slotsOf(ctx));
const found = (ctx.db.collection<Booking>("booking").find({}).value ?? [])
.toSorted((a, b) => order.indexOf(a.slot) - order.indexOf(b.slot));
return found.length ? found.map((b) => `${b.label} (${b.slot})`).join("\n") : "No bookings.";
};A View never writes. The runtime remembers what it read, which is how a screen stays live. Chapter 3 puts that to work.
The app
The salon's settings are a plugin too, last in the list (app.ts). A later plugin's resources patch an earlier one's, so the salon fills in the slots that bookings declared empty:
const salon = definePlugin({
name: "salon",
resources: {
// The salon's week: each slot is booked once.
slots: {
"tue-10": "Tue 10:00",
"tue-11": "Tue 11:00",
"wed-15": "Wed 15:00",
"thu-09": "Thu 09:00",
"fri-14": "Fri 14:00",
"sat-10": "Sat 10:00",
},
},
});
export function plugins(): Plugin[] {
return [bookings, salon];
}Sam books
hello.ts starts the runtime in memory, and books as Sam, who works at the salon:
const store = memoryStore();
const runtime = await createRuntime({ store, plugins: plugins() });
// Every call is someone's. Sam works at the salon: a person.
const sam: Actor = { kind: "person", id: "sam" };
await runtime.call("booking.make", { slot: "tue-10" }, { actor: sam });
await runtime.call("booking.make", { slot: "tue-10" }, { actor: sam })
.catch((error) => console.log(`Refused: ${error.message}`));
console.log(await runtime.render("bookings", {}));
console.log("\nThe record:");
for (const { seq, type, actor, data } of await store.events(0)) {
const what = (data.doc as { label?: string } | undefined)?.label ?? data.action;
console.log(`#${seq} ${type.padEnd(17)} ${String(what).padEnd(13)} ${actor?.kind} ${actor?.id}`);
}Every call carries an actor: who is asking. Here it's a person, sam. Later chapters add customers, the salon's agent and assistants outside, and each call still says who.
Run it
npm run helloRefused: Tue 10:00 is taken.
Tue 10:00 (tue-10)
The record:
#1 action.started booking.make person sam
#2 booking.created Tue 10:00 person sam
#3 action.completed booking.make person sam
#4 action.started booking.make person sam
#5 action.failed booking.make person samTwo kinds of event, both with their actor:
- An Action's lifecycle:
action.started, thenaction.completedoraction.failed. The refused call is on the record too. - A collection write:
booking.created, in the same commit as the booking itself. The record can't miss one.
There are no tests yet: npm test runs none, and passes. Chapter 2 writes the first ones.
What this unlocks
- One way to make a booking. The page, the salon's agent, ChatGPT and WhatsApp will all call
booking.make. None of them gets a second, looser way. - Who did it, on every write. The actor rides along with each event, so "who booked Tuesday?" has an answer.
- Settings, not code. Another salon sets other slots, and uses the same plugin.
Your challenge
Add Saturday at 11:00 to the salon's slots and book it in hello.ts. Then book sun-10, a slot that doesn't exist: what does the record say?
Next, customers come in, and each one may see only their own bookings.