Skip to content

Commit

Integrations are a directory of connectors, for a workspace and for each person

- One catalog (packages/contracts connectors.ts): every connector's category, what it does in g1t, whether it's for a workspace, a person or both, and whether it's available or coming; adding one is one entry - The workspace's directory: categories, search, what's connected (and what needs attention), what's available, what's coming, and asking for one; setup pages per kind, unchanged - Your integrations in your settings: just for you, in every workspace; calendars, mail and status from chat apps coming

syntaqxcommitted Parent05a4195Browse files
28 files+1463−390/28 viewed
+2−2
358358
359359 ### Your own providers
360360
361−Connect a model provider, or any compatible endpoint, in **Settings →
362−Integrations**. See [model providers](/guides/models/#connect-a-provider).
361+Connect a model provider, or any compatible endpoint, in the workspace's
362+**Integrations**, under **AI models**. See [model providers](/guides/models/#connect-a-provider).
363363 Then, on an agent:
364364
365365 - **Both** (the default, when nothing is chosen): the agent may use
+1−0
4141 | Invites to g1t | [`/settings/invites`](https://g1t.sh/settings/invites) | [Making, copying and revoking invites to g1t](#making-invites), while g1t is invite-only; after that, the invites you made. |
4242 | SSH keys | [`/settings/keys`](https://g1t.sh/settings/keys) | Public keys for [git over SSH](/guides/git/#ssh), each with when it was added and last used. |
4343 | Access tokens | [`/settings/tokens`](https://g1t.sh/settings/tokens) | Your [personal access tokens](#access-tokens): their permissions, where they reach, and when they expire. |
44+| Integrations | [`/settings/integrations`](https://g1t.sh/settings/integrations) | [Your own integrations](/guides/integrations/#workspace-and-personal): what you have connected for yourself, in every workspace, and what is coming. |
4445 | GitHub | [`/settings/github`](https://g1t.sh/settings/github) | [Linking and unlinking GitHub](/guides/github/#link-and-unlink-github). |
4546 | Connected applications | [`/settings/applications`](https://g1t.sh/settings/applications) | Tools you [signed in to with OAuth](#signing-in-with-oauth), such as an agent using the MCP server. |
4647 | Two-factor authentication | [`/settings/two-factor`](https://g1t.sh/settings/two-factor) | [An authenticator app and recovery codes](#two-factor-authentication). |
+2−1
473473
474474 A status has a source: you, a calendar, or another integration. Calendar
475475 and integration statuses (such as "In a meeting" while one is on your
476−calendar) are coming with those integrations. One you set yourself always
476+calendar) are coming with those integrations, which you will connect under
477+[your integrations](/guides/integrations/#workspace-and-personal). One you set yourself always
477478 wins: a calendar never replaces or clears it. <Soon />
478479
479480 ## Notifications
+79−10
11 ---
22 title: Integrations
3−description: Connect Sentry, Datadog, Jira, Linear and anything that sends a webhook, so problems become issues and agents read the tickets the work refers to.
3+description: Connect the tools your team and you already use. Workspace integrations work for everyone in a workspace; personal ones work just for you, in every workspace. What is available today, what is coming, and how to ask for one.
44 ---
55
6−A workspace connects to the systems its work already lives in. There are
7−three kinds of connection:
6+g1t connects to the tools your work already lives in: where errors are
7+reported, where tickets are tracked, which models your agents think with,
8+where code came from. Every integration is listed in one directory, by
9+category, whether it works today or is still being built.
10+
11+## Workspace and personal
812
9−| Kind | Systems | What it does |
13+There are two places to connect things, and they are for different people:
14+
15+| | Workspace integrations | Your integrations |
1016 | --- | --- | --- |
11−| [Model providers](/guides/models/) | Anthropic, OpenAI, Google Gemini, xAI, Mistral, DeepSeek, Azure OpenAI, OpenRouter, Groq, Together AI, Fireworks AI, Cerebras, and any Anthropic- or OpenAI-compatible endpoint | Your agents' model requests go to your own accounts, routed by kind of work. |
12−| [Alerts](#alerts) | Sentry, Datadog, a signed webhook | A problem opens an issue, once however often it fires, and an agent can start on it at once. |
13−| [Trackers](#trackers) | Jira, Linear | Agents read the tickets that work mentions, people import tickets as issues, and tickets hear back when the work lands. |
17+| Where | The workspace's **Integrations**, at `g1t.sh/<workspace>/-/integrations` | **Your settings → Integrations**, at [`g1t.sh/settings/integrations`](https://g1t.sh/settings/integrations) |
18+| Who it is for | Everyone in that workspace, people and agents | Just you, in every workspace you belong to |
19+| Who connects | Workspace owners. Members see what is connected. | You |
20+| Examples | Model providers, Sentry, Jira, Linear, GitHub mirroring, webhooks | Signing in with GitHub, agents you connect through MCP, and soon your calendar |
1421
15−Open the workspace's **Settings → Integrations**. Every member
16−can see the connections; only owners can add, test or remove them.
22+Some tools have both sides. GitHub, for instance: a workspace owner
23+installs g1t's GitHub App to import and mirror the team's repositories, and
24+you link your own GitHub account to sign in with it. Each card says when
25+the other side exists.
26+
27+## The directory
1728
29+Both pages are the same directory:
30+
31+- **Categories** down the side (along the top on a phone): Code & CI,
32+ Issues & projects, Chat & meetings, Calendar & email, Docs & knowledge,
33+ Monitoring & incidents, Cloud & deploy, AI models, Data & analytics,
34+ CRM & support, Design, Security & identity, and Files & storage. Each
35+ shows how many integrations it has. A link can open on one, with
36+ `?category=ai` and so on.
37+- **Search** finds an integration by name, by what it does ("in a meeting",
38+ "opens issues"), or by a tag, across every category.
39+- **Connected** comes first: what is set up here, with a line about each
40+ (the account, how many connections) and **Needs attention** when
41+ something is wrong, such as a key that stopped working or a GitHub
42+ installation that was suspended. Choose one to manage it.
43+- **Available** can be connected now. Choose one to open its setup page.
44+- **Soon** is being built. Each says what it will do, and **Ask for this**
45+ tells g1t you want it (below).
46+
47+The tags on a card say what an integration lets g1t do: **Agents can
48+read** (agents fetch what work refers to), **Opens issues**, **Writes
49+back** (the other system hears when work lands), **Mirrors repositories**,
50+**Runs models**, **Sets your status**, and so on.
51+
52+## Available today
53+
54+| Category | Workspace | Personal |
55+| --- | --- | --- |
56+| Code & CI | [GitHub](/guides/github/) import and mirroring, [webhooks](/guides/webhooks/) | [Signing in with GitHub](/guides/github/#link-and-unlink-github) |
57+| Issues & projects | [Jira and Linear](#trackers) | |
58+| Monitoring & incidents | [Sentry, Datadog, and any system that sends a webhook](#alerts) | |
59+| AI models | [Model providers](/guides/models/): Anthropic, OpenAI, Google Gemini, xAI, Mistral, DeepSeek, Azure OpenAI, OpenRouter, Groq, Together AI, Fireworks AI, Cerebras, and any Anthropic- or OpenAI-compatible endpoint; the [AI Gateway](/guides/ai-gateway/) | [MCP clients](/guides/authentication/#signing-in-with-oauth): your own agent, connected to g1t as you |
60+
61+Model providers, alerts and trackers each have a setup page under the
62+directory: `-/integrations/models`, `-/integrations/alerts` and
63+`-/integrations/trackers`. GitHub, webhooks and the AI Gateway keep their
64+own pages; the directory links to them.
65+
1866 Secrets are sealed when they are saved and never shown again, to anyone.
19−The page shows the last four characters of a key, so you can tell keys
67+A setup page shows the last four characters of a key, so you can tell keys
2068 apart. Agents never see a connection's secrets.
2169
70+## Coming soon
71+
72+Every category has integrations on the way, among them Slack, Microsoft
73+Teams, Notion, Confluence, PagerDuty, Cloudflare, AWS, Figma, Okta and
74+Google Drive for workspaces. For you: **Google Calendar** and **Outlook
75+Calendar** will set your [status](/guides/chat/#presence-and-status) to *In a meeting* while
76+you are in one, and let agents find a time that suits everyone; **Zoom**
77+and **Google Meet** will set it to *On a call*. A status you set by hand
78+always wins over one an integration sets.
79+
80+The directory lists them all. Nothing on a **Soon** card does anything
81+yet.
82+
83+## Ask for an integration
84+
85+On any **Soon** card, **Ask for this** opens an email to g1t with the
86+integration and your workspace (or "for my own account") filled in. Add
87+what you would use it for and send it. Searching for something that is
88+not listed offers the same, for whatever you searched for. Requests decide
89+what is built next.
90+
2291 ## Alerts
2392
2493 An alert source opens issues in one repository. Each problem it reports is
+4−3
9494
9595 ## Connect a provider
9696
97−1. Open the workspace's **Settings → Integrations**. You need to be an owner.
98−2. Under **Model providers**, choose one, and give its key (and address, for
99− an endpoint).
97+1. Open the workspace's **Integrations** and choose **AI models**, or go
98+ straight to **Model providers** at `g1t.sh/<workspace>/-/integrations/models`.
99+ You need to be an owner.
100+2. Choose a provider, and give its key (and address, for an endpoint).
100101 3. **Connect**. g1t checks the key at once and lists the provider's models.
101102 **Test** checks it again later.
102103
+1−1
561561 | **Guardrails** | Members | What agents may do and spend across the workspace. Owners change them. |
562562 | [**Secrets and variables**](/guides/secrets-and-variables/) | Members | What runs and deployments are given. Owners change them. |
563563 | **Runners** | Owners | The workspace's self-hosted machines, their groups and registration tokens. |
564−| [**Integrations**](/guides/integrations/) | Members | Model providers and connected services. Owners connect and remove them. |
564+| [**Integrations**](/guides/integrations/) | Members | Every tool the workspace connects to, by category: what is connected, what is available, and what is coming. Owners connect and remove them. |
565565 | [**Webhooks**](/guides/webhooks/) | Members | Where the workspace's events are sent. Owners add and change them. |
566566 | **Billing and plans** | Members | [The g1t plan](/guides/usage-and-billing/#the-g1t-plan), [limits](/guides/usage-and-billing/#limits) and the statement. Owners start the plan, check a card, prepay and set limits. |
567567 | **Audit log** | Members | [Every action agents, people and tokens took](/guides/audit-log/). |
+1−1
119119
120120 Agent work on code uses the same plan or trial as step 3 above, plus
121121 sandbox time. To use your own model provider's key, or any compatible
122− endpoint, connect it in your workspace's **Settings → Integrations**.
122+ endpoint, connect it in your workspace's **Integrations**, under **AI models**.
123123 Your provider bills you for the model; g1t charges the agent rate and
124124 the sandbox time. See [model providers](/guides/models/).
125125
+380−0
1+import {
2+ AlertTriangle,
3+ BarChart3,
4+ BookOpen,
5+ Bot,
6+ CalendarDays,
7+ ChevronRight,
8+ Cloud,
9+ Code2,
10+ FolderOpen,
11+ Headset,
12+ LayoutGrid,
13+ ListChecks,
14+ MessagesSquare,
15+ Palette,
16+ Search,
17+ ShieldCheck,
18+ Siren,
19+} from "lucide-react";
20+import { type ReactNode, useState } from "react";
21+import { Link, useSearchParams } from "react-router";
22+
23+import { type ConnectorScope, type ConnectorView, connectorPath } from "@g1t/contracts/connectors";
24+
25+import { CONTACT } from "../lib/legal";
26+import {
27+ type CategoryFilter,
28+ type ConnectedState,
29+ arrange,
30+ askHref,
31+ categoriesWith,
32+ categoryCounts,
33+ categoryFilter,
34+ categoryTitle,
35+ groupByCategory,
36+ monogram,
37+ tileColor,
38+} from "../lib/connectors";
39+import { cn } from "../lib/cn";
40+import { ProviderMark } from "./model-providers";
41+import { Badge } from "./ui/badge";
42+import { Hint } from "./ui/hint";
43+import { Input } from "./ui";
44+
45+const CATEGORY_ICONS: Record<CategoryFilter, ReactNode> = {
46+ all: <LayoutGrid size={15} />,
47+ code: <Code2 size={15} />,
48+ issues: <ListChecks size={15} />,
49+ chat: <MessagesSquare size={15} />,
50+ calendar: <CalendarDays size={15} />,
51+ docs: <BookOpen size={15} />,
52+ monitoring: <Siren size={15} />,
53+ cloud: <Cloud size={15} />,
54+ ai: <Bot size={15} />,
55+ data: <BarChart3 size={15} />,
56+ crm: <Headset size={15} />,
57+ design: <Palette size={15} />,
58+ security: <ShieldCheck size={15} />,
59+ files: <FolderOpen size={15} />,
60+};
61+
62+/** A connector's tile: its provider's mark, or its letters on a steady colour. No logos. */
63+export function ConnectorMark({ view, size = 36 }: { view: Pick<ConnectorView, "id" | "name" | "provider">; size?: number }) {
64+ if (view.provider) return <ProviderMark provider={view.provider} size={size} />;
65+ const color = tileColor(view.id);
66+ const letters = monogram(view.name);
67+ return (
68+ <span
69+ aria-hidden="true"
70+ className="inline-flex shrink-0 items-center justify-center font-semibold tracking-tight"
71+ style={{
72+ width: size,
73+ height: size,
74+ borderRadius: size * 0.28,
75+ fontSize: size * (letters.length > 1 ? 0.34 : 0.42),
76+ background: `color-mix(in srgb, ${color} 16%, transparent)`,
77+ color: `color-mix(in srgb, ${color} 72%, white)`,
78+ boxShadow: `inset 0 0 0 1px color-mix(in srgb, ${color} 30%, transparent)`,
79+ }}
80+ >
81+ {letters}
82+ </span>
83+ );
84+}
85+
86+export type ConnectorDirectoryProps = {
87+ scope: ConnectorScope;
88+ views: ConnectorView[];
89+ /** What is connected here, by connector id. */
90+ connected: Record<string, ConnectedState>;
91+ /** The workspace's slug on its page; null on your own. */
92+ workspace: string | null;
93+ /** Whether the viewer can connect things here (an owner, or anyone for themselves). */
94+ canManage: boolean;
95+ /** Category navigation as a sidebar beside the cards (wide pages), or as chips above them. */
96+ nav?: "sidebar" | "chips";
97+};
98+
99+/**
100+ * The integrations directory: categories to browse, a search, and every
101+ * connector as a card, sorted into Connected, Available and Soon.
102+ */
103+export function ConnectorDirectory({ scope, views, connected, workspace, canManage, nav = "sidebar" }: ConnectorDirectoryProps) {
104+ const [params, setParams] = useSearchParams();
105+ const category = categoryFilter(params.get("category"));
106+ const [query, setQuery] = useState("");
107+ const counts = categoryCounts(views);
108+ const categories = categoriesWith(views);
109+ const found = arrange(views, { category, query, connected: new Set(Object.keys(connected)) });
110+ const searching = query.trim().length > 0;
111+ const grouped = category === "all" && !searching;
112+ const empty = found.connected.length + found.available.length + found.soon.length === 0;
113+
114+ const choose = (next: CategoryFilter) => {
115+ setParams(
116+ (current) => {
117+ const updated = new URLSearchParams(current);
118+ if (next === "all") updated.delete("category");
119+ else updated.set("category", next);
120+ return updated;
121+ },
122+ { replace: true, preventScrollReset: true },
123+ );
124+ setQuery("");
125+ };
126+
127+ const options: { id: CategoryFilter; title: string }[] = [{ id: "all", title: "All" }, ...categories];
128+ const card = (view: ConnectorView) => (
129+ <ConnectorCard
130+ key={view.id}
131+ view={view}
132+ scope={scope}
133+ state={connected[view.id] ?? null}
134+ workspace={workspace}
135+ canManage={canManage}
136+ showCategory={category === "all" || searching}
137+ />
138+ );
139+
140+ return (
141+ <div className={cn(nav === "sidebar" && "md:grid md:grid-cols-[12rem_minmax(0,1fr)] md:gap-8")}>
142+ {/* Categories: a list beside the cards on a wide page, chips that scroll sideways otherwise. */}
143+ <nav aria-label="Categories" className={cn(nav === "sidebar" && "md:sticky md:top-20 md:self-start")}>
144+ <ul
145+ className={cn(
146+ "-mx-4 flex gap-1.5 overflow-x-auto px-4 pb-1 [scrollbar-width:none] sm:mx-0 sm:px-0",
147+ nav === "sidebar" && "md:mx-0 md:flex-col md:gap-px md:overflow-visible md:px-0",
148+ )}
149+ >
150+ {options.map((option) => {
151+ const current = option.id === category;
152+ return (
153+ <li key={option.id} className="shrink-0">
154+ <button
155+ type="button"
156+ onClick={() => choose(option.id)}
157+ aria-current={current ? "page" : undefined}
158+ className={cn(
159+ "flex items-center gap-2 rounded-full border px-3 py-1.5 text-sm whitespace-nowrap transition-colors",
160+ nav === "sidebar" && "md:w-full md:rounded-md md:border-transparent md:px-2.5",
161+ current
162+ ? "border-accent/40 bg-accent/10 text-fg md:bg-raised"
163+ : "border-line text-muted hover:border-line-strong hover:text-fg md:hover:bg-surface",
164+ )}
165+ >
166+ <span className={cn("hidden text-faint", nav === "sidebar" && "md:inline-flex", current && "text-accent")}>
167+ {CATEGORY_ICONS[option.id]}
168+ </span>
169+ <span className="grow text-left">{option.title}</span>
170+ <span className="text-xs text-faint tabular-nums">{counts[option.id]}</span>
171+ </button>
172+ </li>
173+ );
174+ })}
175+ </ul>
176+ </nav>
177+
178+ <div className="mt-5 min-w-0 md:mt-0">
179+ <div className="relative">
180+ <Search size={15} className="pointer-events-none absolute top-1/2 left-3 -translate-y-1/2 text-faint" />
181+ <Input
182+ type="search"
183+ value={query}
184+ onChange={(event) => setQuery(event.target.value)}
185+ placeholder={category === "all" ? "Search integrations" : `Search all integrations, not just ${categoryTitle(category)}`}
186+ aria-label="Search integrations"
187+ className="pl-9"
188+ />
189+ </div>
190+
191+ {empty && (
192+ <p className="mt-8 rounded-xl border border-dashed border-line px-4 py-8 text-center text-sm text-muted">
193+ Nothing matches “{query.trim()}”.{" "}
194+ <a
195+ href={askHref({ connector: { id: "other", name: query.trim() || "something else" }, scope, workspace, address: CONTACT.support })}
196+ className="text-accent hover:underline"
197+ >
198+ Ask for it
199+ </a>
200+ , and say what you would use it for.
201+ </p>
202+ )}
203+
204+ {found.connected.length > 0 && (
205+ <Group title="Connected" count={found.connected.length}>
206+ <Cards>{found.connected.map(card)}</Cards>
207+ </Group>
208+ )}
209+ {found.available.length > 0 && (
210+ <Group
211+ title="Available"
212+ count={found.available.length}
213+ about={
214+ scope === "workspace"
215+ ? canManage
216+ ? "Ready to connect now."
217+ : `Ready now. An owner of ${workspace} connects them.`
218+ : "Ready to connect now."
219+ }
220+ >
221+ <Cards>{found.available.map(card)}</Cards>
222+ </Group>
223+ )}
224+ {found.soon.length > 0 && (
225+ <Group title="Soon" count={found.soon.length} about="Being built. Ask for the ones you need, and they come sooner.">
226+ {grouped ? (
227+ <div className="space-y-6">
228+ {groupByCategory(found.soon).map((group) => (
229+ <div key={group.id}>
230+ <p className="mb-2 flex items-center gap-2 text-xs font-medium tracking-wide text-faint uppercase">
231+ {CATEGORY_ICONS[group.id]}
232+ {group.title}
233+ </p>
234+ <Cards>{group.views.map((view) => <ConnectorCard key={view.id} view={view} scope={scope} state={null} workspace={workspace} canManage={canManage} showCategory={false} />)}</Cards>
235+ </div>
236+ ))}
237+ </div>
238+ ) : (
239+ <Cards>{found.soon.map(card)}</Cards>
240+ )}
241+ </Group>
242+ )}
243+ </div>
244+ </div>
245+ );
246+}
247+
248+function Group({ title, count, about, children }: { title: string; count: number; about?: string; children: ReactNode }) {
249+ return (
250+ <section className="mt-8">
251+ <h2 className="flex items-baseline gap-2 text-sm font-medium">
252+ {title}
253+ <span className="text-xs font-normal text-faint tabular-nums">{count}</span>
254+ </h2>
255+ {about && <p className="mt-0.5 text-xs text-muted">{about}</p>}
256+ <div className="mt-3">{children}</div>
257+ </section>
258+ );
259+}
260+
261+function Cards({ children }: { children: ReactNode }) {
262+ return <ul className="grid gap-3 sm:grid-cols-2">{children}</ul>;
263+}
264+
265+function ConnectorCard({
266+ view,
267+ scope,
268+ state,
269+ workspace,
270+ canManage,
271+ showCategory,
272+}: {
273+ view: ConnectorView;
274+ scope: ConnectorScope;
275+ state: ConnectedState | null;
276+ workspace: string | null;
277+ canManage: boolean;
278+ showCategory: boolean;
279+}) {
280+ const soon = view.status === "soon";
281+ const setup = view.href ? connectorPath(view.href, workspace ?? "") : null;
282+ const to = state?.manage ?? setup;
283+ const also =
284+ view.alsoIn === "personal"
285+ ? "Each person can also connect their own, under their settings."
286+ : view.alsoIn === "workspace"
287+ ? "A workspace owner can also connect it for everyone."
288+ : null;
289+ return (
290+ <li
291+ className={cn(
292+ "relative flex flex-col rounded-xl border p-4 transition-colors",
293+ soon ? "border-line/70 bg-surface/40" : "border-line bg-surface",
294+ !soon && to && "hover:border-line-strong",
295+ )}
296+ >
297+ <div className="flex items-start gap-3">
298+ <span className={cn(soon && "opacity-70 grayscale-[35%]")}>
299+ <ConnectorMark view={view} />
300+ </span>
301+ <div className="min-w-0 grow">
302+ <div className="flex items-center gap-2">
303+ {!soon && to ? (
304+ // The whole card opens it: the link stretches over the card.
305+ <Link to={to} className="truncate text-sm font-medium after:absolute after:inset-0 after:rounded-xl focus-visible:outline-none">
306+ {view.name}
307+ </Link>
308+ ) : (
309+ <p className={cn("truncate text-sm font-medium", soon && "text-fg/80")}>{view.name}</p>
310+ )}
311+ {state ? (
312+ state.problem ? (
313+ <Hint label={state.problem}>
314+ <Badge tone="warn" className="relative z-10" tabIndex={0}>
315+ <AlertTriangle size={11} /> Needs attention
316+ </Badge>
317+ </Hint>
318+ ) : (
319+ <Badge tone="success">Connected</Badge>
320+ )
321+ ) : soon ? (
322+ <Badge>Soon</Badge>
323+ ) : null}
324+ </div>
325+ {showCategory && <p className="text-xs text-faint">{categoryTitle(view.category)}</p>}
326+ </div>
327+ </div>
328+ <p className={cn("mt-2.5 text-sm", soon ? "text-muted/90" : "text-muted")}>{view.description}</p>
329+ {state?.detail && <p className="mt-1.5 truncate text-xs text-faint">{state.detail}</p>}
330+ {view.capabilities.length > 0 && (
331+ <ul className="mt-3 flex flex-wrap gap-1.5">
332+ {view.capabilities.map((capability) => (
333+ <li key={capability} className="rounded-md bg-raised px-1.5 py-0.5 text-[0.6875rem] text-muted">
334+ {capability}
335+ </li>
336+ ))}
337+ </ul>
338+ )}
339+ <div className="mt-auto flex items-end justify-between gap-3 pt-3">
340+ <p className="text-[0.6875rem] text-faint">{also}</p>
341+ {soon ? (
342+ <a
343+ href={askHref({ connector: view, scope, workspace, address: CONTACT.support })}
344+ className="relative z-10 shrink-0 rounded-md border border-line px-2.5 py-1 text-xs font-medium text-fg/80 hover:border-line-strong hover:bg-raised hover:text-fg"
345+ >
346+ Ask for this
347+ </a>
348+ ) : to ? (
349+ <span className="flex shrink-0 items-center gap-0.5 text-xs font-medium text-accent">
350+ {state ? (canManage ? "Manage" : "View") : canManage ? "Set up" : "View"}
351+ <ChevronRight size={13} />
352+ </span>
353+ ) : null}
354+ </div>
355+ </li>
356+ );
357+}
358+
359+/** A short line saying who a page's connections are for, and where the other kind lives. */
360+export function ScopeNote({ scope, workspace }: { scope: ConnectorScope; workspace: string | null }) {
361+ return (
362+ <p className="mb-6 rounded-xl border border-line bg-surface/60 px-4 py-3 text-sm text-muted">
363+ {scope === "workspace" ? (
364+ <>
365+ Connected here, a tool works <span className="text-fg">for everyone in {workspace}</span>: its people and its agents.
366+ Your own accounts, such as your calendar, are under{" "}
367+ <Link to="/settings/integrations" className="text-accent hover:underline">
368+ your integrations
369+ </Link>
370+ .
371+ </>
372+ ) : (
373+ <>
374+ Connected here, a tool works <span className="text-fg">just for you, in every workspace</span> you belong to. Tools
375+ the whole team shares are connected by a workspace owner, under the workspace's Integrations.
376+ </>
377+ )}
378+ </p>
379+ );
380+}
+3−3
777777 about: trial.granted
778778 ? `This workspace has ${dollars(trial.limitMicros - trial.usedMicros)} of its ${dollars(trial.limitMicros)} trial credit left, for g1t's models and sandboxes.`
779779 : `This workspace gets ${dollars(trial.limitMicros)} of trial credit, for g1t's models and sandboxes, the first time g1t works here.`,
780− to: workspace ? `/${workspace}/-/integrations` : null,
780+ to: workspace ? `/${workspace}/-/integrations/models` : null,
781781 action: "Connect",
782782 }
783783 : canRunAgents
795795 about: `${
796796 trialClosed(trial, workspace ?? "This workspace") ? `${trialClosed(trial, workspace ?? "This workspace")} ` : ""
797797 }Agents need a model to think with. Connect your Anthropic or OpenAI key, or any compatible endpoint.`,
798− to: workspace ? `/${workspace}/-/integrations` : null,
798+ to: workspace ? `/${workspace}/-/integrations/models` : null,
799799 action: "Connect",
800800 },
801801 {
832832 <>
833833 Agents need a model first.{" "}
834834 {workspace && (
835− <Link to={`/${workspace}/-/integrations`} className="font-medium text-fg hover:underline">
835+ <Link to={`/${workspace}/-/integrations/models`} className="font-medium text-fg hover:underline">
836836 Connect one
837837 </Link>
838838 )}
+2−1
99
1010 import { type Connection, MODEL_TASKS, MODEL_TIERS, type ModelRoute, type ModelTask, type ModelTier, type Provider, PROVIDERS, gatewayPatterns } from "@g1t/contracts";
1111
12+import { integrationsSection } from "../lib/integration-sections";
1213 import { Field, Input, SubmitButton } from "./ui";
1314 import { Hint } from "./ui/hint";
1415 import { Select, SelectContent, SelectGroup, SelectItem, SelectLabel, SelectSeparator, SelectTrigger, SelectValue } from "./ui/select";
208209 {providers.map((provider) => (
209210 <Link
210211 key={provider}
211− to={`/${slug}/-/integrations?add=${provider}#add`}
212+ to={`/${slug}/-/integrations/${integrationsSection(PROVIDERS[provider].kind)}?add=${provider}#add`}
212213 preventScrollReset
213214 className={`group flex items-center gap-3 rounded-xl border px-3 py-2.5 transition-colors ${
214215 adding === provider ? "border-accent-dim bg-surface" : "border-line hover:border-line-strong hover:bg-surface"
+2−1
1−import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, Inbox, KanbanSquare, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Scale, Smile, Sparkles, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react";
1+import { Activity, BarChart3, Building2, MessagesSquare, Bell, BookMarked, BookOpen, Blocks, Bot, Box, Brain, Check, ChevronDown, ChevronLeft, ChevronRight, ChevronsUpDown, CircleDot, GripVertical, CircleUserRound, Code2, Compass, CreditCard, Fingerprint, GanttChart, Gauge, GitBranch, GitPullRequest, Globe, History, House, Inbox, KanbanSquare, KeyRound, Layers, LayoutDashboard, LayoutGrid, LifeBuoy, ListTree, Lock, LogIn, LogOut, Mail, Menu, Network, Package, PlayCircle, Plug, Plus, Rocket, Search, ServerCog, Settings, ShieldCheck, Scale, Smile, Sparkles, Ticket, TrendingUp, UserRoundKey, Users, UsersRound, Webhook, X } from "lucide-react";
22 import { type ReactNode, useEffect, useMemo, useRef, useState } from "react";
33 import { Link, NavLink, useFetcher, useLocation, useNavigation, useRouteLoaderData, useSubmit } from "react-router";
44
12351235 </div>
12361236 <Rule />
12371237 <div className="space-y-px">
1238+ {link("integrations", <Blocks size={15} />)}
12381239 {link("github", <GithubMark className="size-[15px]" />)}
12391240 {link("applications", <Plug size={15} />)}
12401241 </div>
+7−0
1010 | "invites"
1111 | "keys"
1212 | "tokens"
13+ | "integrations"
1314 | "github"
1415 | "applications"
1516 | "two-factor"
4142 about:
4243 "Use a token as the password when git asks for one over HTTPS, and to authenticate agents and the API. A token here acts as you.",
4344 },
45+ integrations: {
46+ title: "Integrations",
47+ heading: "Your integrations",
48+ about:
49+ "Your own accounts, connected just for you and working in every workspace you belong to, such as your calendar setting your status. Tools the whole team shares are a workspace's.",
50+ },
4451 github: {
4552 title: "GitHub",
4653 about: "Sign in with your GitHub account, and import or mirror repositories you can reach there.",
+86−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { connectorsFor } from "@g1t/contracts/connectors";
5+
6+import {
7+ arrange,
8+ askHref,
9+ categoriesWith,
10+ categoryCounts,
11+ categoryFilter,
12+ groupByCategory,
13+ matchesQuery,
14+ monogram,
15+ tileColor,
16+} from "./connectors.ts";
17+
18+const workspace = connectorsFor("workspace");
19+const personal = connectorsFor("personal");
20+const none = new Set<string>();
21+
22+test("the two contexts list different connectors", () => {
23+ const ids = (views: typeof workspace) => new Set(views.map((view) => view.id));
24+ assert.ok(ids(personal).has("google-calendar"));
25+ assert.ok(!ids(workspace).has("google-calendar"), "a calendar is your own");
26+ assert.ok(ids(workspace).has("sentry"));
27+ assert.ok(!ids(personal).has("sentry"), "alerts are the workspace's");
28+ assert.ok(ids(workspace).has("github") && ids(personal).has("github"), "GitHub is both");
29+});
30+
31+test("a category shows only its own connectors, sorted into connected, available and soon", () => {
32+ const found = arrange(workspace, { category: "monitoring", query: "", connected: new Set(["sentry"]) });
33+ assert.deepEqual(found.connected.map((view) => view.id), ["sentry"]);
34+ assert.ok(found.available.some((view) => view.id === "datadog"));
35+ assert.ok(!found.available.some((view) => view.id === "sentry"), "connected is not also available");
36+ assert.ok(found.soon.some((view) => view.id === "pagerduty"));
37+ for (const view of [...found.connected, ...found.available, ...found.soon]) assert.equal(view.category, "monitoring");
38+});
39+
40+test("all shows everything once", () => {
41+ const found = arrange(workspace, { category: "all", query: "", connected: none });
42+ assert.equal(found.available.length + found.soon.length, workspace.length);
43+});
44+
45+test("search finds by name, description, tag and keyword, across every category", () => {
46+ const found = arrange(workspace, { category: "design", query: "sentry", connected: none });
47+ assert.deepEqual(found.available.map((view) => view.id), ["sentry"], "a search is not held to the category");
48+ assert.ok(matchesQuery(personal.find((view) => view.id === "google-calendar")!, "in a meeting"));
49+ assert.ok(matchesQuery(workspace.find((view) => view.id === "anthropic")!, "claude"));
50+ assert.ok(matchesQuery(workspace.find((view) => view.id === "incident-io")!, "incidentio"));
51+ assert.ok(matchesQuery(workspace.find((view) => view.id === "pagerduty")!, "opens ISSUES"));
52+ assert.ok(!matchesQuery(workspace.find((view) => view.id === "figma")!, "kubernetes"));
53+});
54+
55+test("the navigation counts each category and leaves out empty ones", () => {
56+ const counts = categoryCounts(workspace);
57+ assert.equal(counts.all, workspace.length);
58+ assert.ok(counts.ai >= 14);
59+ const mine = categoriesWith(personal).map((category) => category.id);
60+ assert.ok(mine.includes("calendar"));
61+ const groups = groupByCategory(personal);
62+ assert.equal(groups.reduce((sum, group) => sum + group.views.length, 0), personal.length);
63+});
64+
65+test("an unknown category is all of them", () => {
66+ assert.equal(categoryFilter("ai"), "ai");
67+ assert.equal(categoryFilter("nope"), "all");
68+ assert.equal(categoryFilter(null), "all");
69+});
70+
71+test("a tile has the connector's letters and a steady colour", () => {
72+ assert.equal(monogram("PagerDuty"), "PD");
73+ assert.equal(monogram("Google Calendar"), "GC");
74+ assert.equal(monogram("Sentry"), "S");
75+ assert.equal(monogram("incident.io"), "II");
76+ assert.equal(monogram("1Password"), "1P");
77+ assert.equal(tileColor("figma"), tileColor("figma"));
78+});
79+
80+test("asking for a connector names it and who it is for", () => {
81+ const href = askHref({ connector: { id: "slack", name: "Slack" }, scope: "workspace", workspace: "acme", address: "hey@example.com" });
82+ assert.ok(href.startsWith("mailto:hey@example.com?subject=Integration%20request%3A%20Slack"));
83+ assert.ok(decodeURIComponent(href).includes("For the workspace: acme"));
84+ const mine = askHref({ connector: { id: "google-calendar", name: "Google Calendar" }, scope: "personal", workspace: null, address: "hey@example.com" });
85+ assert.ok(decodeURIComponent(mine).includes("For my own account."));
86+});
+143−0
1+/**
2+ * The integrations directory: the connector catalog (@g1t/contracts/connectors)
3+ * filtered, searched and sorted into what a workspace, or a person, has
4+ * connected, can connect now, and will be able to soon.
5+ */
6+
7+import {
8+ CONNECTOR_CATEGORIES,
9+ type ConnectorCategory,
10+ type ConnectorScope,
11+ type ConnectorView,
12+} from "@g1t/contracts/connectors";
13+
14+/** "all", or one category: what the directory's navigation selects. */
15+export type CategoryFilter = ConnectorCategory | "all";
16+
17+/** What is connected, by connector id: how it is doing, for its card. */
18+export type ConnectedState = {
19+ /** A line about it: an account, how many connections. */
20+ detail: string | null;
21+ /** Something is wrong with it, said plainly. */
22+ problem: string | null;
23+ /** Where it is managed, when that is not its setup page. */
24+ manage: string | null;
25+};
26+
27+export type Directory = {
28+ connected: ConnectorView[];
29+ available: ConnectorView[];
30+ soon: ConnectorView[];
31+};
32+
33+/** The category a `?category=` asks for, or all of them. */
34+export function categoryFilter(value: string | null | undefined): CategoryFilter {
35+ return CONNECTOR_CATEGORIES.some((category) => category.id === value) ? (value as ConnectorCategory) : "all";
36+}
37+
38+/** A category's title. */
39+export function categoryTitle(id: ConnectorCategory): string {
40+ return CONNECTOR_CATEGORIES.find((category) => category.id === id)?.title ?? id;
41+}
42+
43+/** Lowercased, without accents or punctuation, for matching. */
44+function fold(text: string): string {
45+ return text
46+ .normalize("NFKD")
47+ .replace(/[̀-ͯ]/g, "")
48+ .toLowerCase()
49+ .replace(/[^a-z0-9]+/g, " ")
50+ .trim();
51+}
52+
53+/** Whether a connector answers a search: every word in its name, description, category, tags or keywords. */
54+export function matchesQuery(view: ConnectorView, query: string): boolean {
55+ const words = fold(query).split(" ").filter(Boolean);
56+ if (words.length === 0) return true;
57+ const haystack = fold([view.name, view.description, categoryTitle(view.category), ...view.capabilities, ...view.keywords].join(" "));
58+ const compact = haystack.replace(/ /g, "");
59+ return words.every((word) => haystack.includes(word) || compact.includes(word));
60+}
61+
62+/**
63+ * The directory for one category and search: what is connected first, then
64+ * what can be connected now, then what is coming. Search spans every
65+ * category, so a match in another one is never hidden.
66+ */
67+export function arrange(
68+ views: ConnectorView[],
69+ { category, query, connected }: { category: CategoryFilter; query: string; connected: ReadonlySet<string> },
70+): Directory {
71+ const searching = query.trim().length > 0;
72+ const shown = views.filter(
73+ (view) => (searching || category === "all" || view.category === category) && matchesQuery(view, query),
74+ );
75+ return {
76+ connected: shown.filter((view) => connected.has(view.id)),
77+ available: shown.filter((view) => view.status === "available" && !connected.has(view.id)),
78+ soon: shown.filter((view) => view.status === "soon" && !connected.has(view.id)),
79+ };
80+}
81+
82+/** How many connectors each category has, for its row in the navigation; `all` is every one. */
83+export function categoryCounts(views: ConnectorView[]): Record<CategoryFilter, number> {
84+ const counts = { all: views.length } as Record<CategoryFilter, number>;
85+ for (const category of CONNECTOR_CATEGORIES) counts[category.id] = 0;
86+ for (const view of views) counts[view.category] += 1;
87+ return counts;
88+}
89+
90+/** The categories with something in them for `views`, in catalog order. */
91+export function categoriesWith(views: ConnectorView[]): { id: ConnectorCategory; title: string }[] {
92+ const present = new Set(views.map((view) => view.category));
93+ return CONNECTOR_CATEGORIES.filter((category) => present.has(category.id));
94+}
95+
96+/** Connectors grouped under their categories, in catalog order, leaving out empty ones. */
97+export function groupByCategory(views: ConnectorView[]): { id: ConnectorCategory; title: string; views: ConnectorView[] }[] {
98+ return CONNECTOR_CATEGORIES.map((category) => ({
99+ ...category,
100+ views: views.filter((view) => view.category === category.id),
101+ })).filter((group) => group.views.length > 0);
102+}
103+
104+/** The letters on a connector's tile: two words' initials, or a name's capitals ("PagerDuty" → "PD"). */
105+export function monogram(name: string): string {
106+ const words = name.split(/[\s.\-/]+/).filter((word) => /[A-Za-z0-9]/.test(word));
107+ if (words.length >= 2) return (words[0]![0]! + words[1]![0]!).toUpperCase();
108+ const word = words[0] ?? name;
109+ const capitals = word.slice(1).match(/[A-Z0-9]/);
110+ return capitals ? (word[0]! + capitals[0]).toUpperCase() : word.slice(0, 1).toUpperCase();
111+}
112+
113+/** A tile's colour, always the same for the same connector. */
114+const PALETTE = ["#8b8ff0", "#5b8def", "#5eead4", "#4ade80", "#facc15", "#fb923c", "#f87171", "#f472b6", "#c084fc", "#a1a1aa", "#38bdf8", "#a3e635"];
115+
116+export function tileColor(id: string): string {
117+ let hash = 0;
118+ for (const char of id) hash = (hash * 31 + char.charCodeAt(0)) >>> 0;
119+ return PALETTE[hash % PALETTE.length]!;
120+}
121+
122+/** Where a request for a connector goes: an email to g1t, filled in. */
123+export function askHref({
124+ connector,
125+ scope,
126+ workspace,
127+ address,
128+}: {
129+ connector: Pick<ConnectorView, "id" | "name">;
130+ scope: ConnectorScope;
131+ workspace: string | null;
132+ address: string;
133+}): string {
134+ const subject = `Integration request: ${connector.name}`;
135+ const body = [
136+ `I would like g1t to connect to ${connector.name} (${connector.id}).`,
137+ scope === "workspace" && workspace ? `For the workspace: ${workspace}` : "For my own account.",
138+ "",
139+ "What we would use it for:",
140+ "",
141+ ].join("\n");
142+ return `mailto:${address}?subject=${encodeURIComponent(subject)}&body=${encodeURIComponent(body)}`;
143+}
+25−0
1+import type { ProviderKind } from "@g1t/contracts";
2+
3+/**
4+ * The workspace's integration setup pages, under `-/integrations/<section>`:
5+ * one for each kind of provider connection. The directory at
6+ * `-/integrations` links into them.
7+ */
8+export type IntegrationsSection = "models" | "alerts" | "trackers";
9+
10+const SECTIONS: Record<ProviderKind, IntegrationsSection> = {
11+ models: "models",
12+ alerts: "alerts",
13+ tracker: "trackers",
14+};
15+
16+/** The page a kind of provider is set up on. */
17+export function integrationsSection(kind: ProviderKind): IntegrationsSection {
18+ return SECTIONS[kind];
19+}
20+
21+/** The kind of provider a section's page is for, or null when it is no section. */
22+export function sectionKind(section: string | undefined): ProviderKind | null {
23+ const found = (Object.keys(SECTIONS) as ProviderKind[]).find((kind) => SECTIONS[kind] === section);
24+ return found ?? null;
25+}
+5−1
4141 route("tokens/:id", "routes/settings/token.tsx"),
4242 route("github", "routes/settings/github.tsx"),
4343 route("applications", "routes/settings/applications.tsx"),
44+ route("integrations", "routes/settings/integrations.tsx"),
4445 route("two-factor", "routes/settings/two-factor.tsx"),
4546 route("security-log", "routes/settings/security-log.tsx"),
4647 route("account", "routes/settings/account.tsx"),
118119 route("-/billing", "routes/workspace/billing.tsx"),
119120 route("-/billing/entries", "routes/workspace/statement-entries.ts"),
120121 route("-/billing/statement.csv", "routes/workspace/statement-csv.ts"),
121− route("-/integrations", "routes/workspace/integrations.tsx"),
122+ // The integrations directory, and the setup page for each kind of
123+ // provider connection: models, alerts, trackers (lib/integration-sections).
124+ route("-/integrations", "routes/workspace/integrations-directory.tsx"),
125+ route("-/integrations/:section", "routes/workspace/integrations.tsx"),
122126 route("-/webhooks", "routes/workspace/webhooks.tsx"),
123127 // The workspace's own emoji, for chat (components/emoji).
124128 route("-/emoji", "routes/workspace/emoji.tsx"),
+1−1
587587 through checks, review and fixes. Agents run on a paid workspace, or on the free trial.
588588 </p>
589589 <Link
590− to={`/${params.owner}/-/integrations`}
590+ to={`/${params.owner}/-/integrations/models`}
591591 className="mt-2 inline-block text-xs text-accent hover:underline"
592592 >
593593 Connect a model
+75−0
1+import { Link } from "react-router";
2+
3+import { connectorsFor } from "@g1t/contracts/connectors";
4+
5+import type { Route } from "./+types/integrations";
6+import { ConnectorDirectory } from "../../components/connectors";
7+import type { ConnectedState } from "../../lib/connectors";
8+import { githubSignIn } from "../../lib/github.server";
9+import { page } from "../../lib/meta";
10+import { identity } from "../../lib/services.server";
11+import { requireUser } from "../../lib/session.server";
12+
13+export function meta(args: Route.MetaArgs) {
14+ return page(args, { title: "Integrations · Settings · g1t" });
15+}
16+
17+/**
18+ * Your own integrations: the connector directory's personal side, with
19+ * what you have connected (GitHub sign-in, applications on the MCP server).
20+ * Each is still set up on its own settings page.
21+ */
22+export async function loader({ request, context }: Route.LoaderArgs) {
23+ const user = requireUser(context, request);
24+ const [github, grants] = await Promise.all([
25+ githubSignIn.account(user).catch(() => null),
26+ identity.listOAuthGrants(user).catch(() => []),
27+ ]);
28+ const connected: Record<string, ConnectedState> = {};
29+ if (github?.account) {
30+ connected.github = {
31+ detail: `Linked to @${github.account.login}`,
32+ problem: github.account.authorized ? null : "Access ended: link it again to import repositories.",
33+ manage: null,
34+ };
35+ }
36+ if (grants.length > 0) {
37+ connected.mcp = {
38+ detail: grants.length === 1 ? grants[0]!.clientName : `${grants.length} applications`,
39+ problem: null,
40+ manage: null,
41+ };
42+ }
43+ const workspaces = (user.workspaces ?? []).map((membership) => ({ slug: membership.slug, name: membership.name ?? membership.slug }));
44+ return { connected, workspaces };
45+}
46+
47+export default function PersonalIntegrations({ loaderData }: Route.ComponentProps) {
48+ const { connected, workspaces } = loaderData;
49+ return (
50+ <div>
51+ <ConnectorDirectory
52+ scope="personal"
53+ views={connectorsFor("personal")}
54+ connected={connected}
55+ workspace={null}
56+ canManage
57+ nav="chips"
58+ />
59+ {workspaces.length > 0 && (
60+ <p className="mt-10 border-t border-line pt-5 text-sm text-muted">
61+ Tools the whole team shares are connected for each workspace:{" "}
62+ {workspaces.map((workspace, index) => (
63+ <span key={workspace.slug}>
64+ {index > 0 && ", "}
65+ <Link to={`/${workspace.slug}/-/integrations`} className="text-accent hover:underline">
66+ {workspace.name}
67+ </Link>
68+ </span>
69+ ))}
70+ .
71+ </p>
72+ )}
73+ </div>
74+ );
75+}
+92−0
1+import { redirect } from "react-router";
2+
3+import { type Provider, PROVIDERS } from "@g1t/contracts";
4+import { CONNECTORS, connectorsFor } from "@g1t/contracts/connectors";
5+
6+import type { Route } from "./+types/integrations-directory";
7+import { ConnectorDirectory, ScopeNote } from "../../components/connectors";
8+import type { ConnectedState } from "../../lib/connectors";
9+import { githubApp } from "../../lib/github.server";
10+import { integrationsSection } from "../../lib/integration-sections";
11+import { page } from "../../lib/meta";
12+import { integrations, webhooks } from "../../lib/services.server";
13+import { getViewer, roleIn } from "../../lib/session.server";
14+
15+export function meta({ params, ...args }: Route.MetaArgs) {
16+ return page(args, { title: `Integrations · ${params.owner} · g1t` });
17+}
18+
19+/**
20+ * The workspace's integrations: every connector g1t has or will have, by
21+ * category, with what this workspace has connected first. Each one is set
22+ * up on its own page (the model providers, alerts and trackers pages, the
23+ * GitHub import, webhooks); this page only finds them.
24+ */
25+export async function loader({ params, context, request }: Route.LoaderArgs) {
26+ const viewer = getViewer(context);
27+ const role = roleIn(viewer, params.owner);
28+ if (!role || !viewer) throw new Response(null, { status: 404 });
29+ const slug = params.owner.toLowerCase();
30+
31+ // An older link to one provider's form (`?add=sentry`): its own page now.
32+ const add = new URL(request.url).searchParams.get("add");
33+ if (add && add in PROVIDERS) {
34+ throw redirect(`/${slug}/-/integrations/${integrationsSection(PROVIDERS[add as Provider].kind)}?add=${add}#add`);
35+ }
36+
37+ const [connections, github, hooks] = await Promise.all([
38+ integrations.list(slug, viewer),
39+ githubApp.status(viewer, slug).catch(() => null),
40+ webhooks.list(viewer, { workspace: slug }).catch(() => null),
41+ ]);
42+
43+ const connected: Record<string, ConnectedState> = {};
44+ if (connections.ok) {
45+ for (const connector of CONNECTORS) {
46+ if (!connector.provider) continue;
47+ const mine = connections.value.filter((connection) => connection.provider === connector.provider);
48+ if (mine.length === 0) continue;
49+ const broken = mine.find((connection) => connection.lastError);
50+ connected[connector.id] = {
51+ detail: mine.length === 1 ? mine[0]!.name : `${mine.length} connections: ${mine.map((c) => c.name).join(", ")}`,
52+ problem: broken ? `${broken.name}: ${broken.lastError}` : null,
53+ manage: `/${slug}/-/integrations/${integrationsSection(PROVIDERS[connector.provider].kind)}`,
54+ };
55+ }
56+ }
57+ if (github?.ok && github.value.installations.length > 0) {
58+ const suspended = github.value.installations.find((installation) => installation.suspended);
59+ connected.github = {
60+ detail: `On ${github.value.installations.map((installation) => installation.account).join(", ")}`,
61+ problem: suspended ? `The installation on ${suspended.account} is suspended on GitHub.` : null,
62+ manage: null,
63+ };
64+ }
65+ const ours = hooks?.ok ? hooks.value.filter((hook) => hook.scope === "workspace") : [];
66+ if (ours.length > 0) {
67+ const failing = ours.filter((hook) => hook.lastStatus === "failed");
68+ connected.webhooks = {
69+ detail: ours.length === 1 ? ours[0]!.url : `${ours.length} webhooks`,
70+ problem: failing.length > 0 ? `The last delivery to ${failing[0]!.url} failed.` : null,
71+ manage: null,
72+ };
73+ }
74+
75+ return { slug, owner: role === "owner", connected };
76+}
77+
78+export default function WorkspaceIntegrationsDirectory({ loaderData }: Route.ComponentProps) {
79+ const { slug, owner, connected } = loaderData;
80+ return (
81+ <div>
82+ <ScopeNote scope="workspace" workspace={slug} />
83+ <ConnectorDirectory
84+ scope="workspace"
85+ views={connectorsFor("workspace")}
86+ connected={connected}
87+ workspace={slug}
88+ canManage={owner}
89+ />
90+ </div>
91+ );
92+}
+30−10
11 import {
22 AlertTriangle,
3+ ArrowLeft,
34 CheckCircle2,
45 ChevronRight,
56 Cpu,
2829 import { trialClosed } from "../../lib/trial";
2930 import { GatewayModelsField, MODEL_CATALOG, ModelCatalog, ModelProviderFields, ProviderMark, ProviderTiles, Routing } from "../../components/model-providers";
3031 import { parseGatewayModels } from "../../lib/gateway";
32+import { integrationsSection, sectionKind } from "../../lib/integration-sections";
3133 import { Avatar, CopyLine, ErrorText, Field, Input, SubmitButton, TimeAgo } from "../../components/ui";
3234 import { CheckboxOption } from "../../components/ui/checkbox";
3335 import { Combobox } from "../../components/ui/combobox";
3537 import { assertSameOrigin, getViewer, requireUser, roleIn, unwrap } from "../../lib/session.server";
3638
3739 export function meta({ params, ...args }: Route.MetaArgs) {
38− return page(args, { title: `Integrations · ${params.owner} · g1t` });
40+ const kind = sectionKind(params.section);
41+ return page(args, { title: `${kind ? KIND_INFO[kind].title : "Integrations"} · ${params.owner} · g1t` });
3942 }
4043
4144 const isProvider = (value: string | null): value is Provider => value != null && value in PROVIDERS;
4346 export async function loader({ params, context, request }: Route.LoaderArgs) {
4447 const viewer = getViewer(context);
4548 const role = roleIn(viewer, params.owner);
46− if (!role) throw new Response(null, { status: 404 });
49+ // One kind of connection's page: the directory at `-/integrations` finds them.
50+ const kind = sectionKind(params.section);
51+ if (!role || !kind) throw new Response(null, { status: 404 });
4752 const slug = params.owner.toLowerCase();
4853 const [connections, listed, account, access, routes] = await Promise.all([
4954 integrations.list(slug, viewer),
6772 return {
6873 slug,
6974 role,
75+ kind,
7076 connections: all,
7177 deliveries,
7278 repos: listed.map((repo) => `${repo.namespace}/${repo.name}`),
197203 }
198204
199205 export default function WorkspaceIntegrations({ loaderData, actionData }: Route.ComponentProps) {
200− const { slug, role, connections, deliveries, repos: repoNames, adding, free, marginPercent, hostedOpen, hostedPreview, trial, routes } =
206+ const { slug, role, kind, connections, deliveries, repos: repoNames, adding, free, marginPercent, hostedOpen, hostedPreview, trial, routes } =
201207 loaderData;
202208 const owner = role === "owner";
203209 const modelConnections = connections.filter((connection) => connection.kind === "models");
208214
209215 return (
210216 <div>
217+ <Link to={`/${slug}/-/integrations`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
218+ <ArrowLeft size={14} /> All integrations
219+ </Link>
211220 {justConnected && <Connected connected={justConnected} />}
212221 <div className="mt-4">
213222 <ErrorText>{error}</ErrorText>
214223 </div>
215224
216225 {/* Models -------------------------------------------------------------- */}
217− <Section kind="models" first>
226+ {kind === "models" && (
227+ <Section kind="models">
218228 {!hostedOpen && modelConnections.length === 0 && (
219229 <div className="mb-4 flex items-center gap-3 rounded-xl border border-warn/30 bg-warn/5 p-4">
220230 <span className="inline-flex size-8 shrink-0 items-center justify-center rounded-lg bg-warn/10 text-warn ring-1 ring-warn/30">
271281 />
272282 )}
273283 </Section>
284+ )}
274285
275286 {/* Alerts -------------------------------------------------------------- */}
287+ {kind === "alerts" && (
276288 <Section kind="alerts">
277289 <Connections
278290 list={connections.filter((c) => c.kind === "alerts")}
291303 />
292304 )}
293305 </Section>
306+ )}
294307
295308 {/* Trackers ------------------------------------------------------------ */}
309+ {kind === "tracker" && (
296310 <Section kind="tracker">
297311 <Connections
298312 list={connections.filter((c) => c.kind === "tracker")}
311325 />
312326 )}
313327 </Section>
328+ )}
314329
315330 {!owner && <p className="mt-8 text-sm text-muted">An owner of {slug} can add and remove integrations.</p>}
316331 </div>
317332 );
318333 }
319334
320−function Section({ kind, first, children }: { kind: ProviderKind; first?: boolean; children: ReactNode }) {
335+/** One kind of connection's page: its heading, then what is connected and what to connect. */
336+function Section({ kind, children }: { kind: ProviderKind; children: ReactNode }) {
321337 const info = KIND_INFO[kind];
322338 return (
323− <section className={first ? "" : "mt-12"}>
324− <h3 className="flex items-center gap-2 text-sm font-medium">
339+ <section className="mt-4">
340+ <h1 className="flex items-center gap-2 text-2xl font-semibold tracking-tight [&_svg]:size-5">
325341 <span className="text-muted">{info.icon}</span>
326342 {info.title}
327− </h3>
328− <p className="mt-1 mb-4 text-sm text-muted">{info.blurb}</p>
343+ </h1>
344+ <p className="mt-1.5 mb-6 border-b border-line pb-6 text-sm text-muted">{info.blurb}</p>
329345 {children}
330346 </section>
331347 );
651667 </h3>
652668 <p className="text-xs text-muted">{blurb(provider)}</p>
653669 </div>
654− <Link to={`/${slug}/-/integrations`} className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg" aria-label="Close">
670+ <Link
671+ to={`/${slug}/-/integrations/${integrationsSection(PROVIDERS[provider].kind)}`}
672+ className="rounded-md p-1.5 text-faint hover:bg-raised hover:text-fg"
673+ aria-label="Close"
674+ >
655675 <X size={16} />
656676 </Link>
657677 </div>
+2−1
121121 },
122122 integrations: {
123123 title: "Integrations",
124− about: "Model providers, alerts and trackers. Secrets are sealed when saved, and agents never see them.",
124+ about:
125+ "The tools your team works with, connected once for everyone in the workspace and its agents. Secrets are sealed when saved, and agents never see them.",
125126 },
126127 };
127128
+23−0
12271227 when it is there: toasts while the window is in front, native
12281228 notifications while it is not, and no Web Push.
12291229
1230+## Integrations directory
1231+
1232+Built 2026-10-09. One catalog, `packages/contracts/src/connectors.ts`
1233+(`@g1t/contracts/connectors`), lists every connector: id, name, category,
1234+one-line description, `scopes` (`workspace`, `personal`, or both, with a
1235+`personal` override for what differs), `status` (`available` or `soon`),
1236+`href` per scope for available ones (`:workspace` is filled in), capability
1237+tags, and the integration `provider` behind it. Adding a connector, or
1238+moving one from soon to available, is an edit there plus its setup page.
1239+
1240+Two pages draw from it with the same component (`components/connectors.tsx`):
1241+the workspace's `-/integrations` ("for everyone in <workspace>", owners
1242+manage) and `/settings/integrations` ("just for you, in every workspace").
1243+The workspace's setup pages for providers moved to
1244+`-/integrations/{models,alerts,trackers}`. Connected state comes from
1245+integrations (connections and their `last_error`), the GitHub App's
1246+installations, workspace webhooks, GitHub sign-in and OAuth grants.
1247+
1248+**Ask for this** on a Soon card is a prefilled email to support for now; a
1249+stored request (workspace, connector, person) can replace it without
1250+changing the cards. The Calendar connectors will set presence with
1251+`source: "calendar"` (Presence and status, above).
1252+
12301253 ## Services
12311254
12321255 Following the architecture principles: separate services, interfaces in
+1−1
44 "private": true,
55 "type": "module",
66 "license": "MIT",
7− "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts" },
7+ "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts" },
88 "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" }
99 }
+57−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import { CONNECTOR_CATEGORIES, CONNECTORS, connectorPath, connectorView, connectorsFor } from "./connectors.ts";
5+import { PROVIDERS } from "./integrations.ts";
6+
7+test("every connector has a unique id, a category and a scope", () => {
8+ const ids = new Set<string>();
9+ const categories = new Set(CONNECTOR_CATEGORIES.map((category) => category.id));
10+ for (const connector of CONNECTORS) {
11+ assert.ok(!ids.has(connector.id), `${connector.id} is listed twice`);
12+ ids.add(connector.id);
13+ assert.match(connector.id, /^[a-z0-9-]+$/, connector.id);
14+ assert.ok(categories.has(connector.category), connector.id);
15+ assert.ok(connector.scopes.length > 0, connector.id);
16+ assert.ok(connector.description.length > 0 && !connector.description.includes("\n"), connector.id);
17+ }
18+});
19+
20+test("every category has connectors, and an available one in each scope it has is set up somewhere", () => {
21+ for (const category of CONNECTOR_CATEGORIES) {
22+ assert.ok(CONNECTORS.some((connector) => connector.category === category.id), category.id);
23+ }
24+ for (const scope of ["workspace", "personal"] as const) {
25+ for (const view of connectorsFor(scope)) {
26+ if (view.status === "available") assert.ok(view.href?.startsWith("/"), `${view.id} (${scope}) has nowhere to set it up`);
27+ else assert.equal(view.href, null, view.id);
28+ }
29+ }
30+});
31+
32+test("every integration provider is an available connector", () => {
33+ for (const provider of Object.keys(PROVIDERS)) {
34+ const connector = CONNECTORS.find((c) => c.provider === provider);
35+ assert.ok(connector, `${provider} has no connector`);
36+ assert.equal(connector.status, "available", provider);
37+ }
38+});
39+
40+test("a connector shows its personal side to you", () => {
41+ const github = CONNECTORS.find((c) => c.id === "github")!;
42+ const mine = connectorView(github, "personal")!;
43+ assert.equal(mine.href, "/settings/github");
44+ assert.ok(mine.capabilities.includes("Signs you in"));
45+ assert.equal(mine.alsoIn, "workspace");
46+ const linear = CONNECTORS.find((c) => c.id === "linear")!;
47+ assert.equal(connectorView(linear, "workspace")!.status, "available");
48+ assert.equal(connectorView(linear, "personal")!.status, "soon");
49+ assert.equal(connectorView(linear, "personal")!.href, null);
50+ const calendar = CONNECTORS.find((c) => c.id === "google-calendar")!;
51+ assert.equal(connectorView(calendar, "workspace"), null);
52+});
53+
54+test("a setup path names the workspace", () => {
55+ assert.equal(connectorPath("/:workspace/-/integrations/models?add=openai#add", "acme"), "/acme/-/integrations/models?add=openai#add");
56+ assert.equal(connectorPath("/new/github?workspace=:workspace", "acme"), "/new/github?workspace=acme");
57+});
+436−0
1+import type { Provider } from "./integrations";
2+
3+/**
4+ * The connector catalog: every tool g1t connects to, or will, in one list.
5+ * The workspace's Integrations page and your own Integrations settings are
6+ * both drawn from it, so a new connector is added here, once.
7+ *
8+ * A connector is `available` when g1t can set it up today (its `href` is
9+ * where), and `soon` while it is planned. `scopes` says who connects it:
10+ *
11+ * - `workspace`: an owner connects it once, for everyone in the workspace.
12+ * - `personal`: each person connects their own account, and it follows them
13+ * into every workspace they belong to (a calendar, a personal sign-in).
14+ *
15+ * A connector with both scopes can say something different for each, in
16+ * `personal`: its own description, status, capabilities or setup page.
17+ * `connectorView` resolves that into what one context shows.
18+ *
19+ * Paths in `href` are g1t's own pages. `:workspace` stands for the
20+ * workspace's slug.
21+ */
22+
23+export type ConnectorScope = "workspace" | "personal";
24+export type ConnectorStatus = "available" | "soon";
25+
26+export type ConnectorCategory =
27+ | "code"
28+ | "issues"
29+ | "chat"
30+ | "calendar"
31+ | "docs"
32+ | "monitoring"
33+ | "cloud"
34+ | "ai"
35+ | "data"
36+ | "crm"
37+ | "design"
38+ | "security"
39+ | "files";
40+
41+/** What a connector lets g1t do, as the small tags on its card. */
42+export type ConnectorCapability =
43+ | "Agents can read"
44+ | "Agents can act"
45+ | "Opens issues"
46+ | "Writes back"
47+ | "Imports repositories"
48+ | "Mirrors repositories"
49+ | "Signs you in"
50+ | "Sends events"
51+ | "Alerts into chat"
52+ | "Sets your status"
53+ | "Finds a time"
54+ | "Runs models"
55+ | "Deploys"
56+ | "Syncs people";
57+
58+export type Connector = {
59+ /** Stable and unique: used in links and requests. */
60+ id: string;
61+ name: string;
62+ category: ConnectorCategory;
63+ /** One plain line of what it does in g1t, for a workspace (or for you, when it is only personal). */
64+ description: string;
65+ scopes: ConnectorScope[];
66+ status: ConnectorStatus;
67+ /** Where an available connector is set up, for each scope it has. */
68+ href?: Partial<Record<ConnectorScope, string>>;
69+ capabilities?: ConnectorCapability[];
70+ /** The integration provider behind it, whose connections show it as connected. */
71+ provider?: Provider;
72+ /** What differs when you connect it for yourself. */
73+ personal?: { description?: string; status?: ConnectorStatus; capabilities?: ConnectorCapability[] };
74+ /** More words search should find it by. */
75+ keywords?: string[];
76+};
77+
78+/** A connector as one context (a workspace, or you) shows it. */
79+export type ConnectorView = {
80+ id: string;
81+ name: string;
82+ category: ConnectorCategory;
83+ description: string;
84+ status: ConnectorStatus;
85+ href: string | null;
86+ capabilities: ConnectorCapability[];
87+ provider: Provider | null;
88+ keywords: string[];
89+ /** The other scope it can also be connected in. */
90+ alsoIn: ConnectorScope | null;
91+};
92+
93+/** The categories, in the order the directory lists them. */
94+export const CONNECTOR_CATEGORIES: { id: ConnectorCategory; title: string }[] = [
95+ { id: "code", title: "Code & CI" },
96+ { id: "issues", title: "Issues & projects" },
97+ { id: "chat", title: "Chat & meetings" },
98+ { id: "calendar", title: "Calendar & email" },
99+ { id: "docs", title: "Docs & knowledge" },
100+ { id: "monitoring", title: "Monitoring & incidents" },
101+ { id: "cloud", title: "Cloud & deploy" },
102+ { id: "ai", title: "AI models" },
103+ { id: "data", title: "Data & analytics" },
104+ { id: "crm", title: "CRM & support" },
105+ { id: "design", title: "Design" },
106+ { id: "security", title: "Security & identity" },
107+ { id: "files", title: "Files & storage" },
108+];
109+
110+const W: ConnectorScope[] = ["workspace"];
111+const P: ConnectorScope[] = ["personal"];
112+const BOTH: ConnectorScope[] = ["workspace", "personal"];
113+
114+/** A model provider: set up on the workspace's model providers page. */
115+function model(provider: Provider, name: string, description: string, keywords: string[] = []): Connector {
116+ return {
117+ id: provider.replace(/_/g, "-"),
118+ name,
119+ category: "ai",
120+ description,
121+ scopes: W,
122+ status: "available",
123+ href: { workspace: `/:workspace/-/integrations/models?add=${provider}#add` },
124+ capabilities: ["Runs models"],
125+ provider,
126+ keywords: ["model", "llm", ...keywords],
127+ };
128+}
129+
130+/** A planned connector. */
131+function soon(
132+ id: string,
133+ name: string,
134+ category: ConnectorCategory,
135+ scopes: ConnectorScope[],
136+ description: string,
137+ capabilities: ConnectorCapability[] = [],
138+ personal?: Connector["personal"],
139+): Connector {
140+ return { id, name, category, description, scopes, status: "soon", capabilities, ...(personal ? { personal } : {}) };
141+}
142+
143+export const CONNECTORS: Connector[] = [
144+ // Code & CI -------------------------------------------------------------
145+ {
146+ id: "github",
147+ name: "GitHub",
148+ category: "code",
149+ description: "Import repositories, or mirror them both ways while your team moves over.",
150+ scopes: BOTH,
151+ status: "available",
152+ href: { workspace: "/new/github?workspace=:workspace", personal: "/settings/github" },
153+ capabilities: ["Imports repositories", "Mirrors repositories"],
154+ personal: {
155+ description: "Sign in with your GitHub account, and reach the repositories you can see there to import them.",
156+ capabilities: ["Signs you in", "Imports repositories"],
157+ },
158+ keywords: ["git", "import", "mirror", "sign in"],
159+ },
160+ {
161+ id: "webhooks",
162+ name: "Webhooks",
163+ category: "code",
164+ description: "Every repository's events, sent to your own addresses as they happen, signed.",
165+ scopes: W,
166+ status: "available",
167+ href: { workspace: "/:workspace/-/webhooks" },
168+ capabilities: ["Sends events"],
169+ keywords: ["http", "events", "outgoing"],
170+ },
171+ soon("gitlab", "GitLab", "code", BOTH, "Import projects and keep them mirrored, merge requests included.", ["Imports repositories", "Mirrors repositories"], {
172+ description: "Sign in with GitLab, and reach your own projects to import them.",
173+ capabilities: ["Signs you in", "Imports repositories"],
174+ }),
175+ soon("bitbucket", "Bitbucket", "code", BOTH, "Import repositories and keep them mirrored while you move.", ["Imports repositories", "Mirrors repositories"], {
176+ description: "Reach your own Bitbucket repositories to import them.",
177+ capabilities: ["Imports repositories"],
178+ }),
179+ soon("circleci", "CircleCI", "code", W, "Pipeline results as checks on pull requests, which agents read when a build fails.", ["Agents can read"]),
180+ soon("buildkite", "Buildkite", "code", W, "Build results as checks on pull requests, with the failing step for agents to fix.", ["Agents can read"]),
181+ soon("docker-hub", "Docker Hub", "code", W, "Push images your workflows build, and pull private base images.", ["Deploys"]),
182+
183+ // Issues & projects -----------------------------------------------------
184+ {
185+ id: "linear",
186+ name: "Linear",
187+ category: "issues",
188+ description: "Agents read ENG-42 when work mentions it; import issues, and they hear back when the work lands.",
189+ scopes: BOTH,
190+ status: "available",
191+ href: { workspace: "/:workspace/-/integrations/trackers?add=linear#add" },
192+ capabilities: ["Agents can read", "Writes back"],
193+ provider: "linear",
194+ personal: {
195+ description: "Issues assigned to you in Linear, in your g1t inbox beside everything else.",
196+ status: "soon",
197+ capabilities: ["Agents can read"],
198+ },
199+ keywords: ["tracker", "tickets"],
200+ },
201+ {
202+ id: "jira",
203+ name: "Jira",
204+ category: "issues",
205+ description: "Agents read TECH-1234 when work mentions it; import tickets, and they hear back when the work lands.",
206+ scopes: W,
207+ status: "available",
208+ href: { workspace: "/:workspace/-/integrations/trackers?add=jira#add" },
209+ capabilities: ["Agents can read", "Writes back"],
210+ provider: "jira",
211+ keywords: ["atlassian", "tracker", "tickets"],
212+ },
213+ soon("asana", "Asana", "issues", BOTH, "Tasks agents can read, import as issues, and close when the work merges.", ["Agents can read", "Writes back"], {
214+ description: "Tasks assigned to you in Asana, in your g1t inbox.",
215+ capabilities: ["Agents can read"],
216+ }),
217+ soon("shortcut", "Shortcut", "issues", W, "Stories agents read when work mentions them, updated when the work lands.", ["Agents can read", "Writes back"]),
218+ soon("clickup", "ClickUp", "issues", W, "Tasks agents can read and import, kept in step with their pull requests.", ["Agents can read", "Writes back"]),
219+ soon("trello", "Trello", "issues", W, "Cards become issues, and move along their board as the work does.", ["Opens issues", "Writes back"]),
220+
221+ // Chat & meetings -------------------------------------------------------
222+ soon("slack", "Slack", "chat", BOTH, "Alerts and agent updates in the channels your team already reads, and agents to talk to there.", ["Alerts into chat", "Agents can act"], {
223+ description: "Your status in g1t follows the one you set in Slack, and the other way round.",
224+ capabilities: ["Sets your status"],
225+ }),
226+ soon("microsoft-teams", "Microsoft Teams", "chat", BOTH, "Alerts and agent updates posted to Teams channels, with agents to talk to there.", ["Alerts into chat", "Agents can act"], {
227+ description: "Your status in g1t follows your Teams presence.",
228+ capabilities: ["Sets your status"],
229+ }),
230+ soon("discord", "Discord", "chat", W, "Releases, alerts and agent updates posted to a Discord server.", ["Alerts into chat"]),
231+ soon("zoom", "Zoom", "chat", BOTH, "Meeting notes and recordings agents can read when work refers to them.", ["Agents can read"], {
232+ description: "Sets your status to On a call while you are in a Zoom meeting.",
233+ capabilities: ["Sets your status"],
234+ }),
235+ soon("google-meet", "Google Meet", "chat", P, "Sets your status to On a call while you are in a meeting, and starts a call from any channel.", ["Sets your status"]),
236+
237+ // Calendar & email ------------------------------------------------------
238+ soon("google-calendar", "Google Calendar", "calendar", P, "Sets your status to In a meeting while you are in one; agents can find a time that suits everyone.", ["Sets your status", "Finds a time"]),
239+ soon("outlook-calendar", "Outlook Calendar", "calendar", P, "Sets your status to In a meeting while you are in one; agents can find a time that suits everyone.", ["Sets your status", "Finds a time"]),
240+ soon("gmail", "Gmail", "calendar", P, "Forward a thread to an agent, or turn an email into an issue, from your own inbox.", ["Opens issues", "Agents can read"]),
241+ soon("outlook-mail", "Outlook Mail", "calendar", P, "Forward a thread to an agent, or turn an email into an issue, from your own inbox.", ["Opens issues", "Agents can read"]),
242+
243+ // Docs & knowledge ------------------------------------------------------
244+ soon("notion", "Notion", "docs", BOTH, "Import pages into Docs, and let agents read the ones work refers to.", ["Agents can read"], {
245+ description: "Reach your own Notion pages, to bring them into Docs.",
246+ capabilities: ["Agents can read"],
247+ }),
248+ soon("confluence", "Confluence", "docs", W, "Import spaces into Docs, and let agents read pages work refers to.", ["Agents can read"]),
249+ soon("google-docs", "Google Docs", "docs", BOTH, "Agents read the specs and plans linked from issues and chat.", ["Agents can read"], {
250+ description: "Docs you share in chat open for agents as well as people.",
251+ capabilities: ["Agents can read"],
252+ }),
253+ soon("coda", "Coda", "docs", W, "Agents read the docs and tables linked from issues and chat.", ["Agents can read"]),
254+
255+ // Monitoring & incidents ------------------------------------------------
256+ {
257+ id: "sentry",
258+ name: "Sentry",
259+ category: "monitoring",
260+ description: "New errors open issues with the stack trace, and resolve in Sentry when the fix merges.",
261+ scopes: W,
262+ status: "available",
263+ href: { workspace: "/:workspace/-/integrations/alerts?add=sentry#add" },
264+ capabilities: ["Opens issues", "Agents can read", "Writes back"],
265+ provider: "sentry",
266+ keywords: ["errors", "alerts", "exceptions"],
267+ },
268+ {
269+ id: "datadog",
270+ name: "Datadog",
271+ category: "monitoring",
272+ description: "Monitors that trigger open issues, and recoveries are noted on them.",
273+ scopes: W,
274+ status: "available",
275+ href: { workspace: "/:workspace/-/integrations/alerts?add=datadog#add" },
276+ capabilities: ["Opens issues"],
277+ provider: "datadog",
278+ keywords: ["monitors", "alerts", "apm"],
279+ },
280+ {
281+ id: "alerts-webhook",
282+ name: "Alerts webhook",
283+ category: "monitoring",
284+ description: "Anything that can send signed JSON opens issues: your own scripts, or any monitoring tool.",
285+ scopes: W,
286+ status: "available",
287+ href: { workspace: "/:workspace/-/integrations/alerts?add=webhook#add" },
288+ capabilities: ["Opens issues"],
289+ provider: "webhook",
290+ keywords: ["incoming", "http", "alerts", "pagerduty", "grafana"],
291+ },
292+ soon("pagerduty", "PagerDuty", "monitoring", BOTH, "Incidents open issues an agent can start on, and resolve when the fix lands.", ["Opens issues", "Writes back"], {
293+ description: "Sets your status to On call while you are on call.",
294+ capabilities: ["Sets your status"],
295+ }),
296+ soon("opsgenie", "Opsgenie", "monitoring", W, "Alerts open issues, and close when the fix lands.", ["Opens issues", "Writes back"]),
297+ soon("incident-io", "incident.io", "monitoring", W, "Incidents get a channel in g1t, with agents to dig through what changed.", ["Opens issues", "Alerts into chat"]),
298+ soon("grafana", "Grafana", "monitoring", W, "Alert rules open issues, and agents read the dashboards they link to.", ["Opens issues", "Agents can read"]),
299+ soon("new-relic", "New Relic", "monitoring", W, "Alerts open issues, with the traces agents need to find the cause.", ["Opens issues", "Agents can read"]),
300+ soon("honeycomb", "Honeycomb", "monitoring", W, "Triggers open issues, and agents query traces while they work.", ["Opens issues", "Agents can read"]),
301+ soon("better-stack", "Better Stack", "monitoring", W, "Uptime incidents and log alerts open issues.", ["Opens issues"]),
302+
303+ // Cloud & deploy --------------------------------------------------------
304+ soon("cloudflare", "Cloudflare", "cloud", W, "Deploy projects to your own Cloudflare account, and manage their domains.", ["Deploys"]),
305+ soon("aws", "Amazon Web Services", "cloud", W, "Workflows and deployments reach AWS with short-lived credentials, no stored keys.", ["Deploys"]),
306+ soon("google-cloud", "Google Cloud", "cloud", W, "Workflows and deployments reach Google Cloud with short-lived credentials.", ["Deploys"]),
307+ soon("azure", "Microsoft Azure", "cloud", W, "Workflows and deployments reach Azure with short-lived credentials.", ["Deploys"]),
308+ soon("vercel", "Vercel", "cloud", W, "Preview and production deployments as checks on pull requests.", ["Deploys", "Agents can read"]),
309+ soon("netlify", "Netlify", "cloud", W, "Deploy previews as checks on pull requests.", ["Deploys", "Agents can read"]),
310+ soon("fly-io", "Fly.io", "cloud", W, "Deploy apps from workflows, and roll back from the deployment's page.", ["Deploys"]),
311+ soon("kubernetes", "Kubernetes", "cloud", W, "Roll out to your own clusters from workflows, with each rollout's status on g1t.", ["Deploys"]),
312+
313+ // AI models -------------------------------------------------------------
314+ model("anthropic", "Anthropic", "Claude models on your own Anthropic account, billed there.", ["claude"]),
315+ model("openai", "OpenAI", "GPT models on your own OpenAI account, billed there.", ["gpt"]),
316+ model("gemini", "Google Gemini", "Gemini models on your own Google AI account, billed there.", ["google"]),
317+ model("xai", "xAI", "Grok models on your own xAI account, billed there.", ["grok"]),
318+ model("mistral", "Mistral", "Mistral models on your own account, billed there."),
319+ model("deepseek", "DeepSeek", "DeepSeek models on your own account, billed there."),
320+ model("azure_openai", "Azure OpenAI", "Your Azure OpenAI deployments, in your own subscription.", ["microsoft"]),
321+ model("openrouter", "OpenRouter", "Many labs' models through one OpenRouter key."),
322+ model("groq", "Groq", "Fast open models on your own Groq account."),
323+ model("together", "Together AI", "Open models on your own Together AI account."),
324+ model("fireworks", "Fireworks AI", "Open models on your own Fireworks AI account."),
325+ model("cerebras", "Cerebras", "Fast open models on your own Cerebras account."),
326+ model("anthropic_endpoint", "Anthropic-compatible endpoint", "Any server that speaks the Anthropic API, such as your own gateway.", ["self-hosted", "proxy"]),
327+ model("openai_endpoint", "OpenAI-compatible endpoint", "Any server that speaks the OpenAI API, such as a model you host yourself.", ["self-hosted", "proxy", "ollama", "vllm"]),
328+ {
329+ id: "ai-gateway",
330+ name: "AI Gateway",
331+ category: "ai",
332+ description: "Your own code's model requests through g1t, with the workspace's access tokens, logged and costed.",
333+ scopes: W,
334+ status: "available",
335+ href: { workspace: "/:workspace/-/gateway" },
336+ capabilities: ["Runs models"],
337+ keywords: ["proxy", "api", "tokens"],
338+ },
339+ {
340+ id: "mcp",
341+ name: "MCP clients",
342+ category: "ai",
343+ description: "Your own agent, connected to g1t's MCP server, works in g1t as you.",
344+ scopes: P,
345+ status: "available",
346+ href: { personal: "/settings/applications" },
347+ capabilities: ["Agents can act"],
348+ keywords: ["model context protocol", "agent", "applications", "oauth"],
349+ },
350+ soon("amazon-bedrock", "Amazon Bedrock", "ai", W, "Models in your own AWS account, billed there.", ["Runs models"]),
351+ soon("vertex-ai", "Google Vertex AI", "ai", W, "Models in your own Google Cloud project, billed there.", ["Runs models"]),
352+ soon("hugging-face", "Hugging Face", "ai", W, "Inference endpoints you run on Hugging Face, for any kind of work.", ["Runs models"]),
353+
354+ // Data & analytics ------------------------------------------------------
355+ soon("postgresql", "PostgreSQL", "data", W, "Agents read your schema, and run read-only queries while they investigate.", ["Agents can read"]),
356+ soon("snowflake", "Snowflake", "data", W, "Agents answer questions from your warehouse with read-only queries.", ["Agents can read"]),
357+ soon("bigquery", "BigQuery", "data", W, "Agents answer questions from your datasets with read-only queries.", ["Agents can read"]),
358+ soon("posthog", "PostHog", "data", W, "Agents read product analytics and feature flags while they plan a change.", ["Agents can read"]),
359+ soon("amplitude", "Amplitude", "data", W, "Agents read the charts that work refers to.", ["Agents can read"]),
360+ soon("segment", "Segment", "data", W, "Agents read your tracking plan, and check new events against it.", ["Agents can read"]),
361+
362+ // CRM & support ---------------------------------------------------------
363+ soon("zendesk", "Zendesk", "crm", W, "Turn a ticket into an issue, and tell the customer when the fix ships.", ["Opens issues", "Writes back"]),
364+ soon("intercom", "Intercom", "crm", W, "Conversations become issues, and hear back when the fix ships.", ["Opens issues", "Writes back"]),
365+ soon("hubspot", "HubSpot", "crm", W, "Agents read the account behind a request when work mentions it.", ["Agents can read"]),
366+ soon("salesforce", "Salesforce", "crm", W, "Agents read the account and case behind a request.", ["Agents can read"]),
367+ soon("stripe", "Stripe", "crm", W, "Agents read customers and payments while they work on billing.", ["Agents can read"]),
368+
369+ // Design ----------------------------------------------------------------
370+ soon("figma", "Figma", "design", BOTH, "Agents read the frames linked from an issue, and build to them.", ["Agents can read"], {
371+ description: "Reach your own Figma files, to link and preview them in chat and issues.",
372+ capabilities: ["Agents can read"],
373+ }),
374+ soon("miro", "Miro", "design", W, "Agents read the boards linked from issues and chat.", ["Agents can read"]),
375+ soon("storybook", "Storybook", "design", W, "Each pull request's stories as a check, with what changed.", ["Agents can read"]),
376+
377+ // Security & identity ---------------------------------------------------
378+ soon("okta", "Okta", "security", W, "Single sign-on, and people added and removed with your directory.", ["Signs you in", "Syncs people"]),
379+ soon("entra-id", "Microsoft Entra ID", "security", W, "Single sign-on, and people added and removed with your directory.", ["Signs you in", "Syncs people"]),
380+ soon("google-workspace", "Google Workspace", "security", BOTH, "Single sign-on, and people added and removed with your directory.", ["Signs you in", "Syncs people"], {
381+ description: "Sign in to g1t with your Google account.",
382+ capabilities: ["Signs you in"],
383+ }),
384+ soon("1password", "1Password", "security", W, "Secrets from a vault, read by workflows and deployments when they run.", ["Deploys"]),
385+ soon("snyk", "Snyk", "security", W, "Findings open issues, and an agent opens the fix.", ["Opens issues"]),
386+
387+ // Files & storage -------------------------------------------------------
388+ soon("google-drive", "Google Drive", "files", BOTH, "Agents read the files linked from issues and chat.", ["Agents can read"], {
389+ description: "Attach files from your Drive in chat, without downloading them first.",
390+ capabilities: ["Agents can read"],
391+ }),
392+ soon("dropbox", "Dropbox", "files", BOTH, "Agents read the files linked from issues and chat.", ["Agents can read"], {
393+ description: "Attach files from your Dropbox in chat.",
394+ capabilities: ["Agents can read"],
395+ }),
396+ soon("onedrive", "OneDrive and SharePoint", "files", BOTH, "Agents read the files linked from issues and chat.", ["Agents can read"], {
397+ description: "Attach files from your OneDrive in chat.",
398+ capabilities: ["Agents can read"],
399+ }),
400+ soon("box", "Box", "files", W, "Agents read the files linked from issues and chat.", ["Agents can read"]),
401+];
402+
403+/** The connector by its id. */
404+export function connectorById(id: string): Connector | undefined {
405+ return CONNECTORS.find((connector) => connector.id === id);
406+}
407+
408+/** A connector as `scope` shows it, or null when it cannot be connected there. */
409+export function connectorView(connector: Connector, scope: ConnectorScope): ConnectorView | null {
410+ if (!connector.scopes.includes(scope)) return null;
411+ const own = scope === "personal" ? connector.personal : undefined;
412+ const status = own?.status ?? connector.status;
413+ const other: ConnectorScope = scope === "workspace" ? "personal" : "workspace";
414+ return {
415+ id: connector.id,
416+ name: connector.name,
417+ category: connector.category,
418+ description: own?.description ?? connector.description,
419+ status,
420+ href: status === "available" ? (connector.href?.[scope] ?? null) : null,
421+ capabilities: own?.capabilities ?? connector.capabilities ?? [],
422+ provider: connector.provider ?? null,
423+ keywords: connector.keywords ?? [],
424+ alsoIn: connector.scopes.includes(other) ? other : null,
425+ };
426+}
427+
428+/** Every connector `scope` can show, in catalog order. */
429+export function connectorsFor(scope: ConnectorScope): ConnectorView[] {
430+ return CONNECTORS.map((connector) => connectorView(connector, scope)).filter((view): view is ConnectorView => view != null);
431+}
432+
433+/** A setup path with its `:workspace` filled in. */
434+export function connectorPath(path: string, workspace: string): string {
435+ return path.replace(/:workspace\b/g, encodeURIComponent(workspace));
436+}
+1−0
1111 export * from "./clients";
1212 export * from "./codeowners";
1313 export * from "./compute";
14+export * from "./connectors";
1415 export * from "./context";
1516 export * from "./d1";
1617 export * from "./docs";
+1−1
1313 ["trial_used", "https://g1t.sh/acme/-/billing"],
1414 ["limit", "https://g1t.sh/acme/-/billing#limit"],
1515 ["issue_cap", "https://g1t.sh/acme/-/billing#caps"],
16− ["no_model", "https://g1t.sh/acme/-/integrations"],
16+ ["no_model", "https://g1t.sh/acme/-/integrations/models"],
1717 // Nothing on a page fixes these: support does, or a minute's wait.
1818 ["paused", null],
1919 ["billing_unavailable", null],
+1−1
3535 case "issue_cap":
3636 return `${SITE}/${slug}/-/billing#caps`;
3737 case "no_model":
38− return `${SITE}/${slug}/-/integrations`;
38+ return `${SITE}/${slug}/-/integrations/models`;
3939 default:
4040 return null;
4141 }