diff --git a/components/activity-dock.tsx b/components/activity-dock.tsx
index 8eea8c6..7f89678 100644
--- a/components/activity-dock.tsx
+++ b/components/activity-dock.tsx
@@ -4,7 +4,7 @@ import { createContext, useCallback, useContext, useEffect, useMemo, useRef, use
import { ActivityIcon, BotIcon, FileTextIcon, PenToolIcon, PlayIcon, SearchIcon, SquareIcon, WaypointsIcon } from "lucide-react";
import { cn } from "@/lib/utils";
import type { CallUsage, JobKind, JobRecord } from "@/src/core";
-import { isModelsRefusal, jobCost } from "@/src/core";
+import { isModelsRefusal, isProviderKeyRefusal, jobCost } from "@/src/core";
import { formatTokens, formatUsd } from "@/components/usage-format";
/**
@@ -204,8 +204,15 @@ export function toBlobs(feed: ActivityFeed): ActivityBlob[] {
context,
// BYOK (owner, 2026-09-22): a refusal is info, not failure —
// the honest line the runner left ("failed") reads as setup
- // needed, matching the dispatch card's quiet tone.
- activity: isModelsRefusal(job.error) ? "needs model setup" : (job.activity ?? ""),
+ // needed, matching the dispatch card's quiet tone. The
+ // provider-key refusal twin (zero-keys wave, Platform's
+ // 66dbea9): a pinned model whose key is gone reads as the key
+ // being needed, same quiet tone.
+ activity: isModelsRefusal(job.error)
+ ? "needs model setup"
+ : isProviderKeyRefusal(job.error)
+ ? "needs a provider key"
+ : (job.activity ?? ""),
model: usage?.model,
steps: usage?.steps,
};
diff --git a/components/artifacts/dispatch-card.tsx b/components/artifacts/dispatch-card.tsx
index 9f64f5d..0bcb83e 100644
--- a/components/artifacts/dispatch-card.tsx
+++ b/components/artifacts/dispatch-card.tsx
@@ -4,7 +4,7 @@ import { useContext } from "react";
import { CheckIcon, SettingsIcon, XIcon } from "lucide-react";
import { JobsContext } from "@/components/jobs-context";
import { ChatActionsContext } from "@/components/chat";
-import { isModelsRefusal, type JobKind, type JobRecord } from "@/src/core";
+import { isModelsRefusal, isProviderKeyRefusal, type JobKind, type JobRecord } from "@/src/core";
/**
* The dispatch card (contracts.ts "THE ASYNC PROTOCOL" §4): the tool-call
@@ -36,8 +36,14 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J
// BYOK (owner, 2026-09-22): a job refused for missing models is INFO,
// not scary-error — gray glyph + "needs model setup" + the deep-link
// into the SetupDialog (the same flow the send interception uses).
+ // The provider-key refusal twin (zero-keys wave): a pinned model
+ // whose key is gone reads as the key being needed — the deep-link
+ // lands on the SetupDialog's first-key stage on its own.
if (isModelsRefusal(job.error)) {
- return ;
+ return ;
+ }
+ if (isProviderKeyRefusal(job.error)) {
+ return ;
}
const failed = job.status === "error";
return (
@@ -53,7 +59,7 @@ export function DispatchCard({ toolCallId, kind }: { toolCallId: string; kind: J
}
/** The refused card: quiet info tone — nothing red, nothing "failed". */
-function DispatchRefusedCard({ label }: { label: string }) {
+function DispatchRefusedCard({ label, line, button }: { label: string; line: string; button: string }) {
const ctx = useContext(ChatActionsContext);
return (
{label}
- needs model setup
+ {line}
{ctx && (
)}
diff --git a/components/chat.tsx b/components/chat.tsx
index 00ee7d1..e82e063 100644
--- a/components/chat.tsx
+++ b/components/chat.tsx
@@ -5,7 +5,7 @@ import { useChat } from "@ai-sdk/react";
import { lastAssistantMessageIsCompleteWithToolCalls } from "ai";
import { createChatTransport } from "@/components/chat-transport";
import { SetupDialog } from "@/components/setup-dialog";
-import { useSettingsDialog } from "@/components/settings/settings-dialog.tsx";
+import { useZeroKeys, ZeroKeysPill } from "@/components/setup-cta";
import { useStickToBottomContext } from "use-stick-to-bottom";
import {
Conversation,
@@ -25,7 +25,7 @@ import { DictationBar, useDictation } from "@/components/dictation";
import { JobsContext } from "@/components/jobs-context";
import { StatsBar } from "@/components/stats-bar";
import { useActivity } from "@/components/activity-dock";
-import { mergeAdopted, type ChatSettings, type ModelSlotName } from "@/src/core";
+import { isProviderKeyRefusal, mergeAdopted, type ChatSettings, type ModelSlotName } from "@/src/core";
import { PlanBlock, PlanPopup, latestTree } from "@/components/plan-block";
import { PlanMarginalia } from "@/components/plan-window";
import { MermaidOverlay, SessionMermaidContext } from "@/components/session-overlay";
@@ -106,6 +106,21 @@ export function parseModelsRefusal(message: string | undefined): ModelSlotName[]
}
}
+/** The provider-key refusal's transport seam (zero-keys wave, Platform's
+ * 66dbea9): same discipline as parseModelsRefusal — the 503 body rides
+ * the thrown error message verbatim, so the envelope's own matcher
+ * (core, invariant-tail) decides; the wording can never drift client-
+ * side. Exported pure for tests. */
+export function parseProviderKeyRefusal(message: string | undefined): boolean {
+ if (!message) return false;
+ try {
+ const body = JSON.parse(message) as { code?: unknown; error?: unknown };
+ return body.code === "provider_key_missing" && typeof body.error === "string" && isProviderKeyRefusal(body.error);
+ } catch {
+ return false;
+ }
+}
+
/** useLayoutEffect that is silent during SSR (client components still render on the server in Next). */
const useIsoLayoutEffect = typeof window === "undefined" ? useEffect : useLayoutEffect;
@@ -315,6 +330,10 @@ export function Chat({
// refusal interception share one SetupDialog mount. `configured` is
// seeded server-side (slotsConfigured on the record) and flips on Save.
const [configured, setConfigured] = useState(initialConfigured);
+ // The zero-keys truth (zero-keys wave): no provider key = the pill
+ // takes precedence over the models CTA (keys come first in the chain).
+ // Reads once on mount, re-reads on the "keys" signal.
+ const zeroKeys = useZeroKeys();
// Cross-surface freshness (fix wave): another surface's models write —
// setup save, composer pick, a settings write — re-reads the record so
// the setup CTA appears/disappears live (it used to lag until reload).
@@ -326,7 +345,6 @@ export function Chat({
.catch(() => {});
});
const [setupOpen, setSetupOpen] = useState(false);
- const { open: openSettings } = useSettingsDialog();
/** The last explicitly-sent draft (composer send, verdict note): the
* refusal flow restores it into the composer and Save replays it.
* Auto-continuations (probe answers) never set it — their refusal
@@ -344,7 +362,11 @@ export function Chat({
useEffect(() => {
if (!error) return;
const missing = parseModelsRefusal(error.message);
- if (!missing) return; // a real error keeps the honest banner
+ // The key-refusal twin (zero-keys wave): a pinned model whose
+ // provider key is gone gets the SAME treatment — revert-draft and
+ // open setup (the dialog derives to its first-key stage on its own).
+ const keyRefused = parseProviderKeyRefusal(error.message);
+ if (!missing && !keyRefused) return; // a real error keeps the honest banner
// ORDER MATTERS: clearError() flips this effect's own dep (`error`)
// and would cancel the in-flight work below — so the banner dismiss,
// the draft restore, and the dialog open all land TOGETHER after the
@@ -1087,17 +1109,21 @@ export function Chat({
dictationSlot={}
dictationCancel={dictation.cancel}
setupCta={
- !configured && (
-