@agent-native/dispatch 0.9.2 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +37 -52
  2. package/dist/actions/index.d.ts.map +1 -1
  3. package/dist/actions/index.js +2 -0
  4. package/dist/actions/index.js.map +1 -1
  5. package/dist/actions/provider-api-catalog.d.ts.map +1 -1
  6. package/dist/actions/provider-api-catalog.js +9 -6
  7. package/dist/actions/provider-api-catalog.js.map +1 -1
  8. package/dist/actions/provider-api-docs.d.ts.map +1 -1
  9. package/dist/actions/provider-api-docs.js +7 -5
  10. package/dist/actions/provider-api-docs.js.map +1 -1
  11. package/dist/actions/provider-api-register.d.ts +3 -0
  12. package/dist/actions/provider-api-register.d.ts.map +1 -0
  13. package/dist/actions/provider-api-register.js +160 -0
  14. package/dist/actions/provider-api-register.js.map +1 -0
  15. package/dist/actions/provider-api-request.d.ts.map +1 -1
  16. package/dist/actions/provider-api-request.js +33 -5
  17. package/dist/actions/provider-api-request.js.map +1 -1
  18. package/dist/lib/thread-link-preview.d.ts +23 -0
  19. package/dist/lib/thread-link-preview.d.ts.map +1 -0
  20. package/dist/lib/thread-link-preview.js +150 -0
  21. package/dist/lib/thread-link-preview.js.map +1 -0
  22. package/dist/routes/pages/chat.d.ts +2 -21
  23. package/dist/routes/pages/chat.d.ts.map +1 -1
  24. package/dist/routes/pages/chat.js +3 -12
  25. package/dist/routes/pages/chat.js.map +1 -1
  26. package/dist/routes/pages/overview.d.ts +2 -21
  27. package/dist/routes/pages/overview.d.ts.map +1 -1
  28. package/dist/routes/pages/overview.js +2 -11
  29. package/dist/routes/pages/overview.js.map +1 -1
  30. package/dist/server/lib/provider-api.d.ts +3 -3
  31. package/dist/server/lib/provider-api.d.ts.map +1 -1
  32. package/dist/server/lib/provider-api.js +29 -5
  33. package/dist/server/lib/provider-api.js.map +1 -1
  34. package/dist/server/lib/thread-link-preview.d.ts +1 -22
  35. package/dist/server/lib/thread-link-preview.d.ts.map +1 -1
  36. package/dist/server/lib/thread-link-preview.js +1 -151
  37. package/dist/server/lib/thread-link-preview.js.map +1 -1
  38. package/package.json +3 -1
  39. package/src/actions/index.ts +2 -0
  40. package/src/actions/provider-api-catalog.ts +11 -12
  41. package/src/actions/provider-api-docs.ts +9 -11
  42. package/src/actions/provider-api-register.ts +198 -0
  43. package/src/actions/provider-api-request.ts +49 -10
  44. package/src/lib/thread-link-preview.ts +160 -0
  45. package/src/routes/pages/chat.tsx +3 -20
  46. package/src/routes/pages/overview.tsx +3 -16
  47. package/src/server/lib/provider-api.ts +35 -8
  48. package/src/server/lib/thread-link-preview.spec.ts +2 -4
  49. package/src/server/lib/thread-link-preview.ts +4 -162
@@ -1,25 +1,23 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { z } from "zod";
3
- import {
4
- DISPATCH_PROVIDER_API_IDS,
5
- fetchProviderApiDocs,
6
- } from "../server/lib/provider-api.js";
7
-
8
- const ProviderSchema = z.enum(DISPATCH_PROVIDER_API_IDS);
3
+ import { fetchProviderApiDocs } from "../server/lib/provider-api.js";
9
4
 
