Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
22 commits
Select commit Hold shift + click to select a range
52763bb
Record who may give an app to a Bot, and whether its owner or an admi…
guidovizoso Oct 26, 2026
dcfbd0b
Tell a Bot's owner when an app is theirs to give it, and let an admin…
guidovizoso Oct 26, 2026
919c7f0
Let a Bot's owner give it their own apps, and take back only what the…
guidovizoso Oct 26, 2026
b87271e
Keep a Team Bot on each teammate's own account for apps its owner gav…
guidovizoso Oct 26, 2026
c48fe9f
Let an admin choose on an app's page whether Bot owners can give it
guidovizoso Oct 26, 2026
8c1e8df
Ask a Bot's owner on the app card whether to let it use their app, in…
guidovizoso Oct 26, 2026
597ff8b
Give a new Bot your connected apps, and switch them on its page
guidovizoso Oct 26, 2026
a40eda6
Refuse an owner a grant on an action their app does not advertise
guidovizoso Oct 26, 2026
0e83dd3
Record nothing when an owner's grant lands on one that already stands
guidovizoso Oct 26, 2026
d864e92
Say where an owner gives a write action, in the give route's note
guidovizoso Oct 26, 2026
e4eba71
Format the owner grant refusal
guidovizoso Oct 26, 2026
5ff46b8
Merge branch 'guido/connect-card-resume' into guido/owner-app-grants
guidovizoso Oct 26, 2026
de41a3f
Merge branch 'guido/connect-card-resume' into guido/owner-app-grants
guidovizoso Oct 26, 2026
93070ca
Hold the owner's give call to an absent body, and follow the moved si…
guidovizoso Oct 26, 2026
959312c
Merge branch 'guido/connect-card-resume' into guido/owner-app-grants
guidovizoso Oct 26, 2026
70d76ea
Add a changelog line for owner app grants
guidovizoso Oct 26, 2026
3848c8f
Mark a Shared-account audience an owner's grant set, so it can be wit…
guidovizoso Oct 26, 2026
50ab1e3
Let an owner's grant set a Shared app's audience, and read it only wh…
guidovizoso Oct 26, 2026
4b6577d
Set the audience when an owner gives a Shared app, and withdraw it on…
guidovizoso Oct 26, 2026
4333122
Test an owner's Shared-app grant: usable at once, scoped to the Bot, …
guidovizoso Oct 26, 2026
f15712e
Say on a Shared app's page that owners who give it let their Bots use…
guidovizoso Oct 26, 2026
dc9690a
Document owners giving Shared apps, and add the changelog line
guidovizoso Oct 26, 2026
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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,23 @@ Newest first. `Unreleased` is what is on `main` and not yet tagged.
- Migration `0062_action_policy_labels` adds a `labels` column to `action_policies`, defaulting to
empty. Existing boundary rules keep working and show as "Custom rule" until somebody names them.

### A Bot's owner can give it their own apps

The owner of a Bot can now let it use an app they connect for themselves, without an administrator:
from the app card ("Let Research Bot use your Gmail?"), under Your apps on the Bot's page, or on the
New Bot sheet, which offers their connected apps. Calls under such a grant always use the asking
person's own account, even on a published Team Bot, and an owner can take back only what they gave.
By default this covers apps each person connects for themselves and Routines; an administrator
decides per app on its page, and can switch it off for people with the "Give apps to their own Bots"
capability. Migration `0063_owner_app_grants` adds two nullable columns, `mcp_servers.owner_grants`
and `plugin_grants.granted_as`.

