diff --git a/app/src/components/channels/avatar.tsx b/app/src/components/channels/avatar.tsx
index ae5de378..11a19e22 100644
--- a/app/src/components/channels/avatar.tsx
+++ b/app/src/components/channels/avatar.tsx
@@ -1,5 +1,6 @@
import Avatar from "boring-avatars";
import { memo } from "react";
+import { cn } from "@/lib/utils";
/**
* Memoized roster avatar. Row updates usually change preview/timestamp only, and
@@ -7,50 +8,74 @@ import { memo } from "react";
*
* `size-full` opts the generated SVG out of ancestor icon selectors such as
* `[&_svg:not([class*='size-'])]:size-4`.
+ *
+ * `typing` overlays a working indicator at the bottom-right — three bouncing dots, so a channel
+ * whose agent is mid-turn reads as busy from the roster without moving the row's layout.
*/
export const ChannelAvatar = memo(function ChannelAvatar({
participantIds,
size = 32,
+ typing = false,
}: {
participantIds: string[];
size?: number;
+ typing?: boolean;
}) {
const channelSize = participantIds?.length;
- if (channelSize === 1) {
- return (
-
- );
- }
-
- const firstThree = participantIds.slice(0, 3);
-
- return (
-
- {firstThree.map((c, i) => {
- return (
+ const avatar =
+ channelSize === 1 ? (
+
+ ) : (
+
+ {participantIds.slice(0, 3).map((c, i, shown) => (
- );
- })}
+ ))}
+
+ );
+
+ return (
+
+ {avatar}
+ {typing ? : null}
);
});
+
+/**
+ * Three bouncing dots in a small badge, ringed in the sidebar's own colour so it sits on the
+ * avatar as a badge rather than floating over it. The staggered negative delays start each dot at
+ * a different point in the same bounce, which is what makes the three read as one wave.
+ */
+function TypingBadge() {
+ return (
+
+ Working…
+
+
+
+
+ );
+}
+
+function Dot({ className }: { className?: string }) {
+ return (
+
+ );
+}
diff --git a/app/src/components/channels/channel-chat.tsx b/app/src/components/channels/channel-chat.tsx
index 38d013db..2010a386 100644
--- a/app/src/components/channels/channel-chat.tsx
+++ b/app/src/components/channels/channel-chat.tsx
@@ -14,8 +14,15 @@ import {
transcriptMessages,
} from "@/components/channels/transcript-messages";
import { agentListQueryOptions } from "@/lib/agents/queries";
-import { recordChannelActivityMutationOptions } from "@/lib/channels/mutations";
-import type { AgentChannel } from "@/lib/channels/queries";
+import {
+ recordChannelActivityMutationOptions,
+ setChannelBusyMutationOptions,
+} from "@/lib/channels/mutations";
+import {
+ type AgentChannel,
+ type ChannelSummary,
+ channelKeys,
+} from "@/lib/channels/queries";
import { useActiveBot } from "@/lib/copilot/active-bot";
import { ConversationProvider } from "@/lib/copilot/conversation";
import { afterMs, joinWithin } from "@/lib/copilot/join-thread";
@@ -23,6 +30,7 @@ import { repairUnansweredToolCalls } from "@/lib/copilot/repair-history";
import { stoppedReason } from "@/lib/copilot/stopped-turn";
import { readThreadMessages } from "@/lib/copilot/thread-messages";
import { useSkillCommands } from "@/lib/plugins/skill-commands";
+import { queryClient } from "@/query-client";
import { newId } from "../../lib/new-id";
/**
@@ -182,6 +190,73 @@ export function ChannelChat({
};
}, [copilotkit, agent, isReady, channel.threadId, runtimeAgentId]);
+ /*
+ * A turn nobody here streamed, surfaced while the channel is open.
+ *
+ * A relayed handoff answer runs on the server and lands in this thread with no browser attached.
+ * The roster hears about it — the activity socket patches the channel-list cache — but this
+ * transcript restores history once, on mount, and would show the new turn only after leaving and
+ * coming back. So it watches that same cache: when this channel's `lastMessageAt` advances to a
+ * moment a Bot authored, the durable history is read again. Riding the roster's own cache rather
+ * than a second subscription means "the sidebar updated" and "the transcript refreshes" are the
+ * one signal, and cannot drift apart.
+ *
+ * APPENDED BY ID, NOT COMPARED BY LENGTH. The stored history is not the local transcript: it
+ * keeps only what `readableTurns` can parse, and the local side keeps tool lines the platform
+ * does not hand back — so after a headless turn the stored read can be shorter than the screen
+ * and still hold the news. What is new is exactly the messages whose ids this transcript has
+ * never seen; appending them leaves everything local intact, and this tab's own turns echo back
+ * with ids already on screen and append nothing.
+ *
+ * Retried briefly, because the roster is patched when the turn is on record with the runner and
+ * the platform's read of the thread can be a beat behind it.
+ */
+ useEffect(() => {
+ const authoredAt = () => {
+ const cache = queryClient.getQueryData<{
+ pages: { channels: ChannelSummary[] }[];
+ }>(channelKeys.list());
+ const summary = cache?.pages
+ .flatMap((page) => page.channels)
+ .find((row) => row.id === channel.id);
+ // Only a Bot's turn is news here; a person's own line arrives through the run that sent it.
+ if (!summary || summary.lastMessageAgentId === null) return null;
+ return summary.lastMessageAt;
+ };
+
+ let lastSeen = authoredAt();
+
+ const pull = () => {
+ void (async () => {
+ for (const delayMs of [0, 750, 1500]) {
+ if (delayMs > 0) {
+ await new Promise((resolve) => setTimeout(resolve, delayMs));
+ }
+ const stored = await readThreadMessages(
+ channel.threadId,
+ runtimeAgentId,
+ );
+ const current = agentRef.current;
+ const seen = new Set(current.messages.map((message) => message.id));
+ const fresh = stored.messages.filter(
+ (message) => !seen.has(message.id),
+ );
+ if (fresh.length === 0) continue;
+ current.setMessages([...current.messages, ...fresh]);
+ return;
+ }
+ })();
+ };
+
+ return queryClient.getQueryCache().subscribe(() => {
+ const at = authoredAt();
+ if (at && at !== lastSeen) {
+ lastSeen = at;
+ pull();
+ }
+ });
+ }, [channel.id, channel.threadId, runtimeAgentId]);
+
// Tool calls from this conversation act on this coworker's own computer.
useActiveBot(runtimeAgentId);
@@ -222,6 +297,24 @@ export function ChannelChat({
* Tell the roster what was just said. Failures here must not block the conversation.
*/
const recordActivity = useMutation(recordChannelActivityMutationOptions());
+
+ /*
+ * Show this channel as working on the roster while its own turn runs.
+ *
+ * The server cannot see a person's turn begin — the runtime does not tell it — so the browser
+ * reports it, keyed on whether a turn is in flight. The server broadcasts it to every member, so
+ * the row shows the dots even on a tab that has since navigated elsewhere; a run that outlives
+ * this tab clears itself when the roster next refetches, which is the acceptable failure for a
+ * transient hint. Not cleared on unmount on purpose: a turn keeps running server-side after the
+ * person leaves the channel, and clearing here would drop the indicator while the work goes on.
+ */
+ const setBusy = useMutation(setChannelBusyMutationOptions());
+ const busy = turnsInFlight > 0;
+ // Keyed on the busy transition alone; `setBusy.mutate` is a stable handle, not a dependency.
+ // biome-ignore lint/correctness/useExhaustiveDependencies: firing on the busy transition only.
+ useEffect(() => {
+ setBusy.mutate({ channelId: channel.id, busy });
+ }, [busy, channel.id]);
const report = (text: string, agentId: string | null) => {
const trimmed = text.trim();
if (!trimmed) return;
diff --git a/app/src/components/channels/composer/composer.tsx b/app/src/components/channels/composer/composer.tsx
index dc8ef92b..f642ccfb 100644
--- a/app/src/components/channels/composer/composer.tsx
+++ b/app/src/components/channels/composer/composer.tsx
@@ -34,6 +34,12 @@ const COMPACT_MAX_HEIGHT_PX = 96;
export type ComposerProps = {
className?: string;
+ /**
+ * Classes for the editor itself rather than the frame. `className` styles the box — border,
+ * background, width; the type inside it is PromptArea's, so changing it (a hero composer's
+ * `text-lg`) goes through here, where tailwind-merge lets it beat the built-in `text-sm`.
+ */
+ editorClassName?: string;
compact?: boolean;
/** Agents that `@` can address. Empty means the mention menu reports an empty channel. */
agents?: readonly AgentOption[];
@@ -94,10 +100,12 @@ export type ComposerProps = {
* Defaults to `pending`, which is the right answer for a caller with no gap between the two.
*/
stoppable?: boolean;
+ initialValue?: string;
};
export function Composer({
className,
+ editorClassName,
compact = false,
agents = [],
commands = PLACEHOLDER_COMMANDS,
@@ -108,8 +116,11 @@ export function Composer({
pending = false,
autoFocus = false,
stoppable,
+ initialValue,
}: ComposerProps) {
- const [value, setValue] = useState
([]);
+ const [value, setValue] = useState(
+ initialValue ? [{ type: "text", text: initialValue }] : [],
+ );
const [isSubmitting, setIsSubmitting] = useState(false);
const submitInFlight = useRef(false);
const promptAreaRef = useRef(null);
@@ -292,7 +303,10 @@ export function Composer({
+
+
+
+
+ );
+}
+
+/**
+ * The tour's rhythm, shared by every animated value so they stay in step.
+ *
+ * Nine keyframes make eight segments, alternating hold and travel: the cursor pauses where a
+ * person would, then moves. Easings are per segment for the same reason they differ per value —
+ * a pointer launches fast and glides in ({@link EASE_OUT}, the app's entrance curve), while a
+ * click presses sharply and releases with a little pop (`backOut`). One curve across the whole
+ * loop is what made it read as a metronome.
+ */
+const TOUR = {
+ duration: 10,
+ repeat: Number.POSITIVE_INFINITY,
+ times: [0, 0.1, 0.28, 0.38, 0.56, 0.66, 0.84, 0.92, 1],
+};
+/** Hold segments do not move, so their curve is irrelevant; travel segments glide in. */
+const PATH_EASE = [
+ "linear",
+ [...EASE_OUT],
+ "linear",
+ [...EASE_OUT],
+ "linear",
+ [...EASE_OUT],
+ "linear",
+ [...EASE_OUT],
+] as const;
+/** The click: a sharp press on each hold, released with a slight overshoot on the way out. */
+const CLICK_EASE = [
+ "easeOut",
+ "backOut",
+ "easeOut",
+ "backOut",
+ "easeOut",
+ "backOut",
+ "easeOut",
+ "backOut",
+] as const;
+
+/**
+ * The agent's pointer, forever mid-errand: browser button, a card, then the Finder's files.
+ *
+ * Percentages rather than pixels, so the same journey fits whatever size the illustration is
+ * drawn at.
+ */
+function PointerCursor() {
+ const reducedMotion = useReducedMotion();
+
+ return (
+
+ {/* Tabler's pointer-2, inlined: the installed icon package predates it. */}
+
+
+
+
+ );
+}
+
+/** A text line that is deliberately not text. */
+function Line({ className }: { className?: string }) {
+ return (
+
+ );
+}
+
+function WindowFrame({
+ className,
+ children,
+}: {
+ className?: string;
+ children: React.ReactNode;
+}) {
+ return (
+
+ {children}
+
+ );
+}
+
+/** Traffic lights, monochrome on purpose: the theme's grays, not macOS's colors. */
+function TrafficLights() {
+ return (
+
+
+
+
+
+ );
+}
+
+function BrowserWindow({ className }: { className?: string }) {
+ return (
+
+
+
+ {/* The address pill; the empty span mirrors the lights so it centers truly. */}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {["a", "b", "c"].map((card) => (
+
+ ))}
+
+
+
+ );
+}
+
+/** A document glyph drawn in CSS: a page with a folded corner and two lines of nothing. */
+function FileGlyph() {
+ return (
+
+ );
+}
+
+function FolderGlyph() {
+ return
;
+}
+
+function FinderWindow({ className }: { className?: string }) {
+ const items: Array<{ key: string; folder: boolean }> = [
+ { key: "reports", folder: true },
+ { key: "invoice", folder: false },
+ { key: "assets", folder: true },
+ { key: "notes", folder: false },
+ { key: "draft", folder: false },
+ { key: "archive", folder: true },
+ ];
+
+ return (
+
+
+
+
+
+
+
+
+
+ {["one", "two", "three", "four"].map((row) => (
+
+
+
+
+ ))}
+
+
+
+ {items.map((item) => (
+
+ {item.folder ? : }
+
+
+ ))}
+
+
+
+ );
+}
diff --git a/app/src/components/computer/placeholder.tsx b/app/src/components/computer/placeholder.tsx
new file mode 100644
index 00000000..57dfab79
--- /dev/null
+++ b/app/src/components/computer/placeholder.tsx
@@ -0,0 +1,162 @@
+import type { SVGProps } from "react";
+
+/**
+ * Decorative waiting artwork for the fixed-size computer frame.
+ */
+export function ComputerPlaceholder(props: SVGProps
) {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/app/src/lib/auth/queries.ts b/app/src/lib/auth/queries.ts
index 6c4ded50..5215e08e 100644
--- a/app/src/lib/auth/queries.ts
+++ b/app/src/lib/auth/queries.ts
@@ -1,14 +1,32 @@
import { queryOptions } from "@tanstack/react-query";
import { client, tryClient } from "@/lib/client";
+/**
+ * Where this person is in first-run onboarding.
+ *
+ * On the user rather than its own query, so the `_authed` gate learns it from the request it
+ * already makes. A null `completedAt` is what sends the app to /onboarding.
+ */
+export type OnboardingStatus = {
+ step: number;
+ completedAt: string | null;
+};
+
export type AuthenticatedUser = {
id: string;
email: string;
name?: string | null;
image?: string | null;
role: "admin" | "user";
+ /** Null means this deployment does not track onboarding, which reads as nothing to finish. */
+ onboarding: OnboardingStatus | null;
};
+/** Whether the gate holds: there is an onboarding to do and this person has not finished it. */
+export function needsOnboarding(user: AuthenticatedUser): boolean {
+ return user.onboarding !== null && user.onboarding.completedAt === null;
+}
+
export const authKeys = {
all: ["auth"] as const,
currentUser: () => [...authKeys.all, "current-user"] as const,
diff --git a/app/src/lib/channels/mutations.ts b/app/src/lib/channels/mutations.ts
index af83550c..83212ef5 100644
--- a/app/src/lib/channels/mutations.ts
+++ b/app/src/lib/channels/mutations.ts
@@ -55,6 +55,25 @@ export function recordChannelActivityMutationOptions() {
});
}
+/**
+ * Tell the roster this channel is (or is no longer) running a turn.
+ *
+ * Fire-and-forget, like recorded activity: a working indicator that fails to appear or to clear is
+ * worth nothing next to the run itself, and a person is never shown an error for it. The server
+ * broadcasts it to the channel's members, so the row shows a working dot even on a tab that has
+ * navigated elsewhere.
+ */
+export function setChannelBusyMutationOptions() {
+ return mutationOptions({
+ mutationFn: async (variables: { channelId: string; busy: boolean }) => {
+ await tryClient(`/api/channels/${variables.channelId}/busy`, {
+ method: "POST",
+ body: { busy: variables.busy },
+ });
+ },
+ });
+}
+
/** Pin or unpin a channel for this member. A marker, not a reorder, so no optimistic sort. */
export function setChannelPinnedMutationOptions(queryClient: QueryClient) {
return mutationOptions({
diff --git a/app/src/lib/channels/queries.ts b/app/src/lib/channels/queries.ts
index 6da66bf1..4a62ce05 100644
--- a/app/src/lib/channels/queries.ts
+++ b/app/src/lib/channels/queries.ts
@@ -28,6 +28,15 @@ export type ChannelSummary = AgentChannel & {
pinned: boolean;
/** ISO-8601 when this member last had the channel open, or null for never. The caller's, only. */
lastReadAt: string | null;
+ /**
+ * Whether a turn is running in this channel right now.
+ *
+ * Socket-only and transient: the server never persists it and the roster query never returns it,
+ * so it is undefined until a busy event arrives and is dropped whenever the roster is refetched.
+ * A headless turn — a handoff hop, a relay — sets it, which is how the roster shows work the
+ * browser never streamed.
+ */
+ busy?: boolean;
};
export const channelKeys = {
diff --git a/app/src/lib/channels/use-channel-events.ts b/app/src/lib/channels/use-channel-events.ts
index 70ffab50..8bd1df46 100644
--- a/app/src/lib/channels/use-channel-events.ts
+++ b/app/src/lib/channels/use-channel-events.ts
@@ -24,6 +24,13 @@ export type ChannelActivityEvent = {
* made in another tab or on another replica.
*/
pinned?: boolean;
+ /**
+ * A turn started or ended in this channel. Absent on an ordinary activity event.
+ *
+ * Carries no message: it patches only the row's `busy` flag, so the roster can show a working
+ * indicator without disturbing the preview or the order.
+ */
+ busy?: boolean;
};
/** The infinite query's cache, which holds pages rather than one array. */
@@ -88,6 +95,22 @@ export function applyChannelEvent(
return { ...data, pages };
}
+ /*
+ * A busy signal patches the one field it is about, and never re-sorts.
+ *
+ * The spread below would carry this event's null message onto the row and wipe the preview. Busy
+ * is also not activity — a channel does not jump to the top of the roster because a turn started
+ * in it — so the order is left exactly as it was.
+ */
+ if (activity.busy !== undefined) {
+ if ((previous.busy ?? false) === activity.busy) return data;
+ const channels = page.channels.slice();
+ channels[index] = { ...previous, busy: activity.busy };
+ const pages = data.pages.slice();
+ pages[holdingPage] = { ...page, channels };
+ return { ...data, pages };
+ }
+
// Preserve object identity for unchanged rows so memoized rows do not re-render.
const next = page.channels.slice();
next[index] = { ...previous, ...activity };
diff --git a/app/src/lib/hotkeys/app-hotkeys.tsx b/app/src/lib/hotkeys/app-hotkeys.tsx
new file mode 100644
index 00000000..62c9ccc0
--- /dev/null
+++ b/app/src/lib/hotkeys/app-hotkeys.tsx
@@ -0,0 +1,21 @@
+import { useNavigate } from "@tanstack/react-router";
+import { useHotkey } from "./use-hotkey";
+
+/**
+ * The app-wide shortcuts, bound once for the whole signed-in app.
+ *
+ * Mounted in `_authed` rather than `_app`, so a person on settings or admin can start a chat
+ * without first clicking back into the app frame. Renders nothing; it exists to be a component
+ * because binding needs hooks and `_authed`'s route component is where the whole signed-in tree
+ * hangs.
+ */
+export function AppHotkeys() {
+ const navigate = useNavigate();
+
+ // Same destination as the sidebar's + button: the new-channel composer.
+ useHotkey("new-chat", () => {
+ navigate({ to: "/channel/new" });
+ });
+
+ return null;
+}
diff --git a/app/src/lib/hotkeys/hotkeys.ts b/app/src/lib/hotkeys/hotkeys.ts
new file mode 100644
index 00000000..7aa30361
--- /dev/null
+++ b/app/src/lib/hotkeys/hotkeys.ts
@@ -0,0 +1,89 @@
+/**
+ * Every app-wide keyboard shortcut, in one place.
+ *
+ * One place on purpose: the binding a listener matches against and the combo the settings page
+ * shows a person are the same entry, so they cannot drift apart. Adding a shortcut is adding an
+ * entry here and one `useHotkey` call where it acts; the settings list picks it up by itself.
+ *
+ * Browsers reserve their own combos. Cmd/Ctrl+N — the obvious key for "new chat" — opens a new
+ * browser window before the page ever sees the keystroke, and `preventDefault` is ignored for
+ * reserved shortcuts, so no entry here can use one. Plain Shift+letter combos work everywhere,
+ * at the cost that they are also just how capital letters are typed — which is why `useHotkey`
+ * ignores them while focus is in anything editable.
+ */
+
+export type HotkeyCombo = {
+ /** KeyboardEvent.key, lowercase. */
+ key: string;
+ shift?: boolean;
+ /** Cmd on macOS, Ctrl elsewhere. */
+ mod?: boolean;
+ alt?: boolean;
+};
+
+export type Hotkey = {
+ id: string;
+ /** What the shortcut does, as the settings page says it. */
+ label: string;
+ description: string;
+ combo: HotkeyCombo;
+};
+
+export const HOTKEYS = [
+ {
+ id: "new-chat",
+ label: "New chat",
+ description: "Start a new chat from anywhere in the app.",
+ combo: { key: "n", shift: true },
+ },
+] as const satisfies readonly Hotkey[];
+
+export type HotkeyId = (typeof HOTKEYS)[number]["id"];
+
+export function getHotkey(id: HotkeyId): Hotkey {
+ const hotkey = HOTKEYS.find((candidate) => candidate.id === id);
+ if (!hotkey) {
+ throw new Error(`Unknown hotkey "${id}".`);
+ }
+ return hotkey;
+}
+
+const isMac =
+ typeof navigator !== "undefined" &&
+ /Mac|iPhone|iPad/.test(navigator.platform);
+
+/**
+ * Whether this keystroke is this combo — exactly, not at-least.
+ *
+ * Every modifier is compared, including the ones the combo does not ask for: Shift+N must not
+ * fire on Cmd+Shift+N, or the combo would shadow whatever that means to the browser. `mod` is
+ * Cmd on a Mac and Ctrl elsewhere, and the one it is not still has to be up.
+ */
+export function matchesHotkey(
+ event: KeyboardEvent,
+ combo: HotkeyCombo,
+): boolean {
+ const mod = isMac ? event.metaKey : event.ctrlKey;
+ const otherMod = isMac ? event.ctrlKey : event.metaKey;
+ return (
+ event.key.toLowerCase() === combo.key &&
+ event.shiftKey === Boolean(combo.shift) &&
+ mod === Boolean(combo.mod) &&
+ !otherMod &&
+ event.altKey === Boolean(combo.alt)
+ );
+}
+
+/**
+ * The combo as a person's keyboard writes it, one part per key: symbols on a Mac (["⇧", "N"]),
+ * names everywhere else (["Shift", "N"]). One entry per physical key so each can be drawn as
+ * its own keycap.
+ */
+export function formatHotkey(combo: HotkeyCombo): string[] {
+ const parts: string[] = [];
+ if (combo.mod) parts.push(isMac ? "⌘" : "Ctrl");
+ if (combo.alt) parts.push(isMac ? "⌥" : "Alt");
+ if (combo.shift) parts.push(isMac ? "⇧" : "Shift");
+ parts.push(combo.key.toUpperCase());
+ return parts;
+}
diff --git a/app/src/lib/hotkeys/use-hotkey.ts b/app/src/lib/hotkeys/use-hotkey.ts
new file mode 100644
index 00000000..6f88ca1a
--- /dev/null
+++ b/app/src/lib/hotkeys/use-hotkey.ts
@@ -0,0 +1,51 @@
+import { useEffect, useRef } from "react";
+import { getHotkey, matchesHotkey } from "./hotkeys";
+import type { HotkeyId } from "./hotkeys";
+
+/**
+ * Whether the keystroke belongs to whatever the person is typing into.
+ *
+ * A combo without a modifier is also just a character: Shift+N is how "New York" starts. A
+ * shortcut that fires mid-word steals the letter and throws away the composer the person was
+ * writing in, so anything editable — inputs, textareas, contenteditable transcripts — swallows
+ * the event as far as un-modified hotkeys are concerned.
+ */
+function isEditable(target: EventTarget | null): boolean {
+ if (!(target instanceof HTMLElement)) {
+ return false;
+ }
+ return (
+ target.isContentEditable ||
+ target.tagName === "INPUT" ||
+ target.tagName === "TEXTAREA" ||
+ target.tagName === "SELECT"
+ );
+}
+
+/**
+ * Runs `handler` when the registered combo for `id` is pressed anywhere on the page.
+ *
+ * The combo itself lives in the registry, not at the call site, so the settings page and the
+ * listener can never disagree about what the key is. The handler rides in a ref: it closes over
+ * render-time state, and re-binding a window listener on every render is churn the ref avoids.
+ */
+export function useHotkey(id: HotkeyId, handler: () => void) {
+ const handlerRef = useRef(handler);
+ handlerRef.current = handler;
+
+ useEffect(() => {
+ const { combo } = getHotkey(id);
+ const onKeyDown = (event: KeyboardEvent) => {
+ if (!matchesHotkey(event, combo)) {
+ return;
+ }
+ if (!combo.mod && !combo.alt && isEditable(event.target)) {
+ return;
+ }
+ event.preventDefault();
+ handlerRef.current();
+ };
+ window.addEventListener("keydown", onKeyDown);
+ return () => window.removeEventListener("keydown", onKeyDown);
+ }, [id]);
+}
diff --git a/app/src/lib/onboarding/mutations.ts b/app/src/lib/onboarding/mutations.ts
new file mode 100644
index 00000000..0bef12a3
--- /dev/null
+++ b/app/src/lib/onboarding/mutations.ts
@@ -0,0 +1,48 @@
+import { mutationOptions, type QueryClient } from "@tanstack/react-query";
+import { authKeys, type OnboardingStatus } from "@/lib/auth/queries";
+import { client } from "@/lib/client";
+
+/** The sentence for every write here, since they all fail the same way to a reader. */
+const FALLBACK = "Onboarding could not be saved";
+
+/**
+ * The status lives on the current user, so that is what a write refreshes.
+ *
+ * No `onboardingKeys` on purpose: a key nothing reads would be a cache entry nothing invalidates.
+ * The `_authed` gate and the wizard both read `currentUserQueryOptions`.
+ *
+ * `refetchType: "all"`, because nothing may be observing that query: the gate reads it through
+ * `ensureQueryData`, which takes the cache as it finds it. The default only refetches active
+ * queries, so completing onboarding left a stale "not onboarded" user behind and the gate bounced
+ * the navigation straight back to the wizard.
+ */
+function invalidateCurrentUser(queryClient: QueryClient) {
+ return queryClient.invalidateQueries({
+ queryKey: authKeys.currentUser(),
+ refetchType: "all",
+ });
+}
+
+export function advanceOnboardingMutationOptions(queryClient: QueryClient) {
+ return mutationOptions({
+ mutationFn: (step: number): Promise =>
+ client("/api/me/onboarding", "onboarding", {
+ method: "POST",
+ body: { step },
+ fallback: FALLBACK,
+ }),
+ onSuccess: () => invalidateCurrentUser(queryClient),
+ });
+}
+
+export function completeOnboardingMutationOptions(queryClient: QueryClient) {
+ return mutationOptions({
+ mutationFn: (): Promise =>
+ client("/api/me/onboarding", "onboarding", {
+ method: "POST",
+ body: { completed: true },
+ fallback: FALLBACK,
+ }),
+ onSuccess: () => invalidateCurrentUser(queryClient),
+ });
+}
diff --git a/app/src/routeTree.gen.ts b/app/src/routeTree.gen.ts
index 2ca64fab..ac241081 100644
--- a/app/src/routeTree.gen.ts
+++ b/app/src/routeTree.gen.ts
@@ -13,6 +13,7 @@ import { Route as AuthedRouteImport } from './routes/_authed'
import { Route as SignRouteImport } from './routes/sign'
import { Route as AuthedAppRouteImport } from './routes/_authed/_app'
import { Route as AuthedAdminRouteRouteImport } from './routes/_authed/admin/route'
+import { Route as AuthedOnboardingRouteImport } from './routes/_authed/onboarding'
import { Route as AuthedSettingsRouteRouteImport } from './routes/_authed/settings/route'
import { Route as AuthedAppIndexRouteImport } from './routes/_authed/_app/index'
import { Route as AuthedAppBotRouteImport } from './routes/_authed/_app/bot'
@@ -59,6 +60,11 @@ const AuthedAdminRouteRoute = AuthedAdminRouteRouteImport.update({
path: '/admin',
getParentRoute: () => AuthedRoute,
} as any)
+const AuthedOnboardingRoute = AuthedOnboardingRouteImport.update({
+ id: '/onboarding',
+ path: '/onboarding',
+ getParentRoute: () => AuthedRoute,
+} as any)
const AuthedSettingsRouteRoute = AuthedSettingsRouteRouteImport.update({
id: '/settings',
path: '/settings',
@@ -209,6 +215,7 @@ export interface FileRoutesByFullPath {
'/sign': typeof SignRoute
'/admin': typeof AuthedAdminRouteRouteWithChildren
'/settings': typeof AuthedSettingsRouteRouteWithChildren
+ '/onboarding': typeof AuthedOnboardingRoute
'/bot': typeof AuthedAppBotRoute
'/routines': typeof AuthedAppRoutinesRoute
'/skills': typeof AuthedAppSkillsRoute
@@ -238,6 +245,7 @@ export interface FileRoutesByFullPath {
export interface FileRoutesByTo {
'/': typeof AuthedAppIndexRoute
'/sign': typeof SignRoute
+ '/onboarding': typeof AuthedOnboardingRoute
'/bot': typeof AuthedAppBotRoute
'/routines': typeof AuthedAppRoutinesRoute
'/skills': typeof AuthedAppSkillsRoute
@@ -271,6 +279,7 @@ export interface FileRoutesById {
'/_authed/admin': typeof AuthedAdminRouteRouteWithChildren
'/_authed/settings': typeof AuthedSettingsRouteRouteWithChildren
'/_authed/_app': typeof AuthedAppRouteWithChildren
+ '/_authed/onboarding': typeof AuthedOnboardingRoute
'/_authed/_app/bot': typeof AuthedAppBotRoute
'/_authed/_app/routines': typeof AuthedAppRoutinesRoute
'/_authed/_app/skills': typeof AuthedAppSkillsRoute
@@ -305,6 +314,7 @@ export interface FileRouteTypes {
| '/sign'
| '/admin'
| '/settings'
+ | '/onboarding'
| '/bot'
| '/routines'
| '/skills'
@@ -334,6 +344,7 @@ export interface FileRouteTypes {
to:
| '/'
| '/sign'
+ | '/onboarding'
| '/bot'
| '/routines'
| '/skills'
@@ -366,6 +377,7 @@ export interface FileRouteTypes {
| '/_authed/admin'
| '/_authed/settings'
| '/_authed/_app'
+ | '/_authed/onboarding'
| '/_authed/_app/bot'
| '/_authed/_app/routines'
| '/_authed/_app/skills'
@@ -429,6 +441,13 @@ declare module '@tanstack/react-router' {
preLoaderRoute: typeof AuthedAdminRouteRouteImport
parentRoute: typeof AuthedRoute
}
+ '/_authed/onboarding': {
+ id: '/_authed/onboarding'
+ path: '/onboarding'
+ fullPath: '/onboarding'
+ preLoaderRoute: typeof AuthedOnboardingRouteImport
+ parentRoute: typeof AuthedRoute
+ }
'/_authed/settings': {
id: '/_authed/settings'
path: '/settings'
@@ -709,12 +728,14 @@ interface AuthedRouteChildren {
AuthedAdminRouteRoute: typeof AuthedAdminRouteRouteWithChildren
AuthedSettingsRouteRoute: typeof AuthedSettingsRouteRouteWithChildren
AuthedAppRoute: typeof AuthedAppRouteWithChildren
+ AuthedOnboardingRoute: typeof AuthedOnboardingRoute
}
const AuthedRouteChildren: AuthedRouteChildren = {
AuthedAdminRouteRoute: AuthedAdminRouteRouteWithChildren,
AuthedSettingsRouteRoute: AuthedSettingsRouteRouteWithChildren,
AuthedAppRoute: AuthedAppRouteWithChildren,
+ AuthedOnboardingRoute: AuthedOnboardingRoute,
}
const AuthedRouteWithChildren =
diff --git a/app/src/routes/__root.tsx b/app/src/routes/__root.tsx
index 645f935d..e8752606 100644
--- a/app/src/routes/__root.tsx
+++ b/app/src/routes/__root.tsx
@@ -1,4 +1,8 @@
-import { createRootRouteWithContext, Outlet } from "@tanstack/react-router";
+import {
+ createRootRouteWithContext,
+ Navigate,
+ Outlet,
+} from "@tanstack/react-router";
import { ThemeProvider } from "@/components/theme-provider";
import { TooltipProvider } from "@/components/ui/tooltip";
import type { RouterContext } from "../router-context";
@@ -6,6 +10,12 @@ import "@fontsource-variable/inter/wght.css";
export const Route = createRootRouteWithContext()({
component: RootComponent,
+ /*
+ * An address this build does not know goes home rather than to a dead end. Home sits behind the
+ * auth gate, so the guard decides what that means: /sign for a visitor, /onboarding for somebody
+ * who has not finished it, the app for everyone else.
+ */
+ notFoundComponent: () => ,
});
function RootComponent() {
diff --git a/app/src/routes/_authed.tsx b/app/src/routes/_authed.tsx
index f3742d54..2e34d837 100644
--- a/app/src/routes/_authed.tsx
+++ b/app/src/routes/_authed.tsx
@@ -1,20 +1,30 @@
import { createFileRoute, Outlet, redirect } from "@tanstack/react-router";
-import { currentUserQueryOptions } from "../lib/auth/queries";
+import { currentUserQueryOptions, needsOnboarding } from "../lib/auth/queries";
import { CopilotProvider } from "../lib/copilot/provider";
+import { AppHotkeys } from "../lib/hotkeys/app-hotkeys";
export const Route = createFileRoute("/_authed")({
- beforeLoad: async ({ context }) => {
+ beforeLoad: async ({ context, location }) => {
const user = await context.queryClient.ensureQueryData(
currentUserQueryOptions(),
);
if (!user) {
throw redirect({ to: "/sign" });
}
+ /*
+ * Somebody who has not finished onboarding goes there and nowhere else. Here rather than in
+ * `_app`, so admin and settings are behind the same gate; checked against the destination so
+ * the onboarding route itself stays reachable.
+ */
+ if (needsOnboarding(user) && location.pathname !== "/onboarding") {
+ throw redirect({ to: "/onboarding" });
+ }
},
// Mounted INSIDE the authed boundary, not at the root: the runtime endpoint requires a session, so
// a provider above the sign-in gate would open a run for a visitor who has not signed in yet.
component: () => (
+
),
diff --git a/app/src/routes/_authed/_app/channel/new.tsx b/app/src/routes/_authed/_app/channel/new.tsx
index 100f7b3e..a969ba3f 100644
--- a/app/src/routes/_authed/_app/channel/new.tsx
+++ b/app/src/routes/_authed/_app/channel/new.tsx
@@ -85,6 +85,10 @@ function RouteComponent() {
value={chosen ?? null}
>
{
+ const user = await context.queryClient.ensureQueryData(
+ currentUserQueryOptions(),
+ );
+ // Somebody who has finished, or whose deployment tracks no onboarding, has no business here.
+ if (!user || !needsOnboarding(user)) {
+ throw redirect({ to: "/" });
+ }
+ },
+ component: RouteComponent,
+});
+
+function WelcomeStep() {
+ return (
+
+
+ Welcome to {appConfig.brand.productName}
+
+
+
+
+
+
+
+ );
+}
+
+function ComputerUseStep() {
+ return (
+
+
+ Each agent has its own computer
+
+
+
+
+
+
+
+ );
+}
+
+/** What a roster card needs — placeholders carry these three fields and nothing more. */
+type RosterCard = Pick;
+
+/**
+ * Stand-ins for a deployment that has fewer than three public agents to show. Invented names on
+ * purpose: they illustrate what a roster looks like without claiming any of these exist here.
+ */
+const AGENTS_PLACEHOLDER: RosterCard[] = [
+ {
+ id: "placeholder-research",
+ name: "Research Analyst",
+ avatarSeed: "research-analyst",
+ },
+ { id: "placeholder-data", name: "Data Analyst", avatarSeed: "data-analyst" },
+ {
+ id: "placeholder-support",
+ name: "Support Agent",
+ avatarSeed: "support-agent",
+ },
+];
+
+function RosterStep() {
+ const { data: agents } = useQuery(agentListQueryOptions());
+ const explore =
+ agents?.filter((a) => !a.mine && a.visibility === "public") ?? [];
+ // Always three cards: real public agents first, placeholders topping up a sparse deployment.
+ // slice past the end is just [], so a roster of three or more takes no placeholders at all.
+ const roster: RosterCard[] = [
+ ...explore.slice(0, 3),
+ ...AGENTS_PLACEHOLDER.slice(explore.length),
+ ];
+
+ return (
+
+
+ Choose from a variety of agents or create your own
+
+
+
+ {roster.map((a) => {
+ return (
+
+ );
+ })}
+
+
+
+ );
+}
+
+const STEPS: Array<() => React.ReactNode> = [
+ () => ,
+ () => ,
+ () => ,
+];
+
+/** A pane arrives from the side the journey is moving toward, and leaves out the other. */
+const variants = {
+ initial: (direction: number) => ({ x: `${110 * direction}%`, opacity: 0 }),
+ active: { x: "0%", opacity: 1 },
+ exit: (direction: number) => ({ x: `${-110 * direction}%`, opacity: 0 }),
+};
+
+function RouteComponent() {
+ const navigate = useNavigate();
+ const complete = useMutation(completeOnboardingMutationOptions(queryClient));
+
+ // Browser state on purpose: the step is not persisted while the wizard is being designed.
+ const [step, setStep] = React.useState(0);
+ const [direction, setDirection] = React.useState(1);
+ // The way out: set once the completion is saved, it fades the whole page and then navigates.
+ const [leaving, setLeaving] = React.useState(false);
+ const [ref, bounds] = useMeasure();
+
+ const last = step === STEPS.length - 1;
+
+ const go = (to: number) => {
+ setDirection(to > step ? 1 : -1);
+ setStep(to);
+ };
+
+ return (
+ // Outside `_app` on purpose: no sidebar and no chrome until onboarding is done.
+ // The fade runs only after the completion is saved, so a failed save never fades a page the
+ // person still needs — and navigation waits for the fade, so the home screen never pops in
+ // over a half-faded wizard.
+ {
+ if (leaving) {
+ navigate({ to: "/" });
+ }
+ }}
+ transition={{ duration: 0.5, ease: "easeInOut" }}
+ >
+
+
+ {/* The frame follows each pane's height, so the buttons glide instead of jumping. */}
+ 0 ? bounds.height : "auto" }}
+ className="overflow-hidden"
+ >
+
+
+
+ {STEPS[step]()}
+
+
+
+ {complete.error ? (
+
+ {complete.error.message}
+
+ ) : null}
+
+
+ {
+ if (last) {
+ complete.mutate(undefined, {
+ onSuccess: () => setLeaving(true),
+ });
+ } else {
+ go(step + 1);
+ }
+ }}
+ size="lg"
+ >
+ {complete.isPending
+ ? "Saving…"
+ : last
+ ? "Get started"
+ : "Continue"}
+
+ {step !== 0 && (
+ go(step - 1)}
+ variant="secondary"
+ size="lg"
+ >
+ Back
+
+ )}
+
+
+
+
+
+
+ );
+}
diff --git a/app/src/routes/_authed/settings/index.tsx b/app/src/routes/_authed/settings/index.tsx
index 9b4991da..2fba05d2 100644
--- a/app/src/routes/_authed/settings/index.tsx
+++ b/app/src/routes/_authed/settings/index.tsx
@@ -1,4 +1,5 @@
import { createFileRoute } from "@tanstack/react-router";
+import React from "react";
import {
PageRows,
PageSection,
@@ -12,7 +13,9 @@ import {
ItemDescription,
ItemTitle,
} from "@/components/ui/item";
+import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
+import { formatHotkey, HOTKEYS } from "@/lib/hotkeys/hotkeys";
export const Route = createFileRoute("/_authed/settings/")({
component: RouteComponent,
@@ -53,6 +56,38 @@ function RouteComponent() {
+ {/*
+ * Drawn from the same registry the listeners match against, so this list is what the keys
+ * actually do rather than what somebody remembered they did. Read-only on purpose: these
+ * are not rebindable, and a row with nothing to click says so by having nothing to click.
+ */}
+
+
+ {HOTKEYS.map((hotkey, index) => (
+
+ -
+
+ {hotkey.label}
+ {hotkey.description}
+
+
+
+ {formatHotkey(hotkey.combo).map((part) => (
+
+ {part}
+
+ ))}
+
+
+
+ {index !== HOTKEYS.length - 1 && }
+
+ ))}
+
+
);
}
diff --git a/app/tests/channel-event-patch.test.ts b/app/tests/channel-event-patch.test.ts
index 62efb793..6f073ea6 100644
--- a/app/tests/channel-event-patch.test.ts
+++ b/app/tests/channel-event-patch.test.ts
@@ -169,3 +169,56 @@ describe("a pin", () => {
).toBe(data);
});
});
+
+/**
+ * A busy signal: a turn started or ended in the channel.
+ *
+ * Server-side headless work — a handoff hop, a relay — that no browser streamed, surfaced on the
+ * roster as a working indicator. Message-less on purpose: it must not disturb the preview or the
+ * order the way an ordinary activity event does.
+ */
+describe("a busy signal", () => {
+ test("patches only the busy flag, leaving the last message and order alone", () => {
+ const data = cache([
+ channel("a", {
+ lastMessage: "Said something.",
+ lastMessageAt: "2024-04-01T00:00:00.000Z",
+ lastMessageAgentId: "agent-1",
+ }),
+ channel("b", { lastMessageAt: "2024-05-01T00:00:00.000Z" }),
+ ]);
+
+ const patched = applyChannelEvent(
+ data,
+ event({ channelId: "a", busy: true }),
+ );
+
+ expect(patched).not.toBe("unknown");
+ if (patched === "unknown") return;
+ // Only `busy` changed on row a; its message survives, and b did not jump ahead of it.
+ expect(patched.pages[0]?.channels.map((row) => row.id)).toEqual(["a", "b"]);
+ expect(patched.pages[0]?.channels[0]).toEqual({
+ ...(data.pages[0]?.channels[0] as ChannelSummary),
+ busy: true,
+ });
+ });
+
+ test("clears the same way", () => {
+ const patched = applyChannelEvent(
+ cache([channel("a", { busy: true })]),
+ event({ channelId: "a", busy: false }),
+ );
+
+ expect(patched).not.toBe("unknown");
+ if (patched === "unknown") return;
+ expect(patched.pages[0]?.channels[0]?.busy).toBe(false);
+ });
+
+ test("returns the same cache when the row already says so", () => {
+ const data = cache([channel("a", { busy: true })]);
+
+ expect(applyChannelEvent(data, event({ channelId: "a", busy: true }))).toBe(
+ data,
+ );
+ });
+});
diff --git a/bun.lock b/bun.lock
index 1ac087cf..415b9ce6 100644
--- a/bun.lock
+++ b/bun.lock
@@ -25,6 +25,7 @@
"@shadcn/react": "^0.3.0",
"@tabler/icons-react": "^3.36.1",
"@tanstack/react-form": "^1.33.5",
+ "@tanstack/react-hotkeys": "^0.10.0",
"@tanstack/react-query": "^5.101.4",
"@tanstack/react-router": "^1.170.27",
"better-auth": "^1.6.27",
@@ -35,6 +36,7 @@
"prompt-area": "^0.6.3",
"react": "^19.2.0",
"react-dom": "^19.2.0",
+ "react-use-measure": "^2.1.7",
"shadcn": "^4.17.0",
"streamdown": "^2.5.0",
"tailwind-merge": "^3.6.0",
@@ -659,6 +661,8 @@
"@tanstack/history": ["@tanstack/history@1.162.1", "", {}, "sha512-DR9t6lfLVdrjgCwpglrR9DR7Ok8/HlXjcOE+goWXF3zyuLUO/ug7vMbSFxTqrQTtbRghJfyhmIZ0S6LhPIy44w=="],
+ "@tanstack/hotkeys": ["@tanstack/hotkeys@0.8.0", "", { "dependencies": { "@tanstack/store": "^0.11.0" } }, "sha512-vqH7X9nb0MTJ/O08++dB5bP9jgj4+BIPOUu/U+6myG86lDsirZSVSobpq5UQpE7nBuk62i8eIYeOhd+OMl/UrA=="],
+
"@tanstack/pacer": ["@tanstack/pacer@0.20.1", "", { "dependencies": { "@tanstack/devtools-event-client": "^0.4.3", "@tanstack/store": "^0.9.3" } }, "sha512-ZNQ1bIL6eUXVKdic0tiImvBVkWrg/IoSK6VIacTrO3d3HAGnd70qFJNJagR/YOJIOw4EKGWnodwpYZkN1pWuVQ=="],
"@tanstack/pacer-lite": ["@tanstack/pacer-lite@0.1.1", "", {}, "sha512-y/xtNPNt/YeyoVxE/JCx+T7yjEzpezmbb+toK8DDD1P4m7Kzs5YR956+7OKexG3f8aXgC3rLZl7b1V+yNUSy5w=="],
@@ -667,6 +671,8 @@
"@tanstack/react-form": ["@tanstack/react-form@1.33.5", "", { "dependencies": { "@tanstack/form-core": "1.33.5", "@tanstack/react-store": "^0.11.0" }, "peerDependencies": { "@tanstack/react-start": "*", "react": "^17.0.0 || ^18.0.0 || ^19.0.0" }, "optionalPeers": ["@tanstack/react-start"] }, "sha512-LlRB28qJwO/QCGaHvWnbdh4haBgTFiZVmzA2uzxSBS3YA7/IqrQ6HOBK70CkFQ+DbflZ7NawsmSln13h5iIdTA=="],
+ "@tanstack/react-hotkeys": ["@tanstack/react-hotkeys@0.10.0", "", { "dependencies": { "@tanstack/hotkeys": "0.8.0", "@tanstack/react-store": "^0.11.0" }, "peerDependencies": { "react": ">=16.8", "react-dom": ">=16.8" } }, "sha512-GwOSndI5j3qBVYTmgP1mYyRTnlxb2MS17cwGlsavSxMQPSnmDf+m3LzMIpRMs+3zzQMjg3cYhHsFYizYlFI2tw=="],
+
"@tanstack/react-query": ["@tanstack/react-query@5.102.2", "", { "dependencies": { "@tanstack/query-core": "5.102.2" }, "peerDependencies": { "react": "^18 || ^19" } }, "sha512-KxU8ZyOEuJ81eTSgXa8GQbk/jO/rz0elYtNKt3VMtM2pRjeO8ADIu7sqqmEjFKJKqco9h2N1ojIDuHs6VfDItQ=="],
"@tanstack/react-router": ["@tanstack/react-router@1.170.32", "", { "dependencies": { "@tanstack/history": "1.162.1", "@tanstack/react-store": "^0.9.3", "@tanstack/router-core": "1.171.27", "isbot": "^5.1.22" }, "peerDependencies": { "react": ">=18.0.0 || >=19.0.0", "react-dom": ">=18.0.0 || >=19.0.0" } }, "sha512-SIpxvaTKco100a5ZR3ePmArbhtm3XOx+w1dpGYY9gxHDta4iXSKDdQuhLonwJbIMkVJsU1rwXf0UDHMrF/1snw=="],
@@ -1885,6 +1891,8 @@
"react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="],
+ "react-use-measure": ["react-use-measure@2.1.7", "", { "peerDependencies": { "react": ">=16.13", "react-dom": ">=16.13" }, "optionalPeers": ["react-dom"] }, "sha512-KrvcAo13I/60HpwGO5jpW7E9DfusKyLPLvuHlUyP5zqnmAPhNc6qTRjUQrdTADl0lpPpDVU2/Gg51UlOGHXbdg=="],
+
"readable-stream": ["readable-stream@4.7.0", "", { "dependencies": { "abort-controller": "^3.0.0", "buffer": "^6.0.3", "events": "^3.3.0", "process": "^0.11.10", "string_decoder": "^1.3.0" } }, "sha512-oIGGmcpTLwPga8Bn6/Z75SVaH1z5dUut2ibSyAMVhmUggWpmDn2dapB0n7f8nwaSiRtepAsfJyfXIO5DCVAODg=="],
"readdirp": ["readdirp@5.1.1", "", {}, "sha512-Kko+Y5XQ6fM+Ce3dq3m9YGxnacYZYl9cA1wZjaF3Vbry2L3i1qVg8+CAgNPsXRArPMUMCaOR7oa9Nqntc43JKA=="],
diff --git a/server/drizzle/0024_onboarding.sql b/server/drizzle/0024_onboarding.sql
new file mode 100644
index 00000000..6bf277d3
--- /dev/null
+++ b/server/drizzle/0024_onboarding.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "users" ADD COLUMN "onboarding_step" integer DEFAULT 0 NOT NULL;--> statement-breakpoint
+ALTER TABLE "users" ADD COLUMN "onboarding_completed_at" timestamp with time zone;
\ No newline at end of file
diff --git a/server/drizzle/0025_backfill_existing_users_have_onboarded.sql b/server/drizzle/0025_backfill_existing_users_have_onboarded.sql
new file mode 100644
index 00000000..3acad853
--- /dev/null
+++ b/server/drizzle/0025_backfill_existing_users_have_onboarded.sql
@@ -0,0 +1,10 @@
+-- Everybody who exists before this migration has already been using the product, and the wizard
+-- behind the new gate teaches nothing they need. A null completion is what sends a person to
+-- /onboarding, so anyone here when the column arrives is stamped as done and only people who sign
+-- in for the first time from now on see it.
+--
+-- Written through `drizzle-kit generate --custom`, like 0003: "the people who already exist have
+-- onboarded" is a fact about rows, not about the schema, so no diff can produce it.
+UPDATE "users"
+SET "onboarding_completed_at" = now()
+WHERE "onboarding_completed_at" IS NULL;
diff --git a/server/drizzle/meta/0024_snapshot.json b/server/drizzle/meta/0024_snapshot.json
new file mode 100644
index 00000000..742e2190
--- /dev/null
+++ b/server/drizzle/meta/0024_snapshot.json
@@ -0,0 +1,3020 @@
+{
+ "id": "bcc734d4-d33c-4b1c-a315-06dde9e016bf",
+ "prevId": "a1053424-96eb-4ace-98e4-63ac0ea99060",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.accounts": {
+ "name": "accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "accounts_provider_account_idx": {
+ "name": "accounts_provider_account_idx",
+ "columns": [
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "accounts_user_id_users_id_fk": {
+ "name": "accounts_user_id_users_id_fk",
+ "tableFrom": "accounts",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agents": {
+ "name": "agents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "agent_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "configuration": {
+ "name": "configuration",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "package_id": {
+ "name": "package_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override": {
+ "name": "override",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "agents_package_id_deployment_packages_id_fk": {
+ "name": "agents_package_id_deployment_packages_id_fk",
+ "tableFrom": "agents",
+ "tableTo": "deployment_packages",
+ "columnsFrom": [
+ "package_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_events": {
+ "name": "audit_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "audit_events_created_at_idx": {
+ "name": "audit_events_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_events_type_time_idx": {
+ "name": "audit_events_type_time_idx",
+ "columns": [
+ {
+ "expression": "event_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_events_actor_time_idx": {
+ "name": "audit_events_actor_time_idx",
+ "columns": [
+ {
+ "expression": "actor_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "audit_events_target_time_idx": {
+ "name": "audit_events_target_time_idx",
+ "columns": [
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channel_agents": {
+ "name": "channel_agents",
+ "schema": "",
+ "columns": {
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "channel_agents_channel_id_channels_id_fk": {
+ "name": "channel_agents_channel_id_channels_id_fk",
+ "tableFrom": "channel_agents",
+ "tableTo": "channels",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "channel_agents_agent_id_agents_id_fk": {
+ "name": "channel_agents_agent_id_agents_id_fk",
+ "tableFrom": "channel_agents",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "channel_agents_channel_id_agent_id_pk": {
+ "name": "channel_agents_channel_id_agent_id_pk",
+ "columns": [
+ "channel_id",
+ "agent_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channel_memberships": {
+ "name": "channel_memberships",
+ "schema": "",
+ "columns": {
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pinned_at": {
+ "name": "pinned_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_read_at": {
+ "name": "last_read_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "channel_memberships_channel_id_channels_id_fk": {
+ "name": "channel_memberships_channel_id_channels_id_fk",
+ "tableFrom": "channel_memberships",
+ "tableTo": "channels",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "channel_memberships_user_id_users_id_fk": {
+ "name": "channel_memberships_user_id_users_id_fk",
+ "tableFrom": "channel_memberships",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "channel_memberships_channel_id_user_id_pk": {
+ "name": "channel_memberships_channel_id_user_id_pk",
+ "columns": [
+ "channel_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channels": {
+ "name": "channels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "suggested_prompts": {
+ "name": "suggested_prompts",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "allowed_groups": {
+ "name": "allowed_groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "package_id": {
+ "name": "package_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override": {
+ "name": "override",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message": {
+ "name": "last_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message_at": {
+ "name": "last_message_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message_agent_id": {
+ "name": "last_message_agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "channels_recent_activity_idx": {
+ "name": "channels_recent_activity_idx",
+ "columns": [
+ {
+ "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC",
+ "asc": true,
+ "isExpression": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "channels_package_id_deployment_packages_id_fk": {
+ "name": "channels_package_id_deployment_packages_id_fk",
+ "tableFrom": "channels",
+ "tableTo": "deployment_packages",
+ "columnsFrom": [
+ "package_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ },
+ "channels_last_message_agent_id_agents_id_fk": {
+ "name": "channels_last_message_agent_id_agents_id_fk",
+ "tableFrom": "channels",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "last_message_agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.credentials": {
+ "name": "credentials",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "kind": {
+ "name": "kind",
+ "type": "credential_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "encrypted_value": {
+ "name": "encrypted_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_id": {
+ "name": "key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "credentials_active_key_idx": {
+ "name": "credentials_active_key_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "where": "\"credentials\".\"revoked_at\" IS NULL",
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.deployment_packages": {
+ "name": "deployment_packages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_path": {
+ "name": "source_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "loaded_at": {
+ "name": "loaded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "deployment_packages_tenant_id_unique": {
+ "name": "deployment_packages_tenant_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "tenant_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.intelligence_channel_mappings": {
+ "name": "intelligence_channel_mappings",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "intelligence_channel_mappings_thread_idx": {
+ "name": "intelligence_channel_mappings_thread_idx",
+ "columns": [
+ {
+ "expression": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "intelligence_channel_mappings_user_id_users_id_fk": {
+ "name": "intelligence_channel_mappings_user_id_users_id_fk",
+ "tableFrom": "intelligence_channel_mappings",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "intelligence_channel_mappings_channel_id_channels_id_fk": {
+ "name": "intelligence_channel_mappings_channel_id_channels_id_fk",
+ "tableFrom": "intelligence_channel_mappings",
+ "tableTo": "channels",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "intelligence_channel_mappings_user_id_channel_id_pk": {
+ "name": "intelligence_channel_mappings_user_id_channel_id_pk",
+ "columns": [
+ "user_id",
+ "channel_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.revoked_access": {
+ "name": "revoked_access",
+ "schema": "",
+ "columns": {
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "revoked_by": {
+ "name": "revoked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "token"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sso_providers": {
+ "name": "sso_providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_config": {
+ "name": "oidc_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "saml_config": {
+ "name": "saml_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sso_providers_user_id_users_id_fk": {
+ "name": "sso_providers_user_id_users_id_fk",
+ "tableFrom": "sso_providers",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sso_providers_provider_id_unique": {
+ "name": "sso_providers_provider_id_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "provider_id"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_roles": {
+ "name": "user_roles",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_roles_user_id_users_id_fk": {
+ "name": "user_roles_user_id_users_id_fk",
+ "tableFrom": "user_roles",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_roles_user_id_role_pk": {
+ "name": "user_roles_user_id_role_pk",
+ "columns": [
+ "user_id",
+ "role"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "groups": {
+ "name": "groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "onboarding_step": {
+ "name": "onboarding_step",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "onboarding_completed_at": {
+ "name": "onboarding_completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "nullsNotDistinct": false,
+ "columns": [
+ "email"
+ ]
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verifications": {
+ "name": "verifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.action_policy": {
+ "name": "action_policy",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deny": {
+ "name": "deny",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "allow": {
+ "name": "allow",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.computer_page_frame": {
+ "name": "computer_page_frame",
+ "schema": "",
+ "columns": {
+ "computer_id": {
+ "name": "computer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tool_call_id": {
+ "name": "tool_call_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "frame": {
+ "name": "frame",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "captured_at": {
+ "name": "captured_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "computer_page_frame_captured_idx": {
+ "name": "computer_page_frame_captured_idx",
+ "columns": [
+ {
+ "expression": "captured_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "computer_page_frame_computer_id_tool_call_id_pk": {
+ "name": "computer_page_frame_computer_id_tool_call_id_pk",
+ "columns": [
+ "computer_id",
+ "tool_call_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.computer_snapshot": {
+ "name": "computer_snapshot",
+ "schema": "",
+ "columns": {
+ "computer_id": {
+ "name": "computer_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "snapshot_id": {
+ "name": "snapshot_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "elements": {
+ "name": "elements",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "taken_at": {
+ "name": "taken_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "session": {
+ "name": "session",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_preferences": {
+ "name": "agent_preferences",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hidden_at": {
+ "name": "hidden_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "agent_preferences_user_id_users_id_fk": {
+ "name": "agent_preferences_user_id_users_id_fk",
+ "tableFrom": "agent_preferences",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_preferences_agent_id_agents_id_fk": {
+ "name": "agent_preferences_agent_id_agents_id_fk",
+ "tableFrom": "agent_preferences",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "agent_preferences_user_id_agent_id_pk": {
+ "name": "agent_preferences_user_id_agent_id_pk",
+ "columns": [
+ "user_id",
+ "agent_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_profiles": {
+ "name": "agent_profiles",
+ "schema": "",
+ "columns": {
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role_description": {
+ "name": "role_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "avatar_seed": {
+ "name": "avatar_seed",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "agent_visibility",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "callback_token_hash": {
+ "name": "callback_token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "callback_token_issued_at": {
+ "name": "callback_token_issued_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "agent_profiles_visibility_deleted_idx": {
+ "name": "agent_profiles_visibility_deleted_idx",
+ "columns": [
+ {
+ "expression": "visibility",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "agent_profiles_agent_id_agents_id_fk": {
+ "name": "agent_profiles_agent_id_agents_id_fk",
+ "tableFrom": "agent_profiles",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "agent_profiles_owner_user_id_users_id_fk": {
+ "name": "agent_profiles_owner_user_id_users_id_fk",
+ "tableFrom": "agent_profiles",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "set null",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.routine_runs": {
+ "name": "routine_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "routine_id": {
+ "name": "routine_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "routine_run_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "routine_runs_by_routine_idx": {
+ "name": "routine_runs_by_routine_idx",
+ "columns": [
+ {
+ "expression": "routine_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "routine_runs_routine_id_routines_id_fk": {
+ "name": "routine_runs_routine_id_routines_id_fk",
+ "tableFrom": "routine_runs",
+ "tableTo": "routines",
+ "columnsFrom": [
+ "routine_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.routines": {
+ "name": "routines",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "instruction": {
+ "name": "instruction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cron": {
+ "name": "cron",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'UTC'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "next_run_at": {
+ "name": "next_run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "routines_due_idx": {
+ "name": "routines_due_idx",
+ "columns": [
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "next_run_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "routines_by_owner_idx": {
+ "name": "routines_by_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "routines_owner_user_id_users_id_fk": {
+ "name": "routines_owner_user_id_users_id_fk",
+ "tableFrom": "routines",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "routines_agent_id_agents_id_fk": {
+ "name": "routines_agent_id_agents_id_fk",
+ "tableFrom": "routines",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.component_exclusions": {
+ "name": "component_exclusions",
+ "schema": "",
+ "columns": {
+ "component_name": {
+ "name": "component_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "withheld_by": {
+ "name": "withheld_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "component_exclusions_component_name_components_name_fk": {
+ "name": "component_exclusions_component_name_components_name_fk",
+ "tableFrom": "component_exclusions",
+ "tableTo": "components",
+ "columnsFrom": [
+ "component_name"
+ ],
+ "columnsTo": [
+ "name"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "component_exclusions_agent_id_agents_id_fk": {
+ "name": "component_exclusions_agent_id_agents_id_fk",
+ "tableFrom": "component_exclusions",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "component_exclusions_component_name_agent_id_pk": {
+ "name": "component_exclusions_component_name_agent_id_pk",
+ "columns": [
+ "component_name",
+ "agent_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.component_functions": {
+ "name": "component_functions",
+ "schema": "",
+ "columns": {
+ "component_name": {
+ "name": "component_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "function_name": {
+ "name": "function_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "granted_by": {
+ "name": "granted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "component_functions_component_name_components_name_fk": {
+ "name": "component_functions_component_name_components_name_fk",
+ "tableFrom": "component_functions",
+ "tableTo": "components",
+ "columnsFrom": [
+ "component_name"
+ ],
+ "columnsTo": [
+ "name"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "component_functions_component_name_function_name_pk": {
+ "name": "component_functions_component_name_function_name_pk",
+ "columns": [
+ "component_name",
+ "function_name"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.components": {
+ "name": "components",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "draft_description": {
+ "name": "draft_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "published_description": {
+ "name": "published_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published": {
+ "name": "published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_servers": {
+ "name": "mcp_servers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "vendor": {
+ "name": "vendor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provenance": {
+ "name": "provenance",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'first-party'"
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tools_refreshed_at": {
+ "name": "tools_refreshed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "mcp_servers_credential_id_credentials_id_fk": {
+ "name": "mcp_servers_credential_id_credentials_id_fk",
+ "tableFrom": "mcp_servers",
+ "tableTo": "credentials",
+ "columnsFrom": [
+ "credential_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "restrict",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_tools": {
+ "name": "mcp_tools",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "input_schema": {
+ "name": "input_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "mcp_tools_server_id_mcp_servers_id_fk": {
+ "name": "mcp_tools_server_id_mcp_servers_id_fk",
+ "tableFrom": "mcp_tools",
+ "tableTo": "mcp_servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "mcp_tools_server_id_name_pk": {
+ "name": "mcp_tools_server_id_name_pk",
+ "columns": [
+ "server_id",
+ "name"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_user_credentials": {
+ "name": "mcp_user_credentials",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "mcp_user_credentials_user_idx": {
+ "name": "mcp_user_credentials_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "mcp_user_credentials_server_id_mcp_servers_id_fk": {
+ "name": "mcp_user_credentials_server_id_mcp_servers_id_fk",
+ "tableFrom": "mcp_user_credentials",
+ "tableTo": "mcp_servers",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mcp_user_credentials_user_id_users_id_fk": {
+ "name": "mcp_user_credentials_user_id_users_id_fk",
+ "tableFrom": "mcp_user_credentials",
+ "tableTo": "users",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ },
+ "mcp_user_credentials_credential_id_credentials_id_fk": {
+ "name": "mcp_user_credentials_credential_id_credentials_id_fk",
+ "tableFrom": "mcp_user_credentials",
+ "tableTo": "credentials",
+ "columnsFrom": [
+ "credential_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "no action",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "mcp_user_credentials_server_id_user_id_pk": {
+ "name": "mcp_user_credentials_server_id_user_id_pk",
+ "columns": [
+ "server_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_grants": {
+ "name": "plugin_grants",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ref": {
+ "name": "ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "granted_by": {
+ "name": "granted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "plugin_grants_agent_idx": {
+ "name": "plugin_grants_agent_idx",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "plugin_grants_agent_id_agents_id_fk": {
+ "name": "plugin_grants_agent_id_agents_id_fk",
+ "tableFrom": "plugin_grants",
+ "tableTo": "agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "plugin_grants_kind_ref_agent_id_pk": {
+ "name": "plugin_grants_kind_ref_agent_id_pk",
+ "columns": [
+ "kind",
+ "ref",
+ "agent_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sandboxed_components": {
+ "name": "sandboxed_components",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "draft_description": {
+ "name": "draft_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_html": {
+ "name": "draft_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_css": {
+ "name": "draft_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_js_functions": {
+ "name": "draft_js_functions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_argument_schema": {
+ "name": "draft_argument_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "published_description": {
+ "name": "published_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_html": {
+ "name": "published_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_css": {
+ "name": "published_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_js_functions": {
+ "name": "published_js_functions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_argument_schema": {
+ "name": "published_argument_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sample_arguments": {
+ "name": "sample_arguments",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "published": {
+ "name": "published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "authored_by": {
+ "name": "authored_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.skill_tools": {
+ "name": "skill_tools",
+ "schema": "",
+ "columns": {
+ "skill_id": {
+ "name": "skill_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ref": {
+ "name": "ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "declared_by": {
+ "name": "declared_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "skill_tools_ref_idx": {
+ "name": "skill_tools_ref_idx",
+ "columns": [
+ {
+ "expression": "ref",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "skill_tools_skill_id_skills_id_fk": {
+ "name": "skill_tools_skill_id_skills_id_fk",
+ "tableFrom": "skill_tools",
+ "tableTo": "skills",
+ "columnsFrom": [
+ "skill_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "skill_tools_skill_id_ref_pk": {
+ "name": "skill_tools_skill_id_ref_pk",
+ "columns": [
+ "skill_id",
+ "ref"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.skills": {
+ "name": "skills",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "instructions": {
+ "name": "instructions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin": {
+ "name": "origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'yours'"
+ },
+ "installed_by": {
+ "name": "installed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "skills_slug_key": {
+ "name": "skills_slug_key",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ },
+ "skills_owner_idx": {
+ "name": "skills_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {
+ "skills_owner_user_id_users_id_fk": {
+ "name": "skills_owner_user_id_users_id_fk",
+ "tableFrom": "skills",
+ "tableTo": "users",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "columnsTo": [
+ "id"
+ ],
+ "onDelete": "cascade",
+ "onUpdate": "no action"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.work_items": {
+ "name": "work_items",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_at": {
+ "name": "run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "claimed_by": {
+ "name": "claimed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_until": {
+ "name": "lease_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "work_items_claimable_idx": {
+ "name": "work_items_claimable_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "run_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "concurrently": false,
+ "method": "btree",
+ "with": {}
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "work_items_kind_key_pk": {
+ "name": "work_items_kind_key_pk",
+ "columns": [
+ "kind",
+ "key"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.agent_type": {
+ "name": "agent_type",
+ "schema": "public",
+ "values": [
+ "built_in",
+ "remote_ag_ui"
+ ]
+ },
+ "public.credential_kind": {
+ "name": "credential_kind",
+ "schema": "public",
+ "values": [
+ "model",
+ "connector",
+ "agent",
+ "mcp",
+ "mcp_oauth_client",
+ "mcp_user_token"
+ ]
+ },
+ "public.role": {
+ "name": "role",
+ "schema": "public",
+ "values": [
+ "admin",
+ "user"
+ ]
+ },
+ "public.agent_visibility": {
+ "name": "agent_visibility",
+ "schema": "public",
+ "values": [
+ "public",
+ "private"
+ ]
+ },
+ "public.routine_run_status": {
+ "name": "routine_run_status",
+ "schema": "public",
+ "values": [
+ "succeeded",
+ "failed",
+ "skipped"
+ ]
+ }
+ },
+ "schemas": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "views": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/server/drizzle/meta/0025_snapshot.json b/server/drizzle/meta/0025_snapshot.json
new file mode 100644
index 00000000..c11e750c
--- /dev/null
+++ b/server/drizzle/meta/0025_snapshot.json
@@ -0,0 +1,3020 @@
+{
+ "id": "d96da430-35aa-475c-a060-f55151269d6d",
+ "prevId": "bcc734d4-d33c-4b1c-a315-06dde9e016bf",
+ "version": "7",
+ "dialect": "postgresql",
+ "tables": {
+ "public.accounts": {
+ "name": "accounts",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "account_id": {
+ "name": "account_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "access_token": {
+ "name": "access_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token": {
+ "name": "refresh_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "id_token": {
+ "name": "id_token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "access_token_expires_at": {
+ "name": "access_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "refresh_token_expires_at": {
+ "name": "refresh_token_expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "password": {
+ "name": "password",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "accounts_provider_account_idx": {
+ "name": "accounts_provider_account_idx",
+ "columns": [
+ {
+ "expression": "provider_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "account_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "accounts_user_id_users_id_fk": {
+ "name": "accounts_user_id_users_id_fk",
+ "tableFrom": "accounts",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agents": {
+ "name": "agents",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "type": {
+ "name": "type",
+ "type": "agent_type",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "configuration": {
+ "name": "configuration",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "package_id": {
+ "name": "package_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override": {
+ "name": "override",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "agents_package_id_deployment_packages_id_fk": {
+ "name": "agents_package_id_deployment_packages_id_fk",
+ "tableFrom": "agents",
+ "columnsFrom": [
+ "package_id"
+ ],
+ "tableTo": "deployment_packages",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "set null"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.audit_events": {
+ "name": "audit_events",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "actor_user_id": {
+ "name": "actor_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "event_type": {
+ "name": "event_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_type": {
+ "name": "target_type",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "target_id": {
+ "name": "target_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "audit_events_created_at_idx": {
+ "name": "audit_events_created_at_idx",
+ "columns": [
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "audit_events_type_time_idx": {
+ "name": "audit_events_type_time_idx",
+ "columns": [
+ {
+ "expression": "event_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "audit_events_actor_time_idx": {
+ "name": "audit_events_actor_time_idx",
+ "columns": [
+ {
+ "expression": "actor_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "audit_events_target_time_idx": {
+ "name": "audit_events_target_time_idx",
+ "columns": [
+ {
+ "expression": "target_type",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "target_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "created_at",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ },
+ {
+ "expression": "id",
+ "isExpression": false,
+ "asc": false,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channel_agents": {
+ "name": "channel_agents",
+ "schema": "",
+ "columns": {
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "channel_agents_channel_id_channels_id_fk": {
+ "name": "channel_agents_channel_id_channels_id_fk",
+ "tableFrom": "channel_agents",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "tableTo": "channels",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "channel_agents_agent_id_agents_id_fk": {
+ "name": "channel_agents_agent_id_agents_id_fk",
+ "tableFrom": "channel_agents",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "tableTo": "agents",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {
+ "channel_agents_channel_id_agent_id_pk": {
+ "name": "channel_agents_channel_id_agent_id_pk",
+ "columns": [
+ "channel_id",
+ "agent_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channel_memberships": {
+ "name": "channel_memberships",
+ "schema": "",
+ "columns": {
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "pinned_at": {
+ "name": "pinned_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_read_at": {
+ "name": "last_read_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "channel_memberships_channel_id_channels_id_fk": {
+ "name": "channel_memberships_channel_id_channels_id_fk",
+ "tableFrom": "channel_memberships",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "tableTo": "channels",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "channel_memberships_user_id_users_id_fk": {
+ "name": "channel_memberships_user_id_users_id_fk",
+ "tableFrom": "channel_memberships",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {
+ "channel_memberships_channel_id_user_id_pk": {
+ "name": "channel_memberships_channel_id_user_id_pk",
+ "columns": [
+ "channel_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.channels": {
+ "name": "channels",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "suggested_prompts": {
+ "name": "suggested_prompts",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "allowed_groups": {
+ "name": "allowed_groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "package_id": {
+ "name": "package_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "override": {
+ "name": "override",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message": {
+ "name": "last_message",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message_at": {
+ "name": "last_message_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_message_agent_id": {
+ "name": "last_message_agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "channels_recent_activity_idx": {
+ "name": "channels_recent_activity_idx",
+ "columns": [
+ {
+ "expression": "COALESCE(\"last_message_at\", \"created_at\") DESC",
+ "isExpression": true,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "channels_package_id_deployment_packages_id_fk": {
+ "name": "channels_package_id_deployment_packages_id_fk",
+ "tableFrom": "channels",
+ "columnsFrom": [
+ "package_id"
+ ],
+ "tableTo": "deployment_packages",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "set null"
+ },
+ "channels_last_message_agent_id_agents_id_fk": {
+ "name": "channels_last_message_agent_id_agents_id_fk",
+ "tableFrom": "channels",
+ "columnsFrom": [
+ "last_message_agent_id"
+ ],
+ "tableTo": "agents",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "set null"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.credentials": {
+ "name": "credentials",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "kind": {
+ "name": "kind",
+ "type": "credential_kind",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provider": {
+ "name": "provider",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "encrypted_value": {
+ "name": "encrypted_value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key_id": {
+ "name": "key_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "metadata": {
+ "name": "metadata",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "credentials_active_key_idx": {
+ "name": "credentials_active_key_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "provider",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "key_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "with": {},
+ "method": "btree",
+ "where": "\"credentials\".\"revoked_at\" IS NULL",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.deployment_packages": {
+ "name": "deployment_packages",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "uuid",
+ "primaryKey": true,
+ "notNull": true,
+ "default": "gen_random_uuid()"
+ },
+ "tenant_id": {
+ "name": "tenant_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "source_path": {
+ "name": "source_path",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "checksum": {
+ "name": "checksum",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "loaded_at": {
+ "name": "loaded_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "deployment_packages_tenant_id_unique": {
+ "name": "deployment_packages_tenant_id_unique",
+ "columns": [
+ "tenant_id"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.intelligence_channel_mappings": {
+ "name": "intelligence_channel_mappings",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "thread_id": {
+ "name": "thread_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "intelligence_channel_mappings_thread_idx": {
+ "name": "intelligence_channel_mappings_thread_idx",
+ "columns": [
+ {
+ "expression": "thread_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "intelligence_channel_mappings_user_id_users_id_fk": {
+ "name": "intelligence_channel_mappings_user_id_users_id_fk",
+ "tableFrom": "intelligence_channel_mappings",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "intelligence_channel_mappings_channel_id_channels_id_fk": {
+ "name": "intelligence_channel_mappings_channel_id_channels_id_fk",
+ "tableFrom": "intelligence_channel_mappings",
+ "columnsFrom": [
+ "channel_id"
+ ],
+ "tableTo": "channels",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {
+ "intelligence_channel_mappings_user_id_channel_id_pk": {
+ "name": "intelligence_channel_mappings_user_id_channel_id_pk",
+ "columns": [
+ "user_id",
+ "channel_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.revoked_access": {
+ "name": "revoked_access",
+ "schema": "",
+ "columns": {
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "revoked_at": {
+ "name": "revoked_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "revoked_by": {
+ "name": "revoked_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sessions": {
+ "name": "sessions",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "token": {
+ "name": "token",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ip_address": {
+ "name": "ip_address",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_agent": {
+ "name": "user_agent",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sessions_user_id_users_id_fk": {
+ "name": "sessions_user_id_users_id_fk",
+ "tableFrom": "sessions",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sessions_token_unique": {
+ "name": "sessions_token_unique",
+ "columns": [
+ "token"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sso_providers": {
+ "name": "sso_providers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "issuer": {
+ "name": "issuer",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "oidc_config": {
+ "name": "oidc_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "saml_config": {
+ "name": "saml_config",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "provider_id": {
+ "name": "provider_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "organization_id": {
+ "name": "organization_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "domain": {
+ "name": "domain",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "sso_providers_user_id_users_id_fk": {
+ "name": "sso_providers_user_id_users_id_fk",
+ "tableFrom": "sso_providers",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "set null"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "sso_providers_provider_id_unique": {
+ "name": "sso_providers_provider_id_unique",
+ "columns": [
+ "provider_id"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.user_roles": {
+ "name": "user_roles",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role": {
+ "name": "role",
+ "type": "role",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "user_roles_user_id_users_id_fk": {
+ "name": "user_roles_user_id_users_id_fk",
+ "tableFrom": "user_roles",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {
+ "user_roles_user_id_role_pk": {
+ "name": "user_roles_user_id_role_pk",
+ "columns": [
+ "user_id",
+ "role"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.users": {
+ "name": "users",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "email": {
+ "name": "email",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "image": {
+ "name": "image",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "email_verified": {
+ "name": "email_verified",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "groups": {
+ "name": "groups",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'"
+ },
+ "onboarding_step": {
+ "name": "onboarding_step",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "onboarding_completed_at": {
+ "name": "onboarding_completed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {
+ "users_email_unique": {
+ "name": "users_email_unique",
+ "columns": [
+ "email"
+ ],
+ "nullsNotDistinct": false
+ }
+ },
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.verifications": {
+ "name": "verifications",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "identifier": {
+ "name": "identifier",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "value": {
+ "name": "value",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "expires_at": {
+ "name": "expires_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.action_policy": {
+ "name": "action_policy",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "mode": {
+ "name": "mode",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "deny": {
+ "name": "deny",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "allow": {
+ "name": "allow",
+ "type": "text[]",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.computer_page_frame": {
+ "name": "computer_page_frame",
+ "schema": "",
+ "columns": {
+ "computer_id": {
+ "name": "computer_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "tool_call_id": {
+ "name": "tool_call_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "frame": {
+ "name": "frame",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "captured_at": {
+ "name": "captured_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "computer_page_frame_captured_idx": {
+ "name": "computer_page_frame_captured_idx",
+ "columns": [
+ {
+ "expression": "captured_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "computer_page_frame_computer_id_tool_call_id_pk": {
+ "name": "computer_page_frame_computer_id_tool_call_id_pk",
+ "columns": [
+ "computer_id",
+ "tool_call_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.computer_snapshot": {
+ "name": "computer_snapshot",
+ "schema": "",
+ "columns": {
+ "computer_id": {
+ "name": "computer_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "snapshot_id": {
+ "name": "snapshot_id",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "elements": {
+ "name": "elements",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "taken_at": {
+ "name": "taken_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "session": {
+ "name": "session",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_preferences": {
+ "name": "agent_preferences",
+ "schema": "",
+ "columns": {
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "hidden_at": {
+ "name": "hidden_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "agent_preferences_user_id_users_id_fk": {
+ "name": "agent_preferences_user_id_users_id_fk",
+ "tableFrom": "agent_preferences",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "agent_preferences_agent_id_agents_id_fk": {
+ "name": "agent_preferences_agent_id_agents_id_fk",
+ "tableFrom": "agent_preferences",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "tableTo": "agents",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {
+ "agent_preferences_user_id_agent_id_pk": {
+ "name": "agent_preferences_user_id_agent_id_pk",
+ "columns": [
+ "user_id",
+ "agent_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.agent_profiles": {
+ "name": "agent_profiles",
+ "schema": "",
+ "columns": {
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "role_description": {
+ "name": "role_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "avatar_seed": {
+ "name": "avatar_seed",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "visibility": {
+ "name": "visibility",
+ "type": "agent_visibility",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "callback_token_hash": {
+ "name": "callback_token_hash",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "callback_token_issued_at": {
+ "name": "callback_token_issued_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "deleted_at": {
+ "name": "deleted_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "agent_profiles_visibility_deleted_idx": {
+ "name": "agent_profiles_visibility_deleted_idx",
+ "columns": [
+ {
+ "expression": "visibility",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "deleted_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "agent_profiles_agent_id_agents_id_fk": {
+ "name": "agent_profiles_agent_id_agents_id_fk",
+ "tableFrom": "agent_profiles",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "tableTo": "agents",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "agent_profiles_owner_user_id_users_id_fk": {
+ "name": "agent_profiles_owner_user_id_users_id_fk",
+ "tableFrom": "agent_profiles",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "set null"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.routine_runs": {
+ "name": "routine_runs",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "routine_id": {
+ "name": "routine_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "started_at": {
+ "name": "started_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "status": {
+ "name": "status",
+ "type": "routine_run_status",
+ "typeSchema": "public",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "error": {
+ "name": "error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ }
+ },
+ "indexes": {
+ "routine_runs_by_routine_idx": {
+ "name": "routine_runs_by_routine_idx",
+ "columns": [
+ {
+ "expression": "routine_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "started_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "routine_runs_routine_id_routines_id_fk": {
+ "name": "routine_runs_routine_id_routines_id_fk",
+ "tableFrom": "routine_runs",
+ "columnsFrom": [
+ "routine_id"
+ ],
+ "tableTo": "routines",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.routines": {
+ "name": "routines",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "channel_id": {
+ "name": "channel_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "instruction": {
+ "name": "instruction",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "cron": {
+ "name": "cron",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "timezone": {
+ "name": "timezone",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'UTC'"
+ },
+ "enabled": {
+ "name": "enabled",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": true
+ },
+ "next_run_at": {
+ "name": "next_run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "last_run_at": {
+ "name": "last_run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "routines_due_idx": {
+ "name": "routines_due_idx",
+ "columns": [
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "next_run_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "routines_by_owner_idx": {
+ "name": "routines_by_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "enabled",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "routines_owner_user_id_users_id_fk": {
+ "name": "routines_owner_user_id_users_id_fk",
+ "tableFrom": "routines",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "routines_agent_id_agents_id_fk": {
+ "name": "routines_agent_id_agents_id_fk",
+ "tableFrom": "routines",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "tableTo": "agents",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.component_exclusions": {
+ "name": "component_exclusions",
+ "schema": "",
+ "columns": {
+ "component_name": {
+ "name": "component_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "withheld_by": {
+ "name": "withheld_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "component_exclusions_component_name_components_name_fk": {
+ "name": "component_exclusions_component_name_components_name_fk",
+ "tableFrom": "component_exclusions",
+ "columnsFrom": [
+ "component_name"
+ ],
+ "tableTo": "components",
+ "columnsTo": [
+ "name"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "component_exclusions_agent_id_agents_id_fk": {
+ "name": "component_exclusions_agent_id_agents_id_fk",
+ "tableFrom": "component_exclusions",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "tableTo": "agents",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {
+ "component_exclusions_component_name_agent_id_pk": {
+ "name": "component_exclusions_component_name_agent_id_pk",
+ "columns": [
+ "component_name",
+ "agent_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.component_functions": {
+ "name": "component_functions",
+ "schema": "",
+ "columns": {
+ "component_name": {
+ "name": "component_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "function_name": {
+ "name": "function_name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "granted_by": {
+ "name": "granted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "component_functions_component_name_components_name_fk": {
+ "name": "component_functions_component_name_components_name_fk",
+ "tableFrom": "component_functions",
+ "columnsFrom": [
+ "component_name"
+ ],
+ "tableTo": "components",
+ "columnsTo": [
+ "name"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {
+ "component_functions_component_name_function_name_pk": {
+ "name": "component_functions_component_name_function_name_pk",
+ "columns": [
+ "component_name",
+ "function_name"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.components": {
+ "name": "components",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "draft_description": {
+ "name": "draft_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "published_description": {
+ "name": "published_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published": {
+ "name": "published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "updated_by": {
+ "name": "updated_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_servers": {
+ "name": "mcp_servers",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "vendor": {
+ "name": "vendor",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "url": {
+ "name": "url",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "provenance": {
+ "name": "provenance",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'first-party'"
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "tools_refreshed_at": {
+ "name": "tools_refreshed_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "added_by": {
+ "name": "added_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "mcp_servers_credential_id_credentials_id_fk": {
+ "name": "mcp_servers_credential_id_credentials_id_fk",
+ "tableFrom": "mcp_servers",
+ "columnsFrom": [
+ "credential_id"
+ ],
+ "tableTo": "credentials",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "restrict"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_tools": {
+ "name": "mcp_tools",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "description": {
+ "name": "description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "input_schema": {
+ "name": "input_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {
+ "mcp_tools_server_id_mcp_servers_id_fk": {
+ "name": "mcp_tools_server_id_mcp_servers_id_fk",
+ "tableFrom": "mcp_tools",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "tableTo": "mcp_servers",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {
+ "mcp_tools_server_id_name_pk": {
+ "name": "mcp_tools_server_id_name_pk",
+ "columns": [
+ "server_id",
+ "name"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.mcp_user_credentials": {
+ "name": "mcp_user_credentials",
+ "schema": "",
+ "columns": {
+ "server_id": {
+ "name": "server_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "user_id": {
+ "name": "user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "credential_id": {
+ "name": "credential_id",
+ "type": "uuid",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "scope": {
+ "name": "scope",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "connected_at": {
+ "name": "connected_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "mcp_user_credentials_user_idx": {
+ "name": "mcp_user_credentials_user_idx",
+ "columns": [
+ {
+ "expression": "user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "mcp_user_credentials_server_id_mcp_servers_id_fk": {
+ "name": "mcp_user_credentials_server_id_mcp_servers_id_fk",
+ "tableFrom": "mcp_user_credentials",
+ "columnsFrom": [
+ "server_id"
+ ],
+ "tableTo": "mcp_servers",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "mcp_user_credentials_user_id_users_id_fk": {
+ "name": "mcp_user_credentials_user_id_users_id_fk",
+ "tableFrom": "mcp_user_credentials",
+ "columnsFrom": [
+ "user_id"
+ ],
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ },
+ "mcp_user_credentials_credential_id_credentials_id_fk": {
+ "name": "mcp_user_credentials_credential_id_credentials_id_fk",
+ "tableFrom": "mcp_user_credentials",
+ "columnsFrom": [
+ "credential_id"
+ ],
+ "tableTo": "credentials",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "no action"
+ }
+ },
+ "compositePrimaryKeys": {
+ "mcp_user_credentials_server_id_user_id_pk": {
+ "name": "mcp_user_credentials_server_id_user_id_pk",
+ "columns": [
+ "server_id",
+ "user_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.plugin_grants": {
+ "name": "plugin_grants",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ref": {
+ "name": "ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "agent_id": {
+ "name": "agent_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "granted_by": {
+ "name": "granted_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "plugin_grants_agent_idx": {
+ "name": "plugin_grants_agent_idx",
+ "columns": [
+ {
+ "expression": "agent_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "plugin_grants_agent_id_agents_id_fk": {
+ "name": "plugin_grants_agent_id_agents_id_fk",
+ "tableFrom": "plugin_grants",
+ "columnsFrom": [
+ "agent_id"
+ ],
+ "tableTo": "agents",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {
+ "plugin_grants_kind_ref_agent_id_pk": {
+ "name": "plugin_grants_kind_ref_agent_id_pk",
+ "columns": [
+ "kind",
+ "ref",
+ "agent_id"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.sandboxed_components": {
+ "name": "sandboxed_components",
+ "schema": "",
+ "columns": {
+ "name": {
+ "name": "name",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "draft_description": {
+ "name": "draft_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_html": {
+ "name": "draft_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_css": {
+ "name": "draft_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_js_functions": {
+ "name": "draft_js_functions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "''"
+ },
+ "draft_argument_schema": {
+ "name": "draft_argument_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "published_description": {
+ "name": "published_description",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_html": {
+ "name": "published_html",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_css": {
+ "name": "published_css",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_js_functions": {
+ "name": "published_js_functions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "published_argument_schema": {
+ "name": "published_argument_schema",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "sample_arguments": {
+ "name": "sample_arguments",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "revision": {
+ "name": "revision",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "published": {
+ "name": "published",
+ "type": "boolean",
+ "primaryKey": false,
+ "notNull": true,
+ "default": false
+ },
+ "published_at": {
+ "name": "published_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "authored_by": {
+ "name": "authored_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {},
+ "foreignKeys": {},
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.skill_tools": {
+ "name": "skill_tools",
+ "schema": "",
+ "columns": {
+ "skill_id": {
+ "name": "skill_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "ref": {
+ "name": "ref",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "declared_by": {
+ "name": "declared_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "skill_tools_ref_idx": {
+ "name": "skill_tools_ref_idx",
+ "columns": [
+ {
+ "expression": "ref",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "skill_tools_skill_id_skills_id_fk": {
+ "name": "skill_tools_skill_id_skills_id_fk",
+ "tableFrom": "skill_tools",
+ "columnsFrom": [
+ "skill_id"
+ ],
+ "tableTo": "skills",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {
+ "skill_tools_skill_id_ref_pk": {
+ "name": "skill_tools_skill_id_ref_pk",
+ "columns": [
+ "skill_id",
+ "ref"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.skills": {
+ "name": "skills",
+ "schema": "",
+ "columns": {
+ "id": {
+ "name": "id",
+ "type": "text",
+ "primaryKey": true,
+ "notNull": true
+ },
+ "owner_user_id": {
+ "name": "owner_user_id",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "slug": {
+ "name": "slug",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "title": {
+ "name": "title",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "summary": {
+ "name": "summary",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "instructions": {
+ "name": "instructions",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "origin": {
+ "name": "origin",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'yours'"
+ },
+ "installed_by": {
+ "name": "installed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "skills_slug_key": {
+ "name": "skills_slug_key",
+ "columns": [
+ {
+ "expression": "slug",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": true,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ },
+ "skills_owner_idx": {
+ "name": "skills_owner_idx",
+ "columns": [
+ {
+ "expression": "owner_user_id",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {
+ "skills_owner_user_id_users_id_fk": {
+ "name": "skills_owner_user_id_users_id_fk",
+ "tableFrom": "skills",
+ "columnsFrom": [
+ "owner_user_id"
+ ],
+ "tableTo": "users",
+ "columnsTo": [
+ "id"
+ ],
+ "onUpdate": "no action",
+ "onDelete": "cascade"
+ }
+ },
+ "compositePrimaryKeys": {},
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ },
+ "public.work_items": {
+ "name": "work_items",
+ "schema": "",
+ "columns": {
+ "kind": {
+ "name": "kind",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "key": {
+ "name": "key",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": true
+ },
+ "run_at": {
+ "name": "run_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "claimed_by": {
+ "name": "claimed_by",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "lease_until": {
+ "name": "lease_until",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "attempts": {
+ "name": "attempts",
+ "type": "integer",
+ "primaryKey": false,
+ "notNull": true,
+ "default": 0
+ },
+ "finished_at": {
+ "name": "finished_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "last_error": {
+ "name": "last_error",
+ "type": "text",
+ "primaryKey": false,
+ "notNull": false
+ },
+ "payload": {
+ "name": "payload",
+ "type": "jsonb",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "'{}'::jsonb"
+ },
+ "created_at": {
+ "name": "created_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ },
+ "updated_at": {
+ "name": "updated_at",
+ "type": "timestamp with time zone",
+ "primaryKey": false,
+ "notNull": true,
+ "default": "now()"
+ }
+ },
+ "indexes": {
+ "work_items_claimable_idx": {
+ "name": "work_items_claimable_idx",
+ "columns": [
+ {
+ "expression": "kind",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ },
+ {
+ "expression": "run_at",
+ "isExpression": false,
+ "asc": true,
+ "nulls": "last"
+ }
+ ],
+ "isUnique": false,
+ "with": {},
+ "method": "btree",
+ "concurrently": false
+ }
+ },
+ "foreignKeys": {},
+ "compositePrimaryKeys": {
+ "work_items_kind_key_pk": {
+ "name": "work_items_kind_key_pk",
+ "columns": [
+ "kind",
+ "key"
+ ]
+ }
+ },
+ "uniqueConstraints": {},
+ "policies": {},
+ "checkConstraints": {},
+ "isRLSEnabled": false
+ }
+ },
+ "enums": {
+ "public.agent_type": {
+ "name": "agent_type",
+ "schema": "public",
+ "values": [
+ "built_in",
+ "remote_ag_ui"
+ ]
+ },
+ "public.credential_kind": {
+ "name": "credential_kind",
+ "schema": "public",
+ "values": [
+ "model",
+ "connector",
+ "agent",
+ "mcp",
+ "mcp_oauth_client",
+ "mcp_user_token"
+ ]
+ },
+ "public.role": {
+ "name": "role",
+ "schema": "public",
+ "values": [
+ "admin",
+ "user"
+ ]
+ },
+ "public.agent_visibility": {
+ "name": "agent_visibility",
+ "schema": "public",
+ "values": [
+ "public",
+ "private"
+ ]
+ },
+ "public.routine_run_status": {
+ "name": "routine_run_status",
+ "schema": "public",
+ "values": [
+ "succeeded",
+ "failed",
+ "skipped"
+ ]
+ }
+ },
+ "schemas": {},
+ "views": {},
+ "sequences": {},
+ "roles": {},
+ "policies": {},
+ "_meta": {
+ "columns": {},
+ "schemas": {},
+ "tables": {}
+ }
+}
\ No newline at end of file
diff --git a/server/drizzle/meta/_journal.json b/server/drizzle/meta/_journal.json
index b6330597..0a0fb463 100644
--- a/server/drizzle/meta/_journal.json
+++ b/server/drizzle/meta/_journal.json
@@ -169,6 +169,20 @@
"when": 1787841174859,
"tag": "0023_routines_owner_index",
"breakpoints": true
+ },
+ {
+ "idx": 24,
+ "version": "7",
+ "when": 1787926466892,
+ "tag": "0024_onboarding",
+ "breakpoints": true
+ },
+ {
+ "idx": 25,
+ "version": "7",
+ "when": 1787926472382,
+ "tag": "0025_backfill_existing_users_have_onboarded",
+ "breakpoints": true
}
]
-}
+}
\ No newline at end of file
diff --git a/server/src/agents/handoff-delivery.ts b/server/src/agents/handoff-delivery.ts
index fdf07502..6ea276e5 100644
--- a/server/src/agents/handoff-delivery.ts
+++ b/server/src/agents/handoff-delivery.ts
@@ -86,7 +86,7 @@ export function createHandoffDelivery(options: {
runner: ThreadRunner;
lock: ThreadLock;
/**
- * Where the addressed Bot answers: a conversation of its own with the same person.
+ * A fresh thread of the addressed Bot's own, where its working happens out of sight.
*
* NOT THE CONVERSATION THAT ASKED, and this is a property of the platform rather than a choice. An
* Intelligence thread is owned by exactly one agent: `assertThreadAgentOwnership` refuses any other
@@ -94,29 +94,43 @@ export function createHandoffDelivery(options: {
* second Bot answering inside the first Bot's thread is not something this platform can express
* today, whatever the caller does.
*
- * So the answer lands where that Bot can speak, and the conversation that asked says where it went.
- * The person gets both halves; they are two conversations rather than one, which is the honest
- * shape of what actually happened.
+ * A scratch thread rather than the Bot's own channel with the person, which is where answers used
+ * to land. Two conversations for one question meant the person read the answer somewhere they
+ * never asked anything; now the runner relays what came back through the Bot that asked, in the
+ * conversation they are actually watching, and the scratch thread is never mapped to a channel so
+ * nobody is shown it. It still exists on the platform, which is what makes the turn a recorded
+ * run rather than an unlogged model call.
*/
- answerIn: (input: {
- actorId: string;
- botId: string;
- }) => Promise<{ threadId: string; channelId?: string }>;
+ mintThreadId: () => string;
/**
- * Tell the roster this conversation moved.
+ * Tell the roster a conversation moved, when a turn put words in it.
*
- * A HOP HAS NOBODY WATCHING, which is exactly why this is needed here. A conversation's place in
- * the list and the line under its name are written by the browser when somebody's own run
- * finishes; a hop finishes on a server with no browser attached, so without this the answer lands
- * in a conversation that still says it was last used yesterday and sits where it was. The person
- * is never told, and the whole point of a hop is that they find out.
+ * A HOP HAS NOBODY WATCHING. A conversation's place in the list and the line under its name are
+ * written by the browser when somebody's own run finishes; a hop finishes on a server with no
+ * browser attached, so without this a relayed answer lands in a conversation that never bumps,
+ * never reads as unread, and is found by accident. Routines have the same problem and the same
+ * answer: `recordActivity`, from the server, when the turn is on record.
+ *
+ * Keyed by thread rather than channel, because the thread is all a delivery knows. The wiring
+ * resolves which channel shows that thread — and a scratch thread resolves to nothing, which is
+ * a no-op rather than a mistake.
*/
announce?: (input: {
actorId: string;
- channelId: string;
+ threadId: string;
agentId: string;
text: string;
}) => Promise;
+ /**
+ * Show the asking conversation as working while a FORWARD hop runs, and stop when it is over.
+ *
+ * Only the forward leg, and keyed on the asking thread — `work.threadId`, the conversation the
+ * person is waiting in — because that leg runs in a scratch thread nobody watches. A backwards
+ * hop runs in the asking thread itself, whose lock the runtime already lights through its own
+ * busy signal, so this leaves that leg alone rather than double it. Best-effort and paired in a
+ * `finally`, so a channel never stays lit because a run threw.
+ */
+ setBusy?: (input: { threadId: string; busy: boolean }) => Promise;
newRunId: () => string;
/**
* How long one delivery may take before it is given up on.
@@ -135,8 +149,9 @@ export function createHandoffDelivery(options: {
history,
runner,
lock,
- answerIn,
+ mintThreadId,
announce,
+ setBusy,
newRunId,
deadlineMs = DEFAULT_DELIVERY_DEADLINE_MS,
} = options;
@@ -170,6 +185,17 @@ export function createHandoffDelivery(options: {
* calls its methods, and a stand-in that proxies them is a second thing to keep in step.
*/
const seen = { count: 0, last: "" };
+ /*
+ * What the addressed Bot said, gathered from the stream as it goes past.
+ *
+ * The runner publishes the turn to the platform rather than back through the observable, so
+ * the text exists nowhere this function can read after the fact — the events are the one
+ * chance to hear it. It is what the relay carries back to the conversation that asked, and
+ * with the answer no longer landing in a channel of its own, this is the only copy a person
+ * will ever be shown.
+ */
+ const said: string[] = [];
+ let saying = "";
const runAgent =
typeof agent.runAgent === "function"
? agent.runAgent.bind(agent)
@@ -183,173 +209,219 @@ export function createHandoffDelivery(options: {
input as never,
{
...(config ?? {}),
- onEvent: (emitted: { event?: { type?: unknown } }) => {
+ onEvent: (emitted: {
+ event?: { type?: unknown; delta?: unknown };
+ }) => {
seen.count += 1;
- seen.last = String(emitted?.event?.type ?? "");
+ const type = String(emitted?.event?.type ?? "");
+ seen.last = type;
+ if (type === "TEXT_MESSAGE_START") saying = "";
+ if (type === "TEXT_MESSAGE_CONTENT")
+ saying += String(emitted?.event?.delta ?? "");
+ if (type === "TEXT_MESSAGE_END" && saying.trim().length > 0)
+ said.push(saying.trim());
config?.onEvent?.(emitted);
},
} as never,
);
/*
- * The conversation this answer belongs in.
+ * The conversation this turn runs in.
*
- * Named on the hop for the one kind that goes backwards: telling the asking Bot, where the
- * person is watching, that the Bot it asked never came back. Every other hop lands in the
- * addressed Bot's own conversation, because a thread has exactly one agent.
+ * Named on the hop for the kind that goes backwards — the asking Bot speaking in the
+ * conversation the person is watching, to relay an answer or a failure. Every forward hop
+ * runs in a scratch thread of the addressed Bot's own, because a thread has exactly one
+ * agent; what it says there comes back to the person through the relay, not the thread.
*/
- const where: { threadId: string; channelId?: string } = work.answerIn
+ const where: { threadId: string } = work.answerIn
? { threadId: work.answerIn }
- : await answerIn({ actorId: work.actorId, botId: work.toBotId });
+ : { threadId: mintThreadId() };
/*
- * The conversation's lock, before a single event is streamed.
- *
- * The platform's run id is the one it hands back, not the one asked for: it is the identity the
- * gateway will check every streamed event against, so using the local one would be claiming to
- * be a run that does not exist.
+ * A forward hop lights the asking channel while it runs, because its own run is in a scratch
+ * thread nobody sees. A backwards hop — a relay or a notice — runs in the asking thread
+ * itself, so the runtime's own thread lock already lights it through `onRunBusy`, and
+ * signalling here too would double up. So this covers only the leg the lock cannot: the
+ * addressed Bot thinking, off-screen, on behalf of a channel the person is watching.
*/
- const held = await lock.acquire({
- threadId: where.threadId,
- runId: newRunId(),
- userId: work.actorId,
- agentId: work.toBotId,
- });
- if (!held) {
- /*
- * Somebody else is running in this conversation. Thrown so the hop goes back on the queue
- * and is tried again: a person mid-question, or the Bot that asked still finishing its own
- * sentence, is a wait rather than a failure.
- */
- throw new Error(
- `${where.threadId} is busy with another run; the hop will be tried again`,
+ const lightsAskingChannel = !work.answerIn;
+ if (lightsAskingChannel) {
+ await setBusy?.({ threadId: work.threadId, busy: true }).catch(
+ () => {},
);
}
+ try {
+ /*
+ * The conversation's lock, before a single event is streamed.
+ *
+ * The platform's run id is the one it hands back, not the one asked for: it is the identity the
+ * gateway will check every streamed event against, so using the local one would be claiming to
+ * be a run that does not exist.
+ */
+ const held = await lock.acquire({
+ threadId: where.threadId,
+ runId: newRunId(),
+ userId: work.actorId,
+ agentId: work.toBotId,
+ });
+ if (!held) {
+ /*
+ * Somebody else is running in this conversation. Thrown so the hop goes back on the queue
+ * and is tried again: a person mid-question, or the Bot that asked still finishing its own
+ * sentence, is a wait rather than a failure.
+ */
+ throw new Error(
+ `${where.threadId} is busy with another run; the hop will be tried again`,
+ );
+ }
- const runId = held.runId;
+ const runId = held.runId;
- /*
- * THE CONVERSATION GOES ON THE AGENT, NOT IN THE RUN.
- *
- * `runAgent` takes `runId`, `tools`, `context` and `forwardedProps` and nothing else: AG-UI
- * keeps the messages and the thread on the agent itself, and builds the run's input from them.
- * A `messages` array passed as a parameter is silently ignored, which is the worst shape a
- * mistake can take. Nothing failed. The addressed Bot ran, against an empty conversation, and
- * answered "how can I help?" to a question it had never been shown, in a transcript that
- * displayed the question directly above the answer.
- */
- const asked = [
+ /*
+ * THE CONVERSATION GOES ON THE AGENT, NOT IN THE RUN.
+ *
+ * `runAgent` takes `runId`, `tools`, `context` and `forwardedProps` and nothing else: AG-UI
+ * keeps the messages and the thread on the agent itself, and builds the run's input from them.
+ * A `messages` array passed as a parameter is silently ignored, which is the worst shape a
+ * mistake can take. Nothing failed. The addressed Bot ran, against an empty conversation, and
+ * answered "how can I help?" to a question it had never been shown, in a transcript that
+ * displayed the question directly above the answer.
+ */
/*
* The conversation that ASKED, not the one it is answering in. The addressed Bot is joining
* something already in progress and has to have read it; its own conversation is new and
* empty, and reading that would tell it nothing.
*/
- ...conversationOnly(
+ const prior = conversationOnly(
await history({ threadId: work.threadId, actorId: work.actorId }),
- ),
- { id: `handoff-${runId}`, role: "user", content: message },
- ];
- agent.threadId = where.threadId;
- // The platform's own message type rather than AG-UI's, which is what `history` returns: the
- // two agree where it matters, and converting between them is a place to lose a message.
- agent.setMessages(asked as Parameters[0]);
- /*
- * Renewed while the addressed Bot works, because the lock expires on its own. A run is minutes
- * and the platform's window is short; a lock that lapses mid-answer lets a second run into the
- * conversation, which is the thing it exists to prevent.
- */
- const heartbeat = setInterval(() => {
- void lock.renew({ threadId: where.threadId, runId }).catch(() => {});
- }, LOCK_RENEW_EVERY_MS);
+ );
+ const asked = [
+ /*
+ * A backwards hop gets the tail, not the whole. Its task already carries everything it has
+ * to say — the answer, or the failure — and the person is waiting through this run's
+ * time-to-first-token: a long channel read in full would make relaying an answer slower
+ * the longer the conversation that wanted it. A few recent turns keep the voice and the
+ * pronouns right; the rest is weight.
+ */
+ ...(work.answerIn ? prior.slice(-RELAY_CONTEXT_MESSAGES) : prior),
+ { id: `handoff-${runId}`, role: "user", content: message },
+ ];
+ agent.threadId = where.threadId;
+ // The platform's own message type rather than AG-UI's, which is what `history` returns: the
+ // two agree where it matters, and converting between them is a place to lose a message.
+ agent.setMessages(asked as Parameters[0]);
+ /*
+ * Renewed while the addressed Bot works, because the lock expires on its own. A run is minutes
+ * and the platform's window is short; a lock that lapses mid-answer lets a second run into the
+ * conversation, which is the thing it exists to prevent.
+ */
+ const heartbeat = setInterval(() => {
+ void lock.renew({ threadId: where.threadId, runId }).catch(() => {});
+ }, LOCK_RENEW_EVERY_MS);
- try {
- await settled(
- runner.run({
- threadId: where.threadId,
- agent,
- /*
- * What the conversation KEEPS, which is not what the model was sent.
- *
- * The runner persists whatever it is given here, and given nothing it persists the whole
- * prompt: the asking conversation's history repeated into a second conversation, and a
- * paragraph of instructions to a model sitting in a bubble that looks like something the
- * person typed. What belongs in a transcript is the one line saying why this Bot spoke.
- */
- persistedInputMessages: shown
- ? [{ id: `handoff-${runId}`, role: "user", content: shown }]
- : [],
- /*
- * NOTHING IS PASSED FOR THE CONNECTION, and that is load-bearing.
- *
- * The lock hands back a join token as well as a run id, and it reads like the thing to
- * present here. It is not: it is what a BROWSER presents to join a conversation and
- * watch it, and the runner's socket is a different connection with its own credential.
- * Handing it in overrides that credential, the socket is refused, and because the runner
- * treats a socket that will not connect as something to keep retrying rather than as a
- * failed run, nothing is ever emitted and nothing ever completes. The hop hangs, in
- * total silence, until the deadline below ends it.
- *
- * What makes this run legitimate is the lock itself: the gateway compares the run id on
- * every event to the one the lock holds. Taking the lock is the whole of the ceremony.
- */
- input: {
+ try {
+ await settled(
+ runner.run({
threadId: where.threadId,
- runId,
+ agent,
/*
- * The same conversation the agent was given, so the run's own record of what it was
- * asked agrees with what it read.
+ * What the conversation KEEPS, which is not what the model was sent.
+ *
+ * The runner persists whatever it is given here, and given nothing it persists the whole
+ * prompt: the asking conversation's history repeated into a second conversation, and a
+ * paragraph of instructions to a model sitting in a bubble that looks like something the
+ * person typed. What belongs in a transcript is the one line saying why this Bot spoke.
*/
- messages: asked,
- tools: [],
- context: [],
- state: {},
+ persistedInputMessages: shown
+ ? [{ id: `handoff-${runId}`, role: "user", content: shown }]
+ : [],
/*
- * The deployment's own statement of what this run is, carrying how deep the chain has
- * gone. It is what stops the addressed Bot handing the work on for ever, and it is
- * signed, so the Bot cannot edit its own depth on the way past.
+ * NOTHING IS PASSED FOR THE CONNECTION, and that is load-bearing.
+ *
+ * The lock hands back a join token as well as a run id, and it reads like the thing to
+ * present here. It is not: it is what a BROWSER presents to join a conversation and
+ * watch it, and the runner's socket is a different connection with its own credential.
+ * Handing it in overrides that credential, the socket is refused, and because the runner
+ * treats a socket that will not connect as something to keep retrying rather than as a
+ * failed run, nothing is ever emitted and nothing ever completes. The hop hangs, in
+ * total silence, until the deadline below ends it.
+ *
+ * What makes this run legitimate is the lock itself: the gateway compares the run id on
+ * every event to the one the lock holds. Taking the lock is the whole of the ceremony.
*/
- forwardedProps: { openbotRun: assertion },
- },
- }),
- deadlineMs,
- () =>
- `${work.toBotId} did not finish within ${Math.round(deadlineMs / 1000)}s ${
- seen.count === 0
- ? "and never reached its model"
- : `after ${seen.count} events, the last ${seen.last}`
- }`,
- );
- /*
- * Only once the run is on record. A conversation lifted to the top of somebody's list for an
- * answer that then failed is worse than one that did not move: they open it and find
- * nothing, and nothing says why.
- */
- if (announce && where.channelId && shown) {
- await announce({
- actorId: work.actorId,
- channelId: where.channelId,
- agentId: work.toBotId,
- text: shown,
- }).catch(() => {
- // The turn happened. A roster that has not caught up is worth less than a hop reported
- // as failed and run a second time.
- });
+ input: {
+ threadId: where.threadId,
+ runId,
+ /*
+ * The same conversation the agent was given, so the run's own record of what it was
+ * asked agrees with what it read.
+ */
+ messages: asked,
+ tools: [],
+ context: [],
+ state: {},
+ /*
+ * The deployment's own statement of what this run is, carrying how deep the chain has
+ * gone. It is what stops the addressed Bot handing the work on for ever, and it is
+ * signed, so the Bot cannot edit its own depth on the way past.
+ */
+ forwardedProps: { openbotRun: assertion },
+ },
+ }),
+ deadlineMs,
+ () =>
+ `${work.toBotId} did not finish within ${Math.round(deadlineMs / 1000)}s ${
+ seen.count === 0
+ ? "and never reached its model"
+ : `after ${seen.count} events, the last ${seen.last}`
+ }`,
+ );
+ /*
+ * Only once the run is on record, and only when it said something: a conversation lifted to
+ * the top of somebody's list for a turn that failed, or said nothing, is a conversation
+ * they open to find nothing new.
+ */
+ if (announce && said.length > 0) {
+ await announce({
+ actorId: work.actorId,
+ threadId: where.threadId,
+ agentId: work.toBotId,
+ text: said.join("\n\n"),
+ }).catch(() => {
+ // The turn happened. A roster that has not caught up is worth less than a hop reported
+ // as failed and run a second time.
+ });
+ }
+ } finally {
+ clearInterval(heartbeat);
+ /*
+ * Given back whatever happened. Left held, the conversation is unusable by anybody until the
+ * lock expires: the person cannot ask a follow-up and the next hop is refused, which turns
+ * one failed delivery into a conversation that has stopped working.
+ */
+ /*
+ * The conversation the lock was taken on, which is the one being answered in and NOT the one
+ * that asked. Releasing the asking conversation's lock instead leaves this one held until it
+ * lapses: the person cannot type in it and the next hop to the same Bot is refused, while a
+ * lock somebody else may be holding on the asking side is dropped from under them.
+ */
+ await lock
+ .release({ threadId: where.threadId, runId })
+ .catch(() => {});
}
} finally {
- clearInterval(heartbeat);
- /*
- * Given back whatever happened. Left held, the conversation is unusable by anybody until the
- * lock expires: the person cannot ask a follow-up and the next hop is refused, which turns
- * one failed delivery into a conversation that has stopped working.
- */
- /*
- * The conversation the lock was taken on, which is the one being answered in and NOT the one
- * that asked. Releasing the asking conversation's lock instead leaves this one held until it
- * lapses: the person cannot type in it and the next hop to the same Bot is refused, while a
- * lock somebody else may be holding on the asking side is dropped from under them.
- */
- await lock.release({ threadId: where.threadId, runId }).catch(() => {});
+ if (lightsAskingChannel) {
+ await setBusy?.({ threadId: work.threadId, busy: false }).catch(
+ () => {},
+ );
+ }
}
+
+ /*
+ * What came back, for the runner to relay. Null when the turn produced no words at all —
+ * a run that only called tools — which the runner treats as nothing worth carrying back.
+ */
+ return { answer: said.length > 0 ? said.join("\n\n") : null };
},
};
}
@@ -380,6 +452,14 @@ function conversationOnly(messages: readonly unknown[]): readonly unknown[] {
});
}
+/**
+ * How much of the asking conversation a backwards hop reads.
+ *
+ * Six messages is about three exchanges: enough for the relaying Bot to keep its footing in the
+ * conversation it is speaking into, and small enough that the read never grows with the channel.
+ */
+const RELAY_CONTEXT_MESSAGES = 6;
+
/**
* How often the conversation's lock is refreshed while a Bot is working.
*
diff --git a/server/src/agents/handoff-runner.ts b/server/src/agents/handoff-runner.ts
index 1950e322..5b7ab8db 100644
--- a/server/src/agents/handoff-runner.ts
+++ b/server/src/agents/handoff-runner.ts
@@ -46,6 +46,10 @@ export type HandoffDelivery = {
* Rejecting means the hop did not happen and is worth another go. Resolving means it did, whatever
* the Bot said: a Bot that answers "I could not find that" has answered, and retrying would ask it
* the same question again and bill for the same non-answer.
+ *
+ * Resolves with what the Bot said, because its turn runs in a scratch thread nobody is shown:
+ * the words it comes back with exist for the relay or not at all. Null means a turn of nothing
+ * but tool calls, which is a turn that happened and nothing worth carrying back.
*/
deliver: (input: {
work: HandoffWork;
@@ -67,7 +71,7 @@ export type HandoffDelivery = {
shown?: string;
/** The signed statement of the run it is starting, carrying its depth. */
assertion: string;
- }) => Promise;
+ }) => Promise<{ answer: string | null }>;
};
export type HandoffRunReport = {
@@ -139,6 +143,37 @@ export function createHandoffRunner(options: {
* Marked with `answerIn`, which is also what stops this recursing: a notice that fails is not
* itself worth a notice, and the check above skips any hop that carries one.
*/
+ /**
+ * Put the answer in front of the person, the same way a failure is: by running the Bot that
+ * asked, in the conversation they are watching.
+ *
+ * THE ADDRESSED BOT NEVER SPEAKS THERE — the platform gives a thread exactly one agent — so its
+ * words come home in the asking Bot's voice, attributed. The same `answerIn` marker that stops a
+ * notice recursing stops a relay relaying: a hop that carries one enqueues nothing when it lands.
+ *
+ * The answer is clipped rather than trusted to be a paragraph. It rides inside the prompt of the
+ * relaying run, and a Bot that came back with a report the length of a book would otherwise spend
+ * the relay's whole context window repeating it.
+ */
+ const relay = (work: HandoffWork, key: string, answer: string) =>
+ queue.offer({
+ kind: HANDOFF_KIND,
+ // Outside the run's fan-out prefix and keyed on the hop, for the same two reasons as the
+ // notice below: a relay is not a Bot this run asked for, and one run may legally ask the
+ // same Bot two different things.
+ key: `relay:${key}`,
+ payload: {
+ fromBotId: work.toBotId,
+ toBotId: work.fromBotId,
+ actorId: work.actorId,
+ threadId: work.threadId,
+ runId: work.runId,
+ depth: work.depth,
+ answerIn: work.threadId,
+ task: `You asked ${work.toName ?? work.toBotId} to help with this: ${work.task}\n\nIt answered:\n\n${clip(answer)}\n\nGive the person the outcome. Keep what matters, drop the pleasantries, and say it came from ${work.toName ?? work.toBotId}.`,
+ } as unknown as Record,
+ });
+
const tell = (work: HandoffWork, key: string, reason: string) =>
queue.offer({
kind: HANDOFF_KIND,
@@ -309,7 +344,7 @@ export function createHandoffRunner(options: {
try {
const shown = summarise(work);
- await delivery.deliver({
+ const { answer } = await delivery.deliver({
work,
message: attribute(work),
...(shown ? { shown } : {}),
@@ -349,6 +384,22 @@ export function createHandoffRunner(options: {
continue;
}
report.delivered.push(work.toBotId);
+ /*
+ * The answer goes home through the queue, like the turn that produced it: durable, so a
+ * pod dying between the turn and the relay loses the relay to a retry rather than for
+ * ever. Only for a forward hop with words to carry — a relay of a relay is the loop the
+ * `answerIn` check exists to stop, and a wordless turn has nothing to say.
+ */
+ if (!work.answerIn && answer) {
+ await relay(work, item.key, answer).catch((failure) => {
+ // The turn happened and is on record; a relay that cannot be queued must not undo
+ // that by failing the hop into a retry and a second turn.
+ console.warn(
+ "Could not queue the relay for a delivered hop.",
+ failure,
+ );
+ });
+ }
await recordAuditEvent(auditStore, {
eventType: "agent.handoff_delivered",
targetType: "agent",
@@ -424,6 +475,21 @@ export function createHandoffRunner(options: {
};
}
+/**
+ * How much of an answer one relay will carry.
+ *
+ * Generous, because with the answer living nowhere a person is shown, what the relay drops is gone:
+ * the scratch thread that holds the rest is never mapped to a channel. The cap exists for the Bot
+ * that comes back with a book — an answer that size swamps the relaying run's prompt, and the
+ * asking Bot was told what a good answer looks like precisely so this stays a paragraph.
+ */
+const RELAY_ANSWER_LIMIT = 12_000;
+
+function clip(answer: string): string {
+ if (answer.length <= RELAY_ANSWER_LIMIT) return answer;
+ return `${answer.slice(0, RELAY_ANSWER_LIMIT)}\n\n[…the answer was cut here for length]`;
+}
+
/**
* The same failure, in words that can be said out loud.
*
diff --git a/server/src/agents/handoff-tool.ts b/server/src/agents/handoff-tool.ts
index 1679a40a..52452fa4 100644
--- a/server/src/agents/handoff-tool.ts
+++ b/server/src/agents/handoff-tool.ts
@@ -119,7 +119,7 @@ export function handoffTool(options: {
* way, and the model is owed something it can say out loud.
*/
return outcome.ok
- ? `${HANDED_OVER}${outcome.toName}. It will answer in its own conversation with this person, so tell them you have asked it and what for, and do not answer on its behalf.`
+ ? `${HANDED_OVER}${outcome.toName}. Its answer will be relayed back into this conversation when it finishes, so tell the person you have asked it and what for, and do not answer on its behalf.`
: outcome.refusal;
},
};
diff --git a/server/src/app.ts b/server/src/app.ts
index 20561446..36617003 100644
--- a/server/src/app.ts
+++ b/server/src/app.ts
@@ -36,6 +36,7 @@ import { createComputerRoutes } from "./computer/routes";
import { configuredAuthProviders, type DeploymentConfig } from "./config";
import type { CredentialAdminService, CredentialInput } from "./credentials";
import { createIntelligenceClient } from "./intelligence-client";
+import type { OnboardingStore } from "./people/onboarding";
import type { PeopleStore } from "./people/store";
import { createPluginRoutes } from "./plugins/routes";
import type { PluginStore } from "./plugins/store";
@@ -190,6 +191,17 @@ export function createApp(
* has no door for this at all, not a locked one.
*/
routineStore?: RoutineStore,
+ /**
+ * Where each person is in first-run onboarding.
+ *
+ * Appended last, like everything above it: these are positional, so inserting one anywhere else
+ * silently shifts every existing call site's arguments by one.
+ *
+ * Absent leaves /api/me reporting no onboarding to track, which is the correct degraded
+ * behaviour: a deployment that cannot read the status must not lock everybody behind a gate
+ * nothing can finish.
+ */
+ onboardingStore?: OnboardingStore,
) {
const app = new Hono<{ Variables: AppVariables }>();
@@ -276,9 +288,52 @@ export function createApp(
? createRequireUser(auth, roleRepository)
: authenticationUnavailable;
- app.get("/api/me", requireUser, (context) =>
- context.json({ user: context.var.actor }),
+ app.get("/api/me", requireUser, async (context) =>
+ context.json({
+ user: {
+ ...context.var.actor,
+ /*
+ * Read here rather than in the guard, so only this route pays the extra query. Null means
+ * this deployment does not track onboarding, which the app reads as nothing to finish;
+ * a not-yet-completed status is what sends it to /onboarding.
+ */
+ onboarding: onboardingStore
+ ? await onboardingStore.status(context.var.actor.id)
+ : null,
+ },
+ }),
);
+ app.post("/api/me/onboarding", requireUser, async (context) => {
+ if (!onboardingStore) {
+ return context.json({ error: "Onboarding is not available." }, 503);
+ }
+
+ const body = (await context.req.json().catch(() => undefined)) as
+ | { step?: unknown; completed?: unknown }
+ | undefined;
+
+ if (body?.completed === true) {
+ await onboardingStore.complete(context.var.actor.id);
+ } else if (
+ typeof body?.step === "number" &&
+ Number.isInteger(body.step) &&
+ body.step >= 0 &&
+ // The column's range — the only bound the server knows, since the wizard's length is the
+ // app's fact rather than the deployment's.
+ body.step <= 2_147_483_647
+ ) {
+ await onboardingStore.setStep(context.var.actor.id, body.step);
+ } else {
+ return context.json(
+ { error: "Send the step to move to, or completed: true." },
+ 400,
+ );
+ }
+
+ return context.json({
+ onboarding: await onboardingStore.status(context.var.actor.id),
+ });
+ });
app.get("/api/admin/status", requireUser, (context) => {
const denied = requireAdmin(context);
return denied ?? context.json({ status: "ok" });
diff --git a/server/src/channels/events.ts b/server/src/channels/events.ts
index b0bced6f..9f310bab 100644
--- a/server/src/channels/events.ts
+++ b/server/src/channels/events.ts
@@ -31,6 +31,14 @@ export type ChannelActivityEvent = {
* hub's delivery rule does the rest: nobody else in the channel hears a pin they did not make.
*/
pinned?: boolean;
+ /**
+ * A turn started or ended in this channel. Absent on an ordinary activity event.
+ *
+ * Transient and message-less: it is never written to a table, only announced, so the roster can
+ * show a working indicator for a headless turn — a handoff hop, a relay — that no browser
+ * streams. A missed one costs at most a stuck-looking dot until the next real event, never data.
+ */
+ busy?: boolean;
};
type Send = (payload: string) => void;
diff --git a/server/src/channels/routes.ts b/server/src/channels/routes.ts
index afdc9ea2..58c3864e 100644
--- a/server/src/channels/routes.ts
+++ b/server/src/channels/routes.ts
@@ -182,6 +182,29 @@ export type ChannelStore = {
channelId: string,
activity: ChannelActivity,
): Promise;
+ /**
+ * Tell a channel's members that a turn started or ended in it, by the thread it runs in.
+ *
+ * Keyed by thread because that is all a headless turn knows. A thread that maps to no channel —
+ * the scratch thread a handoff answers in — resolves to nothing and signals nowhere, which is
+ * the point of a scratch thread. Announced, never written: `busy` is a moment, not a fact about
+ * the channel, and a missed one costs a dot until the next real event rather than any data.
+ */
+ signalBusy(threadId: string, busy: boolean): Promise;
+ /**
+ * The same signal, from a person's own run, by the channel they are in.
+ *
+ * A browser knows exactly when its run starts and stops and which channel it is in, which the
+ * server cannot see: the runtime does not tell this deployment when a person's turn begins. So
+ * the browser reports it, and this checks the caller belongs to the channel before announcing —
+ * the membership check `signalBusy` does not need, because that one is only ever called by the
+ * server about work it started itself.
+ */
+ signalChannelBusy(
+ actor: AgentActor,
+ channelId: string,
+ busy: boolean,
+ ): Promise;
};
const PRIVATE_AGENT_CHANNEL_DESCRIPTION = "Private agent channel.";
@@ -756,6 +779,76 @@ export function createChannelStore(
{ isolationLevel: "read committed" },
);
},
+
+ async signalBusy(threadId, busy) {
+ // The channel this thread is shown in, if any. A scratch thread maps to nothing, so a hop
+ // running there signals nowhere and the branch below returns without announcing.
+ const [mapped] = await database
+ .select({ channelId: intelligenceChannelMappings.channelId })
+ .from(intelligenceChannelMappings)
+ .where(eq(intelligenceChannelMappings.threadId, threadId))
+ .limit(1);
+ if (!mapped) return;
+
+ const members = await database
+ .select({ userId: channelMemberships.userId })
+ .from(channelMemberships)
+ .where(eq(channelMemberships.channelId, mapped.channelId));
+ if (members.length === 0) return;
+
+ // No table write: busy is a moment, and the roster query stays the source of truth. Just the
+ // announcement, carrying the members the same way recordActivity does.
+ const event: ChannelActivityEvent = {
+ channelId: mapped.channelId,
+ memberIds: members.map((member) => member.userId),
+ lastMessage: null,
+ lastMessageAt: null,
+ lastMessageAgentId: null,
+ busy,
+ };
+ await database.execute(
+ sql`select pg_notify(${CHANNEL_ACTIVITY_TOPIC}, ${JSON.stringify(event)})`,
+ );
+ },
+
+ async signalChannelBusy(actor, channelId, busy) {
+ const [membership] = await database
+ .select({ userId: channelMemberships.userId })
+ .from(channelMemberships)
+ .innerJoin(
+ channels,
+ and(
+ eq(channels.id, channelMemberships.channelId),
+ isNull(channels.deletedAt),
+ ),
+ )
+ .where(
+ and(
+ eq(channelMemberships.channelId, channelId),
+ eq(channelMemberships.userId, actor.id),
+ ),
+ );
+ // Not a member, no such channel, or a deleted one: the same refusal every way, so belonging
+ // to a channel is not something an outsider can probe for.
+ if (!membership) throw new ChannelNotFoundError(channelId);
+
+ const members = await database
+ .select({ userId: channelMemberships.userId })
+ .from(channelMemberships)
+ .where(eq(channelMemberships.channelId, channelId));
+
+ const event: ChannelActivityEvent = {
+ channelId,
+ memberIds: members.map((member) => member.userId),
+ lastMessage: null,
+ lastMessageAt: null,
+ lastMessageAgentId: null,
+ busy,
+ };
+ await database.execute(
+ sql`select pg_notify(${CHANNEL_ACTIVITY_TOPIC}, ${JSON.stringify(event)})`,
+ );
+ },
};
return store;
}
@@ -983,6 +1076,26 @@ export function createChannelRoutes(
}
});
+ routes.post("/:channelId/busy", requireUser, async (context) => {
+ const body = (await context.req.json().catch(() => null)) as {
+ busy?: unknown;
+ } | null;
+ if (typeof body?.busy !== "boolean") {
+ return context.json({ error: "busy must be true or false" }, 400);
+ }
+
+ try {
+ await store.signalChannelBusy(
+ context.var.actor,
+ context.req.param("channelId"),
+ body.busy,
+ );
+ return context.body(null, 204);
+ } catch (error) {
+ return mapStoreError(context, error);
+ }
+ });
+
routes.put("/:channelId/pin", requireUser, async (context) => {
const body = await context.req.json().catch(() => null);
if (!isChannelInputObject(body)) {
diff --git a/server/src/copilot.ts b/server/src/copilot.ts
index 891c53ee..8e2f9c2f 100644
--- a/server/src/copilot.ts
+++ b/server/src/copilot.ts
@@ -1014,6 +1014,15 @@ export function mountCopilotRuntime(
agentFetch?: AgentFetch,
/** How a run gets its tool for handing work on. Absent means no Bot is offered one. */
handoffForActor?: (actorId: string) => HandoffForRun,
+ /**
+ * Told when a run starts and ends on a thread, so a channel can show it is working.
+ *
+ * The universal seam: every run the runtime processes — a person's own turn, a headless hop —
+ * takes and gives back the thread lock, and it does so on the server, so a person who sends a
+ * message and navigates away still lights the channel they left. A side effect only: it is never
+ * awaited in the lock path and a failure in it never touches whether the lock was taken.
+ */
+ onRunBusy?: (input: { threadId: string; busy: boolean }) => void,
) {
const { intelligence } = config.runtime;
@@ -1145,6 +1154,11 @@ export function mountCopilotRuntime(
}) => {
try {
const held = await intelligenceClient.ɵacquireThreadLock(input);
+ // A run started on this thread. Side effect only, never awaited: a channel showing it is
+ // working is worth nothing next to the lock the run depends on.
+ try {
+ onRunBusy?.({ threadId: input.threadId, busy: true });
+ } catch {}
/*
* The run id only. The lock also hands back a join token, which is what a browser presents
* to watch the conversation; the runner's socket has its own credential and passing this
@@ -1181,6 +1195,11 @@ export function mountCopilotRuntime(
});
},
release: async (input: { threadId: string; runId: string }) => {
+ // The run on this thread is over. Cleared here rather than trusting a browser: the run may
+ // have outlived the tab that started it, and this is where the platform is told it ended.
+ try {
+ onRunBusy?.({ threadId: input.threadId, busy: false });
+ } catch {}
await intelligenceClient.ɵcleanupThreadLock(input);
},
},
diff --git a/server/src/db/schema/core.ts b/server/src/db/schema/core.ts
index f8869b26..44d5753a 100644
--- a/server/src/db/schema/core.ts
+++ b/server/src/db/schema/core.ts
@@ -2,6 +2,7 @@ import { sql } from "drizzle-orm";
import {
boolean,
index,
+ integer,
pgEnum,
pgTable,
primaryKey,
@@ -63,6 +64,16 @@ export const users = pgTable("users", {
* list for everybody.
*/
groups: text("groups").array().notNull().default([]),
+ /**
+ * Where this person is in first-run onboarding.
+ *
+ * The step is where the wizard resumes if they leave halfway; the null completion timestamp is
+ * what gates the app into /onboarding. Set once — finishing again keeps the first timestamp.
+ */
+ onboardingStep: integer("onboarding_step").notNull().default(0),
+ onboardingCompletedAt: timestamp("onboarding_completed_at", {
+ withTimezone: true,
+ }),
createdAt: createdAt(),
updatedAt: updatedAt(),
});
diff --git a/server/src/index.ts b/server/src/index.ts
index a3b18ef6..c3e3c1c6 100644
--- a/server/src/index.ts
+++ b/server/src/index.ts
@@ -4,11 +4,12 @@ import {
IntelligenceAgentRunner,
} from "@copilotkit/runtime/v2";
import { serve } from "bun";
+import { eq } from "drizzle-orm";
import { COMPUTER_GUIDANCE } from "../../shared/bot-prompt";
import { mintRunAssertion, readRunAssertion } from "./agents/callback-token";
import { createAgentFetch } from "./agents/endpoint";
import { askTheirOwnPerson, escalationTool } from "./agents/escalation";
-import { createHandoffDesk } from "./agents/handoff";
+import { createHandoffDesk, HANDOFF_KIND } from "./agents/handoff";
import { createHandoffDelivery } from "./agents/handoff-delivery";
import { createHandoffRunner } from "./agents/handoff-runner";
import { handoffTool } from "./agents/handoff-tool";
@@ -59,6 +60,8 @@ import {
resolveModelApiKey,
} from "./credentials";
import { createDatabase } from "./db/client";
+import { intelligenceChannelMappings } from "./db/schema";
+import { createOnboardingStore } from "./people/onboarding";
import { createPeopleStore } from "./people/store";
import { useRoutineTools } from "./plugins/builtin-routines";
import { redirectUriFor } from "./plugins/oauth";
@@ -75,7 +78,7 @@ import {
synchronizeTenantPackage,
} from "./tenant-package";
import { repeatAfterEach } from "./work/loop";
-import { createWorkQueue } from "./work/queue";
+import { createWorkQueue, startWorkOfferedListener } from "./work/queue";
/**
* Who is asking, for a CopilotKit request.
@@ -804,6 +807,11 @@ const copilotRuntime = mountCopilotRuntime(
});
return passing ? [passing, asking] : [asking];
},
+ // A run started or ended on a thread; light the channel it belongs to. Fire-and-forget, keyed by
+ // thread, and a scratch thread maps to no channel and signals nowhere.
+ (input) => {
+ void channelStore.signalBusy(input.threadId, input.busy).catch(() => {});
+ },
);
/**
@@ -825,24 +833,6 @@ const copilotRuntime = mountCopilotRuntime(
* per day, for a feature it had turned off.
*/
if (config.handoff.maxDepth > 0 && config.handoff.maxPerRun > 0) {
- /**
- * The person a delivery acts as, with a failure a person can be told about.
- *
- * `actorFor` throws when a role cannot be established — a revoked role, or a database that
- * blinked. Thrown from inside a delivery that message becomes the reason on a failed hop, and the
- * reason is paraphrased to somebody by the Bot that asked: "A routine requires an authorized
- * owner." is not a sentence to put in front of a person who asked about a refund policy.
- */
- const theirActor = async (userId: string) => {
- const actor = await actorFor(userId).catch(() => null);
- if (!actor) {
- throw new Error(
- "who this is for could not be confirmed, so the answer had nowhere to go",
- );
- }
- return actor;
- };
-
const runner = createHandoffRunner({
queue: createWorkQueue(database),
owner: `handoff/${process.env.HOSTNAME ?? randomUUID().slice(0, 8)}`,
@@ -880,32 +870,38 @@ if (config.handoff.maxDepth > 0 && config.handoff.maxPerRun > 0) {
history: copilotRuntime.history,
lock: copilotRuntime.threadLock,
/*
- * A conversation of the addressed Bot's own, with the same person.
+ * A scratch thread of the addressed Bot's own, one per hop.
*
* An Intelligence thread has exactly one agent, so a second Bot cannot answer inside the first
- * Bot's conversation however it asks. Rather than pretend otherwise, the answer lands where
- * that Bot can speak and the conversation that asked says where it went.
+ * Bot's conversation however it asks. Its turn runs here instead, unmapped to any channel, and
+ * what it said comes back to the conversation that asked through the relay — in the asking
+ * Bot's voice, which is the only voice that thread admits. Minted with the deployment's own
+ * identity, like every thread this deployment starts.
*/
- answerIn: async (input) => {
- // The conversation this person already has with that Bot, made only if they have not had
- // one. See ChannelStore.direct: a hop is retried, and creating here left an empty channel
- // behind for every attempt.
- // The person's own role, for the same reason the desk resolves it: an administrator sees Bots
- // a user does not, and a conversation with one of those is still theirs.
- const channel = await channelStore.direct(
- await theirActor(input.actorId),
- input.botId,
- );
- return { threadId: channel.threadId, channelId: channel.id };
+ mintThreadId: () => threadIdentity.mint(),
+ /*
+ * The roster, told that a relayed answer landed. The delivery knows only the thread it ran
+ * in; this resolves which channel shows that thread — a scratch thread maps to nothing and
+ * announces nowhere, which is the point of a scratch thread.
+ */
+ announce: async (input) => {
+ const [mapped] = await database
+ .select({ channelId: intelligenceChannelMappings.channelId })
+ .from(intelligenceChannelMappings)
+ .where(eq(intelligenceChannelMappings.threadId, input.threadId))
+ .limit(1);
+ if (!mapped) return;
+ const actor = await actorFor(input.actorId).catch(() => null);
+ if (!actor) return;
+ await channelStore.recordActivity(actor, mapped.channelId, {
+ text: input.text,
+ agentId: input.agentId,
+ at: new Date(),
+ });
},
- // The roster is written by whoever finished a run, and for a hop that is this server rather
- // than a browser. See ChannelStore.recordActivity.
- announce: async (input) =>
- channelStore.recordActivity(
- await theirActor(input.actorId),
- input.channelId,
- { text: input.text, agentId: input.agentId, at: new Date() },
- ),
+ // The asking conversation shown as working while a hop runs in it. Keyed by thread, resolved
+ // to its channel by the store; a scratch thread maps to none and signals nowhere.
+ setBusy: (input) => channelStore.signalBusy(input.threadId, input.busy),
newRunId: () => randomUUID(),
// The same address and the same token the runtime uses. Assembling either from configuration
// produced a runner every join was refused for, because the thread's active run is a lock the
@@ -932,12 +928,41 @@ if (config.handoff.maxDepth > 0 && config.handoff.maxPerRun > 0) {
};
/*
- * ONE SWEEP AT A TIME ON THIS REPLICA. See repeatAfterEach: an interval would start another sweep
- * every two seconds while a five-minute delivery runs, each claiming a different batch, and this
- * replica's concurrent agent runs would grow with the backlog rather than stopping at the limit
- * it was asked for.
+ * ONE SWEEP AT A TIME ON THIS REPLICA, from both callers below. A sweep poked while one is
+ * running is remembered rather than started, and runs once the current one ends — a wake-up
+ * that arrived mid-sweep may be for a hop the running sweep's claim already missed.
*/
- repeatAfterEach(sweep, 2_000);
+ let sweeping = false;
+ let sweepAgain = false;
+ const kick = async () => {
+ if (sweeping) {
+ sweepAgain = true;
+ return;
+ }
+ sweeping = true;
+ try {
+ do {
+ sweepAgain = false;
+ await sweep();
+ } while (sweepAgain);
+ } finally {
+ sweeping = false;
+ }
+ };
+
+ /*
+ * Woken by the queue itself, from any replica: a person is waiting through every hop, and the
+ * poll below would spend up to two seconds per leg doing nothing. The poll stays as the
+ * backstop — a notification is a latency optimisation, and one lost in transit costs one
+ * interval, never the work. See repeatAfterEach for why an interval must not be used: an
+ * interval would start another sweep every two seconds while a five-minute delivery runs, each
+ * claiming a different batch, and this replica's concurrent agent runs would grow with the
+ * backlog rather than stopping at the limit it was asked for.
+ */
+ await startWorkOfferedListener(config.databaseUrl, (kind) => {
+ if (kind === HANDOFF_KIND) void kick();
+ });
+ repeatAfterEach(kick, 2_000);
}
/*
@@ -1026,6 +1051,8 @@ const app = createApp(
routineRunner,
// A person's own standing instructions: the list, and a switch to stop one.
routineStore,
+ // Where each person is in first-run onboarding, read by /api/me and written by the wizard.
+ createOnboardingStore(database),
);
/**
diff --git a/server/src/people/onboarding.ts b/server/src/people/onboarding.ts
new file mode 100644
index 00000000..90e2a571
--- /dev/null
+++ b/server/src/people/onboarding.ts
@@ -0,0 +1,64 @@
+import { eq, sql } from "drizzle-orm";
+import type { Database } from "../db/client";
+import { users } from "../db/schema";
+
+/**
+ * Where one person is in first-run onboarding.
+ *
+ * A null `completedAt` is what gates the app into /onboarding; `step` is where the wizard resumes
+ * if they left halfway through.
+ */
+export type OnboardingStatus = {
+ step: number;
+ completedAt: string | null;
+};
+
+export type OnboardingStore = {
+ status: (userId: string) => Promise;
+ setStep: (userId: string, step: number) => Promise;
+ complete: (userId: string) => Promise;
+};
+
+export function createOnboardingStore(database: Database): OnboardingStore {
+ return {
+ async status(userId) {
+ const [row] = await database
+ .select({
+ step: users.onboardingStep,
+ completedAt: users.onboardingCompletedAt,
+ })
+ .from(users)
+ .where(eq(users.id, userId))
+ .limit(1);
+
+ /*
+ * No row reads as not started rather than as an error. It should not happen — a session
+ * cannot outlive its users row (the foreign key cascades) and the single-user actor's row is
+ * seeded at startup — but a guard that throws here would take /api/me down with it.
+ */
+ return {
+ step: row?.step ?? 0,
+ completedAt: row?.completedAt ? row.completedAt.toISOString() : null,
+ };
+ },
+
+ async setStep(userId, step) {
+ await database
+ .update(users)
+ .set({ onboardingStep: step, updatedAt: new Date() })
+ .where(eq(users.id, userId));
+ },
+
+ async complete(userId) {
+ await database
+ .update(users)
+ .set({
+ // Idempotent: finishing again keeps the first timestamp, so "when did they onboard"
+ // stays one answer.
+ onboardingCompletedAt: sql`coalesce(${users.onboardingCompletedAt}, now())`,
+ updatedAt: new Date(),
+ })
+ .where(eq(users.id, userId));
+ },
+ };
+}
diff --git a/server/src/work/queue.ts b/server/src/work/queue.ts
index 82249791..8676a9c1 100644
--- a/server/src/work/queue.ts
+++ b/server/src/work/queue.ts
@@ -16,9 +16,53 @@
* from under the first. Both then ran it. Every time this file names a moment it names it in SQL.
*/
import { and, eq, gte, isNull, like, lt, or, sql } from "drizzle-orm";
+import postgres from "postgres";
import type { Database } from "../db/client";
import { workItems } from "../db/schema";
+/**
+ * Said aloud when work is queued, so a sweep can start now instead of at its next poll.
+ *
+ * The payload is the kind, and nothing more: a listener decides whether it sweeps that kind at
+ * all, and the queue stays the truth either way. A notification is a latency optimisation, never
+ * a delivery mechanism — one lost in transit costs up to one poll interval, not the work.
+ */
+export const WORK_OFFERED_TOPIC = "openbot_work_offered";
+
+/** Inside the offering transaction where there is one, so it fires on commit and never before. */
+const announceOffered = (db: Pick, kind: string) =>
+ db.execute(sql`select pg_notify(${WORK_OFFERED_TOPIC}, ${kind})`);
+
+export type WorkOfferedListener = { stop: () => Promise };
+
+/**
+ * Hear work being offered, from any instance, including this one.
+ *
+ * On its own connection, because `LISTEN` holds one for the life of the subscription: taken from
+ * the pool, it would be a connection the rest of the server never gets back.
+ */
+export async function startWorkOfferedListener(
+ databaseUrl: string,
+ onOffered: (kind: string) => void,
+): Promise {
+ const connection = postgres(databaseUrl, { max: 1 });
+
+ await connection.listen(WORK_OFFERED_TOPIC, (payload) => {
+ try {
+ onOffered(payload);
+ } catch {
+ // A listener's mistake is not a reason to tear down the subscription: the poll behind it is
+ // still correct, and the next sweep picks up whatever this wake-up would have.
+ }
+ });
+
+ return {
+ stop: async () => {
+ await connection.end();
+ },
+ };
+}
+
export type WorkItem = {
kind: string;
key: string;
@@ -178,6 +222,7 @@ export function createWorkQueue(database: Database): WorkQueue {
if (!atMost) {
const [written] = await write(database);
+ if (written) await announceOffered(database, kind);
return written ? "queued" : "already";
}
@@ -215,6 +260,7 @@ export function createWorkQueue(database: Database): WorkQueue {
if (already.length > 0) return "already";
if ((row?.total ?? 0) >= atMost.max) return "refused";
const [written] = await write(transaction as unknown as Database);
+ if (written) await announceOffered(transaction, kind);
return written ? "queued" : "already";
});
},
diff --git a/server/tests/agent-handoff-delivery.test.ts b/server/tests/agent-handoff-delivery.test.ts
index 7252c6d7..6f364e28 100644
--- a/server/tests/agent-handoff-delivery.test.ts
+++ b/server/tests/agent-handoff-delivery.test.ts
@@ -64,7 +64,7 @@ function delivery(
agentFor: async () => agent,
history: async () => options.history ?? PRIOR,
newRunId: () => "run-2",
- answerIn: async () => ({ threadId: "answer-thread" }),
+ mintThreadId: () => "scratch-thread",
lock: {
acquire: async () => {
lockCalls.push("acquire");
@@ -133,7 +133,7 @@ describe("turning a hop into a turn", () => {
openbotRun: "signed-assertion",
});
// The addressed Bot's own conversation, because a thread has exactly one agent.
- expect(requests[0]?.threadId).toBe("answer-thread");
+ expect(requests[0]?.threadId).toBe("scratch-thread");
});
/*
@@ -229,13 +229,14 @@ describe("holding the conversation while a Bot answers", () => {
});
/**
- * Where an answer can land, which the platform decides rather than this code.
+ * Where the turn can run, which the platform decides rather than this code.
*
* An Intelligence thread is owned by exactly one agent. A second Bot answering inside the first
- * Bot's conversation is refused however it asks, so the answer goes where that Bot can speak.
+ * Bot's conversation is refused however it asks, so its turn runs in a scratch thread of its own
+ * and the words come home through the relay.
*/
-describe("which conversation the answer lands in", () => {
- test("the addressed Bot's own, not the one that asked", async () => {
+describe("which conversation the turn runs in", () => {
+ test("a scratch thread of the addressed Bot's own, not the one that asked", async () => {
const { delivery: deliver, requests } = delivery(FINISHED);
await deliver.deliver({
@@ -245,8 +246,8 @@ describe("which conversation the answer lands in", () => {
assertion: "s",
});
- expect(requests[0]?.threadId).toBe("answer-thread");
- expect(requests[0]?.input.threadId).toBe("answer-thread");
+ expect(requests[0]?.threadId).toBe("scratch-thread");
+ expect(requests[0]?.input.threadId).toBe("scratch-thread");
});
test("but it reads the conversation that asked", async () => {
@@ -330,7 +331,7 @@ describe("a delivery that never finishes", () => {
agentFor: async () => stubAgent(),
history: async () => PRIOR,
newRunId: () => "run-2",
- answerIn: async () => ({ threadId: "answer-thread" }),
+ mintThreadId: () => "scratch-thread",
lock: {
acquire: async () => ({ runId: "platform-run" }),
renew: async () => {},
@@ -360,7 +361,7 @@ describe("a delivery that never finishes", () => {
});
// Not `thread-1`, which is the conversation that ASKED and whose lock this run never held.
- expect(released).toEqual(["answer-thread"]);
+ expect(released).toEqual(["scratch-thread"]);
});
});
@@ -427,11 +428,239 @@ describe("what the addressed Bot is actually given", () => {
expect(given.at(-1)).toMatchObject({ role: "user", content: "the ask" });
// And it runs in its own conversation, which the agent also carries.
expect((agent as unknown as { threadId: string }).threadId).toBe(
- "answer-thread",
+ "scratch-thread",
);
});
});
+/**
+ * Lighting the asking channel while a hop runs.
+ *
+ * A forward hop runs in a scratch thread nobody watches, so it signals the asking channel itself. A
+ * backwards hop runs in the asking thread, whose lock the runtime already watches, so it must not
+ * signal again — that would double the indicator on and off.
+ */
+describe("the working indicator", () => {
+ function withBusy() {
+ const busy: Array<{ threadId: string; busy: boolean }> = [];
+ const deliver = createHandoffDelivery({
+ agentFor: async () => stubAgent(),
+ history: async () => PRIOR,
+ newRunId: () => "run-2",
+ mintThreadId: () => "scratch-thread",
+ setBusy: async (input) => {
+ busy.push(input);
+ },
+ lock: {
+ acquire: async () => ({ runId: "platform-run" }),
+ renew: async () => {},
+ release: async () => {},
+ },
+ runner: {
+ run: () =>
+ new Observable((subscriber) => {
+ for (const event of FINISHED) subscriber.next(event);
+ subscriber.complete();
+ }),
+ },
+ });
+ return { busy, deliver };
+ }
+
+ test("a forward hop lights the asking channel, on then off", async () => {
+ const { busy, deliver } = withBusy();
+
+ await deliver.deliver({
+ work: WORK,
+ message: "m",
+ shown: "s",
+ assertion: "s",
+ });
+
+ // Keyed on the asking thread, not the scratch thread the run happens in.
+ expect(busy).toEqual([
+ { threadId: "thread-1", busy: true },
+ { threadId: "thread-1", busy: false },
+ ]);
+ });
+
+ test("a backwards hop leaves the indicator to the runtime lock", async () => {
+ const { busy, deliver } = withBusy();
+
+ await deliver.deliver({
+ work: { ...WORK, answerIn: "thread-1" },
+ message: "m",
+ assertion: "s",
+ });
+
+ expect(busy).toEqual([]);
+ });
+});
+
+/**
+ * What the Bot said, gathered from the stream.
+ *
+ * The runner publishes the turn to the platform rather than back through the observable, and the
+ * scratch thread it lands in is never shown to anybody: the events going past are the one chance to
+ * hear the answer, and the resolved value is the only copy the relay — and therefore the person —
+ * will ever get.
+ */
+describe("what comes back for the relay", () => {
+ /** An agent whose run emits the given events through the delivery's own onEvent hook. */
+ function talkingAgent(
+ events: Array<{ type: string; delta?: string }>,
+ ): AbstractAgent {
+ const agent = {
+ threadId: "",
+ messages: [] as unknown[],
+ setMessages(messages: unknown[]) {
+ agent.messages = messages;
+ },
+ runAgent: (
+ _input: unknown,
+ config?: { onEvent?: (emitted: unknown) => void },
+ ) => {
+ for (const event of events) config?.onEvent?.({ event });
+ return Promise.resolve();
+ },
+ };
+ return agent as unknown as AbstractAgent;
+ }
+
+ /** A runner that drives the agent the way the real one does, then completes. */
+ function throughTheAgent(agent: AbstractAgent) {
+ return createHandoffDelivery({
+ agentFor: async () => agent,
+ history: async () => PRIOR,
+ newRunId: () => "run-2",
+ mintThreadId: () => "scratch-thread",
+ lock: {
+ acquire: async () => ({ runId: "platform-run" }),
+ renew: async () => {},
+ release: async () => {},
+ },
+ runner: {
+ run: (request) => {
+ void (
+ request.agent as unknown as {
+ runAgent: (input: unknown, config?: unknown) => Promise;
+ }
+ ).runAgent({}, {});
+ return new Observable((subscriber) => {
+ for (const event of FINISHED) subscriber.next(event);
+ subscriber.complete();
+ });
+ },
+ },
+ });
+ }
+
+ test("the words the Bot said resolve out of the delivery", async () => {
+ const agent = talkingAgent([
+ { type: "TEXT_MESSAGE_START" },
+ { type: "TEXT_MESSAGE_CONTENT", delta: "The outage " },
+ { type: "TEXT_MESSAGE_CONTENT", delta: "was Tuesday." },
+ { type: "TEXT_MESSAGE_END" },
+ ]);
+
+ const { answer } = await throughTheAgent(agent).deliver({
+ work: WORK,
+ message: "m",
+ shown: "s",
+ assertion: "s",
+ });
+
+ expect(answer).toBe("The outage was Tuesday.");
+ });
+
+ test("a turn that said several things carries all of them", async () => {
+ const agent = talkingAgent([
+ { type: "TEXT_MESSAGE_START" },
+ { type: "TEXT_MESSAGE_CONTENT", delta: "Looking now." },
+ { type: "TEXT_MESSAGE_END" },
+ { type: "TEXT_MESSAGE_START" },
+ { type: "TEXT_MESSAGE_CONTENT", delta: "Found it: Tuesday." },
+ { type: "TEXT_MESSAGE_END" },
+ ]);
+
+ const { answer } = await throughTheAgent(agent).deliver({
+ work: WORK,
+ message: "m",
+ shown: "s",
+ assertion: "s",
+ });
+
+ expect(answer).toBe("Looking now.\n\nFound it: Tuesday.");
+ });
+
+ test("a turn that spoke announces itself, where the wiring can find a channel", async () => {
+ const announced: Array<{
+ threadId: string;
+ agentId: string;
+ text: string;
+ }> = [];
+ const agent = talkingAgent([
+ { type: "TEXT_MESSAGE_START" },
+ { type: "TEXT_MESSAGE_CONTENT", delta: "Tuesday." },
+ { type: "TEXT_MESSAGE_END" },
+ ]);
+ const deliver = createHandoffDelivery({
+ agentFor: async () => agent,
+ history: async () => PRIOR,
+ newRunId: () => "run-2",
+ mintThreadId: () => "scratch-thread",
+ announce: async ({ threadId, agentId, text }) => {
+ announced.push({ threadId, agentId, text });
+ },
+ lock: {
+ acquire: async () => ({ runId: "platform-run" }),
+ renew: async () => {},
+ release: async () => {},
+ },
+ runner: {
+ run: (request) => {
+ void (
+ request.agent as unknown as {
+ runAgent: (input: unknown, config?: unknown) => Promise;
+ }
+ ).runAgent({}, {});
+ return new Observable((subscriber) => {
+ for (const event of FINISHED) subscriber.next(event);
+ subscriber.complete();
+ });
+ },
+ },
+ });
+
+ // A backwards hop: the relay, landing where the person is watching.
+ await deliver.deliver({
+ work: { ...WORK, answerIn: "thread-1" },
+ message: "m",
+ assertion: "s",
+ });
+
+ expect(announced).toEqual([
+ { threadId: "thread-1", agentId: WORK.toBotId, text: "Tuesday." },
+ ]);
+ });
+
+ test("a turn of nothing but tool calls has nothing to carry", async () => {
+ const agent = talkingAgent([
+ { type: "TOOL_CALL_START" },
+ { type: "TOOL_CALL_END" },
+ ]);
+
+ const { answer } = await throughTheAgent(agent).deliver({
+ work: WORK,
+ message: "m",
+ shown: "s",
+ assertion: "s",
+ });
+
+ expect(answer).toBeNull();
+ });
+});
+
/**
* A message is not always a string.
*
diff --git a/server/tests/agent-handoff-endtoend.integration.test.ts b/server/tests/agent-handoff-endtoend.integration.test.ts
index f4cd49f5..86584ef7 100644
--- a/server/tests/agent-handoff-endtoend.integration.test.ts
+++ b/server/tests/agent-handoff-endtoend.integration.test.ts
@@ -146,6 +146,7 @@ describe("a hop, from the tool call to the delivery", () => {
delivery: {
deliver: async ({ work, message }) => {
delivered.push({ work, message });
+ return { answer: null };
},
},
});
@@ -195,6 +196,7 @@ describe("a hop, from the tool call to the delivery", () => {
delivery: {
deliver: async ({ work }) => {
seen.push(work.toBotId);
+ return { answer: null };
},
},
}),
@@ -231,7 +233,7 @@ describe("a hop, from the tool call to the delivery", () => {
owner: `replica-${suite}`,
sign: () => "signed",
auditStore,
- delivery: { deliver: async () => {} },
+ delivery: { deliver: async () => ({ answer: null }) },
});
await runner.sweep();
diff --git a/server/tests/agent-handoff-runner.integration.test.ts b/server/tests/agent-handoff-runner.integration.test.ts
index 91a76045..5abad448 100644
--- a/server/tests/agent-handoff-runner.integration.test.ts
+++ b/server/tests/agent-handoff-runner.integration.test.ts
@@ -85,6 +85,7 @@ describe("a batch of hops and a lease that can run out", () => {
deliver: async ({ work }) => {
ran.push(`a:${work.toBotId}`);
if (work.toBotId === "bot-1") await held.promise;
+ return { answer: null };
},
},
});
@@ -94,6 +95,7 @@ describe("a batch of hops and a lease that can run out", () => {
delivery: {
deliver: async ({ work }) => {
ran.push(`b:${work.toBotId}`);
+ return { answer: null };
},
},
});
@@ -140,6 +142,7 @@ describe("a batch of hops and a lease that can run out", () => {
deliver: async ({ work }) => {
ran.push(work.toBotId);
if (work.toBotId === "bot-1") await held.promise;
+ return { answer: null };
},
},
});
diff --git a/server/tests/agent-handoff-runner.test.ts b/server/tests/agent-handoff-runner.test.ts
index fa7c726e..61c5c56e 100644
--- a/server/tests/agent-handoff-runner.test.ts
+++ b/server/tests/agent-handoff-runner.test.ts
@@ -31,6 +31,8 @@ function runner(options?: {
message: string;
shown?: string;
}) => Promise;
+ /** What the delivery says the Bot answered. Null — nothing worth relaying — unless a test cares. */
+ answer?: string | null;
}) {
const calls: Array<{ verb: string; key: string; owner?: string }> = [];
const events: string[] = [];
@@ -92,6 +94,7 @@ function runner(options?: {
deliver: async ({ work, message, shown, assertion }) => {
delivered.push({ message, assertion });
await options?.deliver?.({ work, message, shown });
+ return { answer: options?.answer ?? null };
},
},
}),
@@ -342,6 +345,89 @@ describe("a hop that failed for good", () => {
});
});
+/**
+ * The answer coming home.
+ *
+ * The addressed Bot's turn runs in a scratch thread nobody is shown, so its words reach the person
+ * one way: a backwards hop that runs the asking Bot, in the conversation being watched, with the
+ * answer in its prompt. Attributed by the deployment, in the asking Bot's voice — the only voice
+ * that thread admits.
+ */
+describe("relaying the answer home", () => {
+ test("a delivered hop sends the answer back through the Bot that asked", async () => {
+ const { runner: sweeper, offered } = runner({
+ answer: "The outage was Tuesday, 02:10 to 02:45.",
+ });
+
+ await sweeper.sweep();
+
+ expect(offered).toHaveLength(1);
+ expect(offered[0]).toMatchObject({
+ fromBotId: "researcher",
+ toBotId: "assistant",
+ answerIn: "thread-1",
+ threadId: "thread-1",
+ depth: 1,
+ });
+ expect(offered[0]?.task).toContain("find the outage window");
+ expect(offered[0]?.task).toContain(
+ "The outage was Tuesday, 02:10 to 02:45.",
+ );
+ });
+
+ test("its key is outside the run's own prefix, like the notice", async () => {
+ const { runner: sweeper, calls } = runner({ answer: "Tuesday." });
+
+ await sweeper.sweep();
+
+ const key = calls.find((call) => call.verb === "offer")?.key ?? "";
+ expect(key.startsWith("run-1:")).toBe(false);
+ expect(key).toContain("run-1:abc");
+ });
+
+ /* A relay of a relay is a loop. The `answerIn` marker that stops a notice stops this too. */
+ test("a relay is not itself relayed", async () => {
+ const { runner: sweeper, offered } = runner({
+ claimed: [
+ {
+ kind: "bot.message",
+ key: "relay:run-1:abc",
+ payload: { ...WORK, answerIn: "thread-1" },
+ attempts: 1,
+ },
+ ] as unknown as WorkItem[],
+ answer: "Understood, telling them now.",
+ });
+
+ await sweeper.sweep();
+
+ expect(offered).toEqual([]);
+ });
+
+ test("a turn that said nothing sends nothing home", async () => {
+ const { runner: sweeper, offered } = runner({ answer: null });
+
+ await sweeper.sweep();
+
+ expect(offered).toEqual([]);
+ });
+
+ /*
+ * The answer rides inside the relaying run's prompt, and a Bot that came back with a book would
+ * spend that run's whole context window repeating it.
+ */
+ test("an answer the length of a book is clipped, and says so", async () => {
+ const { runner: sweeper, offered } = runner({
+ answer: "x".repeat(20_000),
+ });
+
+ await sweeper.sweep();
+
+ expect(offered[0]?.task).toContain("[…the answer was cut here for length]");
+ expect((offered[0]?.task ?? "").length).toBeLessThan(14_000);
+ });
+});
+
/**
* What a notice leaves in the transcript.
*
diff --git a/server/tests/guards.test.ts b/server/tests/guards.test.ts
index d4130d03..335c1de6 100644
--- a/server/tests/guards.test.ts
+++ b/server/tests/guards.test.ts
@@ -70,6 +70,8 @@ describe("server authorization", () => {
name: "OpenBot Member",
image: "https://example.test/member.png",
role: "user",
+ // No store was passed, so this deployment tracks no onboarding and the app gates nobody.
+ onboarding: null,
},
});
});
diff --git a/server/tests/onboarding-routes.test.ts b/server/tests/onboarding-routes.test.ts
new file mode 100644
index 00000000..451be8d8
--- /dev/null
+++ b/server/tests/onboarding-routes.test.ts
@@ -0,0 +1,157 @@
+import { describe, expect, test } from "bun:test";
+import { createApp } from "../src/app";
+import { loadConfig } from "../src/config";
+import type { OnboardingStore } from "../src/people/onboarding";
+import { testEnvironment } from "./support/environment";
+
+const MEMBER = {
+ id: "member-1",
+ email: "member@openbot.test",
+ name: "A Member",
+ image: null,
+};
+
+/**
+ * The wizard's server half: /api/me carries where somebody is, and one POST moves them.
+ *
+ * The rules worth pinning are the shapes — what the app gates on has to keep meaning what it meant,
+ * and a body the route does not understand has to be refused rather than written.
+ */
+function appWith(store?: OnboardingStore) {
+ return createApp(
+ loadConfig(testEnvironment()),
+ {
+ handler: () => new Response(null, { status: 204 }),
+ api: { getSession: async () => ({ user: MEMBER }) },
+ } as never,
+ { rolesForUser: async () => ["user"] },
+ /*
+ * Positions 4-23 are the other stores; `store` is 24, onboardingStore, the signature's last.
+ * Every parameter from 4 on is optional, so a wrong count is a silent type-check pass — see
+ * people-routes.test.ts, which learned this the hard way.
+ */
+ ...(Array.from({ length: 20 }) as never[]),
+ store as never,
+ );
+}
+
+/** A store holding one person's status in memory, with the same coalesce rule the real one has. */
+function memoryStore(initial: { step: number; completedAt: string | null }) {
+ const state = { ...initial };
+ const store: OnboardingStore = {
+ status: async () => ({ ...state }),
+ setStep: async (_userId, step) => {
+ state.step = step;
+ },
+ complete: async () => {
+ state.completedAt = state.completedAt ?? "2026-08-27T00:00:00.000Z";
+ },
+ };
+ return { store, state };
+}
+
+describe("onboarding routes", () => {
+ test("/api/me carries the person's onboarding status", async () => {
+ const { store } = memoryStore({ step: 1, completedAt: null });
+ const app = appWith(store);
+
+ const response = await app.request("http://openbot.local/api/me");
+
+ expect(response.status).toBe(200);
+ const body = (await response.json()) as {
+ user: { onboarding: { step: number; completedAt: string | null } };
+ };
+ expect(body.user.onboarding).toEqual({ step: 1, completedAt: null });
+ });
+
+ test("/api/me reports null onboarding when the deployment has no store", async () => {
+ const app = appWith(undefined);
+
+ const response = await app.request("http://openbot.local/api/me");
+
+ expect(response.status).toBe(200);
+ const body = (await response.json()) as { user: { onboarding: null } };
+ expect(body.user.onboarding).toBeNull();
+ });
+
+ test("a step moves the person and answers with where they are now", async () => {
+ const { store, state } = memoryStore({ step: 0, completedAt: null });
+ const app = appWith(store);
+
+ const response = await app.request(
+ "http://openbot.local/api/me/onboarding",
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ step: 2 }),
+ },
+ );
+
+ expect(response.status).toBe(200);
+ expect(state.step).toBe(2);
+ await expect(response.json()).resolves.toEqual({
+ onboarding: { step: 2, completedAt: null },
+ });
+ });
+
+ test("completed: true stamps the completion", async () => {
+ const { store, state } = memoryStore({ step: 2, completedAt: null });
+ const app = appWith(store);
+
+ const response = await app.request(
+ "http://openbot.local/api/me/onboarding",
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ completed: true }),
+ },
+ );
+
+ expect(response.status).toBe(200);
+ expect(state.completedAt).not.toBeNull();
+ const body = (await response.json()) as {
+ onboarding: { completedAt: string | null };
+ };
+ expect(body.onboarding.completedAt).toBe(state.completedAt);
+ });
+
+ test.each([
+ [{}],
+ [{ step: -1 }],
+ [{ step: 1.5 }],
+ [{ step: "2" }],
+ [{ completed: false }],
+ // Past the column's range: written as sent, this would be a database error, not a bad request.
+ [{ step: 2_147_483_648 }],
+ ])("refuses a body it does not understand: %j", async (body) => {
+ const { store, state } = memoryStore({ step: 0, completedAt: null });
+ const app = appWith(store);
+
+ const response = await app.request(
+ "http://openbot.local/api/me/onboarding",
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify(body),
+ },
+ );
+
+ expect(response.status).toBe(400);
+ expect(state).toEqual({ step: 0, completedAt: null });
+ });
+
+ test("answers 503 rather than pretending when there is no store", async () => {
+ const app = appWith(undefined);
+
+ const response = await app.request(
+ "http://openbot.local/api/me/onboarding",
+ {
+ method: "POST",
+ headers: { "content-type": "application/json" },
+ body: JSON.stringify({ completed: true }),
+ },
+ );
+
+ expect(response.status).toBe(503);
+ });
+});