diff --git a/messages/en-US.json b/messages/en-US.json
index 27be1ffe3..a4dec1b17 100644
--- a/messages/en-US.json
+++ b/messages/en-US.json
@@ -1780,6 +1780,16 @@
"myVirtualApiKeysUnnamed": "Unnamed key",
"myVirtualApiKeysRevealSecret": "Reveal Secret",
"myVirtualApiKeysViewSecretDescription": "This secret authenticates you to AI Gateway resources",
+ "aiClientConfigTitle": "Configure Coding Agents",
+ "aiClientConfigDescription": "Copy configuration for popular coding agents, or let the Pangolin CLI set it up for you automatically.",
+ "aiClientConfigDescriptionClaude": "Anthropic's agentic coding tool for the terminal.",
+ "aiClientConfigDescriptionCodex": "OpenAI's agentic coding tool for the terminal.",
+ "aiClientConfigDescriptionOpencode": "Open source terminal coding agent.",
+ "aiClientConfigDescriptionCursor": "AI code editor built on VS Code.",
+ "aiClientConfigTabCli": "Automatic (CLI)",
+ "aiClientConfigTabManual": "Manual Configuration",
+ "aiClientConfigEndpointPlaceholder": "https://example.resource.url.com",
+ "resourceGeneralAiClientConfigLink": "See how to configure access to this resource in common clients like Claude Code and OpenCode",
"aiProvidersTitle": "AI Providers",
"aiProvidersDescription": "Connect model providers for AI workloads in this organization",
"aiProvidersBannerTitle": "Connect Model Providers",
diff --git a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx
index 8dbdb3809..37c69ec1a 100644
--- a/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx
+++ b/src/app/[orgId]/settings/resources/private/[niceId]/general/page.tsx
@@ -27,6 +27,7 @@ import { SwitchInput } from "@app/components/SwitchInput";
import { createGeneralFormSchema } from "@app/lib/privateResourceForm";
import { zodResolver } from "@hookform/resolvers/zod";
import { useTranslations } from "next-intl";
+import Link from "next/link";
import { useActionState, useMemo } from "react";
import { useForm } from "react-hook-form";
import { z } from "zod";
@@ -70,6 +71,16 @@ export default function PrivateResourceGeneralPage() {
{t("privateResourceGeneralDescription")}
+ {siteResource.mode === "inference" ? (
+
+
+ {t("resourceGeneralAiClientConfigLink")}
+
+
+ ) : null}
diff --git a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx
index e1f087671..d4d32b044 100644
--- a/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx
+++ b/src/app/[orgId]/settings/resources/public/[niceId]/general/page.tsx
@@ -256,6 +256,16 @@ export default function GeneralForm() {
{t("resourceGeneralDescription")}
+ {resource.mode === "inference" ? (
+
+
+ {t("resourceGeneralAiClientConfigLink")}
+
+
+ ) : null}
diff --git a/src/components/UserVirtualApiKeys.tsx b/src/components/UserVirtualApiKeys.tsx
index 1ab2ae92d..9b2c9ba7b 100644
--- a/src/components/UserVirtualApiKeys.tsx
+++ b/src/components/UserVirtualApiKeys.tsx
@@ -5,6 +5,7 @@ import moment from "moment";
import { Button } from "@app/components/ui/button";
import CopyTextBox from "@app/components/CopyTextBox";
import CopyToClipboard from "@app/components/CopyToClipboard";
+import { AiClientConfigSection } from "@app/components/ai-client-config/AiClientConfigSection";
import {
SettingsContainer,
SettingsFormCell,
@@ -166,6 +167,14 @@ export default function UserVirtualApiKeys({
}: UserVirtualApiKeysProps) {
const t = useTranslations();
const resourceName = initialData.resourceName;
+ const { getCopyText: getKeyCopyText } = useMyVirtualApiKeySecret(
+ orgId,
+ initialData.userKey.virtualApiKeyId
+ );
+ const keyPreview = formatVirtualApiKeyPreview(
+ initialData.userKey.virtualApiKeyId,
+ initialData.userKey.lastChars
+ );
return (
<>
@@ -177,6 +186,15 @@ export default function UserVirtualApiKeys({
resourceName={resourceName}
/>
+
+
{initialData.manualKeys.length > 0 ? (
diff --git a/src/components/ai-client-config/AiClientConfigCard.tsx b/src/components/ai-client-config/AiClientConfigCard.tsx
new file mode 100644
index 000000000..0bda5f40b
--- /dev/null
+++ b/src/components/ai-client-config/AiClientConfigCard.tsx
@@ -0,0 +1,138 @@
+"use client";
+
+import { AiConfigCodeBlock } from "@app/components/ai-client-config/AiConfigCodeBlock";
+import {
+ Collapsible,
+ CollapsibleContent,
+ CollapsibleTrigger
+} from "@app/components/ui/collapsible";
+import {
+ Select,
+ SelectContent,
+ SelectItem,
+ SelectTrigger,
+ SelectValue
+} from "@app/components/ui/select";
+import {
+ Tabs,
+ TabsContent,
+ TabsList,
+ TabsTrigger
+} from "@app/components/ui/tabs";
+import type { AiClientGuide, AiClientPresetId } from "@app/lib/aiClientConfig";
+import { cn } from "@app/lib/cn";
+import { ChevronDown, type LucideIcon } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useState } from "react";
+
+type AiClientConfigCardProps = {
+ guide: AiClientGuide;
+ description: string;
+ icon: LucideIcon;
+ defaultOpen?: boolean;
+};
+
+export function AiClientConfigCard({
+ guide,
+ description,
+ icon: Icon,
+ defaultOpen = false
+}: AiClientConfigCardProps) {
+ const t = useTranslations();
+ const [open, setOpen] = useState(defaultOpen);
+ const [presetId, setPresetId] = useState(
+ guide.presets[0]?.id ?? "default"
+ );
+
+ const preset =
+ guide.presets.find((p) => p.id === presetId) ?? guide.presets[0];
+
+ const manualContent = (
+
+ {guide.presets.length > 1 ? (
+
+ setPresetId(value as AiClientPresetId)
+ }
+ >
+
+
+
+
+ {guide.presets.map((p) => (
+
+ {p.label}
+
+ ))}
+
+
+ ) : null}
+
+ {preset?.blocks.map((block) => (
+
+ ))}
+
+
+ );
+
+ return (
+
+
+
+
+
{guide.name}
+
+ {description}
+
+
+
+
+
+ {guide.cli ? (
+
+
+
+ {t("aiClientConfigTabCli")}
+
+
+ {t("aiClientConfigTabManual")}
+
+
+
+
+
+ {guide.cli.configureWithKey ? (
+
+ ) : null}
+ {guide.cli.runWithKey ? (
+
+ ) : null}
+
+
+ {manualContent}
+
+
+ ) : (
+ manualContent
+ )}
+
+
+ );
+}
diff --git a/src/components/ai-client-config/AiClientConfigSection.tsx b/src/components/ai-client-config/AiClientConfigSection.tsx
new file mode 100644
index 000000000..4b208b885
--- /dev/null
+++ b/src/components/ai-client-config/AiClientConfigSection.tsx
@@ -0,0 +1,77 @@
+"use client";
+
+import { AiClientConfigCard } from "@app/components/ai-client-config/AiClientConfigCard";
+import {
+ SettingsSection,
+ SettingsSectionBody,
+ SettingsSectionDescription,
+ SettingsSectionHeader,
+ SettingsSectionTitle
+} from "@app/components/Settings";
+import type { AiClientAuth } from "@app/lib/aiClientConfig";
+import { buildAiClientGuides } from "@app/lib/aiClientConfig";
+import { cn } from "@app/lib/cn";
+import { MousePointerClick, Sparkles, SquareTerminal, TerminalSquare } from "lucide-react";
+import { useTranslations } from "next-intl";
+import { useMemo } from "react";
+
+type AiClientConfigSectionProps = {
+ endpoint: string;
+ auth: AiClientAuth;
+ className?: string;
+};
+
+export function AiClientConfigSection({
+ endpoint,
+ auth,
+ className
+}: AiClientConfigSectionProps) {
+ const t = useTranslations();
+
+ const guides = useMemo(
+ () => buildAiClientGuides(endpoint, auth),
+ [endpoint, auth]
+ );
+
+ const icons = {
+ claude: Sparkles,
+ codex: TerminalSquare,
+ opencode: SquareTerminal,
+ cursor: MousePointerClick
+ } as const;
+
+ const descriptions: Record = {
+ claude: t("aiClientConfigDescriptionClaude"),
+ codex: t("aiClientConfigDescriptionCodex"),
+ opencode: t("aiClientConfigDescriptionOpencode"),
+ cursor: t("aiClientConfigDescriptionCursor")
+ };
+
+ return (
+
+
+
+ {t("aiClientConfigTitle")}
+
+
+ {t("aiClientConfigDescription")}
+
+
+
+
+ {guides.map((guide, index) => (
+
+ ))}
+
+
+
+ );
+}
diff --git a/src/components/ai-client-config/AiConfigCodeBlock.tsx b/src/components/ai-client-config/AiConfigCodeBlock.tsx
new file mode 100644
index 000000000..db2d2658d
--- /dev/null
+++ b/src/components/ai-client-config/AiConfigCodeBlock.tsx
@@ -0,0 +1,27 @@
+"use client";
+
+import CopyTextBox from "@app/components/CopyTextBox";
+import type { AiConfigBlock } from "@app/lib/aiClientConfig";
+
+export function AiConfigCodeBlock({ block }: { block: AiConfigBlock }) {
+ return (
+
+
+ {block.label}
+
+
+
+
+
+ );
+}
diff --git a/src/components/resource-launcher/LauncherResourcePanel.tsx b/src/components/resource-launcher/LauncherResourcePanel.tsx
index 999d1bc03..e8d7398d6 100644
--- a/src/components/resource-launcher/LauncherResourcePanel.tsx
+++ b/src/components/resource-launcher/LauncherResourcePanel.tsx
@@ -1,5 +1,6 @@
"use client";
+import { AiClientConfigSection } from "@app/components/ai-client-config/AiClientConfigSection";
import CopyToClipboard from "@app/components/CopyToClipboard";
import {
InfoSection,
@@ -27,6 +28,7 @@ import {
} from "@app/components/SidePanel";
import { Alert, AlertDescription, AlertTitle } from "@app/components/ui/alert";
import { Button } from "@app/components/ui/button";
+import { useMyVirtualApiKeySecret } from "@app/hooks/useMyVirtualApiKeySecret";
import {
derivePublicAuthState,
formatPublicResourceType
@@ -34,6 +36,7 @@ import {
import { getLauncherResourceAdminHref } from "@app/lib/launcherResourceAdminHref";
import { isSafeUrlForLink } from "@app/lib/launcherResourceAccess";
import { launcherQueries } from "@app/lib/queries";
+import { formatVirtualApiKeyPreview } from "@app/lib/virtualApiKeyFormat";
import type { LauncherResource } from "@server/routers/launcher/types";
import type { GetResourceAuthInfoResponse } from "@server/routers/resource/getResourceAuthInfo";
import type { GetResourceResponse } from "@server/routers/resource/getResource";
@@ -249,6 +252,15 @@ function PublicResourceDetails({
const authState = derivePublicAuthState(resource.mode, authInfo);
const infoSectionCount = 2 + (showAuthBadge ? 1 : 0) + (showHealth ? 1 : 0);
+ const { data: aiKeysData } = useQuery({
+ ...launcherQueries.myVirtualApiKeys(orgId, resource.resourceGuid),
+ enabled: isInference
+ });
+ const { getCopyText: getAiKeyCopyText } = useMyVirtualApiKeySecret(
+ orgId,
+ aiKeysData?.userKey.virtualApiKeyId ?? ""
+ );
+
return (
@@ -333,6 +345,19 @@ function PublicResourceDetails({
orgId={orgId}
resourceGuid={resource.resourceGuid}
/>
+ {aiKeysData ? (
+
+ ) : null}
>
) : null}
@@ -397,13 +422,19 @@ function PrivateResourceDetails({
{isInference ? (
-
+ <>
+
+
+ >
) : null}
);
diff --git a/src/lib/aiClientConfig.ts b/src/lib/aiClientConfig.ts
new file mode 100644
index 000000000..1d7ade034
--- /dev/null
+++ b/src/lib/aiClientConfig.ts
@@ -0,0 +1,338 @@
+export const AI_CLIENT_IDS = ["claude", "codex", "opencode", "cursor"] as const;
+export type AiClientId = (typeof AI_CLIENT_IDS)[number];
+
+export type AiClientAuth =
+ | { mode: "keyed"; keyDisplay: string; getKeyText: () => Promise }
+ | { mode: "keyless" };
+
+export type AiConfigBlock = {
+ id: string;
+ label: string;
+ kind?: "code" | "steps";
+ displayText: string;
+ getCopyText?: () => Promise;
+};
+
+export type AiClientPresetId = "default" | "bedrock" | "vertex" | "kimi";
+
+export type AiConfigPreset = {
+ id: AiClientPresetId;
+ label: string;
+ blocks: AiConfigBlock[];
+};
+
+export type AiCliCommands = {
+ configure: AiConfigBlock;
+ configureWithKey?: AiConfigBlock;
+ run: AiConfigBlock;
+ runWithKey?: AiConfigBlock;
+};
+
+export type AiClientGuide = {
+ id: AiClientId;
+ name: string;
+ cli: AiCliCommands | null;
+ presets: AiConfigPreset[];
+};
+
+function authValue(auth: AiClientAuth): {
+ display: string;
+ getCopyText?: () => Promise;
+} {
+ if (auth.mode === "keyed") {
+ return { display: auth.keyDisplay, getCopyText: auth.getKeyText };
+ }
+ return { display: "-" };
+}
+
+function block(
+ id: string,
+ label: string,
+ build: (keyValue: string) => string,
+ auth: AiClientAuth,
+ kind: "code" | "steps" = "code"
+): AiConfigBlock {
+ const { display, getCopyText } = authValue(auth);
+ return {
+ id,
+ label,
+ kind,
+ displayText: build(display),
+ getCopyText: getCopyText ? async () => build(await getCopyText()) : undefined
+ };
+}
+
+function buildCli(clientArg: "claude" | "codex", auth: AiClientAuth): AiCliCommands {
+ const configure: AiConfigBlock = {
+ id: `cli-configure-${clientArg}`,
+ label: "Configure",
+ displayText: `pangolin configure ${clientArg}`
+ };
+ const run: AiConfigBlock = {
+ id: `cli-run-${clientArg}`,
+ label: "Run",
+ displayText: `pangolin run ${clientArg}`
+ };
+
+ if (auth.mode !== "keyed") {
+ return { configure, run };
+ }
+
+ return {
+ configure,
+ run,
+ configureWithKey: {
+ id: `cli-configure-key-${clientArg}`,
+ label: "Configure with an API key",
+ displayText: `pangolin configure ${clientArg} ${auth.keyDisplay}`,
+ getCopyText: async () =>
+ `pangolin configure ${clientArg} ${await auth.getKeyText()}`
+ },
+ runWithKey: {
+ id: `cli-run-key-${clientArg}`,
+ label: "Run with an API key",
+ displayText: `pangolin run ${clientArg} ${auth.keyDisplay}`,
+ getCopyText: async () =>
+ `pangolin run ${clientArg} ${await auth.getKeyText()}`
+ }
+ };
+}
+
+function buildClaudeGuide(endpoint: string, auth: AiClientAuth): AiClientGuide {
+ const defaultSettings = block(
+ "claude-default-settings",
+ "~/.claude/settings.json",
+ (key) =>
+ [
+ "{",
+ ` "apiKeyHelper": "echo '${key}'",`,
+ ' "env": {',
+ ` "ANTHROPIC_BASE_URL": "${endpoint}"`,
+ " }",
+ "}"
+ ].join("\n"),
+ auth
+ );
+
+ const defaultShell = block(
+ "claude-default-shell",
+ "Shell",
+ (key) =>
+ [
+ `export ANTHROPIC_BASE_URL=${endpoint}`,
+ `export ANTHROPIC_API_KEY=${auth.mode === "keyed" ? key : "none"}`,
+ "claude"
+ ].join("\n"),
+ auth
+ );
+
+ const bedrockSettings = block(
+ "claude-bedrock-settings",
+ "~/.claude/settings.json",
+ () =>
+ [
+ "{",
+ ' "env": {',
+ ' "ANTHROPIC_MODEL": "claude-sonnet-4-6",',
+ ` "ANTHROPIC_BEDROCK_BASE_URL": "${endpoint}/bedrock",`,
+ ' "CLAUDE_CODE_USE_BEDROCK": "1",',
+ ' "CLAUDE_CODE_SKIP_BEDROCK_AUTH": "1"',
+ " }",
+ "}"
+ ].join("\n"),
+ auth
+ );
+
+ const vertexSettings = block(
+ "claude-vertex-settings",
+ "~/.claude/settings.json",
+ () =>
+ [
+ "{",
+ ' "env": {',
+ ' "CLOUD_ML_REGION": "global",',
+ ' "ANTHROPIC_VERTEX_PROJECT_ID": "",',
+ ' "CLAUDE_CODE_USE_VERTEX": "1",',
+ ' "CLAUDE_CODE_SKIP_VERTEX_AUTH": "1",',
+ ` "ANTHROPIC_VERTEX_BASE_URL": "${endpoint}/v1"`,
+ " }",
+ "}"
+ ].join("\n"),
+ auth
+ );
+
+ const kimiSettings = block(
+ "claude-kimi-settings",
+ "~/.claude/settings.json",
+ (key) =>
+ [
+ "{",
+ ` "apiKeyHelper": "echo '${key}'",`,
+ ' "env": {',
+ ` "ANTHROPIC_BASE_URL": "${endpoint}/anthropic",`,
+ ' "ANTHROPIC_MODEL": "kimi-k2",',
+ ' "ANTHROPIC_DEFAULT_OPUS_MODEL": "kimi-k2",',
+ ' "ANTHROPIC_DEFAULT_SONNET_MODEL": "kimi-k2",',
+ ' "ANTHROPIC_DEFAULT_HAIKU_MODEL": "kimi-k2",',
+ ' "CLAUDE_CODE_SUBAGENT_MODEL": "kimi-k2",',
+ ' "ENABLE_TOOL_SEARCH": "false"',
+ " }",
+ "}"
+ ].join("\n"),
+ auth
+ );
+
+ return {
+ id: "claude",
+ name: "Claude Code",
+ cli: buildCli("claude", auth),
+ presets: [
+ {
+ id: "default",
+ label: "Default (Anthropic)",
+ blocks: [defaultSettings, defaultShell]
+ },
+ {
+ id: "bedrock",
+ label: "Amazon Bedrock",
+ blocks: [bedrockSettings]
+ },
+ {
+ id: "vertex",
+ label: "Google Vertex AI",
+ blocks: [vertexSettings]
+ },
+ {
+ id: "kimi",
+ label: "Kimi K2 (Moonshot AI)",
+ blocks: [kimiSettings]
+ }
+ ]
+ };
+}
+
+function buildCodexGuide(endpoint: string, auth: AiClientAuth): AiClientGuide {
+ const settings = block(
+ "codex-settings",
+ "~/.codex/config.toml",
+ () =>
+ [
+ 'model_provider = "pangolin"',
+ "",
+ "[model_providers.pangolin]",
+ 'name = "Pangolin AI Gateway"',
+ `base_url = "${endpoint}/v1"`,
+ 'wire_api = "responses"',
+ ...(auth.mode === "keyed" ? ['env_key = "PANGOLIN_API_KEY"'] : [])
+ ].join("\n"),
+ auth
+ );
+
+ const shell =
+ auth.mode === "keyed"
+ ? block(
+ "codex-shell",
+ "Shell",
+ (key) => `export PANGOLIN_API_KEY=${key}`,
+ auth
+ )
+ : null;
+
+ return {
+ id: "codex",
+ name: "Codex",
+ cli: buildCli("codex", auth),
+ presets: [
+ {
+ id: "default",
+ label: "Default",
+ blocks: shell ? [settings, shell] : [settings]
+ }
+ ]
+ };
+}
+
+function buildOpencodeGuide(endpoint: string, auth: AiClientAuth): AiClientGuide {
+ const config = block(
+ "opencode-config",
+ "opencode.json",
+ () =>
+ [
+ "{",
+ ' "$schema": "https://opencode.ai/config.json",',
+ ' "provider": {',
+ ' "anthropic": {',
+ ' "options": {',
+ ` "baseURL": "${endpoint}/v1"`,
+ " }",
+ " }",
+ " }",
+ "}"
+ ].join("\n"),
+ auth
+ );
+
+ const authFile = block(
+ "opencode-auth",
+ "auth.json",
+ (key) =>
+ ["{", ' "anthropic": {', ' "type": "api",', ` "key": "${key}"`, " }", "}"].join(
+ "\n"
+ ),
+ auth
+ );
+
+ return {
+ id: "opencode",
+ name: "OpenCode",
+ cli: null,
+ presets: [
+ {
+ id: "default",
+ label: "Default",
+ blocks: [config, authFile]
+ }
+ ]
+ };
+}
+
+function buildCursorGuide(endpoint: string, auth: AiClientAuth): AiClientGuide {
+ const steps = block(
+ "cursor-steps",
+ "Cursor Settings",
+ (key) =>
+ [
+ "1. Open Cursor Settings -> Models.",
+ '2. Enable "Override OpenAI Base URL".',
+ `3. Set the base URL to: ${endpoint}/v1`,
+ auth.mode === "keyed"
+ ? `4. Paste your API key into the OpenAI API Key field: ${key}`
+ : '4. Leave the OpenAI API Key field set to a placeholder (e.g. "-"). Pangolin authenticates the request over your Newt/Olm connection automatically.',
+ "5. Add a custom model matching the model your Pangolin AI Gateway serves (e.g. claude-sonnet-4-6)."
+ ].join("\n"),
+ auth,
+ "steps"
+ );
+
+ return {
+ id: "cursor",
+ name: "Cursor",
+ cli: null,
+ presets: [
+ {
+ id: "default",
+ label: "Default",
+ blocks: [steps]
+ }
+ ]
+ };
+}
+
+export function buildAiClientGuides(endpoint: string, auth: AiClientAuth): AiClientGuide[] {
+ return [
+ buildClaudeGuide(endpoint, auth),
+ buildCodexGuide(endpoint, auth),
+ buildOpencodeGuide(endpoint, auth),
+ buildCursorGuide(endpoint, auth)
+ ];
+}