diff --git a/.changeset/dashboard-api-keys-page.md b/.changeset/dashboard-api-keys-page.md
new file mode 100644
index 000000000..052200001
--- /dev/null
+++ b/.changeset/dashboard-api-keys-page.md
@@ -0,0 +1,5 @@
+---
+"@anticapture/dashboard": minor
+---
+
+Add the API Keys page: signed-in users create, view, and revoke personal API keys for the Anticapture MCP server / public API. Includes the one-time key reveal on creation, a per-key list (name, status, created, last used), and a "Connect your AI agent" section with copy-paste install commands for Claude Code, Cursor, and Codex. Reached from the new "API" sidebar entry; gated behind sign-in.
diff --git a/apps/dashboard/app/api-keys/page.tsx b/apps/dashboard/app/api-keys/page.tsx
new file mode 100644
index 000000000..f1f0af54a
--- /dev/null
+++ b/apps/dashboard/app/api-keys/page.tsx
@@ -0,0 +1,36 @@
+import type { Metadata } from "next";
+
+import { ApiKeysManager } from "@/features/api-keys";
+import { Footer } from "@/shared/components/design-system/footer/Footer";
+import { HeaderSidebar } from "@/widgets";
+import { HeaderMobile } from "@/widgets/HeaderMobile";
+
+// Platform-account feature: keys belong to the signed-in user, not to a DAO,
+// so this page lives at the root (main sidebar "API" entry), outside any
+// /{daoId} context.
+export const metadata: Metadata = {
+ title: "API Keys — Anticapture",
+ description:
+ "Create and manage API keys to query Anticapture from Claude, Cursor, or Codex.",
+ alternates: { canonical: "/api-keys" },
+ robots: { index: false, follow: false },
+};
+
+export default function ApiKeysPage() {
+ return (
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ );
+}
diff --git a/apps/dashboard/features/api-keys/ApiKeysManager.tsx b/apps/dashboard/features/api-keys/ApiKeysManager.tsx
new file mode 100644
index 000000000..4e4c6bcf5
--- /dev/null
+++ b/apps/dashboard/features/api-keys/ApiKeysManager.tsx
@@ -0,0 +1,139 @@
+"use client";
+
+import { Code, Plus } from "lucide-react";
+import { useEffect, useRef, useState } from "react";
+
+import { Button } from "@/shared/components";
+import { DividerDefault } from "@/shared/components/design-system/divider/DividerDefault";
+import { Modal } from "@/shared/components/design-system/modal/Modal";
+import { SectionTitle } from "@/shared/components/design-system/section/section-title/SectionTitle";
+import { useSession } from "@/shared/services/auth/client";
+import { useLogin } from "@/shared/services/auth/LoginProvider";
+import type { UserApiKey } from "@/shared/services/user-api/apiKeysClient";
+
+import { ApiKeysTable } from "./components/ApiKeysTable";
+import { ConnectAgentSection } from "./components/ConnectAgentSection";
+import { CreateApiKeyModal } from "./components/CreateApiKeyModal";
+import { SaveApiKeyModal } from "./components/SaveApiKeyModal";
+import { useApiKeys } from "./hooks/useApiKeys";
+
+export const ApiKeysManager = () => {
+ const { data: session, isPending } = useSession();
+ const { openLogin } = useLogin();
+ const isAuthed = !isPending && !!session;
+
+ const { keys, isLoading, create, revoke } = useApiKeys(isAuthed);
+
+ const [createOpen, setCreateOpen] = useState(false);
+ // Holds the just-created plaintext for the one-time reveal modal.
+ const [created, setCreated] = useState<{
+ token: string;
+ label: string;
+ } | null>(null);
+ // Plaintexts of keys created this session, so the connect command can
+ // embed the real key ("Your key is already in it"). Never persisted.
+ const [sessionTokens, setSessionTokens] = useState>(
+ {},
+ );
+ const [lastCreatedId, setLastCreatedId] = useState(null);
+ const [toRevoke, setToRevoke] = useState(null);
+
+ const handleCreate = (label: string) => {
+ create.mutate(label, {
+ onSuccess: (key) => {
+ setCreateOpen(false);
+ setCreated({ token: key.token, label: key.label });
+ setSessionTokens((prev) => ({ ...prev, [key.id]: key.token }));
+ setLastCreatedId(key.id);
+ },
+ });
+ };
+
+ const handleRevoke = () => {
+ if (!toRevoke) return;
+ revoke.mutate(toRevoke.id, { onSuccess: () => setToRevoke(null) });
+ };
+
+ // Signed-out arrivals get the sign-in modal right away, over the page —
+ // no interstitial screen. The gate is consumed on the FIRST session
+ // resolution either way, so it neither re-pops after a dismissal nor when
+ // the user signs out while on the page; gated actions (Create key)
+ // re-open it.
+ const autoOpenedLogin = useRef(false);
+ useEffect(() => {
+ if (isPending || autoOpenedLogin.current) return;
+ autoOpenedLogin.current = true;
+ if (!session) openLogin();
+ }, [isPending, session, openLogin]);
+
+ return (
+
+
+
+ }
+ title="API Keys"
+ description="Query Anticapture from Claude, Cursor, or Codex. Just ask in natural language."
+ />
+
+
+
+ {isLoading ? (
+
Loading…
+ ) : (
+
+ )}
+
+
+
+
+
+
+
+
+ {created && (
+ !open && setCreated(null)}
+ token={created.token}
+ label={created.label}
+ />
+ )}
+
+ !open && setToRevoke(null)}
+ title="Revoke API key"
+ description={
+ toRevoke
+ ? `This permanently revokes "${toRevoke.label}". Any agent using it stops working right away. This can't be undone.`
+ : ""
+ }
+ confirmLabel="Revoke"
+ cancelLabel="Cancel"
+ confirmVariant="destructive"
+ isConfirmLoading={revoke.isPending}
+ onConfirm={handleRevoke}
+ >
+
+
+
+ {selected.lastUsedAt
+ ? `Last call from your AI ${formatRelativeTime(
+ Date.parse(selected.lastUsedAt) / 1000,
+ ).toLowerCase()}`
+ : "Waiting for the first call from your AI…"}
+
+
+ )}
+
+
+ );
+};
diff --git a/apps/dashboard/features/api-keys/components/CreateApiKeyModal.tsx b/apps/dashboard/features/api-keys/components/CreateApiKeyModal.tsx
new file mode 100644
index 000000000..11d0b1006
--- /dev/null
+++ b/apps/dashboard/features/api-keys/components/CreateApiKeyModal.tsx
@@ -0,0 +1,62 @@
+"use client";
+
+import { useEffect, useState } from "react";
+
+import { Modal } from "@/shared/components/design-system/modal/Modal";
+import { Input } from "@/shared/components/design-system/form/fields/input/Input";
+
+/**
+ * Names a new key, then delegates the one-time plaintext reveal to the caller
+ * (which opens SaveApiKeyModal). Keeps its own submitting state so double
+ * confirms can't mint twice.
+ */
+export const CreateApiKeyModal = ({
+ open,
+ onOpenChange,
+ onCreate,
+ isCreating,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ onCreate: (label: string) => void;
+ isCreating: boolean;
+}) => {
+ const [label, setLabel] = useState("");
+
+ // Reset the field whenever the modal (re)opens.
+ useEffect(() => {
+ if (open) setLabel("");
+ }, [open]);
+
+ const trimmed = label.trim();
+
+ return (
+ onCreate(trimmed)}
+ >
+
+
+ setLabel(e.target.value)}
+ />
+
+
+ );
+};
diff --git a/apps/dashboard/features/api-keys/components/SaveApiKeyModal.tsx b/apps/dashboard/features/api-keys/components/SaveApiKeyModal.tsx
new file mode 100644
index 000000000..c379fb7f7
--- /dev/null
+++ b/apps/dashboard/features/api-keys/components/SaveApiKeyModal.tsx
@@ -0,0 +1,65 @@
+"use client";
+
+import { Check, Copy } from "lucide-react";
+import { useState } from "react";
+
+import { Modal } from "@/shared/components/design-system/modal/Modal";
+import { cn } from "@/shared/utils/cn";
+
+/**
+ * Shown once, right after a key is created: the plaintext is never retrievable
+ * again, so the copy affordance is prominent and closing is a deliberate
+ * confirm ("I've saved it").
+ */
+export const SaveApiKeyModal = ({
+ open,
+ onOpenChange,
+ token,
+ label,
+}: {
+ open: boolean;
+ onOpenChange: (open: boolean) => void;
+ token: string;
+ label: string;
+}) => {
+ const [copied, setCopied] = useState(false);
+
+ const copy = async () => {
+ await navigator.clipboard.writeText(token);
+ setCopied(true);
+ setTimeout(() => setCopied(false), 2000);
+ };
+
+ return (
+ onOpenChange(false)}
+ >
+
+
+
+ {token}
+
+
+
+
+
+ );
+};
diff --git a/apps/dashboard/features/api-keys/hooks/useApiKeys.ts b/apps/dashboard/features/api-keys/hooks/useApiKeys.ts
new file mode 100644
index 000000000..438e52142
--- /dev/null
+++ b/apps/dashboard/features/api-keys/hooks/useApiKeys.ts
@@ -0,0 +1,50 @@
+"use client";
+
+import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
+
+import {
+ createApiKey,
+ listApiKeys,
+ revokeApiKey,
+ type CreatedApiKey,
+ type UserApiKey,
+} from "@/shared/services/user-api/apiKeysClient";
+
+const QUERY_KEY = ["user-api", "api-keys"] as const;
+
+/**
+ * API-key list + create/revoke against the User API (session-authenticated
+ * via the /api/user proxy). `enabled` gates the fetch on an authenticated
+ * session so we don't fire a guaranteed 401 before sign-in.
+ */
+export const useApiKeys = (enabled: boolean) => {
+ const queryClient = useQueryClient();
+
+ const query = useQuery({
+ queryKey: QUERY_KEY,
+ queryFn: async () => (await listApiKeys()).items,
+ enabled,
+ });
+
+ const invalidate = () =>
+ queryClient.invalidateQueries({ queryKey: QUERY_KEY });
+
+ const create = useMutation({
+ mutationFn: (label: string) => createApiKey(label),
+ onSuccess: invalidate,
+ });
+
+ const revoke = useMutation({
+ mutationFn: (id: string) => revokeApiKey(id),
+ onSuccess: invalidate,
+ });
+
+ return {
+ keys: (query.data ?? []) as UserApiKey[],
+ isLoading: query.isLoading,
+ isError: query.isError,
+ refetch: query.refetch,
+ create,
+ revoke,
+ };
+};
diff --git a/apps/dashboard/features/api-keys/index.ts b/apps/dashboard/features/api-keys/index.ts
new file mode 100644
index 000000000..471c1a87d
--- /dev/null
+++ b/apps/dashboard/features/api-keys/index.ts
@@ -0,0 +1 @@
+export { ApiKeysManager } from "./ApiKeysManager";
diff --git a/apps/dashboard/shared/services/user-api/apiKeysClient.ts b/apps/dashboard/shared/services/user-api/apiKeysClient.ts
new file mode 100644
index 000000000..fdddbde37
--- /dev/null
+++ b/apps/dashboard/shared/services/user-api/apiKeysClient.ts
@@ -0,0 +1,49 @@
+// Hand-written client for the User API self-service API-keys surface, reached
+// through the same-origin /api/user proxy with the better-auth session cookie.
+// The User API is not part of Gateful's OpenAPI, so it has no generated SDK.
+
+const BASE = "/api/user/me/api-keys";
+
+export type UserApiKey = {
+ id: string;
+ label: string;
+ createdAt: string;
+ revokedAt: string | null;
+ /** From Authful; null when never used or Authful was unreachable. */
+ lastUsedAt: string | null;
+};
+
+/** Create returns the plaintext exactly once — never retrievable again. */
+export type CreatedApiKey = UserApiKey & { token: string };
+
+export class ApiKeysRequestError extends Error {
+ constructor(readonly status: number) {
+ super(`api-keys request failed with status ${status}`);
+ this.name = "ApiKeysRequestError";
+ }
+}
+
+const request = async (url: string, init?: RequestInit): Promise => {
+ const res = await fetch(url, {
+ ...init,
+ credentials: "include",
+ headers: {
+ ...(init?.body ? { "content-type": "application/json" } : {}),
+ ...init?.headers,
+ },
+ });
+ if (!res.ok) throw new ApiKeysRequestError(res.status);
+ if (res.status === 204) return undefined as T;
+ return (await res.json()) as T;
+};
+
+export const listApiKeys = () => request<{ items: UserApiKey[] }>(BASE);
+
+export const createApiKey = (label: string) =>
+ request(BASE, {
+ method: "POST",
+ body: JSON.stringify({ label }),
+ });
+
+export const revokeApiKey = (id: string) =>
+ request(`${BASE}/${id}`, { method: "DELETE" });
diff --git a/apps/dashboard/widgets/HeaderMobile.tsx b/apps/dashboard/widgets/HeaderMobile.tsx
index 2360e3775..280d62597 100644
--- a/apps/dashboard/widgets/HeaderMobile.tsx
+++ b/apps/dashboard/widgets/HeaderMobile.tsx
@@ -5,6 +5,7 @@ import {
Menu,
BarChart4,
BookOpen,
+ Code,
Heart,
HelpCircle,
Bell,
@@ -45,6 +46,12 @@ export const HeaderMobile = ({
label: "Alerts",
icon: Bell,
},
+ {
+ page: "api-keys",
+ isGlobal: true,
+ label: "API",
+ icon: Code,
+ },
],
[],
);
diff --git a/apps/dashboard/widgets/HeaderSidebar.tsx b/apps/dashboard/widgets/HeaderSidebar.tsx
index a27be5c3e..514513cf2 100644
--- a/apps/dashboard/widgets/HeaderSidebar.tsx
+++ b/apps/dashboard/widgets/HeaderSidebar.tsx
@@ -1,6 +1,6 @@
"use client";
-import { BarChart4, Bell } from "lucide-react";
+import { BarChart4, Bell, Code } from "lucide-react";
import Link from "next/link";
import { useMemo } from "react";
@@ -26,6 +26,12 @@ export const HeaderSidebar = () => {
icon: Bell,
isGlobal: true,
},
+ {
+ page: "api-keys",
+ label: "API",
+ icon: Code,
+ isGlobal: true,
+ },
],
[],
);