Repository: raycast/extensions
Pull request #19102: Update dovetail extension
Author: login: patbarlow
Labels: name: extension fix / improvement
description: Label for PRs with extension's fix improvements
name: OP is author
description: The OP of the PR is the author of the extension
name: extension: dovetail
description: Issues related to the dovetail extension
Created: 2025-05-12T01:30:40Z | Merged: 2025-05-12T05:28:12Z

## Description

- Switch to new `/v1/data`, `/v1/insights`, and `/v1/contacts` endpoints
- Add new contact search and display experience
- Show detail view for data items with clean markdown and metadata
- Add action to open data items directly in Dovetail
- Remove deprecated notes endpoints

## Screencast

![Raycast-1](https://github.com/user-attachments/assets/4bb5b78f-9e5e-4f28-a3a8-601b84b2a488)
![Raycast-2](https://github.com/user-attachments/assets/e4398a45-ccdb-474f-8539-6efabbac95ce)

## Checklist

- [x] I read the [extension guidelines](https://developers.raycast.com/basics/prepare-an-extension-for-store)
- [x] I read the [documentation about publishing](https://developers.raycast.com/basics/publish-an-extension)
- [x] I ran `npm run build` and [tested this distribution build in Raycast](https://developers.raycast.com/basics/prepare-an-extension-for-store#metadata-and-configuration)
- [x] I checked that files in the `assets` folder are used by the extension itself
- [x] I checked that assets used in the `README` are located outside the metadata folder if they were not generated with our [metadata tool](https://developers.raycast.com/basics/prepare-an-extension-for-store#how-to-use-it)



Diff:
diff --git a/extensions/dovetail/CHANGELOG.md b/extensions/dovetail/CHANGELOG.md
index 51b96eddac1..ce5ca7fb76f 100644
--- a/extensions/dovetail/CHANGELOG.md
+++ b/extensions/dovetail/CHANGELOG.md
@@ -1,3 +1,13 @@
 # Dovetail Changelog
 
+## [Search contacts and view data] - 2025-05-12
+
+- Switch to new `/v1/data`, `/v1/insights`, and `/v1/contacts` endpoints for improved reliability
+- Add new contact search and display experience
+- Show detail view for data items with clean markdown and metadata
+- Add action to open data items directly in Dovetail
+- Remove deprecated notes endpoints
+
 ## [Initial Version] - 2025-01-10
+
+- Add data and insight search ability
diff --git a/extensions/dovetail/package-lock.json b/extensions/dovetail/package-lock.json
index d9e70a19779..1d8998018c8 100644
--- a/extensions/dovetail/package-lock.json
+++ b/extensions/dovetail/package-lock.json
@@ -9,7 +9,8 @@
       "dependencies": {
         "@raycast/api": "^1.88.4",
         "@raycast/utils": "^1.17.0",
-        "date-fns": "^4.1.0"
+        "date-fns": "^4.1.0",
+        "zod": "^3.24.3"
       },
       "devDependencies": {
         "@raycast/eslint-config": "^1.0.11",
@@ -3223,6 +3224,15 @@
       "funding": {
         "url": "https://github.com/sponsors/sindresorhus"
       }
+    },
+    "node_modules/zod": {
+      "version": "3.24.3",
+      "resolved": "https://registry.npmjs.org/zod/-/zod-3.24.3.tgz",
+      "integrity": "sha512-HhY1oqzWCQWuUqvBFnsyrtZRhyPeR7SUGv+C4+MsisMuVfSPx8HpwWqH8tRahSlt6M3PiFAcoeFhZAqIXTxoSg==",
+      "license": "MIT",
+      "funding": {
+        "url": "https://github.com/sponsors/colinhacks"
+      }
     }
   }
 }
diff --git a/extensions/dovetail/package.json b/extensions/dovetail/package.json
index 7416f50bb68..6e8a2b62460 100644
--- a/extensions/dovetail/package.json
+++ b/extensions/dovetail/package.json
@@ -22,6 +22,12 @@
       "title": "Search Insights",
       "description": "Search for insights across your workspace.",
       "mode": "view"
+    },
+    {
+      "name": "search-contacts",
+      "title": "Search Contacts",
+      "description": "Search for contacts in your workspace.",
+      "mode": "view"
     }
   ],
   "preferences": [
@@ -38,7 +44,8 @@
   "dependencies": {
     "@raycast/api": "^1.88.4",
     "@raycast/utils": "^1.17.0",
-    "date-fns": "^4.1.0"
+    "date-fns": "^4.1.0",
+    "zod": "^3.24.3"
   },
   "devDependencies": {
     "@raycast/eslint-config": "^1.0.11",
diff --git a/extensions/dovetail/src/api/client.ts b/extensions/dovetail/src/api/client.ts
new file mode 100644
index 00000000000..e162caa3757
--- /dev/null
+++ b/extensions/dovetail/src/api/client.ts
@@ -0,0 +1,112 @@
+import { endpoints } from "./endpoints";
+import { Insight, ApiResponse, ApiEndpoint, DataExport, Contact, Data } from "../types/dovetail";
+import { z } from "zod";
+
+async function fetchDovetail<T>(
+  endpoint: keyof typeof endpoints,
+  body?: Record<string, unknown>,
+  token?: string,
+): Promise<ApiResponse<T>> {
+  const { path, method, schema } = endpoints[endpoint];
+  const url = `https://dovetail.com/api${path}`;
+  const headers = {
+    "Content-Type": "application/json",
+    ...(token ? { Authorization: `Bearer ${token}` } : {}),
+  };
+  const res = await fetch(url, {
+    method,
+    headers,
+    ...(body ? { body: JSON.stringify(body) } : {}),
+  });
+  const json = await res.json();
+  const parsed = (schema as z.ZodType).safeParse(json);
+  if (!parsed.success) {
+    throw new Error("Invalid API response");
+  }
+  return parsed.data as ApiResponse<T>;
+}
+
+async function fetchDovetailDynamic<T>(
+  endpoint: ApiEndpoint,
+  body?: Record<string, unknown>,
+  token?: string,
+): Promise<ApiResponse<T>> {
+  const { path, method, schema } = endpoint;
+  const url = `https://dovetail.com/api${path}`;
+  const headers = {
+    "Content-Type": "application/json",
+    ...(token ? { Authorization: `Bearer ${token}` } : {}),
+  };
+  const res = await fetch(url, {
+    method,
+    headers,
+    ...(body ? { body: JSON.stringify(body) } : {}),
+  });
+  const json = await res.json();
+  const parsed = (schema as z.ZodType).safeParse(json);
+  if (!parsed.success) {
+    throw new Error("Invalid API response");
+  }
+  return parsed.data as ApiResponse<T>;
+}
+
+export async function getInsights(query: string, token?: string) {
+  const result = await fetchDovetail<Insight[]>("insights", undefined, token);
+  let insights = result.data;
+  if (query) {
+    const q = query.toLowerCase();
+    insights = insights.filter((i) => i.title.toLowerCase().includes(q));
+  }
+  return { data: insights };
+}
+
+export async function getContacts(query: string, token?: string) {
+  const result = await fetchDovetail<Contact[]>("contacts", undefined, token);
+  let contacts = result.data;
+  if (query) {
+    const q = query.toLowerCase();
+    contacts = contacts.filter((c) => c.name && c.name.toLowerCase().includes(q));
+  }
+  return { data: contacts };
+}
+
+export async function getContactDetails(contactId: string, token?: string) {
+  const endpoint = endpoints.contactDetails;
+  const result = await fetchDovetailDynamic<Contact>(
+    { path: endpoint.path(contactId), method: endpoint.method, schema: endpoint.schema },
+    undefined,
+    token,
+  );
+  return result.data;
+}
+
+export async function getData(query: string, token?: string) {
+  const result = await fetchDovetail<Data[]>("data", undefined, token);
+  let data = result.data;
+  if (query) {
+    const q = query.toLowerCase();
+    data = data.filter((i) => i.title.toLowerCase().includes(q));
+  }
+  return { data };
+}
+
+export async function getDataExportMarkdown(dataId: string, token?: string): Promise<DataExport> {
+  const res = await fetch(`https://dovetail.com/api/v1/data/${dataId}/export/markdown`, {
+    headers: {
+      Authorization: `Bearer ${token}`,
+    },
+  });
+  if (!res.ok) throw new Error("Failed to fetch data export");
+  const json = await res.json();
+  return json.data;
+}
+
+export async function getDataExportHtml(dataId: string, token?: string): Promise<string> {
+  const res = await fetch(`https://dovetail.com/api/v1/data/${dataId}/export/html`, {
+    headers: {
+      Authorization: `Bearer ${token}`,
+    },
+  });
+  if (!res.ok) throw new Error("Failed to fetch data export");
+  return await res.text();
+}
diff --git a/extensions/dovetail/src/api/endpoints.ts b/extensions/dovetail/src/api/endpoints.ts
new file mode 100644
index 00000000000..b6b434caa41
--- /dev/null
+++ b/extensions/dovetail/src/api/endpoints.ts
@@ -0,0 +1,76 @@
+import { z } from "zod";
+
+export const tokenInfo = {
+  path: "/v1/token-info",
+  method: "GET",
+  schema: z.object({
+    id: z.string(),
+    scopes: z.array(z.string()),
+    expires_at: z.string().datetime(),
+  }),
+};
+
+export const insights = {
+  path: "/v1/insights",
+  method: "GET",
+  schema: z.object({
+    data: z.array(
+      z.object({
+        id: z.string(),
+        title: z.string(),
+        type: z.string(),
+        created_at: z.string(),
+        published: z.boolean(),
+      }),
+    ),
+  }),
+};
+
+export const contacts = {
+  path: "/v1/contacts",
+  method: "GET",
+  schema: z.object({
+    data: z.array(
+      z.object({
+        id: z.string(),
+        name: z.string().nullable(),
+        created_at: z.string().optional(),
+        deleted: z.boolean().optional(),
+      }),
+    ),
+  }),
+};
+
+export const contactDetails = {
+  path: (id: string) => `/v1/contacts/${id}`,
+  method: "GET",
+  schema: z.object({
+    data: z.object({
+      id: z.string(),
+      name: z.string().nullable(),
+      email: z.string().nullable().optional(),
+      created_at: z.string().optional(),
+      deleted: z.boolean().optional(),
+      fields: z.array(z.record(z.any())).optional(),
+    }),
+  }),
+};
+
+export const data = {
+  path: "/v1/data",
+  method: "GET",
+  schema: z.object({
+    data: z.array(
+      z.object({
+        id: z.string(),
+        type: z.string(),
+        title: z.string(),
+        created_at: z.string(),
+        deleted: z.boolean(),
+      }),
+    ),
+  }),
+};
+
+export const endpoints = { tokenInfo, insights, contacts, contactDetails, data };
+export type EndpointKey = keyof typeof endpoints;
diff --git a/extensions/dovetail/src/hooks/useAuth.ts b/extensions/dovetail/src/hooks/useAuth.ts
new file mode 100644
index 00000000000..976ebe57ed3
--- /dev/null
+++ b/extensions/dovetail/src/hooks/useAuth.ts
@@ -0,0 +1,7 @@
+import { getPreferenceValues } from "@raycast/api";
+
+export function useAuth() {
+  const preferences = getPreferenceValues<{ dovetailApiToken: string }>();
+  const token = preferences.dovetailApiToken;
+  return { token };
+}
diff --git a/extensions/dovetail/src/hooks/useSearch.ts b/extensions/dovetail/src/hooks/useSearch.ts
new file mode 100644
index 00000000000..f31d7013103
--- /dev/null
+++ b/extensions/dovetail/src/hooks/useSearch.ts
@@ -0,0 +1,44 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import { useAuth } from "./useAuth";
+
+type FetchFunction<T> = (query: string, token?: string) => Promise<{ data: T[] }>;
+
+export function useSearch<T>(fetchFunction: FetchFunction<T>) {
+  const { token } = useAuth();
+  const [query, setQuery] = useState("");
+  const [data, setData] = useState<T[]>([]);
+  const [isLoading, setIsLoading] = useState(false);
+  const cancelRef = useRef<AbortController | null>(null);
+
+  const fetchData = useCallback(
+    async (searchQuery: string) => {
+      setIsLoading(true);
+      try {
+        const result = await fetchFunction(searchQuery, token ?? undefined);
+        setData(result.data);
+      } catch (error) {
+        console.error("Search error:", error);
+        setData([]);
+      } finally {
+        setIsLoading(false);
+      }
+    },
+    [token, fetchFunction],
+  );
+
+  useEffect(() => {
+    cancelRef.current?.abort();
+    cancelRef.current = new AbortController();
+    fetchData(query);
+    return () => cancelRef.current?.abort();
+  }, [query, fetchData]);
+
+  const onSearchTextChange = useCallback((text: string) => {
+    setQuery(text);
+    setData([]);
+  }, []);
+
+  const numberOfResults = data.length === 1 ? "1 result" : `${data.length} results`;
+
+  return { data, isLoading, onSearchTextChange, numberOfResults };
+}
diff --git a/extensions/dovetail/src/search-contacts.tsx b/extensions/dovetail/src/search-contacts.tsx
new file mode 100644
index 00000000000..edcf69db4d3
--- /dev/null
+++ b/extensions/dovetail/src/search-contacts.tsx
@@ -0,0 +1,123 @@
+import React, { useState, useEffect } from "react";
+import { List } from "@raycast/api";
+import { Contact, Field } from "./types/dovetail";
+import { getContactDetails } from "./api/client";
+import { useAuth } from "./hooks/useAuth";
+import { format } from "date-fns";
+import { getAvatarIcon } from "@raycast/utils";
+import { useSearch } from "./hooks/useSearch";
+import { getContacts } from "./api/client";
+
+function useContactDetail(contactId: string | null) {
+  const { token } = useAuth();
+  const [contact, setContact] = useState<Contact | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    if (!contactId) return;
+    setLoading(true);
+    getContactDetails(contactId, token).then((data) => {
+      setContact(data as Contact);
+      setLoading(false);
+    });
+  }, [contactId, token]);
+
+  return { contact, loading };
+}
+
+function getFieldValue(fields: Field[], label: string): string | undefined {
+  const field = fields.find((f) => f.label?.toLowerCase() === label.toLowerCase());
+  if (!field) return undefined;
+  if (Array.isArray(field.value)) return field.value.join(", ");
+  return field.value ?? undefined;
+}
+
+const TAG_COLORS = ["#4F8FFF", "#FFD700", "#FF69B4", "#00C49A", "#FF7043", "#A259FF", "#FFB300", "#00B8D9"];
+
+export default function SearchContacts() {
+  const { data, isLoading, numberOfResults } = useSearch<Contact>(getContacts);
+  const [selectedId, setSelectedId] = useState<string | null>(null);
+  const [searchText, setSearchText] = useState("");
+  const [filteredContacts, setFilteredContacts] = useState<Contact[]>(data);
+  const { contact, loading } = useContactDetail(selectedId);
+
+  useEffect(() => {
+    setFilteredContacts(
+      data.filter((contact) => (contact.name || "").toLowerCase().includes(searchText.toLowerCase())),
+    );
+  }, [searchText, data]);
+
+  return (
+    <List
+      isLoading={isLoading}
+      isShowingDetail
+      onSearchTextChange={setSearchText}
+      filtering={false}
+      throttle
+      searchBarPlaceholder="Search contacts by name..."
+      selectedItemId={selectedId || undefined}
+      onSelectionChange={setSelectedId}
+    >
+      <List.Section title="Contacts" subtitle={numberOfResults}>
+        {filteredContacts.map((item) => (
+          <List.Item
+            key={item.id}
+            id={item.id}
+            title={item.name || "Untitled contact"}
+            icon={getAvatarIcon(item.name || "")}
+            detail={
+              item.id === selectedId ? (
+                <List.Item.Detail
+                  isLoading={loading}
+                  metadata={
+                    <List.Item.Detail.Metadata>
+                      <List.Item.Detail.Metadata.Label title="Name" text={contact?.name || "Untitled contact"} />
+                      <List.Item.Detail.Metadata.Label
+                        title="Email"
+                        text={Array.isArray(contact?.fields) ? getFieldValue(contact.fields, "Email") || "" : ""}
+                      />
+                      {contact?.created_at && (
+                        <List.Item.Detail.Metadata.Label
+                          title="Created"
+                          text={format(new Date(contact.created_at), "d MMMM yyyy")}
+                        />
+                      )}
+                      {Array.isArray(contact?.fields) && contact.fields.length > 0 && (
+                        <>
+                          <List.Item.Detail.Metadata.Separator />
+                          {contact.fields.map((field: Field, idx: number) => {
+                            if (!field.label || field.label.toLowerCase() === "email") return null;
+                            if (Array.isArray(field.value)) {
+                              return (
+                                <List.Item.Detail.Metadata.TagList key={idx} title={field.label}>
+                                  {field.value.map((tag: string, tagIdx: number) => (
+                                    <List.Item.Detail.Metadata.TagList.Item
+                                      key={tagIdx}
+                                      text={tag}
+                                      color={TAG_COLORS[idx % TAG_COLORS.length]}
+                                    />
+                                  ))}
+                                </List.Item.Detail.Metadata.TagList>
+                              );
+                            }
+                            return (
+                              <List.Item.Detail.Metadata.Label
+                                key={idx}
+                                title={field.label}
+                                text={field.value == null ? "" : String(field.value)}
+                              />
+                            );
+                          })}
+                        </>
+                      )}
+                    </List.Item.Detail.Metadata>
+                  }
+                />
+              ) : undefined
+            }
+          />
+        ))}
+      </List.Section>
+    </List>
+  );
+}
diff --git a/extensions/dovetail/src/search-data.tsx b/extensions/dovetail/src/search-data.tsx
index d77c33675bc..3d0efb0cb35 100644
--- a/extensions/dovetail/src/search-data.tsx
+++ b/extensions/dovetail/src/search-data.tsx
@@ -1,177 +1,75 @@
-import { useState, useEffect, useCallback, useRef } from "react";
-import {
-  List,
-  ActionPanel,
-  Action,
-  showToast,
-  Icon,
-  ToastStyle,
-  getPreferenceValues,
-  openExtensionPreferences,
-} from "@raycast/api";
-import fetch from "node-fetch";
+import { Icon, List, ActionPanel, Action, Detail, useNavigation } from "@raycast/api";
+import { Data } from "./types/dovetail";
+import { formatRelativeDate, formatFullDate, cleanMarkdown } from "./utils/formatting";
+import { useState, useEffect } from "react";
+import { useAuth } from "./hooks/useAuth";
+import { getDataExportMarkdown } from "./api/client";
+import { useSearch } from "./hooks/useSearch";
+import { getData } from "./api/client";
 import { format } from "date-fns";
 
-const DEFAULT_PAGE_SIZE = 100;
+function DataDetail({ dataId }: { dataId: string }) {
+  const { token } = useAuth();
+  const [markdown, setMarkdown] = useState<string>("");
+  const [isLoading, setIsLoading] = useState(true);
+  const dovetailUrl = `https://dovetail.com/data/${dataId}`;
 
-interface Note {
-  id: string;
-  title: string;
-  project_title: string;
-  created_at: string;
-}
-
-export default function SearchDovetail() {
-  // Retrieve preferences, including the Dovetail API token
-  const preferences = getPreferenceValues<{ dovetailApiToken: string }>();
-  const apiToken = preferences.dovetailApiToken;
-
-  const [query, setQuery] = useState("");
-  const [data, setData] = useState<Note[]>([]);
-  const [hasNextPage, setHasNextPage] = useState(false);
-  const [endCursor, setEndCursor] = useState<string | null>(null);
-  const cancelRef = useRef<AbortController | null>(null);
-
-  // Warn if we don't have an API token
   useEffect(() => {
-    if (!apiToken) {
-      showToast(
-        ToastStyle.Failure,
-        "API token missing",
-        "Please enter your Dovetail API token in the extension preferences.",
-      );
-      openExtensionPreferences();
-    }
-  }, [apiToken]);
-
-  function getShortDate(createdAt: Date) {
-    const now = new Date();
-    const diffMs = now.getTime() - createdAt.getTime();
-    const diffMinutes = Math.floor(diffMs / (1000 * 60));
-    if (diffMinutes < 60) {
-      return `${diffMinutes}m`;
-    }
-    const diffHours = Math.floor(diffMinutes / 60);
-    if (diffHours < 24) {
-      return `${diffHours}h`;
-    }
-    if (diffHours < 48) {
-      return "1d";
-    }
-    return format(createdAt, "MMM d");
-  }
-
-  function truncateString(str: string, maxLength = 50) {
-    if (str.length <= maxLength) {
-      return str;
-    }
-    return str.slice(0, maxLength - 1) + "…";
-  }
-
-  const searchDovetail = async (searchQuery: string, after?: string | null, signal?: AbortSignal) => {
-    // Don’t attempt to fetch if no token is provided
-    if (!apiToken) return;
-
-    try {
-      const response = await fetch("https://dovetail.com/api/v1/search", {
-        method: "POST",
-        headers: {
-          "Content-Type": "application/json",
-          Authorization: `Bearer ${apiToken}`,
-        },
-        body: JSON.stringify({
-          query: searchQuery,
-          limit: DEFAULT_PAGE_SIZE,
-          after,
-          filter: {
-            notes: [
-              {
-                title: {
-                  contains: searchQuery,
-                },
-              },
-            ],
-          },
-        }),
-        signal,
-      });
-
-      if (!response.ok) {
-        throw new Error(`Error fetching data: ${response.statusText}`);
+    async function fetchMarkdown() {
+      setIsLoading(true);
+      try {
+        const data = await getDataExportMarkdown(dataId, token);
+        const created = format(new Date(data.created_at), "dd MMM yyyy");
+        let content = cleanMarkdown(data.content_markdown || "");
+        content = content.replace(/^\[?AudioVideo.*\n?/im, "");
+        setMarkdown(`# ${data.title}\n\n**Created on ${created}**\n\n${content}`);
+      } catch (e) {
+        setMarkdown("Failed to load data export.");
       }
-
-      const responseData = await response.json();
-
-      setData((prev) => [...prev, ...responseData.data.notes]);
-
-      if (responseData.data.pageInfo) {
-        setHasNextPage(responseData.data.pageInfo.hasNextPage);
-        setEndCursor(responseData.data.pageInfo.endCursor);
-      } else {
-        setHasNextPage(false);
-        setEndCursor(null);
-      }
-    } catch (error: unknown) {
-      if ((error as Error).name !== "AbortError") {
-        showToast(ToastStyle.Failure, "Error fetching data", (error as Error).message);
-      }
-    }
-  };
-
-  const onLoadMore = useCallback(() => {
-    if (hasNextPage) {
-      searchDovetail(query, endCursor);
+      setIsLoading(false);
     }
-  }, [hasNextPage, endCursor, query]);
+    fetchMarkdown();
+  }, [dataId, token]);
 
-  const onSearchTextChange = useCallback((text: string) => {
-    setQuery(text);
-    setData([]);
-    setEndCursor(null);
-  }, []);
-
-  useEffect(() => {
-    cancelRef.current?.abort();
-    cancelRef.current = new AbortController();
-    searchDovetail(query, null, cancelRef.current.signal);
-    return () => cancelRef.current?.abort();
-  }, [query]);
+  return (
+    <Detail
+      isLoading={isLoading}
+      markdown={markdown}
+      navigationTitle="Data Details"
+      actions={
+        <ActionPanel>
+          <Action.OpenInBrowser url={dovetailUrl} title="Open in Dovetail" />
+        </ActionPanel>
+      }
+    />
+  );
+}
 
-  const numberOfResults = data.length === 1 ? "1 result" : `${data.length} results`;
+export default function SearchData() {
+  const { data, isLoading, onSearchTextChange, numberOfResults } = useSearch<Data>(getData);
+  const { push } = useNavigation();
 
   return (
     <List
-      isLoading={data === undefined}
+      isLoading={isLoading}
       onSearchTextChange={onSearchTextChange}
       throttle
       searchBarPlaceholder="Search for data in any project..."
-      pagination={{ onLoadMore, hasMore: hasNextPage, pageSize: DEFAULT_PAGE_SIZE }}
     >
       <List.Section title="Most relevant" subtitle={numberOfResults}>
-        {data.map((item) => {
-          const createdAt = new Date(item.created_at);
-          const accessories: List.Item.Accessory[] = [
-            {
-              text: getShortDate(createdAt),
-              tooltip: `Created: ${format(createdAt, "EEEE d MMMM yyyy 'at' HH:mm")}`,
-            },
-          ];
-
-          return (
-            <List.Item
-              key={item.id}
-              title={truncateString(item.title || "No Title", 60)}
-              icon={{ source: Icon.Document }}
-              subtitle={truncateString(item.project_title || "No project", 40)}
-              accessories={accessories}
-              actions={
-                <ActionPanel>
-                  <Action.OpenInBrowser title="Open in Dovetail" url={`https://dovetail.com/data/${item.id}`} />
-                </ActionPanel>
-              }
-            />
-          );
-        })}
+        {data.map((item) => (
+          <List.Item
+            key={item.id}
+            title={item.title || "Untitled"}
+            icon={Icon.Document}
+            accessories={[{ text: formatRelativeDate(item.created_at), tooltip: formatFullDate(item.created_at) }]}
+            actions={
+              <ActionPanel>
+                <Action title="Show Details" onAction={() => push(<DataDetail dataId={item.id} />)} />
+              </ActionPanel>
+            }
+          />
+        ))}
       </List.Section>
     </List>
   );
diff --git a/extensions/dovetail/src/search-insights.tsx b/extensions/dovetail/src/search-insights.tsx
index 85ee324fe3a..9e727f00097 100644
--- a/extensions/dovetail/src/search-insights.tsx
+++ b/extensions/dovetail/src/search-insights.tsx
@@ -1,177 +1,39 @@
-import { useState, useEffect, useCallback, useRef } from "react";
-import {
-  List,
-  ActionPanel,
-  Action,
-  showToast,
-  Icon,
-  ToastStyle,
-  getPreferenceValues,
-  openExtensionPreferences,
-} from "@raycast/api";
-import fetch from "node-fetch";
-import { format } from "date-fns";
-
-const DEFAULT_PAGE_SIZE = 100;
-
-interface Insight {
-  id: string;
-  title: string;
-  project_title: string;
-  created_at: string;
-}
+import { Action, ActionPanel, Icon, List } from "@raycast/api";
+import { Insight } from "./types/dovetail";
+import { formatRelativeDate, formatFullDate } from "./utils/formatting";
+import { useSearch } from "./hooks/useSearch";
+import { getInsights } from "./api/client";
 
 export default function SearchInsights() {
-  // Retrieve preferences, including the Dovetail API token
-  const preferences = getPreferenceValues<{ dovetailApiToken: string }>();
-  const apiToken = preferences.dovetailApiToken;
-
-  const [query, setQuery] = useState("");
-  const [data, setData] = useState<Insight[]>([]);
-  const [hasNextPage, setHasNextPage] = useState(false);
-  const [endCursor, setEndCursor] = useState<string | null>(null);
-  const cancelRef = useRef<AbortController | null>(null);
-
-  // Warn if we don't have an API token
-  useEffect(() => {
-    if (!apiToken) {
-      showToast(
-        ToastStyle.Failure,
-        "API token missing",
-        "Please enter your Dovetail API token in the extension preferences.",
-      );
-      openExtensionPreferences();
-    }
-  }, [apiToken]);
-
-  function getShortDate(createdAt: Date) {
-    const now = new Date();
-    const diffMs = now.getTime() - createdAt.getTime();
-    const diffMinutes = Math.floor(diffMs / (1000 * 60));
-    if (diffMinutes < 60) {
-      return `${diffMinutes}m`;
-    }
-    const diffHours = Math.floor(diffMinutes / 60);
-    if (diffHours < 24) {
-      return `${diffHours}h`;
-    }
-    if (diffHours < 48) {
-      return "1d";
-    }
-    return format(createdAt, "MMM d");
-  }
-
-  function truncateString(str: string, maxLength = 50) {
-    if (str.length <= maxLength) {
-      return str;
-    }
-    return str.slice(0, maxLength - 1) + "…";
-  }
-
-  const searchDovetail = async (searchQuery: string, after?: string | null, signal?: AbortSignal) => {
-    // Don’t attempt to fetch if no token is provided
-    if (!apiToken) return;
-
-    try {
-      const response = await fetch("https://dovetail.com/api/v1/search", {
-        method: "POST",
-        headers: {
-          "Content-Type": "application/json",
-          Authorization: `Bearer ${apiToken}`,
-        },
-        body: JSON.stringify({
-          query: searchQuery,
-          limit: DEFAULT_PAGE_SIZE,
-          after,
-          filter: {
-            insights: [
-              {
-                title: {
-                  contains: searchQuery,
-                },
-              },
-            ],
-          },
-        }),
-        signal,
-      });
-
-      if (!response.ok) {
-        throw new Error(`Error fetching data: ${response.statusText}`);
-      }
-
-      const responseData = await response.json();
-
-      setData((prev) => [...prev, ...responseData.data.insights]);
-
-      if (responseData.data.pageInfo) {
-        setHasNextPage(responseData.data.pageInfo.hasNextPage);
-        setEndCursor(responseData.data.pageInfo.endCursor);
-      } else {
-        setHasNextPage(false);
-        setEndCursor(null);
-      }
-    } catch (error: unknown) {
-      if ((error as Error).name !== "AbortError") {
-        showToast(ToastStyle.Failure, "Error fetching data", (error as Error).message);
-      }
-    }
-  };
-
-  const onLoadMore = useCallback(() => {
-    if (hasNextPage) {
-      searchDovetail(query, endCursor);
-    }
-  }, [hasNextPage, endCursor, query]);
-
-  const onSearchTextChange = useCallback((text: string) => {
-    setQuery(text);
-    setData([]);
-    setEndCursor(null);
-  }, []);
-
-  useEffect(() => {
-    cancelRef.current?.abort();
-    cancelRef.current = new AbortController();
-    searchDovetail(query, null, cancelRef.current.signal);
-    return () => cancelRef.current?.abort();
-  }, [query]);
-
-  const numberOfResults = data.length === 1 ? "1 result" : `${data.length} results`;
+  const { data, isLoading, onSearchTextChange, numberOfResults } = useSearch<Insight>(getInsights);
 
   return (
     <List
-      isLoading={data === undefined}
+      isLoading={isLoading}
       onSearchTextChange={onSearchTextChange}
       throttle
       searchBarPlaceholder="Search for insights in any project..."
-      pagination={{ onLoadMore, hasMore: hasNextPage, pageSize: DEFAULT_PAGE_SIZE }}
     >
       <List.Section title="Most relevant" subtitle={numberOfResults}>
-        {data.map((item) => {
-          const createdAt = new Date(item.created_at);
-          const accessories: List.Item.Accessory[] = [
-            {
-              text: getShortDate(createdAt),
-              tooltip: `Created: ${format(createdAt, "EEEE d MMMM yyyy 'at' HH:mm")}`,
-            },
-          ];
-
-          return (
-            <List.Item
-              key={item.id}
-              title={truncateString(item.title || "No Title", 60)}
-              icon={{ source: Icon.Document }}
-              subtitle={truncateString(item.project_title || "No project", 40)}
-              accessories={accessories}
-              actions={
-                <ActionPanel>
-                  <Action.OpenInBrowser title="Open in Dovetail" url={`https://dovetail.com/insights/${item.id}`} />
-                </ActionPanel>
-              }
-            />
-          );
-        })}
+        {data.map((item) => (
+          <List.Item
+            key={item.id}
+            title={item.title || "Untitled insight"}
+            subtitle={!item.published ? "Draft" : ""}
+            icon={Icon.Stars}
+            accessories={[
+              {
+                text: formatRelativeDate(item.created_at),
+                tooltip: formatFullDate(item.created_at),
+              },
+            ]}
+            actions={
+              <ActionPanel>
+                <Action.OpenInBrowser url={`https://dovetail.com/insights/${item.id}`} title="Open in Dovetail" />
+              </ActionPanel>
+            }
+          />
+        ))}
       </List.Section>
     </List>
   );
diff --git a/extensions/dovetail/src/types/dovetail.ts b/extensions/dovetail/src/types/dovetail.ts
new file mode 100644
index 00000000000..030ac493eda
--- /dev/null
+++ b/extensions/dovetail/src/types/dovetail.ts
@@ -0,0 +1,66 @@
+export interface Insight {
+  id: string;
+  title: string;
+  type: string;
+  created_at: string;
+  published: boolean;
+}
+
+export interface Field {
+  id: string;
+  name: string;
+  label?: string;
+  value: string | string[];
+}
+
+export interface Contact {
+  id: string;
+  name: string | null;
+  email?: string | null;
+  created_at?: string;
+  deleted?: boolean;
+  fields?: Field[];
+}
+
+export interface PageInfo {
+  hasNextPage: boolean;
+  endCursor: string | null;
+}
+
+export interface Data {
+  id: string;
+  type: string;
+  title: string;
+  created_at: string;
+  deleted: boolean;
+}
+
+export interface ApiResponse<T> {
+  data: T;
+  pageInfo?: PageInfo;
+}
+
+export interface DataExport {
+  id: string;
+  title: string;
+  created_at: string;
+  content_markdown?: string;
+}
+
+export interface Channel {
+  id: string;
+  name: string;
+}
+
+export interface Project {
+  id: string;
+  name: string;
+}
+
+export interface ApiEndpoint {
+  path: string;
+  method: string;
+  schema: {
+    _type: unknown;
+  };
+}
diff --git a/extensions/dovetail/src/utils/formatting.ts b/extensions/dovetail/src/utils/formatting.ts
new file mode 100644
index 00000000000..3ad3cff6f4e
--- /dev/null
+++ b/extensions/dovetail/src/utils/formatting.ts
@@ -0,0 +1,22 @@
+import { format, differenceInDays } from "date-fns";
+
+export function formatRelativeDate(dateString: string) {
+  const date = new Date(dateString);
+  const now = new Date();
+  const days = differenceInDays(now, date);
+  if (days < 1) return "1d";
+  if (days < 7) return `${days + 1}d`;
+  return format(date, "MMM d");
+}
+
+export function formatFullDate(dateString: string) {
+  const date = new Date(dateString);
+  return `Created: ${format(date, "EEEE d MMMM yyyy 'at' HH:mm")}`;
+}
+
+export function cleanMarkdown(md: string): string {
+  return md
+    .replace(/\n{3,}/g, "\n\n")
+    .replace(/^\s+|\s+$/g, "")
+    .replace(/\n\s+\n/g, "\n\n");
+}


Reviews:
user: login: greptile-apps[bot]
body: 
## PR Summary

This PR updates the Dovetail extension with new API endpoints and enhanced functionality for searching and displaying data, insights, and contacts.

- Added new `/v1/contacts` endpoint integration with detailed contact search and display experience in `search-contacts.tsx`
- Implemented Zod schema validation for API responses in `endpoints.ts` for improved type safety
- Added markdown detail view for data items with clean formatting and metadata display
- Introduced reusable `useSearch` hook in `hooks/useSearch.ts` for consistent search behavior across views
- Potential issue: `useContactDetail` hook lacks proper error handling for API failures

The changes modernize the extension while adding significant new functionality. The code is generally well-structured but could benefit from more robust error handling in certain areas.

<sub>💡 (2/5) Greptile learns from your feedback when you react with 👍/👎!</sub>

<sub>11 file(s) reviewed, 14 comment(s)</sub>
<sub>[Edit PR Review Bot Settings](https://app.greptile.com/review/github) | [Greptile](https://greptile.com?utm_source=greptile_expert&utm_medium=github&utm_campaign=code_reviews&utm_content=extensions_19102)</sub>
state: COMMENTED
author_association: CONTRIBUTOR
submitted_at: 2025-05-12T01:36:19Z
user: login: pernielsentikaer
body: Hi 👋 

Looks good to me, approved 🔥
state: APPROVED
author_association: COLLABORATOR
submitted_at: 2025-05-12T05:10:14Z

Review comments:
diff_hunk: @@ -22,6 +22,12 @@
       "title": "Search Insights",
       "description": "Search for insights across your workspace.",
       "mode": "view"
+    },
+    {
+      "name": "search-contacts",
+      "title": "Search Contacts",
+      "description": "Search for contacts in your workspace.",
+      "mode": "view"
     }
path: extensions/dovetail/package.json
user: login: greptile-apps[bot]
body: style: Consider adding subtitle to this command to match the service title (Dovetail)
created_at: 2025-05-12T01:32:30Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,76 @@
+import { z } from "zod";
+
+export const tokenInfo = {
+  path: "/v1/token-info",
+  method: "GET",
+  schema: z.object({
+    id: z.string(),
+    scopes: z.array(z.string()),
+    expires_at: z.string().datetime(),
+  }),
+};
+
+export const insights = {
+  path: "/v1/insights",
+  method: "GET",
+  schema: z.object({
+    data: z.array(
+      z.object({
+        id: z.string(),
+        title: z.string(),
+        type: z.string(),
+        created_at: z.string(),
path: extensions/dovetail/src/api/endpoints.ts
user: login: greptile-apps[bot]
body: style: created_at field should use .datetime() validation like tokenInfo endpoint for consistent date validation
created_at: 2025-05-12T01:32:52Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,112 @@
+import { endpoints } from "./endpoints";
+import { Insight, ApiResponse, ApiEndpoint, DataExport, Contact, Data } from "../types/dovetail";
+import { z } from "zod";
+
+async function fetchDovetail<T>(
+  endpoint: keyof typeof endpoints,
+  body?: Record<string, unknown>,
+  token?: string,
+): Promise<ApiResponse<T>> {
+  const { path, method, schema } = endpoints[endpoint];
+  const url = `https://dovetail.com/api${path}`;
+  const headers = {
+    "Content-Type": "application/json",
+    ...(token ? { Authorization: `Bearer ${token}` } : {}),
+  };
+  const res = await fetch(url, {
+    method,
+    headers,
+    ...(body ? { body: JSON.stringify(body) } : {}),
+  });
+  const json = await res.json();
+  const parsed = (schema as z.ZodType).safeParse(json);
+  if (!parsed.success) {
+    throw new Error("Invalid API response");
+  }
+  return parsed.data as ApiResponse<T>;
+}
+
+async function fetchDovetailDynamic<T>(
+  endpoint: ApiEndpoint,
+  body?: Record<string, unknown>,
+  token?: string,
+): Promise<ApiResponse<T>> {
+  const { path, method, schema } = endpoint;
+  const url = `https://dovetail.com/api${path}`;
+  const headers = {
+    "Content-Type": "application/json",
+    ...(token ? { Authorization: `Bearer ${token}` } : {}),
+  };
+  const res = await fetch(url, {
+    method,
+    headers,
+    ...(body ? { body: JSON.stringify(body) } : {}),
+  });
+  const json = await res.json();
+  const parsed = (schema as z.ZodType).safeParse(json);
+  if (!parsed.success) {
+    throw new Error("Invalid API response");
+  }
+  return parsed.data as ApiResponse<T>;
+}
+
+export async function getInsights(query: string, token?: string) {
+  const result = await fetchDovetail<Insight[]>("insights", undefined, token);
+  let insights = result.data;
+  if (query) {
+    const q = query.toLowerCase();
+    insights = insights.filter((i) => i.title.toLowerCase().includes(q));
+  }
+  return { data: insights };
+}
+
+export async function getContacts(query: string, token?: string) {
+  const result = await fetchDovetail<Contact[]>("contacts", undefined, token);
+  let contacts = result.data;
+  if (query) {
+    const q = query.toLowerCase();
+    contacts = contacts.filter((c) => c.name && c.name.toLowerCase().includes(q));
+  }
+  return { data: contacts };
+}
+
+export async function getContactDetails(contactId: string, token?: string) {
+  const endpoint = endpoints.contactDetails;
+  const result = await fetchDovetailDynamic<Contact>(
+    { path: endpoint.path(contactId), method: endpoint.method, schema: endpoint.schema },
+    undefined,
+    token,
+  );
+  return result.data;
+}
+
+export async function getData(query: string, token?: string) {
+  const result = await fetchDovetail<Data[]>("data", undefined, token);
+  let data = result.data;
+  if (query) {
+    const q = query.toLowerCase();
+    data = data.filter((i) => i.title.toLowerCase().includes(q));
+  }
+  return { data };
+}
+
+export async function getDataExportMarkdown(dataId: string, token?: string): Promise<DataExport> {
+  const res = await fetch(`https://dovetail.com/api/v1/data/${dataId}/export/markdown`, {
+    headers: {
+      Authorization: `Bearer ${token}`,
+    },
+  });
+  if (!res.ok) throw new Error("Failed to fetch data export");
+  const json = await res.json();
+  return json.data;
+}
path: extensions/dovetail/src/api/client.ts
user: login: greptile-apps[bot]
body: style: getDataExportMarkdown doesn't use the shared fetchDovetail function and lacks Zod schema validation, which is inconsistent with other endpoints. Consider using the same pattern as other functions for consistency and type safety.
created_at: 2025-05-12T01:33:01Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,112 @@
+import { endpoints } from "./endpoints";
+import { Insight, ApiResponse, ApiEndpoint, DataExport, Contact, Data } from "../types/dovetail";
+import { z } from "zod";
+
+async function fetchDovetail<T>(
+  endpoint: keyof typeof endpoints,
+  body?: Record<string, unknown>,
+  token?: string,
+): Promise<ApiResponse<T>> {
+  const { path, method, schema } = endpoints[endpoint];
+  const url = `https://dovetail.com/api${path}`;
+  const headers = {
+    "Content-Type": "application/json",
+    ...(token ? { Authorization: `Bearer ${token}` } : {}),
+  };
+  const res = await fetch(url, {
+    method,
+    headers,
+    ...(body ? { body: JSON.stringify(body) } : {}),
+  });
+  const json = await res.json();
path: extensions/dovetail/src/api/client.ts
user: login: greptile-apps[bot]
body: logic: No error handling for non-200 responses before parsing JSON. Add res.ok check like in getDataExportMarkdown

```suggestion
  const res = await fetch(url, {
    method,
    headers,
    ...(body ? { body: JSON.stringify(body) } : {}),
  });
  if (!res.ok) throw new Error("Failed to fetch data");
  const json = await res.json();
```
created_at: 2025-05-12T01:33:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,112 @@
+import { endpoints } from "./endpoints";
+import { Insight, ApiResponse, ApiEndpoint, DataExport, Contact, Data } from "../types/dovetail";
+import { z } from "zod";
+
+async function fetchDovetail<T>(
+  endpoint: keyof typeof endpoints,
+  body?: Record<string, unknown>,
+  token?: string,
+): Promise<ApiResponse<T>> {
+  const { path, method, schema } = endpoints[endpoint];
+  const url = `https://dovetail.com/api${path}`;
+  const headers = {
+    "Content-Type": "application/json",
+    ...(token ? { Authorization: `Bearer ${token}` } : {}),
+  };
+  const res = await fetch(url, {
+    method,
+    headers,
+    ...(body ? { body: JSON.stringify(body) } : {}),
+  });
+  const json = await res.json();
+  const parsed = (schema as z.ZodType).safeParse(json);
+  if (!parsed.success) {
+    throw new Error("Invalid API response");
+  }
+  return parsed.data as ApiResponse<T>;
+}
path: extensions/dovetail/src/api/client.ts
user: login: greptile-apps[bot]
body: style: fetchDovetail function duplicates code with fetchDovetailDynamic. Consider consolidating into a single function
created_at: 2025-05-12T01:33:02Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,44 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import { useAuth } from "./useAuth";
+
+type FetchFunction<T> = (query: string, token?: string) => Promise<{ data: T[] }>;
+
+export function useSearch<T>(fetchFunction: FetchFunction<T>) {
+  const { token } = useAuth();
+  const [query, setQuery] = useState("");
+  const [data, setData] = useState<T[]>([]);
+  const [isLoading, setIsLoading] = useState(false);
+  const cancelRef = useRef<AbortController | null>(null);
+
+  const fetchData = useCallback(
+    async (searchQuery: string) => {
+      setIsLoading(true);
+      try {
+        const result = await fetchFunction(searchQuery, token ?? undefined);
path: extensions/dovetail/src/hooks/useSearch.ts
user: login: greptile-apps[bot]
body: logic: AbortController signal not passed to fetchFunction - cancellation won't work

```suggestion
        const result = await fetchFunction(searchQuery, token ?? undefined, cancelRef.current?.signal);
```
created_at: 2025-05-12T01:33:43Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,44 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import { useAuth } from "./useAuth";
+
+type FetchFunction<T> = (query: string, token?: string) => Promise<{ data: T[] }>;
+
+export function useSearch<T>(fetchFunction: FetchFunction<T>) {
+  const { token } = useAuth();
+  const [query, setQuery] = useState("");
+  const [data, setData] = useState<T[]>([]);
+  const [isLoading, setIsLoading] = useState(false);
+  const cancelRef = useRef<AbortController | null>(null);
+
+  const fetchData = useCallback(
+    async (searchQuery: string) => {
+      setIsLoading(true);
+      try {
+        const result = await fetchFunction(searchQuery, token ?? undefined);
+        setData(result.data);
+      } catch (error) {
+        console.error("Search error:", error);
+        setData([]);
path: extensions/dovetail/src/hooks/useSearch.ts
user: login: greptile-apps[bot]
body: style: Error should be shown to user with showFailureToast from @raycast/utils instead of just console.error

```suggestion
        showFailureToast(error, { title: "Search failed" });
        setData([]);
```
created_at: 2025-05-12T01:33:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,44 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import { useAuth } from "./useAuth";
+
+type FetchFunction<T> = (query: string, token?: string) => Promise<{ data: T[] }>;
+
+export function useSearch<T>(fetchFunction: FetchFunction<T>) {
+  const { token } = useAuth();
+  const [query, setQuery] = useState("");
+  const [data, setData] = useState<T[]>([]);
+  const [isLoading, setIsLoading] = useState(false);
+  const cancelRef = useRef<AbortController | null>(null);
+
+  const fetchData = useCallback(
+    async (searchQuery: string) => {
+      setIsLoading(true);
+      try {
+        const result = await fetchFunction(searchQuery, token ?? undefined);
+        setData(result.data);
+      } catch (error) {
+        console.error("Search error:", error);
+        setData([]);
+      } finally {
+        setIsLoading(false);
+      }
+    },
+    [token, fetchFunction],
+  );
+
+  useEffect(() => {
+    cancelRef.current?.abort();
+    cancelRef.current = new AbortController();
+    fetchData(query);
+    return () => cancelRef.current?.abort();
+  }, [query, fetchData]);
+
+  const onSearchTextChange = useCallback((text: string) => {
+    setQuery(text);
+    setData([]);
+  }, []);
path: extensions/dovetail/src/hooks/useSearch.ts
user: login: greptile-apps[bot]
body: style: Clearing data before new search causes UI flicker - consider keeping old results until new ones arrive
created_at: 2025-05-12T01:33:44Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,44 @@
+import { useState, useCallback, useRef, useEffect } from "react";
+import { useAuth } from "./useAuth";
+
+type FetchFunction<T> = (query: string, token?: string) => Promise<{ data: T[] }>;
path: extensions/dovetail/src/hooks/useSearch.ts
user: login: greptile-apps[bot]
body: logic: FetchFunction type should include AbortSignal parameter to support cancellation

```suggestion
type FetchFunction<T> = (query: string, token?: string, signal?: AbortSignal) => Promise<{ data: T[] }>;
```
created_at: 2025-05-12T01:33:45Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,177 +1,75 @@
-import { useState, useEffect, useCallback, useRef } from "react";
-import {
-  List,
-  ActionPanel,
-  Action,
-  showToast,
-  Icon,
-  ToastStyle,
-  getPreferenceValues,
-  openExtensionPreferences,
-} from "@raycast/api";
-import fetch from "node-fetch";
+import { Icon, List, ActionPanel, Action, Detail, useNavigation } from "@raycast/api";
+import { Data } from "./types/dovetail";
+import { formatRelativeDate, formatFullDate, cleanMarkdown } from "./utils/formatting";
+import { useState, useEffect } from "react";
+import { useAuth } from "./hooks/useAuth";
+import { getDataExportMarkdown } from "./api/client";
+import { useSearch } from "./hooks/useSearch";
+import { getData } from "./api/client";
 import { format } from "date-fns";
 
-const DEFAULT_PAGE_SIZE = 100;
+function DataDetail({ dataId }: { dataId: string }) {
+  const { token } = useAuth();
+  const [markdown, setMarkdown] = useState<string>("");
+  const [isLoading, setIsLoading] = useState(true);
+  const dovetailUrl = `https://dovetail.com/data/${dataId}`;
 
-interface Note {
-  id: string;
-  title: string;
-  project_title: string;
-  created_at: string;
-}
-
-export default function SearchDovetail() {
-  // Retrieve preferences, including the Dovetail API token
-  const preferences = getPreferenceValues<{ dovetailApiToken: string }>();
-  const apiToken = preferences.dovetailApiToken;
-
-  const [query, setQuery] = useState("");
-  const [data, setData] = useState<Note[]>([]);
-  const [hasNextPage, setHasNextPage] = useState(false);
-  const [endCursor, setEndCursor] = useState<string | null>(null);
-  const cancelRef = useRef<AbortController | null>(null);
-
-  // Warn if we don't have an API token
   useEffect(() => {
-    if (!apiToken) {
-      showToast(
-        ToastStyle.Failure,
-        "API token missing",
-        "Please enter your Dovetail API token in the extension preferences.",
-      );
-      openExtensionPreferences();
-    }
-  }, [apiToken]);
-
-  function getShortDate(createdAt: Date) {
-    const now = new Date();
-    const diffMs = now.getTime() - createdAt.getTime();
-    const diffMinutes = Math.floor(diffMs / (1000 * 60));
-    if (diffMinutes < 60) {
-      return `${diffMinutes}m`;
-    }
-    const diffHours = Math.floor(diffMinutes / 60);
-    if (diffHours < 24) {
-      return `${diffHours}h`;
-    }
-    if (diffHours < 48) {
-      return "1d";
-    }
-    return format(createdAt, "MMM d");
-  }
-
-  function truncateString(str: string, maxLength = 50) {
-    if (str.length <= maxLength) {
-      return str;
-    }
-    return str.slice(0, maxLength - 1) + "…";
-  }
-
-  const searchDovetail = async (searchQuery: string, after?: string | null, signal?: AbortSignal) => {
-    // Don’t attempt to fetch if no token is provided
-    if (!apiToken) return;
-
-    try {
-      const response = await fetch("https://dovetail.com/api/v1/search", {
-        method: "POST",
-        headers: {
-          "Content-Type": "application/json",
-          Authorization: `Bearer ${apiToken}`,
-        },
-        body: JSON.stringify({
-          query: searchQuery,
-          limit: DEFAULT_PAGE_SIZE,
-          after,
-          filter: {
-            notes: [
-              {
-                title: {
-                  contains: searchQuery,
-                },
-              },
-            ],
-          },
-        }),
-        signal,
-      });
-
-      if (!response.ok) {
-        throw new Error(`Error fetching data: ${response.statusText}`);
+    async function fetchMarkdown() {
+      setIsLoading(true);
+      try {
+        const data = await getDataExportMarkdown(dataId, token);
+        const created = format(new Date(data.created_at), "dd MMM yyyy");
+        let content = cleanMarkdown(data.content_markdown || "");
+        content = content.replace(/^\[?AudioVideo.*\n?/im, "");
+        setMarkdown(`# ${data.title}\n\n**Created on ${created}**\n\n${content}`);
+      } catch (e) {
+        setMarkdown("Failed to load data export.");
       }
path: extensions/dovetail/src/search-data.tsx
user: login: greptile-apps[bot]
body: style: Use showFailureToast from @raycast/utils instead of setting error message directly in markdown

```suggestion
        showFailureToast(e, { title: "Failed to load data export" });
      }
```
created_at: 2025-05-12T01:34:11Z
author_association: CONTRIBUTOR
diff_hunk: @@ -1,177 +1,75 @@
-import { useState, useEffect, useCallback, useRef } from "react";
-import {
-  List,
-  ActionPanel,
-  Action,
-  showToast,
-  Icon,
-  ToastStyle,
-  getPreferenceValues,
-  openExtensionPreferences,
-} from "@raycast/api";
-import fetch from "node-fetch";
+import { Icon, List, ActionPanel, Action, Detail, useNavigation } from "@raycast/api";
+import { Data } from "./types/dovetail";
+import { formatRelativeDate, formatFullDate, cleanMarkdown } from "./utils/formatting";
+import { useState, useEffect } from "react";
+import { useAuth } from "./hooks/useAuth";
+import { getDataExportMarkdown } from "./api/client";
+import { useSearch } from "./hooks/useSearch";
+import { getData } from "./api/client";
 import { format } from "date-fns";
 
-const DEFAULT_PAGE_SIZE = 100;
+function DataDetail({ dataId }: { dataId: string }) {
+  const { token } = useAuth();
+  const [markdown, setMarkdown] = useState<string>("");
+  const [isLoading, setIsLoading] = useState(true);
+  const dovetailUrl = `https://dovetail.com/data/${dataId}`;
 
-interface Note {
-  id: string;
-  title: string;
-  project_title: string;
-  created_at: string;
-}
-
-export default function SearchDovetail() {
-  // Retrieve preferences, including the Dovetail API token
-  const preferences = getPreferenceValues<{ dovetailApiToken: string }>();
-  const apiToken = preferences.dovetailApiToken;
-
-  const [query, setQuery] = useState("");
-  const [data, setData] = useState<Note[]>([]);
-  const [hasNextPage, setHasNextPage] = useState(false);
-  const [endCursor, setEndCursor] = useState<string | null>(null);
-  const cancelRef = useRef<AbortController | null>(null);
-
-  // Warn if we don't have an API token
   useEffect(() => {
-    if (!apiToken) {
-      showToast(
-        ToastStyle.Failure,
-        "API token missing",
-        "Please enter your Dovetail API token in the extension preferences.",
-      );
-      openExtensionPreferences();
-    }
-  }, [apiToken]);
-
-  function getShortDate(createdAt: Date) {
-    const now = new Date();
-    const diffMs = now.getTime() - createdAt.getTime();
-    const diffMinutes = Math.floor(diffMs / (1000 * 60));
-    if (diffMinutes < 60) {
-      return `${diffMinutes}m`;
-    }
-    const diffHours = Math.floor(diffMinutes / 60);
-    if (diffHours < 24) {
-      return `${diffHours}h`;
-    }
-    if (diffHours < 48) {
-      return "1d";
-    }
-    return format(createdAt, "MMM d");
-  }
-
-  function truncateString(str: string, maxLength = 50) {
-    if (str.length <= maxLength) {
-      return str;
-    }
-    return str.slice(0, maxLength - 1) + "…";
-  }
-
-  const searchDovetail = async (searchQuery: string, after?: string | null, signal?: AbortSignal) => {
-    // Don’t attempt to fetch if no token is provided
-    if (!apiToken) return;
-
-    try {
-      const response = await fetch("https://dovetail.com/api/v1/search", {
-        method: "POST",
-        headers: {
-          "Content-Type": "application/json",
-          Authorization: `Bearer ${apiToken}`,
-        },
-        body: JSON.stringify({
-          query: searchQuery,
-          limit: DEFAULT_PAGE_SIZE,
-          after,
-          filter: {
-            notes: [
-              {
-                title: {
-                  contains: searchQuery,
-                },
-              },
-            ],
-          },
-        }),
-        signal,
-      });
-
-      if (!response.ok) {
-        throw new Error(`Error fetching data: ${response.statusText}`);
+    async function fetchMarkdown() {
+      setIsLoading(true);
+      try {
+        const data = await getDataExportMarkdown(dataId, token);
+        const created = format(new Date(data.created_at), "dd MMM yyyy");
+        let content = cleanMarkdown(data.content_markdown || "");
+        content = content.replace(/^\[?AudioVideo.*\n?/im, "");
+        setMarkdown(`# ${data.title}\n\n**Created on ${created}**\n\n${content}`);
+      } catch (e) {
+        setMarkdown("Failed to load data export.");
       }
-
-      const responseData = await response.json();
-
-      setData((prev) => [...prev, ...responseData.data.notes]);
-
-      if (responseData.data.pageInfo) {
-        setHasNextPage(responseData.data.pageInfo.hasNextPage);
-        setEndCursor(responseData.data.pageInfo.endCursor);
-      } else {
-        setHasNextPage(false);
-        setEndCursor(null);
-      }
-    } catch (error: unknown) {
-      if ((error as Error).name !== "AbortError") {
-        showToast(ToastStyle.Failure, "Error fetching data", (error as Error).message);
-      }
-    }
-  };
-
-  const onLoadMore = useCallback(() => {
-    if (hasNextPage) {
-      searchDovetail(query, endCursor);
+      setIsLoading(false);
     }
-  }, [hasNextPage, endCursor, query]);
+    fetchMarkdown();
+  }, [dataId, token]);
 
-  const onSearchTextChange = useCallback((text: string) => {
-    setQuery(text);
-    setData([]);
-    setEndCursor(null);
-  }, []);
-
-  useEffect(() => {
-    cancelRef.current?.abort();
-    cancelRef.current = new AbortController();
-    searchDovetail(query, null, cancelRef.current.signal);
-    return () => cancelRef.current?.abort();
-  }, [query]);
+  return (
+    <Detail
+      isLoading={isLoading}
+      markdown={markdown}
+      navigationTitle="Data Details"
+      actions={
+        <ActionPanel>
+          <Action.OpenInBrowser url={dovetailUrl} title="Open in Dovetail" />
+        </ActionPanel>
+      }
+    />
+  );
+}
 
-  const numberOfResults = data.length === 1 ? "1 result" : `${data.length} results`;
+export default function SearchData() {
+  const { data, isLoading, onSearchTextChange, numberOfResults } = useSearch<Data>(getData);
+  const { push } = useNavigation();
 
   return (
     <List
-      isLoading={data === undefined}
+      isLoading={isLoading}
       onSearchTextChange={onSearchTextChange}
       throttle
       searchBarPlaceholder="Search for data in any project..."
-      pagination={{ onLoadMore, hasMore: hasNextPage, pageSize: DEFAULT_PAGE_SIZE }}
     >
       <List.Section title="Most relevant" subtitle={numberOfResults}>
-        {data.map((item) => {
-          const createdAt = new Date(item.created_at);
-          const accessories: List.Item.Accessory[] = [
-            {
-              text: getShortDate(createdAt),
-              tooltip: `Created: ${format(createdAt, "EEEE d MMMM yyyy 'at' HH:mm")}`,
-            },
-          ];
-
-          return (
-            <List.Item
-              key={item.id}
-              title={truncateString(item.title || "No Title", 60)}
-              icon={{ source: Icon.Document }}
-              subtitle={truncateString(item.project_title || "No project", 40)}
-              accessories={accessories}
-              actions={
-                <ActionPanel>
-                  <Action.OpenInBrowser title="Open in Dovetail" url={`https://dovetail.com/data/${item.id}`} />
-                </ActionPanel>
-              }
-            />
-          );
-        })}
+        {data.map((item) => (
+          <List.Item
+            key={item.id}
+            title={item.title || "Untitled"}
+            icon={Icon.Document}
+            accessories={[{ text: formatRelativeDate(item.created_at), tooltip: formatFullDate(item.created_at) }]}
+            actions={
+              <ActionPanel>
+                <Action title="Show Details" onAction={() => push(<DataDetail dataId={item.id} />)} />
+              </ActionPanel>
path: extensions/dovetail/src/search-data.tsx
user: login: greptile-apps[bot]
body: style: Add Action.OpenInBrowser here too for quick access without needing to open details
created_at: 2025-05-12T01:34:12Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,123 @@
+import React, { useState, useEffect } from "react";
+import { List } from "@raycast/api";
+import { Contact, Field } from "./types/dovetail";
+import { getContactDetails } from "./api/client";
+import { useAuth } from "./hooks/useAuth";
+import { format } from "date-fns";
+import { getAvatarIcon } from "@raycast/utils";
+import { useSearch } from "./hooks/useSearch";
+import { getContacts } from "./api/client";
+
+function useContactDetail(contactId: string | null) {
+  const { token } = useAuth();
+  const [contact, setContact] = useState<Contact | null>(null);
+  const [loading, setLoading] = useState(true);
+
+  useEffect(() => {
+    if (!contactId) return;
+    setLoading(true);
+    getContactDetails(contactId, token).then((data) => {
+      setContact(data as Contact);
+      setLoading(false);
+    });
+  }, [contactId, token]);
+
+  return { contact, loading };
+}
+
+function getFieldValue(fields: Field[], label: string): string | undefined {
+  const field = fields.find((f) => f.label?.toLowerCase() === label.toLowerCase());
+  if (!field) return undefined;
+  if (Array.isArray(field.value)) return field.value.join(", ");
+  return field.value ?? undefined;
+}
+
+const TAG_COLORS = ["#4F8FFF", "#FFD700", "#FF69B4", "#00C49A", "#FF7043", "#A259FF", "#FFB300", "#00B8D9"];
+
+export default function SearchContacts() {
+  const { data, isLoading, numberOfResults } = useSearch<Contact>(getContacts);
+  const [selectedId, setSelectedId] = useState<string | null>(null);
+  const [searchText, setSearchText] = useState("");
+  const [filteredContacts, setFilteredContacts] = useState<Contact[]>(data);
path: extensions/dovetail/src/search-contacts.tsx
user: login: greptile-apps[bot]
body: logic: Initial state should be empty array [] instead of data to avoid potential undefined issues on first render

```suggestion
  const [filteredContacts, setFilteredContacts] = useState<Contact[]>([]);
```
created_at: 2025-05-12T01:34:34Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,66 @@
+export interface Insight {
+  id: string;
+  title: string;
+  type: string;
+  created_at: string;
+  published: boolean;
+}
+
+export interface Field {
+  id: string;
+  name: string;
+  label?: string;
+  value: string | string[];
+}
+
+export interface Contact {
+  id: string;
+  name: string | null;
+  email?: string | null;
+  created_at?: string;
+  deleted?: boolean;
+  fields?: Field[];
+}
+
+export interface PageInfo {
+  hasNextPage: boolean;
+  endCursor: string | null;
+}
+
+export interface Data {
+  id: string;
+  type: string;
+  title: string;
+  created_at: string;
+  deleted: boolean;
+}
+
+export interface ApiResponse<T> {
+  data: T;
+  pageInfo?: PageInfo;
+}
path: extensions/dovetail/src/types/dovetail.ts
user: login: greptile-apps[bot]
body: style: ApiResponse&lt;T&gt; could benefit from a constraint on T to ensure it's an object type, e.g. `ApiResponse<T extends object>`
created_at: 2025-05-12T01:35:00Z
author_association: CONTRIBUTOR
diff_hunk: @@ -0,0 +1,22 @@
+import { format, differenceInDays } from "date-fns";
+
+export function formatRelativeDate(dateString: string) {
+  const date = new Date(dateString);
+  const now = new Date();
+  const days = differenceInDays(now, date);
+  if (days < 1) return "1d";
+  if (days < 7) return `${days + 1}d`;
path: extensions/dovetail/src/utils/formatting.ts
user: login: greptile-apps[bot]
body: logic: Adding 1 to days when days &lt; 7 but not when days &lt; 1 creates inconsistent behavior. This could cause off-by-one errors in date displays.
created_at: 2025-05-12T01:35:18Z
author_association: CONTRIBUTOR

PR comments:
user: login: raycastbot
created_at: 2025-05-12T01:31:00Z
body: Thank you for the update! :tada:

You can expect an initial review within five business days.
author_association: COLLABORATOR
user: login: github-actions[bot]
created_at: 2025-05-12T05:28:59Z
body: <!-- add-pr-comment:add-pr-comment -->

**Published to the Raycast Store:**
https://raycast.com/barlow/dovetail
author_association: CONTRIBUTOR
user: login: raycastbot
created_at: 2025-05-12T05:29:01Z
body: :tada: :tada: :tada:

We've rewarded your Raycast account with some credits. You will soon be able to exchange them for some swag.
author_association: COLLABORATOR