diff --git a/integrations/feature-base/definitions/boards.ts b/integrations/feature-base/definitions/boards.ts
new file mode 100644
index 00000000000..55ea61b66be
--- /dev/null
+++ b/integrations/feature-base/definitions/boards.ts
@@ -0,0 +1,62 @@
+import { z } from '@botpress/sdk'
+const boardModel = {
+ id: z.string().title('id').describe('The unique identifier of the board.'),
+ category: z.string().title('Category').describe('The name of the board/category. Example: "Feature Requests"'),
+ private: z.boolean().title('Private').optional().describe('Flag indicating whether this board is private.'),
+ segmentIds: z.array(z.string()).title('Segment Ids').describe('An array of segment IDs associated with this board.'),
+ roles: z.array(z.string()).title('Roles').describe('An array of roles that have access to this board.'),
+ hiddenFromRoles: z
+ .array(z.string())
+ .title('Hidden From Roles')
+ .describe('An array of roles that cannot see this board.'),
+ disablePostCreation: z
+ .boolean()
+ .title('Disable Post Creation')
+ .optional()
+ .describe('Flag indicating whether post creation is disabled for this board.'),
+ disableFollowUpQuestions: z
+ .boolean()
+ .title('Disable Follow Up Questions')
+ .optional()
+ .describe('Flag indicating whether follow-up questions are disabled for this board.'),
+ customInputFields: z
+ .array(z.string())
+ .title('Custom Input Fields')
+ .describe('An array of custom input fields ids that apply to this board.'),
+ defaultAuthorOnly: z
+ .boolean()
+ .title('Default Author Only')
+ .optional()
+ .describe('Flag indicating whether posts in this board are visible to the author only by default.'),
+ defaultCompanyOnly: z
+ .boolean()
+ .title('Default Company Only')
+ .optional()
+ .describe('Flag indicating whether posts in this board are visible to the company only by default.'),
+}
+
+export const listBoards = {
+ title: 'List boards',
+ description: 'List all boards',
+ input: {
+ schema: z.object({}),
+ },
+ output: {
+ schema: z.object({
+ results: z.array(z.object(boardModel)).title('Results').describe('An array of boards.'),
+ }),
+ },
+}
+
+export const getBoard = {
+ title: 'Get a board',
+ description: 'Get a board by ID',
+ input: {
+ schema: z.object({
+ id: z.string().optional().title('ID').describe('The unique identifier of the board to retrieve.'),
+ }),
+ },
+ output: {
+ schema: z.object(boardModel).title('Board').describe('A single board'),
+ },
+}
diff --git a/integrations/feature-base/definitions/posts.ts b/integrations/feature-base/definitions/posts.ts
new file mode 100644
index 00000000000..3d73e77d0c3
--- /dev/null
+++ b/integrations/feature-base/definitions/posts.ts
@@ -0,0 +1,158 @@
+import { z } from '@botpress/sdk'
+
+export const createPost = {
+ title: 'Create a post',
+ description: 'Create a post on feature base',
+ input: {
+ schema: z.object({
+ title: z.string().title('Title').describe('The title of the submission. It must be at least 2 characters long.'),
+ category: z.string().title('Category').describe('The board (a.k.a category) of the submission.'),
+ content: z
+ .string()
+ .title('Content')
+ .optional()
+ .describe('The content of the submission. Can be an empty string.'),
+ email: z
+ .string()
+ .title('Email')
+ .optional()
+ .describe(
+ 'The email of the user submitting the post. Will create a new user if the email is not associated with an existing user.'
+ ),
+ authorName: z
+ .string()
+ .title('Author Name')
+ .optional()
+ .describe(
+ 'Used when you provide an email. If the email is not associated with an existing user, a new user will be created with this name.'
+ ),
+ tags: z
+ .array(z.string())
+ .title('Tags')
+ .optional()
+ .describe('The tags associated with the submission. Needs to be an array of tag names.'),
+ commentsAllowed: z
+ .boolean()
+ .title('Comments Allowed')
+ .optional()
+ .describe('Flag indicating whether comments are allowed on the submission.'),
+ status: z.string().title('Status').optional().describe('The status of the submission.'),
+ date: z.date().title('Date').optional().describe('Set the post creation date.'),
+ }),
+ },
+ output: {
+ schema: z.object({
+ submission: z
+ .object({
+ id: z.string(),
+ })
+ .title('Submission')
+ .describe('Represent the created post.'),
+ }),
+ },
+}
+
+export const listPosts = {
+ title: 'List posts',
+ description: 'List all posts',
+ input: {
+ schema: z.object({
+ id: z.string().title('ID').optional().describe("Find submission by it's id."),
+ q: z.string().title('Query').optional().describe('Search for posts by title or content.'),
+ category: z
+ .array(z.string())
+ .title('Category')
+ .optional()
+ .describe('Filter posts by providing an array of category(board) names.'),
+ status: z.array(z.string()).title('Status').optional().describe('Filter posts by status ids.'),
+ sortBy: z.string().title('Sort By').optional().describe('Sort posts by a specific attribute.'),
+ startDate: z.date().title('Start Date').optional().describe('Get posts created after a specific date.'),
+ endDate: z.date().title('End Date').optional().describe('Get posts created before a specific date.'),
+ limit: z.number().title('Limit').optional().describe('Number of results per page'),
+ page: z.number().title('Page').optional().describe('Page number. Starts at 1'),
+ nextToken: z.string().title('Next Token').optional().describe('Page number. Starts at 1'),
+ }),
+ },
+ output: {
+ schema: z.object({
+ nextToken: z.string().title('Next Token').optional().describe('Use the token to fetch the next page of posts.'),
+ results: z
+ .array(
+ z.object({
+ title: z.string(),
+ content: z.string(),
+ author: z.string(),
+ authorId: z.string(),
+ organization: z.string(),
+ postCategory: z.object({
+ category: z.string(),
+ }),
+ id: z.string(),
+ })
+ )
+ .title('Results')
+ .describe('An array of posts.'),
+ }),
+ },
+}
+
+export const updatePost = {
+ title: 'Update post',
+ description: 'Update a post',
+ input: {
+ schema: z.object({
+ id: z.string().title('ID').describe('The id of the submission.'),
+ title: z.string().title('Title').optional().describe('The title of the post. Example: "Add dark mode support"'),
+ content: z
+ .string()
+ .title('Content')
+ .optional()
+ .describe(
+ 'The HTML content of the post. Example: "
It would be great to have dark mode support for better viewing at night.
"'
+ ),
+ status: z.string().title('Status').optional().describe('The status of the submission. Example: "In Progress"'),
+ commentsAllowed: z
+ .boolean()
+ .title('Comments Allowed')
+ .optional()
+ .describe('Flag indicating whether comments are allowed on the submission. Example: true'),
+ category: z
+ .string()
+ .title('Category')
+ .optional()
+ .describe('The category of the submission. Example: "💡 Feature Request"'),
+ sendStatusUpdateEmail: z
+ .boolean()
+ .title('Send Status Update Email')
+ .optional()
+ .describe('Flag indicating whether to send a status update email to the upvoters. Default: false'),
+ tags: z
+ .array(z.string())
+ .title('Tags')
+ .optional()
+ .describe('The tags of the submission. Example: ["tag1", "tag2"]'),
+ inReview: z
+ .boolean()
+ .title('In Review')
+ .optional()
+ .describe('Flag indicating whether the submission is in review. In review posts are not visible to users.'),
+ date: z.date().title('Date').optional().describe('The post creation date.'),
+ }),
+ },
+ output: {
+ schema: z.object({}),
+ },
+}
+
+export const deletePost = {
+ title: 'Delete post',
+ description: 'Delete a post',
+ input: {
+ schema: z.object({
+ id: z.string().title('ID').describe('The id of the submission.'),
+ }),
+ },
+ output: {
+ schema: z.object({}),
+ },
+}
diff --git a/integrations/feature-base/hub.md b/integrations/feature-base/hub.md
new file mode 100644
index 00000000000..1264be947bc
--- /dev/null
+++ b/integrations/feature-base/hub.md
@@ -0,0 +1,11 @@
+# Feature Base integration
+
+## Description
+
+This integration allows Botpress bots to interact with Feature Base’s API. By connecting your bot to Feature Base, you can dynamically manage and display content such as posts or boards without leaving the conversation. Whether it’s retrieving live data, adding new entries, or updating existing ones, this integration turns your chatbot into a real-time content manager for Feature Base.
+
+## Getting started
+
+### Configuration
+
+Your API key is needed to configure this integration. How to find your API key is describe in [this documentation](https://docs.featurebase.app/quickstart).
diff --git a/integrations/feature-base/icon.svg b/integrations/feature-base/icon.svg
new file mode 100644
index 00000000000..3b3cc730465
--- /dev/null
+++ b/integrations/feature-base/icon.svg
@@ -0,0 +1,13 @@
+
diff --git a/integrations/feature-base/integration.definition.ts b/integrations/feature-base/integration.definition.ts
new file mode 100644
index 00000000000..50fc28a4513
--- /dev/null
+++ b/integrations/feature-base/integration.definition.ts
@@ -0,0 +1,25 @@
+import { z, IntegrationDefinition } from '@botpress/sdk'
+import { listBoards, getBoard } from 'definitions/boards'
+import { listPosts, createPost, deletePost, updatePost } from 'definitions/posts'
+
+export default new IntegrationDefinition({
+ name: 'feature-base',
+ version: '0.1.0',
+ title: 'Feature Base',
+ description: 'CRUD operations for Feature Base',
+ readme: 'hub.md',
+ icon: 'icon.svg',
+ configuration: {
+ schema: z.object({
+ apiKey: z.string().min(1, 'API Key is required').describe('Your Feature Base API Key').title('API Key'),
+ }),
+ },
+ actions: {
+ listBoards,
+ getBoard,
+ createPost,
+ listPosts,
+ deletePost,
+ updatePost,
+ },
+})
diff --git a/integrations/feature-base/package.json b/integrations/feature-base/package.json
new file mode 100644
index 00000000000..118bb90e8fc
--- /dev/null
+++ b/integrations/feature-base/package.json
@@ -0,0 +1,18 @@
+{
+ "name": "@botpresshub/feature-base",
+ "scripts": {
+ "build": "bp add -y && bp build",
+ "check:type": "tsc --noEmit",
+ "check:bplint": "bp lint"
+ },
+ "private": true,
+ "dependencies": {
+ "@botpress/client": "workspace:*",
+ "@botpress/sdk": "workspace:*",
+ "axios": "^1.11.0"
+ },
+ "devDependencies": {
+ "@types/node": "^22.16.4",
+ "typescript": "^5.6.3"
+ }
+}
diff --git a/integrations/feature-base/src/client.ts b/integrations/feature-base/src/client.ts
new file mode 100644
index 00000000000..8df2f64a0ce
--- /dev/null
+++ b/integrations/feature-base/src/client.ts
@@ -0,0 +1,128 @@
+import { RuntimeError } from '@botpress/client'
+import axios, { Axios, AxiosResponse } from 'axios'
+import * as bp from '.botpress'
+
+type Actions = bp.actions.Actions
+type Input = Actions[K]['input']
+
+export type ErrorResponse = {
+ code: number
+ message: string
+}
+
+type Output = Actions[K]['output']
+type ApiOutput = Output | ErrorResponse
+
+type PagedApiOutput =
+ | ErrorResponse
+ | (Omit, 'nextToken'> & {
+ page: number
+ limit: number
+ totalResults: number
+ })
+
+export class FeatureBaseClient {
+ private _client: Axios
+
+ public constructor(apiKey: string) {
+ this._client = axios.create({
+ baseURL: 'https://do.featurebase.app',
+ headers: {
+ 'X-API-Key': apiKey,
+ 'Content-Type': 'application/json',
+ },
+ })
+ }
+
+ private _unwrapPagedResponse(response: PagedApiOutput): Output {
+ if ('message' in response) {
+ throw new RuntimeError(response.message)
+ }
+ const { limit, page, totalResults, ...result } = response
+ let nextToken: string | undefined = undefined
+ if (limit * page < totalResults) {
+ nextToken = String(page + 1)
+ }
+ return {
+ ...result,
+ nextToken,
+ }
+ }
+
+ private _parsePagedParams(
+ params: Input
+ ): Omit, 'nextToken'> & { page?: number } {
+ if (!('nextToken' in params)) {
+ return params
+ }
+ let page: number | undefined = undefined
+ if (params.nextToken && !isNaN(Number(params.nextToken))) {
+ page = Number(params.nextToken)
+ }
+ return {
+ ...params,
+ page,
+ }
+ }
+
+ private _unwrapResponse(response: ApiOutput): Output {
+ if ('message' in response) {
+ throw new RuntimeError(response.message)
+ }
+
+ return response
+ }
+
+ private _handleAxiosError(thrown: unknown): never {
+ if (axios.isAxiosError(thrown)) {
+ throw new RuntimeError(thrown.response?.data?.message || thrown.message)
+ } else {
+ const error = thrown instanceof Error ? thrown : new Error(String(thrown))
+ throw new RuntimeError(error.message)
+ }
+ }
+
+ public async listBoards(): Promise