Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,7 +145,7 @@ A Bot is any endpoint speaking [AG-UI](https://github.com/ag-ui-protocol/ag-ui),
- **Bring your own agent**: any AG-UI endpoint is a Bot, on a framework or hand written. Endpoints are validated with the same target checks used for browser navigation, and an auth header is stored write-only.
- **Components instead of prose**: compiled React components live in `app/src/components/gallery/`, sandboxed ones are authored in `/admin/playground` and published with no deployment. Every call asks the server whether the component exists, is published, and is not withheld from that Bot. Data functions are granted per component.
- **Governed MCP with OAuth**: a curated catalogue ships for Atlassian, Box, Slack, Salesforce and ServiceNow. Servers can use a write-only bearer token or OAuth discovery, dynamic registration and PKCE; encrypted tokens, grants, policy, approvals and audit stay server-side.
- **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer.
- **Skills are instructions, not capabilities**: personal skills attach only to Bots their author owns, deployment skills are admin-owned, and both are invoked with `/` in the composer. Authors can record which connected tools a skill expects without granting access to any of them.
- **Durable work control center**: `/work` combines queued and active runs, reusable manual/scheduled/webhook routines, user-to-Bot and Bot-to-Bot handoffs, shared project artifacts, and actionable notifications. A leased executor adds heartbeats, bounded attempts, timeout budgets, retries and crash recovery.
- **Governed autonomy without a second runtime**: proactive monitors stay quiet when nothing needs attention; paired Telegram, Slack, Discord, and signed webhook messages use the ordinary task queue and a retrying outbox; skill and memory learning stays in review; transient model failures can follow a visible fallback route; and large tool catalogs/results are bounded without bypassing grants or policy.
- **Shared project teams**: assign several coworkers to a project, open one team channel, and explicitly choose which coworker answers each turn. Computers, credentials, and browser sessions remain isolated per Bot.
Expand Down
8 changes: 8 additions & 0 deletions app/index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,14 @@
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>OpenBot</title>
<!-- Set the saved theme before the browser paints; a module or deferred script would be too late. -->
<script>
try {
const dark = window.localStorage.getItem("openbot-theme") === "dark";
document.documentElement.classList.toggle("dark", dark);
document.documentElement.style.colorScheme = dark ? "dark" : "light";
} catch {}
</script>
</head>
<body>
<div id="root"></div>
Expand Down
1 change: 1 addition & 0 deletions app/src/components/skills/edit-skill.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -79,6 +79,7 @@ export function EditSkill({ slug }: { slug: string }) {
title: skill.title,
summary: skill.summary ?? "",
instructions: skill.instructions,
tools: skill.tools ?? [],
}}
error={saveSkill.error}
/*
Expand Down
18 changes: 14 additions & 4 deletions app/src/components/skills/skill-fields.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { useForm } from "@tanstack/react-form";
import { SkillTools } from "@/components/skills/skill-tools";
import { Button } from "@/components/ui/button";
import {
Field,
Expand All @@ -11,12 +12,12 @@ import { Textarea } from "@/components/ui/textarea";
import { type SkillFormValues, skillFormSchema } from "@/lib/skills/form";

/**
* The four things a person decides about a skill: what they type, what it is called, what it is
* for, and what the Bot is actually told.
* The things a person decides about a skill: its command, name, purpose, instructions, and the
* connected tools it expects.
*
* Written as its own component rather than inline in the panel because creating and editing a skill
* are the same four fields, and the second one is coming — a skill whose instructions can only be
* set once is a skill nobody will correct.
* share the same fields — a skill whose instructions can only be set once is a skill nobody will
* correct.
*/
export function SkillFields({
defaultValues,
Expand Down Expand Up @@ -188,6 +189,15 @@ export function SkillFields({
);
}}
</form.Field>

<form.Field name="tools">
{(field) => (
<SkillTools
onChange={field.handleChange}
selected={field.state.value}
/>
)}
</form.Field>
</FieldGroup>

{/*
Expand Down
123 changes: 123 additions & 0 deletions app/src/components/skills/skill-tools.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
import { useQuery } from "@tanstack/react-query";
import { Button } from "@/components/ui/button";
import { Field, FieldLabel } from "@/components/ui/field";
import { pluginsPageQueryOptions } from "@/lib/plugins/queries";
import { declaredElsewhere } from "@/lib/skills/form";

/**
* Tool requirements belong to the saved skill, but never widen a Bot's grants.
*/
export function SkillTools({
selected,
onChange,
}: {
selected: string[];
onChange: (refs: string[]) => void;
}) {
const plugins = useQuery(pluginsPageQueryOptions());
const held = new Set(selected);
const servers = (plugins.data?.servers ?? []).filter(
(server) => server.tools.length > 0,
);
const missing = plugins.data
? declaredElsewhere(
selected,
servers.flatMap((server) => server.tools.map((tool) => tool.ref)),
)
: [];

const toggle = (ref: string) => {
onChange(
held.has(ref)
? selected.filter((candidate) => candidate !== ref)
: [...selected, ref],
);
};

return (
<Field>
<FieldLabel>Tools this skill expects</FieldLabel>

{plugins.isPending ? (
<p className="text-muted-foreground text-xs" role="status">
Loading connected tools…
</p>
) : plugins.error ? (
<p className="text-destructive text-xs" role="alert">
Connected tools could not be loaded.
</p>
) : servers.length === 0 ? (
<p className="text-muted-foreground text-xs">
No connected server currently offers tools. Most skills only need
instructions, so you can still save this one.
</p>
) : (
<div className="flex flex-col gap-3">
{servers.map((server) => (
<div className="flex flex-col gap-1.5" key={server.id}>
<p className="text-muted-foreground text-xs">{server.title}</p>
<div className="flex flex-wrap gap-2">
{server.tools.map((tool) => {
const selectedTool = held.has(tool.ref);
return (
<Button
aria-pressed={selectedTool}
key={tool.ref}
onClick={() => toggle(tool.ref)}
size="sm"
title={tool.description}
type="button"
variant={selectedTool ? "default" : "outline"}
>
{tool.name}
{tool.effect === "write" ? (
<span
aria-label="changes data"
className="ml-1 opacity-60"
role="img"
>
</span>
) : null}
</Button>
);
})}
</div>
</div>
))}
</div>
)}

{missing.length > 0 ? (
<div className="flex flex-col gap-1.5">
<p className="text-muted-foreground text-xs">Not connected here</p>
<div className="flex flex-wrap gap-2">
{missing.map((ref) => (
<Button
aria-pressed={true}
key={ref}
onClick={() => toggle(ref)}
size="sm"
title={`${ref} — no connected server currently offers this tool`}
type="button"
variant="secondary"
>
{ref}
</Button>
))}
</div>
<p className="text-muted-foreground text-xs">
These saved requirements are unavailable because a connector is
missing or stopped offering the tool. Click one to remove it.
</p>
</div>
) : null}

<p className="text-muted-foreground text-xs">
This records what the skill needs. It does not grant access: the Bot can
only call tools it was already given, and every call still passes policy
and audit checks.
</p>
</Field>
);
}
24 changes: 20 additions & 4 deletions app/src/components/theme-provider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,15 +19,31 @@ type ThemeContextValue = {
const ThemeContext = createContext<ThemeContextValue | null>(null);

export function ThemeProvider({ children }: { children: ReactNode }) {
const [dark, setDark] = useState(() =>
parseStoredDarkTheme(window.localStorage.getItem(THEME_STORAGE_KEY)),
);
const [dark, setDark] = useState(() => {
try {
return parseStoredDarkTheme(
window.localStorage.getItem(THEME_STORAGE_KEY),
);
} catch {
// Storage can be unavailable in privacy modes. The light default still leaves the app usable.
return false;
}
});

useEffect(() => {
applyDarkTheme(dark, {
setStoredValue: (key, value) => window.localStorage.setItem(key, value),
setStoredValue: (key, value) => {
try {
window.localStorage.setItem(key, value);
} catch {
// Applying the live theme matters even when the preference cannot be persisted.
}
},
toggleRootClass: (name, force) =>
document.documentElement.classList.toggle(name, force),
setRootColorScheme: (scheme) => {
document.documentElement.style.colorScheme = scheme;
},
});
}, [dark]);

Expand Down
88 changes: 71 additions & 17 deletions app/src/lib/copilot/bot-thread.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";

/**
* The thread the direct Bot chat talks in.
Expand All @@ -13,9 +13,14 @@ import { useEffect, useState } from "react";

const KEY = "openbot.bot-thread";

/** One construction point keeps reads and writes isolated per Bot. */
export function botThreadKey(agentId: string): string {
return `${KEY}.${agentId}`;
}

function remembered(agentId: string): string | null {
try {
return window.localStorage.getItem(`${KEY}.${agentId}`);
return window.localStorage.getItem(botThreadKey(agentId));
} catch {
// Storage can be unavailable or full. A thread for this visit is better than no chat at all.
return null;
Expand All @@ -24,7 +29,7 @@ function remembered(agentId: string): string | null {

function remember(agentId: string, threadId: string): void {
try {
window.localStorage.setItem(`${KEY}.${agentId}`, threadId);
window.localStorage.setItem(botThreadKey(agentId), threadId);
} catch {
// As above: the conversation still works, it just will not be here next time.
}
Expand All @@ -48,32 +53,81 @@ async function mint(): Promise<string | null> {
* `undefined` until it is known, which is not the same as absent: rendering the chat before then
* would let it mint an id of its own, and that is the one this deployment would then be stuck with.
*/
export function useBotThread(agentId: string): string | undefined {
export type BotThread = {
threadId: string | undefined;
/** Starts a separate conversation without deleting the one currently stored upstream. */
startNew: () => Promise<boolean>;
startingNew: boolean;
};

export function useBotThread(agentId: string): BotThread {
const [threadId, setThreadId] = useState<string | undefined>(undefined);
const [startingNew, setStartingNew] = useState(false);
const requestRef = useRef(0);
const mintingRef = useRef(false);
const mountedRef = useRef(true);

useEffect(() => {
let current = true;
mountedRef.current = true;
const request = requestRef.current + 1;
requestRef.current = request;
setThreadId(undefined);
setStartingNew(false);
mintingRef.current = false;

const existing = remembered(agentId);
if (existing) {
setThreadId(existing);
return;
} else {
mintingRef.current = true;
setStartingNew(true);
void mint()
.then((minted) => {
if (!mountedRef.current || requestRef.current !== request) return;
// Falling back to one made here keeps the chat working when the deployment cannot be asked;
// it is simply a thread nothing can later attribute.
const next = minted ?? crypto.randomUUID();
if (minted) remember(agentId, minted);
setThreadId(next);
})
.finally(() => {
if (!mountedRef.current || requestRef.current !== request) return;
mintingRef.current = false;
setStartingNew(false);
});
}

void mint().then((minted) => {
if (!current) return;
// Falling back to one made here keeps the chat working when the deployment cannot be asked;
// it is simply a thread nothing can later attribute.
const next = minted ?? crypto.randomUUID();
if (minted) remember(agentId, minted);
setThreadId(next);
});

return () => {
current = false;
mountedRef.current = false;
if (requestRef.current !== request) return;
requestRef.current += 1;
mintingRef.current = false;
};
}, [agentId]);

return threadId;
const startNew = useCallback(async () => {
if (mintingRef.current) return false;

const request = requestRef.current + 1;
requestRef.current = request;
mintingRef.current = true;
setStartingNew(true);
try {
const minted = await mint();
if (!minted || !mountedRef.current || requestRef.current !== request) {
return false;
}

remember(agentId, minted);
setThreadId(minted);
return true;
} finally {
if (mountedRef.current && requestRef.current === request) {
mintingRef.current = false;
setStartingNew(false);
}
}
}, [agentId]);

return { threadId, startNew, startingNew };
}
3 changes: 3 additions & 0 deletions app/src/lib/plugins/queries.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,8 @@ export type PluginSkill = {
origin: string;
installedBy: string | null;
grantedTo: string[];
/** Tools the skill expects as `<serverId>/<toolName>` refs; this grants no access. */
tools: string[];
};

export type CatalogueItem = {
Expand Down Expand Up @@ -83,6 +85,7 @@ export type GrantedPlugins = {
title: string;
summary: string;
instructions: string;
tools: string[];
}[];
};

Expand Down
12 changes: 12 additions & 0 deletions app/src/lib/skills/form.ts
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@ export const skillFormSchema = z.object({
.string()
.trim()
.min(1, "Instructions are required — this is what the Bot follows."),
/** Tool requirements are declarations. The server remains authoritative for valid refs. */
tools: z.array(z.string()),
});

export type SkillFormValues = z.infer<typeof skillFormSchema>;
Expand All @@ -43,4 +45,14 @@ export const emptySkillForm: SkillFormValues = {
title: "",
summary: "",
instructions: "",
tools: [],
};

/** Declarations that are not offered by any currently connected server. */
export function declaredElsewhere(
selected: readonly string[],
offered: readonly string[],
): string[] {
const known = new Set(offered);
return selected.filter((ref) => !known.has(ref));
}
Loading
Loading