feat(captain): Add paywall and expose Custom Tools (#13977)

# Pull Request Template

## Description

Custom tools is now discoverable on all plans

## Type of change
- [x] Bug fix (non-breaking change which fixes an issue)

## How Has This Been Tested?

Before:
<img width="390" height="446" alt="CleanShot 2026-04-02 at 13 40 11@2x"
src="https://github.com/user-attachments/assets/0a751954-f3ad-47d6-85b8-1e2f1476a646"
/>


After:

<img width="392" height="522" alt="CleanShot 2026-04-02 at 13 40 47@2x"
src="https://github.com/user-attachments/assets/62a252f6-2551-47a9-b50c-be949f08c456"
/>

<img width="1826" height="638" alt="CleanShot 2026-04-02 at 13 37 39@2x"
src="https://github.com/user-attachments/assets/77dc2a75-3d76-44cf-8579-8d3457879bd0"
/>


## Checklist:

- [x] My code follows the style guidelines of this project
- [x] I have performed a self-review of my code
- [x] I have commented on my code, particularly in hard-to-understand
areas
- [x] I have made corresponding changes to the documentation
- [x] My changes generate no new warnings
- [x] I have added tests that prove my fix is effective or that my
feature works
- [x] New and existing unit tests pass locally with my changes
- [x] Any dependent changes have been merged and published in downstream
modules

---------

Co-authored-by: Shivam Mishra <scm.mymail@gmail.com>
This commit is contained in:
Aakash Bakhle
2026-04-07 10:58:29 +05:30
committed by GitHub
parent 118270d2e8
commit fbe3560b7a
7 changed files with 58 additions and 29 deletions

View File

@@ -6,6 +6,13 @@ import { useAccount } from 'dashboard/composables/useAccount';
import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue'; import BasePaywallModal from 'dashboard/routes/dashboard/settings/components/BasePaywallModal.vue';
defineProps({
featurePrefix: {
type: String,
default: 'CAPTAIN',
},
});
const router = useRouter(); const router = useRouter();
const currentUser = useMapGetter('getCurrentUser'); const currentUser = useMapGetter('getCurrentUser');
@@ -31,7 +38,7 @@ const openBilling = () => {
> >
<BasePaywallModal <BasePaywallModal
class="mx-auto" class="mx-auto"
feature-prefix="CAPTAIN" :feature-prefix="featurePrefix"
:i18n-key="i18nKey" :i18n-key="i18nKey"
:is-super-admin="isSuperAdmin" :is-super-admin="isSuperAdmin"
:is-on-chatwoot-cloud="isOnChatwootCloud" :is-on-chatwoot-cloud="isOnChatwootCloud"

View File

@@ -63,16 +63,6 @@ const hasAdvancedAssignment = computed(() => {
); );
}); });
const hasCustomTools = computed(() => {
return (
isFeatureEnabledonAccount.value(
accountId.value,
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS
) ||
isFeatureEnabledonAccount.value(accountId.value, FEATURE_FLAGS.CAPTAIN_V2)
);
});
const toggleShortcutModalFn = show => { const toggleShortcutModalFn = show => {
if (show) { if (show) {
emit('openKeyShortcutModal'); emit('openKeyShortcutModal');
@@ -374,8 +364,6 @@ const menuItems = computed(() => {
navigationPath: 'captain_assistants_inboxes_index', navigationPath: 'captain_assistants_inboxes_index',
}), }),
}, },
...(hasCustomTools.value
? [
{ {
name: 'Tools', name: 'Tools',
label: t('SIDEBAR.CAPTAIN_TOOLS'), label: t('SIDEBAR.CAPTAIN_TOOLS'),
@@ -384,8 +372,6 @@ const menuItems = computed(() => {
navigationPath: 'captain_tools_index', navigationPath: 'captain_tools_index',
}), }),
}, },
]
: []),
{ {
name: 'Settings', name: 'Settings',
label: t('SIDEBAR.CAPTAIN_SETTINGS'), label: t('SIDEBAR.CAPTAIN_SETTINGS'),

View File

@@ -50,6 +50,7 @@ export const FEATURE_FLAGS = {
export const PREMIUM_FEATURES = [ export const PREMIUM_FEATURES = [
FEATURE_FLAGS.SLA, FEATURE_FLAGS.SLA,
FEATURE_FLAGS.CAPTAIN, FEATURE_FLAGS.CAPTAIN,
FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS,
FEATURE_FLAGS.CUSTOM_ROLES, FEATURE_FLAGS.CUSTOM_ROLES,
FEATURE_FLAGS.AUDIT_LOGS, FEATURE_FLAGS.AUDIT_LOGS,
FEATURE_FLAGS.HELP_CENTER, FEATURE_FLAGS.HELP_CENTER,

View File

@@ -838,6 +838,18 @@
"SUCCESS_MESSAGE": "Custom tool deleted successfully", "SUCCESS_MESSAGE": "Custom tool deleted successfully",
"ERROR_MESSAGE": "Failed to delete custom tool" "ERROR_MESSAGE": "Failed to delete custom tool"
}, },
"PAYWALL": {
"TITLE": "Upgrade to use tools with Captain",
"AVAILABLE_ON": "Captain Tools are only available in Business and Enterprise plans. Please upgrade to Business plan to use the feature.",
"UPGRADE_PROMPT": "",
"UPGRADE_NOW": "Open billing",
"CANCEL_ANYTIME": ""
},
"ENTERPRISE_PAYWALL": {
"AVAILABLE_ON": "Captain Tools are only available in the paid plans.",
"UPGRADE_PROMPT": "Please upgrade to a paid plan to use this feature.",
"ASK_ADMIN": "Please reach out to your administrator for the upgrade."
},
"TEST": { "TEST": {
"BUTTON": "Test connection", "BUTTON": "Test connection",
"SUCCESS": "Endpoint returned HTTP {status}", "SUCCESS": "Endpoint returned HTTP {status}",

View File

@@ -23,6 +23,12 @@ const meta = {
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE], installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
}; };
const metaCustomTools = {
permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS,
installationTypes: [INSTALLATION_TYPES.CLOUD, INSTALLATION_TYPES.ENTERPRISE],
};
const metaV2 = { const metaV2 = {
permissions: ['administrator', 'agent'], permissions: ['administrator', 'agent'],
featureFlag: FEATURE_FLAGS.CAPTAIN_V2, featureFlag: FEATURE_FLAGS.CAPTAIN_V2,
@@ -46,7 +52,7 @@ const assistantRoutes = [
path: frontendURL('accounts/:accountId/captain/:assistantId/tools'), path: frontendURL('accounts/:accountId/captain/:assistantId/tools'),
component: CustomToolsIndex, component: CustomToolsIndex,
name: 'captain_tools_index', name: 'captain_tools_index',
meta, meta: metaCustomTools,
}, },
{ {
path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'), path: frontendURL('accounts/:accountId/captain/:assistantId/scenarios'),

View File

@@ -5,13 +5,14 @@ import { FEATURE_FLAGS } from 'dashboard/featureFlags';
import { usePolicy } from 'dashboard/composables/usePolicy'; import { usePolicy } from 'dashboard/composables/usePolicy';
import PageLayout from 'dashboard/components-next/captain/PageLayout.vue'; import PageLayout from 'dashboard/components-next/captain/PageLayout.vue';
import CaptainPaywall from 'dashboard/components-next/captain/pageComponents/Paywall.vue';
import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue'; import CustomToolsPageEmptyState from 'dashboard/components-next/captain/pageComponents/emptyStates/CustomToolsPageEmptyState.vue';
import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue'; import CreateCustomToolDialog from 'dashboard/components-next/captain/pageComponents/customTool/CreateCustomToolDialog.vue';
import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue'; import CustomToolCard from 'dashboard/components-next/captain/pageComponents/customTool/CustomToolCard.vue';
import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue'; import DeleteDialog from 'dashboard/components-next/captain/pageComponents/DeleteDialog.vue';
const store = useStore(); const store = useStore();
const { isFeatureFlagEnabled } = usePolicy(); const { isFeatureFlagEnabled, shouldShowPaywall } = usePolicy();
const SOFT_LIMIT = 10; const SOFT_LIMIT = 10;
const isV2 = computed(() => isFeatureFlagEnabled(FEATURE_FLAGS.CAPTAIN_V2)); const isV2 = computed(() => isFeatureFlagEnabled(FEATURE_FLAGS.CAPTAIN_V2));
@@ -80,7 +81,9 @@ const onDeleteSuccess = () => {
}; };
onMounted(() => { onMounted(() => {
if (!shouldShowPaywall(FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS)) {
fetchCustomTools(); fetchCustomTools();
}
}); });
</script> </script>
@@ -89,6 +92,7 @@ onMounted(() => {
:header-title="$t('CAPTAIN.CUSTOM_TOOLS.HEADER')" :header-title="$t('CAPTAIN.CUSTOM_TOOLS.HEADER')"
:button-label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')" :button-label="$t('CAPTAIN.CUSTOM_TOOLS.ADD_NEW')"
:button-policy="['administrator']" :button-policy="['administrator']"
:feature-flag="FEATURE_FLAGS.CAPTAIN_CUSTOM_TOOLS"
:total-count="customToolsMeta.totalCount" :total-count="customToolsMeta.totalCount"
:current-page="customToolsMeta.page" :current-page="customToolsMeta.page"
:show-pagination-footer="!isFetching && !!customTools.length" :show-pagination-footer="!isFetching && !!customTools.length"
@@ -98,6 +102,10 @@ onMounted(() => {
@update:current-page="onPageChange" @update:current-page="onPageChange"
@click="openCreateDialog" @click="openCreateDialog"
> >
<template #paywall>
<CaptainPaywall feature-prefix="CAPTAIN.CUSTOM_TOOLS" />
</template>
<template #emptyState> <template #emptyState>
<CustomToolsPageEmptyState @click="openCreateDialog" /> <CustomToolsPageEmptyState @click="openCreateDialog" />
</template> </template>

View File

@@ -1,4 +1,5 @@
<script setup> <script setup>
import { useAdmin } from 'dashboard/composables/useAdmin';
import Icon from 'next/icon/Icon.vue'; import Icon from 'next/icon/Icon.vue';
import ButtonV4 from 'next/button/Button.vue'; import ButtonV4 from 'next/button/Button.vue';
@@ -22,6 +23,11 @@ defineProps({
}); });
const emit = defineEmits(['upgrade']); const emit = defineEmits(['upgrade']);
// Cloud agents land on this modal too, but billing is admin-only — they need
// the escalation message instead of a button they cannot use. Mirrors the
// pattern in UpgradePage.vue.
const { isAdmin } = useAdmin();
</script> </script>
<template> <template>
@@ -47,11 +53,14 @@ const emit = defineEmits(['upgrade']);
/> />
<p class="text-sm font-normal text-n-slate-11"> <p class="text-sm font-normal text-n-slate-11">
{{ $t(`${featurePrefix}.${i18nKey}.UPGRADE_PROMPT`) }} {{ $t(`${featurePrefix}.${i18nKey}.UPGRADE_PROMPT`) }}
<span v-if="!isOnChatwootCloud && !isSuperAdmin"> <span v-if="isOnChatwootCloud && !isAdmin">
{{ $t('GENERAL_SETTINGS.LIMIT_MESSAGES.NON_ADMIN') }}
</span>
<span v-else-if="!isOnChatwootCloud && !isSuperAdmin">
{{ $t(`${featurePrefix}.ENTERPRISE_PAYWALL.ASK_ADMIN`) }} {{ $t(`${featurePrefix}.ENTERPRISE_PAYWALL.ASK_ADMIN`) }}
</span> </span>
</p> </p>
<template v-if="isOnChatwootCloud"> <template v-if="isOnChatwootCloud && isAdmin">
<ButtonV4 blue solid md @click="emit('upgrade')"> <ButtonV4 blue solid md @click="emit('upgrade')">
{{ $t(`${featurePrefix}.PAYWALL.UPGRADE_NOW`) }} {{ $t(`${featurePrefix}.PAYWALL.UPGRADE_NOW`) }}
</ButtonV4> </ButtonV4>
@@ -59,7 +68,7 @@ const emit = defineEmits(['upgrade']);
{{ $t(`${featurePrefix}.PAYWALL.CANCEL_ANYTIME`) }} {{ $t(`${featurePrefix}.PAYWALL.CANCEL_ANYTIME`) }}
</span> </span>
</template> </template>
<template v-else-if="isSuperAdmin"> <template v-else-if="!isOnChatwootCloud && isSuperAdmin">
<a href="/super_admin" class="block w-full"> <a href="/super_admin" class="block w-full">
<ButtonV4 solid blue md class="w-full"> <ButtonV4 solid blue md class="w-full">
{{ $t(`${featurePrefix}.PAYWALL.UPGRADE_NOW`) }} {{ $t(`${featurePrefix}.PAYWALL.UPGRADE_NOW`) }}