diff --git a/apps/emdash-desktop/src/renderer/features/conversations/acp/acp-chat-panel.tsx b/apps/emdash-desktop/src/renderer/features/conversations/acp/acp-chat-panel.tsx
index 653beb5702..11ad0d8f2a 100644
--- a/apps/emdash-desktop/src/renderer/features/conversations/acp/acp-chat-panel.tsx
+++ b/apps/emdash-desktop/src/renderer/features/conversations/acp/acp-chat-panel.tsx
@@ -47,7 +47,7 @@ import { isHeicLikeFile, isUnstableDropPath } from '@renderer/lib/pty/terminal-i
import { useAgents } from '@renderer/lib/stores/use-agents';
import { Button } from '@renderer/lib/ui/button';
import { log } from '@renderer/utils/logger';
-import type { LinkedIssue } from '@shared/core/linked-issue';
+import { linkedIssueMentionName, type LinkedIssue } from '@shared/core/linked-issue';
import type { AcpChatStore, AcpPromptAttachment } from './acp-chat-store';
import type { AcpChatTabResource } from './acp-chat-tab-resource';
import { chatViewCommandForShortcut, executeChatViewCommand } from './acp-chat-view-commands';
@@ -78,7 +78,7 @@ function toIssueMentionItem(issue: LinkedIssue): MentionItem {
return {
id: token,
label: token,
- name: issue.displayIdentifier ?? issue.identifier,
+ name: linkedIssueMentionName(issue),
kind: 'issue',
description: issue.title,
icon: ,
diff --git a/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx b/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx
index 7884a00c0e..db56da4c39 100644
--- a/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx
+++ b/apps/emdash-desktop/src/renderer/features/integrations/integration-setup-modal.tsx
@@ -92,21 +92,18 @@ function IntegrationSetupForm({
))}
{method.help || method.helpUrl ? (
-
- {method.help ? (
-
- {method.help}
- {method.helpUrl ? (
-
- ) : null}
-
+
+ {method.help ?
{method.help}
: null}
+ {method.helpUrl ? (
+
) : null}
) : null}
diff --git a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx
index cbe6f3d45b..f93fa241be 100644
--- a/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx
+++ b/apps/emdash-desktop/src/renderer/features/tasks/components/issue-selector/issue-selector.tsx
@@ -28,7 +28,7 @@ import {
import { Select, SelectContent, SelectItem, SelectTrigger } from '@renderer/lib/ui/select';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/lib/ui/tooltip';
import { cn } from '@renderer/utils/utils';
-import type { LinkedIssue } from '@shared/core/linked-issue';
+import { linkedIssueDisplayIdentifier, type LinkedIssue } from '@shared/core/linked-issue';
import { getLinkedIssueMap, type LinkedIssueInfo } from './use-linked-issue-urls';
import { useIssueSearch } from './useIssueSearch';
@@ -39,8 +39,7 @@ export function IssueIdentifier({
issue: Pick
;
className?: string;
}) {
- const identifier =
- issue.displayIdentifier === null ? null : (issue.displayIdentifier ?? issue.identifier);
+ const identifier = linkedIssueDisplayIdentifier(issue);
if (!identifier) return null;
return (
diff --git a/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx b/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx
index 0eacce925a..1b77068838 100644
--- a/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx
+++ b/apps/emdash-desktop/src/renderer/features/tasks/task-titlebar.tsx
@@ -43,7 +43,7 @@ import { ToggleGroup, ToggleGroupItem } from '@renderer/lib/ui/toggle-group';
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/lib/ui/tooltip';
import { formatDiffLineCount } from '@renderer/utils/format-diff-line-count';
import { cn } from '@renderer/utils/utils';
-import type { LinkedIssue } from '@shared/core/linked-issue';
+import { linkedIssueDisplayIdentifier, type LinkedIssue } from '@shared/core/linked-issue';
import { AutomationRunPill } from './components/automation-run-pill';
import { IssueSelector, ProviderLogo } from './components/issue-selector/issue-selector';
import { PreviewServerPills } from './components/preview-servers/preview-server-pills';
@@ -416,8 +416,7 @@ const ActiveTaskTitlebar = observer(function ActiveTaskTitlebar({
});
function LinkedIssueBadge({ issue }: { issue: LinkedIssue }) {
- const displayIdentifier =
- issue.displayIdentifier === null ? null : (issue.displayIdentifier ?? issue.identifier);
+ const displayIdentifier = linkedIssueDisplayIdentifier(issue);
return (
diff --git a/apps/emdash-desktop/src/shared/core/linked-issue.test.ts b/apps/emdash-desktop/src/shared/core/linked-issue.test.ts
new file mode 100644
index 0000000000..11e7a40eac
--- /dev/null
+++ b/apps/emdash-desktop/src/shared/core/linked-issue.test.ts
@@ -0,0 +1,41 @@
+import { describe, expect, it } from 'vitest';
+import { linkedIssueDisplayIdentifier, linkedIssueMentionName } from './linked-issue';
+
+describe('linked issue display helpers', () => {
+ it('uses displayIdentifier for issue mentions when available', () => {
+ expect(
+ linkedIssueMentionName({
+ identifier: 'internal-id',
+ displayIdentifier: 'ENG-123',
+ title: 'Fix issue mentions',
+ })
+ ).toBe('ENG-123');
+ });
+
+ it('uses title for issue mentions when the provider hides internal identifiers', () => {
+ expect(
+ linkedIssueMentionName({
+ identifier: '37818d1b-a831-812e-8ca0-c115c72de662',
+ displayIdentifier: null,
+ title: 'ai health paper website',
+ })
+ ).toBe('ai health paper website');
+ });
+
+ it('keeps raw identifiers visible only when displayIdentifier is unspecified', () => {
+ const issue = { identifier: '#42', title: 'Fix login' };
+
+ expect(linkedIssueDisplayIdentifier(issue)).toBe('#42');
+ expect(linkedIssueMentionName(issue)).toBe('#42');
+ });
+
+ it('uses a generic mention name when both display identifier and title are hidden', () => {
+ expect(
+ linkedIssueMentionName({
+ identifier: 'internal-id',
+ displayIdentifier: null,
+ title: '',
+ })
+ ).toBe('Linked issue');
+ });
+});
diff --git a/apps/emdash-desktop/src/shared/core/linked-issue.ts b/apps/emdash-desktop/src/shared/core/linked-issue.ts
index 1bdb94ec31..66156e7b1c 100644
--- a/apps/emdash-desktop/src/shared/core/linked-issue.ts
+++ b/apps/emdash-desktop/src/shared/core/linked-issue.ts
@@ -17,6 +17,7 @@ const v0Schema = z.object({
'featurebase',
'asana',
'monday',
+ 'notion',
'trello',
]),
url: z.string(),
@@ -54,3 +55,15 @@ export const linkedIssueSchema = linkedIssue.schema;
/** The TypeScript type for a linked issue. */
export type LinkedIssue = typeof linkedIssue.Type;
+
+export function linkedIssueDisplayIdentifier(
+ issue: Pick
+): string | null {
+ return issue.displayIdentifier === null ? null : (issue.displayIdentifier ?? issue.identifier);
+}
+
+export function linkedIssueMentionName(
+ issue: Pick
+): string {
+ return linkedIssueDisplayIdentifier(issue) ?? (issue.title || 'Linked issue');
+}
diff --git a/packages/plugins/package.json b/packages/plugins/package.json
index f8ea97ba6e..f17b0ae5f8 100644
--- a/packages/plugins/package.json
+++ b/packages/plugins/package.json
@@ -46,6 +46,7 @@
"@llamaduck/forgejo-ts": "^14.0.3",
"@makeplane/plane-node-sdk": "^0.2.11",
"@mondaydotcomorg/api": "^14.0.0",
+ "@notionhq/client": "^5.22.0",
"@octokit/rest": "^22.0.1",
"@team-plain/graphql": "^1.0.1",
"asana": "^3.1.12",
diff --git a/packages/plugins/src/integrations/impl/notion/client.ts b/packages/plugins/src/integrations/impl/notion/client.ts
new file mode 100644
index 0000000000..ecc591894f
--- /dev/null
+++ b/packages/plugins/src/integrations/impl/notion/client.ts
@@ -0,0 +1,43 @@
+import { err, ok, type Result } from '@emdash/shared';
+import { Client } from '@notionhq/client';
+import { parseCredentials } from '../../helpers/credentials';
+import type { IntegrationCredentials } from '../../host';
+import type { IntegrationError } from '../../types';
+import { toNotionIntegrationError } from './error';
+import {
+ type NotionClient,
+ type NotionCredentials,
+ notionCredentialsSchema,
+ type NotionVerifiedConnection,
+} from './types';
+
+export function readNotionCredentials(
+ credentials: IntegrationCredentials
+): Result {
+ return parseCredentials(notionCredentialsSchema, credentials);
+}
+
+export function createNotionClient(credentials: NotionCredentials): NotionClient {
+ return new Client({ auth: credentials.apiToken });
+}
+
+export async function verifyNotionCredentials(
+ rawCredentials: IntegrationCredentials
+): Promise> {
+ const credentials = readNotionCredentials(rawCredentials);
+ if (!credentials.success) return err(credentials.error);
+
+ const client = createNotionClient(credentials.data);
+ try {
+ const user = await client.users.me({});
+ const displayName = user.name ?? (user.type === 'bot' ? 'Notion bot' : 'Notion user');
+ const displayDetail = user.type === 'person' ? user.person.email : undefined;
+ return ok({
+ displayName,
+ ...(displayDetail ? { displayDetail } : {}),
+ credentials: credentials.data,
+ });
+ } catch (error) {
+ return err(toNotionIntegrationError(error, 'Failed to validate Notion token.'));
+ }
+}
diff --git a/packages/plugins/src/integrations/impl/notion/error.ts b/packages/plugins/src/integrations/impl/notion/error.ts
new file mode 100644
index 0000000000..ae4ea56489
--- /dev/null
+++ b/packages/plugins/src/integrations/impl/notion/error.ts
@@ -0,0 +1,51 @@
+import { APIErrorCode, ClientErrorCode, isNotionClientError } from '@notionhq/client';
+import type { IntegrationError } from '../../types';
+
+export function toNotionIntegrationError(
+ error: unknown,
+ fallback = 'Notion request failed.'
+): IntegrationError {
+ if (!isNotionClientError(error)) {
+ if (error instanceof Error) return { type: 'generic', message: error.message || fallback };
+ return { type: 'generic', message: fallback };
+ }
+
+ switch (error.code) {
+ case APIErrorCode.Unauthorized:
+ return {
+ type: 'auth_failed',
+ message: 'Notion authentication failed. Check your integration token.',
+ };
+ case APIErrorCode.RestrictedResource:
+ return {
+ type: 'auth_failed',
+ message: 'Notion token is missing the required capabilities or page access.',
+ };
+ case APIErrorCode.ObjectNotFound:
+ return {
+ type: 'not_found_or_no_access',
+ message: 'Notion resource was not found or the integration does not have access.',
+ };
+ case APIErrorCode.RateLimited:
+ return {
+ type: 'rate_limited',
+ message: 'Notion API rate limit exceeded. Please try again shortly.',
+ };
+ case APIErrorCode.InternalServerError:
+ case APIErrorCode.ServiceUnavailable:
+ case APIErrorCode.GatewayTimeout:
+ case ClientErrorCode.RequestTimeout:
+ return {
+ type: 'host_unreachable',
+ message: 'Notion API is temporarily unavailable. Please try again.',
+ };
+ case APIErrorCode.InvalidJSON:
+ case APIErrorCode.InvalidRequestURL:
+ case APIErrorCode.InvalidRequest:
+ case APIErrorCode.ValidationError:
+ case APIErrorCode.ConflictError:
+ case ClientErrorCode.InvalidPathParameter:
+ case ClientErrorCode.ResponseError:
+ return { type: 'generic', message: error.message || fallback };
+ }
+}
diff --git a/packages/plugins/src/integrations/impl/notion/icon.ts b/packages/plugins/src/integrations/impl/notion/icon.ts
new file mode 100644
index 0000000000..b7c7d32f75
--- /dev/null
+++ b/packages/plugins/src/integrations/impl/notion/icon.ts
@@ -0,0 +1,15 @@
+import type { PluginIconAsset } from '@emdash/shared/plugins';
+
+const svg =
+ '';
+
+export const icon: PluginIconAsset = {
+ kind: 'svg',
+ alt: 'Notion',
+ variants: [
+ {
+ minSize: 0,
+ light: svg,
+ },
+ ],
+};
diff --git a/packages/plugins/src/integrations/impl/notion/index.test.ts b/packages/plugins/src/integrations/impl/notion/index.test.ts
new file mode 100644
index 0000000000..5d167e6a9c
--- /dev/null
+++ b/packages/plugins/src/integrations/impl/notion/index.test.ts
@@ -0,0 +1,100 @@
+import type { Logger } from '@emdash/shared/logger';
+import { APIErrorCode, APIResponseError } from '@notionhq/client';
+import type * as NotionSdk from '@notionhq/client';
+import { afterEach, describe, expect, it, vi } from 'vitest';
+import type { IntegrationHostContext } from '../../host';
+import { provider } from './index';
+
+const notionSdk = vi.hoisted(() => ({
+ constructor: vi.fn(),
+ me: vi.fn(),
+}));
+
+vi.mock('@notionhq/client', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ Client: class {
+ constructor(config: unknown) {
+ notionSdk.constructor(config);
+ }
+
+ users = {
+ me: notionSdk.me,
+ };
+ },
+ };
+});
+
+const logger: Logger = {
+ level: 'error',
+ debug: () => {},
+ info: () => {},
+ warn: () => {},
+ error: () => {},
+ child: () => logger,
+};
+
+const auth = provider.behavior.auth;
+if (!auth) throw new Error('Notion auth behavior is not registered.');
+
+const host: IntegrationHostContext = { log: logger };
+
+afterEach(() => {
+ notionSdk.constructor.mockReset();
+ notionSdk.me.mockReset();
+});
+
+describe('notion integration verify', () => {
+ it('validates the integration token and returns normalized credentials', async () => {
+ notionSdk.me.mockResolvedValueOnce({
+ object: 'user',
+ id: 'bot-1',
+ type: 'bot',
+ name: 'Emdash',
+ avatar_url: null,
+ bot: {},
+ });
+
+ const result = await auth.verify(host, { apiToken: ' ntn_valid ' });
+
+ expect(result).toEqual({
+ connected: true,
+ displayName: 'Emdash',
+ credentials: { apiToken: 'ntn_valid' },
+ });
+ expect(notionSdk.constructor).toHaveBeenCalledWith({ auth: 'ntn_valid' });
+ expect(notionSdk.me).toHaveBeenCalledWith({});
+ });
+
+ it('returns an error for an empty token', async () => {
+ const result = await auth.verify(host, { apiToken: ' ' });
+
+ expect(result).toEqual({
+ connected: false,
+ error: 'Notion integration token is required.',
+ });
+ expect(notionSdk.constructor).not.toHaveBeenCalled();
+ });
+
+ it('maps Notion unauthorized errors to a helpful auth message', async () => {
+ notionSdk.me.mockRejectedValueOnce(
+ new APIResponseError({
+ code: APIErrorCode.Unauthorized,
+ status: 401,
+ message: 'API token is invalid.',
+ headers: new Headers(),
+ rawBodyText: '{}',
+ additional_data: undefined,
+ request_id: undefined,
+ })
+ );
+
+ const result = await auth.verify(host, { apiToken: 'bad-token' });
+
+ expect(result).toEqual({
+ connected: false,
+ error: 'Notion authentication failed. Check your integration token.',
+ });
+ });
+});
diff --git a/packages/plugins/src/integrations/impl/notion/index.ts b/packages/plugins/src/integrations/impl/notion/index.ts
new file mode 100644
index 0000000000..6f900d8280
--- /dev/null
+++ b/packages/plugins/src/integrations/impl/notion/index.ts
@@ -0,0 +1,44 @@
+import type { VerifyResult } from '../../capabilities/auth';
+import { defineIntegrationPlugin, registerIntegrationPluginBehavior } from '../../plugin';
+import { verifyNotionCredentials } from './client';
+import { icon } from './icon';
+
+const plugin = defineIntegrationPlugin(
+ {
+ id: 'notion',
+ name: 'Notion',
+ description: 'Connect your Notion workspace',
+ websiteUrl: 'https://www.notion.so',
+ },
+ {
+ auth: {
+ methods: [
+ {
+ kind: 'form',
+ fields: [
+ {
+ id: 'apiToken',
+ label: 'Integration token',
+ secret: true,
+ required: true,
+ placeholder: 'ntn_…',
+ },
+ ],
+ help: 'Create a Notion internal integration token or personal access token, then share each page or database you want Emdash to access with that integration.',
+ helpUrl: 'https://developers.notion.com/guides/get-started/authorization',
+ },
+ ],
+ },
+ },
+ { icon }
+);
+
+export const provider = registerIntegrationPluginBehavior(plugin, {
+ auth: {
+ async verify(_host, credentials): Promise {
+ const result = await verifyNotionCredentials(credentials);
+ if (!result.success) return { connected: false, error: result.error.message };
+ return { connected: true, ...result.data };
+ },
+ },
+});
diff --git a/packages/plugins/src/integrations/impl/notion/types.ts b/packages/plugins/src/integrations/impl/notion/types.ts
new file mode 100644
index 0000000000..41bfea15b8
--- /dev/null
+++ b/packages/plugins/src/integrations/impl/notion/types.ts
@@ -0,0 +1,17 @@
+import type { Client as NotionSdkClient } from '@notionhq/client';
+import z from 'zod';
+import { credentialString } from '../../helpers/credentials';
+
+export const notionCredentialsSchema = z.object({
+ apiToken: credentialString('Notion integration token is required.'),
+});
+
+export type NotionCredentials = z.infer;
+
+export type NotionClient = NotionSdkClient;
+
+export type NotionVerifiedConnection = {
+ displayName?: string;
+ displayDetail?: string;
+ credentials: NotionCredentials;
+};
diff --git a/packages/plugins/src/integrations/registry.ts b/packages/plugins/src/integrations/registry.ts
index e223b28c03..b0acf3a854 100644
--- a/packages/plugins/src/integrations/registry.ts
+++ b/packages/plugins/src/integrations/registry.ts
@@ -7,6 +7,7 @@ import { provider as gitlab } from './impl/gitlab';
import { provider as jira } from './impl/jira';
import { provider as linear } from './impl/linear';
import { provider as monday } from './impl/monday';
+import { provider as notion } from './impl/notion';
import { provider as plain } from './impl/plain';
import { provider as plane } from './impl/plane';
import { provider as trello } from './impl/trello';
@@ -24,6 +25,7 @@ for (const provider of [
trello,
asana,
monday,
+ notion,
featurebase,
plain,
]) {
diff --git a/packages/plugins/src/issues/impl/notion/context.ts b/packages/plugins/src/issues/impl/notion/context.ts
new file mode 100644
index 0000000000..91604463e4
--- /dev/null
+++ b/packages/plugins/src/issues/impl/notion/context.ts
@@ -0,0 +1,91 @@
+import type { BlockObjectResponse, PartialBlockObjectResponse } from '@notionhq/client';
+import { richTextPlainText } from './mapper';
+
+export function formatNotionContext(
+ blocks: Array
+): string | undefined {
+ const lines = blocks.map(blockToMarkdown).filter((line): line is string => !!line?.trim());
+ return lines.length ? lines.join('\n') : undefined;
+}
+
+function blockToMarkdown(
+ block: BlockObjectResponse | PartialBlockObjectResponse
+): string | undefined {
+ if (!('type' in block)) return undefined;
+
+ switch (block.type) {
+ case 'paragraph':
+ return richTextPlainText('paragraph' in block ? block.paragraph?.rich_text : undefined);
+ case 'heading_1':
+ return heading(
+ '#',
+ richTextPlainText('heading_1' in block ? block.heading_1?.rich_text : undefined)
+ );
+ case 'heading_2':
+ return heading(
+ '##',
+ richTextPlainText('heading_2' in block ? block.heading_2?.rich_text : undefined)
+ );
+ case 'heading_3':
+ return heading(
+ '###',
+ richTextPlainText('heading_3' in block ? block.heading_3?.rich_text : undefined)
+ );
+ case 'heading_4':
+ return heading(
+ '####',
+ richTextPlainText('heading_4' in block ? block.heading_4?.rich_text : undefined)
+ );
+ case 'bulleted_list_item':
+ return listItem(
+ '-',
+ richTextPlainText(
+ 'bulleted_list_item' in block ? block.bulleted_list_item?.rich_text : undefined
+ )
+ );
+ case 'numbered_list_item':
+ return listItem(
+ '1.',
+ richTextPlainText(
+ 'numbered_list_item' in block ? block.numbered_list_item?.rich_text : undefined
+ )
+ );
+ case 'to_do':
+ if (!('to_do' in block)) return undefined;
+ return listItem(
+ `[${block.to_do?.checked ? 'x' : ' '}]`,
+ richTextPlainText(block.to_do?.rich_text)
+ );
+ case 'quote':
+ return quote(richTextPlainText('quote' in block ? block.quote?.rich_text : undefined));
+ case 'code':
+ if (!('code' in block)) return undefined;
+ return codeBlock(block.code?.language ?? '', richTextPlainText(block.code?.rich_text));
+ case 'child_page':
+ return 'child_page' in block && block.child_page?.title
+ ? `Child page: ${block.child_page.title}`
+ : undefined;
+ case 'child_database':
+ return 'child_database' in block && block.child_database?.title
+ ? `Child database: ${block.child_database.title}`
+ : undefined;
+ default:
+ return undefined;
+ }
+}
+
+function heading(prefix: string, text: string): string | undefined {
+ return text ? `${prefix} ${text}` : undefined;
+}
+
+function listItem(prefix: string, text: string): string | undefined {
+ return text ? `${prefix} ${text}` : undefined;
+}
+
+function quote(text: string): string | undefined {
+ return text ? `> ${text}` : undefined;
+}
+
+function codeBlock(language: string, text: string): string | undefined {
+ return text ? `\`\`\`${language}\n${text}\n\`\`\`` : undefined;
+}
diff --git a/packages/plugins/src/issues/impl/notion/index.test.ts b/packages/plugins/src/issues/impl/notion/index.test.ts
new file mode 100644
index 0000000000..34e60e9c08
--- /dev/null
+++ b/packages/plugins/src/issues/impl/notion/index.test.ts
@@ -0,0 +1,266 @@
+import { noopLogger } from '@emdash/shared/logger';
+import type { PageObjectResponse } from '@notionhq/client';
+import type * as NotionSdk from '@notionhq/client';
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { provider } from './index';
+
+const notionSdk = vi.hoisted(() => ({
+ blocksChildrenList: vi.fn(),
+ pagesRetrieve: vi.fn(),
+ search: vi.fn(),
+}));
+
+vi.mock('@notionhq/client', async (importOriginal) => {
+ const actual = await importOriginal();
+ return {
+ ...actual,
+ Client: class {
+ blocks = { children: { list: notionSdk.blocksChildrenList } };
+ pages = { retrieve: notionSdk.pagesRetrieve };
+ search = notionSdk.search;
+ },
+ };
+});
+
+const issues = provider.behavior.issues;
+if (!issues) throw new Error('Notion issues behavior is not registered');
+if (!issues.getIssue) throw new Error('Notion getIssue behavior is not registered');
+const getIssue = issues.getIssue;
+
+const host = { log: noopLogger, credentials: { apiToken: 'ntn_valid' } };
+
+function notionPage(
+ id: string,
+ title: string,
+ parentType: 'database_id' | 'data_source_id' | 'page_id' | 'workspace' = 'database_id'
+): PageObjectResponse {
+ const parent =
+ parentType === 'workspace'
+ ? { type: 'workspace' as const, workspace: true }
+ : { type: parentType, [parentType]: `${parentType}-1` };
+
+ return {
+ object: 'page',
+ id,
+ created_time: '2026-01-01T00:00:00.000Z',
+ last_edited_time: '2026-01-02T00:00:00.000Z',
+ created_by: { object: 'user', id: 'user-1' },
+ last_edited_by: { object: 'user', id: 'user-1' },
+ cover: null,
+ icon: null,
+ parent,
+ archived: false,
+ in_trash: false,
+ is_locked: false,
+ properties: {
+ Name: {
+ id: 'title',
+ type: 'title',
+ title: title
+ ? [
+ {
+ type: 'text',
+ plain_text: title,
+ href: null,
+ annotations: {
+ bold: false,
+ italic: false,
+ strikethrough: false,
+ underline: false,
+ code: false,
+ color: 'default',
+ },
+ text: { content: title, link: null },
+ },
+ ]
+ : [],
+ },
+ },
+ url: `https://www.notion.so/${id}`,
+ public_url: null,
+ request_id: 'request-1',
+ } as unknown as PageObjectResponse;
+}
+
+function richText(text: string) {
+ return [
+ {
+ type: 'text',
+ plain_text: text,
+ href: null,
+ annotations: {
+ bold: false,
+ italic: false,
+ strikethrough: false,
+ underline: false,
+ code: false,
+ color: 'default',
+ },
+ text: { content: text, link: null },
+ },
+ ];
+}
+
+function paragraphBlock(id: string, text: string) {
+ return {
+ object: 'block',
+ id,
+ type: 'paragraph',
+ paragraph: { rich_text: richText(text), color: 'default' },
+ };
+}
+
+describe('notion issues plugin', () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('lists only titled database pages by default', async () => {
+ notionSdk.search.mockResolvedValueOnce({
+ object: 'list',
+ results: [
+ notionPage('task-page', 'Implement onboarding', 'database_id'),
+ notionPage('plain-page', 'Team notes', 'page_id'),
+ notionPage('untitled-page', '', 'database_id'),
+ ],
+ next_cursor: null,
+ has_more: false,
+ type: 'page_or_database',
+ page_or_database: {},
+ });
+
+ const result = await issues.listIssues(host, { limit: 50 });
+
+ expect(notionSdk.search).toHaveBeenCalledWith({
+ filter: { property: 'object', value: 'page' },
+ sort: { timestamp: 'last_edited_time', direction: 'descending' },
+ page_size: 100,
+ });
+ expect(result).toEqual({
+ success: true,
+ data: [expect.objectContaining({ identifier: 'task-page', title: 'Implement onboarding' })],
+ });
+ });
+
+ it('continues listing until enough database pages survive filtering', async () => {
+ notionSdk.search
+ .mockResolvedValueOnce({
+ object: 'list',
+ results: [
+ notionPage('plain-page', 'Team notes', 'page_id'),
+ notionPage('untitled-page', ''),
+ ],
+ next_cursor: 'next-page',
+ has_more: true,
+ type: 'page_or_database',
+ page_or_database: {},
+ })
+ .mockResolvedValueOnce({
+ object: 'list',
+ results: [notionPage('task-page', 'Implement onboarding', 'database_id')],
+ next_cursor: null,
+ has_more: false,
+ type: 'page_or_database',
+ page_or_database: {},
+ });
+
+ const result = await issues.listIssues(host, { limit: 1 });
+
+ expect(notionSdk.search).toHaveBeenNthCalledWith(2, {
+ filter: { property: 'object', value: 'page' },
+ sort: { timestamp: 'last_edited_time', direction: 'descending' },
+ page_size: 100,
+ start_cursor: 'next-page',
+ });
+ expect(result).toEqual({
+ success: true,
+ data: [expect.objectContaining({ identifier: 'task-page', title: 'Implement onboarding' })],
+ });
+ });
+
+ it('keeps explicit search broad but filters untitled pages', async () => {
+ notionSdk.search.mockResolvedValueOnce({
+ object: 'list',
+ results: [notionPage('plain-page', 'Team notes', 'page_id'), notionPage('untitled-page', '')],
+ next_cursor: null,
+ has_more: false,
+ type: 'page_or_database',
+ page_or_database: {},
+ });
+
+ const result = await issues.searchIssues(host, { searchTerm: 'team', limit: 20 });
+
+ expect(notionSdk.search).toHaveBeenCalledWith({
+ query: 'team',
+ filter: { property: 'object', value: 'page' },
+ sort: { timestamp: 'last_edited_time', direction: 'descending' },
+ page_size: 20,
+ });
+ expect(result).toEqual({
+ success: true,
+ data: [expect.objectContaining({ identifier: 'plain-page', title: 'Team notes' })],
+ });
+ });
+
+ it('reads every block page when getting issue context', async () => {
+ notionSdk.pagesRetrieve.mockResolvedValueOnce(notionPage('task-page', 'Implement onboarding'));
+ notionSdk.blocksChildrenList
+ .mockResolvedValueOnce({
+ object: 'list',
+ results: [paragraphBlock('block-1', 'First requirement')],
+ next_cursor: 'next-blocks',
+ has_more: true,
+ type: 'block',
+ block: {},
+ })
+ .mockResolvedValueOnce({
+ object: 'list',
+ results: [paragraphBlock('block-2', 'Later requirement')],
+ next_cursor: null,
+ has_more: false,
+ type: 'block',
+ block: {},
+ });
+
+ const result = await getIssue(host, { identifier: 'task-page' });
+
+ expect(notionSdk.blocksChildrenList).toHaveBeenNthCalledWith(1, {
+ block_id: 'task-page',
+ page_size: 100,
+ });
+ expect(notionSdk.blocksChildrenList).toHaveBeenNthCalledWith(2, {
+ block_id: 'task-page',
+ page_size: 100,
+ start_cursor: 'next-blocks',
+ });
+ expect(result).toEqual({
+ success: true,
+ data: expect.objectContaining({
+ identifier: 'task-page',
+ context: 'First requirement\nLater requirement',
+ }),
+ });
+ });
+
+ it('skips partial blocks while formatting issue context', async () => {
+ notionSdk.pagesRetrieve.mockResolvedValueOnce(notionPage('task-page', 'Implement onboarding'));
+ notionSdk.blocksChildrenList.mockResolvedValueOnce({
+ object: 'list',
+ results: [
+ { object: 'block', id: 'partial-paragraph', type: 'paragraph' },
+ paragraphBlock('block-1', 'Accessible requirement'),
+ ],
+ next_cursor: null,
+ has_more: false,
+ type: 'block',
+ block: {},
+ });
+
+ const result = await getIssue(host, { identifier: 'task-page' });
+
+ expect(result).toEqual({
+ success: true,
+ data: expect.objectContaining({ context: 'Accessible requirement' }),
+ });
+ });
+});
diff --git a/packages/plugins/src/issues/impl/notion/index.ts b/packages/plugins/src/issues/impl/notion/index.ts
new file mode 100644
index 0000000000..bd1eff5d43
--- /dev/null
+++ b/packages/plugins/src/issues/impl/notion/index.ts
@@ -0,0 +1,129 @@
+import { err, ok } from '@emdash/shared';
+import {
+ isFullPage,
+ type BlockObjectResponse,
+ type PageObjectResponse,
+ type PartialBlockObjectResponse,
+} from '@notionhq/client';
+import type { ConnectedIntegrationHostContext } from '../../../integrations/host';
+import {
+ createNotionClient,
+ readNotionCredentials,
+} from '../../../integrations/impl/notion/client';
+import { toNotionIntegrationError } from '../../../integrations/impl/notion/error';
+import { clampIssueLimit, normalizeSearchTerm } from '../../helpers/provider-inputs';
+import { defineIssuesPlugin, registerIssuesPluginBehavior } from '../../plugin';
+import type {
+ IssueGetOpts,
+ IssueGetResult,
+ IssueListResult,
+ IssueQueryOpts,
+ IssueSearchOpts,
+} from '../../types';
+import { formatNotionContext } from './context';
+import { hasMeaningfulTitle, isDatabasePage, toIssueData, toIssueListItems } from './mapper';
+
+const NOTION_PAGE_SIZE = 100;
+
+export async function listIssues(
+ host: ConnectedIntegrationHostContext,
+ opts: IssueQueryOpts
+): Promise {
+ const parsedCredentials = readNotionCredentials(host.credentials);
+ if (!parsedCredentials.success) return err(parsedCredentials.error);
+
+ const client = createNotionClient(parsedCredentials.data);
+ const limit = clampIssueLimit(opts.limit, 50, 100);
+
+ try {
+ const pages: PageObjectResponse[] = [];
+ let startCursor: string | null | undefined;
+
+ do {
+ const response = await client.search({
+ filter: { property: 'object', value: 'page' },
+ sort: { timestamp: 'last_edited_time', direction: 'descending' },
+ page_size: NOTION_PAGE_SIZE,
+ ...(startCursor ? { start_cursor: startCursor } : {}),
+ });
+ pages.push(...response.results.filter(isFullPage).filter(isDatabasePage));
+ startCursor = response.has_more ? response.next_cursor : null;
+ } while (startCursor && pages.filter(hasMeaningfulTitle).length < limit);
+
+ return ok(toIssueListItems(pages).slice(0, limit));
+ } catch (error) {
+ host.log.warn('Notion listIssues failed', { error });
+ return err(toNotionIntegrationError(error, 'Unable to fetch Notion pages.'));
+ }
+}
+
+export async function searchIssues(
+ host: ConnectedIntegrationHostContext,
+ opts: IssueSearchOpts
+): Promise {
+ const term = normalizeSearchTerm(opts.searchTerm);
+ if (!term) return ok([]);
+
+ const parsedCredentials = readNotionCredentials(host.credentials);
+ if (!parsedCredentials.success) return err(parsedCredentials.error);
+
+ const client = createNotionClient(parsedCredentials.data);
+ const limit = clampIssueLimit(opts.limit, 20, 100);
+
+ try {
+ const response = await client.search({
+ query: term,
+ filter: { property: 'object', value: 'page' },
+ sort: { timestamp: 'last_edited_time', direction: 'descending' },
+ page_size: limit,
+ });
+ return ok(toIssueListItems(response.results.filter(isFullPage)));
+ } catch (error) {
+ host.log.warn('Notion searchIssues failed', { error });
+ return err(toNotionIntegrationError(error, 'Unable to search Notion pages.'));
+ }
+}
+
+export async function getIssue(
+ host: ConnectedIntegrationHostContext,
+ opts: IssueGetOpts
+): Promise {
+ const parsedCredentials = readNotionCredentials(host.credentials);
+ if (!parsedCredentials.success) return err(parsedCredentials.error);
+
+ const client = createNotionClient(parsedCredentials.data);
+
+ try {
+ const page = await client.pages.retrieve({ page_id: opts.identifier });
+ if (!isFullPage(page)) {
+ return err({
+ type: 'not_found_or_no_access',
+ message: 'Notion page was not found or the integration does not have access.',
+ });
+ }
+
+ const blocks: Array = [];
+ let startCursor: string | null | undefined;
+
+ do {
+ const response = await client.blocks.children.list({
+ block_id: page.id,
+ page_size: NOTION_PAGE_SIZE,
+ ...(startCursor ? { start_cursor: startCursor } : {}),
+ });
+ blocks.push(...response.results);
+ startCursor = response.has_more ? response.next_cursor : null;
+ } while (startCursor);
+
+ return ok({ ...toIssueData(page), context: formatNotionContext(blocks) });
+ } catch (error) {
+ host.log.warn('Notion getIssue failed', { error });
+ return err(toNotionIntegrationError(error, 'Unable to fetch Notion page context.'));
+ }
+}
+
+const plugin = defineIssuesPlugin({ integrationId: 'notion' }, { issues: {} }, {});
+
+export const provider = registerIssuesPluginBehavior(plugin, {
+ issues: { listIssues, searchIssues, getIssue },
+});
diff --git a/packages/plugins/src/issues/impl/notion/mapper.ts b/packages/plugins/src/issues/impl/notion/mapper.ts
new file mode 100644
index 0000000000..6ff54fbe92
--- /dev/null
+++ b/packages/plugins/src/issues/impl/notion/mapper.ts
@@ -0,0 +1,101 @@
+import type { PageObjectResponse, RichTextItemResponse } from '@notionhq/client';
+import type { IssueData } from '../../types';
+
+type NotionPageProperty = PageObjectResponse['properties'][string];
+const UNTITLED_NOTION_PAGE = 'Untitled Notion page';
+
+export function toIssueData(page: PageObjectResponse): IssueData {
+ return {
+ identifier: page.id,
+ displayIdentifier: null,
+ title: pageTitle(page),
+ url: page.url,
+ description: firstRichTextProperty(page),
+ status: firstStatusProperty(page),
+ assignees: peopleProperty(page),
+ project: pageParentLabel(page),
+ updatedAt: page.last_edited_time,
+ };
+}
+
+export function hasMeaningfulTitle(page: PageObjectResponse): boolean {
+ return pageTitle(page) !== UNTITLED_NOTION_PAGE;
+}
+
+export function isDatabasePage(page: PageObjectResponse): boolean {
+ return page.parent.type === 'database_id' || page.parent.type === 'data_source_id';
+}
+
+function pageTitle(page: PageObjectResponse): string {
+ const title = Object.values(page.properties).find((property) => property.type === 'title');
+ if (!title || title.type !== 'title') return UNTITLED_NOTION_PAGE;
+ return richTextPlainText(title.title) || UNTITLED_NOTION_PAGE;
+}
+
+function firstRichTextProperty(page: PageObjectResponse): string | undefined {
+ for (const property of Object.values(page.properties)) {
+ if (property.type !== 'rich_text') continue;
+ const value = richTextPlainText(property.rich_text);
+ if (value) return value;
+ }
+ return undefined;
+}
+
+function firstStatusProperty(page: PageObjectResponse): string | undefined {
+ const preferredNames = ['status', 'state', 'stage'];
+ for (const name of preferredNames) {
+ const property = findProperty(page.properties, name);
+ const value = statusPropertyLabel(property);
+ if (value) return value;
+ }
+
+ for (const property of Object.values(page.properties)) {
+ const value = statusPropertyLabel(property);
+ if (value) return value;
+ }
+ return undefined;
+}
+
+function statusPropertyLabel(property: NotionPageProperty | undefined): string | undefined {
+ if (!property) return undefined;
+ if (property.type === 'status') return property.status?.name;
+ if (property.type === 'select') return property.select?.name;
+ return undefined;
+}
+
+function peopleProperty(page: PageObjectResponse): string[] | undefined {
+ const people = Object.values(page.properties)
+ .filter((property) => property.type === 'people')
+ .flatMap((property) => (property.type === 'people' ? property.people : []))
+ .map((person) => ('name' in person ? person.name : undefined))
+ .filter((name): name is string => !!name);
+
+ return people.length ? people : undefined;
+}
+
+function pageParentLabel(page: PageObjectResponse): string | undefined {
+ if (page.parent.type === 'database_id') return 'Database';
+ if (page.parent.type === 'data_source_id') return 'Data source';
+ if (page.parent.type === 'page_id') return 'Page';
+ if (page.parent.type === 'workspace') return 'Workspace';
+ return undefined;
+}
+
+function findProperty(
+ properties: PageObjectResponse['properties'],
+ name: string
+): NotionPageProperty | undefined {
+ const lowerName = name.toLowerCase();
+ return Object.entries(properties).find(([key]) => key.toLowerCase() === lowerName)?.[1];
+}
+
+export function richTextPlainText(richText: RichTextItemResponse[] | undefined): string {
+ return (richText ?? [])
+ .map((item) => item.plain_text)
+ .join('')
+ .trim();
+}
+
+export function toIssueListItems(pages: PageObjectResponse[]) {
+ return pages.filter(hasMeaningfulTitle).map(toIssueData);
+}
diff --git a/packages/plugins/src/issues/registry.ts b/packages/plugins/src/issues/registry.ts
index 33958c24f2..f35b29194f 100644
--- a/packages/plugins/src/issues/registry.ts
+++ b/packages/plugins/src/issues/registry.ts
@@ -6,6 +6,7 @@ import { provider as gitlab } from './impl/gitlab';
import { provider as jira } from './impl/jira';
import { provider as linear } from './impl/linear';
import { provider as monday } from './impl/monday';
+import { provider as notion } from './impl/notion';
import { provider as plain } from './impl/plain';
import { provider as plane } from './impl/plane';
import { provider as trello } from './impl/trello';
@@ -36,6 +37,7 @@ for (const provider of [
trello,
asana,
monday,
+ notion,
featurebase,
plain,
]) {
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index a8a37b69e3..74b30bc5d4 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -561,6 +561,9 @@ importers:
'@mondaydotcomorg/api':
specifier: ^14.0.0
version: 14.0.0
+ '@notionhq/client':
+ specifier: ^5.22.0
+ version: 5.22.0
'@octokit/rest':
specifier: ^22.0.1
version: 22.0.1
@@ -2047,6 +2050,10 @@ packages:
resolution: {integrity: sha512-IYqDGiTXab6FniAgnSdZwgWbomxpy9FtYvLKs7wCUs2a8RkITG+DFGO1DM9cr+E3/RgADRpFjrKVaJ1z6sjtEg==}
engines: {node: '>= 20.19.0'}
+ '@notionhq/client@5.22.0':
+ resolution: {integrity: sha512-lZ3JGBCd6O6MNHWn/58QcUqX1FgmlcODcx/EaUEEpuxLXF5tSi+v29Vzoz8mZ6JgDWDn5pMzzjB69QevYjQQZA==}
+ engines: {node: '>=18'}
+
'@nx/nx-darwin-arm64@23.0.0':
resolution: {integrity: sha512-c/rXP3LYXJLC1F+9KDrWE+n1nkDnTEfHnA1KAK3A/CSk8EfgY0RhekcdbGISrHqgbccdVTBRTNUeTdwD+w23Xw==}
cpu: [arm64]
@@ -10436,6 +10443,8 @@ snapshots:
'@noble/hashes@2.2.0': {}
+ '@notionhq/client@5.22.0': {}
+
'@nx/nx-darwin-arm64@23.0.0':
optional: true