diff --git a/apps/desktop/src/app/chat/composer/status-stack/index.tsx b/apps/desktop/src/app/chat/composer/status-stack/index.tsx
index e8107764d88..739080be8b8 100644
--- a/apps/desktop/src/app/chat/composer/status-stack/index.tsx
+++ b/apps/desktop/src/app/chat/composer/status-stack/index.tsx
@@ -4,6 +4,7 @@ import { useNavigate } from 'react-router-dom'
import { blurComposerInput } from '@/app/chat/composer/focus'
import { AGENTS_ROUTE } from '@/app/routes'
+import { BillingBanner } from '@/components/billing-banner'
import { composerDockCard } from '@/components/chat/composer-dock'
import { StatusSection } from '@/components/chat/status-section'
import { Button } from '@/components/ui/button'
@@ -11,6 +12,7 @@ import { Codicon } from '@/components/ui/codicon'
import { Tip, TipKeybindLabel } from '@/components/ui/tooltip'
import { type Translations, useI18n } from '@/i18n'
import { cn } from '@/lib/utils'
+import { $billingBlock } from '@/store/billing-block'
import {
$statusItemsBySession,
type ComposerStatusItem,
@@ -70,6 +72,7 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const itemsBySession = useStore($statusItemsBySession)
const previewsBySession = useStore($previewStatusBySession)
const scrolledUp = useStore($threadScrolledUp)
+ const billing = useStore($billingBlock)
const groups = useMemo(
() => groupStatusItems(sessionId ? (itemsBySession[sessionId] ?? []) : []),
@@ -123,6 +126,13 @@ export function ComposerStatusStack({ queue, sessionId }: ComposerStatusStackPro
const sections: { key: string; node: ReactNode }[] = []
+ // Billing wall sits at the very top of the stack — it's the most important
+ // thing above the composer when the account is out of credits. Rendered here
+ // (not as a composer-disable) so slash commands stay usable.
+ if (billing && sessionId && billing.sessionId === sessionId) {
+ sections.push({ key: 'billing', node: })
+ }
+
for (const group of groups) {
sections.push({
key: group.type,
diff --git a/apps/desktop/src/app/contrib/wiring.tsx b/apps/desktop/src/app/contrib/wiring.tsx
index b2d0c03a988..6c36e452e6e 100644
--- a/apps/desktop/src/app/contrib/wiring.tsx
+++ b/apps/desktop/src/app/contrib/wiring.tsx
@@ -27,6 +27,7 @@ import { type ChatMessage, chatMessageText, preserveLocalAssistantErrors, toChat
import { sessionMessagesSignature } from '@/lib/session-signatures'
import { isMessagingSource } from '@/lib/session-source'
import { latestSessionTodos } from '@/lib/todos'
+import { $billingSettingsRequest } from '@/store/billing-block'
import { setCronFocusJobId } from '@/store/cron'
import { $pinnedSessionIds, pinSession, restoreWorktree, unpinSession } from '@/store/layout'
import { $filePreviewTarget, $previewTarget } from '@/store/preview'
@@ -126,6 +127,10 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const busyRef = useRef(false)
const creatingSessionRef = useRef(false)
+ // Billing recovery routes to Settings → Billing from surfaces without router
+ // context (the sticky toast). The shell owns `navigate`, so it consumes the
+ // intent counter here; the ref skips the initial mount value.
+ const billingSettingsSeenRef = useRef(0)
const messagingTranscriptSignatureRef = useRef(new Map())
// Stable identity for the whole callback surface (see WiringActions). Mutated
// in place each render so memoized surfaces never re-render on churn.
@@ -133,7 +138,20 @@ export function ContribWiring({ children }: { children: ReactNode }) {
const gatewayState = useStore($gatewayState)
const activeSessionId = useStore($activeSessionId)
+ const billingSettingsRequest = useStore($billingSettingsRequest)
const currentCwd = useStore($currentCwd)
+
+ useEffect(() => {
+ if (billingSettingsRequest === billingSettingsSeenRef.current) {
+ return
+ }
+
+ billingSettingsSeenRef.current = billingSettingsRequest
+
+ if (billingSettingsRequest > 0) {
+ navigate(`${SETTINGS_ROUTE}?tab=billing`)
+ }
+ }, [billingSettingsRequest, navigate])
const freshDraftReady = useStore($freshDraftReady)
const resumeFailedSessionId = useStore($resumeFailedSessionId)
const resumeExhaustedSessionId = useStore($resumeExhaustedSessionId)
diff --git a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts
index 5b8fccd8bb0..7cc7d2a9f92 100644
--- a/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts
+++ b/apps/desktop/src/app/session/hooks/use-message-stream/gateway-event.ts
@@ -1,3 +1,4 @@
+import type { BillingBlock } from '@hermes/shared'
import type { HermesSkin } from '@hermes/shared/skin'
import type { QueryClient } from '@tanstack/react-query'
import { type MutableRefObject, useCallback, useEffect, useRef } from 'react'
@@ -15,6 +16,7 @@ import { triggerHaptic } from '@/lib/haptics'
import { modelOptionsQueryKey } from '@/lib/model-options'
import { isProviderSetupErrorMessage } from '@/lib/provider-setup-errors'
import { reconcileApprovalModeForProfile } from '@/store/approval-mode'
+import { billingCtaLabel, clearBillingBlock, runBillingRecovery, setBillingBlock } from '@/store/billing-block'
import { clearClarifyRequest, setClarifyRequest } from '@/store/clarify'
import { setSessionCompacting } from '@/store/compaction'
import { refreshBackgroundProcesses } from '@/store/composer-status'
@@ -57,6 +59,50 @@ import type { ClientSessionState } from '../../../types'
import { hasSessionInfoStatePatch, sessionInfoStatePatch, SUBAGENT_EVENT_TYPES, toTodoPayload } from './utils'
+function firstBillingLine(text: string): string {
+ return (text || '').split('\n')[0]?.trim() ?? ''
+}
+
+/**
+ * A turn failed on a billing wall (out of credits / payment required). The
+ * gateway forwards the structured descriptor built by `agent/billing_links.py`;
+ * we cache it per-session (drives the in-chat banner) AND raise one sticky,
+ * billing-specific toast — never the generic "Hermes error" — with a smart CTA
+ * (Nous → in-app Settings → Billing, other providers → their billing page).
+ */
+function surfaceBillingBlock(sessionId: string, raw: unknown): void {
+ if (!raw || typeof raw !== 'object') {
+ return
+ }
+
+ const block = raw as BillingBlock
+
+ if (typeof block.provider !== 'string') {
+ return
+ }
+
+ setBillingBlock(sessionId, block)
+
+ const ctaCopy = {
+ addCredits: translateNow('billingBlock.addCredits'),
+ openBilling: translateNow('billingBlock.openBilling')
+ }
+
+ notify({
+ // Collapse repeat walls from the same provider into one toast.
+ id: `billing-block:${block.provider}`,
+ kind: 'warning',
+ icon: 'credit-card',
+ title: block.is_nous
+ ? translateNow('billingBlock.titleNous')
+ : translateNow('billingBlock.titleProvider', block.provider_label),
+ message: firstBillingLine(block.message) || translateNow('billingBlock.fallbackMessage'),
+ // Sticky: a credit wall blocks every turn until resolved.
+ durationMs: 0,
+ action: { label: billingCtaLabel(block, ctaCopy), onClick: () => runBillingRecovery(block) }
+ })
+}
+
const COMPACTION_RESUME_EVENT_TYPES = new Set([
'message.delta',
'message.interim',
@@ -390,6 +436,9 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
setSessionCompacting(sessionId, false)
compactedTurnRef.current.delete(sessionId)
nativeSubagentSessionsRef.current.delete(sessionId)
+ // A fresh turn on this session optimistically clears its billing wall;
+ // if credits are still exhausted the next failure re-raises it.
+ clearBillingBlock(sessionId)
if (isActiveEvent) {
triggerHaptic('streamStart')
@@ -513,6 +562,12 @@ export function useGatewayEventHandler(deps: GatewayEventDeps) {
const finalText = coerceGatewayText(payload?.text) || coerceGatewayText(payload?.rendered)
completeAssistantMessage(sessionId, finalText, payload?.response_previewed)
+ // Structured billing wall forwarded by the gateway (out of credits /
+ // payment required) — cache it + raise a billing-specific toast.
+ if (payload?.billing) {
+ surfaceBillingBlock(sessionId, payload.billing)
+ }
+
if (isActiveEvent) {
setTurnStartedAt(null)
diff --git a/apps/desktop/src/components/billing-banner.tsx b/apps/desktop/src/components/billing-banner.tsx
new file mode 100644
index 00000000000..73f7107decb
--- /dev/null
+++ b/apps/desktop/src/components/billing-banner.tsx
@@ -0,0 +1,72 @@
+import { useStore } from '@nanostores/react'
+
+import { StatusRow } from '@/components/chat/status-row'
+import { Button } from '@/components/ui/button'
+import { Codicon } from '@/components/ui/codicon'
+import { Tip } from '@/components/ui/tooltip'
+import { useI18n } from '@/i18n'
+import { $billingBlock, billingCtaLabel, clearBillingBlock, runBillingRecovery } from '@/store/billing-block'
+
+function firstLine(text: string): string {
+ return (text || '').split('\n')[0]?.trim() ?? ''
+}
+
+/**
+ * Persistent, in-stack billing wall for THIS session. Rendered as a shared
+ * {@link StatusRow} — same chrome as its status-stack siblings, so it reads as
+ * one piece with the composer card (no bordered alert-in-a-card). It never
+ * disables the composer — slash commands (`/topup`, `/model`, `/login`) stay
+ * usable — it only offers recovery: Nous opens Settings → Billing in-app, other
+ * providers deep-link out. The sticky toast is the loud surface; this is the calm
+ * reminder that outlives it.
+ */
+export function BillingBanner({ sessionId }: { sessionId: null | string }) {
+ const active = useStore($billingBlock)
+ const { t } = useI18n()
+
+ if (!active || !sessionId || active.sessionId !== sessionId) {
+ return null
+ }
+
+ const { block } = active
+ const copy = t.billingBlock
+ const title = block.is_nous ? copy.titleNous : copy.titleProvider(block.provider_label)
+ const message = firstLine(block.message) || copy.fallbackMessage
+
+ return (
+ }
+ trailing={
+ <>
+
+
+
+
+ >
+ }
+ trailingVisible
+ >
+
+ {title}
+ {message && · {message}}
+
+
+ )
+}
diff --git a/apps/desktop/src/i18n/en.ts b/apps/desktop/src/i18n/en.ts
index 79351a81517..53802fcd8cd 100644
--- a/apps/desktop/src/i18n/en.ts
+++ b/apps/desktop/src/i18n/en.ts
@@ -177,6 +177,15 @@ export const en: Translations = {
`Software rendering active — remote display detected (${reason}). GPU acceleration is disabled to prevent flickering.`
},
+ billingBlock: {
+ titleNous: 'Out of Nous credits',
+ titleProvider: provider => `Out of credits — ${provider}`,
+ fallbackMessage: 'Your account is out of credits. Add credits to keep going.',
+ openBilling: 'Open billing',
+ addCredits: 'Add credits',
+ dismiss: 'Dismiss'
+ },
+
titlebar: {
hideSidebar: 'Hide sidebar',
showSidebar: 'Show sidebar',
diff --git a/apps/desktop/src/i18n/ja.ts b/apps/desktop/src/i18n/ja.ts
index afa02b44bf8..c712198e613 100644
--- a/apps/desktop/src/i18n/ja.ts
+++ b/apps/desktop/src/i18n/ja.ts
@@ -178,6 +178,15 @@ export const ja = defineLocale({
`ソフトウェアレンダリングが有効です — リモートディスプレイを検出しました(${reason})。ちらつきを防ぐため GPU アクセラレーションは無効化されています。`
},
+ billingBlock: {
+ titleNous: 'Nous クレジットが不足しています',
+ titleProvider: provider => `クレジット不足 — ${provider}`,
+ fallbackMessage: 'アカウントのクレジットが不足しています。続行するにはクレジットを追加してください。',
+ openBilling: '請求を開く',
+ addCredits: 'クレジットを追加',
+ dismiss: '閉じる'
+ },
+
titlebar: {
hideSidebar: 'サイドバーを非表示',
showSidebar: 'サイドバーを表示',
diff --git a/apps/desktop/src/i18n/types.ts b/apps/desktop/src/i18n/types.ts
index 4ba7fc84c38..18f96afb90d 100644
--- a/apps/desktop/src/i18n/types.ts
+++ b/apps/desktop/src/i18n/types.ts
@@ -218,6 +218,15 @@ export interface Translations {
message: (reason: string) => string
}
+ billingBlock: {
+ titleNous: string
+ titleProvider: (provider: string) => string
+ fallbackMessage: string
+ openBilling: string
+ addCredits: string
+ dismiss: string
+ }
+
titlebar: {
hideSidebar: string
showSidebar: string
diff --git a/apps/desktop/src/i18n/zh-hant.ts b/apps/desktop/src/i18n/zh-hant.ts
index e3c382a8ef5..c19c10bb476 100644
--- a/apps/desktop/src/i18n/zh-hant.ts
+++ b/apps/desktop/src/i18n/zh-hant.ts
@@ -172,6 +172,15 @@ export const zhHant = defineLocale({
message: reason => `軟體繪圖已啟用 — 偵測到遠端顯示(${reason})。為防止畫面閃爍,已停用 GPU 加速。`
},
+ billingBlock: {
+ titleNous: 'Nous 額度已用盡',
+ titleProvider: provider => `額度已用盡 — ${provider}`,
+ fallbackMessage: '您的帳戶額度已用盡。請儲值以繼續使用。',
+ openBilling: '開啟帳單',
+ addCredits: '新增額度',
+ dismiss: '忽略'
+ },
+
titlebar: {
hideSidebar: '隱藏側邊欄',
showSidebar: '顯示側邊欄',
diff --git a/apps/desktop/src/i18n/zh.ts b/apps/desktop/src/i18n/zh.ts
index 578a6faa740..8515ec15f42 100644
--- a/apps/desktop/src/i18n/zh.ts
+++ b/apps/desktop/src/i18n/zh.ts
@@ -172,6 +172,15 @@ export const zh: Translations = {
message: reason => `软件渲染已启用 — 检测到远程显示(${reason})。为防止画面闪烁,已禁用 GPU 加速。`
},
+ billingBlock: {
+ titleNous: 'Nous 额度已用尽',
+ titleProvider: provider => `额度已用尽 — ${provider}`,
+ fallbackMessage: '您的账户额度已用尽。请充值以继续使用。',
+ openBilling: '打开账单',
+ addCredits: '添加额度',
+ dismiss: '忽略'
+ },
+
titlebar: {
hideSidebar: '隐藏侧边栏',
showSidebar: '显示侧边栏',
diff --git a/apps/desktop/src/lib/chat-messages.ts b/apps/desktop/src/lib/chat-messages.ts
index 74beafcffd6..828021b21ea 100644
--- a/apps/desktop/src/lib/chat-messages.ts
+++ b/apps/desktop/src/lib/chat-messages.ts
@@ -1,4 +1,5 @@
import type { ThreadMessageLike } from '@assistant-ui/react'
+import type { BillingBlock } from '@hermes/shared'
import { dedupeGeneratedImageEchoesInParts } from '@/lib/generated-images'
import { mediaDisplayLabel, mediaMarkdownHref } from '@/lib/media'
@@ -96,6 +97,10 @@ export type GatewayEventPayload = {
// message.complete — signals the final text was already previewed via
// interim_assistant_callback, so the UI can settle instead of duplicating.
response_previewed?: boolean
+ // Structured billing wall forwarded on message.complete when a turn fails
+ // with FailoverReason.billing (shape mirrors @hermes/shared BillingBlock).
+ billing?: BillingBlock
+ failure_reason?: string
}
export function textPart(text: string): ChatMessagePart {
diff --git a/apps/desktop/src/store/billing-block.test.ts b/apps/desktop/src/store/billing-block.test.ts
new file mode 100644
index 00000000000..24d2f97402f
--- /dev/null
+++ b/apps/desktop/src/store/billing-block.test.ts
@@ -0,0 +1,86 @@
+import type { BillingBlock } from '@hermes/shared'
+import { beforeEach, expect, test, vi } from 'vitest'
+
+vi.mock('@/lib/external-link', () => ({ openExternalLink: vi.fn() }))
+
+import { openExternalLink } from '@/lib/external-link'
+
+import {
+ $billingBlock,
+ $billingSettingsRequest,
+ billingCtaLabel,
+ clearBillingBlock,
+ requestBillingSettings,
+ runBillingRecovery,
+ setBillingBlock
+} from './billing-block'
+
+function makeBlock(overrides: Partial = {}): BillingBlock {
+ return {
+ billing_url: 'https://platform.openai.com/settings/organization/billing',
+ is_nous: false,
+ message: 'You are out of credits.',
+ model: 'gpt-5',
+ provider: 'openai',
+ provider_label: 'OpenAI',
+ ...overrides
+ }
+}
+
+beforeEach(() => {
+ $billingBlock.set(null)
+ $billingSettingsRequest.set(0)
+ vi.clearAllMocks()
+})
+
+test('setBillingBlock stores the block against its session', () => {
+ setBillingBlock('s1', makeBlock())
+ expect($billingBlock.get()?.sessionId).toBe('s1')
+ expect($billingBlock.get()?.block.provider).toBe('openai')
+})
+
+test('clearBillingBlock scoped to a session leaves a different session block intact', () => {
+ setBillingBlock('s1', makeBlock())
+ clearBillingBlock('s2')
+ expect($billingBlock.get()).not.toBeNull()
+
+ clearBillingBlock('s1')
+ expect($billingBlock.get()).toBeNull()
+})
+
+test('clearBillingBlock with no arg clears any active block', () => {
+ setBillingBlock('s1', makeBlock())
+ clearBillingBlock()
+ expect($billingBlock.get()).toBeNull()
+})
+
+test('runBillingRecovery routes Nous to in-app Settings, never an external link', () => {
+ runBillingRecovery(makeBlock({ is_nous: true, provider: 'nous', provider_label: 'Nous Portal' }))
+ expect($billingSettingsRequest.get()).toBe(1)
+ expect(openExternalLink).not.toHaveBeenCalled()
+})
+
+test('runBillingRecovery deep-links a third-party provider to its billing page', () => {
+ const block = makeBlock({ billing_url: 'https://openrouter.ai/settings/credits', provider: 'openrouter' })
+ runBillingRecovery(block)
+ expect(openExternalLink).toHaveBeenCalledWith('https://openrouter.ai/settings/credits')
+ expect($billingSettingsRequest.get()).toBe(0)
+})
+
+test('runBillingRecovery falls back to in-app settings when a provider has no URL', () => {
+ runBillingRecovery(makeBlock({ billing_url: null, provider: 'custom' }))
+ expect(openExternalLink).not.toHaveBeenCalled()
+ expect($billingSettingsRequest.get()).toBe(1)
+})
+
+test('requestBillingSettings increments the intent counter', () => {
+ requestBillingSettings()
+ requestBillingSettings()
+ expect($billingSettingsRequest.get()).toBe(2)
+})
+
+test('billingCtaLabel picks the right verb per route', () => {
+ const copy = { addCredits: 'Add credits', openBilling: 'Open billing' }
+ expect(billingCtaLabel(makeBlock({ is_nous: true }), copy)).toBe('Open billing')
+ expect(billingCtaLabel(makeBlock({ is_nous: false }), copy)).toBe('Add credits')
+})
diff --git a/apps/desktop/src/store/billing-block.ts b/apps/desktop/src/store/billing-block.ts
new file mode 100644
index 00000000000..4d640302b63
--- /dev/null
+++ b/apps/desktop/src/store/billing-block.ts
@@ -0,0 +1,79 @@
+import type { BillingBlock } from '@hermes/shared'
+import { atom } from 'nanostores'
+
+import { openExternalLink } from '@/lib/external-link'
+
+/**
+ * The active inference billing wall, if any. Set from the gateway
+ * `message.complete` / `error` event when a turn fails with
+ * `FailoverReason.billing` (see `agent/billing_links.py`). One global slot: a
+ * credit wall on the active session's provider is the whole app's problem, and
+ * the newest block wins. Cleared when a new turn starts or the user dismisses.
+ */
+export interface ActiveBillingBlock {
+ block: BillingBlock
+ sessionId: string
+ at: number
+}
+
+export const $billingBlock = atom(null)
+
+/**
+ * Navigation intent counter. A toast fired outside React (or any surface
+ * without router context) bumps this to ask the shell — which owns
+ * `useNavigate` — to open Settings → Billing in-app. See `contrib/wiring.tsx`.
+ */
+export const $billingSettingsRequest = atom(0)
+
+export function setBillingBlock(sessionId: string, block: BillingBlock): void {
+ $billingBlock.set({ at: Date.now(), block, sessionId })
+}
+
+export function clearBillingBlock(sessionId?: string): void {
+ const current = $billingBlock.get()
+
+ if (!current) {
+ return
+ }
+
+ // A scoped clear (new turn on session X) must not wipe a block raised by a
+ // different session's provider.
+ if (sessionId && current.sessionId !== sessionId) {
+ return
+ }
+
+ $billingBlock.set(null)
+}
+
+export function requestBillingSettings(): void {
+ $billingSettingsRequest.set($billingSettingsRequest.get() + 1)
+}
+
+/**
+ * The single recovery action for a billing wall, shared by the toast and the
+ * in-chat banner so both behave identically: Nous routes to the in-app
+ * Settings → Billing surface; a third-party provider deep-links to its own
+ * billing page (falling back to the in-app surface only if we have no URL).
+ */
+export function runBillingRecovery(block: BillingBlock): void {
+ if (block.is_nous) {
+ requestBillingSettings()
+
+ return
+ }
+
+ if (block.billing_url) {
+ openExternalLink(block.billing_url)
+
+ return
+ }
+
+ requestBillingSettings()
+}
+
+export function billingCtaLabel(
+ block: BillingBlock,
+ copy: { addCredits: string; openBilling: string }
+): string {
+ return block.is_nous ? copy.openBilling : copy.addCredits
+}