AI Patterns

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.

Vercel

Manage teams, projects, and deployments; search documentation and control infrastructure.

Tools

Check domain availability and priceMCP

Check whether one or more domain names are available for purchase and retrieve their pricing information.

Get temporary access to a Vercel URLMCP

Generate a temporary shareable link (valid ~23 hours) that bypasses authentication for a protected Vercel deployment URL, avoiding 403 errors.

Get agent run detailsMCP

Get detailed metadata for a single agent run, including events, workflow metadata, usage, and subagent breakout data. Requires a run ID.

Get agent run traceMCP

Get the full trace for a single agent run, including turns, messages, reasoning, tool calls, token usage, and tool input/output.

Get a deploymentMCP

Retrieve details for a specific Vercel deployment using its ID or URL, including status, metadata, and configuration.

Create a deploymentMCP

Deploy a project by pushing a new build. Accepts environment variables, build settings, and a target environment.

Update environment variableMCP

Create or update an environment variable for a project across one or more target environments (production, preview, development).

Add domain to projectMCP

Assign a custom domain to a Vercel project and configure its DNS records automatically.

List team membersMCP

Return all members of a Vercel team, including their roles, join dates, and access scopes.

Invite team memberMCP

Send an invitation email to add a new member to the team with a specified role.

Update member roleMCP

Change the role of an existing team member between Owner, Member, and Developer.

Delete a deploymentMCP

Permanently remove a deployment by ID. This action cannot be undone.

Remove domain from projectMCP

Detach a custom domain from a project and delete its associated DNS configuration.

Remove team memberMCP

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.