10
5
  export default defineAction({
11
6
  description:
12
- "Inspect provider API docs/spec metadata, or fetch a registered provider docs/spec URL. Use this before arbitrary provider-api-request calls when the exact endpoint, filter operator, payload shape, pagination, or API version is uncertain.",
7
+ "Inspect provider API docs/spec metadata, or fetch ANY public API documentation page, OpenAPI spec, changelog, or web page. Registered docs/spec URLs from provider-api-catalog are curated starting points, but any public https/http URL is allowed. Use web-search to find documentation URLs first when uncertain, then fetch them here. SSRF guard still applies — private/internal addresses are blocked.",
13
8
  schema: z.object({
14
- provider: ProviderSchema.describe(
15
- "Provider whose API docs/spec to inspect.",
16
- ),
9
+ provider: z
10
+ .string()
11
+ .min(1)
12
+ .describe(
13
+ "Provider whose API docs/spec to inspect. Can be a built-in provider id or a custom provider id registered via provider-api-register.",
14
+ ),
17
15
  url: z
18
16
  .string()
19
17
  .url()
20
18
  .optional()
21
19
  .describe(
22
- "Optional docs/spec URL from provider-api-catalog to fetch. Only registered docs/spec origins are allowed.",
20
+ "Optional URL to fetch. Can be any public https/http URL API documentation pages, OpenAPI specs, changelogs, README files, etc. Registered docs/spec URLs from provider-api-catalog are curated starting points.",
23
21
  ),
24
22
  maxBytes: z.coerce
25
23
  .number()
@@ -0,0 +1,198 @@
1
+ import { defineAction } from "@agent-native/core";
2
+ import { z } from "zod";
3
+ import {
4
+ upsertCustomProvider,
5
+ deleteCustomProvider,
6
+ listCustomProviders,
7
+ getCustomProvider,
8
+ } from "@agent-native/core/provider-api";
9
+ import { getCredentialContext } from "@agent-native/core/server";
10
+
11
+ const AuthSchema = z.discriminatedUnion("type", [
12
+ z.object({
13
+ type: z.literal("none"),
14
+ }),
15
+ z.object({
16
+ type: z.literal("bearer"),
17
+ credentialKey: z
18
+ .string()
19
+ .min(1)
20
+ .describe(
21
+ "Name of the credential key (e.g. MY_API_TOKEN). Must already be saved via app secrets — this action never accepts secret values.",
22
+ ),
23
+ }),
24
+ z.object({
25
+ type: z.literal("basic"),
26
+ usernameKey: z
27
+ .string()
28
+ .min(1)
29
+ .describe("Credential key name for the username/login."),
30
+ passwordKey: z
31
+ .string()
32
+ .min(1)
33
+ .describe("Credential key name for the password/secret."),
34
+ }),
35
+ z.object({
36
+ type: z.literal("api-key-header"),
37
+ credentialKey: z
38
+ .string()
39
+ .min(1)
40
+ .describe("Credential key name for the API key value."),
41
+ headerName: z
42
+ .string()
43
+ .min(1)
44
+ .describe("HTTP header name to send the key in (e.g. X-Api-Key)."),
45
+ }),
46
+ ]);
47
+
48
+ export default defineAction({
49
+ description: `Register or update a custom API provider so the agent can call it via provider-api-request and look up its docs via provider-api-docs.
50
+
51
+ IMPORTANT — credentials:
52
+ - This action stores only credential KEY NAMES, never secret values.
53
+ - If the required API key is not yet saved, instruct the user to add it via app Settings → Keys, or use the create-vault-secret action, before calling this action.
54
+ - Supported auth kinds: none, bearer, basic, api-key-header.
55
+ google-service-account and oauth-bearer are NOT supported for custom providers.
56
+
57
+ After registration the provider appears in provider-api-catalog and can be used with provider-api-request.`,
58
+ schema: z.object({
59
+ operation: z
60
+ .enum(["upsert", "delete", "list", "get"])
61
+ .default("upsert")
62
+ .describe(
63
+ "Operation: upsert (create or update), delete (remove), list (all custom providers), get (single provider).",
64
+ ),
65
+ id: z
66
+ .string()
67
+ .min(1)
68
+ .max(64)
69
+ .optional()
70
+ .describe(
71
+ "Provider slug (e.g. my-api). Lowercase letters, digits, hyphens only. Required for upsert/delete/get.",
72
+ ),
73
+ label: z
74
+ .string()
75
+ .min(1)
76
+ .max(200)
77
+ .optional()
78
+ .describe(
79
+ "Human-readable name (e.g. 'My Analytics API'). Required for upsert.",
80
+ ),
81
+ baseUrl: z
82
+ .string()
83
+ .url()
84
+ .optional()
85
+ .describe(
86
+ "Base URL for the API (e.g. https://api.example.com/v1). Required for upsert. Must be a public https/http URL.",
87
+ ),
88
+ auth: AuthSchema.optional().describe(
89
+ "Auth configuration. Required for upsert. Use type 'none' for public APIs.",
90
+ ),
91
+ docsUrls: z
92
+ .array(z.string().url())
93
+ .optional()
94
+ .describe("Optional list of documentation URLs for this provider."),
95
+ allowedHostSuffixes: z
96
+ .array(z.string())
97
+ .optional()
98
+ .describe(
99
+ "Optional list of additional host suffixes requests may target beyond the base URL origin (e.g. ['example.com']).",
100
+ ),
101
+ defaultHeaders: z
102
+ .record(z.string(), z.string())
103
+ .optional()
104
+ .describe(
105
+ "Optional headers to include on every request (e.g. { 'Accept': 'application/json' }).",
106
+ ),
107
+ notes: z
108
+ .string()
109
+ .max(1000)
110
+ .optional()
111
+ .describe("Optional notes about this provider shown in the catalog."),
112
+ scope: z
113
+ .enum(["user", "org"])
114
+ .default("org")
115
+ .describe(
116
+ "Whether to store the provider for the current user only ('user') or for the whole workspace ('org').",
117
+ ),
118
+ }),
119
+ http: false,
120
+ run: async ({
121
+ operation,
122
+ id,
123
+ label,
124
+ baseUrl,
125
+ auth,
126
+ docsUrls,
127
+ allowedHostSuffixes,
128
+ defaultHeaders,
129
+ notes,
130
+ scope,
131
+ }) => {
132
+ const ctx = getCredentialContext();
133
+ if (!ctx) {
134
+ throw new Error(
135
+ "provider-api-register requires an authenticated request context.",
136
+ );
137
+ }
138
+ const scopeId =
139
+ scope === "org" ? (ctx.orgId ?? ctx.userEmail) : ctx.userEmail;
140
+
141
+ if (operation === "list") {
142
+ const providers = await listCustomProviders(scope, scopeId);
143
+ return {
144
+ providers: providers.map((p) => ({
145
+ id: p.id,
146
+ label: p.label,
147
+ baseUrl: p.baseUrl,
148
+ authType: p.auth.type,
149
+ docsUrls: p.docsUrls,
150
+ notes: p.notes,
151
+ updatedAt: p.updatedAt,
152
+ })),
153
+ count: providers.length,
154
+ };
155
+ }
156
+
157
+ if (operation === "get") {
158
+ if (!id) throw new Error("id is required for get operation.");
159
+ const provider = await getCustomProvider(scope, scopeId, id);
160
+ if (!provider) {
161
+ return { found: false, id };
162
+ }
163
+ return { found: true, provider };
164
+ }
165
+
166
+ if (operation === "delete") {
167
+ if (!id) throw new Error("id is required for delete operation.");
168
+ const deleted = await deleteCustomProvider(scope, scopeId, id);
169
+ return { deleted, id };
170
+ }
171
+
172
+ // upsert
173
+ if (!id) throw new Error("id is required for upsert operation.");
174
+ if (!label) throw new Error("label is required for upsert operation.");
175
+ if (!baseUrl) throw new Error("baseUrl is required for upsert operation.");
176
+ if (!auth) throw new Error("auth is required for upsert operation.");
177
+
178
+ await upsertCustomProvider({
179
+ scope,
180
+ scopeId,
181
+ id,
182
+ label,
183
+ baseUrl,
184
+ auth,
185
+ docsUrls,
186
+ allowedHostSuffixes,
187
+ defaultHeaders,
188
+ notes,
189
+ });
190
+
191
+ return {
192
+ registered: true,
193
+ id,
194
+ label,
195
+ message: `Custom provider "${id}" registered. Use provider-api-catalog to inspect it and provider-api-request to call it.`,
196
+ };
197
+ },
198
+ });
@@ -1,20 +1,19 @@
1
1
  import { defineAction } from "@agent-native/core";
2
2
  import { z } from "zod";
3
- import {
4
- DISPATCH_PROVIDER_API_IDS,
5
- executeProviderApiRequest,
6
- } from "../server/lib/provider-api.js";
3
+ import { executeProviderApiRequest } from "../server/lib/provider-api.js";
7
4
 
8
- const ProviderSchema = z.enum(DISPATCH_PROVIDER_API_IDS);
9
5
  const MethodSchema = z.enum(["GET", "POST", "PUT", "PATCH", "DELETE", "HEAD"]);
10
6
 
11
7
  export default defineAction({
12
8
  description:
13
- "Make an arbitrary authenticated HTTP request to a shared workspace integration or configured provider API. Use this as the flexible escape hatch when Dispatch needs a provider endpoint, filter, pagination mode, payload, or API version that no canned action models. The request is constrained to the provider host, uses configured credentials automatically, blocks private/internal URLs, and redacts secrets from responses.",
9
+ "Make an arbitrary authenticated HTTP request to a shared workspace integration, configured provider API, or custom provider registered via provider-api-register. Use this as the flexible escape hatch when Dispatch needs a provider endpoint, filter, pagination mode, payload, or API version that no canned action models. The request is constrained to the provider host, uses configured credentials automatically, blocks private/internal URLs, and redacts secrets from responses.",
14
10
  schema: z.object({
15
- provider: ProviderSchema.describe(
16
- "Configured provider API to call, e.g. slack, github, notion, hubspot, gmail, google_drive, google_calendar, granola, stripe, jira.",
17
- ),
11
+ provider: z
12
+ .string()
13
+ .min(1)
14
+ .describe(
15
+ "Provider id to call — built-in (e.g. slack, github, notion, hubspot, gmail, google_drive, google_calendar, granola, stripe, jira) or a custom provider id registered via provider-api-register. Use provider-api-catalog to list available providers.",
16
+ ),
18
17
  method: MethodSchema.default("GET").describe("HTTP method to use."),
19
18
  path: z
20
19
  .string()
@@ -71,7 +70,47 @@ export default defineAction({
71
70
  .min(1_000)
72
71
  .max(4 * 1024 * 1024)
73
72
  .optional()
74
- .describe("Maximum response bytes to read. Default 1MB, max 4MB."),
73
+ .describe(
74
+ "Maximum response bytes to read. Default 1MB, max 4MB. Ignored when saveToFile is set (allows up to 20MB).",
75
+ ),
76
+ saveToFile: z
77
+ .string()
78
+ .optional()
79
+ .describe(
80
+ "Workspace file path to save the full response body to instead of returning it in context (e.g. 'analysis/hubspot-deals.json'). When set, returns only a compact summary {savedTo, status, bytes, preview} and allows up to 20MB response. Useful for large datasets that would overflow context.",
81
+ ),
82
+ fetchAllPages: z
83
+ .object({
84
+ cursorPath: z
85
+ .string()
86
+ .describe(
87
+ "Dot-path in the JSON response body where the next-page cursor lives, e.g. 'meta.next_cursor' or 'pagination.next_page_token'.",
88
+ ),
89
+ cursorParam: z
90
+ .string()
91
+ .describe(
92
+ "Query parameter name to pass the cursor on subsequent pages, e.g. 'cursor' or 'page_token'.",
93
+ ),
94
+ itemsPath: z
95
+ .string()
96
+ .optional()
97
+ .describe(
98
+ "Dot-path to the items array in each response, e.g. 'results' or 'data.items'. When omitted, the whole response body is appended per page.",
99
+ ),
100
+ maxPages: z.coerce
101
+ .number()
102
+ .int()
103
+ .min(1)
104
+ .max(50)
105
+ .optional()
106
+ .describe(
107
+ "Maximum pages to fetch. Default 10, max 50. Stops early when the cursor is empty.",
108
+ ),
109
+ })
110
+ .optional()
111
+ .describe(
112
+ "Enable cursor-based pagination. After each response, reads cursorPath from the JSON body and re-issues the request with cursorParam set, accumulating items from itemsPath (or whole bodies) until cursor is empty or maxPages is reached. Combine with saveToFile to write the full dataset to a workspace file.",
113
+ ),
75
114
  }),
76
115
  http: false,
77
116
  run: async (args) => executeProviderApiRequest(args),
@@ -0,0 +1,160 @@
1
+ export interface ThreadLinkPreview {
2
+ title: string;
3
+ description: string;
4
+ imageUrl: string | null;
5
+ }
6
+
7
+ const IMAGE_URL_KEYS = new Set([
8
+ "previewUrl",
9
+ "thumbnailUrl",
10
+ "imageUrl",
11
+ "image",
12
+ "downloadUrl",
13
+ ]);
14
+
15
+ const GENERATION_TOOL_NAMES = new Set([
16
+ "generate-image",
17
+ "generate-image-batch",
18
+ "refine-image",
19
+ "rerun-generation-run",
20
+ ]);
21
+
22
+ function safeJsonParse(value: string): unknown {
23
+ try {
24
+ return JSON.parse(value);
25
+ } catch {
26
+ return null;
27
+ }
28
+ }
29
+
30
+ function cleanUrlCandidate(value: string): string {
31
+ return value
32
+ .trim()
33
+ .replace(/[),.;\]}]+$/g, "")
34
+ .replace(/^["'(<]+/g, "");
35
+ }
36
+
37
+ function isAbsoluteHttpUrl(value: string): boolean {
38
+ try {
39
+ const url = new URL(value);
40
+ return url.protocol === "https:" || url.protocol === "http:";
41
+ } catch {
42
+ return false;
43
+ }
44
+ }
45
+
46
+ function isImageLikeUrl(value: string): boolean {
47
+ try {
48
+ const url = new URL(value);
49
+ return (
50
+ /\.(?:png|jpe?g|webp|gif|avif)(?:$|[?#])/i.test(url.pathname) ||
51
+ /\/api\/assets\/[^/]+\/content(?:$|[?#])/i.test(url.pathname)
52
+ );
53
+ } catch {
54
+ return false;
55
+ }
56
+ }
57
+
58
+ function validPreviewImageUrl(value: unknown, key?: string): string | null {
59
+ if (typeof value !== "string") return null;
60
+ const candidate = cleanUrlCandidate(value);
61
+ if (!isAbsoluteHttpUrl(candidate)) return null;
62
+ if (key && IMAGE_URL_KEYS.has(key)) return candidate;
63
+ return isImageLikeUrl(candidate) ? candidate : null;
64
+ }
65
+
66
+ function imageUrlFromStructuredValue(value: unknown): string | null {
67
+ if (!value || typeof value !== "object") return null;
68
+ if (Array.isArray(value)) {
69
+ for (let i = value.length - 1; i >= 0; i--) {
70
+ const found = imageUrlFromStructuredValue(value[i]);
71
+ if (found) return found;
72
+ }
73
+ return null;
74
+ }
75
+
76
+ const record = value as Record<string, unknown>;
77
+ for (const key of IMAGE_URL_KEYS) {
78
+ const found = validPreviewImageUrl(record[key], key);
79
+ if (found) return found;
80
+ }
81
+ for (const [key, child] of Object.entries(record).reverse()) {
82
+ const direct = validPreviewImageUrl(child, key);
83
+ if (direct) return direct;
84
+ if (child && typeof child === "object") {
85
+ const nested = imageUrlFromStructuredValue(child);
86
+ if (nested) return nested;
87
+ }
88
+ }
89
+ return null;
90
+ }
91
+
92
+ function imageUrlFromText(value: string): string | null {
93
+ const matches = value.match(/https?:\/\/[^\s<>"']+/g);
94
+ if (!matches) return null;
95
+ for (let i = matches.length - 1; i >= 0; i--) {
96
+ const candidate = validPreviewImageUrl(matches[i]);
97
+ if (candidate) return candidate;
98
+ }
99
+ return null;
100
+ }
101
+
102
+ export function extractThreadPreviewImageUrl(
103
+ threadData: string,
104
+ ): string | null {
105
+ const parsed = safeJsonParse(threadData);
106
+ if (!parsed || typeof parsed !== "object") return null;
107
+ const messages = (parsed as { messages?: unknown }).messages;
108
+ if (!Array.isArray(messages)) return null;
109
+
110
+ for (
111
+ let messageIndex = messages.length - 1;
112
+ messageIndex >= 0;
113
+ messageIndex--
114
+ ) {
115
+ const entry = messages[messageIndex] as any;
116
+ const message = entry?.message ?? entry;
117
+ const content = message?.content;
118
+ if (!Array.isArray(content)) continue;
119
+
120
+ for (let partIndex = content.length - 1; partIndex >= 0; partIndex--) {
121
+ const part = content[partIndex] as Record<string, unknown>;
122
+ const result = typeof part.result === "string" ? part.result : "";
123
+ if (!result.trim()) continue;
124
+
125
+ const toolName = typeof part.toolName === "string" ? part.toolName : "";
126
+ const parsedResult = safeJsonParse(result);
127
+ if (parsedResult && GENERATION_TOOL_NAMES.has(toolName)) {
128
+ const structured = imageUrlFromStructuredValue(parsedResult);
129
+ if (structured) return structured;
130
+ }
131
+
132
+ const fromText = imageUrlFromText(result);
133
+ if (fromText) return fromText;
134
+ }
135
+ }
136
+ return null;
137
+ }
138
+
139
+ export function buildThreadLinkPreviewMeta(preview: ThreadLinkPreview | null) {
140
+ const title = preview?.title ? `${preview.title} - Dispatch` : "Dispatch";
141
+ const description =
142
+ preview?.description ||
143
+ "Open this Agent-Native thread in the Dispatch workspace.";
144
+ const image = preview?.imageUrl ?? null;
145
+ return [
146
+ { title },
147
+ { name: "description", content: description },
148
+ { property: "og:title", content: title },
149
+ { property: "og:description", content: description },
150
+ { property: "og:type", content: "website" },
151
+ ...(image ? [{ property: "og:image", content: image }] : []),
152
+ {
153
+ name: "twitter:card",
154
+ content: image ? "summary_large_image" : "summary",
155
+ },
156
+ { name: "twitter:title", content: title },
157
+ { name: "twitter:description", content: description },
158
+ ...(image ? [{ name: "twitter:image", content: image }] : []),
159
+ ];
160
+ }
@@ -1,15 +1,7 @@
1
1
  import { useEffect, useRef } from "react";
2
- import {
3
- useLocation,
4
- useNavigate,
5
- type LoaderFunctionArgs,
6
- } from "react-router";
2
+ import { useLocation, useNavigate } from "react-router";
7
3
  import { AgentChatSurface } from "@agent-native/core/client";
8
4
  import { submitOverviewPrompt } from "@/lib/overview-chat";
9
- import {
10
- buildThreadLinkPreviewMeta,
11
- loadThreadLinkPreview,
12
- } from "@/server/lib/thread-link-preview";
13
5
 
14
6
  interface DispatchChatLocationState {
15
7
  dispatchPrompt?: {
@@ -23,17 +15,8 @@ interface DispatchChatLocationState {
23
15
  };
24
16
  }
25
17
 
26
- export async function loader({ request }: LoaderFunctionArgs) {
27
- const threadId = new URL(request.url).searchParams.get("thread");
28
- return {
29
- threadPreview: await loadThreadLinkPreview(threadId),
30
- };
31
- }
32
-
33
- export function meta({ data }: { data?: Awaited<ReturnType<typeof loader>> }) {
34
- return data?.threadPreview
35
- ? buildThreadLinkPreviewMeta(data.threadPreview)
36
- : [{ title: "Chat — Dispatch" }];
18
+ export function meta() {
19
+ return [{ title: "Chat — Dispatch" }];
37
20
  }
38
21
 
39
22
  export default function ChatRoute() {
@@ -1,5 +1,5 @@
1
1
  import { useEffect, useMemo, useState } from "react";
2
- import { Link, useNavigate, type LoaderFunctionArgs } from "react-router";
2
+ import { Link, useNavigate } from "react-router";
3
3
  import {
4
4
  PromptComposer,
5
5
  useActionQuery,
@@ -34,10 +34,6 @@ import {
34
34
  TooltipTrigger,
35
35
  } from "@/components/ui/tooltip";
36
36
  import { submitOverviewPrompt } from "@/lib/overview-chat";
37
- import {
38
- buildThreadLinkPreviewMeta,
39
- loadThreadLinkPreview,
40
- } from "@/server/lib/thread-link-preview";
41
37
  import type { WorkspaceAppSummary } from "@/lib/workspace-apps";
42
38
 
43
39
  interface IntegrationStatus {
@@ -475,17 +471,8 @@ function StepRow({ step }: { step: ChecklistStep }) {
475
471
  );
476
472
  }
477
473
 
478
- export async function loader({ request }: LoaderFunctionArgs) {
479
- const threadId = new URL(request.url).searchParams.get("thread");
480
- return {
481
- threadPreview: await loadThreadLinkPreview(threadId),
482
- };
483
- }
484
-
485
- export function meta({ data }: { data?: Awaited<ReturnType<typeof loader>> }) {
486
- return data?.threadPreview
487
- ? buildThreadLinkPreviewMeta(data.threadPreview)
488
- : [{ title: "Overview — Dispatch" }];
474
+ export function meta() {
475
+ return [{ title: "Overview — Dispatch" }];
489
476
  }
490
477
 
491
478
  export default function OverviewRoute() {
@@ -1,6 +1,7 @@
1
1
  import {
2
2
  PROVIDER_API_IDS,
3
3
  createProviderApiRuntime,
4
+ listCustomProviders,
4
5
  type ProviderApiId,
5
6
  type ProviderApiMethod,
6
7
  type ProviderApiRequestArgs,
@@ -11,26 +12,52 @@ export const DISPATCH_PROVIDER_API_IDS = PROVIDER_API_IDS;
11
12
  export type DispatchProviderApiId = ProviderApiId;
12
13
  export type { ProviderApiMethod, ProviderApiRequestArgs };
13
14
 
15
+ function requireCtx(action: string) {
16
+ const ctx = getCredentialContext();
17
+ if (!ctx) {
18
+ throw new Error(
19
+ `Dispatch provider API ${action} requires an authenticated request context.`,
20
+ );
21
+ }
22
+ return ctx;
23
+ }
24
+
14
25
  const runtime = createProviderApiRuntime({
15
26
  appId: "dispatch",
16
27
  localCredentialSource: "dispatch_local",
17
- getCredentialContext: () => {
28
+ getCredentialContext: () => requireCtx("requests"),
29
+ getCustomProviders: async () => {
18
30
  const ctx = getCredentialContext();
19
- if (!ctx) {
20
- throw new Error(
21
- "Dispatch provider API requests require an authenticated request context.",
22
- );
31
+ if (!ctx) return [];
32
+ // Load custom providers for both user scope and org scope.
33
+ const results = await Promise.allSettled([
34
+ listCustomProviders("user", ctx.userEmail),
35
+ ctx.orgId ? listCustomProviders("org", ctx.orgId) : Promise.resolve([]),
36
+ ]);
37
+ const userProviders =
38
+ results[0].status === "fulfilled" ? results[0].value : [];
39
+ const orgProviders =
40
+ results[1].status === "fulfilled" ? results[1].value : [];
41
+ // Merge: user-scope providers take precedence over org-scope ones with the
42
+ // same id, and neither can shadow a built-in id.
43
+ const seen = new Set<string>(PROVIDER_API_IDS as unknown as string[]);
44
+ const merged = [];
45
+ for (const p of [...userProviders, ...orgProviders]) {
46
+ if (!seen.has(p.id)) {
47
+ seen.add(p.id);
48
+ merged.push(p);
49
+ }
23
50
  }
24
- return ctx;
51
+ return merged;
25
52
  },
26
53
  });
27
54
 
28
- export function listProviderApiCatalog(provider?: DispatchProviderApiId) {
55
+ export function listProviderApiCatalog(provider?: string) {
29
56
  return runtime.listCatalog(provider);
30
57
  }
31
58
 
32
59
  export function fetchProviderApiDocs(options: {
33
- provider: DispatchProviderApiId;
60
+ provider: string;
34
61
  url?: string;
35
62
  maxBytes?: number;
36
63
  }) {
@@ -9,10 +9,8 @@ vi.mock("@agent-native/core/server", () => ({
9
9
  getThread: getThreadMock,
10
10
  }));
11
11
 
12
- import {
13
- extractThreadPreviewImageUrl,
14
- loadThreadLinkPreview,
15
- } from "./thread-link-preview";
12
+ import { loadThreadLinkPreview } from "./thread-link-preview";
13
+ import { extractThreadPreviewImageUrl } from "../../lib/thread-link-preview";
16
14
 
17
15
  function threadDataWithResult(toolName: string, result: unknown) {
18
16
  return JSON.stringify({