diff --git a/.design-sync/NOTES.md b/.design-sync/NOTES.md
index 263d18af..01ead25f 100644
--- a/.design-sync/NOTES.md
+++ b/.design-sync/NOTES.md
@@ -50,6 +50,11 @@ Tailwind and TypeScript). Nothing there reimplements a component.
provider's `useState` initializer, before the store connects: the fake answers the store's
first `browser.list` synchronously, so an answer taken before the browsers opened arrives after
their `browser.changed` and wins (the panel then says "Browser closed" over the page).
+- Routines: `BotloftProvider` `routines` go into `FakeRoutines` in the initializer too (same
+ first-answer race as the browsers) and as `routine.changed` after. `pkg/routines.ts` builds
+ them; with no `nextInMinutes` the next run follows the schedule (local time; cron is not worked
+ out, an hour from now), so the row's "Next" agrees with its schedule. `pkg/ids.ts` holds the
+ shared id and time helpers.
- Type emission covers the whole package with the repo as root; `.design-sync` is renamed to `ds`
in `dist/types/emit` (ts-morph skips dot folders), and `paths` points bare imports at
`app/node_modules` (`@types` first, or React resolves to its JS). `vite/client.d.ts` is added
@@ -76,7 +81,7 @@ Tailwind and TypeScript). Nothing there reimplements a component.
- The icon set follows the app's imports: an icon the app stops using disappears from `icons`.
- Bot colors in previews and conventions are sample values, not tokens.
- Scope (owner's choices on 2026-10-02): the base pieces, the bot badges, and the app screens the
- explainer video needs (sidebar, crew cards, a bot's turn, messages, requests, the live browser).
- Not in it yet: the whole crew view, the composer, the screens panel, routines, settings.
+ explainer video needs (sidebar, crew cards, a bot's turn, messages, requests, the live browser, routines).
+ Not in it yet: the whole crew view, the composer, the screens panel, settings.
- `scene.ts` mirrors protocol shapes and daemon summaries by hand; a protocol change
(`protocol.gen.ts`) or a new request kind needs it updated, or the tsc step fails.
diff --git a/.design-sync/config.json b/.design-sync/config.json
index dc6c9279..94c510f5 100644
--- a/.design-sync/config.json
+++ b/.design-sync/config.json
@@ -22,13 +22,14 @@
"BotStateBadge": "/** The bot: its state and whether the owner paused it. */ bot: { state: \"offline\" | \"launching\" | \"idle\" | \"busy\" | \"needs_approval\" | \"rate_limited\" | \"auth_error\" | \"backoff\" | \"archived\"; paused: boolean }; /** A paused crew shows its stopped bots as paused. */ crewPaused?: boolean; compact?: boolean;",
"ChiefBadge": "/** The crew this bot leads; only its name is read, for the tooltip. */ crew: { name: string }; /** Just the crown, for tight rows. */ compact?: boolean;",
"Menu": "/** Accessible name of the button that opens the menu. */ label: string; /** An icon from window.Botloft.icons, e.g. icons.Ellipsis. */ icon: React.ComponentType<{ size?: number; strokeWidth?: number }>; items: { label: string; icon?: React.ComponentType<{ size?: number; strokeWidth?: number }>; danger?: boolean; disabled?: boolean; /** Makes the item one of a set of choices, marked when chosen. */ checked?: boolean; onSelect: () => void }[];",
- "ApprovalCard": "/** A request: Botloft.chat.askCommand / askSite / suggestBot / askPlan(...).body. The card follows its toolName (command, website, suggested bot, plan) and its status (pending, allowed, denied, expired). Needs BotloftProvider around it. */ approval: any; /** A bot from Botloft.makeBot(...). */ bot: any;",
+ "ApprovalCard": "/** A request: Botloft.chat.askCommand / askSite / suggestBot / askPlan / askRoutine(...).body. The card follows its toolName (command, website, suggested bot, plan, routine) and its status (pending, allowed, denied, expired). Needs BotloftProvider around it. */ approval: any; /** A bot from Botloft.makeBot(...). */ bot: any;",
"BotRun": "/** One turn of the bot, in order: items from Botloft.chat.reply / tool / askCommand / askSite / suggestBot / askPlan / turn(bot, ...). Needs BotloftProvider around it. */ items: any[]; /** A bot from Botloft.makeBot(...). */ bot: any; /** The turn in progress: the reply being written (draft) or the working dots (working). Only a live run moves the avatar. */ live?: { draft: string; working: boolean };",
"InboundRow": "/** A message from Botloft.makeMessage({ from, to, body }). From \"owner\" it is your bubble on the right; from a bot or \"system\" it sits on the left with the sender's avatar. The sender must be among BotloftProvider bots. */ message: any; /** The bot whose chat this is: the message's recipient. */ /** A bot from Botloft.makeBot(...). */ bot: any;",
"CrewBots": "/** A crew from Botloft.makeCrew(...). */ crew: any; /** Its bots, from Botloft.makeBot({ crew, ... }); the Chief (chief: true) gets the crown. Pass the same ones to BotloftProvider. */ bots: any[]; onNewBot: () => void;",
"Sidebar": "/** No props: it lists the crews and bots given to BotloftProvider, with each bot's state and last activity, and the account at the bottom. Put it as the direct first child of the app window's flex row (fixed height); wrapped in anything else it shrinks to its content. */ className?: never;",
- "BotloftProvider": "/** Crews from Botloft.makeCrew(...). */ crews?: any[]; /** Bots from Botloft.makeBot(...). A new array updates them, e.g. to change a state over time. */ bots?: any[]; /** Open browsers for BrowserPanel: [{ bot, url, title, tabs?: [{ url, title }], action?: { kind: \"open\" | \"click\" | \"type\" | \"scroll\" | \"back\", x?, y?, label? } }]. A new action object moves the bot's cursor on the page again. */ browsers?: any[]; /** The bot whose chat is open: highlighted in the sidebar. */ selectedBotId?: string; /** Called when Allow or Deny is clicked on a request card. */ onAnswer?: (answer: { approvalId: string; allow: boolean; note?: string }) => void; children: React.ReactNode;",
- "BrowserPanel": "/** The bot whose browser this is; its page comes from BotloftProvider browsers (it shows \"closed\" without one). Place it as the last child of the app window's flex row, right of the chat column: it is a side panel the owner can widen. */ bot: any; /** Counts up each time the owner asks to take the browser into their own hands. */ take?: number; onClose: () => void;"
+ "BotloftProvider": "/** Crews from Botloft.makeCrew(...). */ crews?: any[]; /** Bots from Botloft.makeBot(...). A new array updates them, e.g. to change a state over time. */ bots?: any[]; /** Open browsers for BrowserPanel: [{ bot, url, title, tabs?: [{ url, title }], action?: { kind: \"open\" | \"click\" | \"type\" | \"scroll\" | \"back\", x?, y?, label? } }]. A new action object moves the bot's cursor on the page again. */ browsers?: any[]; /** Routines from Botloft.makeRoutine(...), for CrewRoutines. A new array updates them, e.g. to show a run starting. */ routines?: any[]; /** The bot whose chat is open: highlighted in the sidebar. */ selectedBotId?: string; /** Called when Allow or Deny is clicked on a request card. */ onAnswer?: (answer: { approvalId: string; allow: boolean; note?: string }) => void; children: React.ReactNode;",
+ "BrowserPanel": "/** The bot whose browser this is; its page comes from BotloftProvider browsers (it shows \"closed\" without one). Place it as the last child of the app window's flex row, right of the chat column: it is a side panel the owner can widen. */ bot: any; /** Counts up each time the owner asks to take the browser into their own hands. */ take?: number; onClose: () => void;",
+ "CrewRoutines": "/** A crew from Botloft.makeCrew(...): it lists every routine of the crew's bots that BotloftProvider was given (routines from Botloft.makeRoutine), each with its bot, its schedule in words, when it runs next and how its last run went. */ crew: any;"
},
"overrides": {
"Dialog": {
@@ -72,6 +73,9 @@
"BrowserPanel": {
"cardMode": "single",
"viewport": "1440x620"
+ },
+ "CrewRoutines": {
+ "cardMode": "column"
}
},
"readmeHeader": ".design-sync/conventions.md"
diff --git a/.design-sync/conventions.md b/.design-sync/conventions.md
index 72e2cf20..2a9c0b65 100644
--- a/.design-sync/conventions.md
+++ b/.design-sync/conventions.md
@@ -61,6 +61,13 @@ const scout = makeBot({ crew, name: "Scout", state: "busy", role: "Finds sources
`chat.askCommand(bot, command, { explanation })`, `chat.suggestBot(chief, { name, role, model,
instructions, reason })`, `chat.askPlan(bot, markdown)`, `chat.turn(bot, seconds)`. Each takes
`{ status: "allowed" | "denied" }` to show it answered. `ApprovalCard` takes one of them's `.body`.
+ `chat.askRoutine(bot, { name, prompt, schedule, bot: "writer" })` is a bot asking to set up a
+ routine (for itself, or for another bot by handle).
+- Routines, for `CrewRoutines` (pass them as `BotloftProvider` `routines`): `makeRoutine({ bot,
+ name, prompt, schedule: schedule.weekly([5], "09:00") | schedule.weekdays("08:30") |
+ schedule.every(120), lastRun: { status: "done" | "failed" | "queued" | "skipped" } })`. Days are
+ 1 = Monday ... 7 = Sunday. The next run follows the schedule. `status: "queued"` reads "Running
+ now": a new `routines` array with it shows a routine starting.
- `makeMessage({ from: bot | "owner" | "system", to: bot, body, kind: "note" | "task" })` for
`InboundRow`: yours on the right, a bot's or Botloft's on the left.
@@ -76,8 +83,10 @@ const scout = makeBot({ crew, name: "Scout", state: "busy", role: "Finds sources
`Choices` (2-6 options side by side), `Switch`, `Tabs`, `Menu`, `Details`, `SidePanel`.
- App screens: `Sidebar` (crews and bots, 288px wide), `CrewBots` (a crew's bot cards), `BotRun`
(one turn of a bot in its chat, inside a `
`), `InboundRow` (a message in a chat, inside a
- ``), `ApprovalCard` (a request: a website, a command, a suggested bot, a plan),
- `BrowserPanel` (a bot's browser, live: tabs, address bar, the page and the bot's cursor).
+ ``), `ApprovalCard` (a request: a website, a command, a suggested bot, a plan, a routine),
+ `BrowserPanel` (a bot's browser, live: tabs, address bar, the page and the bot's cursor),
+ `CrewRoutines` (a crew's routines: each with its bot, schedule in words, next run, last run;
+ it fills the chat column's place, ``).
`BotloftProvider`'s `onAnswer` hears Allow and Deny on request cards.
- **The app window:** one row, `className="flex bg-canvas text-ink"` with a fixed height
(`style={{ height: 820 }}`): the `Sidebar` as its **direct first child**, then the chat column
diff --git a/.design-sync/pkg/ids.ts b/.design-sync/pkg/ids.ts
new file mode 100644
index 00000000..09d5d698
--- /dev/null
+++ b/.design-sync/pkg/ids.ts
@@ -0,0 +1,8 @@
+// Ids and times for the sample data: unique per page, prefixed like the
+// daemon's (`bot_`, `msg_`...), and times counted back from now.
+
+let counter = 0;
+
+export const id = (prefix: string) => `${prefix}_${String(++counter).padStart(6, "0")}`;
+
+export const ago = (minutes: number) => Date.now() - minutes * 60_000;
diff --git a/.design-sync/pkg/index.ts b/.design-sync/pkg/index.ts
index c17af3f4..519dbf38 100644
--- a/.design-sync/pkg/index.ts
+++ b/.design-sync/pkg/index.ts
@@ -13,6 +13,7 @@ export { ApprovalCard } from "../../app/src/features/chat/ApprovalCard";
export { BotRun } from "../../app/src/features/chat/BotRun";
export { BrowserPanel } from "../../app/src/features/browser/BrowserPanel";
export { InboundRow } from "../../app/src/features/chat/InboundRow";
+export { CrewRoutines } from "../../app/src/features/routines/RoutineList";
export { CrewBots } from "../../app/src/features/crews/CrewBots";
export { Sidebar } from "../../app/src/features/crews/Sidebar";
export { setLocaleChoice } from "../../app/src/i18n";
@@ -31,4 +32,5 @@ export { type Tab, Tabs } from "../../app/src/ui/Tabs";
export { notifyError, Toaster } from "../../app/src/ui/toast";
export * as icons from "./icons.gen";
export { BotloftProvider, type BotloftProviderProps, type BrowserSeed } from "./provider";
+export { makeRoutine, schedule } from "./routines";
export { AVATAR_PALETTE, chat, makeBot, makeCrew, makeMessage } from "./scene";
diff --git a/.design-sync/pkg/provider.tsx b/.design-sync/pkg/provider.tsx
index b84aac49..77f64eeb 100644
--- a/.design-sync/pkg/provider.tsx
+++ b/.design-sync/pkg/provider.tsx
@@ -17,6 +17,7 @@ import type {
BotId,
BrowserActionKind,
Crew,
+ Routine,
} from "../../app/src/lib/protocol.gen";
import { DaemonProvider, HostProvider, useApp, useAppStore } from "../../app/src/store/context";
@@ -35,6 +36,8 @@ export interface BotloftProviderProps {
bots?: Bot[];
/** Open browsers, shown live by BrowserPanel. */
browsers?: BrowserSeed[];
+ /** Routines from makeRoutine, listed by CrewRoutines; a new array updates them. */
+ routines?: Routine[];
/** The bot whose chat is open: highlighted in the sidebar. */
selectedBotId?: BotId | null;
/** Called when Allow or Deny is clicked on a request card. */
@@ -76,6 +79,7 @@ export function BotloftProvider({
crews = [],
bots = [],
browsers = [],
+ routines = [],
selectedBotId,
onAnswer,
children,
@@ -89,6 +93,7 @@ export function BotloftProvider({
// Before the store connects: the fake answers its first `browser.list`
// at once, and an answer from before the browsers opened would win.
for (const browser of browsers) showBrowser(api, browser);
+ for (const routine of routines) api.routines.routines.set(routine.id, routine);
const call = api.call.bind(api) as FakeBotloft["call"];
// A request card's answer goes to the design, not to a daemon.
api.call = ((method: string, ...params: unknown[]) => {
@@ -124,6 +129,12 @@ export function BotloftProvider({
showBrowser(fake, browser);
}
}, [fake, browsers]);
+ useEffect(() => {
+ for (const routine of routines) {
+ fake.routines.routines.set(routine.id, routine);
+ fake.emit({ name: "routine.changed", params: routine });
+ }
+ }, [fake, routines]);
return (
diff --git a/.design-sync/pkg/routines.ts b/.design-sync/pkg/routines.ts
new file mode 100644
index 00000000..60352dec
--- /dev/null
+++ b/.design-sync/pkg/routines.ts
@@ -0,0 +1,109 @@
+// Routines for the design system's sample data (spec 20): schedules as the
+// app writes them and routines in the protocol's shape, for CrewRoutines.
+
+import type { Bot, Routine, RunStatus, Schedule, SkipReason } from "../../app/src/lib/protocol.gen";
+import { ago, id } from "./ids";
+
+export const zone = () => Intl.DateTimeFormat().resolvedOptions().timeZone;
+
+/** Schedules as the app writes them: days are 1 = Monday ... 7 = Sunday, time "HH:MM". */
+export const schedule = {
+ weekly(days: number[], time: string): Schedule {
+ return { kind: "weekly", days, time };
+ },
+ weekdays(time: string): Schedule {
+ return { kind: "weekly", days: [1, 2, 3, 4, 5], time };
+ },
+ every(minutes: number): Schedule {
+ return { kind: "interval", minutes };
+ },
+ cron(expr: string): Schedule {
+ return { kind: "cron", expr };
+ },
+};
+
+export interface RoutineSeed {
+ bot: Bot;
+ name: string;
+ /** The message the bot gets each time it runs. */
+ prompt?: string;
+ schedule: Schedule;
+ enabled?: boolean;
+ /**
+ * When it runs next, from now; null for never. Left out, it follows the
+ * schedule: the next of its days and time, or one interval after the last run.
+ */
+ nextInMinutes?: number | null;
+ /** How it went last time; "queued" reads as running now. */
+ lastRun?: { status: RunStatus; minutesAgo?: number; reason?: SkipReason; skippedCount?: number };
+}
+
+/** The next run by the schedule, in local time; `lastAt` anchors an interval. */
+function nextBySchedule(rule: Schedule, lastAt: number | null): number | null {
+ const now = Date.now();
+ switch (rule.kind) {
+ case "interval": {
+ const step = rule.minutes * 60_000;
+ let next = (lastAt ?? now) + step;
+ while (next <= now) next += step;
+ return next;
+ }
+ case "weekly": {
+ const [hours = 0, minutes = 0] = rule.time.split(":").map(Number);
+ for (let ahead = 0; ahead <= 7; ahead++) {
+ const day = new Date(now);
+ day.setDate(day.getDate() + ahead);
+ day.setHours(hours, minutes, 0, 0);
+ // getDay(): 0 = Sunday; the schedule's days: 1 = Monday ... 7 = Sunday.
+ const weekday = day.getDay() === 0 ? 7 : day.getDay();
+ if (rule.days.includes(weekday) && day.getTime() > now) {
+ return day.getTime();
+ }
+ }
+ return null;
+ }
+ case "cron":
+ // Not worked out here: an hour from now.
+ return now + 60 * 60_000;
+ }
+}
+
+export function makeRoutine(seed: RoutineSeed): Routine {
+ const routineId = id("rtn");
+ const last = seed.lastRun;
+ const lastAt = ago(last?.minutesAgo ?? 0);
+ return {
+ id: routineId,
+ botId: seed.bot.id,
+ name: seed.name,
+ prompt: seed.prompt ?? "",
+ schedule: seed.schedule,
+ timezone: zone(),
+ overlap: "skip",
+ missed: "run_once",
+ enabled: seed.enabled ?? true,
+ nextRunAt:
+ seed.nextInMinutes === null
+ ? null
+ : seed.nextInMinutes !== undefined
+ ? Date.now() + seed.nextInMinutes * 60_000
+ : nextBySchedule(seed.schedule, last ? lastAt : null),
+ lastRun: last
+ ? {
+ id: id("run"),
+ routineId,
+ scheduledFor: lastAt,
+ status: last.status,
+ reason: last.reason ?? null,
+ skippedCount: last.skippedCount ?? 0,
+ messageId: null,
+ createdAt: lastAt,
+ finishedAt: last.status === "queued" ? null : lastAt,
+ }
+ : null,
+ createdAt: ago(60 * 24 * 2),
+ updatedAt: lastAt,
+ archivedAt: null,
+ };
+}
+
diff --git a/.design-sync/pkg/scene.ts b/.design-sync/pkg/scene.ts
index 47936a66..b341038a 100644
--- a/.design-sync/pkg/scene.ts
+++ b/.design-sync/pkg/scene.ts
@@ -7,6 +7,7 @@
import { SITE_TOOL } from "../../app/src/features/browser/SiteCard";
import { PLAN_TOOL } from "../../app/src/features/chat/PlanCard";
import { SUGGEST_TOOL } from "../../app/src/features/chat/SuggestionCard";
+import { ROUTINE_TOOL } from "../../app/src/features/routines/RoutineRequestCard";
import {
AVATAR_PALETTE,
type Activity,
@@ -21,12 +22,12 @@ import {
type Message,
type MessageKind,
type PermissionMode,
+ type Schedule,
type ToolStatus,
} from "../../app/src/lib/protocol.gen";
+import { ago, id } from "./ids";
+import { zone } from "./routines";
-let counter = 0;
-const id = (prefix: string) => `${prefix}_${String(++counter).padStart(6, "0")}`;
-const ago = (minutes: number) => Date.now() - minutes * 60_000;
const slug = (name: string) => name.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "bot";
export interface CrewSeed {
@@ -194,6 +195,20 @@ export const chat = {
const title = plan.split("\n").map((line) => line.replace(/^#+/, "").trim()).find(Boolean) ?? "";
return ask(bot, PLAN_TOOL, { plan }, title, extra);
},
+ /** The bot asks to set up a routine (for itself, or for `bot`, another bot's handle). */
+ askRoutine(
+ bot: Bot,
+ routine: { name: string; prompt: string; schedule: Schedule; bot?: string },
+ extra: { status?: ApprovalStatus; minutesAgo?: number } = {},
+ ): ChatItem {
+ return ask(
+ bot,
+ ROUTINE_TOOL,
+ { timezone: zone(), overlap: "skip", missed: "run_once", ...routine },
+ routine.name,
+ extra,
+ );
+ },
/** How the turn ended; closes a BotRun. */
turn(bot: Bot, seconds: number, minutesAgo = 0): ChatItem {
return item(bot, { kind: "turn", durationMs: seconds * 1000, tokens: null, error: null }, minutesAgo);
diff --git a/.design-sync/previews/ApprovalCard.tsx b/.design-sync/previews/ApprovalCard.tsx
index 53de902f..ef5f1b28 100644
--- a/.design-sync/previews/ApprovalCard.tsx
+++ b/.design-sync/previews/ApprovalCard.tsx
@@ -1,4 +1,4 @@
-import { ApprovalCard, BotloftProvider, chat, makeBot, makeCrew, setLocaleChoice } from "@botloft/ui";
+import { ApprovalCard, BotloftProvider, chat, makeBot, makeCrew, schedule, setLocaleChoice } from "@botloft/ui";
// The app follows the browser's language; the cards are in English.
setLocaleChoice("en");
@@ -6,10 +6,11 @@ setLocaleChoice("en");
const crew = makeCrew({ name: "Research" });
const chief = makeBot({ crew, name: "Chief", chief: true });
const scout = makeBot({ crew, name: "Scout", state: "needs_approval" });
+const writer = makeBot({ crew, name: "Writer" });
function Card({ approval, bot }: { approval: Parameters[0]["approval"]; bot: typeof scout }) {
return (
-
+
@@ -70,3 +71,19 @@ export function Allowed() {
);
}
+
+export function Routine() {
+ return (
+
+ );
+}
diff --git a/.design-sync/previews/CrewRoutines.tsx b/.design-sync/previews/CrewRoutines.tsx
new file mode 100644
index 00000000..fa969210
--- /dev/null
+++ b/.design-sync/previews/CrewRoutines.tsx
@@ -0,0 +1,73 @@
+import {
+ BotloftProvider,
+ CrewRoutines,
+ makeBot,
+ makeCrew,
+ makeRoutine,
+ schedule,
+ setLocaleChoice,
+} from "@botloft/ui";
+
+// The app follows the browser's language; the cards are in English.
+setLocaleChoice("en");
+
+const crew = makeCrew({ name: "Research" });
+const chief = makeBot({ crew, name: "Chief", chief: true });
+const scout = makeBot({ crew, name: "Scout" });
+const writer = makeBot({ crew, name: "Writer" });
+
+const routines = [
+ makeRoutine({
+ bot: writer,
+ name: "Friday summary",
+ prompt: "Write this week's summary from sources.md and send it to me.",
+ schedule: schedule.weekly([5], "09:00"),
+ lastRun: { status: "done", minutesAgo: 60 * 24 * 5 },
+ }),
+ makeRoutine({
+ bot: scout,
+ name: "Source check",
+ prompt: "Look for new papers and posts on on-device AI; add the good ones to sources.md.",
+ schedule: schedule.every(120),
+ lastRun: { status: "done", minutesAgo: 73 },
+ }),
+ makeRoutine({
+ bot: chief,
+ name: "Morning plan",
+ prompt: "Check what each bot is on and tell me if anything is stuck.",
+ schedule: schedule.weekdays("08:30"),
+ lastRun: { status: "skipped", reason: "missed", skippedCount: 1, minutesAgo: 60 * 9 },
+ }),
+];
+
+export function ResearchRoutines() {
+ return (
+
+
+
+
+
+ );
+}
+
+export function Running() {
+ const [friday, ...rest] = routines;
+ const now = friday && { ...friday, lastRun: friday.lastRun && { ...friday.lastRun, status: "queued" as const, finishedAt: null } };
+ return (
+
+
+
+
+
+ );
+}
+
+export function DarkTheme() {
+ return (
+
+
+
+
+
+ );
+}