Where an administrator lets owners give a Shared app, an owner's grant works straight away: it lets
the people who can use the Bot (just the owner, or a Team Bot's audience) use the team account, with
no separate approval. Writes still ask first. Taking the app back, or the administrator switching the
app back to admins only, withdraws that. Migration `0064_owner_shared_audience` adds the nullable
`shared_use_approvals.approved_as`.

### An app card carries the task on once the app is ready

When a Bot stopped because it needed an app, and the person then connected it in another tab or an
Expand Down
74 changes: 74 additions & 0 deletions app/src/components/agents/new-bot-sheet.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@ import { MotionConfig, motion, useReducedMotion } from "motion/react";
import { useEffect, useRef, useState } from "react";
import { HarnessMark } from "@/components/agents/harness-mark";
import AgentOrb from "@/components/agents/orb/agent-orb";
import { PluginLogo } from "@/components/plugins/plugin-logo";
import { Button } from "@/components/ui/button";
import { Checkbox } from "@/components/ui/checkbox";
import {
Field,
FieldDescription,
Expand Down Expand Up @@ -39,7 +41,13 @@ import {
agentCapabilitiesQueryOptions,
testAgentConnection,
} from "@/lib/agents/queries";
import { giveBotApp } from "@/lib/app-access/mutations";
import {
type GiveableApp,
giveableAppsQueryOptions,
} from "@/lib/app-access/queries";
import { isComposing } from "@/lib/composing";
import { pluginKeys } from "@/lib/plugins/queries";
import { cn } from "@/lib/utils";
import { queryClient } from "@/query-client";

Expand Down Expand Up @@ -165,6 +173,13 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) {
const [unreachable, setUnreachable] = useState<string>();
/** The Bot just made, held for the moment the sheet spends saying so before it hands over. */
const [createdId, setCreatedId] = useState<string>();
/**
* The person's own apps this Bot may use. Ticked unless the person unticks one: an app they
* connected is one they already use, and a new Bot that cannot reach any of it is no use until
* somebody finds the Bot page. Kept as what was UNticked so an app that loads late arrives ticked.
*/
const giveable = useQuery(giveableAppsQueryOptions()).data ?? [];
const [leftOut, setLeftOut] = useState<Set<string>>(() => new Set());
const reduceMotion = useReducedMotion();

// A deployment with no Bot of its own cannot run one here, so nothing is chosen for the person.
Expand Down Expand Up @@ -228,6 +243,17 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) {
authValue: managed ? key : "",
}),
);
/*
* The ticked apps, given once the Bot exists. A refusal is not a failed create: the Bot is made,
* and its page lists every app it could be given with a switch each. So each is tried, none
* holds up the others, and none stops the hand-over.
*/
await Promise.allSettled(
giveable
.filter((app) => !leftOut.has(app.key))
.map((app) => giveBotApp({ agentId: agent.id, app: app.key })),
);
void queryClient.invalidateQueries({ queryKey: pluginKeys.all });
setCreatedId(agent.id);
};

