diff --git a/app/src/global-config.ts b/app/src/global-config.ts
index c4614fbe82e..1dd28e0ff7f 100644
--- a/app/src/global-config.ts
+++ b/app/src/global-config.ts
@@ -12,7 +12,7 @@ interface GlobalConfig {
};
isDev: boolean;
features: {
- /** The admin-only "Use with my data" agent chat (`/debug/agent`). */
+ /** The "Use with my data" agent chat (`/agent`, and `/debug/agent` for admins). */
agentChat: boolean;
};
}
@@ -22,7 +22,7 @@ const apiBaseUrl = import.meta.env.VITE_API_URL || 'http://localhost:8000';
/**
* Build-time switch for the agent chat (`docs/concepts/agent-network.md`).
* Vite inlines the variable, so with the flag unset this is the literal
- * `false` and the bundler drops every branch behind it, the `.adapt()` probe
+ * `false` and the bundler drops every branch behind it, the `.use_my_data()` probe
* included. The route in `src/routes/index.tsx` reads the variable inline for
* the same reason, so the chat page's chunk is not even built.
*/
diff --git a/app/src/hooks/useAgentLaneOpen.ts b/app/src/hooks/useAgentLaneOpen.ts
index b31de5cef2f..ec6eb07b480 100644
--- a/app/src/hooks/useAgentLaneOpen.ts
+++ b/app/src/hooks/useAgentLaneOpen.ts
@@ -1,7 +1,7 @@
/**
* Whether the public lane of the agent chat is open (`GET /agent/lane`), for
- * the entry points: the plot page's button, the landing page's line and the
- * public chat page itself.
+ * the entry points: the plot page's `.use_my_data()`, the landing page's line
+ * and the public chat page itself.
*
* The API answers `{"open": true}` while the owner has the lane open and 404
* while it is closed, so one switch on the server opens and closes every entry
diff --git a/app/src/pages/DebugPage.test.tsx b/app/src/pages/DebugPage.test.tsx
index a85344f3e61..5c58e42d458 100644
--- a/app/src/pages/DebugPage.test.tsx
+++ b/app/src/pages/DebugPage.test.tsx
@@ -101,7 +101,7 @@ describe('DebugPage', () => {
expect(screen.getAllByText('scatter-basic').length).toBeGreaterThan(0);
});
// A 200 from /debug/status marks this browser as an admin's, which lets
- // the plot page show the agent chat's `.adapt()` button.
+ // the plot page show the agent chat's `.use_my_data()` entry.
expect(localStorage.getItem('anyplot.adminHint')).toBe('1');
});
diff --git a/app/src/pages/DebugPage.tsx b/app/src/pages/DebugPage.tsx
index cb5b874521c..31687089311 100644
--- a/app/src/pages/DebugPage.tsx
+++ b/app/src/pages/DebugPage.tsx
@@ -106,7 +106,7 @@ const PING_HISTORY = 30;
// in sessionStorage so it survives reloads of the same tab without
// persisting across browser sessions (src/utils/adminAuth.ts).
// A 200 from /debug/status also sets the admin hint that lets the plot page
-// show the agent chat's `.adapt()` button; a refusal clears it.
+// show the agent chat's `.use_my_data()` entry; a refusal clears it.
export function DebugPage() {
const { trackPageview } = useAnalytics();
diff --git a/app/src/pages/SpecPage.test.tsx b/app/src/pages/SpecPage.test.tsx
index 80df966c177..ef11dd2a388 100644
--- a/app/src/pages/SpecPage.test.tsx
+++ b/app/src/pages/SpecPage.test.tsx
@@ -57,6 +57,12 @@ vi.mock('src/sections/spec-detail/SpecDetailView', () => ({
SpecDetailView: () =>
SpecDetailView
,
}));
+// The lane `useAgentEligibility` names for the pair; null is no entry (also while it probes).
+const agentEligibility: { lane: 'admin' | 'public' | null } = { lane: null };
+vi.mock('src/hooks/useAgentEligibility', () => ({
+ useAgentEligibility: () => agentEligibility.lane,
+}));
+
const mockSpecData = {
id: 'scatter-basic',
title: 'Basic Scatter Plot',
@@ -85,6 +91,7 @@ beforeEach(() => {
vi.restoreAllMocks();
mockParams = { specId: 'scatter-basic' };
mockNavigate.mockReset();
+ agentEligibility.lane = null;
});
function mockFetchSuccess(data = mockSpecData) {
@@ -235,6 +242,22 @@ describe('SpecPage', () => {
});
});
+ it('puts .use_my_data() beside the carousel only for an eligible pair', async () => {
+ mockParams = { specId: 'scatter-basic', language: 'python', library: 'matplotlib' };
+ mockFetchSuccess();
+ const { rerender } = render();
+
+ await screen.findByRole('button', { name: 'Back to library comparison' });
+ expect(screen.queryByRole('button', { name: 'Use with my data' })).not.toBeInTheDocument();
+
+ agentEligibility.lane = 'public';
+ rerender();
+ await userEvent.click(await screen.findByRole('button', { name: 'Use with my data' }));
+ expect(mockNavigate).toHaveBeenCalledWith(
+ '/agent?spec=scatter-basic&library=matplotlib&language=python&source=plot_page'
+ );
+ });
+
it('does not report a miss when the implementation exists', async () => {
mockParams = { specId: 'scatter-basic', language: 'python', library: 'matplotlib' };
mockFetchSuccess();
diff --git a/app/src/pages/SpecPage.tsx b/app/src/pages/SpecPage.tsx
index 28528c48b84..1f3f0c0a6f3 100644
--- a/app/src/pages/SpecPage.tsx
+++ b/app/src/pages/SpecPage.tsx
@@ -309,11 +309,11 @@ export function SpecPage() {
[specId, trackEvent, mode, fetchCode]
);
- // "Use with my data": for an eligible pair, while the agent chat is built.
- // An admin gets the admin chat, through a full navigation so Cloudflare
- // Access can intercept the /debug path; everyone else gets the public chat
- // while its lane is open, through the router. The chat page records
- // `agent_open{source: plot_page}`.
+ // "Use with my data" (`.use_my_data()` beside the library carousel): for an
+ // eligible pair, while the agent chat is built. An admin gets the admin chat,
+ // through a full navigation so Cloudflare Access can intercept the /debug
+ // path; everyone else gets the public chat while its lane is open, through
+ // the router. The chat page records `agent_open{source: plot_page}`.
const agentLane = useAgentEligibility(mode === 'detail' ? specId : null, currentImpl?.library_id);
const handleUseWithMyData = useCallback(() => {
if (!specId || !currentImpl) return;
@@ -624,6 +624,7 @@ export function SpecPage() {
selectedLibrary={selectedLibrary || ''}
onSelect={handleLibrarySelect}
onAll={() => navigate(specPath(specId!))}
+ onUseWithMyData={agentLane ? handleUseWithMyData : undefined}
/>
({
const lane = vi.hoisted(() => ({ open: false as boolean | null }));
vi.mock('src/hooks/useAgentLaneOpen', () => ({ useAgentLaneOpen: () => lane.open }));
-import { AGENT_LANE_LINE, HeroSection } from 'src/sections/landing/HeroSection';
+import { AGENT_LANE_LABEL, AGENT_LANE_LINE, HeroSection } from 'src/sections/landing/HeroSection';
describe('HeroSection', () => {
beforeEach(() => {
@@ -31,17 +31,30 @@ describe('HeroSection', () => {
lane.open = false;
});
- it('points to the public agent chat only while its lane is open', () => {
+ it('announces .use_my_data() only while the public lane is open, leading to the catalogue', async () => {
+ const user = userEvent.setup();
const { rerender } = render();
- expect(screen.queryByText(new RegExp(AGENT_LANE_LINE))).not.toBeInTheDocument();
+ expect(screen.queryByRole('link', { name: AGENT_LANE_LABEL })).not.toBeInTheDocument();
lane.open = true;
rerender();
- const link = screen.getByRole('link', { name: new RegExp(AGENT_LANE_LINE) });
- expect(link).toHaveAttribute(
- 'href',
- '/agent?spec=scatter-basic&library=matplotlib&language=python&source=landing'
- );
+ const link = screen.getByRole('link', { name: AGENT_LANE_LABEL });
+ expect(link).toHaveTextContent(`# ${AGENT_LANE_LINE} →`);
+ expect(link).toHaveTextContent('# new: pick any plot, then .use_my_data() →');
+ expect(link).not.toHaveTextContent(/experimental/i);
+ expect(link).toHaveAttribute('href', '/plots');
+
+ await user.click(link);
+ expect(trackEvent).toHaveBeenCalledWith('nav_click', {
+ source: 'hero_use_my_data',
+ target: '/plots',
+ });
+ });
+
+ it('keeps the line hidden while the lane probe is pending', () => {
+ lane.open = null;
+ render();
+ expect(screen.queryByRole('link', { name: AGENT_LANE_LABEL })).not.toBeInTheDocument();
});
it('tracks the primary browse CTA, mcp link and github link', async () => {
diff --git a/app/src/sections/landing/HeroSection.tsx b/app/src/sections/landing/HeroSection.tsx
index a10d4b3bee6..444dcf4d235 100644
--- a/app/src/sections/landing/HeroSection.tsx
+++ b/app/src/sections/landing/HeroSection.tsx
@@ -209,7 +209,11 @@ export function HeroSection({ potd = null }: HeroSectionProps) {
/>
-
+
+ trackEvent('nav_click', { source: 'hero_use_my_data', target: paths.plots })
+ }
+ />
{/* Right: terminal plot */}
@@ -222,19 +226,25 @@ export function HeroSection({ potd = null }: HeroSectionProps) {
}
/**
- * The one line that points to the public agent chat, shown only while its lane is open
- * (`useAgentLaneOpen`), so closing the lane on the server removes it too. It opens the chat
- * on a plot everyone knows; `source=landing` feeds `agent_open`.
+ * The one line that announces "use with my data", shown only while the public agent lane
+ * is open (`useAgentLaneOpen`), so closing the lane on the server removes it too. It leads
+ * to the catalogue rather than into the chat: the entry is `.use_my_data()` beside a plot
+ * page's library carousel, so the visitor picks the plot first. Tracked as
+ * `nav_click{source: hero_use_my_data}`. No "experimental" here: the chat's notice says it.
*/
-export const AGENT_LANE_LINE = 'experimental: adapt any plot to your own data';
+export const AGENT_LANE_LINE = 'new: pick any plot, then .use_my_data()';
+/** The line's accessible name, in words rather than the method-call style. */
+export const AGENT_LANE_LABEL = 'New: pick any plot, then use it with my data';
-function AgentLaneLine() {
+function AgentLaneLine({ onClick }: { onClick: () => void }) {
const open = useAgentLaneOpen();
if (open !== true) return null;
return (
- # {AGENT_LANE_LINE} →
+ {/* A no-break space keeps the arrow with `.use_my_data()` when a narrow phone wraps
+ the line. */}
+ # {AGENT_LANE_LINE}
+ {' '}→
);
}
diff --git a/app/src/sections/spec-detail/LibraryPills.test.tsx b/app/src/sections/spec-detail/LibraryPills.test.tsx
index 6feafca4475..183a6ac0b98 100644
--- a/app/src/sections/spec-detail/LibraryPills.test.tsx
+++ b/app/src/sections/spec-detail/LibraryPills.test.tsx
@@ -1,6 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
-import { LibraryPills } from 'src/sections/spec-detail/LibraryPills';
+import { LibraryPills, USE_MY_DATA_LABEL } from 'src/sections/spec-detail/LibraryPills';
import { render, screen, userEvent } from 'src/test-utils';
const mockImplementations = [
@@ -89,4 +89,52 @@ describe('LibraryPills', () => {
expect(screen.getAllByText('matplotlib').length).toBeGreaterThan(0);
});
+
+ it('shows .use_my_data() only when SpecPage passes onUseWithMyData', async () => {
+ const onUseWithMyData = vi.fn();
+ const user = userEvent.setup();
+ const props = {
+ implementations: mockImplementations,
+ selectedLibrary: 'matplotlib',
+ onSelect: vi.fn(),
+ onAll: vi.fn(),
+ };
+ const { rerender } = render();
+ expect(screen.queryByRole('button', { name: USE_MY_DATA_LABEL })).not.toBeInTheDocument();
+
+ rerender();
+ const action = screen.getByRole('button', { name: 'Use with my data' });
+ // Label and arrow are two spans set apart by the flex gap, not by a space.
+ expect(action).toHaveTextContent('.use_my_data()→');
+ // "experimental" is the chat's notice to make, not the entry's.
+ expect(screen.queryByText(/experimental/i)).not.toBeInTheDocument();
+ await user.click(action);
+ expect(onUseWithMyData).toHaveBeenCalledTimes(1);
+ expect(props.onAll).not.toHaveBeenCalled();
+ });
+
+ it('opens .use_my_data() from the keyboard, after the carousel in the tab order', async () => {
+ const onUseWithMyData = vi.fn();
+ const user = userEvent.setup();
+ render(
+
+ );
+
+ await user.tab();
+ expect(screen.getByRole('button', { name: 'Back to library comparison' })).toHaveFocus();
+ await user.tab();
+ await user.tab();
+ await user.tab();
+ const action = screen.getByRole('button', { name: USE_MY_DATA_LABEL });
+ expect(action).toHaveFocus();
+ await user.keyboard('{Enter}');
+ await user.keyboard(' ');
+ expect(onUseWithMyData).toHaveBeenCalledTimes(2);
+ });
});
diff --git a/app/src/sections/spec-detail/LibraryPills.tsx b/app/src/sections/spec-detail/LibraryPills.tsx
index e28a411e6e0..43c4c5713f1 100644
--- a/app/src/sections/spec-detail/LibraryPills.tsx
+++ b/app/src/sections/spec-detail/LibraryPills.tsx
@@ -32,6 +32,96 @@ interface LibraryPillsProps {
selectedLibrary: string;
onSelect: (libraryId: string) => void;
onAll?: () => void;
+ /**
+ * "Use with my data": present only when SpecPage's `useAgentEligibility` names a lane
+ * for this pair. Renders `.use_my_data() →` to the right of the carousel, the mirror of
+ * `← .compare()` on its left.
+ */
+ onUseWithMyData?: () => void;
+}
+
+/**
+ * Accessible name of the agent chat entry; the visible label is `.use_my_data() →`. It
+ * says nothing the label does not: "experimental" is the chat's notice to make, once open.
+ */
+export const USE_MY_DATA_LABEL = 'Use with my data';
+
+/**
+ * One of the two text actions that flank the carousel: mono, `--ink-soft`, green on
+ * hover, the arrow nudging outwards. `arrow` says on which side the arrow sits.
+ */
+function TextAction({
+ arrow,
+ ariaLabel,
+ onActivate,
+ children,
+}: {
+ arrow: 'left' | 'right';
+ ariaLabel: string;
+ onActivate: () => void;
+ children: React.ReactNode;
+}) {
+ const arrowGlyph = (
+
+ {arrow === 'left' ? '←' : '→'}
+
+ );
+ return (
+ {
+ if (e.key === 'Enter' || e.key === ' ') {
+ e.preventDefault();
+ onActivate();
+ }
+ }}
+ aria-label={ariaLabel}
+ sx={{
+ display: 'inline-flex',
+ alignItems: 'center',
+ gap: 0.75,
+ fontFamily: typography.mono,
+ fontSize: fontSize.sm,
+ color: 'var(--ink-soft)',
+ cursor: 'pointer',
+ whiteSpace: 'nowrap',
+ transition: 'color 0.2s',
+ '& .action-arrow': { transition: 'transform 0.2s' },
+ '&:hover': { color: colors.primary },
+ '&:hover .action-arrow': { transform: `translateX(${arrow === 'left' ? -3 : 3}px)` },
+ '&:focus-visible': {
+ outline: `2px solid ${colors.primary}`,
+ outlineOffset: 2,
+ borderRadius: '2px',
+ },
+ }}
+ >
+ {arrow === 'left' && arrowGlyph}
+ {children}
+ {arrow === 'right' && arrowGlyph}
+
+ );
+}
+
+/**
+ * Vertical rule between a text action and the carousel, desktop only. The margin next to
+ * the carousel is the smaller one: it compensates for the arrow IconButton's ~5 px internal
+ * padding so the visible whitespace on either side of the rule is equal.
+ */
+function ActionRule({ side }: { side: 'left' | 'right' }) {
+ return (
+
+ );
}
export const LibraryPills = memo(function LibraryPills({
@@ -39,6 +129,7 @@ export const LibraryPills = memo(function LibraryPills({
selectedLibrary,
onSelect,
onAll,
+ onUseWithMyData,
}: LibraryPillsProps) {
// Sort implementations alphabetically
const sortedImpls = useMemo(() => {
@@ -87,73 +178,62 @@ export const LibraryPills = memo(function LibraryPills({
if (sortedImpls.length === 0) return null;
+ // Layout. From md up, one row on a three-column grid whose side columns are equal
+ // (`minmax(0, 1fr)`): the carousel is the centre column, so it stays centred over the
+ // image whether or not the right-hand action is there, and the action can fade in after
+ // the eligibility probe without moving anything. Below md the carousel's own width
+ // leaves no room beside it, so the two text actions share a line above it, `.compare()`
+ // at the left edge and `.use_my_data()` at the right, and the carousel takes the line
+ // below. That first line is there for `.compare()` anyway, so the late action does not
+ // move the page either.
return (
- {onAll && (
- <>
- {
- if (e.key === 'Enter' || e.key === ' ') {
- e.preventDefault();
- onAll();
- }
- }}
- aria-label="Back to library comparison"
- sx={{
- display: 'inline-flex',
- alignItems: 'center',
- gap: 0.75,
- fontFamily: typography.mono,
- fontSize: fontSize.sm,
- color: 'var(--ink-soft)',
- cursor: 'pointer',
- whiteSpace: 'nowrap',
- transition: 'color 0.2s',
- '& .all-arrow': { transition: 'transform 0.2s' },
- '&:hover': { color: colors.primary },
- '&:hover .all-arrow': { transform: 'translateX(-3px)' },
- '&:focus-visible': {
- outline: `2px solid ${colors.primary}`,
- outlineOffset: 2,
- borderRadius: '2px',
- },
- }}
- >
-
- ←
-
- .compare()
-
- {/* Vertical divider. Asymmetric mx compensates for the prev IconButton's
- ~5 px internal left padding so the visible whitespace on either side
- of the rule is equal. */}
-
- >
- )}
+
+ {onAll && (
+ <>
+
+ .compare()
+
+
+ >
+ )}
+
- {/* Carousel row — stays horizontal even when the wrapper stacks on xs */}
-
+ {/* Carousel row; in the DOM between the two actions, so the tab order follows the
+ desktop row from left to right */}
+
{/* Left Arrow */}
+
+
+ {onUseWithMyData && (
+
+
+
+ .use_my_data()
+
+
+ )}
+
);
});
diff --git a/app/src/sections/spec-detail/SpecDetailView.test.tsx b/app/src/sections/spec-detail/SpecDetailView.test.tsx
index 3a666413935..20f105ea681 100644
--- a/app/src/sections/spec-detail/SpecDetailView.test.tsx
+++ b/app/src/sections/spec-detail/SpecDetailView.test.tsx
@@ -122,17 +122,9 @@ describe('SpecDetailView', () => {
expect(screen.getByRole('button', { name: /show interactive/i })).toBeInTheDocument();
});
- it('shows the .adapt() button only when SpecPage passes onUseWithMyData', async () => {
- const onUseWithMyData = vi.fn();
- const user = userEvent.setup();
- const { rerender } = render();
- expect(
- screen.queryByRole('button', { name: 'Use with my data (experimental)' })
- ).not.toBeInTheDocument();
-
- rerender();
- await user.click(screen.getByRole('button', { name: 'Use with my data (experimental)' }));
- expect(onUseWithMyData).toHaveBeenCalledTimes(1);
+ it('keeps the "use with my data" entry off the image (it sits beside the carousel)', () => {
+ render();
+ expect(screen.queryByRole('button', { name: /use with my data/i })).not.toBeInTheDocument();
});
it('shows implementation counter with current/total', () => {
diff --git a/app/src/sections/spec-detail/SpecDetailView.tsx b/app/src/sections/spec-detail/SpecDetailView.tsx
index 9a1e73d5301..bf1761ea3f9 100644
--- a/app/src/sections/spec-detail/SpecDetailView.tsx
+++ b/app/src/sections/spec-detail/SpecDetailView.tsx
@@ -12,7 +12,6 @@ import DownloadIcon from '@mui/icons-material/Download';
import ImageOutlinedIcon from '@mui/icons-material/ImageOutlined';
import OpenInNewIcon from '@mui/icons-material/OpenInNew';
import PlayArrowIcon from '@mui/icons-material/PlayArrow';
-import TableChartOutlinedIcon from '@mui/icons-material/TableChartOutlined';
import ThumbDownIcon from '@mui/icons-material/ThumbDown';
import ThumbDownOutlinedIcon from '@mui/icons-material/ThumbDownOutlined';
import ThumbUpIcon from '@mui/icons-material/ThumbUp';
@@ -51,13 +50,6 @@ interface SpecDetailViewProps {
onCopyCode: (impl: Implementation) => void;
onDownload: (impl: Implementation) => void;
onTrackEvent: (event: string, props?: Record) => void;
- /**
- * "Use with my data": present only when the agent chat is built, the pair is
- * eligible, and this browser is an admin's (or local development) or the
- * public lane is open — SpecPage decides (`useAgentEligibility`). Renders the
- * fourth overlay button.
- */
- onUseWithMyData?: () => void;
}
export function SpecDetailView({
@@ -75,7 +67,6 @@ export function SpecDetailView({
onCopyCode,
onDownload,
onTrackEvent,
- onUseWithMyData,
}: SpecDetailViewProps) {
const sortedImpls = [...implementations].sort((a, b) => a.library_id.localeCompare(b.library_id));
const currentIndex = sortedImpls.findIndex(impl => impl.library_id === selectedLibrary);
@@ -611,21 +602,6 @@ export function SpecDetailView({
)}
- {currentImpl && onUseWithMyData && (
-
- {
- (e.currentTarget as HTMLElement).blur();
- onUseWithMyData();
- }}
- aria-label="Use with my data (experimental)"
- sx={overlayBtnSx}
- size="medium"
- >
-
-
-
- )}
{implementations.length > 1 && !zoomed && (
diff --git a/app/src/utils/adminAuth.ts b/app/src/utils/adminAuth.ts
index 586d1f72fc3..2add500cf6d 100644
--- a/app/src/utils/adminAuth.ts
+++ b/app/src/utils/adminAuth.ts
@@ -9,7 +9,7 @@
* - The admin hint is a localStorage flag that `DebugPage` sets once
* `/debug/status` answered 200 and clears when it is refused. It is a UI
* hint, never an authorization: it only decides whether a public page may
- * probe `/debug/agent/*` at all (the `.adapt()` button), so public visitors
+ * probe `/debug/agent/*` at all (the `.use_my_data()` entry), so public visitors
* never send a request to the debug API. The server's admin gate still
* decides every call.
*
diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts
index 6521f487214..6f7f9207ee7 100644
--- a/app/src/vite-env.d.ts
+++ b/app/src/vite-env.d.ts
@@ -3,7 +3,7 @@
interface ImportMetaEnv {
readonly VITE_API_URL?: string;
readonly VITE_DEBUG_API_URL?: string;
- /** `true` builds the admin-only agent chat page and the `.adapt()` button. */
+ /** `true` builds the agent chat page and the plot page's `.use_my_data()` entry. */
readonly VITE_ENABLE_AGENT_CHAT?: string;
}
diff --git a/changelog.d/use-my-data-entry.md b/changelog.d/use-my-data-entry.md
new file mode 100644
index 00000000000..6b2e1b63986
--- /dev/null
+++ b/changelog.d/use-my-data-entry.md
@@ -0,0 +1,15 @@
+### Changed
+
+- **The plot page's way into "use with my data" is a text action beside the
+ library carousel.** `.use_my_data() →` sits to the right of the carousel as
+ the mirror of `← .compare()` on its left and replaces the data-table icon on
+ the image, which said little and crowded the overlay buttons. It keeps the
+ icon's gating (open lane or an admin, eligible pair), its chat link and
+ `agent_open`'s `source=plot_page`; "experimental" is left to the chat's
+ notice. The carousel now sits centred over the image, so the action fades
+ in after the eligibility check without moving anything; on a phone or
+ tablet the two actions share the line above the carousel (#12539).
+- **The landing page's line points to the catalogue.** While the public lane
+ is open it reads `# new: pick any plot, then .use_my_data() →` and leads to
+ `/plots` instead of opening the chat on one fixed plot, tracked as
+ `nav_click{source: hero_use_my_data}` (#12539).
diff --git a/docs/concepts/agent-network.md b/docs/concepts/agent-network.md
index 0c14d853e40..de53abfe7c5 100644
--- a/docs/concepts/agent-network.md
+++ b/docs/concepts/agent-network.md
@@ -83,8 +83,8 @@ Deviations from the first sketch, each evidence-backed: there is no data-intake
### Request flow for "Use with my data"
-1. The `.adapt()` overlay button in `SpecDetailView` does a full navigation to `/debug/agent?spec=&library=&language=`.
-2. `POST /debug/agent/sessions {spec_id, library, locale}`. The BFF validates the spec and library against `core/constants.py` and the enabled libraries from anyplot-agents `GET /v1/status` (cached), loads a catalogue snapshot through `core/catalogue/queries.py` (title, description, data roles, notes, noqa-stripped code, library version), derives the user id, and forwards it. anyplot-agents normalises the code, runs the readiness scan and the SECURITY validator, and answers `422 not_eligible` for blocked pairs (the 13 map specs, validator failures, files without a `THEME` block or with a non-literal save target). anyplot-agents has no database access in phase 1. `GET /debug/agent/eligibility?spec=&library=` gives the button the same answer. `locale` is the browser's locale, recorded with a feedback case; it sets no language, and every session starts in English (see [Session language](#session-language)).
+1. The `.use_my_data()` action beside the plot page's library carousel (`LibraryPills`; an `.adapt()` button on the image until 2026-11-02) does a full navigation to `/debug/agent?spec=&library=&language=` for an admin. A public visitor goes through the router to `/agent?spec=&library=&language=` instead, whose requests take the same steps on `/agent/*` (see the **Public page** bullet in [Serving and infrastructure](#serving-and-infrastructure)); the steps here name the admin routes.
+2. `POST /debug/agent/sessions {spec_id, library, locale}`. The BFF validates the spec and library against `core/constants.py` and the enabled libraries from anyplot-agents `GET /v1/status` (cached), loads a catalogue snapshot through `core/catalogue/queries.py` (title, description, data roles, notes, noqa-stripped code, library version), derives the user id, and forwards it. anyplot-agents normalises the code, runs the readiness scan and the SECURITY validator, and answers `422 not_eligible` for blocked pairs (the 13 map specs, validator failures, files without a `THEME` block or with a non-literal save target). anyplot-agents has no database access in phase 1. `GET /debug/agent/eligibility?spec=&library=` gives the plot page's entry the same answer. `locale` is the browser's locale, recorded with a feedback case; it sets no language, and every session starts in English (see [Session language](#session-language)).
3. `POST .../dataset {text}` runs the deterministic parse and returns a 20-row preview, the `DatasetProfile`, default bindings and warnings. A deterministic pre-filter over every header and cell for text addressed to an AI, then the dataset judge with a data rubric, refuse instruction-like datasets. Both look for injection only: names, postal and e-mail addresses, phone numbers, web addresses and amounts are the user's data and plot like any other value. The judge sees every header and the same five sample rows and top values the root and the adapter get (every value of a column with at most 12 distinct ones, else the five most frequent), with cells in full, in parts of about 4,000 characters, one judge call per part (one part for every eval fixture). When the paste carried notes around the table (`context_lines`, see [Parse](#parse)), they go through the same pre-filter and then, after the dataset judge passed the table, one scope-judge call with the scope rubric, the lines fenced as ``. A pre-filter hit, a verdict other than `in_scope` or no verdict drops every note with a warning and stores the table all the same; no verdict on notes is a strike, because a hidden injection in a copied web table is not the user's doing, and the judge's tokens are booked to the user like the dataset judge's (the `notes_judge` attribution line carries the verdict and the counts, never the text). The route answers the kept notes as `context_lines`, the stored dataset keeps them, and every version that `prepare_data`, `generate_data` or `.to_long()` makes from it carries them on; a new paste replaces them. Judging the notes at the paste rather than in the turn that delivers them keeps every place the root can read them (the summary turn, `get_dataset_profile`) behind one verdict, and lets the data panel show exactly what the assistant got (`# notes sent to the assistant`). No other LLM call happens for a table. Text that is no table the parser can read (prose such as "a bar chart with dog 7, cat 3 and bird 4", `label value` lines without a separator, a list of names typed one per line; at most 4,000 characters and 200 non-blank lines, so that a list without a header fits the schema's 200 rows, never a JSON array or object, which the parser reads or refuses itself) takes the transcriber path instead; free text past those limits is `422 unparseable` with the limit as its cause, never stored as it parsed. One column of typed values (integers, numbers, dates, booleans: the input of a histogram, a density or a box plot), of digit codes kept as text (`01067`) or of categories under a header, at least one repeating, is a table and never reaches the transcriber (live round 7 of 2026-10-16: 800 commute times timed out the model every time); a category list whose header has the categories' form (no `_`, brackets, `:`, `/`, `%`, `#` or digit, and no more words than a category) is stored with the short note `read '' as the column name`, since no cheap rule tells `Tier` above `Hund, Katze` from `Apfel` above `Birne, Kirsche`; a header with a column name's form gets no note. A list of values without a header line keeps its first line as the first value, in a column named `value`, with a warning (`parse._header_as_row`); one of 20,001 values is refused as too many rows. A transcription whose answer stops at its output cap or breaks the schema's bounds (`models.AnswerCutOff`, not retried), or that runs out of time on a text of at least half the limits (`transcribe.near_transcribe_cap`), is `422 unparseable` with a cause that names the text's line and character count and asks for a separator; a short text that times out (a stalled endpoint: the budget is the only clock, no request has a timeout of its own), an error, a quota or an unparseable answer is `503 guard_unavailable` (owner rule of 2026-10-11): the pre-filter runs on the raw text, the judge model reads it under the `TranscribedTable` schema (`agents/anyplot/data/transcribe.py`, rubric `prompts/transcriber.md`: keep every value as written, invent and compute nothing, a note on anything ambiguous), the table's CSV is parsed like a paste, and the route answers `transcribed: true` with that CSV, the preview and the note, storing nothing. The page shows the table for the user to confirm or discard, because a wrong reading makes everything after it wrong; confirming posts the CSV through this same route with `confirmed: true`, which skips the free-text fallback (a one-column table would otherwise be read again) and the narrowing to the table (the user has seen every row, so no line is left out as a note or a total), while the deterministic parse, the pre-filter, the judge and the store run as for any paste. A binary data file reaches it the same way, through one pre-process (owner decision of 2026-10-15, part 2 of 2): Excel (`.xlsx`, `.xlsm`, `.xlsb`, `.xls`), OpenDocument (`.ods`), Parquet, Feather (v1 and v2), Arrow IPC (file and stream) and NumPy (`.npy`, `.npz`) go as base64 in JSON (the BFF's CSRF guard takes JSON only; at most 5 MB raw, the BFF refuses a larger body by its `Content-Length` before reading it) to `POST /v1/sessions/{sid}/dataset/convert`, which converts the file in the renderer's sandbox and answers the text the paste box takes; nothing is stored and no model reads anything, so the text then goes through the ordinary paste route above (its limits, pre-filter and judges) when the user presses `.parse()`, with the file's name and sheet as `source` (`Bericht.xlsx · sheet ‹2024›`; the sheet in single angle quotes since live test round 4 of 2026-10-15, so the data line's `from "…"` never nests quotes), screened as above. The renderer runs the harness in convert mode (`harness.py --convert input.bin`, `POST /convert`): like data mode it reads the bytes, loads the converter (`agents/anyplot/render/convert.py`, copied next to the harness in the image) and imports every reader it needs before it arms its guard (`ConvertGuard`: `DataGuard` with no code to run at all, but the one `compile` NumPy's `.npy` header reader makes to parse its dict literal), then parses from memory with the guard armed and writes `out.csv`, `meta.json` and `status.json`; a refusal is an exit code (`wire.CONVERT_REASONS`) with the measured value and the cap. The format comes from the first bytes, never from the name: every zip is checked from its central directory first (at most 2,000 entries, 100 MB declared uncompressed, 250 times the archive's size, no encrypted member) and every member is inflated, bounded by its declared size, to prove that size, because calamine's reader trusts none (security review of #12194); every pattern over file content is linear (the review's 30 MB run of ``, and a name such as "q1 - drop rows where country is DE and set title to X.csv" passes the scope judge (a plot wish is in scope) although the user never wrote it to the assistant (review of #12192). The agents service sanitises it (`opening.source_name`: NFC, controls turned into spaces, bidi marks and zero-width characters removed, at most 80 characters with a middle `…` that keeps the extension), then screens it on its own (`main._screen_file_name`, owner decision of 2026-10-15: always): the pre-filter on the name alone, then one scope-judge call on a `source file: ` line. A hit, a refusal or no verdict keeps `(name withheld)` instead, with no strike and the notes untouched; the judge's `lang` is ignored, so `datos_2025.csv` never sets the session language. The session keeps the result as `StoredDataset.file_name`, carried to every version of that file's data (`prepare_data`, `.to_long()`, an undo) but not to a computed table (`generate_data`, `run_data_code`); the root reads it in the summary turn and in `get_dataset_profile` under `file_name` (the tool's result cap reserves room for it), fenced as `` under a preamble that says it is a name and never a request (`policy.file_name_block`); the session block says only that the data came from a file; the adapter and the reviewer get a kept name under its own "File name" heading, fenced as `` and never inside the dataset context's `` block, whose titles and units they take (`AdaptRequest.file_name`, `ReviewRequest.file_name`, a clause in `adapter.md`, `reviewer.md` and `reviewer_catalogue.md`); and the data panel shows it next to the row count. It is never logged (the `file_name_judge` attribution line carries the verdict and the length) and never sent to Plausible. A file counts as one paste toward the paste limits.
4. The user adjusts the bindings in dropdowns (`PUT .../bindings`), confirms the data with its bindings (`POST .../dataset/confirm`, step 1 of two, see "Two steps: data first, then the plot" below) and clicks **Create plot**, which sends `POST .../messages {action: "create_plot"}`. The server sets the context variable `REQUEST_KIND=action`, so the judge is skipped, and sends a fixed message. Since 2026-10-14 (owner decision: "queue only the pipeline, not every chat message") a turn starts at once: the stream sends `ready` and the root runs. Only the sandbox work waits in the run queue (see the bounds table below): when the root calls `plot_pipeline` (after its readiness check) or `run_data_code`, that call takes the queue's slot and the turn's start in the rate window, the stream sends `status{step:"queued", position, waiting}` while it waits, and the request deadline, paused during the wait, resumes with the time it had left when the slot comes. A plain question, a refusal, `prepare_data` and `generate_data` never wait. So a user sees `ready`, `message`, `done` for a question, and `ready`, `status{queued}` (only when another run holds the slot or the minute's start is spent), `status{adapting}` and the pipeline's steps, `plot`, `message`, `done` for a plot.
5. Root call 1 calls `plot_pipeline({})`. `PipelineArgs` has no spec, library or dataset field; those come from server-set session state, so neither the model nor an injection can redirect the pipeline. Its `theme` is the one theme the run renders and the reviewer sees; the root sets it only when the user asks for a theme. Left out, the run takes the site theme the page sent with the turn (`POST .../messages?theme=`, owner decision of 2026-11-01 after user test E2, where the dark site got a bright plot), so a change after the user switched the site follows the switch; a change of a version whose theme the user asked for keeps that theme (`CodeVersion.theme_asked`), and a caller without a page (`adk web`, the evals) keeps the latest version's theme, else light (`pipeline.run_theme`). The page sends the theme as a query parameter because the BFF's and the service's bodies are strict, and an unknown parameter lets the page, the API and the service deploy in any order. Without a dataset, with incomplete bindings or without the user's confirmation of the data the pipeline returns `PlotResult(status="not_ready", reason=...)` (`no_dataset`, `incomplete_bindings`, `data_unconfirmed`) before any LLM call.
@@ -534,8 +534,8 @@ Built on 2026-10-10: the harness, the two fixture generators and the 146 fixture
- **BFF** in `api/routers/agent.py`: `APIRouter(prefix="/debug/agent", dependencies=[Depends(require_admin)])`. A small refactor `require_admin_identity()` returns `AdminIdentity(email|None, via)` and `require_admin` wraps it unchanged. `user_id = "adm_" + HMAC(key, email or "token")[:16]`. POST requests need `Content-Type: application/json`, `X-Anyplot-Client: agent-chat/1` and an allowed Origin. ID tokens come from `google.oauth2.id_token.fetch_id_token` (skipped for localhost). The messages route is an async generator declared with `response_class=EventSourceResponse` (which is what gives FastAPI's automatic 15 s pings; Cloudflare returns 524 after 125 s), reads upstream with httpx `aiter_lines()`, assembles complete events, re-validates each event type against the `anyplot/1` allowlist, and yields `ServerSentEvent(event=..., raw_data=...)`; on upstream failure it emits `error{code:"upstream"}`. Routes mirror `/v1` plus `GET eligibility`, `POST sessions/{sid}/feedback`, and the triage routes `GET /debug/agent/cases`, `GET /debug/agent/cases/{id}` and `PATCH /debug/agent/cases/{id}`. The deploy smoke test expects 401 on `/debug/agent/status`.
- **Public lane** in `api/routers/agent_public.py` (since 2026-11-02, shipped dark): `APIRouter(prefix="/agent")` behind `AGENT_PUBLIC_ENABLED`, which calls the admin lane's route functions with its own context. The identity is `require_public_identity`, the one seam a bot check (Turnstile) plugs into: the page's tab id from `X-Anyplot-Anon` plus the client's network from `client_ip` (Cloudflare's `CF-Connecting-IP`; IPv4 address or IPv6 /64), both forwarded upstream for the agents service to salt (`X-Anyplot-Anon`, `X-Anyplot-Network`), with `X-Anyplot-Tier: public`. The session id travels as `X-Anyplot-Session`, never in a URL, because Cloud Run keeps request URLs with the client address for 30 days. Admin-only and absent: the fan-out's `dataset/copy` and `source: fanout`, `GET /budget` and the `/cases*` triage; a test maps every admin route onto a public one or that list. `GET /agent/lane` answers `{"open": true}` without an identity or an upstream call, so the frontend learns at runtime whether to show the entry points: one switch opens and closes everything. A per-instance stream cap (`AGENT_PUBLIC_MAX_STREAMS`, 20) and a per-network hourly feedback cap (`AGENT_PUBLIC_FEEDBACK_PER_HOUR`, 20) are the lane's own; the per-user and per-IP limits live in the agents service. The route table is in [the API reference](../reference/api.md#public-lane).
- **SSE protocol `anyplot/1`**, translated from ADK events and never forwarded raw: `ready{v, run_id}`, sent when the turn starts, at once; `status{step:"queued", position, waiting}` mid-turn while its `plot_pipeline` or `run_data_code` call waits in the run queue, at once, on every change and every 15 s unchanged (`position` 1 runs next, `waiting` counts every queued entry, this one included); `status{step, attempt}` for the pipeline's steps; `message{text}` (root final text only), `plot{PlotResult}`, `refusal{code, text}`, `error{code, ref}` with codes `capacity` (also for a run that waited the queue's maximum), `run_limit` (since 2026-10-21, a sandbox run past the user's `AGENT_USER_MAX_RUNS` across sessions), `deadline`, `guard_unavailable`, `upstream` and `internal`, and `done{llm_calls, tokens, bindings?, data_confirmed?, dataset?, language?}` (the last four are optional: after a run `bindings` and `data_confirmed` carry the session's bindings and whether the data is confirmed, so the page syncs its dropdowns and its step after a turn in which the root's `set_bindings` changed them, `dataset` carries the new data version after a turn in which the root's `prepare_data` or `generate_data` changed the data, and `language` carries the session language as `{language, source}`, `source` one of `default`, `pasted`, `detected` and `chosen`, so the page syncs its chip (the dataset route's answer carries the same field after a paste); a turn refused before the queue, such as the budget refusal, and a session gone meanwhile omit them). The translator tolerates the ADK 2.x `node_info` and `output` fields. A user already over the daily budget gets `ready`, `refusal{code:"budget"}` and `done` without waiting in the queue. Because queued time does not count toward the agents service's deadline, the BFF restarts its own turn budget (`AGENT_REQUEST_TIMEOUT_S`) on every queued status, with the queue's 15 s heartbeat on top because the run may start that long before its first event, and on the first event after the wait. Nothing moves a turn past `AGENT_TURN_MAX_S` (1,190 s), which stays below anyplot-api's `--timeout=1200`: a turn still queued when its run could no longer finish inside that cap ends with `error{code:"capacity"}`, and closing the upstream stream takes it out of the queue before it spends a token.
-- **Frontend.** A lazy `app/src/pages/AgentChatPage.tsx` at `debug/agent`, laid out as one chat column (since 2026-10-11, "chat first"). The data card (`app/src/sections/agent-chat/DataPanel.tsx`) is the first turn of the thread: a textarea with a 1 MB counter (200 KB until 2026-10-14), preview table, binding dropdowns, `.confirm()` at its foot, and the `# changed` block with `.undo()` after the assistant changed the data or computed it from a formula, without `.undo()` when the formula made the session's first data; a formula pasted into the textarea gets the server's `422 formula`, and the card offers `.send()`, which sends the text to the chat as a turn. The assistant's summary of a stored parse is the next turn, and each data change updates the card in place. After `.confirm()` the card collapses into a header pinned under the site's sticky masthead (`✓ data fits · N rows · M columns · version V`), whose `.show()` opens the full card again without leaving step 2; any binding change, undo, data request or new paste drops the confirmation on the server, and the page returns to step 1 with the card open. The header then stays, as `! confirm again · N rows · M columns · version V` with the phrase in bold on a warning-tinted surface, and its `.show()` leads back to the card from anywhere in the thread, for example after a data request sent from below the plot. The thread gets a warning notice that names what dropped the confirmation (`bindings changed`, `the data changed`, `not confirmed on the server`), and the card's step line repeats it next to `.confirm()`; the first binding setup after a paste, where nothing was confirmed yet, gets none. The step line follows the data the card shows: a data request or undo after the loss names the changed data, and a new paste, which nobody has confirmed yet, names no loss. A table read from free text that waits for `.use()` or `.discard()` holds the page in step 1, even when a turn reports the server's confirmation of the stored data. While the header shows, the page's `scroll-padding-top` keeps keyboard focus and scrolled-to content below it. Below the thread sit a progress grid (since 2026-10-12, the owner's variant A: one row per round, `first try`, `second try`, ... (`repair 1` until live round 9 of 2026-10-16, which read as no second round; a running repair round says what it repairs in words on the right, `repairing text size, layout`, from the rubric ids, `Round.defects`, that the round before passed on), the same four steps in the same columns so each step lines up under the round above, and how the round ended on the right, such as `✓ ok`, `✗ review: 3 findings`, `✗ 2 measured`, `✗ code check failed` or, for the shipped round of a `needs_attention` result, `~ 1 finding open` (the round marked `shipped`, which is not always the last: a repair that fails its check leaves the earlier render shipped); the step a round stopped at shows a red ✗ and the steps after it a hollow dot; the facts come from `PlotResult.rounds` and the `outcome` and `findings` that the `repairing` status carries for the round before it; the result card keeps the same grid under `# rounds` and names the shipped round the same way (`second try`), or, when an earlier round's render won, which one (`first try shipped of 2`); on a phone the round's label and outcome take a line above its steps), a Stop button (`AbortController` plus the cancel route) and the composer. While a turn without pipeline steps runs (the summary, a question, a data request), the thread shows an in-progress line in the assistant's slot (`summarizing your data …`, `working on your message …`) until the first reply or pipeline step; the progress grid shows only for a plot run, a queue position or pipeline steps, so the two never say the same thing. While anything is in flight, the bindings block says that the bindings and `.confirm()` wait, and a running parse says so under `.parse()`. Once a dataset exists, the composer always shows `.create_plot()`: before the confirmation as the quiet button next to `.send()`, whose click calls nothing and shows a notice under the composer that leads to `.confirm()` (`# confirm the data first · …`, with `.show()`, which scrolls to the button and focuses it; for a table read from text that still waits, it leads to `.use()`). The notice reads the data card at every render, so a table read from text that arrives or is discarded after the click changes its text and the target of its `.show()`. In step 2 it is the filled action: with an empty box it starts the first plot as it is, with text the text goes as the user's turn, so the assistant makes the first plot with that change. Once the library has a plot, the composer refines with `.send()` (a question or a change on the latest version) and keeps a quiet `.start_over()` beside it (since 2026-10-12, `.create_plot()` before: the owner could not tell the two apart), described as "new from the catalogue plot, without your changes": a fresh plot of the same data from the catalogue code. A wide table (since 2026-10-12, `agents/anyplot/data/shape.py`): when the spec wants one required label role and exactly one required numeric role, no numeric series family, and the table holds 2 to 20 numeric columns and no text or boolean column (date columns are kept as keys), the dataset answers carry `suggestion`, the `pivot_longer` into two columns named in the session language (`Weekday`, `Month` or `Year` when every melted header is a weekday, month or year of one language, else `Category`, and `Value`; German `Wochentag`, `Monat`, `Jahr`, `Kategorie`, `Wert`; `shape.LONG_NAMES` for en, de, fr, es, it, pt, nl and pl, English otherwise; English for every session until live round 9 of 2026-10-16, where `Weekday` and `Value` became a German heatmap's axis and colorbar titles; the language route and every turn's `done` return the recomputed offer, so the card always names what `.to_long()` applies; a name that an input column already has gives way to the role's name, then to a numbered one, so names never decide the offer; `.to_long()` binds the two new columns to the roles they were made for, `LongSuggestion.bindings`, so `Category` never takes a spec's `category` role by its name), since live test round 8 of 2026-10-16, where the role names `y` and `value` became the plot's title "Wert nach Wochentag und Stunde"; the data card shows it as `# wide table` with `.to_long()`, which `POST /v1/sessions/{sid}/dataset/to_long` applies without a model call as the next version with the new columns bound to their roles, the user's bindings on the kept columns carried over and the defaults for the rest (undo as usual), and the root's profile tool carries it as `to_long` so the summary names it in one sentence. Since live test round 7 of 2026-10-16 a wide matrix gets it too: a spec whose required roles are two label roles of a grid or grouped chart (`shape.MATRIX_ROLES`: a heatmap's `x` and `y`, a grouped box's `category` and `subcategory`, a stacked bar's `component`; never a flow's `source` and `target`, a hierarchy's `name` and `parent` or a party's `color`) and one numeric role, and a table of one row-label column with a distinct label in every row (the one text column, else the one date column, else a first column of whole numbers that is a year or every hour of a range) plus at least three numeric columns (`Stunde;Mo;...;So`) is offered the `pivot_longer` of the numeric columns into the second label role, the row labels kept as the key for the first. Years and parts of a date are keys in both rules, never melted (an unnamed 1-to-12 column counts as a month only right after a year column that repeats), and so is a first column that numbers the rows (`id`, `Nr`, `Stunde` 0 to 3), which keeps the category of `id,a,b,c` on `id`. The numeric columns the offer melts (`bindings.label_siblings`) are never bound by default to a role that takes labels, also when `carry_bindings` refills the open roles after an undo: the role stays open, so the bindings are incomplete until `.to_long()` (the default had bound the heatmap's `y` to `Mo` and `value` to `Di`, and a confirmed run drew a meaningless matrix after 170 s). Bound so by hand, the binding is valid with a warning, shown under the dropdown (the dataset answer's `suggestion.siblings`) and in the session block, where the root offers `.to_long()` before any plot; blocking `.confirm()` on it is an open owner decision. Since 2026-10-12 the chat header carries a language chip next to the library menu (the library pills until 2026-11-02): `lang: en`, `lang: de · from data` after a paste set it from the notes around the table or the column names (since 2026-10-14; one `#` notice), `lang: de · detected` after the first typed message set it (the thread then shows one `#` line that names the chip), or `lang: de · chosen`. A click opens a popover with the ten common languages and a field for any other language in Latin letters; a choice calls the language route, and a `done` with `language` updates the chip, never moving it from `chosen` back down to `detected` or `default`. Keyboard focus follows the steps: after the user's own `.confirm()` it moves to `.create_plot()` before the first plot and to the header's `.show()` after it, a turn that reports the confirmation again leaves it where it is, and a key press on a composer button that a run replaces with Stop moves it into the message box, so a second press never stops the run. The **result card** (`app/src/sections/agent-chat/ResultCard.tsx`) reuses the plot page's overlay actions: the rendered plot shown inline as a blob URL, in a box that takes the loaded PNG's own aspect because the footer strip makes it taller than 16:9, with a light and dark switch that renders the other theme on demand through the theme toggle route, **Copy image** (Clipboard API `navigator.clipboard.write([new ClipboardItem({"image/png": blob})])`, falling back to download where unsupported), **Download PNG** for every rendered theme and **Open full size**; the adapted code in `CodeHighlighter` with **Copy code** (`useCopyCode`, one click), **Download plot.py** and **Download data.csv** (the pair runs unchanged); the change list and residual notes; and the quick-feedback control. The card has no composer of its own (since 2026-10-12): a change request or a question about the plot goes through the thread's one composer below it, and the assistant decides whether it is a change or an answer. Earlier versions stay reachable in the thread, each with its own image and code. The same card is reused unchanged when the feature goes public. `app/src/lib/sse.ts` parses the stream over `fetchWithAuth(...).body.getReader()`. The `.adapt()` button is the fourth overlay button in `app/src/sections/spec-detail/SpecDetailView.tsx`, wired through `onUseWithMyData` from `SpecPage.tsx`, and renders only when `CONFIG.features.agentChat && (CONFIG.isDev || adminHint) && eligible`, where `adminHint` is a localStorage flag (`anyplot.adminHint`, `app/src/utils/adminAuth.ts`) that `DebugPage` sets after `/debug/status` succeeds and clears on a refusal, and `eligible` comes from the eligibility route (`app/src/hooks/useAgentEligibility.ts`). It does a full navigation so Cloudflare Access can intercept, and public pages never probe `/api/debug/*`. As built on 2026-10-10: the dataset response lists the spec's roles (`{name, kinds, required, variadic, description}`), so every role gets a dropdown, a variadic family one per member (`y1`, `y2`, ...) plus an empty one for the next, kept contiguous on the client; a refused binding set answers with the check's lines, which the panel lists; the `plot` event carries the stored `version`, which addresses the artifact and theme toggle routes (see [Agent chat](../reference/api.md#agent-chat)); the page starts nothing while a turn, a theme render, a parse or a binding change is in flight, since the server would refuse it with `409 run_active`; Stop calls the cancel route and keeps the stream open until the server's `done` (at most 30 s), so a result stored just before the stop still arrives; the session is also deleted on `pagehide` with `keepalive`, and since nothing restores it, the page asks before it goes while the session holds data, a table read from text, a plot or work in flight (since 2026-11-01, after a reload dropped the 15 plots of a `.plot_all()` unasked): a reload, a closed tab or a typed address gets the browser's `beforeunload` dialog, and a link inside the site or the back button gets the router's blocker with a `confirm`, while the page's own query edits (a library, `&all=1`) and the reload links after `session_expired` or `unreachable` pass (`app/src/hooks/useLeaveWarning.ts`); a request without a readable answer (in production usually an expired Cloudflare Access session) reloads the page once, then offers a reload link; the queue position shows as a small counter and as a `queued · 2 of 3` line above the progress grid; the button adds `source=plot_page` to the URL, which the page reads for `agent_open` and then drops. `app/scripts/agent-bff-mock.mjs` serves the documented BFF routes and a scripted stream locally (for `scatter-basic` and the series family of `line-multi`), so the page can be driven in a browser without the API, the agents service or a model.
-- **Public page** (since 2026-11-02, launch package): `/agent?spec=&library=&language=` is the same `AgentChatPage` on the public lane (`PublicAgentChatPage`), an ordinary route that opens while `GET /agent/lane` says the lane is open (`useAgentLaneOpen`, one request per page load) and says the assistant is closed otherwise. Its requests go to `/agent/*` through `src/lib/agent.ts` with the credential `PUBLIC_LANE` in place of the admin token: no cookie, the tab's random id from sessionStorage as `X-Anyplot-Anon`, the session id moved from the path into `X-Anyplot-Session` (`publicRoute`). Above the first input it shows the experimental notice (an AI, which one and where, can be wrong and is not checked by hand, what not to paste, nothing stored without feedback, daily limits; the EU AI Act's Art. 50 notice), with a link to the legal page's `#assistant`; while the session plots in Highcharts, both lanes show the licence note. It offers no `all`, and the `# include data.csv` boxes of the feedback form and `.save()` start unticked, the feedback form with a consent line that says what sending stores (`sections/agent-chat/lane.ts`). The entry points follow the same switch: the plot page's `.adapt()` button (tooltip `.adapt() · use with my data · experimental`) shows for everyone while the lane is open and the pair is eligible, an admin still going to `/debug/agent`, and the landing page carries one line that opens the chat on scatter-basic in matplotlib (`agent_open{source: landing}`).
+- **Frontend.** A lazy `app/src/pages/AgentChatPage.tsx` at `debug/agent`, laid out as one chat column (since 2026-10-11, "chat first"). The data card (`app/src/sections/agent-chat/DataPanel.tsx`) is the first turn of the thread: a textarea with a 1 MB counter (200 KB until 2026-10-14), preview table, binding dropdowns, `.confirm()` at its foot, and the `# changed` block with `.undo()` after the assistant changed the data or computed it from a formula, without `.undo()` when the formula made the session's first data; a formula pasted into the textarea gets the server's `422 formula`, and the card offers `.send()`, which sends the text to the chat as a turn. The assistant's summary of a stored parse is the next turn, and each data change updates the card in place. After `.confirm()` the card collapses into a header pinned under the site's sticky masthead (`✓ data fits · N rows · M columns · version V`), whose `.show()` opens the full card again without leaving step 2; any binding change, undo, data request or new paste drops the confirmation on the server, and the page returns to step 1 with the card open. The header then stays, as `! confirm again · N rows · M columns · version V` with the phrase in bold on a warning-tinted surface, and its `.show()` leads back to the card from anywhere in the thread, for example after a data request sent from below the plot. The thread gets a warning notice that names what dropped the confirmation (`bindings changed`, `the data changed`, `not confirmed on the server`), and the card's step line repeats it next to `.confirm()`; the first binding setup after a paste, where nothing was confirmed yet, gets none. The step line follows the data the card shows: a data request or undo after the loss names the changed data, and a new paste, which nobody has confirmed yet, names no loss. A table read from free text that waits for `.use()` or `.discard()` holds the page in step 1, even when a turn reports the server's confirmation of the stored data. While the header shows, the page's `scroll-padding-top` keeps keyboard focus and scrolled-to content below it. Below the thread sit a progress grid (since 2026-10-12, the owner's variant A: one row per round, `first try`, `second try`, ... (`repair 1` until live round 9 of 2026-10-16, which read as no second round; a running repair round says what it repairs in words on the right, `repairing text size, layout`, from the rubric ids, `Round.defects`, that the round before passed on), the same four steps in the same columns so each step lines up under the round above, and how the round ended on the right, such as `✓ ok`, `✗ review: 3 findings`, `✗ 2 measured`, `✗ code check failed` or, for the shipped round of a `needs_attention` result, `~ 1 finding open` (the round marked `shipped`, which is not always the last: a repair that fails its check leaves the earlier render shipped); the step a round stopped at shows a red ✗ and the steps after it a hollow dot; the facts come from `PlotResult.rounds` and the `outcome` and `findings` that the `repairing` status carries for the round before it; the result card keeps the same grid under `# rounds` and names the shipped round the same way (`second try`), or, when an earlier round's render won, which one (`first try shipped of 2`); on a phone the round's label and outcome take a line above its steps), a Stop button (`AbortController` plus the cancel route) and the composer. While a turn without pipeline steps runs (the summary, a question, a data request), the thread shows an in-progress line in the assistant's slot (`summarizing your data …`, `working on your message …`) until the first reply or pipeline step; the progress grid shows only for a plot run, a queue position or pipeline steps, so the two never say the same thing. While anything is in flight, the bindings block says that the bindings and `.confirm()` wait, and a running parse says so under `.parse()`. Once a dataset exists, the composer always shows `.create_plot()`: before the confirmation as the quiet button next to `.send()`, whose click calls nothing and shows a notice under the composer that leads to `.confirm()` (`# confirm the data first · …`, with `.show()`, which scrolls to the button and focuses it; for a table read from text that still waits, it leads to `.use()`). The notice reads the data card at every render, so a table read from text that arrives or is discarded after the click changes its text and the target of its `.show()`. In step 2 it is the filled action: with an empty box it starts the first plot as it is, with text the text goes as the user's turn, so the assistant makes the first plot with that change. Once the library has a plot, the composer refines with `.send()` (a question or a change on the latest version) and keeps a quiet `.start_over()` beside it (since 2026-10-12, `.create_plot()` before: the owner could not tell the two apart), described as "new from the catalogue plot, without your changes": a fresh plot of the same data from the catalogue code. A wide table (since 2026-10-12, `agents/anyplot/data/shape.py`): when the spec wants one required label role and exactly one required numeric role, no numeric series family, and the table holds 2 to 20 numeric columns and no text or boolean column (date columns are kept as keys), the dataset answers carry `suggestion`, the `pivot_longer` into two columns named in the session language (`Weekday`, `Month` or `Year` when every melted header is a weekday, month or year of one language, else `Category`, and `Value`; German `Wochentag`, `Monat`, `Jahr`, `Kategorie`, `Wert`; `shape.LONG_NAMES` for en, de, fr, es, it, pt, nl and pl, English otherwise; English for every session until live round 9 of 2026-10-16, where `Weekday` and `Value` became a German heatmap's axis and colorbar titles; the language route and every turn's `done` return the recomputed offer, so the card always names what `.to_long()` applies; a name that an input column already has gives way to the role's name, then to a numbered one, so names never decide the offer; `.to_long()` binds the two new columns to the roles they were made for, `LongSuggestion.bindings`, so `Category` never takes a spec's `category` role by its name), since live test round 8 of 2026-10-16, where the role names `y` and `value` became the plot's title "Wert nach Wochentag und Stunde"; the data card shows it as `# wide table` with `.to_long()`, which `POST /v1/sessions/{sid}/dataset/to_long` applies without a model call as the next version with the new columns bound to their roles, the user's bindings on the kept columns carried over and the defaults for the rest (undo as usual), and the root's profile tool carries it as `to_long` so the summary names it in one sentence. Since live test round 7 of 2026-10-16 a wide matrix gets it too: a spec whose required roles are two label roles of a grid or grouped chart (`shape.MATRIX_ROLES`: a heatmap's `x` and `y`, a grouped box's `category` and `subcategory`, a stacked bar's `component`; never a flow's `source` and `target`, a hierarchy's `name` and `parent` or a party's `color`) and one numeric role, and a table of one row-label column with a distinct label in every row (the one text column, else the one date column, else a first column of whole numbers that is a year or every hour of a range) plus at least three numeric columns (`Stunde;Mo;...;So`) is offered the `pivot_longer` of the numeric columns into the second label role, the row labels kept as the key for the first. Years and parts of a date are keys in both rules, never melted (an unnamed 1-to-12 column counts as a month only right after a year column that repeats), and so is a first column that numbers the rows (`id`, `Nr`, `Stunde` 0 to 3), which keeps the category of `id,a,b,c` on `id`. The numeric columns the offer melts (`bindings.label_siblings`) are never bound by default to a role that takes labels, also when `carry_bindings` refills the open roles after an undo: the role stays open, so the bindings are incomplete until `.to_long()` (the default had bound the heatmap's `y` to `Mo` and `value` to `Di`, and a confirmed run drew a meaningless matrix after 170 s). Bound so by hand, the binding is valid with a warning, shown under the dropdown (the dataset answer's `suggestion.siblings`) and in the session block, where the root offers `.to_long()` before any plot; blocking `.confirm()` on it is an open owner decision. Since 2026-10-12 the chat header carries a language chip next to the library menu (the library pills until 2026-11-02): `lang: en`, `lang: de · from data` after a paste set it from the notes around the table or the column names (since 2026-10-14; one `#` notice), `lang: de · detected` after the first typed message set it (the thread then shows one `#` line that names the chip), or `lang: de · chosen`. A click opens a popover with the ten common languages and a field for any other language in Latin letters; a choice calls the language route, and a `done` with `language` updates the chip, never moving it from `chosen` back down to `detected` or `default`. Keyboard focus follows the steps: after the user's own `.confirm()` it moves to `.create_plot()` before the first plot and to the header's `.show()` after it, a turn that reports the confirmation again leaves it where it is, and a key press on a composer button that a run replaces with Stop moves it into the message box, so a second press never stops the run. The **result card** (`app/src/sections/agent-chat/ResultCard.tsx`) reuses the plot page's overlay actions: the rendered plot shown inline as a blob URL, in a box that takes the loaded PNG's own aspect because the footer strip makes it taller than 16:9, with a light and dark switch that renders the other theme on demand through the theme toggle route, **Copy image** (Clipboard API `navigator.clipboard.write([new ClipboardItem({"image/png": blob})])`, falling back to download where unsupported), **Download PNG** for every rendered theme and **Open full size**; the adapted code in `CodeHighlighter` with **Copy code** (`useCopyCode`, one click), **Download plot.py** and **Download data.csv** (the pair runs unchanged); the change list and residual notes; and the quick-feedback control. The card has no composer of its own (since 2026-10-12): a change request or a question about the plot goes through the thread's one composer below it, and the assistant decides whether it is a change or an answer. Earlier versions stay reachable in the thread, each with its own image and code. The same card is reused unchanged when the feature goes public. `app/src/lib/sse.ts` parses the stream over `fetchWithAuth(...).body.getReader()`. The entry is the text action `.use_my_data() →` to the right of the library carousel in `app/src/sections/spec-detail/LibraryPills.tsx`, the mirror of `← .compare()` on its left, with no "experimental" of its own, which the chat's notice says once open (until 2026-11-02 it was the `.adapt()` button, a fourth overlay button on the image in `SpecDetailView.tsx`); it is wired through `onUseWithMyData` from `SpecPage.tsx` and renders only when `useAgentEligibility` (`app/src/hooks/useAgentEligibility.ts`) names a lane for the pair: the admin lane when `CONFIG.features.agentChat && (CONFIG.isDev || adminHint)`, else the public lane while `CONFIG.features.agentChat` holds and `GET /agent/lane` says it is open, in both cases only when that lane's eligibility route answers `eligible`. `adminHint` is a localStorage flag (`anyplot.adminHint`, `app/src/utils/adminAuth.ts`) that `DebugPage` sets after `/debug/status` succeeds and clears on a refusal. For an admin the entry does a full navigation to `/debug/agent` so Cloudflare Access can intercept; a public visitor goes through the router to `/agent`, and public pages never probe `/api/debug/*`. As built on 2026-10-10: the dataset response lists the spec's roles (`{name, kinds, required, variadic, description}`), so every role gets a dropdown, a variadic family one per member (`y1`, `y2`, ...) plus an empty one for the next, kept contiguous on the client; a refused binding set answers with the check's lines, which the panel lists; the `plot` event carries the stored `version`, which addresses the artifact and theme toggle routes (see [Agent chat](../reference/api.md#agent-chat)); the page starts nothing while a turn, a theme render, a parse or a binding change is in flight, since the server would refuse it with `409 run_active`; Stop calls the cancel route and keeps the stream open until the server's `done` (at most 30 s), so a result stored just before the stop still arrives; the session is also deleted on `pagehide` with `keepalive`, and since nothing restores it, the page asks before it goes while the session holds data, a table read from text, a plot or work in flight (since 2026-11-01, after a reload dropped the 15 plots of a `.plot_all()` unasked): a reload, a closed tab or a typed address gets the browser's `beforeunload` dialog, and a link inside the site or the back button gets the router's blocker with a `confirm`, while the page's own query edits (a library, `&all=1`) and the reload links after `session_expired` or `unreachable` pass (`app/src/hooks/useLeaveWarning.ts`); a request without a readable answer (in production usually an expired Cloudflare Access session) reloads the page once, then offers a reload link; the queue position shows as a small counter and as a `queued · 2 of 3` line above the progress grid; the entry adds `source=plot_page` to the URL, which the page reads for `agent_open` and then drops. `app/scripts/agent-bff-mock.mjs` serves the documented BFF routes and a scripted stream locally (for `scatter-basic` and the series family of `line-multi`), so the page can be driven in a browser without the API, the agents service or a model.
+- **Public page** (since 2026-11-02, launch package): `/agent?spec=&library=&language=` is the same `AgentChatPage` on the public lane (`PublicAgentChatPage`), an ordinary route that opens while `GET /agent/lane` says the lane is open (`useAgentLaneOpen`, one request per page load) and says the assistant is closed otherwise. Its requests go to `/agent/*` through `src/lib/agent.ts` with the credential `PUBLIC_LANE` in place of the admin token: no cookie, the tab's random id from sessionStorage as `X-Anyplot-Anon`, the session id moved from the path into `X-Anyplot-Session` (`publicRoute`). Above the first input it shows the experimental notice (an AI, which one and where, can be wrong and is not checked by hand, what not to paste, nothing stored without feedback, daily limits; the EU AI Act's Art. 50 notice), with a link to the legal page's `#assistant`; while the session plots in Highcharts, both lanes show the licence note. It offers no `all`, and the `# include data.csv` boxes of the feedback form and `.save()` start unticked, the feedback form with a consent line that says what sending stores (`sections/agent-chat/lane.ts`). The entry points follow the same switch: the plot page's `.use_my_data() →` beside the library carousel (accessible name "Use with my data"; until 2026-11-02 an `.adapt()` button on the image) shows for everyone while the lane is open and the pair is eligible, an admin still going to `/debug/agent`, and while the lane is open the landing page carries one line, `# new: pick any plot, then .use_my_data() →`, that leads to the catalogue (`/plots`, `nav_click{source: hero_use_my_data}`), so the visitor picks the plot first; on launch day it opened the chat on scatter-basic in matplotlib (`agent_open{source: landing}`).
- **Analytics** (enum properties only, documented in [Plausible](../reference/plausible.md#agent-chat)): the pageviews `/debug/agent` and `/agent` (the public page), `agent_open{library,source,spec}`, `agent_data_parsed{status,size_bucket}`, `agent_data_confirmed{size_bucket}`, `agent_data_prepared{changes}`, `agent_data_undo{version}`, `agent_plot_rendered{library,status,repaired,spec}`, `agent_guardrail_block{reason}`, `agent_session_saved{include_data}`, `agent_result_feedback{reaction,include_data}`, and `copy_code{page:'agent_chat',method:'agent'}`, all built; the public page fires the same events. The session language fires no event: decided while the chat was admin-only, when the chip's numbers would have been tiny, and kept for the experimental launch.
## Repository layout
diff --git a/docs/reference/plausible.md b/docs/reference/plausible.md
index ac1e5b035ed..e89571a5863 100644
--- a/docs/reference/plausible.md
+++ b/docs/reference/plausible.md
@@ -142,7 +142,7 @@ the public lane the `# include data.csv` boxes start unticked, so
| Event Name | Properties | Where | Description |
|------------|-----------|-------|-------------|
-| `agent_open` | `library`, `source`, `spec` | AgentChatPage.tsx | The chat page opened, for an admin or on the public lane. `source` is `plot_page` when the plot page's `.adapt()` button led there, `landing` when the landing page's line did (public lane, since 2026-11-02), `direct` otherwise (a typed URL, a bookmark, a reload) |
+| `agent_open` | `library`, `source`, `spec` | AgentChatPage.tsx | The chat page opened, for an admin or on the public lane. `source` is `plot_page` when the plot page's entry led there (`.use_my_data() →` beside the library carousel; the `.adapt()` button on the image until 2026-11-02, under the same value), `landing` when the landing page's line did (public lane, on 2026-11-02 only: the line now leads to `/plots`, tracked as `nav_click{source: hero_use_my_data}`; the chat still accepts the value), `direct` otherwise (a typed URL, a bookmark, a reload) |
| `agent_data_parsed` | `status`, `size_bucket` | useAgentSession.ts | The pasted data came back from the parse. `status` ∈ `ok`, `too_long`, `unparseable`, `data_refused`, `error` (any other refusal, including a formula the server answered with `422 formula`, which the page offers to send to the chat), `data_request` (since 2026-11-01: the text asked for data instead of holding it, such as "Beispieldaten"; the server answered `422 data_request` and the page sent the text to the chat as the user's message, where the assistant generates example data), `transcribed` (the text was not a table, a model read it into one and the page asks the user to confirm it; nothing is stored yet), `discarded` (the user dropped such a table; `.use()` instead parses it again and reports `ok`); `size_bucket` ∈ `lt_1kb`, `1_10kb`, `10_50kb`, `50_200kb`, `over_200kb` (the last one is refused in the browser before anything is sent) |
| `agent_data_confirmed` | `size_bucket` | useAgentSession.ts | The user ended step 1 with `.confirm()`: the stored data and its bindings fit, and the page now offers `.create_plot()`. Fires again after each new parse, binding change, data change or undo that the user confirms. `size_bucket` as for `agent_data_parsed`; data the assistant generated from a formula without a paste counts as `lt_1kb` |
| `agent_data_prepared` | `changes` | useAgentSession.ts | The assistant changed the data in a turn (its `prepare_data`, for example "read Anteil as numbers" or "sum per month") or computed it from a formula (its `generate_data`, for example "sin(x) from -pi to pi"): the turn's `done` carried a new data version, which the data panel shows with its change lines and, when an earlier version exists, `.undo()`. A generated table in a session without data is version 1 and fires this event too, and so does the data panel's `.to_long()` (a wide table made long, no model call). `changes` ∈ `1`, `2`, `3+`, the number of change lines (one per operation; a generated table has one, plus one per series with undefined values) |
@@ -231,6 +231,7 @@ carry `spec`, `library`, or `value` for richer breakdowns.
| `masthead_logo` / `masthead_branch` / `masthead_release` | MastheadRule.tsx | masthead `~/anyplot.ai · main · v1.x.x` |
| `breadcrumb` | MastheadRule.tsx | breadcrumb segments on non-landing routes |
| `hero_cta_browse` / `hero_mcp` / `hero_github` | HeroSection.tsx | hero call-to-action + secondary links |
+| `hero_use_my_data` | HeroSection.tsx | `# new: pick any plot, then .use_my_data() →` under the hero CTAs, shown only while the agent chat's public lane is open → `/plots` |
| `potd_terminal_image` / `potd_terminal_filename` / `potd_terminal_github` | PlotOfTheDayTerminal.tsx | hero terminal-framed POTD |
| `featured_thumb` | LandingPage.tsx | featured plot grid |
| `library_card` | LandingPage.tsx | library cards (carries `value=`) |