Skip to content

Commit

The Marketplace adds what g1t doesn't do and connects what you already use: extensions (Mail, Support, CRM, Recruiting, On-call, and bridges to a helpdesk, CRM or ERP you run, all Soon), starter kits, and integrations connected by the workspace or by each person; agents aren't installed there. Agent templates live in Agents, as starting points you configure, at Agents → Templates, and the Marketplace's old agent addresses lead there. The marketplace and agents guides say how.

syntaqxcommitted Parentaf86810Browse files
38 files+1068−7250/38 viewed
+4−4
5555 | Spend: where the money went by agent, person, channel, model and product, every budget, a receipt for each task, and your spend in the top bar | Preview |
5656 | Spend by extension | Coming |
5757 | Memory, sessions with live steps and cost, routines on a schedule | Live |
58−| An agent catalog in the Marketplace: roles to add an agent into, which members can ask owners for | Live |
58+| Agent templates in Agents: starting points for a new agent, configured once started | Live |
5959 | Foundational skills in every agent: PDFs, Word documents and spreadsheets, reports with sources, charts, code review, thread summaries; owners turn them off per agent | Live |
60−| More catalog specialists; web research; skills you write, add from the Marketplace or learn from work | Coming |
60+| More specialist templates; web research; skills you write, add from the Marketplace or learn from work | Coming |
6161 | Agents on runners anywhere (g1t's, yours, your desktop), with sessions that persist between tasks | Coming |
6262 | Agents answering in Slack and Teams | Coming |
6363
8585 | Integrations: the GitHub App, Sentry, Datadog, Jira, Linear, alerts | Live |
8686 | REST API, OpenAPI, MCP server, webhooks, an event bus | Live |
8787 | Usage billing with no seats, a public price book, spend limits, itemised invoices | Live |
88−| The Marketplace: agents from the catalog and integrations, added by owners, with install requests from members | Live |
89−| Extensions shared from public repositories (Support, Recruiting, Mail and On-call are listed); Drive, Gmail, Calendar and Slack integrations | Coming |
88+| The Marketplace: extensions and integrations, added by owners, with install requests from members | Live |
89+| Extensions shared from public repositories (Mail, Support, CRM, Recruiting, On-call and helpdesk, CRM and ERP bridges are listed); Drive, Gmail, Calendar and Slack integrations | Coming |
9090 | Single sign-on (SAML, OIDC) and SCIM per workspace | Coming |
9191 | Storage: databases and buckets per team, with copies of production for anything unreviewed | Coming |
9292 | Self-hosting: the core forge in Docker Compose (agents, deployments and context search off) | Preview |
+19−10
6565 <Steps>
6666
6767 1. Open **Agents** in the dock, then choose **New agent**, or go to
68− `g1t.sh/<workspace>/-/agents/new`. Or start from the
69− [agent catalog](/guides/marketplace/#the-agent-catalog) in the
70− Marketplace, where members who aren't owners can ask for a role.
71−2. **Pick a role** from the templates, grouped by department, or choose
72− **Start from nothing**. A role fills in every field below, and you can
73− change any of them. See [role templates](#role-templates).
68+ `g1t.sh/<workspace>/-/agents/new`. Or open **Templates** in the Agents
69+ sidebar (`g1t.sh/<workspace>/-/agents/templates`), read one in full and
70+ choose **Start from this**.
71+2. **Pick a template**, grouped by department, or choose **Start from
72+ nothing**. A template fills in every field below, and you can change any
73+ of them. See [role templates](#role-templates).
7474 3. **Name it.** Each role suggests a name, such as *Margo* for QA. Choose
7575 **Another name** to shuffle through others that suit the role, or type
7676 your own. The handle follows the name: `margo`, mentioned as `@margo`.
110110
111111 ## Role templates
112112
113−Roles are ordinary agents you adopt, rename and change, grouped by
114−department. Each starts with a fun name (and more to shuffle through), a
115−title, broad responsibilities, a voice, sensible model limits, and a
116−subagent or two for its own work.
113+Templates are starting points g1t provides, the way you would set up an
114+assistant's instructions yourself: each is a role you configure, rename and
115+change, grouped by department. Each starts with a fun name (and more to
116+shuffle through), a title, broad responsibilities, a voice, sensible model
117+limits, and a subagent or two for its own work. Nothing is installed: once
118+you start an agent from a template, the agent is yours, and the template
119+never changes under it.
120+
121+**Templates** in the Agents sidebar, at `g1t.sh/<workspace>/-/agents/templates`,
122+lists them all, with the agents in your workspace that started from each.
123+Choose one to read all of it: what it's responsible for, the helpers it
124+works with, the instructions it starts from, and what you configure when
125+you start one. Every member can read them; owners start agents from them.
117126
118127 | Department | Suggested name | Title | Answers for | Voice | Models |
119128 | --- | --- | --- | --- | --- | --- |
+69−52
11 ---
22 title: Marketplace
3−description: Add agents, integrations and extensions to a workspace. Anyone browses the Marketplace; owners add things, and everyone else asks an owner. What each listing may do, where its data goes, and how installs, requests and the kill switch work.
3+description: Add functionality to a workspace with extensions, and connect the tools your team already uses so agents can work with that data. Anyone browses the Marketplace; owners add things, and everyone else asks an owner. What each listing may do, where its data goes, and how installs, requests and the kill switch work.
44 ---
55
6−The Marketplace is where a workspace adds what isn't built in. Code,
7−Deployments, Chat, Agents and Artifacts come with every workspace; the
8−Marketplace lists what you can add to them:
6+The Marketplace is where a workspace adds functionality g1t doesn't have
7+built in, and connects the data it already keeps elsewhere so agents can
8+use it. Code, Deployments, Chat, Agents and Artifacts come with every
9+workspace and aren't listed. Two kinds of listing are:
910
1011 | Kind | What it adds | Status |
1112 | --- | --- | --- |
12−| **Agents** | An agent hired into a role from the agent catalog. | Live |
13+| **Extensions** | Pages, data, cards and agent roles for what your team does besides code, such as email on your domain, support or recruiting. Some bridge a system you already run. | Coming: g1t's own are listed, and install from their first release |
1314 | **Integrations** | A connection to a tool you already use, which gives agents new abilities without adding pages. | Live for the integrations marked available; the rest are coming |
14−| **Extensions** | Pages, data, cards and agent roles for what your team does besides code. | Coming: g1t's own are listed, and install from their first release |
1515
16+Agents aren't in the Marketplace. To add one, start from a
17+[template](/guides/agents/#role-templates) in Agents.
18+
1619 Open it from **Apps** at the foot of the dock (the launcher's
1720 **Marketplace** link, or **Browse the Marketplace** on the Apps page), from
1821 **More** on a phone, or with <kbd>Ctrl</kbd> <kbd>K</kbd> and
2225 ## Who can add things
2326
2427 Every member of a workspace can browse the Marketplace. Only its
25−**owners** add agents, connect integrations and install extensions,
26−because each of them can spend the workspace's money or reach its data.
27−Everyone else sees **Request** where an owner sees **Add**, and asks.
28+**owners** install extensions and connect integrations, because each of
29+them can spend the workspace's money or reach its data. Everyone else sees
30+**Request** where an owner sees **Install** or **Connect**, and asks.
2831
2932 ## The Marketplace's pages
3033
3134 | Tab | What it shows |
3235 | --- | --- |
33−| **Discover** | What every workspace has, a few roles from the agent catalog with `@g1t` first, integrations with the connected ones first, and extensions. |
34−| **Agents** | The agent catalog: every role, by department, with who in the workspace has the job. |
35−| **Integrations** | Connected integrations, the ones you can connect now, and the ones coming. Search by name, by what it does, or by a word such as *tracker*. |
36−| **Extensions** | Every extension listed, published ones first, and how extensions are shared. |
36+| **Discover** | Featured extensions, integrations with the connected ones first, starter kits, and connected systems. |
37+| **Extensions** | Every extension listed: published ones first, then g1t's own that are coming, then connected systems, and how extensions are shared. |
38+| **Integrations** | Connected integrations, the ones you can connect now, the ones each person connects for themselves, and the ones coming. Search by name, by what it does, or by a word such as *tracker*. |
3739 | **Requests** | For owners, every request members made, waiting first; for anyone else, **Your requests**. The tab shows how many are waiting. |
3840
39−## The agent catalog
40−
41−Each role in the catalog is a starting point for an agent: a name it
42−suggests, a title, responsibilities, a voice, model limits and the helpers
43−it works with. [Role templates](/guides/agents/#role-templates) lists them.
44−Choose a role to see all of it, including the instructions it starts from
45−and who in the workspace was already hired into it.
41+## Extensions
4642
47−To add an agent from the catalog as an owner:
43+An extension adds pages to its own sidebar, data, cards in chat, tools for
44+agents and agent roles. g1t's own are listed now, marked **Soon**, so you
45+can see what each will add and what it will be able to do. None can be
46+installed before its first release.
4847
49−1. Open **Marketplace → Agents**, or `g1t.sh/<workspace>/-/marketplace/agents`.
50−2. Choose **Add to workspace** on the role (**Add another** when someone
51− already has the job).
52−3. Finish the new-agent form, which opens with the role chosen, and choose
53− **Create agent**. See [Hire an agent](/guides/agents/#hire-an-agent).
54−
55−Adding an agent from a role answers every open request for that role: each
56−person who asked hears that it was added.
48+| Extension | What it adds |
49+| --- | --- |
50+| **Mail** | Email on your own domain, run by g1t. Shared inboxes such as support@ work like channels: agents sort and draft, and sending needs approval or a rule you set. |
51+| **Support** | Conversations, escalations, macros and a knowledge base. Customer cards in chat, and bug reports become issues in Code. |
52+| **CRM** | Accounts, deals by stage and a forecast. Agents log calls and draft follow-ups for a person to send. |
53+| **Recruiting** | Openings, candidates, scorecards and interview loops. Agents screen; people decide. |
54+| **On-call** | Rotations, pages and incidents, with who is on call now. |
5755
58−## Integrations
56+### Connected systems
5957
60−The Marketplace lists every integration in the
61−[integrations directory](/guides/integrations/) that a workspace can
62−connect, with whether yours has. **Connect** opens its setup page;
63−**Manage** opens it once it is connected. **Needs attention** means a
64−connection's last check failed: hover it to see why.
58+A connected system is an extension that bridges a system your team already
59+runs, so agents work across it and g1t together. Its page says *Data leaves
60+g1t to* the system you connect.
6561
66−Integrations each person connects for themselves, such as a calendar or a
67−mailbox, are listed among the coming ones; they are set up in your own
68−[settings](/guides/integrations/#workspace-and-personal) once available.
62+| Extension | What it bridges |
63+| --- | --- |
64+| **Helpdesk bridge** | Tickets from your helpdesk, with replies drafted for a person to send and bug reports linked to issues in Code. |
65+| **CRM bridge** | Accounts and deals from your CRM, in g1t and in chat. |
66+| **ERP bridge** | Orders, invoices and stock from your ERP. Anything that changes money or stock waits for a person's approval. |
6967
70−## Extensions
68+### Starter kits
7169
72−An extension adds pages to its own sidebar, data, cards in chat, tools for
73−agents and agent roles. g1t's own are listed now, marked **Soon**, so you
74−can see what each will add and what it will be able to do: **Support**,
75−**Recruiting**, **Mail** and **On-call**. None can be installed before its
76−first release.
70+A starter kit is a set of extensions for one kind of team, installed
71+together once each of them is published: **Customer team** (Mail, Support,
72+CRM), **Engineering extras** (On-call, Helpdesk bridge) and **People and
73+operations** (Recruiting, ERP bridge). They are on **Discover**, marked
74+**Soon** until then.
7775
7876 ### Who stands behind a listing
7977
8886
8987 Before anything is installed, its page lists:
9088
91−- **What it adds**: its pages, the agent roles it brings, the tools agents
92− get, and the notifications it sends.
89+- **What it adds**: its pages, the cards it shows in chat, the agent roles
90+ it brings, the tools agents get, and the notifications it sends.
9391 - **It will be able to**: what it may do, in plain words, and the scopes
9492 its token asks for, each described.
9593 - **Where it runs, and where data goes**: on g1t, or on its publisher's
96− servers, and every domain outside g1t its data goes to, as *Data leaves
97− g1t to api.example.com*. *Its data stays in g1t* means it sends nothing
98− elsewhere.
94+ servers, and everywhere outside g1t its data goes, as *Data leaves g1t to
95+ api.example.com*, or *to the CRM you connect* for a connected system.
96+ *Its data stays in g1t* means it sends nothing elsewhere.
9997 - **Version** and **Source**: the release it installs, and the repository
10098 and tag it was published from.
10199 - **Price**: every listing is free. What its agents do is billed at what
116114 | `scopes` | The [scopes](/guides/authentication/#scopes) its token asks for. |
117115 | `permissions` | What it may do, in plain words, for the install screen. |
118116 | `domains` | Every host outside g1t its data goes to. A connected extension must name the ones it runs on. |
117+| `bridges` | Optional. The system it connects to, in words, such as `the CRM you connect`, when its data goes wherever the workspace points it. |
119118 | `ui.entry` | Its page, loaded in a sandboxed frame from the user-content domain, which reaches g1t only through what it was allowed. |
120−| `adds` | Its `pages`, `agent_roles`, `tools` and `notifications`. |
119+| `adds` | Its `pages`, `cards`, `agent_roles`, `tools` and `notifications`. |
121120 | `pricing` | `null`: listings are free. |
122121
123122 ### Installed extensions
133132 Installing, switching on or off, setting a budget and uninstalling are in
134133 the [audit log](/guides/audit-log/).
135134
135+## Integrations
136+
137+Integrations are how agents reach your data: each one says, in plain words,
138+what it lets agents do, such as *Agents can read* or *Opens issues*. The
139+Marketplace lists every integration in the
140+[integrations directory](/guides/integrations/) that a workspace can
141+connect, with whether yours has. **Connect** opens its setup page;
142+**Manage** opens it once it is connected. **Needs attention** means a
143+connection's last check failed: hover it to see why.
144+
145+Some integrations each person connects for themselves, such as a GitHub
146+account or an MCP client. They are listed under **Connected by each
147+person**, and **Connect yours** opens your own
148+[settings](/guides/integrations/#workspace-and-personal) for them. Agents
149+use a personal connection only when that person asks. Those that aren't
150+available yet, such as a calendar or a mailbox, are listed among the
151+coming ones.
152+
136153 ## Ask an owner to add something
137154
138−1. Choose **Request** on an agent, an integration or an extension.
155+1. Choose **Request** on an extension or an integration.
139156 2. Say why you want it, if you like, and choose **Send request**.
140157
141158 Every owner is notified. The button then reads **Requested**, and **Your
147164
148165 | | |
149166 | --- | --- |
150−| **Add**, **Connect** or **Install** | Opens where it is added. Adding an agent from its role answers every request for that role by itself. |
167+| **Install** or **Connect** | Opens where it is added. Installing an extension answers every request for it by itself. |
151168 | **Mark added** | After you connected the integration, or added it another way. |
152169 | **Turn down** | The person who asked is told. They can ask again. |
153170
+2−2
601601 GitHub, each opening its own page; installed extensions join them once
602602 extensions are published. An app you can't use shows **Request access**.
603603 The built-in apps' own pages are in their sidebars, not in Apps: Code's has
604−**Projects**, **Security** and **Packages**; Agents' has **Context** and
605−**Memory**; People's has **Teams**; Workspace's has **Usage**,
604+**Projects**, **Security** and **Packages**; Agents' has **Templates**,
605+**Context** and **Memory**; People's has **Teams**; Workspace's has **Usage**,
606606 **AI Gateway**, **Integrations** and the **Audit log**.
607607
608608 A pinned app shows as its mark under the built-in ones, in the order you
+4−4
8383 | [Spend](/guides/spend/) | Where the money went, by agent, person, channel, model and product; every budget with what happens at 100%; a receipt for each task; your own spend in the top bar. | <Status is="preview" /> |
8484 | [Acting for the asker](/guides/agent-access/) | Agents act with the access of the person who asked, and answer with only what their audience may see. | <Status is="live" /> |
8585 | [Memory](/guides/agent-memory/), [sessions](/guides/agent-sessions/) and [routines](/guides/agent-routines/) | What an agent keeps, every run with its steps and cost, and work on a schedule. | <Status is="live" /> |
86−| [Agent catalog](/guides/marketplace/#the-agent-catalog) | Roles to add an agent into, from the Marketplace, each with responsibilities, a voice and the helpers it works with. | <Status is="live" /> |
87−| More specialists | Catalog roles that bring their own skills, tools and the runner they prefer. | <Status is="coming" /> |
86+| [Agent templates](/guides/agents/#role-templates) | Starting points for a new agent in Agents, each with responsibilities, a voice and the helpers it works with, configured once you start one. | <Status is="live" /> |
87+| More specialists | Templates that bring their own skills, tools and the runner they prefer. | <Status is="coming" /> |
8888 | [Foundational skills](/guides/agent-skills/) | Every agent makes PDFs, Word documents and spreadsheets, writes reports with sources, charts data, reviews code and summarizes threads, with the tools it already has. Owners turn skills off per agent. | <Status is="live" /> |
8989 | Web research, more skills | Research on the open web, set per team; skills you write, add from the Marketplace or publish from what agents learn. | <Status is="coming" /> |
9090 | Agents on runners | Every agent's machine is a runner: g1t's, one of yours, or your desktop, with sessions that last between tasks. | <Status is="coming" /> |
114114 | --- | --- | --- |
115115 | [Integrations](/guides/integrations/) | The GitHub App, Sentry, Datadog, Jira, Linear and alerts. | <Status is="live" /> |
116116 | [API](/reference/api/), [MCP](/reference/mcp/) and [webhooks](/guides/webhooks/) | Everything as a REST endpoint and an MCP tool, and a webhook for every event. | <Status is="live" /> |
117−| [Marketplace](/guides/marketplace/) | Agents from the catalog and integrations, added by owners; members ask for what they need. | <Status is="live" /> |
118−| Extensions | Pages, cards, tools and agent roles shared from public repositories, installed by an owner after reading their permissions and where their data goes. Support, Recruiting, Mail and On-call are listed. | <Status is="coming" /> |
117+| [Marketplace](/guides/marketplace/) | Extensions that add functionality, and integrations that connect your data for agents, added by owners; members ask for what they need. | <Status is="live" /> |
118+| Extensions | Pages, cards, tools and agent roles shared from public repositories, installed by an owner after reading their permissions and where their data goes. Mail, Support, CRM, Recruiting, On-call and bridges to a helpdesk, CRM or ERP are listed. | <Status is="coming" /> |
119119 | More integrations | Drive, Gmail, Calendar, Slack and Microsoft 365, each listing what it lets agents do. | <Status is="coming" /> |
120120 | Mail | Email on your own domain, with shared inboxes that agents work in. | <Status is="coming" /> |
121121
+72−0
1+/**
2+ * Agent templates' pieces (routes/workspace/agents/templates.tsx): a
3+ * template as a card, and what the viewer can do with it. Owners start an
4+ * agent from one; everyone else can read what each brings.
5+ */
6+import { Plus } from "lucide-react";
7+import { Link } from "react-router";
8+
9+import { PixelCreature } from "./agent-avatar";
10+import { Hint } from "./ui/hint";
11+import { type TemplateListing, startPath, templatePath } from "../lib/agent-templates";
12+import { cn } from "../lib/cn";
13+
14+const SMALL = "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-[0.8125rem] font-medium transition-colors";
15+const PRIMARY = `${SMALL} bg-fg text-bg hover:bg-fg-hover`;
16+const QUIET = `${SMALL} border border-line text-fg/85 hover:border-line-strong hover:bg-raised hover:text-fg`;
17+
18+/** Starting an agent from a template: the new-agent form with it chosen, for owners; a word on who can, for everyone else. */
19+export function StartAction({ listing, slug, owner, className }: { listing: TemplateListing; slug: string; owner: boolean; className?: string }) {
20+ if (!owner) {
21+ return (
22+ <Hint label="Owners start new agents. Ask one in chat if you want an agent like this.">
23+ <span tabIndex={0} className={cn(SMALL, "border border-line text-muted outline-none focus-visible:ring-2 focus-visible:ring-accent", className)}>
24+ Owners start agents
25+ </span>
26+ </Hint>
27+ );
28+ }
29+ return (
30+ <Link to={startPath(slug, listing.template.id)} className={cn(listing.agents.length > 0 ? QUIET : PRIMARY, className)}>
31+ <Plus size={14} />
32+ {listing.agents.length > 0 ? "Start another" : "Start from this"}
33+ </Link>
34+ );
35+}
36+
37+/** A template as a card: the face it suggests, its name and title, what it does, and which agents started from it. */
38+export function TemplateCard({ listing, slug, owner }: { listing: TemplateListing; slug: string; owner: boolean }) {
39+ const { template, agents } = listing;
40+ return (
41+ <article className="relative flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
42+ <div className="flex items-start gap-3">
43+ <PixelCreature seed={template.handle} size={44} />
44+ <div className="min-w-0 grow">
45+ <h3 className="truncate text-sm font-semibold">
46+ <Link to={templatePath(slug, template.id)} prefetch="intent" className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:ring-2 focus-visible:after:ring-accent">
47+ {template.title}
48+ </Link>
49+ </h3>
50+ <p className="truncate text-xs text-muted">
51+ {template.department ? `${template.department} · ` : ""}Suggests {template.display_name}
52+ </p>
53+ </div>
54+ </div>
55+ <ul className="mt-3 grow space-y-1">
56+ {template.responsibilities.slice(0, 3).map((duty) => (
57+ <li key={duty} className="flex gap-2 text-[0.8125rem] leading-snug text-muted">
58+ <span className="mt-[0.45rem] size-1 shrink-0 rounded-full bg-line-strong" aria-hidden="true" />
59+ <span className="line-clamp-1">{duty}</span>
60+ </li>
61+ ))}
62+ </ul>
63+ <div className="relative mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
64+ <span className="min-w-0 truncate text-xs text-faint">
65+ {agents.length === 0 ? "No agent started from it" : agents.length === 1 ? `@${agents[0]!.handle} started from it` : `${agents.length} agents started from it`}
66+ </span>
67+ {/* A member reads on; who may start one is said on the template's page. */}
68+ {owner && <StartAction listing={listing} slug={slug} owner={owner} />}
69+ </div>
70+ </article>
71+ );
72+}
+4−1
1−import { Activity, Brain, ChevronRight, Dices, Network, Plus, Route as RouteIcon, Sparkles } from "lucide-react";
1+import { Activity, Brain, ChevronRight, Dices, LayoutTemplate, Network, Plus, Route as RouteIcon, Sparkles } from "lucide-react";
22 import { type ReactNode, useState } from "react";
33 import { Form, NavLink, useLocation, useNavigation, useRouteLoaderData } from "react-router";
44
138138 Overview
139139 </SideLink>
140140 )}
141+ <SideLink to={`/${slug}/-/agents/templates`} icon={<LayoutTemplate size={15} className="text-faint" />}>
142+ Templates
143+ </SideLink>
141144 {code && (
142145 <>
143146 <SideLink to={`/${slug}/-/context`} icon={<Network size={15} className="text-faint" />}>
+58−70
11 /**
22 * The Marketplace's pieces (routes/workspace/marketplace/): listings for
3− * agents and integrations with what the workspace has of each, the button
4− * that adds one (owners) or asks an owner to (everyone else), and a
5− * request as owners and askers see it.
3+ * extensions and integrations with what the workspace has of each, the
4+ * button that adds one (owners) or asks an owner to (everyone else),
5+ * starter kits, and a request as owners and askers see it.
66 */
77 import { ArrowRight, Check, CircleAlert, Clock, Plus, Send, X } from "lucide-react";
88 import { type ReactNode, useEffect, useState } from "react";
1010
1111 import type { ExtensionManifest, InstallRequest, ListingTier } from "@g1t/contracts";
1212
13−import { PixelCreature } from "./agent-avatar";
1413 import { ConnectorMark } from "./connectors";
1514 import { Badge } from "./ui/badge";
1615 import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog";
1716 import { Hint } from "./ui/hint";
1817 import { ErrorText, SubmitButton, Textarea, TimeAgo } from "./ui";
19−import { type AgentListing, type ExtensionListing, type IntegrationListing, TIERS, catalogAgentPath, extensionPath, hirePath, marketplacePath } from "../lib/marketplace";
18+import { type ExtensionListing, type IntegrationListing, type StarterKit, TIERS, extensionPath, marketplacePath } from "../lib/marketplace";
2019 import { cn } from "../lib/cn";
2120
2221 const SMALL = "inline-flex h-8 shrink-0 items-center justify-center gap-1.5 rounded-md px-3 text-[0.8125rem] font-medium transition-colors";
3635 );
3736 }
3837
39−/** "Coming", on what is planned and not built. */
38+/** "Soon", on what is planned and not built. */
4039 export function ComingBadge() {
41− return <Badge tone="neutral">Coming</Badge>;
40+ return <Badge tone="neutral">Soon</Badge>;
4241 }
4342
4443 /** A section's heading, with a count or a link at its end. */
9998 <fetcher.Form method="post" action={marketplacePath(slug, "requests")} className="grid gap-4">
10099 <DialogHeader>
101100 <DialogTitle>Ask to add {name}</DialogTitle>
102− <DialogDescription>Only the workspace's owners add agents and integrations. Each of them is notified, and you hear back when one answers.</DialogDescription>
101+ <DialogDescription>Only the workspace's owners add extensions and integrations. Each of them is notified, and you hear back when one answers.</DialogDescription>
103102 </DialogHeader>
104103 <input type="hidden" name="intent" value="request" />
105104 <input type="hidden" name="listing" value={listing} />
129128 <Link to={marketplacePath(slug, "requests")} className="text-xs text-warn hover:underline">
130129 {count} {count === 1 ? "request" : "requests"}
131130 </Link>
132− );
133−}
134−
135−/** What an owner or a member can do with a role in the catalog. */
136−export function AgentAction({ listing, slug, owner, className }: { listing: AgentListing; slug: string; owner: boolean; className?: string }) {
137− if (owner) {
138− return (
139− <Link to={hirePath(slug, listing.template.id)} className={cn(listing.hired.length > 0 ? ACTION.quiet : ACTION.primary, className)}>
140− <Plus size={14} />
141− {listing.hired.length > 0 ? "Add another" : "Add to workspace"}
142− </Link>
143− );
144− }
145− return <RequestButton slug={slug} listing={listing.ref} name={`${listing.template.display_name}, ${listing.template.title}`} requested={listing.requested} className={className} />;
146−}
147−
148−/** A role in the agent catalog, as a card: its face, the name it suggests, what it does, and what the workspace has of it. */
149−export function AgentCard({ listing, slug, owner }: { listing: AgentListing; slug: string; owner: boolean }) {
150− const { template, hired } = listing;
151− return (
152− <article className="group relative flex flex-col rounded-xl border border-line bg-surface p-4 transition-colors hover:border-line-strong">
153− <div className="flex items-start gap-3">
154− <PixelCreature seed={template.handle} size={44} />
155− <div className="min-w-0 grow">
156− <h3 className="truncate text-sm font-semibold">
157− <Link to={catalogAgentPath(slug, template.id)} prefetch="intent" className="outline-none after:absolute after:inset-0 after:rounded-xl focus-visible:after:ring-2 focus-visible:after:ring-accent">
158− {template.display_name}
159− </Link>
160− </h3>
161− <p className="truncate text-xs text-muted">
162− {template.title}
163− </p>
164− </div>
165− <TierBadge tier="official" />
166− </div>
167− <ul className="mt-3 grow space-y-1">
168− {template.responsibilities.slice(0, 3).map((duty) => (
169− <li key={duty} className="flex gap-2 text-[0.8125rem] leading-snug text-muted">
170− <span className="mt-[0.45rem] size-1 shrink-0 rounded-full bg-line-strong" aria-hidden="true" />
171− <span className="line-clamp-1">{duty}</span>
172− </li>
173− ))}
174− </ul>
175− <div className="relative mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
176− <span className="min-w-0 truncate text-xs text-faint">
177− {/* What waits on an owner says more than what isn't there. */}
178− {owner && listing.waiting > 0 && hired.length === 0 ? (
179− <Waiting slug={slug} count={listing.waiting} />
180− ) : hired.length === 0 ? (
181− "Not added"
182− ) : hired.length === 1 ? (
183− `@${hired[0]!.handle} works here`
184− ) : (
185− `${hired.length} work here`
186− )}
187− </span>
188− <AgentAction listing={listing} slug={slug} owner={owner} />
189− </div>
190− </article>
191131 );
192132 }
193133
296236 );
297237 }
298238
239+/** Where an extension's data goes, in a few words for a card. */
240+function dataLine(manifest: Pick<ExtensionManifest, "domains" | "bridges">): string {
241+ // "the CRM you connect" reads, on a card, as "Works with your CRM".
242+ if (manifest.bridges) return `Works with ${manifest.bridges.replace(/^the (.+) you connect$/, "your $1")}`;
243+ return manifest.domains.length === 0 ? "Data stays in g1t" : `Data goes to ${manifest.domains[0]}`;
244+}
245+
299246 /** An extension as a card: its mark, publisher and tier, what it does, and where its data goes. */
300247 export function ExtensionCard({ listing, slug, owner }: { listing: ExtensionListing; slug: string; owner: boolean }) {
301248 const { manifest } = listing;
319266 </div>
320267 <p className="mt-3 line-clamp-2 grow text-[0.8125rem] leading-snug text-muted">{manifest.tagline}</p>
321268 <div className="relative mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
322− <span className="min-w-0 truncate text-xs text-faint">{manifest.domains.length === 0 ? "Data stays in g1t" : `Data goes to ${manifest.domains[0]}`}</span>
269+ <span className="min-w-0 truncate text-xs text-faint">{dataLine(manifest)}</span>
323270 <ExtensionAction listing={listing} slug={slug} owner={owner} />
324271 </div>
325272 </article>
345292 <div className="min-w-0 grow">
346293 <p className="flex flex-wrap items-center gap-2 text-sm">
347294 <span className="font-medium">{request.name}</span>
348− <span className="text-xs text-faint">{request.kind === "agent" ? "Agent" : request.kind === "extension" ? "Extension" : "Integration"}</span>
295+ <span className="text-xs text-faint">{request.kind === "extension" ? "Extension" : "Integration"}</span>
349296 <RequestStatus request={request} />
350297 </p>
351298 <p className="mt-0.5 text-xs text-muted">
372319 {addTo && (
373320 <Link to={addTo} className={ACTION.primary}>
374321 <Plus size={14} />
375− {request.kind === "agent" ? "Add" : request.kind === "extension" ? "Install" : "Connect"}
322+ {request.kind === "extension" ? "Install" : "Connect"}
376323 </Link>
377324 )}
378325 <SubmitButton fetcher={fetcher} name="status" value="done" className={ACTION.quiet} pending="Saving…">
388335 </li>
389336 );
390337 }
338+
339+/**
340+ * A starter kit as a card: the extensions it brings, overlapped, its name
341+ * and what it is for. Installed together once every one is published;
342+ * until then it says Soon.
343+ */
344+export function StarterKitCard({ kit, extensions, slug }: { kit: StarterKit; extensions: ExtensionManifest[]; slug: string }) {
345+ const ready = extensions.length > 0 && extensions.every((extension) => extension.status === "available");
346+ return (
347+ <article className="flex flex-col rounded-xl border border-line bg-surface p-4">
348+ <div className="flex items-center">
349+ {extensions.map((extension, index) => (
350+ <span key={extension.id} className={cn("rounded-[0.6rem] ring-2 ring-surface", index > 0 && "-ml-2")}>
351+ <ExtensionMark manifest={extension} size={30} />
352+ </span>
353+ ))}
354+ </div>
355+ <h3 className="mt-3 text-sm font-semibold">{kit.name}</h3>
356+ <p className="mt-0.5 grow text-[0.8125rem] leading-snug text-muted">{kit.about}</p>
357+ <div className="mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
358+ <p className="min-w-0 truncate text-xs text-faint">
359+ {extensions.map((extension, index) => (
360+ <span key={extension.id}>
361+ {index > 0 && ", "}
362+ <Link to={extensionPath(slug, extension.id)} className="hover:text-fg hover:underline">
363+ {extension.name}
364+ </Link>
365+ </span>
366+ ))}
367+ </p>
368+ {!ready && (
369+ <Hint label="Its extensions aren't all published yet. The kit installs them together once they are.">
370+ <span tabIndex={0} className={cn(SMALL, "border border-dashed border-line-strong text-muted outline-none focus-visible:ring-2 focus-visible:ring-accent")}>
371+ Soon
372+ </span>
373+ </Hint>
374+ )}
375+ </div>
376+ </article>
377+ );
378+}
+6−5
14761476 trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page });
14771477 } else if (third) {
14781478 trail.push({ label: SECTIONS[third] ?? third, to: page });
1479− // The Marketplace names its tab: Marketplace / Agents.
1480− const tabs: Record<string, string> = { agents: "Agents", integrations: "Integrations", extensions: "Extensions", requests: "Requests" };
1481− const tab = third === "marketplace" && fourth ? tabs[fourth] : undefined;
1479+ // The Marketplace names its tab (Marketplace / Extensions), and Agents its templates.
1480+ const tabs: Record<string, string> =
1481+ third === "marketplace" ? { integrations: "Integrations", extensions: "Extensions", requests: "Requests" } : third === "agents" ? { templates: "Templates" } : {};
1482+ const tab = fourth ? tabs[fourth] : undefined;
14821483 if (tab) trail.push({ label: tab, to: `${page}/${fourth}` });
14831484 }
14841485 } else if (second) {
15761577 ...(hasCodeAccess(ws) ? [{ label: "Code overview", to: `/${ws.slug}/-/overview`, icon: <Code2 size={15} /> }] : []),
15771578 { label: "Artifacts", to: `/${ws.slug}/-/artifacts`, icon: <Shapes size={15} /> },
15781579 { label: "Apps", hint: "Everything installed that you can use", to: `/${ws.slug}/-/apps`, icon: <LayoutGrid size={15} /> },
1579− { label: "Marketplace", hint: "Agents and integrations to add", to: `/${ws.slug}/-/marketplace`, icon: <Store size={15} /> },
1580− { label: "Agent catalog", hint: "Roles to add an agent into", to: `/${ws.slug}/-/marketplace/agents`, icon: <Bot size={15} /> },
1580+ { label: "Marketplace", hint: "Extensions and integrations to add", to: `/${ws.slug}/-/marketplace`, icon: <Store size={15} /> },
1581+ { label: "Agent templates", hint: "Starting points for a new agent", to: `/${ws.slug}/-/agents/templates`, icon: <Bot size={15} /> },
15811582 { label: "Workspace", hint: "Billing, policies, settings", to: `/${ws.slug}/-/workspace`, icon: <Building2 size={15} /> },
15821583 ]
15831584 : []),
+20−0
1+/**
2+ * What the agent template pages read: the templates and the workspace's
3+ * agents, to say which started from each. Each part that does not answer
4+ * comes back null, and the page says so.
5+ */
6+import type { AgentTemplate, User } from "@g1t/contracts";
7+
8+import type { StartedAgent } from "./agent-templates";
9+import { workspaceAgents } from "./services.server";
10+
11+export async function loadTemplates(slug: string, viewer: User): Promise<{ templates: AgentTemplate[] | null; agents: StartedAgent[] | null }> {
12+ const [templates, agents] = await Promise.all([
13+ workspaceAgents.templates().catch(() => null),
14+ workspaceAgents
15+ .list(slug, viewer)
16+ .then((result) => (result.ok ? result.value.map(({ id, handle, display_name, template, archived_at }) => ({ id, handle, display_name, template, archived_at })) : null))
17+ .catch(() => null),
18+ ]);
19+ return { templates, agents };
20+}
+65−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { AgentTemplate } from "@g1t/contracts";
5+
6+import { byDepartment, roleLine, routingWords, startPath, templateListings, templatePath, templatesPath } from "./agent-templates.ts";
7+
8+const template = (id: string, title: string, department = "Engineering"): AgentTemplate => ({
9+ id,
10+ display_name: title.split(" ")[0]!,
11+ handle: id,
12+ name_ideas: [],
13+ role: title,
14+ title,
15+ department,
16+ responsibilities: ["Do the work"],
17+ subagents: [],
18+ instructions: "",
19+ personality_preset: "crisp",
20+ routing: { floor: null, ceiling: null, providers: [], pinned: null },
21+});
22+
23+test("templates live under Agents, and starting one opens the new-agent form with it chosen", () => {
24+ assert.equal(templatesPath("acme"), "/acme/-/agents/templates");
25+ assert.equal(templatePath("acme", "qa"), "/acme/-/agents/templates/qa");
26+ assert.equal(startPath("acme", "qa"), "/acme/-/agents/new?template=qa");
27+});
28+
29+test("a template counts the agents started from it, not archived ones", () => {
30+ const [eng, qa] = templateListings(
31+ [template("engineering", "Software Engineer"), template("qa", "QA Engineer")],
32+ [
33+ { id: "a1", handle: "otto", display_name: "Otto", template: "engineering", archived_at: null },
34+ { id: "a2", handle: "bolt", display_name: "Bolt", template: "engineering", archived_at: "2026-10-01T00:00:00Z" },
35+ { id: "a3", handle: "g1t", display_name: "g1t", template: null, archived_at: null },
36+ ],
37+ );
38+ assert.deepEqual(eng!.agents.map((a) => a.handle), ["otto"]);
39+ assert.equal(qa!.agents.length, 0);
40+});
41+
42+test("without the agents service, no agent is counted", () => {
43+ const [eng] = templateListings([template("engineering", "Software Engineer")], null);
44+ assert.deepEqual(eng!.agents, []);
45+});
46+
47+test("templates group by department, in the order they come", () => {
48+ const groups = byDepartment(templateListings([template("a", "A One"), template("b", "B Two", "Sales"), template("c", "C Three"), template("d", "D Four", "")], null));
49+ assert.deepEqual(
50+ groups.map(([department, list]) => [department, list.map((l) => l.template.id)]),
51+ [
52+ ["Engineering", ["a", "c"]],
53+ ["Sales", ["b"]],
54+ ["Other", ["d"]],
55+ ],
56+ );
57+});
58+
59+test("a role and its model limits read as words", () => {
60+ assert.equal(roleLine({ title: "QA Engineer", department: "Engineering" }), "QA Engineer · Engineering");
61+ assert.equal(roleLine({ title: "QA Engineer", department: "" }), "QA Engineer");
62+ assert.equal(routingWords({ floor: null, ceiling: null }), "Any model the work needs");
63+ assert.equal(routingWords({ floor: "large", ceiling: null }), "Large models or better");
64+ assert.equal(routingWords({ floor: null, ceiling: "large" }), "Up to large models");
65+});
+73−0
1+/**
2+ * Agent templates (routes/workspace/agents/templates.tsx): the starting
3+ * points g1t provides for an agent, each a role with responsibilities,
4+ * helpers, a voice, model limits and instructions. A template isn't
5+ * installed: an owner starts a new agent from one and configures it, and
6+ * the agent is the workspace's own from then on.
7+ *
8+ * No Workers or React imports, so it can be tested under Node.
9+ */
10+import type { AgentTemplate, WorkspaceAgent } from "@g1t/contracts";
11+
12+/** Every template, under Agents. */
13+export function templatesPath(slug: string): string {
14+ return `/${slug}/-/agents/templates`;
15+}
16+
17+/** One template's page. */
18+export function templatePath(slug: string, template: string): string {
19+ return `/${slug}/-/agents/templates/${template}`;
20+}
21+
22+/** Where an owner starts an agent from a template: the new-agent form, with the template chosen. */
23+export function startPath(slug: string, template: string): string {
24+ return `/${slug}/-/agents/new?template=${encodeURIComponent(template)}`;
25+}
26+
27+/** An agent as a template's page needs it: who it is and which template it started from. */
28+export type StartedAgent = Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "template" | "archived_at">;
29+
30+/** A template, with the workspace's agents that started from it. */
31+export type TemplateListing = {
32+ template: AgentTemplate;
33+ /** Agents started from it, not archived. */
34+ agents: Pick<WorkspaceAgent, "id" | "handle" | "display_name">[];
35+};
36+
37+/**
38+ * Every template, in the order the agents service gives them, with the
39+ * agents started from each. `agents` null: the agents service could not
40+ * say, so none is counted.
41+ */
42+export function templateListings(templates: AgentTemplate[], agents: StartedAgent[] | null): TemplateListing[] {
43+ return templates.map((template) => ({
44+ template,
45+ agents: (agents ?? [])
46+ .filter((agent) => agent.template === template.id && !agent.archived_at)
47+ .map(({ id, handle, display_name }) => ({ id, handle, display_name })),
48+ }));
49+}
50+
51+/** Listings by department, in the order the templates come, "Other" for none. */
52+export function byDepartment(listings: TemplateListing[]): [string, TemplateListing[]][] {
53+ const departments = new Map<string, TemplateListing[]>();
54+ for (const listing of listings) {
55+ const key = listing.template.department || "Other";
56+ departments.set(key, [...(departments.get(key) ?? []), listing]);
57+ }
58+ return [...departments];
59+}
60+
61+/** "Software Engineer · Engineering": a role as one line. */
62+export function roleLine(template: Pick<AgentTemplate, "title" | "department">): string {
63+ return template.department ? `${template.title} · ${template.department}` : template.title;
64+}
65+
66+/** How a routing limit reads: "Any model the work needs", "Large models or better", "Up to large models", "Large to frontier models". */
67+export function routingWords(routing: Pick<AgentTemplate["routing"], "floor" | "ceiling">): string {
68+ const name = (tier: string) => tier.charAt(0).toUpperCase() + tier.slice(1);
69+ if (routing.floor && routing.ceiling) return `${name(routing.floor)} to ${routing.ceiling} models`;
70+ if (routing.floor) return `${name(routing.floor)} models or better`;
71+ if (routing.ceiling) return `Up to ${routing.ceiling} models`;
72+ return "Any model the work needs";
73+}
+3−19
11 /**
2− * What the Marketplace's pages read: the agent catalog's roles with the
3− * workspace's agents, and what the workspace has connected. Each part that
4− * does not answer comes back null, and the page says so.
2+ * What the Marketplace's pages read besides the layout's: what the
3+ * workspace has connected (lib/connected.server.ts).
54 */
6−import type { AgentTemplate, User, WorkspaceAgent } from "@g1t/contracts";
5+import type { User } from "@g1t/contracts";
76
87 import type { ConnectedState } from "./connectors";
98 import { connectedStates } from "./connected.server";
10−import { workspaceAgents } from "./services.server";
11−
12−/** An agent as the Marketplace needs it: who it is and which role it was hired into. */
13−export type HiredAgent = Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "template" | "archived_at">;
14−
15−export async function loadCatalog(slug: string, viewer: User): Promise<{ templates: AgentTemplate[] | null; agents: HiredAgent[] | null }> {
16− const [templates, agents] = await Promise.all([
17− workspaceAgents.templates().catch(() => null),
18− workspaceAgents
19− .list(slug, viewer)
20− .then((result) => (result.ok ? result.value.map(({ id, handle, display_name, template, archived_at }) => ({ id, handle, display_name, template, archived_at })) : null))
21− .catch(() => null),
22− ]);
23− return { templates, agents };
24−}
259
2610 export async function loadConnected(slug: string, viewer: User): Promise<Record<string, ConnectedState>> {
2711 return connectedStates(slug, viewer);
+18−50
11 import assert from "node:assert/strict";
22 import { test } from "node:test";
33
4−import type { AgentTemplate, InstallRequest } from "@g1t/contracts";
4+import type { InstallRequest } from "@g1t/contracts";
55 import { connectorsFor } from "@g1t/contracts/connectors";
66
7−import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace";
7+import { FIRST_PARTY_EXTENSIONS, extensionById } from "@g1t/contracts/marketplace";
88
9−import { addPath, agentListings, comingIntegrations, extensionListings, integrationListings, integrationMatches, marketplaceForm, routingWords } from "./marketplace.ts";
9+import { STARTER_KITS, addPath, comingIntegrations, extensionListings, integrationListings, integrationMatches, isConnectedSystem, marketplaceForm } from "./marketplace.ts";
1010 import { modeOf } from "./workspace-nav.ts";
11−
12−const template = (id: string, title: string): AgentTemplate => ({
13− id,
14− display_name: title.split(" ")[0]!,
15− handle: id,
16− name_ideas: [],
17− role: title,
18− title,
19− department: "Engineering",
20− responsibilities: ["Do the work"],
21− subagents: [],
22− instructions: "",
23− personality_preset: "crisp",
24− routing: { floor: null, ceiling: null, providers: [], pinned: null },
25−});
2611
2712 const request = (listing: string, by: string, status: InstallRequest["status"] = "open"): InstallRequest => ({
2813 id: `ins_${listing}_${by}`,
2914 listing,
30− kind: listing.startsWith("agent:") ? "agent" : "integration",
15+ kind: listing.startsWith("extension:") ? "extension" : "integration",
3116 name: listing,
3217 note: null,
3318 requested_by: by,
3520 status,
3621 resolved_by: null,
3722 resolved_at: null,
38−});
39−
40−test("a role counts the agents hired into it, not archived ones", () => {
41− const [eng, qa] = agentListings(
42− [template("engineering", "Software Engineer"), template("qa", "QA Engineer")],
43− [
44− { id: "a1", handle: "otto", display_name: "Otto", template: "engineering", archived_at: null },
45− { id: "a2", handle: "bolt", display_name: "Bolt", template: "engineering", archived_at: "2026-10-01T00:00:00Z" },
46− { id: "a3", handle: "g1t", display_name: "g1t", template: null, archived_at: null },
47− ],
48− [request("agent:qa", "ana"), request("agent:qa", "bo"), request("agent:engineering", "ana", "declined")],
49− "Ana",
50− );
51− assert.deepEqual(eng!.hired.map((a) => a.handle), ["otto"]);
52− assert.equal(eng!.requested, false, "a turned-down request isn't waiting");
53− assert.equal(qa!.hired.length, 0);
54− assert.equal(qa!.requested, true, "usernames match whatever their case");
55− assert.equal(qa!.waiting, 2);
5623 });
5724
58−test("without the agents service, nobody counts as hired", () => {
59− const [eng] = agentListings([template("engineering", "Software Engineer")], null, [], "ana");
60− assert.deepEqual(eng!.hired, []);
61−});
62−
6325 test("only integrations a workspace can connect today are listed, connected first", () => {
6426 const views = connectorsFor("workspace");
6527 const listings = integrationListings(views, { sentry: { detail: "acme", problem: null, manage: "/acme/-/integrations/alerts" } }, [], "ana", "acme");
7638
7739 test("an owner adds what a request asks for where it is added", () => {
7840 const views = connectorsFor("workspace");
79− assert.equal(addPath({ listing: "agent:qa" }, "acme", views), "/acme/-/agents/new?template=qa");
41+ assert.equal(addPath({ listing: "agent:qa" }, "acme", views), null, "agents aren't added from the Marketplace");
8042 assert.equal(addPath({ listing: "integration:jira" }, "acme", views), "/acme/-/integrations/trackers?add=jira#add");
8143 assert.equal(addPath({ listing: "nonsense" }, "acme", views), null);
8244 });
8749 for (const [k, v] of Object.entries(fields)) data.set(k, v);
8850 return data;
8951 };
90− assert.deepEqual(marketplaceForm(form({ intent: "request", listing: "agent:qa", note: " flaky " })), { intent: "request", listing: "agent:qa", note: "flaky" });
91− assert.deepEqual(marketplaceForm(form({ intent: "request", listing: "agent:qa" })), { intent: "request", listing: "agent:qa", note: null });
52+ assert.deepEqual(marketplaceForm(form({ intent: "request", listing: "integration:jira", note: " flaky " })), { intent: "request", listing: "integration:jira", note: "flaky" });
53+ assert.deepEqual(marketplaceForm(form({ intent: "request", listing: "extension:support" })), { intent: "request", listing: "extension:support", note: null });
54+ assert.equal(marketplaceForm(form({ intent: "request", listing: "agent:qa" })), null, "agents start from templates in Agents");
9255 assert.equal(marketplaceForm(form({ intent: "request", listing: "app:qa" })), null);
9356 assert.deepEqual(marketplaceForm(form({ intent: "resolve", id: "ins_1", status: "declined" })), { intent: "resolve", id: "ins_1", status: "declined" });
9457 assert.equal(marketplaceForm(form({ intent: "resolve", id: "ins_1", status: "open" })), null);
12891 assert.equal(addPath({ listing: "extension:mail" }, "acme", []), "/acme/-/marketplace/extensions/mail");
12992 });
13093
131−test("routing limits read as words", () => {
132− assert.equal(routingWords({ floor: null, ceiling: null }), "Any model the work needs");
133− assert.equal(routingWords({ floor: "large", ceiling: null }), "Large models or better");
134− assert.equal(routingWords({ floor: null, ceiling: "large" }), "Up to large models");
94+test("starter kits name extensions that are listed, and connected systems are told apart", () => {
95+ for (const kit of STARTER_KITS) {
96+ assert.ok(kit.extensions.length > 0, kit.id);
97+ for (const id of kit.extensions) assert.ok(extensionById(id), `${kit.id}: ${id}`);
98+ }
99+ const systems = FIRST_PARTY_EXTENSIONS.filter(isConnectedSystem).map((m) => m.id);
100+ assert.deepEqual(systems, ["helpdesk-bridge", "crm-bridge", "erp-bridge"]);
101+ assert.ok(FIRST_PARTY_EXTENSIONS.filter(isConnectedSystem).every((m) => m.bridges), "a connected system says what it bridges");
135102 });
136103
137104 test("the Marketplace is part of Apps, not a mode of its own", () => {
138105 assert.equal(modeOf("/acme/-/marketplace", "acme"), "apps");
139− assert.equal(modeOf("/acme/-/marketplace/agents/qa", "acme"), "apps");
106+ assert.equal(modeOf("/acme/-/marketplace/extensions/mail", "acme"), "apps");
107+ assert.equal(modeOf("/acme/-/agents/templates", "acme"), "agents", "agent templates are Agents'");
140108 });
+31−72
11 /**
2− * The Marketplace (routes/workspace/marketplace/): what a workspace can
3− * add today, read against what it has and what its people asked for.
2+ * The Marketplace (routes/workspace/marketplace/): what adds functionality
3+ * to a workspace, read against what it has and what its people asked for.
44 *
5− * Two kinds of listing can be added now: an agent from the catalog (the
6− * agents service's role templates) and an integration the connector
7− * catalog marks available for a workspace. Owners add them; anyone else
8− * asks, and the agents service keeps the request (`install_requests`).
9− * Extensions have no listings yet: their tiers are described, not filled.
5+ * Two kinds of listing: extensions, which add pages, data, cards and agent
6+ * roles (g1t's own are listed before their first release), and
7+ * integrations, which connect what a team already uses so agents can work
8+ * with it. Owners add them; anyone else asks, and the agents service keeps
9+ * the request (`install_requests`). Agents aren't listed: they start from
10+ * templates in Agents mode (lib/agent-templates.ts).
1011 *
1112 * No Workers or React imports, so it can be tested under Node.
1213 */
13−import type { AgentTemplate, ExtensionInstall, ExtensionManifest, InstallRequest, InstallRequestStatus, ListingTier, WorkspaceAgent } from "@g1t/contracts";
14+import type { ExtensionInstall, ExtensionManifest, InstallRequest, InstallRequestStatus, ListingTier } from "@g1t/contracts";
1415 import { type ConnectorView, connectorPath } from "@g1t/contracts/connectors";
1516 import { listingRef, parseListing } from "@g1t/contracts/marketplace";
1617
1718 import type { ConnectedState } from "./connectors";
1819
1920 /** The Marketplace's pages, under `/<workspace>/-/marketplace`. */
20−export function marketplacePath(slug: string, page: "" | "agents" | "integrations" | "extensions" | "requests" = ""): string {
21+export function marketplacePath(slug: string, page: "" | "extensions" | "integrations" | "requests" = ""): string {
2122 return `/${slug}/-/marketplace${page ? `/${page}` : ""}`;
22−}
23−
24−/** One role's page in the agent catalog. */
25−export function catalogAgentPath(slug: string, template: string): string {
26− return `/${slug}/-/marketplace/agents/${template}`;
27−}
28−
29−/** Where an owner hires an agent into a role: the new-agent form, with the role chosen. */
30−export function hirePath(slug: string, template: string): string {
31− return `/${slug}/-/agents/new?template=${encodeURIComponent(template)}`;
3223 }
3324
3425 /** Who stands behind a listing, in a word and a sentence. */
4839 function openCount(requests: InstallRequest[], ref: string): number {
4940 return requests.filter((request) => request.status === "open" && request.listing === ref).length;
5041 }
51−
52−/** A role in the agent catalog, with what the workspace has of it. */
53−export type AgentListing = {
54− ref: string;
55− template: AgentTemplate;
56− /** The workspace's agents hired into this role, not archived. */
57− hired: Pick<WorkspaceAgent, "id" | "handle" | "display_name">[];
58− /** The viewer asked for it, and an owner hasn't answered. */
59− requested: boolean;
60− /** Open requests for it: everyone's for an owner, the viewer's own otherwise. */
61− waiting: number;
62−};
6342
64−/**
65− * Every role in the catalog, in the order the agents service gives them,
66− * with who has been hired into each. `agents` null: the agents service
67− * could not say, so nobody counts as hired.
68− */
69−export function agentListings(
70− templates: AgentTemplate[],
71− agents: Pick<WorkspaceAgent, "id" | "handle" | "display_name" | "template" | "archived_at">[] | null,
72− requests: InstallRequest[],
73− username: string,
74−): AgentListing[] {
75− return templates.map((template) => {
76− const ref = listingRef("agent", template.id);
77− return {
78− ref,
79− template,
80− hired: (agents ?? [])
81− .filter((agent) => agent.template === template.id && !agent.archived_at)
82− .map(({ id, handle, display_name }) => ({ id, handle, display_name })),
83− requested: requests.some((request) => openFor(request, ref, username)),
84− waiting: openCount(requests, ref),
85− };
86− });
87−}
88−
8943 /** An integration a workspace can connect, with whether it has. */
9044 export type IntegrationListing = {
9145 ref: string;
148102 waiting: number;
149103 };
150104
105+/** Where extensions are grouped: connected systems apart from the rest. */
106+export const CONNECTED_SYSTEMS = "Connected systems";
107+
108+/** Whether an extension bridges a system the team already runs. */
109+export function isConnectedSystem(manifest: Pick<ExtensionManifest, "category">): boolean {
110+ return manifest.category === CONNECTED_SYSTEMS;
111+}
112+
113+/**
114+ * A starting set of extensions for one kind of team, installed together
115+ * once each is published. Ids are first-party extensions'.
116+ */
117+export type StarterKit = { id: string; name: string; about: string; extensions: string[] };
118+
119+export const STARTER_KITS: StarterKit[] = [
120+ { id: "customers", name: "Customer team", about: "Mail on your domain, support conversations and a pipeline.", extensions: ["mail", "support", "crm"] },
121+ { id: "engineering", name: "Engineering extras", about: "On-call rotations, and the helpdesk your customers already write to.", extensions: ["on-call", "helpdesk-bridge"] },
122+ { id: "operations", name: "People and operations", about: "Hiring, and the orders and invoices in your ERP.", extensions: ["recruiting", "erp-bridge"] },
123+];
124+
151125 /** Every extension listed, published ones first, each with its install. */
152126 export function extensionListings(manifests: ExtensionManifest[], installs: ExtensionInstall[], requests: InstallRequest[], username: string): ExtensionListing[] {
153127 const listings = manifests.map((manifest) => {
186160 return requests.filter((request) => request.status === "open");
187161 }
188162
189−/** Where an owner goes to add what a request asks for: the hire form, or the integration's setup page. */
163+/** Where an owner goes to add what a request asks for: the extension's page, or the integration's setup page. */
190164 export function addPath(request: Pick<InstallRequest, "listing">, slug: string, views: ConnectorView[]): string | null {
191165 const parsed = parseListing(request.listing);
192166 if (!parsed) return null;
193− if (parsed.kind === "agent") return hirePath(slug, parsed.id);
194167 if (parsed.kind === "extension") return extensionPath(slug, parsed.id);
195168 const view = views.find((v) => v.id === parsed.id);
196169 return view?.href ? connectorPath(view.href, slug) : null;
231204 }
232205 if (intent === "uninstall" && extension) return { intent, listing: extension };
233206 return null;
234−}
235−
236−/** "Software Engineer, Engineering": a role as one line. */
237−export function roleLine(template: Pick<AgentTemplate, "title" | "department">): string {
238− return template.department ? `${template.title} · ${template.department}` : template.title;
239−}
240−
241−/** How a routing limit reads: "Any model", "At least large", "Up to large", "Large to frontier". */
242−export function routingWords(routing: Pick<AgentTemplate["routing"], "floor" | "ceiling">): string {
243− const name = (tier: string) => tier.charAt(0).toUpperCase() + tier.slice(1);
244− if (routing.floor && routing.ceiling) return `${name(routing.floor)} to ${routing.ceiling} models`;
245− if (routing.floor) return `${name(routing.floor)} models or better`;
246− if (routing.ceiling) return `Up to ${routing.ceiling} models`;
247− return "Any model the work needs";
248207 }
+5−0
2727 // Code's Overview lives at -/overview now: not an old address.
2828 assert.equal(workspaceRedirect("/acme/-/overview"), null);
2929 assert.equal(workspaceRedirect("/acme/-/soon/teams"), "/acme/-/teams");
30+ // Agent templates were the Marketplace's Agents tab.
31+ assert.equal(workspaceRedirect("/acme/-/marketplace/agents"), "/acme/-/agents/templates");
32+ assert.equal(workspaceRedirect("/acme/-/marketplace/agents/qa.data", "?_routes=x"), "/acme/-/agents/templates/qa");
33+ assert.equal(workspaceRedirect("/acme/-/marketplace/agentsx"), null);
34+ assert.equal(workspaceRedirect("/acme/-/marketplace/extensions"), null);
3035 assert.equal(workspaceRedirect("/acme/-/soon/insights", "?x=1"), "/acme/-/insights?x=1");
3136 assert.equal(workspaceRedirect("/acme/-/soon/board"), null);
3237 assert.equal(workspaceRedirect("/acme/-/people"), null);
+9−1
6666 "soon/insights": "-/insights",
6767 };
6868
69+/** Workspace pages that moved with everything under them, by their old name under `-/`: the rest of the path follows. */
70+const MOVED_UNDER: Record<string, string> = {
71+ // Agent templates were the Marketplace's Agents tab.
72+ "marketplace/agents": "-/agents/templates",
73+};
74+
6975 /** The page a request is for: a click's data request ends in `.data`. */
7076 export function pagePath(pathname: string): string {
7177 return pathname.replace(/\.data$/, "").replace(/\/+$/, "") || "/";
109115 return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`;
110116 }
111117 if (parts[1] !== "-") return null;
112− const to = MOVED[parts.slice(2).join("/")];
118+ const rest = parts.slice(2).join("/");
119+ const under = Object.keys(MOVED_UNDER).find((from) => rest === from || rest.startsWith(`${from}/`));
120+ const to = under ? `${MOVED_UNDER[under]}${rest.slice(under.length)}` : MOVED[rest];
113121 if (to === undefined) return null;
114122 return `/${slug}${to ? `/${to}` : ""}${search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : ""}`;
115123 }
+9−5
150150 route("-/agents", "routes/workspace/agents/layout.tsx", [
151151 index("routes/workspace/agents.tsx"),
152152 route("new", "routes/workspace/agents/new.tsx"),
153+ // Templates: starting points for a new agent, each configured once started.
154+ route("templates", "routes/workspace/agents/templates.tsx"),
155+ route("templates/:template", "routes/workspace/agents/template.tsx"),
153156 route(":handle", "routes/workspace/agents/agent.tsx", [
154157 index("routes/workspace/agents/sessions.tsx"),
155158 route("sessions/:id", "routes/workspace/agents/session.tsx"),
203206 route("-/today", "routes/workspace/home.tsx"),
204207 route("-/home", "routes/workspace/moved.ts", { id: "routes/workspace/moved-home" }),
205208 route("-/apps", "routes/workspace/apps.tsx"),
206− // The Marketplace: anyone browses it; owners add agents and integrations,
207− // and everyone else asks (`requests` takes every form it posts).
209+ // The Marketplace: anyone browses it; owners add extensions and
210+ // integrations, and everyone else asks (`requests` takes every form it
211+ // posts). Agent templates were its Agents tab; they are under Agents now.
208212 route("-/marketplace", "routes/workspace/marketplace/layout.tsx", [
209213 index("routes/workspace/marketplace/discover.tsx"),
210− route("agents", "routes/workspace/marketplace/agents.tsx"),
211− route("agents/:template", "routes/workspace/marketplace/agent.tsx"),
212− route("integrations", "routes/workspace/marketplace/integrations.tsx"),
213214 route("extensions", "routes/workspace/marketplace/extensions.tsx"),
214215 route("extensions/:extension", "routes/workspace/marketplace/extension.tsx"),
216+ route("integrations", "routes/workspace/marketplace/integrations.tsx"),
215217 route("requests", "routes/workspace/marketplace/requests.tsx"),
216218 ]),
219+ route("-/marketplace/agents", "routes/workspace/moved.ts", { id: "routes/workspace/moved-marketplace-agents" }),
220+ route("-/marketplace/agents/:template", "routes/workspace/moved.ts", { id: "routes/workspace/moved-marketplace-agent" }),
217221 // What Code's pages say to a member without Code.
218222 route("-/code-access", "routes/workspace/code-access.tsx"),
219223 route("-/memory", "routes/workspace/memory.tsx"),
+2−2
249249 <h2 id="roster" className="text-sm font-medium">
250250 Agents <span className="text-faint">{agents.length}</span>
251251 </h2>
252− <Link to={`/${slug}/-/marketplace/agents`} className="text-xs text-muted hover:text-fg">
253− Add from the agent catalog
252+ <Link to={`/${slug}/-/agents/templates`} className="text-xs text-muted hover:text-fg">
253+ Start from a template
254254 </Link>
255255 </div>
256256 <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
+5−1
9595 <header className="mt-4 mb-8">
9696 <h1 className="text-2xl font-semibold tracking-tight">New agent</h1>
9797 <p className="mt-1.5 max-w-2xl text-sm text-muted">
98− Hire a colleague into a role: a name, a title, a team and what it is responsible for. Start from one of these roles and make it yours, or from nothing. Once it is hired, you will be in a direct message with it.
98+ Hire a colleague into a role: a name, a title, a team and what it is responsible for. Start from a template and configure it (its instructions, voice, models and budget), or from nothing. Once it is hired, you will be in a direct message with it.{" "}
99+ <Link to={`/${params.owner}/-/agents/templates`} className="text-fg underline-offset-2 hover:underline">
100+ Read each template in full
101+ </Link>
102+ .
99103 </p>
100104 </header>
101105 {loaderData.templates == null && (
+160−0
1+/**
2+ * One agent template: what an agent started from it is responsible for,
3+ * the helpers it works with, how it talks, which models it runs on, the
4+ * instructions it starts from, what you configure when you start one, and
5+ * the agents in the workspace that started from it.
6+ */
7+import { ArrowLeft } from "lucide-react";
8+import { Link, data } from "react-router";
9+
10+import type { Route } from "./+types/template";
11+import { PixelCreature } from "../../../components/agent-avatar";
12+import { StartAction } from "../../../components/agent-templates";
13+import { PRESETS } from "../../../lib/agent-form";
14+import { routingWords, templateListings, templatesPath } from "../../../lib/agent-templates";
15+import { loadTemplates } from "../../../lib/agent-templates.server";
16+import { page } from "../../../lib/meta";
17+import { requireUser, roleIn } from "../../../lib/session.server";
18+
19+export function meta({ params, ...args }: Route.MetaArgs) {
20+ const template = args.loaderData?.templates?.find((t) => t.id === params.template);
21+ return page(args, { title: `${template ? template.title : "Template"} · Agent templates · ${params.owner} · g1t` });
22+}
23+
24+export async function loader({ params, context, request }: Route.LoaderArgs) {
25+ const viewer = requireUser(context, request);
26+ const slug = params.owner.toLowerCase();
27+ const role = roleIn(viewer, slug);
28+ if (!role) throw data(null, { status: 404 });
29+ const loaded = await loadTemplates(slug, viewer);
30+ if (loaded.templates && !loaded.templates.some((t) => t.id === params.template)) throw data(null, { status: 404 });
31+ return { slug, owner: role === "owner", ...loaded };
32+}
33+
34+export default function AgentTemplate({ loaderData, params }: Route.ComponentProps) {
35+ const { slug, owner } = loaderData;
36+ const listing = templateListings(loaderData.templates ?? [], loaderData.agents).find((l) => l.template.id === params.template);
37+ const back = (
38+ <Link to={templatesPath(slug)} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
39+ <ArrowLeft size={14} />
40+ All templates
41+ </Link>
42+ );
43+ if (!listing) {
44+ return (
45+ <div>
46+ {back}
47+ <p className="mt-6 text-sm text-muted">Templates can't be shown right now: the agents service didn't answer. Reload in a minute.</p>
48+ </div>
49+ );
50+ }
51+ const { template, agents } = listing;
52+ const voice = PRESETS.find((preset) => preset.value === template.personality_preset);
53+ const names = [template.display_name, ...template.name_ideas.filter((name) => name !== template.display_name)];
54+ return (
55+ <div className="pb-4">
56+ {back}
57+ <header className="mt-5 flex flex-wrap items-center gap-4">
58+ <PixelCreature seed={template.handle} size={64} />
59+ <div className="min-w-0 grow basis-60">
60+ <h1 className="text-2xl font-semibold tracking-tight">{template.title}</h1>
61+ <p className="mt-0.5 text-sm text-muted">{template.department ? `${template.department} · ` : ""}A template from g1t</p>
62+ </div>
63+ <StartAction listing={listing} slug={slug} owner={owner} className="h-9 px-4 text-sm" />
64+ </header>
65+
66+ <div className="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_18rem]">
67+ <div className="min-w-0 space-y-8">
68+ <section aria-labelledby="duties">
69+ <h2 id="duties" className="text-sm font-semibold">
70+ What it's responsible for
71+ </h2>
72+ <ul className="mt-3 space-y-2">
73+ {template.responsibilities.map((duty) => (
74+ <li key={duty} className="flex gap-2.5 text-sm text-fg-soft">
75+ <span className="mt-2 size-1.5 shrink-0 rounded-full bg-accent" aria-hidden="true" />
76+ {duty}
77+ </li>
78+ ))}
79+ </ul>
80+ </section>
81+
82+ {template.subagents.length > 0 && (
83+ <section aria-labelledby="helpers">
84+ <h2 id="helpers" className="text-sm font-semibold">
85+ Helpers it works with
86+ </h2>
87+ <p className="mt-1 text-sm text-muted">Smaller agents it starts for one piece of its work, paid from its own budget.</p>
88+ <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
89+ {template.subagents.map((helper) => (
90+ <li key={helper.name} className="px-4 py-3">
91+ <p className="font-mono text-[0.8125rem]">{helper.name}</p>
92+ <p className="mt-0.5 text-sm text-muted">{helper.description}</p>
93+ </li>
94+ ))}
95+ </ul>
96+ </section>
97+ )}
98+
99+ <section aria-labelledby="instructions">
100+ <h2 id="instructions" className="text-sm font-semibold">
101+ The instructions it starts from
102+ </h2>
103+ <p className="mt-1 text-sm text-muted">Yours to edit when you start the agent, and on its profile after.</p>
104+ <pre className="mt-3 max-h-96 overflow-auto rounded-xl border border-line bg-surface p-4 font-sans text-[0.8125rem] leading-relaxed whitespace-pre-wrap text-fg-soft">{template.instructions}</pre>
105+ </section>
106+ </div>
107+
108+ <aside className="space-y-6 text-sm">
109+ <section aria-labelledby="configure" className="rounded-xl border border-line bg-surface p-4">
110+ <h2 id="configure" className="text-xs text-faint">
111+ What you configure
112+ </h2>
113+ <dl className="mt-3 space-y-4">
114+ <div>
115+ <dt className="text-xs text-faint">Name</dt>
116+ <dd className="mt-0.5">{names.slice(0, 4).join(", ")}, or your own</dd>
117+ </div>
118+ <div>
119+ <dt className="text-xs text-faint">Voice</dt>
120+ <dd className="mt-0.5">{voice ? voice.label : template.personality_preset}</dd>
121+ {voice && <dd className="text-xs text-muted">{voice.about}</dd>}
122+ </div>
123+ <div>
124+ <dt className="text-xs text-faint">Models</dt>
125+ <dd className="mt-0.5">{routingWords(template.routing)}</dd>
126+ </div>
127+ <div>
128+ <dt className="text-xs text-faint">Budget</dt>
129+ <dd className="mt-0.5">The workspace's default for a new agent, billed at what it costs</dd>
130+ </div>
131+ <div>
132+ <dt className="text-xs text-faint">Team and access</dt>
133+ <dd className="mt-0.5">A team you pick. It works with the access of the person asking, never more.</dd>
134+ </div>
135+ </dl>
136+ </section>
137+ <section aria-labelledby="here" className="rounded-xl border border-line bg-surface p-4">
138+ <h2 id="here" className="text-xs text-faint">
139+ In this workspace
140+ </h2>
141+ {agents.length === 0 ? (
142+ <p className="mt-1 text-muted">No agent has started from it yet.</p>
143+ ) : (
144+ <ul className="mt-2 space-y-1.5">
145+ {agents.map((agent) => (
146+ <li key={agent.id} className="flex items-center gap-2">
147+ <PixelCreature seed={agent.handle} size={20} />
148+ <Link to={`/${slug}/-/agents/${agent.handle}`} className="truncate hover:underline">
149+ {agent.display_name} <span className="text-faint">@{agent.handle}</span>
150+ </Link>
151+ </li>
152+ ))}
153+ </ul>
154+ )}
155+ </section>
156+ </aside>
157+ </div>
158+ </div>
159+ );
160+}
+87−0
1+/**
2+ * Agent templates: the starting points g1t provides for a new agent, by
3+ * department, with the agents in the workspace that started from each. A
4+ * template isn't installed: an owner starts an agent from one and
5+ * configures it, and the agent is the workspace's own from then on.
6+ */
7+import { ArrowLeft, Plus } from "lucide-react";
8+import { Link, data } from "react-router";
9+
10+import type { Route } from "./+types/templates";
11+import { TemplateCard } from "../../../components/agent-templates";
12+import { EmptyState } from "../../../components/ui";
13+import { byDepartment, startPath, templateListings } from "../../../lib/agent-templates";
14+import { loadTemplates } from "../../../lib/agent-templates.server";
15+import { page } from "../../../lib/meta";
16+import { requireUser, roleIn } from "../../../lib/session.server";
17+
18+export function meta({ params, ...args }: Route.MetaArgs) {
19+ return page(args, { title: `Agent templates · ${params.owner} · g1t` });
20+}
21+
22+export async function loader({ params, context, request }: Route.LoaderArgs) {
23+ const viewer = requireUser(context, request);
24+ const slug = params.owner.toLowerCase();
25+ const role = roleIn(viewer, slug);
26+ if (!role) throw data(null, { status: 404 });
27+ return { slug, owner: role === "owner", ...(await loadTemplates(slug, viewer)) };
28+}
29+
30+export default function AgentTemplates({ loaderData }: Route.ComponentProps) {
31+ const { slug, owner, templates, agents } = loaderData;
32+ const listings = templateListings(templates ?? [], agents);
33+ return (
34+ <div className="pb-4">
35+ <Link to={`/${slug}/-/agents`} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
36+ <ArrowLeft size={14} />
37+ Agents
38+ </Link>
39+ <header className="mt-4 mb-8 flex flex-wrap items-end justify-between gap-4">
40+ <div className="min-w-0 grow basis-lg">
41+ <h1 className="text-2xl font-semibold tracking-tight">Templates</h1>
42+ <p className="mt-1.5 max-w-2xl text-sm text-muted">
43+ Starting points for a new agent, each a role with responsibilities, helpers, a voice, model limits and instructions. Start an agent from one and make it yours: its name, instructions, voice, models and budget are all
44+ yours to change, then and later. A template is never updated under an agent started from it.
45+ </p>
46+ </div>
47+ {owner && (
48+ <Link to={`/${slug}/-/agents/new`} className="inline-flex h-9 shrink-0 items-center gap-1.5 rounded-md bg-fg px-4 text-sm font-medium text-bg hover:bg-fg-hover">
49+ <Plus size={15} />
50+ New agent
51+ </Link>
52+ )}
53+ </header>
54+ {templates == null ? (
55+ <EmptyState title="Templates can't be shown right now">The agents service didn't answer. Reload in a minute.</EmptyState>
56+ ) : (
57+ <div className="space-y-10">
58+ {/* One grid in department order: most departments have a single template. */}
59+ <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
60+ {byDepartment(listings)
61+ .flatMap(([, list]) => list)
62+ .map((listing) => (
63+ <TemplateCard key={listing.template.id} listing={listing} slug={slug} owner={owner} />
64+ ))}
65+ </div>
66+ {owner && (
67+ <section aria-label="Your own">
68+ <h2 className="mb-3 text-xs font-semibold tracking-wide text-faint uppercase">Your own</h2>
69+ <Link
70+ to={startPath(slug, "blank")}
71+ className="flex max-w-md items-center gap-3 rounded-xl border border-dashed border-line-strong p-4 text-sm transition-colors hover:border-fg/40 hover:bg-raised/40"
72+ >
73+ <span className="flex size-11 shrink-0 items-center justify-center rounded-lg bg-raised text-muted">
74+ <Plus size={18} />
75+ </span>
76+ <span className="min-w-0">
77+ <span className="block font-medium">Start from nothing</span>
78+ <span className="block text-xs text-muted">Give it a name, a title, a team and what it is responsible for.</span>
79+ </span>
80+ </Link>
81+ </section>
82+ )}
83+ </div>
84+ )}
85+ </div>
86+ );
87+}
+0−156
1−/**
2− * One role in the agent catalog: what an agent hired into it is
3− * responsible for, the helpers it works with, how it talks, which models
4− * it runs on, the instructions it starts from, and who in the workspace
5− * already has the job.
6− */
7−import { ArrowLeft } from "lucide-react";
8−import { Link, data } from "react-router";
9−
10−import type { Route } from "./+types/agent";
11−import { PixelCreature } from "../../../components/agent-avatar";
12−import { AgentAction, TierBadge } from "../../../components/marketplace";
13−import { PRESETS } from "../../../lib/agent-form";
14−import { agentListings, marketplacePath, roleLine, routingWords } from "../../../lib/marketplace";
15−import { loadCatalog } from "../../../lib/marketplace.server";
16−import { page } from "../../../lib/meta";
17−import { requireUser, roleIn } from "../../../lib/session.server";
18−import { useMarketplace } from "./layout";
19−
20−export function meta({ params, ...args }: Route.MetaArgs) {
21− const template = args.loaderData?.templates?.find((t) => t.id === params.template);
22− return page(args, { title: `${template ? template.title : "Agent"} · Marketplace · ${params.owner} · g1t` });
23−}
24−
25−export async function loader({ params, context, request }: Route.LoaderArgs) {
26− const viewer = requireUser(context, request);
27− const slug = params.owner.toLowerCase();
28− if (!roleIn(viewer, slug)) throw data(null, { status: 404 });
29− const catalog = await loadCatalog(slug, viewer);
30− if (catalog.templates && !catalog.templates.some((t) => t.id === params.template)) throw data(null, { status: 404 });
31− return catalog;
32−}
33−
34−export default function CatalogAgent({ loaderData, params }: Route.ComponentProps) {
35− const { slug, owner, username, requests } = useMarketplace();
36− const listing = agentListings(loaderData.templates ?? [], loaderData.agents, requests?.requests ?? [], username).find((l) => l.template.id === params.template);
37− const back = (
38− <Link to={marketplacePath(slug, "agents")} className="inline-flex items-center gap-1.5 text-sm text-muted hover:text-fg">
39− <ArrowLeft size={14} />
40− All roles
41− </Link>
42− );
43− if (!listing) {
44− return (
45− <div>
46− {back}
47− <p className="mt-6 text-sm text-muted">The agent catalog can't be shown right now: the agents service didn't answer. Reload in a minute.</p>
48− </div>
49− );
50− }
51− const { template, hired } = listing;
52− const voice = PRESETS.find((preset) => preset.value === template.personality_preset);
53− const others = template.name_ideas.filter((name) => name !== template.display_name);
54− return (
55− <div>
56− {back}
57− <header className="mt-5 flex flex-wrap items-center gap-4">
58− <PixelCreature seed={template.handle} size={64} />
59− <div className="min-w-0 grow basis-60">
60− <h2 className="flex flex-wrap items-center gap-2 text-xl font-semibold tracking-tight">
61− {template.display_name}
62− <TierBadge tier="official" />
63− </h2>
64− <p className="mt-0.5 text-sm text-muted">{roleLine(template)}</p>
65− </div>
66− <AgentAction listing={listing} slug={slug} owner={owner} className="h-9 px-4 text-sm" />
67− </header>
68−
69− <div className="mt-8 grid gap-8 lg:grid-cols-[minmax(0,1fr)_18rem]">
70− <div className="min-w-0 space-y-8">
71− <section aria-labelledby="duties">
72− <h3 id="duties" className="text-sm font-semibold">
73− What it's responsible for
74− </h3>
75− <ul className="mt-3 space-y-2">
76− {template.responsibilities.map((duty) => (
77− <li key={duty} className="flex gap-2.5 text-sm text-fg-soft">
78− <span className="mt-2 size-1.5 shrink-0 rounded-full bg-accent" aria-hidden="true" />
79− {duty}
80− </li>
81− ))}
82− </ul>
83− </section>
84−
85− {template.subagents.length > 0 && (
86− <section aria-labelledby="helpers">
87− <h3 id="helpers" className="text-sm font-semibold">
88− Helpers it works with
89− </h3>
90− <p className="mt-1 text-sm text-muted">Smaller agents it starts for one piece of its work, paid from its own budget.</p>
91− <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface">
92− {template.subagents.map((helper) => (
93− <li key={helper.name} className="px-4 py-3">
94− <p className="font-mono text-[0.8125rem]">{helper.name}</p>
95− <p className="mt-0.5 text-sm text-muted">{helper.description}</p>
96− </li>
97− ))}
98− </ul>
99− </section>
100− )}
101−
102− <section aria-labelledby="instructions">
103− <h3 id="instructions" className="text-sm font-semibold">
104− The instructions it starts from
105− </h3>
106− <p className="mt-1 text-sm text-muted">Yours to change once it is added, on its profile.</p>
107− <pre className="mt-3 max-h-96 overflow-auto rounded-xl border border-line bg-surface p-4 font-sans text-[0.8125rem] leading-relaxed whitespace-pre-wrap text-fg-soft">{template.instructions}</pre>
108− </section>
109− </div>
110−
111− <aside className="space-y-6 text-sm">
112− <dl className="space-y-4 rounded-xl border border-line bg-surface p-4">
113− <div>
114− <dt className="text-xs text-faint">Voice</dt>
115− <dd className="mt-0.5">{voice ? voice.label : template.personality_preset}</dd>
116− {voice && <dd className="text-xs text-muted">{voice.about}</dd>}
117− </div>
118− <div>
119− <dt className="text-xs text-faint">Models</dt>
120− <dd className="mt-0.5">{routingWords(template.routing)}</dd>
121− </div>
122− <div>
123− <dt className="text-xs text-faint">Budget</dt>
124− <dd className="mt-0.5">The workspace's default for a new agent, billed at what it costs</dd>
125− </div>
126− {others.length > 0 && (
127− <div>
128− <dt className="text-xs text-faint">Other names it suits</dt>
129− <dd className="mt-0.5 text-muted">{others.slice(0, 5).join(", ")}</dd>
130− </div>
131− )}
132− </dl>
133− <section aria-labelledby="here" className="rounded-xl border border-line bg-surface p-4">
134− <h3 id="here" className="text-xs text-faint">
135− In this workspace
136− </h3>
137− {hired.length === 0 ? (
138− <p className="mt-1 text-muted">Nobody has this job yet.</p>
139− ) : (
140− <ul className="mt-2 space-y-1.5">
141− {hired.map((agent) => (
142− <li key={agent.id} className="flex items-center gap-2">
143− <PixelCreature seed={agent.handle} size={20} />
144− <Link to={`/${slug}/-/agents/${agent.handle}`} className="truncate hover:underline">
145− {agent.display_name} <span className="text-faint">@{agent.handle}</span>
146− </Link>
147− </li>
148− ))}
149− </ul>
150− )}
151− </section>
152− </aside>
153− </div>
154− </div>
155− );
156−}
+0−60
1−/**
2− * The agent catalog: every role an agent can be hired into, by
3− * department, with who in the workspace was hired into each. Owners add
4− * one through the new-agent form with the role chosen; anyone else asks.
5− */
6−import type { Route } from "./+types/agents";
7−import { AgentCard } from "../../../components/marketplace";
8−import { G1tMark } from "../../../components/orchestrator";
9−import { EmptyState } from "../../../components/ui";
10−import { agentListings } from "../../../lib/marketplace";
11−import { loadCatalog } from "../../../lib/marketplace.server";
12−import { requireUser, roleIn } from "../../../lib/session.server";
13−import { useMarketplace } from "./layout";
14−
15−export async function loader({ params, context, request }: Route.LoaderArgs) {
16− const viewer = requireUser(context, request);
17− const slug = params.owner.toLowerCase();
18− if (!roleIn(viewer, slug)) throw new Response(null, { status: 404 });
19− return loadCatalog(slug, viewer);
20−}
21−
22−export default function AgentCatalog({ loaderData }: Route.ComponentProps) {
23− const { slug, owner, username, requests } = useMarketplace();
24− if (loaderData.templates == null) {
25− return <EmptyState title="The agent catalog can't be shown right now">The agents service didn't answer. Reload in a minute.</EmptyState>;
26− }
27− const listings = agentListings(loaderData.templates, loaderData.agents, requests?.requests ?? [], username);
28− // By department, in the order the catalog lists them.
29− const departments = new Map<string, typeof listings>();
30− for (const listing of listings) {
31− const key = listing.template.department || "Other";
32− departments.set(key, [...(departments.get(key) ?? []), listing]);
33− }
34− return (
35− <div className="space-y-10">
36− <div className="flex flex-wrap items-start gap-4 rounded-2xl border border-line bg-surface p-5">
37− <G1tMark size={40} />
38− <div className="min-w-0 grow basis-72">
39− <p className="text-sm font-semibold">Start with g1t, add specialists as the work grows</p>
40− <p className="mt-1 max-w-3xl text-sm text-muted">
41− g1t, the orchestrator, is in every workspace and knows every agent in it. Each role below is a starting point: pick a name, a team and a budget when you add it, and change anything after. Every agent works with the access of the person asking, never more, and starts with the workspace's default budget.
42− </p>
43− </div>
44− <p className="shrink-0 text-xs text-faint">
45− {listings.length} {listings.length === 1 ? "role" : "roles"}, built by g1t
46− </p>
47− </div>
48− {[...departments].map(([department, list]) => (
49− <section key={department} aria-label={department}>
50− <h2 className="mb-3 text-xs font-semibold tracking-wide text-faint uppercase">{department}</h2>
51− <div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
52− {list.map((listing) => (
53− <AgentCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
54− ))}
55− </div>
56− </section>
57− ))}
58− </div>
59− );
60−}
+72−75
11 /**
2− * The Marketplace's front page: what comes with every workspace, roles
3− * from the agent catalog, integrations (connected first) and extensions.
4− * Every listing is real: what can be added today with its real state, or
5− * g1t's own extensions marked Soon until their first release.
2+ * The Marketplace's front page: featured extensions, integrations
3+ * (connected first), starter kits, and connected systems. Every listing is
4+ * real: integrations a workspace can connect today, with their real state,
5+ * and g1t's own extensions marked Soon until their first release.
66 */
7−import { Rocket } from "lucide-react";
8−import type { ReactNode } from "react";
97 import { Link } from "react-router";
108
9+import { type ExtensionManifest, FIRST_PARTY_EXTENSIONS, extensionById } from "@g1t/contracts/marketplace";
1110 import { connectorsFor } from "@g1t/contracts/connectors";
1211
1312 import type { Route } from "./+types/discover";
14−import { appIcon } from "../../../components/apps";
15−import { AgentCard, ExtensionCard, IntegrationCard, SectionHead, SeeAll } from "../../../components/marketplace";
16−import { G1tMark } from "../../../components/orchestrator";
17−import { EmptyState } from "../../../components/ui";
18−import { appOf } from "../../../lib/apps";
19−import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace";
20−import { agentListings, extensionListings, integrationListings, marketplacePath } from "../../../lib/marketplace";
21−import { loadCatalog, loadConnected } from "../../../lib/marketplace.server";
13+import { ComingBadge, ExtensionCard, ExtensionMark, IntegrationCard, SectionHead, SeeAll, StarterKitCard, TierBadge } from "../../../components/marketplace";
14+import { STARTER_KITS, extensionListings, extensionPath, integrationListings, isConnectedSystem, marketplacePath } from "../../../lib/marketplace";
15+import { loadConnected } from "../../../lib/marketplace.server";
2216 import { requireUser, roleIn } from "../../../lib/session.server";
2317 import { useMarketplace } from "./layout";
2418
2620 const viewer = requireUser(context, request);
2721 const slug = params.owner.toLowerCase();
2822 if (!roleIn(viewer, slug)) throw new Response(null, { status: 404 });
29− const [catalog, connected] = await Promise.all([loadCatalog(slug, viewer), loadConnected(slug, viewer)]);
30− return { ...catalog, connected };
23+ return { connected: await loadConnected(slug, viewer) };
3124 }
3225
33−/** How many of each kind of listing the front page shows before "All". */
34−const AGENTS_SHOWN = 5;
26+/** How many integrations the front page shows before "All". */
3527 const INTEGRATIONS_SHOWN = 6;
28+/** The extensions the front page leads with: the first the largest. */
29+const FEATURED = ["mail", "support", "crm"];
3630
3731 export default function MarketplaceDiscover({ loaderData }: Route.ComponentProps) {
3832 const { slug, owner, username, requests, installs } = useMarketplace();
3933 const asked = requests?.requests ?? [];
40− const agents = agentListings(loaderData.templates ?? [], loaderData.agents, asked, username);
4134 const integrations = integrationListings(connectorsFor("workspace"), loaderData.connected, asked, username, slug);
4235 const extensions = extensionListings(FIRST_PARTY_EXTENSIONS, installs, asked, username);
43− const builtIn: { name: string; to: string; icon: ReactNode }[] = [
44− ...(["code", "chat", "agents", "artifacts"] as const).map((key) => ({ name: appOf(key).name, to: appOf(key).path(slug), icon: appIcon(key, 15) })),
45− { name: "Deployments", to: `/${slug}/-/projects`, icon: <Rocket size={15} /> },
46− ];
36+ const featured = FEATURED.map((id) => extensionById(id)).filter((manifest) => manifest != null);
37+ const bridges = extensions.filter((listing) => isConnectedSystem(listing.manifest));
4738 return (
4839 <div className="space-y-12">
49− <section aria-labelledby="included" className="rounded-2xl border border-line bg-surface p-5 sm:p-6">
50− <div className="flex flex-wrap items-baseline justify-between gap-2">
51− <h2 id="included" className="text-base font-semibold tracking-tight">
52− In every workspace
53− </h2>
54− <span className="text-xs text-faint">Nothing to add</span>
55− </div>
56− <p className="mt-1 max-w-2xl text-sm text-muted">Code and Deployments are built in, because what agents make has to be reviewed, tested and shipped. So are Chat, Agents and Artifacts.</p>
57− <ul className="mt-4 flex flex-wrap gap-2">
58− {builtIn.map(({ name, to, icon }) => (
59− <li key={name}>
60− <Link to={to} className="flex h-9 items-center gap-2 rounded-full border border-line bg-bg pr-3.5 pl-2.5 text-sm transition-colors hover:border-line-strong">
61− <span className="text-muted">{icon}</span>
62− {name}
63− </Link>
64− </li>
65− ))}
66− </ul>
67− </section>
68−
69− <section aria-labelledby="agents">
70− <SectionHead id="agents" title="Agents" aside={agents.length > 0 && <SeeAll to={marketplacePath(slug, "agents")}>All {agents.length} roles</SeeAll>}>
71− Each role brings responsibilities, a voice and the helpers it works with. Add one and rename it, or keep to g1t, which brings in whoever fits.
72− </SectionHead>
73− {loaderData.templates == null ? (
74− <EmptyState title="The agent catalog can't be shown right now">The agents service didn't answer. Reload in a minute.</EmptyState>
75− ) : (
76− <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
77− <Link
78− to={`/${slug}/-/agents`}
79− className="flex flex-col rounded-xl border border-accent/30 bg-accent/[0.06] p-4 transition-colors hover:border-accent/60"
80− >
81− <div className="flex items-center gap-3">
82− <G1tMark size={44} />
83− <div className="min-w-0">
84− <p className="text-sm font-semibold">g1t</p>
85− <p className="text-xs text-muted">Orchestrator · in every workspace</p>
86− </div>
87− </div>
88− <p className="mt-3 grow text-[0.8125rem] leading-snug text-muted">Ask g1t anything. It answers itself or hands the work to the agent that fits, and you see the hand-off.</p>
89− <p className="mt-4 border-t border-accent/20 pt-3 text-xs text-accent">Already here</p>
90− </Link>
91− {agents.slice(0, AGENTS_SHOWN).map((listing) => (
92− <AgentCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
40+ <section aria-labelledby="featured">
41+ <h2 id="featured" className="sr-only">
42+ Featured extensions
43+ </h2>
44+ <div className="grid gap-3 lg:grid-cols-[minmax(0,1.6fr)_minmax(0,1fr)]">
45+ {featured[0] && <Feature manifest={featured[0]} slug={slug} large />}
46+ <div className="grid gap-3">
47+ {featured.slice(1).map((manifest) => (
48+ <Feature key={manifest.id} manifest={manifest} slug={slug} />
9349 ))}
9450 </div>
95− )}
51+ </div>
9652 </section>
9753
9854 <section aria-labelledby="integrations">
99− <SectionHead id="integrations" title="Integrations" aside={<SeeAll to={marketplacePath(slug, "integrations")}>All {integrations.length}</SeeAll>}>
100− Integrations connect what you already use and give agents new abilities, without adding pages.
55+ <SectionHead id="integrations" title="Integrations" aside={<SeeAll to={marketplacePath(slug, "integrations")}>All integrations</SeeAll>}>
56+ Connect what your team already uses, so agents can read it and act in it. Each lists, in plain words, what it lets agents do.
10157 </SectionHead>
10258 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
10359 {integrations.slice(0, INTEGRATIONS_SHOWN).map((listing) => (
10662 </div>
10763 </section>
10864
109− <section aria-labelledby="extensions">
110− <SectionHead id="extensions" title="Extensions" aside={<SeeAll to={marketplacePath(slug, "extensions")}>All extensions</SeeAll>}>
111− Extensions add pages, data, cards and agent roles for what your team does besides code. Each is shared from a public repository, and the install screen lists every permission and every domain its data goes to.
65+ <section aria-labelledby="kits">
66+ <SectionHead id="kits" title="Starter kits" aside="Several extensions, one install">
67+ A set of extensions for one kind of team, installed together.
11268 </SectionHead>
113− <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
114− {extensions.map((listing) => (
69+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
70+ {STARTER_KITS.map((kit) => (
71+ <StarterKitCard key={kit.id} kit={kit} slug={slug} extensions={kit.extensions.map((id) => extensionById(id)).filter((manifest) => manifest != null)} />
72+ ))}
73+ </div>
74+ </section>
75+
76+ <section aria-labelledby="systems">
77+ <SectionHead id="systems" title="Connected systems" aside={<SeeAll to={marketplacePath(slug, "extensions")}>All extensions</SeeAll>}>
78+ Extensions that bridge a helpdesk, CRM or ERP you already run, so agents work across it and g1t together.
79+ </SectionHead>
80+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
81+ {bridges.map((listing) => (
11582 <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
11683 ))}
11784 </div>
11986 </div>
12087 );
12188 }
89+
90+/** A featured extension: its mark, name and tagline, and the pages it adds. */
91+function Feature({ manifest, slug, large = false }: { manifest: ExtensionManifest; slug: string; large?: boolean }) {
92+ return (
93+ <Link
94+ to={extensionPath(slug, manifest.id)}
95+ prefetch="intent"
96+ className={`group relative flex flex-col justify-end overflow-hidden rounded-2xl border border-line bg-surface transition-colors hover:border-line-strong ${large ? "min-h-64 p-6" : "min-h-36 p-4"}`}
97+ >
98+ {/* A wash of the accent, from the corner. */}
99+ <span aria-hidden="true" className="pointer-events-none absolute inset-0 bg-[radial-gradient(120%_120%_at_100%_0%,var(--color-accent)_0%,transparent_55%)] opacity-[0.12]" />
100+ <div className="relative flex items-center gap-2">
101+ <ExtensionMark manifest={manifest} size={large ? 36 : 28} />
102+ <TierBadge tier={manifest.publisher.tier} />
103+ {manifest.status !== "available" && <ComingBadge />}
104+ </div>
105+ <h3 className={`relative mt-3 font-semibold tracking-tight ${large ? "text-2xl" : "text-base"}`}>{manifest.name}</h3>
106+ <p className={`relative mt-1 max-w-xl text-muted ${large ? "text-[0.9375rem]" : "line-clamp-2 text-[0.8125rem]"}`}>{large ? manifest.description : manifest.tagline}</p>
107+ {large && (
108+ <ul className="relative mt-4 flex flex-wrap gap-1.5" aria-label="Pages it adds">
109+ {manifest.adds.pages.map((name) => (
110+ <li key={name} className="rounded-full border border-line bg-bg/60 px-2.5 py-0.5 text-xs text-fg-soft">
111+ {name}
112+ </li>
113+ ))}
114+ </ul>
115+ )}
116+ </Link>
117+ );
118+}
+2−1
7676 </h3>
7777 <div className="mt-3 grid gap-3 sm:grid-cols-2">
7878 <Group title="Pages, in its sidebar" items={manifest.adds.pages} empty="None" />
79− <Group title="Agent roles you can add" items={manifest.adds.agent_roles} empty="None. Your agents get its tools." />
79+ <Group title="Cards, in chat and elsewhere" items={manifest.adds.cards} empty="None" />
80+ <Group title="Agent roles it brings" items={manifest.adds.agent_roles} empty="None. Your agents get its tools." />
8081 <Group title="Tools for agents" items={manifest.adds.tools} empty="None" />
8182 <Group title="Notifications it sends" items={manifest.adds.notifications} empty="None" />
8283 </div>
+18−5
11 /**
22 * Extensions in the Marketplace: every listed extension, from its
33 * manifest (@g1t/contracts marketplace.ts), published ones first, with what
4− * the workspace installed; then how extensions are published and who
5− * stands behind each tier.
4+ * the workspace installed; connected systems apart; then how extensions
5+ * are published and who stands behind each tier.
66 */
77 import { FIRST_PARTY_EXTENSIONS, LISTING_TIERS } from "@g1t/contracts/marketplace";
88
99 import { ExtensionCard, SectionHead, TierBadge } from "../../../components/marketplace";
10−import { TIERS, extensionListings } from "../../../lib/marketplace";
10+import { TIERS, extensionListings, isConnectedSystem } from "../../../lib/marketplace";
1111 import { useMarketplace } from "./layout";
1212
1313 export default function MarketplaceExtensions() {
1414 const { slug, owner, username, requests, installs } = useMarketplace();
1515 const listings = extensionListings(FIRST_PARTY_EXTENSIONS, installs, requests?.requests ?? [], username);
1616 const published = listings.filter((l) => l.manifest.status === "available");
17− const coming = listings.filter((l) => l.manifest.status !== "available");
17+ const coming = listings.filter((l) => l.manifest.status !== "available" && !isConnectedSystem(l.manifest));
18+ const systems = listings.filter((l) => l.manifest.status !== "available" && isConnectedSystem(l.manifest));
1819 return (
1920 <div className="space-y-12">
2021 {published.length > 0 && (
2829 </section>
2930 )}
3031 <section aria-labelledby="soon">
31− <SectionHead id="soon" title={published.length > 0 ? "Coming" : "Coming from g1t"} aside={`${coming.length}`}>
32+ <SectionHead id="soon" title={published.length > 0 ? "Soon" : "Soon from g1t"} aside={`${coming.length}`}>
3233 Extensions add pages, data, cards and agent roles for what your team does besides code. These are g1t's own, listed before their first release; each can be installed once it is published.
3334 </SectionHead>
3435 <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
3738 ))}
3839 </div>
3940 </section>
41+ {systems.length > 0 && (
42+ <section aria-labelledby="systems">
43+ <SectionHead id="systems" title="Connected systems" aside={`${systems.length}`}>
44+ Extensions that bridge a system your team already runs, so agents work across it and g1t together. The install screen names where its data goes.
45+ </SectionHead>
46+ <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
47+ {systems.map((listing) => (
48+ <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} />
49+ ))}
50+ </div>
51+ </section>
52+ )}
4053 <section aria-labelledby="how" className="rounded-2xl border border-line bg-surface p-5 sm:p-6">
4154 <h2 id="how" className="text-base font-semibold tracking-tight">
4255 How extensions are shared
+37−5
11 /**
22 * Integrations in the Marketplace: every connector a workspace can connect
3− * today, connected ones first, then the ones the catalog lists as coming.
4− * Each is set up on its own page under Workspace → Integrations; owners
5− * connect, anyone else asks.
3+ * today, connected ones first; those each person connects for themselves;
4+ * then the ones the catalog lists as coming. Each workspace one is set up
5+ * on its own page under Workspace → Integrations; owners connect, anyone
6+ * else asks.
67 */
78 import { Search } from "lucide-react";
89 import { useState } from "react";
910 import { Link } from "react-router";
1011
11−import { connectorView, connectorsFor, CONNECTORS } from "@g1t/contracts/connectors";
12+import { type ConnectorView, connectorView, connectorsFor, CONNECTORS } from "@g1t/contracts/connectors";
1213
1314 import type { Route } from "./+types/integrations";
1415 import { ConnectorMark } from "../../../components/connectors";
3536 const available = shown.filter((listing) => !listing.connected);
3637 const wanted = query.trim().toLowerCase();
3738 const personal = CONNECTORS.filter((c) => !c.scopes.includes("workspace")).map((c) => connectorView(c, "personal")).filter((view) => view != null);
39+ // What each person connects for themselves, today.
40+ const yours = CONNECTORS.map((c) => connectorView(c, "personal")).filter((view): view is ConnectorView & { href: string } => view != null && view.status === "available" && view.href != null && (!wanted || [view.name, view.description, ...view.keywords].some((w) => w.toLowerCase().includes(wanted))));
3841 const coming = comingIntegrations(views, personal).filter((view) => !wanted || [view.name, view.description, ...view.keywords].some((w) => w.toLowerCase().includes(wanted)));
3942 return (
4043 <div className="space-y-10">
6770 )}
6871
6972 <section aria-labelledby="available">
70− <SectionHead id="available" title={connected.length > 0 ? "Available" : "Available to connect"} aside={`${available.length}`} />
73+ <SectionHead id="available" title={connected.length > 0 ? "Available" : "Available to connect"} aside={`${available.length}`}>
74+ Connected once for the whole workspace by an owner. Each says what it lets agents do.
75+ </SectionHead>
7176 {available.length === 0 ? (
7277 <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">{wanted ? "No integration you can connect matches." : "Everything available is connected."}</p>
7378 ) : (
7984 )}
8085 </section>
8186
87+ {yours.length > 0 && (
88+ <section aria-labelledby="yours">
89+ <SectionHead id="yours" title="Connected by each person" aside={`${yours.length}`}>
90+ Each person connects these for themselves, and agents use them only when that person asks.
91+ </SectionHead>
92+ <ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
93+ {yours.map((view) => (
94+ <li key={view.id} className="flex flex-col rounded-xl border border-line bg-surface p-4">
95+ <div className="flex grow items-start gap-3">
96+ <ConnectorMark view={view} size={40} />
97+ <div className="min-w-0 grow">
98+ <h3 className="truncate text-sm font-semibold">{view.name}</h3>
99+ <p className="mt-0.5 line-clamp-2 text-[0.8125rem] leading-snug text-muted">{view.description}</p>
100+ </div>
101+ </div>
102+ <div className="mt-4 flex items-center justify-between gap-3 border-t border-line pt-3">
103+ <span className="truncate text-xs text-faint">Each person's own</span>
104+ <Link to={view.href} className="inline-flex h-8 shrink-0 items-center rounded-md border border-line px-3 text-[0.8125rem] font-medium text-fg/85 hover:border-line-strong hover:bg-raised hover:text-fg">
105+ Connect yours
106+ </Link>
107+ </div>
108+ </li>
109+ ))}
110+ </ul>
111+ </section>
112+ )}
113+
82114 {coming.length > 0 && (
83115 <section aria-labelledby="coming">
84116 <SectionHead id="coming" title="Coming" aside={<ComingBadge />}>
+10−8
11 /**
2− * The Marketplace: what a workspace can add, which anyone in it may
3− * browse. Owners add agents and integrations; everyone else asks an owner,
4− * and the agents service keeps the request (lib/marketplace.ts). It is not
5− * in the dock: the Apps page and the launcher lead here, as does ⌘K.
2+ * The Marketplace: what adds functionality to a workspace, which anyone in
3+ * it may browse. Extensions add pages, data and cards; integrations connect
4+ * what the team already uses so agents can work with it. Owners add them;
5+ * everyone else asks an owner, and the agents service keeps the request
6+ * (lib/marketplace.ts). Agents aren't here: they start from templates in
7+ * Agents mode. It is not in the dock: the Apps page and the launcher lead
8+ * here, as does ⌘K.
69 *
710 * This layout holds the heading, the tabs and the viewer's requests, which
811 * every tab reads to say what is waiting.
912 */
10−import { Blocks, Bot, Inbox, Plug, Sparkles } from "lucide-react";
13+import { Blocks, Inbox, Plug, Sparkles } from "lucide-react";
1114 import { Link, NavLink, Outlet, data, useLocation, useRouteLoaderData } from "react-router";
1215
1316 import type { ExtensionInstall, InstallRequests } from "@g1t/contracts";
6467 const { pathname } = useLocation();
6568 const tabs = [
6669 { to: marketplacePath(slug), label: "Discover", icon: <Sparkles size={14} />, end: true },
67− { to: marketplacePath(slug, "agents"), label: "Agents", icon: <Bot size={14} /> },
70+ { to: marketplacePath(slug, "extensions"), label: "Extensions", icon: <Blocks size={14} /> },
6871 { to: marketplacePath(slug, "integrations"), label: "Integrations", icon: <Plug size={14} /> },
69− { to: marketplacePath(slug, "extensions"), label: "Extensions", icon: <Blocks size={14} /> },
7072 { to: marketplacePath(slug, "requests"), label: owner ? "Requests" : "Your requests", icon: <Inbox size={14} />, count: waiting },
7173 ];
7274 return (
7577 <div>
7678 <h1 className="text-2xl font-semibold tracking-tight">Marketplace</h1>
7779 <p className="mt-1 max-w-2xl text-sm text-muted">
78− Agents, integrations and extensions {name} can add.{" "}
80+ Add what {name} does besides code, and connect the tools you already use so agents can work with your data.{" "}
7981 {owner ? "As an owner, you add them for everyone." : "Owners add them; ask one for what you need, and they'll hear about it."}
8082 </p>
8183 </div>
+3−3
5454 const answered = requests.requests.filter((request) => request.status !== "open");
5555 if (requests.requests.length === 0) {
5656 return owner ? (
57− <EmptyState title="No requests">When a member asks for an agent or an integration, it shows here, and you're notified.</EmptyState>
57+ <EmptyState title="No requests">When a member asks for an extension or an integration, it shows here, and you're notified.</EmptyState>
5858 ) : (
5959 <EmptyState title="You haven't asked for anything">
60− Find an agent or an integration and choose <b>Request</b>. The workspace's owners are notified, and you hear back when one answers.
60+ Find an extension or an integration and choose <b>Request</b>. The workspace's owners are notified, and you hear back when one answers.
6161 </EmptyState>
6262 );
6363 }
6666 <section aria-labelledby="waiting">
6767 <SectionHead id="waiting" title={owner ? "Waiting on an owner" : "Waiting"} aside={`${waiting.length}`}>
6868 {owner
69− ? "Adding an agent from the catalog answers every request for its role. After connecting an integration, mark its requests added."
69+ ? "Installing an extension answers every request for it. After connecting an integration, mark its requests added."
7070 : "Your requests the workspace's owners haven't answered yet."}
7171 </SectionHead>
7272 {waiting.length === 0 ? (
+4−0
4242 refused({ pricing: { monthly: 5 } }, /free/);
4343 refused({ permissions: [] }, /permissions/);
4444 refused({ ui: { entry: "index.html" } }, /ui.entry/);
45+ refused({ bridges: " " }, /bridges/);
46+ refused({ adds: { pages: [], agent_roles: [], tools: [], notifications: [] } }, /cards/);
4547 // A community extension that runs on its own servers, declaring them, is fine.
4648 assert.ok(checkManifest({ ...published, runtime: "connected", publisher: { name: "anyone", tier: "community" }, domains: ["api.example.com"] }, isScope).ok);
4749 });
4951 test("the install screen says where data goes", () => {
5052 assert.equal(dataDisclosure({ domains: [] }), "Its data stays in g1t.");
5153 assert.equal(dataDisclosure({ domains: ["api.acme.dev", "eu.acme.dev"] }), "Data leaves g1t to api.acme.dev, eu.acme.dev.");
54+ assert.equal(dataDisclosure({ domains: [], bridges: "the CRM you connect" }), "Data leaves g1t to the CRM you connect.");
5255 });
5356
5457 test("an extension's page loads from the user-content origin, by version", () => {
6669 test("listings are referenced by kind and id", () => {
6770 assert.deepEqual(parseListing("extension:on-call"), { kind: "extension", id: "on-call" });
6871 assert.equal(parseListing("app:on-call"), null);
72+ assert.equal(parseListing("agent:engineering"), null, "agents start from templates in Agents, not the Marketplace");
6973 });
+130−62
11 /**
2− * The Marketplace: what a workspace can add, the installs it has, and
3− * members' requests to add things. Anyone in a workspace browses it; only
4− * owners add things. A member asks instead, and the request reaches every
5− * owner as a notification.
2+ * The Marketplace: what adds functionality to a workspace, the installs it
3+ * has, and members' requests to add things. Extensions add pages, data and
4+ * cards; integrations connect what a team already uses so agents can work
5+ * with it. Agents are not listed here: Agents mode has templates to start
6+ * one from. Anyone in a workspace browses the Marketplace; only owners add
7+ * things. A member asks instead, and the request reaches every owner as a
8+ * notification.
69 *
710 * Every listing has a reference, `<kind>:<id>`:
811 *
9− * - `agent:<template>`: an agent hired from one of the catalog's roles
10− * (the agents service's templates); the agents service keeps the agent;
1112 * - `integration:<connector>`: a connector from the catalog
1213 * (./connectors.ts); the integrations service keeps the connection;
1314 * - `extension:<id>`: an extension, described by its manifest
2829 */
2930
3031 /** What a listing adds to a workspace. */
31−export type ListingKind = "agent" | "integration" | "extension";
32+export type ListingKind = "integration" | "extension";
3233
33−export const LISTING_KINDS: readonly ListingKind[] = ["agent", "integration", "extension"];
34+export const LISTING_KINDS: readonly ListingKind[] = ["integration", "extension"];
3435
3536 /** Whether a listing can be added today, or is planned. */
3637 export type ListingStatus = "available" | "soon";
4647 export type ExtensionAdds = {
4748 /** Its pages, in its sidebar, in order. */
4849 pages: string[];
49− /** Agent roles it brings, hired like any other. */
50+ /** Agent roles it brings: templates, configured like any other agent. */
5051 agent_roles: string[];
52+ /** Cards it shows in chat and elsewhere, in words. */
53+ cards: string[];
5154 /** Tools agents can call. */
5255 tools: string[];
5356 /** Notification kinds it sends. */
8689 */
8790 domains: string[];
8891 /**
92+ * The system outside g1t it bridges, in words (`the CRM you connect`),
93+ * when its data goes wherever the workspace points it rather than to
94+ * fixed domains. The install screen says so. Absent or null: none.
95+ */
96+ bridges?: string | null;
97+ /**
8998 * Its page inside g1t: a path served from the user-content domain
9099 * (g1tusercontent.com, or a self-hosted instance's own), loaded in a
91100 * sandboxed frame that reaches g1t only through the bridge. Null: no page.
139148 if (!Array.isArray(m.permissions) || m.permissions.length === 0) return bad("List what it can do in `permissions`, in plain words.");
140149 if (!Array.isArray(m.domains) || m.domains.some((domain) => typeof domain !== "string" || !isHost(domain))) return bad("`domains` are host names, such as api.example.com.");
141150 if (m.runtime === "connected" && m.domains.length === 0) return bad("A connected extension declares the domains it runs on.");
151+ if (m.bridges != null && (typeof m.bridges !== "string" || !m.bridges.trim())) return bad("`bridges` names the system it connects to, in words.");
142152 if (m.ui !== null && (typeof m.ui !== "object" || typeof m.ui?.entry !== "string" || !m.ui.entry.startsWith("/"))) return bad("`ui.entry` is a path, such as /index.html.");
143− if (!m.adds || !["pages", "agent_roles", "tools", "notifications"].every((k) => Array.isArray((m.adds as Record<string, unknown>)[k]))) return bad("`adds` lists pages, agent_roles, tools and notifications.");
153+ if (!m.adds || !["pages", "cards", "agent_roles", "tools", "notifications"].every((k) => Array.isArray((m.adds as Record<string, unknown>)[k]))) return bad("`adds` lists pages, cards, agent_roles, tools and notifications.");
144154 if (m.pricing !== null) return bad("Listings are free for now: `pricing` is null.");
145155 return { ok: true, value: m as ExtensionManifest };
146156 }
150160 }
151161
152162 /** What the install screen says about where an extension's data goes. */
153−export function dataDisclosure(manifest: Pick<ExtensionManifest, "domains">): string {
154− if (manifest.domains.length === 0) return "Its data stays in g1t.";
155− return `Data leaves g1t to ${manifest.domains.join(", ")}.`;
163+export function dataDisclosure(manifest: Pick<ExtensionManifest, "domains" | "bridges">): string {
164+ const to = [...manifest.domains, ...(manifest.bridges ? [manifest.bridges] : [])];
165+ if (to.length === 0) return "Its data stays in g1t.";
166+ return `Data leaves g1t to ${to.join(", ")}.`;
156167 }
157168
158169 /** Where an extension's page loads from: its entry on the user-content origin, under its id and version. */
167178 return match ? match[1]! : null;
168179 }
169180
181+/** What every first-party listing shares before its first release. */
182+const UNRELEASED = {
183+ publisher: { name: "g1t", tier: "official" as const },
184+ status: "soon" as const,
185+ source: null,
186+ version: null,
187+ runtime: "hosted" as const,
188+ ui: { entry: "/index.html" },
189+ pricing: null,
190+};
191+
170192 /**
171193 * g1t's own extensions. None is published yet: each is listed so people
172− * see what is coming, and can't be installed until its first release.
194+ * see what is coming, and can't be installed until its first release. The
195+ * last three are connected systems: each bridges a system a team already
196+ * runs, so agents work across it and g1t together.
173197 */
174198 export const FIRST_PARTY_EXTENSIONS: ExtensionManifest[] = [
175199 {
200+ ...UNRELEASED,
201+ id: "mail",
202+ name: "Mail",
203+ tagline: "Email on your own domain, with shared inboxes agents work in.",
204+ description:
205+ "Email that g1t runs on your domain. Shared inboxes such as support@ and sales@ work like channels: agents sort and draft, and sending needs approval or a rule you set. Spam and phishing filtering is included, at what it costs to run. Gmail and Outlook keep working alongside it.",
206+ category: "Communication",
207+ scopes: ["notifications:write", "artifacts:read"],
208+ permissions: ["Host email for your domain", "Let agents read shared inboxes and draft replies", "Send only with approval or a rule you set"],
209+ domains: [],
210+ adds: { pages: ["Inbox", "Shared inboxes", "Sent", "Rules for agents"], cards: ["Email threads in chat"], agent_roles: [], tools: ["mail", "thread"], notifications: ["Drafts to approve"] },
211+ },
212+ {
213+ ...UNRELEASED,
176214 id: "support",
177215 name: "Support",
178216 tagline: "Customer conversations an agent answers from your docs, escalating the rest.",
179217 description:
180218 "Conversations, escalations, macros and a knowledge base. A support agent answers what your docs cover, turns bug reports into issues in Code, and hands anything else to a person. Email to customers waits for approval.",
181219 category: "Customers",
182− publisher: { name: "g1t", tier: "official" },
183− status: "soon",
184− source: null,
185− version: null,
186− runtime: "hosted",
187220 scopes: ["issues:write", "artifacts:read", "notifications:write"],
188221 permissions: ["Read and reply to support email, with your approval", "Store conversations in workspace data", "Open issues from bug reports"],
189222 domains: [],
190− ui: { entry: "/index.html" },
191− adds: { pages: ["Conversations", "Escalations", "Macros", "Knowledge"], agent_roles: ["Support Specialist"], tools: ["conversation", "macro"], notifications: ["Escalations"] },
192− pricing: null,
223+ adds: {
224+ pages: ["Conversations", "Escalations", "Macros", "Knowledge"],
225+ cards: ["Customer cards in chat", "Bug reports as issues in Code"],
226+ agent_roles: ["Support Specialist"],
227+ tools: ["conversation", "macro"],
228+ notifications: ["Escalations"],
229+ },
230+ },
231+ {
232+ ...UNRELEASED,
233+ id: "crm",
234+ name: "CRM",
235+ tagline: "Accounts, deals and a pipeline, with agents that keep it current.",
236+ description:
237+ "Accounts and contacts, deals by stage and a weekly forecast, kept as workspace data. Agents log calls and email, draft follow-ups for a person to send, and flag deals that have gone quiet.",
238+ category: "Customers",
239+ scopes: ["artifacts:write", "notifications:write"],
240+ permissions: ["Store accounts, contacts and deals in workspace data", "Read email threads with customers in shared inboxes", "Draft follow-ups that a person sends"],
241+ domains: [],
242+ adds: { pages: ["Pipeline", "Deals", "Accounts", "Forecast"], cards: ["Account cards in chat"], agent_roles: ["Sales Ops"], tools: ["deal", "account"], notifications: ["Deals gone quiet"] },
193243 },
194244 {
245+ ...UNRELEASED,
195246 id: "recruiting",
196247 name: "Recruiting",
197248 tagline: "Openings, candidates and interview loops. Agents screen, people decide.",
198− description: "A pipeline board, scorecards and scheduling. A recruiting agent screens applicants against your scorecard and books interview loops; offers and decisions stay with people.",
249+ description:
250+ "A pipeline board, scorecards and scheduling. A recruiting agent screens applicants against your scorecard and books interview loops; offers and decisions stay with people.",
199251 category: "People",
200− publisher: { name: "g1t", tier: "official" },
201− status: "soon",
202− source: null,
203− version: null,
204− runtime: "hosted",
205252 scopes: ["artifacts:write", "notifications:write"],
206253 permissions: ["Store candidates and openings in workspace data", "Read and write interview events on connected calendars", "Email candidates from a shared address, with your approval"],
207254 domains: [],
208− ui: { entry: "/index.html" },
209− adds: { pages: ["Pipeline", "Openings", "Candidates", "Interviews", "Offers"], agent_roles: ["Recruiter"], tools: ["candidate", "opening"], notifications: ["Offers to sign"] },
210− pricing: null,
255+ adds: {
256+ pages: ["Pipeline", "Openings", "Candidates", "Interviews", "Offers"],
257+ cards: ["Candidate cards in chat"],
258+ agent_roles: ["Recruiter"],
259+ tools: ["candidate", "opening"],
260+ notifications: ["Offers to sign"],
261+ },
211262 },
212263 {
213− id: "mail",
214− name: "Mail",
215− tagline: "Email on your own domain, with shared inboxes agents work in.",
216− description:
217− "Email that g1t runs on your domain. Shared inboxes such as support@ and sales@ work like channels: agents sort and draft, and sending needs approval or a rule you set. Spam and phishing filtering is included, at what it costs to run. Gmail and Outlook keep working alongside it.",
218− category: "Communication",
219− publisher: { name: "g1t", tier: "official" },
220− status: "soon",
221− source: null,
222− version: null,
223− runtime: "hosted",
224− scopes: ["notifications:write", "artifacts:read"],
225− permissions: ["Host email for your domain", "Let agents read shared inboxes and draft replies", "Send only with approval or a rule you set"],
226− domains: [],
227− ui: { entry: "/index.html" },
228− adds: { pages: ["Inbox", "Shared inboxes", "Sent", "Rules for agents"], agent_roles: [], tools: ["mail", "thread"], notifications: ["Drafts to approve"] },
229− pricing: null,
230− },
231− {
264+ ...UNRELEASED,
232265 id: "on-call",
233266 name: "On-call",
234267 tagline: "Rotations, pages and who is on call now.",
235268 description: "Rotations and schedules, pages through notifications, and an incident channel an operations agent keeps a timeline in.",
236269 category: "Engineering",
237− publisher: { name: "g1t", tier: "official" },
238− status: "soon",
239− source: null,
240− version: null,
241− runtime: "hosted",
242270 scopes: ["notifications:write", "issues:read"],
243271 permissions: ["Store rotations in workspace data", "Page people through their notifications", "Read issues an incident links to"],
244272 domains: [],
245− ui: { entry: "/index.html" },
246− adds: { pages: ["Rotations", "Pages", "Incidents"], agent_roles: [], tools: ["rotation", "page"], notifications: ["Pages", "Your shift"] },
247− pricing: null,
273+ adds: { pages: ["Rotations", "Pages", "Incidents"], cards: ["Who's on call, in chat"], agent_roles: [], tools: ["rotation", "page"], notifications: ["Pages", "Your shift"] },
274+ },
275+ {
276+ ...UNRELEASED,
277+ id: "helpdesk-bridge",
278+ name: "Helpdesk bridge",
279+ tagline: "Work tickets from the helpdesk you already run, next to your code and chat.",
280+ description:
281+ "Connects the helpdesk your team already uses. Its tickets show in g1t with the customer and the conversation; agents draft replies there for a person to send, and a bug report becomes an issue in Code that stays linked to its ticket.",
282+ category: "Connected systems",
283+ scopes: ["issues:write", "notifications:write"],
284+ permissions: ["Read tickets and customers in the helpdesk you connect", "Draft replies there, sent by a person or a rule you set", "Open issues in Code linked to a ticket"],
285+ domains: [],
286+ bridges: "the helpdesk you connect",
287+ adds: { pages: ["Tickets", "Linked issues"], cards: ["Ticket cards in chat"], agent_roles: [], tools: ["ticket"], notifications: ["Tickets assigned to you"] },
288+ },
289+ {
290+ ...UNRELEASED,
291+ id: "crm-bridge",
292+ name: "CRM bridge",
293+ tagline: "The accounts and deals in the CRM you already use, in g1t and in chat.",
294+ description:
295+ "Connects the CRM your team already uses. Accounts and deals show in g1t and in chat, and agents log calls, update stages and draft follow-ups there, each change in the audit log.",
296+ category: "Connected systems",
297+ scopes: ["notifications:write"],
298+ permissions: ["Read accounts, contacts and deals in the CRM you connect", "Log activity and update deal stages there", "Draft follow-ups that a person sends"],
299+ domains: [],
300+ bridges: "the CRM you connect",
301+ adds: { pages: ["Accounts", "Deals"], cards: ["Account cards in chat"], agent_roles: [], tools: ["account", "deal"], notifications: ["Deals gone quiet"] },
302+ },
303+ {
304+ ...UNRELEASED,
305+ id: "erp-bridge",
306+ name: "ERP bridge",
307+ tagline: "Orders, invoices and stock from your ERP, for agents that answer and reconcile.",
308+ description:
309+ "Connects the ERP your business runs on. Agents look up orders, invoices and stock to answer questions in chat and support, and reconcile what doesn't match. Anything that changes money or stock waits for a person's approval.",
310+ category: "Connected systems",
311+ scopes: ["notifications:write"],
312+ permissions: ["Read orders, invoices and stock in the ERP you connect", "Propose changes there, each approved by a person"],
313+ domains: [],
314+ bridges: "the ERP you connect",
315+ adds: { pages: ["Orders", "Invoices", "Approvals"], cards: ["Order cards in chat"], agent_roles: [], tools: ["order", "invoice"], notifications: ["Changes to approve"] },
248316 },
249317 ];
250318
261329
262330 export const LISTING_TIERS: readonly ListingTier[] = ["official", "verified", "community", "internal"];
263331
264−/** A listing's reference, split: `agent:engineering` is `{ kind: "agent", id: "engineering" }`. */
332+/** A listing's reference, split: `integration:sentry` is `{ kind: "integration", id: "sentry" }`. */
265333 export type ListingRef = { kind: ListingKind; id: string };
266334
267335 /** Where a request stands: waiting on an owner, added, or turned down. */
275343 /** `<kind>:<id>`. */
276344 listing: string;
277345 kind: ListingKind;
278− /** The listing's name when it was asked for, such as `Software Engineer` or `Sentry`. */
346+ /** The listing's name when it was asked for, such as `Sentry` or `Support`. */
279347 name: string;
280348 /** Why they want it, in their words, or null. */
281349 note: string | null;
304372 /** How long an answered request stays listed. */
305373 export const ANSWERED_REQUESTS_DAYS = 30;
306374
307−/** A listing's reference: `agent:engineering`. */
375+/** A listing's reference: `integration:sentry`. */
308376 export function listingRef(kind: ListingKind, id: string): string {
309377 return `${kind}:${id}`;
310378 }
312380 /** A reference split into kind and id; null when it is not one. */
313381 export function parseListing(value: unknown): ListingRef | null {
314382 if (typeof value !== "string") return null;
315− const match = /^(agent|integration|extension):([a-z0-9][a-z0-9_-]{0,63})$/.exec(value.trim());
383+ const match = /^(integration|extension):([a-z0-9][a-z0-9_-]{0,63})$/.exec(value.trim());
316384 return match ? { kind: match[1] as ListingKind, id: match[2]! } : null;
317385 }
318386
+3−2
728728 */
729729 installRequests(workspace: string, viewer: User): Promise<Result<InstallRequests>>;
730730 /**
731− * A member asks the workspace's owners to add a listing (`agent:<template>`
732− * or `integration:<connector>`), and every owner is notified. Owners add
731+ * A member asks the workspace's owners to add a listing
732+ * (`extension:<id>` or `integration:<connector>`), and every owner is
733+ * notified. Owners add
733734 * things themselves, so they don't ask. Asking again while a request for
734735 * the same listing is open is a conflict.
735736 */
+1−1
2828 });
2929
3030 test("g1t's own names and routes are reserved", () => {
31− for (const name of ["g1t", "G1T", "g1t-agent", "settings", "admin", "inbox", "new", "runs", "fleet"]) {
31+ for (const name of ["g1t", "G1T", "g1t-agent", "settings", "admin", "inbox", "new", "templates", "runs", "fleet"]) {
3232 const checked = checkHandle(name);
3333 assert.equal(checked.ok, false, name);
3434 if (!checked.ok) assert.match(checked.message, /reserved/);
+3−3
1212
1313 /**
1414 * Handles the site's agent pages would read as their own routes
15− * (`/<workspace>/agents/new`, `…/runs`, `…/fleet`), besides the names
16− * nobody may register.
15+ * (`/<workspace>/-/agents/new`, `…/templates`, `…/runs`, `…/fleet`),
16+ * besides the names nobody may register.
1717 */
18−const ROUTES = new Set(["new", "runs", "fleet"]);
18+const ROUTES = new Set(["new", "templates", "runs", "fleet"]);
1919
2020 const HANDLE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,31}$/;
2121
+1−3
221221 }
222222 this.audit(a.viewer!, a.workspace, "create_agent", definition.handle, `Created @${definition.handle} (version 1)`);
223223 this.defer(this.hello(a.workspace, managed.value, id, a.viewer!));
224− // Hiring from a catalog role answers every request for that role.
225− if (definition.template) this.defer(this.answerListing(a.workspace, managed.value, `agent:${definition.template}`, a.viewer!));
226224 const row = await this.row(managed.value, definition.handle);
227225 return ok(toAgent(row!, new Date()));
228226 }
355353 kind: "approval",
356354 workspace: slug,
357355 title: `@${asker.username} asks you to add ${request.name}`,
358− body: request.note ?? (request.kind === "agent" ? "An agent from the catalog. Add it, or turn the request down." : request.kind === "extension" ? "An extension. Install it, or turn the request down." : "An integration. Connect it, or turn the request down."),
356+ body: request.note ?? (request.kind === "extension" ? "An extension. Install it, or turn the request down." : "An integration. Connect it, or turn the request down."),
359357 href: requestsPath(slug),
360358 actor: { kind: "user", id: asker.id, name: asker.username, avatar: asker.avatar ?? null, avatar_seed: null },
361359 created_at: new Date().toISOString(),
+37−23
3131 const later = (days: number) => new Date(at.getTime() + days * 86_400_000);
3232
3333 test("only what a workspace can add today is a listing", () => {
34− assert.deepEqual(findListing("agent:engineering"), { ref: "agent:engineering", kind: "agent", id: "engineering", name: "Software Engineer" });
35− assert.equal(findListing("integration:sentry")?.name, "Sentry");
36− assert.equal(findListing("agent:astronaut"), null, "no such role");
34+ assert.deepEqual(findListing("integration:sentry"), { ref: "integration:sentry", kind: "integration", id: "sentry", name: "Sentry" });
35+ assert.equal(findListing("agent:engineering"), null, "agents start from templates in Agents, not the Marketplace");
36+ assert.equal(findListing("integration:astronaut"), null, "no such integration");
3737 assert.equal(findListing("integration:slack"), null, "Slack is planned, not available");
3838 assert.equal(findListing("integration:mcp"), null, "MCP clients are connected by each person, not a workspace");
3939 assert.equal(findListing("extension:support"), null, "no extensions are listed yet");
4242
4343 test("references parse strictly, and notes are tidied", () => {
4444 assert.deepEqual(parseListing(" integration:alerts-webhook "), { kind: "integration", id: "alerts-webhook" });
45− assert.equal(parseListing("agent:"), null);
46− assert.equal(parseListing("agent:Engineering"), null);
47− assert.equal(parseListing("agent:a b"), null);
45+ assert.equal(parseListing("integration:"), null);
46+ assert.equal(parseListing("integration:Sentry"), null);
47+ assert.equal(parseListing("integration:a b"), null);
48+ assert.equal(parseListing("agent:qa"), null);
4849 assert.equal(cleanRequestNote(" for on-call "), "for on-call");
4950 assert.equal(cleanRequestNote(" "), null);
5051 assert.equal(cleanRequestNote("x".repeat(400))?.length, 280);
5354
5455 test("a member's request is kept, and asking twice is one request", async () => {
5556 const db = fakeD1();
56− const listing = findListing("agent:qa")!;
57+ const listing = findListing("integration:sentry")!;
5758 const first = await openRequest(db, ws, nextId(), listing, ana, "Flaky checks", at);
5859 assert.ok(first.ok);
5960 assert.equal(first.value.status, "open");
60− assert.equal(first.value.name, "QA Engineer");
61+ assert.equal(first.value.name, "Sentry");
62+ assert.equal(first.value.kind, "integration");
6163 assert.equal(first.value.note, "Flaky checks");
6264 const again = await openRequest(db, ws, nextId(), listing, ana, null, later(0.1));
6365 assert.equal(again.ok, false);
6870
6971 test("an owner sees every request; a member sees their own", async () => {
7072 const db = fakeD1();
71− await openRequest(db, ws, nextId(), findListing("agent:qa")!, ana, null, at);
73+ await openRequest(db, ws, nextId(), findListing("integration:jira")!, ana, null, at);
7274 await openRequest(db, ws, nextId(), findListing("integration:linear")!, bo, null, later(0.01));
73− await openRequest(db, "wsp_other", nextId(), findListing("agent:docs")!, ana, null, at);
74− assert.deepEqual((await listRequests(db, ws, owner, true, at)).map((r) => r.listing), ["integration:linear", "agent:qa"]);
75− assert.deepEqual((await listRequests(db, ws, ana, false, at)).map((r) => r.listing), ["agent:qa"]);
75+ await openRequest(db, "wsp_other", nextId(), findListing("integration:datadog")!, ana, null, at);
76+ assert.deepEqual((await listRequests(db, ws, owner, true, at)).map((r) => r.listing), ["integration:linear", "integration:jira"]);
77+ assert.deepEqual((await listRequests(db, ws, ana, false, at)).map((r) => r.listing), ["integration:jira"]);
7678 });
7779
7880 test("answering a request: once, by status, and answered ones age out", async () => {
9799
98100 test("adding a listing answers every open request for it", async () => {
99101 const db = fakeD1();
100− await openRequest(db, ws, nextId(), findListing("agent:support")!, ana, null, at);
101− await openRequest(db, ws, nextId(), findListing("agent:support")!, bo, null, at);
102− await openRequest(db, ws, nextId(), findListing("agent:sales")!, bo, null, at);
103− const answered = await resolveListing(db, ws, "agent:support", owner, later(1));
102+ await openRequest(db, ws, nextId(), findListing("integration:linear")!, ana, null, at);
103+ await openRequest(db, ws, nextId(), findListing("integration:linear")!, bo, null, at);
104+ await openRequest(db, ws, nextId(), findListing("integration:jira")!, bo, null, at);
105+ const answered = await resolveListing(db, ws, "integration:linear", owner, later(1));
104106 assert.deepEqual(answered.map((a) => a.requested_by_id).sort(), ["usr_ana", "usr_bo"]);
105107 assert.ok(answered.every((a) => a.request.status === "done"));
106108 const open = (await listRequests(db, ws, owner, true, later(1))).filter((r) => r.status === "open");
107− assert.deepEqual(open.map((r) => r.listing), ["agent:sales"]);
109+ assert.deepEqual(open.map((r) => r.listing), ["integration:jira"]);
110+});
111+
112+test("a request kept from a kind the Marketplace no longer lists is left out", async () => {
113+ const db = fakeD1();
114+ await openRequest(db, ws, nextId(), findListing("integration:sentry")!, ana, null, at);
115+ // An agent request, as the table could hold one from before agents left the Marketplace.
116+ await db
117+ .prepare("INSERT INTO install_requests (id, workspace_id, listing, name, requested_by, requested_by_id, requested_at) VALUES (?, ?, ?, ?, ?, ?, ?)")
118+ .bind("ins_old", ws, "agent:qa", "QA Engineer", "ana", "usr_ana", at.toISOString())
119+ .run();
120+ assert.deepEqual((await listRequests(db, ws, owner, true, at)).map((r) => r.listing), ["integration:sentry"]);
121+ assert.deepEqual(await resolveListing(db, ws, "agent:qa", owner, later(1)), []);
108122 });
109123
110124 test("a person keeps at most so many requests open", async () => {
111125 const db = fakeD1();
112− // The catalog has fewer listings than the limit, so the same few are asked, answered and asked again.
113− const listings = ["agent:engineering", "agent:qa", "agent:operations", "agent:docs", "agent:product", "agent:support", "agent:sales", "integration:sentry", "integration:datadog", "integration:linear", "integration:jira", "integration:github", "integration:webhooks", "integration:alerts-webhook", "integration:anthropic", "integration:openai", "integration:gemini", "integration:xai", "integration:mistral", "integration:deepseek"];
126+ const listings = ["integration:github", "integration:webhooks", "integration:linear", "integration:jira", "integration:sentry", "integration:datadog", "integration:alerts-webhook", "integration:anthropic", "integration:openai", "integration:gemini", "integration:xai", "integration:mistral", "integration:deepseek", "integration:azure-openai", "integration:openrouter", "integration:together", "integration:fireworks", "integration:cerebras", "integration:anthropic-endpoint", "integration:openai-endpoint"];
114127 assert.equal(listings.length, MAX_OPEN_REQUESTS);
115128 for (const ref of listings) assert.ok((await openRequest(db, ws, nextId(), findListing(ref)!, ana, null, at)).ok, ref);
116129 const over = await openRequest(db, ws, nextId(), findListing("integration:groq")!, ana, null, at);
118131 });
119132
120133 test("what the asker is told, and where it leads", () => {
121− const base = { id: "ins_1", listing: "agent:qa", kind: "agent" as const, name: "QA Engineer", note: null, requested_by: "ana", requested_at: at.toISOString(), resolved_by: "chase", resolved_at: at.toISOString() };
122− assert.equal(answerLine({ ...base, status: "done" }, "@chase").title, "QA Engineer was added to the workspace");
123− assert.match(answerLine({ ...base, status: "declined" }, "@chase").title, /@chase turned down your request for QA Engineer/);
124− assert.equal(listingPath("acme", "agent:qa"), "/acme/-/marketplace/agents/qa");
134+ const base = { id: "ins_1", listing: "integration:sentry", kind: "integration" as const, name: "Sentry", note: null, requested_by: "ana", requested_at: at.toISOString(), resolved_by: "chase", resolved_at: at.toISOString() };
135+ assert.equal(answerLine({ ...base, status: "done" }, "@chase").title, "Sentry was added to the workspace");
136+ assert.equal(answerLine({ ...base, status: "done" }, "@chase").body, "@chase connected it.");
137+ assert.match(answerLine({ ...base, status: "declined" }, "@chase").title, /@chase turned down your request for Sentry/);
138+ assert.equal(listingPath("acme", "extension:support"), "/acme/-/marketplace/extensions/support");
125139 assert.equal(listingPath("acme", "integration:sentry"), "/acme/-/marketplace/integrations");
126140 });
+22−20
11 /**
22 * The Marketplace's install requests (docs.g1t.sh/guides/marketplace/): a
3− * member asks the workspace's owners to add an agent from the catalog or
4− * an integration, and an owner adds it or turns it down. Kept in
3+ * member asks the workspace's owners to add an extension or an
4+ * integration, and an owner adds it or turns it down. Kept in
55 * `install_requests`, one open request per person and listing.
66 *
7− * Only what can be added today can be asked for: a role from the agent
8− * catalog (./templates.ts), or a connector the catalog marks available for
9− * a workspace (@g1t/contracts/connectors). Who is told, and how, is
10− * index.ts's; this file is the rules and the table, tested on SQLite.
7+ * Only what can be added today can be asked for: a published extension,
8+ * or a connector the catalog marks available for a workspace
9+ * (@g1t/contracts/connectors). Agents aren't asked for here: they start
10+ * from templates in Agents mode, and owners make them. Who is told, and
11+ * how, is index.ts's; this file is the rules and the table, tested on
12+ * SQLite.
1113 */
1214 import type { FailureCode, InstallRequest, InstallRequestStatus, ListingKind, Result } from "@g1t/contracts";
1315 import { CONNECTORS } from "@g1t/contracts/connectors";
1618 // Plain values rather than @g1t/contracts' helpers, so this file runs under Node's tests.
1719 const ok = <T>(value: T): Result<T> => ({ ok: true, value });
1820 const fail = (code: FailureCode, message: string): Result<never> => ({ ok: false, error: { code, message } });
19−
20−import { TEMPLATES } from "./templates.ts";
2121
2222 /** Something the workspace can add, as a request names it. */
2323 export type Listing = { ref: string; kind: ListingKind; id: string; name: string };
2626 export function findListing(value: unknown): Listing | null {
2727 const parsed = parseListing(value);
2828 if (!parsed) return null;
29− if (parsed.kind === "agent") {
30− const template = TEMPLATES.find((t) => t.id === parsed.id);
31− return template ? { ref: listingRef("agent", template.id), kind: "agent", id: template.id, name: template.title } : null;
32− }
3329 if (parsed.kind === "extension") {
3430 const extension = extensionById(parsed.id);
3531 return extension?.status === "available" ? { ref: listingRef("extension", extension.id), kind: "extension", id: extension.id, name: extension.name } : null;
5450
5551 const COLUMNS = "id, listing, name, note, requested_by, requested_by_id, requested_at, status, resolved_by, resolved_at";
5652
57−export function toRequest(row: Row): InstallRequest {
53+/** A row as a request; null for one whose listing is no longer a kind the Marketplace has. */
54+export function toRequest(row: Row): InstallRequest | null {
55+ const parsed = parseListing(row.listing);
56+ if (!parsed) return null;
5857 return {
5958 id: row.id,
6059 listing: row.listing,
61− kind: parseListing(row.listing)?.kind ?? "agent",
60+ kind: parsed.kind,
6261 name: row.name,
6362 note: row.note,
6463 requested_by: row.requested_by,
8786 );
8887 const bound = owner ? statement.bind(workspaceId, since) : statement.bind(workspaceId, since, viewer.id);
8988 const { results } = await bound.all<Row>();
90− return results.map(toRequest);
89+ return results.map(toRequest).filter((request) => request !== null);
9190 }
9291
9392 /**
131130 .bind(row.id, workspaceId, row.listing, row.name, row.note, row.requested_by, row.requested_by_id, row.requested_at)
132131 .first<{ id: string }>();
133132 if (!inserted) return fail("conflict", `You've already asked for ${listing.name}. The workspace's owners have your request.`);
134− return ok(toRequest(row));
133+ return ok(toRequest(row)!);
135134 }
136135
137136 /** A request answered, with who asked, for telling them. */
154153 )
155154 .bind(status, by.username, now.toISOString(), id, workspaceId)
156155 .first<Row>();
157− if (row) return ok({ request: toRequest(row), requested_by_id: row.requested_by_id });
156+ const request = row ? toRequest(row) : null;
157+ if (row && request) return ok({ request, requested_by_id: row.requested_by_id });
158158 const exists = await db.prepare("SELECT status FROM install_requests WHERE id = ? AND workspace_id = ?").bind(id, workspaceId).first<{ status: string }>();
159159 return exists ? fail("conflict", "That request was already answered.") : fail("not_found", "There is no such request.");
160160 }
171171 )
172172 .bind(by.username, now.toISOString(), workspaceId, listing)
173173 .all<Row>();
174− return results.map((row) => ({ request: toRequest(row), requested_by_id: row.requested_by_id }));
174+ return results.flatMap((row) => {
175+ const request = toRequest(row);
176+ return request ? [{ request, requested_by_id: row.requested_by_id }] : [];
177+ });
175178 }
176179
177180 /** Where a request is looked at in the workspace `slug`. */
182185 /** Where the thing a request asked for is, once added. */
183186 export function listingPath(slug: string, listing: string): string {
184187 const parsed = parseListing(listing);
185− if (parsed?.kind === "agent") return `/${slug}/-/marketplace/agents/${parsed.id}`;
186188 if (parsed?.kind === "extension") return `/${slug}/-/marketplace/extensions/${parsed.id}`;
187189 return `/${slug}/-/marketplace/integrations`;
188190 }
194196 }
195197 return {
196198 title: `${request.name} was added to the workspace`,
197− body: request.kind === "agent" ? `${by} added it from the agent catalog.` : request.kind === "extension" ? `${by} installed it.` : `${by} connected it.`,
199+ body: request.kind === "extension" ? `${by} installed it.` : `${by} connected it.`,
198200 };
199201 }