Expand Down Expand Up @@ -334,8 +360,18 @@ function NewBotFlow({ onCreated }: { onCreated: (agentId: string) => void }) {
>
{step === "about" ? (
<AboutStep
apps={giveable}
instructions={instructions}
leftOut={leftOut}
name={name}
onApp={(app, used) =>
setLeftOut((current) => {
const next = new Set(current);
if (used) next.delete(app);
else next.add(app);
return next;
})
}
runtime={runtime ?? { kind: "builtin" }}
onInstructions={setInstructions}
onName={setName}
Expand Down Expand Up @@ -512,8 +548,14 @@ const VISIBILITY_OPTIONS: Array<{
* Who can see it was the old wizard's last step. It is here because this step had the room, and a
* question that does not change what is being made is cheaper answered alongside the name than as a
* screen of its own.
*
* The apps it can use come last and arrive ticked: the person's own connected apps an admin lets
* owners give. Drawn only when there are some, so a person with none connected is asked nothing.
*/
function AboutStep({
apps,
leftOut,
onApp,
name,
title,
instructions,
Expand All @@ -533,6 +575,9 @@ function AboutStep({
onTitle: (value: string) => void;
onInstructions: (value: string) => void;
onVisibility: (value: Visibility) => void;
apps: GiveableApp[];
leftOut: Set<string>;
onApp: (app: string, used: boolean) => void;
}) {
const runsHere = runtime.kind === "builtin";
return (
Expand Down Expand Up @@ -611,6 +656,35 @@ function AboutStep({
})}
</div>
</FieldSet>
{apps.length > 0 ? (
<FieldSet>
<FieldLegend variant="label">Apps it can use</FieldLegend>
<FieldDescription>
It always uses the account of whoever is asking.
</FieldDescription>
<div className="grid gap-2 sm:grid-cols-2">
{apps.map((app) => (
<label
className="flex items-center gap-3 rounded-lg bg-muted/50 p-3 text-sm"
htmlFor={`new-bot-app-${app.key}`}
key={app.key}
>
<Checkbox
checked={!leftOut.has(app.key)}
id={`new-bot-app-${app.key}`}
onCheckedChange={(checked) =>
onApp(app.key, checked === true)
}
/>
<PluginLogo logo={app.logo} />
<span className="min-w-0 truncate font-medium">
{app.displayName}
</span>
</label>
))}
</div>
</FieldSet>
) : null}
</FieldGroup>
);
}
Expand Down
142 changes: 137 additions & 5 deletions app/src/components/bot-profile/access.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import {
IconApps,
IconBox,
IconChevronRight,
IconPlug,
IconPlus,
} from "@tabler/icons-react";
import { useMutation, useQuery } from "@tanstack/react-query";
Expand Down Expand Up @@ -30,7 +31,16 @@ import {
import { Separator } from "@/components/ui/separator";
import { Switch } from "@/components/ui/switch";
import type { AgentProfile } from "@/lib/agents/queries";
import { botAppsQueryOptions } from "@/lib/app-access/queries";
import {
giveBotAppMutationOptions,
takeBackBotAppMutationOptions,
} from "@/lib/app-access/mutations";
import {
type AppAccess,
botAppsQueryOptions,
type GiveableApp,
giveableAppsQueryOptions,
} from "@/lib/app-access/queries";
import { currentUserQueryOptions } from "@/lib/auth/queries";
import { setPluginGrantMutationOptions } from "@/lib/plugins/mutations";
import {
Expand Down Expand Up @@ -86,13 +96,133 @@ export function connectorCount(plugins: GrantedPlugins): number {
export function AccessSections({ agent }: { agent: AgentProfile }) {
return (
<>
{agent.mine ? <YourApps agent={agent} /> : null}
{agent.mine ? <YourSkills agentId={agent.id} /> : null}
<Granted agentId={agent.id} />
<Granted agentId={agent.id} mine={agent.mine} />
<HandoffPanel agentId={agent.id} />
</>
);
}

/**
* Your own apps, one switch each: the ones you connected that an admin lets owners give, and any you
* already gave this Bot. Only on your own Bot, for the reason the server gives — see
* `server/src/plugins/owner-grants.ts`.
*
* WHAT A SWITCH GIVES is what an admin's one press gives: the app's read-only actions. Every call
* runs on the asking person's own account, even after the Bot is published to the team, so the
* description says "your" only about the person reading it.
*
* AN APP AN ADMIN GAVE THIS BOT is drawn switched on and locked. Taking it away is the admin's, and
* the server would refuse it.
*/
function YourApps({ agent }: { agent: AgentProfile }) {
const giveable = useQuery(giveableAppsQueryOptions());
const held = useQuery(botAppsQueryOptions(agent.id));
const give = useMutation(giveBotAppMutationOptions(queryClient));
const takeBack = useMutation(takeBackBotAppMutationOptions(queryClient));
const error = give.error ?? takeBack.error;
const busy = give.isPending || takeBack.isPending;
if (giveable.isPending || held.isPending) return null;
const states = new Map((held.data ?? []).map((app) => [app.key, app]));
/*
* What you could give, and then anything you already gave that no longer is — an account you
* disconnected since, an app an admin closed to owners — so it can still be switched off here.
*/
const rows: (GiveableApp & { access?: AppAccess })[] = [
...(giveable.data ?? []).map((app) => ({
...app,
access: states.get(app.key),
})),
...(held.data ?? [])
.filter(
(app) =>
app.givenByOwner &&
!(giveable.data ?? []).some((given) => given.key === app.key),
)
.map((app) => ({ ...app, access: app })),
];
return (
<PageSection
description={`Turn on an app to let ${agent.name} use it. It always uses the account of whoever is asking.`}
title="Your apps"
>
{error ? (
<p className="mt-4 text-destructive text-sm" role="alert">
{error.message}
</p>
) : null}
{giveable.error ? (
<p className="mt-4 text-destructive text-sm" role="alert">
Couldn't load your apps. Reload the page.
</p>
) : rows.length === 0 ? (
<PageEmpty
action={
<Button
render={(props) => <Link {...props} to="/connections" />}
size="sm"
variant="outline"
>
<IconPlug />
Connections
</Button>
}
icon={IconApps}
title="No apps to give yet"
>
Connect an app to let your Bots use it.
</PageEmpty>
) : (
<PageRows>
{rows.map((app, index) => {
const granted = app.access?.granted ?? false;
const locked = granted && !app.access?.givenByOwner;
const yours =
app.account === "personal"
? `your ${app.displayName}`
: app.displayName;
return (
<Fragment key={app.key}>
{index > 0 ? <Separator /> : null}
<Item data-app={app.key} size="sm">
<ItemMedia variant="icon">
<PluginLogo logo={app.logo} />
</ItemMedia>
<ItemContent>
<ItemTitle>{app.displayName}</ItemTitle>
<ItemDescription>
{locked
? "An admin gave it to this Bot."
: granted
? `${agent.name} can use ${yours}.`
: `${agent.name} can't use ${yours}.`}
</ItemDescription>
</ItemContent>
<ItemActions>
<Switch
aria-label={`Let ${agent.name} use ${app.displayName}`}
checked={granted}
disabled={busy || locked}
onCheckedChange={(next) => {
give.reset();
takeBack.reset();
const variables = { agentId: agent.id, app: app.key };
if (next) give.mutate(variables);
else takeBack.mutate(variables);
}}
/>
</ItemActions>
</Item>
</Fragment>
);
})}
</PageRows>
)}
</PageSection>
);
}

/**
* Your own skills, one switch each. Only on your own Bot: the server lets a person put their own
* skill on a Bot they own, and neither half alone is enough.
Expand Down Expand Up @@ -177,7 +307,7 @@ function YourSkills({ agentId }: { agentId: string }) {
* the person looking.
*
* READ-ONLY FOR GRANTS, NOT FOR THE PERSON'S OWN PART. Apps are granted by an administrator, who gets
* a link to where that is decided. But a Bot that holds Gmail is no use to somebody who has not
* a link to where that is decided — or, on a Bot you own, by you, under Your apps above. But a Bot that holds Gmail is no use to somebody who has not
* connected their Gmail, and this list used to say "Gmail · 4 tools" to everybody alike — so each
* app now says what it is for the person reading (`GET /api/plugins/for/:agentId/apps`) with the one
* thing they can do about it: Connect, Reconnect, Request access, or Requested. The state is the
Expand All @@ -187,7 +317,7 @@ function YourSkills({ agentId }: { agentId: string }) {
* whoever is asking, exactly as the connections read does, so this cannot become a list of who has
* connected what.
*/
function Granted({ agentId }: { agentId: string }) {
function Granted({ agentId, mine }: { agentId: string; mine: boolean }) {
const me = useQuery(currentUserQueryOptions()).data;
const plugins = useQuery(agentPluginsQueryOptions(agentId));
const apps = useQuery(botAppsQueryOptions(agentId));
Expand Down Expand Up @@ -224,7 +354,9 @@ function Granted({ agentId }: { agentId: string }) {
) : null}
{count === 0 ? (
<PageEmpty icon={IconApps} title="No apps yet">
An admin has to add them. Until then this Bot can only talk with you.
{mine
? "Turn one on under Your apps, or ask an admin. Until then this Bot can only talk with you."
: "An admin has to add them. Until then this Bot can only talk with you."}
</PageEmpty>
) : (
<PageRows>
Expand Down
Loading
Loading