permissions
Connector Panel
A settings panel for connecting an app or MCP server — shows capabilities overview, groups tools by type, and lets users set per-tool permission levels.
Manage teams, projects, and deployments; search documentation and control infrastructure.
Tools
Check whether one or more domain names are available for purchase and retrieve their pricing information.
Generate a temporary shareable link (valid ~23 hours) that bypasses authentication for a protected Vercel deployment URL, avoiding 403 errors.
Get detailed metadata for a single agent run, including events, workflow metadata, usage, and subagent breakout data. Requires a run ID.
Get the full trace for a single agent run, including turns, messages, reasoning, tool calls, token usage, and tool input/output.
Retrieve details for a specific Vercel deployment using its ID or URL, including status, metadata, and configuration.
Deploy a project by pushing a new build. Accepts environment variables, build settings, and a target environment.
Create or update an environment variable for a project across one or more target environments (production, preview, development).
Assign a custom domain to a Vercel project and configure its DNS records automatically.
Return all members of a Vercel team, including their roles, join dates, and access scopes.
Send an invitation email to add a new member to the team with a specified role.
Change the role of an existing team member between Owner, Member, and Developer.
Permanently remove a deployment by ID. This action cannot be undone.
Detach a custom domain from a project and delete its associated DNS configuration.
Revoke a member's access to the team. They will lose access to all team projects immediately.
"use client";
import * as React from "react";
import { AnimatePresence, motion } from "motion/react";
import {
BadgeCheck,
ChevronDown,
ExternalLink,
Plus,
Search,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
export type ToolPermission = "allow" | "always-ask" | "disable";
export interface ConnectorTool {
id: string;
name: string;
description: string;
badge?: string;
permission: ToolPermission;
}
export interface ConnectorToolGroup {
id: string;
label: string;
tools: ConnectorTool[];
}
export interface ConnectorLink {
label: string;
href: string;
icon?: React.ComponentType<{ className?: string }>;
}
export interface ConnectorPanelProps {
appName: string;
appDescription: string;
appIcon?: React.ReactNode;
verified?: boolean;
overview?: string[];
links?: ConnectorLink[];
toolGroups: ConnectorToolGroup[];
onAddConnector?: () => void;
onClose?: () => void;
onPermissionChange?: (toolId: string, permission: ToolPermission) => void;
onGroupAllow?: (groupId: string) => void;
className?: string;
}
// ─── Permission toggle ────────────────────────────────────────────────────────
function PermissionToggle({
value,
onChange,
toolName,
}: {
value: ToolPermission;
onChange: (p: ToolPermission) => void;
toolName: string;
}) {
const options: { value: ToolPermission; label: string }[] = [
{ value: "disable", label: "Disable" },
{ value: "always-ask", label: "Always ask" },
{ value: "allow", label: "Allow" },
];
return (
<div
role="group"
aria-label={`Permission for ${toolName}`}
className="flex shrink-0 items-center gap-1"
>
{options.map((opt) => (
<button
key={opt.value}
type="button"
onClick={() => onChange(opt.value)}
aria-pressed={value === opt.value}
className={cn(
"rounded-md px-2.5 py-1 text-xs font-medium transition-colors",
value === opt.value
? "bg-foreground text-background"
: "text-muted-foreground hover:bg-accent hover:text-foreground",
)}
>
{opt.label}
</button>
))}
</div>
);
}
// ─── Tool row ─────────────────────────────────────────────────────────────────
function ToolRow({
tool,
onPermissionChange,
}: {
tool: ConnectorTool;
onPermissionChange: (p: ToolPermission) => void;
}) {
return (
<div className="flex items-start gap-3 border-t px-4 py-3">
<div className="min-w-0 flex-1">
<div className="flex flex-wrap items-center gap-1.5">
<span className="text-xs font-semibold">{tool.name}</span>
{tool.badge && (
<span className="rounded-full border px-1.5 py-0.5 text-[10px] font-medium text-muted-foreground">
{tool.badge}
</span>
)}
</div>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
{tool.description}
</p>
</div>
<PermissionToggle
value={tool.permission}
onChange={onPermissionChange}
toolName={tool.name}
/>
</div>
);
}
// ─── Tool group section ───────────────────────────────────────────────────────
function ToolGroupSection({
group,
onPermissionChange,
onGroupAllow,
}: {
group: ConnectorToolGroup & { tools: ConnectorTool[] };
onPermissionChange: (toolId: string, p: ToolPermission) => void;
onGroupAllow: (groupId: string) => void;
}) {
const [collapsed, setCollapsed] = React.useState(false);
const allAllowed = group.tools.every((t) => t.permission === "allow");
return (
<div>
<div className="flex items-center gap-2 px-4 py-2">
<button
type="button"
onClick={() => setCollapsed((c) => !c)}
aria-expanded={!collapsed}
className="flex flex-1 items-center gap-1 text-xs font-medium text-muted-foreground hover:text-foreground"
>
<ChevronDown
className={cn(
"size-3.5 shrink-0 transition-transform",
collapsed && "-rotate-90",
)}
aria-hidden
/>
{group.label}
</button>
{!allAllowed && (
<button
type="button"
onClick={() => onGroupAllow(group.id)}
className="rounded-md bg-foreground px-2.5 py-1 text-xs font-medium text-background transition-opacity hover:opacity-80"
>
Allow
</button>
)}
</div>
<AnimatePresence initial={false}>
{!collapsed && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.18 }}
className="overflow-hidden"
>
{group.tools.map((tool) => (
<ToolRow
key={tool.id}
tool={tool}
onPermissionChange={(p) => onPermissionChange(tool.id, p)}
/>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}
// ─── Filter bar ───────────────────────────────────────────────────────────────
function FilterBar({
groups,
activeGroup,
onGroupChange,
query,
onQueryChange,
}: {
groups: ConnectorToolGroup[];
activeGroup: string | null;
onGroupChange: (id: string | null) => void;
query: string;
onQueryChange: (q: string) => void;
}) {
const [dropOpen, setDropOpen] = React.useState(false);
const dropRef = React.useRef<HTMLDivElement>(null);
React.useEffect(() => {
if (!dropOpen) return;
function handle(e: MouseEvent) {
if (!dropRef.current?.contains(e.target as Node)) setDropOpen(false);
}
document.addEventListener("mousedown", handle);
return () => document.removeEventListener("mousedown", handle);
}, [dropOpen]);
const currentLabel =
groups.find((g) => g.id === activeGroup)?.label ?? "All";
return (
<div className="flex items-center gap-2 border-b px-4 py-2.5">
<div ref={dropRef} className="relative shrink-0">
<button
type="button"
onClick={() => setDropOpen((o) => !o)}
aria-haspopup="listbox"
aria-expanded={dropOpen}
className="inline-flex items-center gap-1 rounded-md border px-2.5 py-1.5 text-xs font-medium transition-colors hover:bg-accent"
>
{currentLabel}
<ChevronDown
className={cn(
"size-3 opacity-60 transition-transform",
dropOpen && "rotate-180",
)}
aria-hidden
/>
</button>
<AnimatePresence>
{dropOpen && (
<motion.ul
role="listbox"
initial={{ opacity: 0, y: 4 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: 4 }}
transition={{ duration: 0.1 }}
className="absolute left-0 top-full z-10 mt-1 min-w-[140px] overflow-hidden rounded-xl border bg-popover shadow-lg"
>
<li>
<button
type="button"
role="option"
aria-selected={activeGroup === null}
onClick={() => {
onGroupChange(null);
setDropOpen(false);
}}
className={cn(
"w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent",
activeGroup === null && "font-semibold",
)}
>
All
</button>
</li>
{groups.map((g) => (
<li key={g.id}>
<button
type="button"
role="option"
aria-selected={activeGroup === g.id}
onClick={() => {
onGroupChange(g.id);
setDropOpen(false);
}}
className={cn(
"w-full px-3 py-2 text-left text-xs transition-colors hover:bg-accent",
activeGroup === g.id && "font-semibold",
)}
>
{g.label}
</button>
</li>
))}
</motion.ul>
)}
</AnimatePresence>
</div>
<div className="relative flex-1">
<Search
className="absolute left-2.5 top-1/2 size-3.5 -translate-y-1/2 text-muted-foreground"
aria-hidden
/>
<input
type="search"
value={query}
onChange={(e) => onQueryChange(e.target.value)}
placeholder="Search tools"
aria-label="Search tools"
className="w-full rounded-md border bg-background py-1.5 pl-8 pr-3 text-xs placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring"
/>
</div>
</div>
);
}
// ─── Main export ──────────────────────────────────────────────────────────────
export function ConnectorPanel({
appName,
appDescription,
appIcon,
verified = false,
overview = [],
links = [],
toolGroups: initialGroups,
onAddConnector,
onClose,
onPermissionChange,
onGroupAllow,
className,
}: ConnectorPanelProps) {
const [toolGroups, setToolGroups] = React.useState(initialGroups);
const [query, setQuery] = React.useState("");
const [activeGroup, setActiveGroup] = React.useState<string | null>(null);
function handlePermissionChange(toolId: string, permission: ToolPermission) {
setToolGroups((prev) =>
prev.map((group) => ({
...group,
tools: group.tools.map((t) =>
t.id === toolId ? { ...t, permission } : t,
),
})),
);
onPermissionChange?.(toolId, permission);
}
function handleGroupAllow(groupId: string) {
setToolGroups((prev) =>
prev.map((group) =>
group.id === groupId
? {
...group,
tools: group.tools.map((t) => ({
...t,
permission: "allow" as ToolPermission,
})),
}
: group,
),
);
onGroupAllow?.(groupId);
}
const filteredGroups = toolGroups
.filter((g) => !activeGroup || g.id === activeGroup)
.map((g) => ({
...g,
tools: g.tools.filter(
(t) =>
!query ||
t.name.toLowerCase().includes(query.toLowerCase()) ||
t.description.toLowerCase().includes(query.toLowerCase()),
),
}))
.filter((g) => g.tools.length > 0);
return (
<div
className={cn(
"flex w-full flex-col overflow-hidden rounded-2xl border bg-background shadow-xl",
className,
)}
>
{/* Header */}
<div className="flex items-start gap-3 border-b px-5 py-4">
{appIcon && (
<div className="flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-xl border bg-muted">
{appIcon}
</div>
)}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-1.5">
<span className="text-sm font-semibold">{appName}</span>
{verified && (
<BadgeCheck
className="size-4 shrink-0 text-blue-500"
aria-label="Verified"
/>
)}
</div>
<p className="mt-0.5 text-xs leading-relaxed text-muted-foreground">
{appDescription}
</p>
</div>
<div className="flex shrink-0 items-center gap-1.5">
<button
type="button"
onClick={onAddConnector}
className="inline-flex items-center gap-1.5 rounded-lg bg-foreground px-3 py-1.5 text-xs font-medium text-background transition-opacity hover:opacity-80"
>
<Plus className="size-3.5" aria-hidden />
Add connector
</button>
{onClose && (
<button
type="button"
onClick={onClose}
aria-label="Close"
className="rounded-md p-1.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
>
<X className="size-4" aria-hidden />
</button>
)}
</div>
</div>
{/* Body */}
<div className="flex min-h-0 flex-1">
{/* Sidebar */}
<aside
aria-label="Connector overview"
className="w-44 shrink-0 border-r px-4 py-4"
>
{overview.length > 0 && (
<div className="mb-5">
<p className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
Overview
</p>
<ul className="space-y-2">
{overview.map((item, i) => (
<li key={i} className="flex items-start gap-1.5">
<span
className="mt-1.5 size-1.5 shrink-0 rounded-full bg-muted-foreground/40"
aria-hidden
/>
<span className="text-xs leading-relaxed text-muted-foreground">
{item}
</span>
</li>
))}
</ul>
</div>
)}
{links.length > 0 && (
<div>
<p className="mb-2 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground/60">
Links
</p>
<ul className="space-y-1.5">
{links.map((link, i) => {
const Icon = link.icon ?? ExternalLink;
return (
<li key={i}>
<a
href={link.href}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-1.5 text-xs text-muted-foreground transition-colors hover:text-foreground"
>
<Icon className="size-3.5 shrink-0" aria-hidden />
{link.label}
</a>
</li>
);
})}
</ul>
</div>
)}
</aside>
{/* Tools column */}
<div className="flex min-w-0 flex-1 flex-col">
<div className="px-4 py-2.5">
<h2 className="text-xs font-semibold">Tools</h2>
</div>
<FilterBar
groups={toolGroups}
activeGroup={activeGroup}
onGroupChange={setActiveGroup}
query={query}
onQueryChange={setQuery}
/>
<div className="flex-1 overflow-y-auto">
{filteredGroups.length === 0 ? (
<p className="px-4 py-10 text-center text-xs text-muted-foreground">
No tools match your search.
</p>
) : (
filteredGroups.map((group) => (
<ToolGroupSection
key={group.id}
group={group}
onPermissionChange={handlePermissionChange}
onGroupAllow={handleGroupAllow}
/>
))
)}
</div>
</div>
</div>
</div>
);
}
A UX spec for this pattern — written for agents implementing or reusing it, not the code.
# Connector Panel
## Summary
A structured settings panel for connecting and configuring an external app or MCP server with an AI agent. It shows what the integration can do (overview bullets and links in a sidebar), lists every tool the integration exposes grouped by capability, and lets users set per-tool or per-group permission levels — Disable, Always ask, or Allow — without leaving the panel. The header carries the app identity (name, verified badge) and an Add connector CTA; a sidebar holds discovery content; the main column offers search and group filtering over the tool list.
## When to use
- When onboarding a new MCP server or reviewing an existing integration's permissions before activating it.
- When users need to browse what an integration can do and control exactly which tools it may run autonomously, run after confirmation, or never run.
- In an agent settings area, marketplace, or connector catalog — not inline in an agent chat transcript.
## When not to use
- For in-transcript per-call approval (one tool, one moment) — use Tool Approval instead. This panel manages standing permissions, not live prompts.
- As a generic settings form or feature-flag manager unrelated to agent tool permissions.
- When the integration exposes only one or two tools — a simple toggle row suffices; the full panel layout is only worth its overhead at five or more tools.
## Anatomy
- **Header**: app icon, name, verified badge (if publisher-verified), one-line description, "Add connector" CTA, close button.
- **Sidebar**: "Overview" section — a short bullet list of capabilities phrased from the user's perspective; "Links" section — anchor links to the app's website and documentation.
- **Filter bar**: Group filter dropdown (All + each group name) and a text search that matches on tool name or description.
- **Tool group section**: a collapsible label row showing the group name and a group-level "Allow" button that disappears once every tool in the group is allowed; below it, one tool row per tool.
- **Tool row**: tool name, protocol badge (e.g. "MCP"), description, and a three-way permission toggle (Disable / Always ask / Allow).
## Behavior
- Search immediately filters tool rows by name or description substring across all groups.
- The group dropdown constrains the visible groups; combining it with search narrows within that group.
- The group-level "Allow" button sets every tool in the group to Allow in one action.
- Per-tool permission is a mutually exclusive three-way toggle: only one of Disable, Always ask, Allow is active at a time.
- Groups are individually collapsible; collapsing hides tool rows without changing their permissions.
- The panel is layout-agnostic — it can be rendered as a modal (with a backdrop owned by the caller) or embedded directly in a settings page.
## Content guidelines
- Overview bullets phrase capabilities from the user's perspective: "Access deployment logs for debugging", not "Provides log access".
- Tool names match the tool's identifier exactly — no paraphrasing.
- Tool descriptions state what the tool does, not why the agent would use it ("Generate a temporary shareable link…", not "Useful for sharing protected pages").
- The verified badge appears only when the publisher's identity has been confirmed — don't use it as a general quality signal.
- Protocol badges ("MCP") are short, literal, and uppercased.
## Accessibility
- The permission toggle group for each tool uses `role="group"` with `aria-label` naming the tool ("Permission for Check domain availability").
- Each permission button exposes its active state via `aria-pressed`.
- The verified badge icon carries `aria-label="Verified"` so it's not invisible to screen readers.
- Group collapse buttons set `aria-expanded` to reflect the open/closed state.
- The group filter dropdown follows the listbox disclosure pattern: `aria-haspopup="listbox"`, `aria-expanded`, closes on Escape or outside click.
- The search input has `aria-label="Search tools"`.
## Related patterns
- Tool Approval — per-call in-context permission prompt; this panel manages standing permissions set before calls happen.
- Agent Triggers — similar two-column layout (sidebar overview + main content) for agent configuration.