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.
38 files+1068−7250/38 viewed
| 55 | 55 | | 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 | | |
| 56 | 56 | | Spend by extension | Coming | | |
| 57 | 57 | | 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 | | |
| 59 | 59 | | 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 | | |
| 61 | 61 | | Agents on runners anywhere (g1t's, yours, your desktop), with sessions that persist between tasks | Coming | | |
| 62 | 62 | | Agents answering in Slack and Teams | Coming | | |
| 63 | 63 | ||
| ⋯ | |||
| 85 | 85 | | Integrations: the GitHub App, Sentry, Datadog, Jira, Linear, alerts | Live | | |
| 86 | 86 | | REST API, OpenAPI, MCP server, webhooks, an event bus | Live | | |
| 87 | 87 | | 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 | | |
| 90 | 90 | | Single sign-on (SAML, OIDC) and SCIM per workspace | Coming | | |
| 91 | 91 | | Storage: databases and buckets per team, with copies of production for anything unreviewed | Coming | | |
| 92 | 92 | | Self-hosting: the core forge in Docker Compose (agents, deployments and context search off) | Preview | | |
| 65 | 65 | <Steps> | |
| 66 | 66 | ||
| 67 | 67 | 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). | |
| 74 | 74 | 3. **Name it.** Each role suggests a name, such as *Margo* for QA. Choose | |
| 75 | 75 | **Another name** to shuffle through others that suit the role, or type | |
| 76 | 76 | your own. The handle follows the name: `margo`, mentioned as `@margo`. | |
| ⋯ | |||
| 110 | 110 | ||
| 111 | 111 | ## Role templates | |
| 112 | 112 | ||
| 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. | |
| 117 | 126 | ||
| 118 | 127 | | Department | Suggested name | Title | Answers for | Voice | Models | | |
| 119 | 128 | | --- | --- | --- | --- | --- | --- | | |
| 1 | 1 | --- | |
| 2 | 2 | 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. | |
| 4 | 4 | --- | |
| 5 | 5 | ||
| 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: | |
| 9 | 10 | ||
| 10 | 11 | | Kind | What it adds | Status | | |
| 11 | 12 | | --- | --- | --- | | |
| 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 | | |
| 13 | 14 | | **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 | | |
| 15 | 15 | ||
| 16 | + | Agents aren't in the Marketplace. To add one, start from a | |
| 17 | + | [template](/guides/agents/#role-templates) in Agents. | |
| 18 | + | ||
| 16 | 19 | Open it from **Apps** at the foot of the dock (the launcher's | |
| 17 | 20 | **Marketplace** link, or **Browse the Marketplace** on the Apps page), from | |
| 18 | 21 | **More** on a phone, or with <kbd>Ctrl</kbd> <kbd>K</kbd> and | |
| ⋯ | |||
| 22 | 25 | ## Who can add things | |
| 23 | 26 | ||
| 24 | 27 | 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. | |
| 28 | 31 | ||
| 29 | 32 | ## The Marketplace's pages | |
| 30 | 33 | ||
| 31 | 34 | | Tab | What it shows | | |
| 32 | 35 | | --- | --- | | |
| 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*. | | |
| 37 | 39 | | **Requests** | For owners, every request members made, waiting first; for anyone else, **Your requests**. The tab shows how many are waiting. | | |
| 38 | 40 | ||
| 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 | |
| 46 | 42 | ||
| 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. | |
| 48 | 47 | ||
| 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. | | |
| 57 | 55 | ||
| 58 | − | ## Integrations | |
| 56 | + | ### Connected systems | |
| 59 | 57 | ||
| 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. | |
| 65 | 61 | ||
| 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. | | |
| 69 | 67 | ||
| 70 | − | ## Extensions | |
| 68 | + | ### Starter kits | |
| 71 | 69 | ||
| 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. | |
| 77 | 75 | ||
| 78 | 76 | ### Who stands behind a listing | |
| 79 | 77 | ||
| ⋯ | |||
| 88 | 86 | ||
| 89 | 87 | Before anything is installed, its page lists: | |
| 90 | 88 | ||
| 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. | |
| 93 | 91 | - **It will be able to**: what it may do, in plain words, and the scopes | |
| 94 | 92 | its token asks for, each described. | |
| 95 | 93 | - **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. | |
| 99 | 97 | - **Version** and **Source**: the release it installs, and the repository | |
| 100 | 98 | and tag it was published from. | |
| 101 | 99 | - **Price**: every listing is free. What its agents do is billed at what | |
| ⋯ | |||
| 116 | 114 | | `scopes` | The [scopes](/guides/authentication/#scopes) its token asks for. | | |
| 117 | 115 | | `permissions` | What it may do, in plain words, for the install screen. | | |
| 118 | 116 | | `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. | | |
| 119 | 118 | | `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`. | | |
| 121 | 120 | | `pricing` | `null`: listings are free. | | |
| 122 | 121 | ||
| 123 | 122 | ### Installed extensions | |
| ⋯ | |||
| 133 | 132 | Installing, switching on or off, setting a budget and uninstalling are in | |
| 134 | 133 | the [audit log](/guides/audit-log/). | |
| 135 | 134 | ||
| 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 | + | ||
| 136 | 153 | ## Ask an owner to add something | |
| 137 | 154 | ||
| 138 | − | 1. Choose **Request** on an agent, an integration or an extension. | |
| 155 | + | 1. Choose **Request** on an extension or an integration. | |
| 139 | 156 | 2. Say why you want it, if you like, and choose **Send request**. | |
| 140 | 157 | ||
| 141 | 158 | Every owner is notified. The button then reads **Requested**, and **Your | |
| ⋯ | |||
| 147 | 164 | ||
| 148 | 165 | | | | | |
| 149 | 166 | | --- | --- | | |
| 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. | | |
| 151 | 168 | | **Mark added** | After you connected the integration, or added it another way. | | |
| 152 | 169 | | **Turn down** | The person who asked is told. They can ask again. | | |
| 153 | 170 | ||
| 601 | 601 | GitHub, each opening its own page; installed extensions join them once | |
| 602 | 602 | extensions are published. An app you can't use shows **Request access**. | |
| 603 | 603 | 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**, | |
| 606 | 606 | **AI Gateway**, **Integrations** and the **Audit log**. | |
| 607 | 607 | ||
| 608 | 608 | A pinned app shows as its mark under the built-in ones, in the order you |
| 83 | 83 | | [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" /> | | |
| 84 | 84 | | [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" /> | | |
| 85 | 85 | | [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" /> | | |
| 88 | 88 | | [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" /> | | |
| 89 | 89 | | 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" /> | | |
| 90 | 90 | | 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" /> | | |
| ⋯ | |||
| 114 | 114 | | --- | --- | --- | | |
| 115 | 115 | | [Integrations](/guides/integrations/) | The GitHub App, Sentry, Datadog, Jira, Linear and alerts. | <Status is="live" /> | | |
| 116 | 116 | | [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" /> | | |
| 119 | 119 | | More integrations | Drive, Gmail, Calendar, Slack and Microsoft 365, each listing what it lets agents do. | <Status is="coming" /> | | |
| 120 | 120 | | Mail | Email on your own domain, with shared inboxes that agents work in. | <Status is="coming" /> | | |
| 121 | 121 | ||
| 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 | + | } |
| 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"; | |
| 2 | 2 | import { type ReactNode, useState } from "react"; | |
| 3 | 3 | import { Form, NavLink, useLocation, useNavigation, useRouteLoaderData } from "react-router"; | |
| 4 | 4 | ||
| ⋯ | |||
| 138 | 138 | Overview | |
| 139 | 139 | </SideLink> | |
| 140 | 140 | )} | |
| 141 | + | <SideLink to={`/${slug}/-/agents/templates`} icon={<LayoutTemplate size={15} className="text-faint" />}> | |
| 142 | + | Templates | |
| 143 | + | </SideLink> | |
| 141 | 144 | {code && ( | |
| 142 | 145 | <> | |
| 143 | 146 | <SideLink to={`/${slug}/-/context`} icon={<Network size={15} className="text-faint" />}> | |
| 1 | 1 | /** | |
| 2 | 2 | * 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. | |
| 6 | 6 | */ | |
| 7 | 7 | import { ArrowRight, Check, CircleAlert, Clock, Plus, Send, X } from "lucide-react"; | |
| 8 | 8 | import { type ReactNode, useEffect, useState } from "react"; | |
| ⋯ | |||
| 10 | 10 | ||
| 11 | 11 | import type { ExtensionManifest, InstallRequest, ListingTier } from "@g1t/contracts"; | |
| 12 | 12 | ||
| 13 | − | import { PixelCreature } from "./agent-avatar"; | |
| 14 | 13 | import { ConnectorMark } from "./connectors"; | |
| 15 | 14 | import { Badge } from "./ui/badge"; | |
| 16 | 15 | import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "./ui/dialog"; | |
| 17 | 16 | import { Hint } from "./ui/hint"; | |
| 18 | 17 | 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"; | |
| 20 | 19 | import { cn } from "../lib/cn"; | |
| 21 | 20 | ||
| 22 | 21 | 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"; | |
| ⋯ | |||
| 36 | 35 | ); | |
| 37 | 36 | } | |
| 38 | 37 | ||
| 39 | − | /** "Coming", on what is planned and not built. */ | |
| 38 | + | /** "Soon", on what is planned and not built. */ | |
| 40 | 39 | export function ComingBadge() { | |
| 41 | − | return <Badge tone="neutral">Coming</Badge>; | |
| 40 | + | return <Badge tone="neutral">Soon</Badge>; | |
| 42 | 41 | } | |
| 43 | 42 | ||
| 44 | 43 | /** A section's heading, with a count or a link at its end. */ | |
| ⋯ | |||
| 99 | 98 | <fetcher.Form method="post" action={marketplacePath(slug, "requests")} className="grid gap-4"> | |
| 100 | 99 | <DialogHeader> | |
| 101 | 100 | <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> | |
| 103 | 102 | </DialogHeader> | |
| 104 | 103 | <input type="hidden" name="intent" value="request" /> | |
| 105 | 104 | <input type="hidden" name="listing" value={listing} /> | |
| ⋯ | |||
| 129 | 128 | <Link to={marketplacePath(slug, "requests")} className="text-xs text-warn hover:underline"> | |
| 130 | 129 | {count} {count === 1 ? "request" : "requests"} | |
| 131 | 130 | </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> | |
| 191 | 131 | ); | |
| 192 | 132 | } | |
| 193 | 133 | ||
| ⋯ | |||
| 296 | 236 | ); | |
| 297 | 237 | } | |
| 298 | 238 | ||
| 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 | + | ||
| 299 | 246 | /** An extension as a card: its mark, publisher and tier, what it does, and where its data goes. */ | |
| 300 | 247 | export function ExtensionCard({ listing, slug, owner }: { listing: ExtensionListing; slug: string; owner: boolean }) { | |
| 301 | 248 | const { manifest } = listing; | |
| ⋯ | |||
| 319 | 266 | </div> | |
| 320 | 267 | <p className="mt-3 line-clamp-2 grow text-[0.8125rem] leading-snug text-muted">{manifest.tagline}</p> | |
| 321 | 268 | <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> | |
| 323 | 270 | <ExtensionAction listing={listing} slug={slug} owner={owner} /> | |
| 324 | 271 | </div> | |
| 325 | 272 | </article> | |
| ⋯ | |||
| 345 | 292 | <div className="min-w-0 grow"> | |
| 346 | 293 | <p className="flex flex-wrap items-center gap-2 text-sm"> | |
| 347 | 294 | <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> | |
| 349 | 296 | <RequestStatus request={request} /> | |
| 350 | 297 | </p> | |
| 351 | 298 | <p className="mt-0.5 text-xs text-muted"> | |
| ⋯ | |||
| 372 | 319 | {addTo && ( | |
| 373 | 320 | <Link to={addTo} className={ACTION.primary}> | |
| 374 | 321 | <Plus size={14} /> | |
| 375 | − | {request.kind === "agent" ? "Add" : request.kind === "extension" ? "Install" : "Connect"} | |
| 322 | + | {request.kind === "extension" ? "Install" : "Connect"} | |
| 376 | 323 | </Link> | |
| 377 | 324 | )} | |
| 378 | 325 | <SubmitButton fetcher={fetcher} name="status" value="done" className={ACTION.quiet} pending="Saving…"> | |
| ⋯ | |||
| 388 | 335 | </li> | |
| 389 | 336 | ); | |
| 390 | 337 | } | |
| 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 | + | } | |
| 1476 | 1476 | trail.push({ label: third === "settings" ? "General" : (SECTIONS[third] ?? third), to: page }); | |
| 1477 | 1477 | } else if (third) { | |
| 1478 | 1478 | 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; | |
| 1482 | 1483 | if (tab) trail.push({ label: tab, to: `${page}/${fourth}` }); | |
| 1483 | 1484 | } | |
| 1484 | 1485 | } else if (second) { | |
| ⋯ | |||
| 1576 | 1577 | ...(hasCodeAccess(ws) ? [{ label: "Code overview", to: `/${ws.slug}/-/overview`, icon: <Code2 size={15} /> }] : []), | |
| 1577 | 1578 | { label: "Artifacts", to: `/${ws.slug}/-/artifacts`, icon: <Shapes size={15} /> }, | |
| 1578 | 1579 | { 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} /> }, | |
| 1581 | 1582 | { label: "Workspace", hint: "Billing, policies, settings", to: `/${ws.slug}/-/workspace`, icon: <Building2 size={15} /> }, | |
| 1582 | 1583 | ] | |
| 1583 | 1584 | : []), | |
| 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 | + | } |
| 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 | + | }); |
| 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 | + | } |
| 1 | 1 | /** | |
| 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). | |
| 5 | 4 | */ | |
| 6 | − | import type { AgentTemplate, User, WorkspaceAgent } from "@g1t/contracts"; | |
| 5 | + | import type { User } from "@g1t/contracts"; | |
| 7 | 6 | ||
| 8 | 7 | import type { ConnectedState } from "./connectors"; | |
| 9 | 8 | 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 | − | } | |
| 25 | 9 | ||
| 26 | 10 | export async function loadConnected(slug: string, viewer: User): Promise<Record<string, ConnectedState>> { | |
| 27 | 11 | return connectedStates(slug, viewer); |
| 1 | 1 | import assert from "node:assert/strict"; | |
| 2 | 2 | import { test } from "node:test"; | |
| 3 | 3 | ||
| 4 | − | import type { AgentTemplate, InstallRequest } from "@g1t/contracts"; | |
| 4 | + | import type { InstallRequest } from "@g1t/contracts"; | |
| 5 | 5 | import { connectorsFor } from "@g1t/contracts/connectors"; | |
| 6 | 6 | ||
| 7 | − | import { FIRST_PARTY_EXTENSIONS } from "@g1t/contracts/marketplace"; | |
| 7 | + | import { FIRST_PARTY_EXTENSIONS, extensionById } from "@g1t/contracts/marketplace"; | |
| 8 | 8 | ||
| 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"; | |
| 10 | 10 | 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 | − | }); | |
| 26 | 11 | ||
| 27 | 12 | const request = (listing: string, by: string, status: InstallRequest["status"] = "open"): InstallRequest => ({ | |
| 28 | 13 | id: `ins_${listing}_${by}`, | |
| 29 | 14 | listing, | |
| 30 | − | kind: listing.startsWith("agent:") ? "agent" : "integration", | |
| 15 | + | kind: listing.startsWith("extension:") ? "extension" : "integration", | |
| 31 | 16 | name: listing, | |
| 32 | 17 | note: null, | |
| 33 | 18 | requested_by: by, | |
| ⋯ | |||
| 35 | 20 | status, | |
| 36 | 21 | resolved_by: null, | |
| 37 | 22 | 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); | |
| 56 | 23 | }); | |
| 57 | 24 | ||
| 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 | − | ||
| 63 | 25 | test("only integrations a workspace can connect today are listed, connected first", () => { | |
| 64 | 26 | const views = connectorsFor("workspace"); | |
| 65 | 27 | const listings = integrationListings(views, { sentry: { detail: "acme", problem: null, manage: "/acme/-/integrations/alerts" } }, [], "ana", "acme"); | |
| ⋯ | |||
| 76 | 38 | ||
| 77 | 39 | test("an owner adds what a request asks for where it is added", () => { | |
| 78 | 40 | 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"); | |
| 80 | 42 | assert.equal(addPath({ listing: "integration:jira" }, "acme", views), "/acme/-/integrations/trackers?add=jira#add"); | |
| 81 | 43 | assert.equal(addPath({ listing: "nonsense" }, "acme", views), null); | |
| 82 | 44 | }); | |
| ⋯ | |||
| 87 | 49 | for (const [k, v] of Object.entries(fields)) data.set(k, v); | |
| 88 | 50 | return data; | |
| 89 | 51 | }; | |
| 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"); | |
| 92 | 55 | assert.equal(marketplaceForm(form({ intent: "request", listing: "app:qa" })), null); | |
| 93 | 56 | assert.deepEqual(marketplaceForm(form({ intent: "resolve", id: "ins_1", status: "declined" })), { intent: "resolve", id: "ins_1", status: "declined" }); | |
| 94 | 57 | assert.equal(marketplaceForm(form({ intent: "resolve", id: "ins_1", status: "open" })), null); | |
| ⋯ | |||
| 128 | 91 | assert.equal(addPath({ listing: "extension:mail" }, "acme", []), "/acme/-/marketplace/extensions/mail"); | |
| 129 | 92 | }); | |
| 130 | 93 | ||
| 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"); | |
| 135 | 102 | }); | |
| 136 | 103 | ||
| 137 | 104 | test("the Marketplace is part of Apps, not a mode of its own", () => { | |
| 138 | 105 | 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'"); | |
| 140 | 108 | }); | |
| 1 | 1 | /** | |
| 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. | |
| 4 | 4 | * | |
| 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). | |
| 10 | 11 | * | |
| 11 | 12 | * No Workers or React imports, so it can be tested under Node. | |
| 12 | 13 | */ | |
| 13 | − | import type { AgentTemplate, ExtensionInstall, ExtensionManifest, InstallRequest, InstallRequestStatus, ListingTier, WorkspaceAgent } from "@g1t/contracts"; | |
| 14 | + | import type { ExtensionInstall, ExtensionManifest, InstallRequest, InstallRequestStatus, ListingTier } from "@g1t/contracts"; | |
| 14 | 15 | import { type ConnectorView, connectorPath } from "@g1t/contracts/connectors"; | |
| 15 | 16 | import { listingRef, parseListing } from "@g1t/contracts/marketplace"; | |
| 16 | 17 | ||
| 17 | 18 | import type { ConnectedState } from "./connectors"; | |
| 18 | 19 | ||
| 19 | 20 | /** 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 { | |
| 21 | 22 | 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)}`; | |
| 32 | 23 | } | |
| 33 | 24 | ||
| 34 | 25 | /** Who stands behind a listing, in a word and a sentence. */ | |
| ⋯ | |||
| 48 | 39 | function openCount(requests: InstallRequest[], ref: string): number { | |
| 49 | 40 | return requests.filter((request) => request.status === "open" && request.listing === ref).length; | |
| 50 | 41 | } | |
| 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 | − | }; | |
| 63 | 42 | ||
| 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 | − | ||
| 89 | 43 | /** An integration a workspace can connect, with whether it has. */ | |
| 90 | 44 | export type IntegrationListing = { | |
| 91 | 45 | ref: string; | |
| ⋯ | |||
| 148 | 102 | waiting: number; | |
| 149 | 103 | }; | |
| 150 | 104 | ||
| 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 | + | ||
| 151 | 125 | /** Every extension listed, published ones first, each with its install. */ | |
| 152 | 126 | export function extensionListings(manifests: ExtensionManifest[], installs: ExtensionInstall[], requests: InstallRequest[], username: string): ExtensionListing[] { | |
| 153 | 127 | const listings = manifests.map((manifest) => { | |
| ⋯ | |||
| 186 | 160 | return requests.filter((request) => request.status === "open"); | |
| 187 | 161 | } | |
| 188 | 162 | ||
| 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. */ | |
| 190 | 164 | export function addPath(request: Pick<InstallRequest, "listing">, slug: string, views: ConnectorView[]): string | null { | |
| 191 | 165 | const parsed = parseListing(request.listing); | |
| 192 | 166 | if (!parsed) return null; | |
| 193 | − | if (parsed.kind === "agent") return hirePath(slug, parsed.id); | |
| 194 | 167 | if (parsed.kind === "extension") return extensionPath(slug, parsed.id); | |
| 195 | 168 | const view = views.find((v) => v.id === parsed.id); | |
| 196 | 169 | return view?.href ? connectorPath(view.href, slug) : null; | |
| ⋯ | |||
| 231 | 204 | } | |
| 232 | 205 | if (intent === "uninstall" && extension) return { intent, listing: extension }; | |
| 233 | 206 | 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"; | |
| 248 | 207 | } | |
| 27 | 27 | // Code's Overview lives at -/overview now: not an old address. | |
| 28 | 28 | assert.equal(workspaceRedirect("/acme/-/overview"), null); | |
| 29 | 29 | 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); | |
| 30 | 35 | assert.equal(workspaceRedirect("/acme/-/soon/insights", "?x=1"), "/acme/-/insights?x=1"); | |
| 31 | 36 | assert.equal(workspaceRedirect("/acme/-/soon/board"), null); | |
| 32 | 37 | assert.equal(workspaceRedirect("/acme/-/people"), null); |
| 66 | 66 | "soon/insights": "-/insights", | |
| 67 | 67 | }; | |
| 68 | 68 | ||
| 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 | + | ||
| 69 | 75 | /** The page a request is for: a click's data request ends in `.data`. */ | |
| 70 | 76 | export function pagePath(pathname: string): string { | |
| 71 | 77 | return pathname.replace(/\.data$/, "").replace(/\/+$/, "") || "/"; | |
| ⋯ | |||
| 109 | 115 | return `/${slug}${to ? `/${to}` : ""}${rest ? `?${rest}` : ""}`; | |
| 110 | 116 | } | |
| 111 | 117 | 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]; | |
| 113 | 121 | if (to === undefined) return null; | |
| 114 | 122 | return `/${slug}${to ? `/${to}` : ""}${search && search !== "?" ? (search.startsWith("?") ? search : `?${search}`) : ""}`; | |
| 115 | 123 | } | |
| 150 | 150 | route("-/agents", "routes/workspace/agents/layout.tsx", [ | |
| 151 | 151 | index("routes/workspace/agents.tsx"), | |
| 152 | 152 | 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"), | |
| 153 | 156 | route(":handle", "routes/workspace/agents/agent.tsx", [ | |
| 154 | 157 | index("routes/workspace/agents/sessions.tsx"), | |
| 155 | 158 | route("sessions/:id", "routes/workspace/agents/session.tsx"), | |
| ⋯ | |||
| 203 | 206 | route("-/today", "routes/workspace/home.tsx"), | |
| 204 | 207 | route("-/home", "routes/workspace/moved.ts", { id: "routes/workspace/moved-home" }), | |
| 205 | 208 | 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. | |
| 208 | 212 | route("-/marketplace", "routes/workspace/marketplace/layout.tsx", [ | |
| 209 | 213 | 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"), | |
| 213 | 214 | route("extensions", "routes/workspace/marketplace/extensions.tsx"), | |
| 214 | 215 | route("extensions/:extension", "routes/workspace/marketplace/extension.tsx"), | |
| 216 | + | route("integrations", "routes/workspace/marketplace/integrations.tsx"), | |
| 215 | 217 | route("requests", "routes/workspace/marketplace/requests.tsx"), | |
| 216 | 218 | ]), | |
| 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" }), | |
| 217 | 221 | // What Code's pages say to a member without Code. | |
| 218 | 222 | route("-/code-access", "routes/workspace/code-access.tsx"), | |
| 219 | 223 | route("-/memory", "routes/workspace/memory.tsx"), | |
| 249 | 249 | <h2 id="roster" className="text-sm font-medium"> | |
| 250 | 250 | Agents <span className="text-faint">{agents.length}</span> | |
| 251 | 251 | </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 | |
| 254 | 254 | </Link> | |
| 255 | 255 | </div> | |
| 256 | 256 | <ul className="mt-3 divide-y divide-line overflow-hidden rounded-xl border border-line bg-surface"> |
| 95 | 95 | <header className="mt-4 mb-8"> | |
| 96 | 96 | <h1 className="text-2xl font-semibold tracking-tight">New agent</h1> | |
| 97 | 97 | <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 | + | . | |
| 99 | 103 | </p> | |
| 100 | 104 | </header> | |
| 101 | 105 | {loaderData.templates == null && ( |
| 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 | + | } |
| 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 | + | } |
| 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 | − | } |
| 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 | − | } |
| 1 | 1 | /** | |
| 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. | |
| 6 | 6 | */ | |
| 7 | − | import { Rocket } from "lucide-react"; | |
| 8 | − | import type { ReactNode } from "react"; | |
| 9 | 7 | import { Link } from "react-router"; | |
| 10 | 8 | ||
| 9 | + | import { type ExtensionManifest, FIRST_PARTY_EXTENSIONS, extensionById } from "@g1t/contracts/marketplace"; | |
| 11 | 10 | import { connectorsFor } from "@g1t/contracts/connectors"; | |
| 12 | 11 | ||
| 13 | 12 | 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"; | |
| 22 | 16 | import { requireUser, roleIn } from "../../../lib/session.server"; | |
| 23 | 17 | import { useMarketplace } from "./layout"; | |
| 24 | 18 | ||
| ⋯ | |||
| 26 | 20 | const viewer = requireUser(context, request); | |
| 27 | 21 | const slug = params.owner.toLowerCase(); | |
| 28 | 22 | 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) }; | |
| 31 | 24 | } | |
| 32 | 25 | ||
| 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". */ | |
| 35 | 27 | const INTEGRATIONS_SHOWN = 6; | |
| 28 | + | /** The extensions the front page leads with: the first the largest. */ | |
| 29 | + | const FEATURED = ["mail", "support", "crm"]; | |
| 36 | 30 | ||
| 37 | 31 | export default function MarketplaceDiscover({ loaderData }: Route.ComponentProps) { | |
| 38 | 32 | const { slug, owner, username, requests, installs } = useMarketplace(); | |
| 39 | 33 | const asked = requests?.requests ?? []; | |
| 40 | − | const agents = agentListings(loaderData.templates ?? [], loaderData.agents, asked, username); | |
| 41 | 34 | const integrations = integrationListings(connectorsFor("workspace"), loaderData.connected, asked, username, slug); | |
| 42 | 35 | 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)); | |
| 47 | 38 | return ( | |
| 48 | 39 | <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} /> | |
| 93 | 49 | ))} | |
| 94 | 50 | </div> | |
| 95 | − | )} | |
| 51 | + | </div> | |
| 96 | 52 | </section> | |
| 97 | 53 | ||
| 98 | 54 | <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. | |
| 101 | 57 | </SectionHead> | |
| 102 | 58 | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| 103 | 59 | {integrations.slice(0, INTEGRATIONS_SHOWN).map((listing) => ( | |
| ⋯ | |||
| 106 | 62 | </div> | |
| 107 | 63 | </section> | |
| 108 | 64 | ||
| 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. | |
| 112 | 68 | </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) => ( | |
| 115 | 82 | <ExtensionCard key={listing.ref} listing={listing} slug={slug} owner={owner} /> | |
| 116 | 83 | ))} | |
| 117 | 84 | </div> | |
| ⋯ | |||
| 119 | 86 | </div> | |
| 120 | 87 | ); | |
| 121 | 88 | } | |
| 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 | + | } | |
| 76 | 76 | </h3> | |
| 77 | 77 | <div className="mt-3 grid gap-3 sm:grid-cols-2"> | |
| 78 | 78 | <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." /> | |
| 80 | 81 | <Group title="Tools for agents" items={manifest.adds.tools} empty="None" /> | |
| 81 | 82 | <Group title="Notifications it sends" items={manifest.adds.notifications} empty="None" /> | |
| 82 | 83 | </div> |
| 1 | 1 | /** | |
| 2 | 2 | * Extensions in the Marketplace: every listed extension, from its | |
| 3 | 3 | * 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. | |
| 6 | 6 | */ | |
| 7 | 7 | import { FIRST_PARTY_EXTENSIONS, LISTING_TIERS } from "@g1t/contracts/marketplace"; | |
| 8 | 8 | ||
| 9 | 9 | import { ExtensionCard, SectionHead, TierBadge } from "../../../components/marketplace"; | |
| 10 | − | import { TIERS, extensionListings } from "../../../lib/marketplace"; | |
| 10 | + | import { TIERS, extensionListings, isConnectedSystem } from "../../../lib/marketplace"; | |
| 11 | 11 | import { useMarketplace } from "./layout"; | |
| 12 | 12 | ||
| 13 | 13 | export default function MarketplaceExtensions() { | |
| 14 | 14 | const { slug, owner, username, requests, installs } = useMarketplace(); | |
| 15 | 15 | const listings = extensionListings(FIRST_PARTY_EXTENSIONS, installs, requests?.requests ?? [], username); | |
| 16 | 16 | 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)); | |
| 18 | 19 | return ( | |
| 19 | 20 | <div className="space-y-12"> | |
| 20 | 21 | {published.length > 0 && ( | |
| ⋯ | |||
| 28 | 29 | </section> | |
| 29 | 30 | )} | |
| 30 | 31 | <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}`}> | |
| 32 | 33 | 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. | |
| 33 | 34 | </SectionHead> | |
| 34 | 35 | <div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3"> | |
| ⋯ | |||
| 37 | 38 | ))} | |
| 38 | 39 | </div> | |
| 39 | 40 | </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 | + | )} | |
| 40 | 53 | <section aria-labelledby="how" className="rounded-2xl border border-line bg-surface p-5 sm:p-6"> | |
| 41 | 54 | <h2 id="how" className="text-base font-semibold tracking-tight"> | |
| 42 | 55 | How extensions are shared | |
| 1 | 1 | /** | |
| 2 | 2 | * 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. | |
| 6 | 7 | */ | |
| 7 | 8 | import { Search } from "lucide-react"; | |
| 8 | 9 | import { useState } from "react"; | |
| 9 | 10 | import { Link } from "react-router"; | |
| 10 | 11 | ||
| 11 | − | import { connectorView, connectorsFor, CONNECTORS } from "@g1t/contracts/connectors"; | |
| 12 | + | import { type ConnectorView, connectorView, connectorsFor, CONNECTORS } from "@g1t/contracts/connectors"; | |
| 12 | 13 | ||
| 13 | 14 | import type { Route } from "./+types/integrations"; | |
| 14 | 15 | import { ConnectorMark } from "../../../components/connectors"; | |
| ⋯ | |||
| 35 | 36 | const available = shown.filter((listing) => !listing.connected); | |
| 36 | 37 | const wanted = query.trim().toLowerCase(); | |
| 37 | 38 | 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)))); | |
| 38 | 41 | const coming = comingIntegrations(views, personal).filter((view) => !wanted || [view.name, view.description, ...view.keywords].some((w) => w.toLowerCase().includes(wanted))); | |
| 39 | 42 | return ( | |
| 40 | 43 | <div className="space-y-10"> | |
| ⋯ | |||
| 67 | 70 | )} | |
| 68 | 71 | ||
| 69 | 72 | <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> | |
| 71 | 76 | {available.length === 0 ? ( | |
| 72 | 77 | <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> | |
| 73 | 78 | ) : ( | |
| ⋯ | |||
| 79 | 84 | )} | |
| 80 | 85 | </section> | |
| 81 | 86 | ||
| 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 | + | ||
| 82 | 114 | {coming.length > 0 && ( | |
| 83 | 115 | <section aria-labelledby="coming"> | |
| 84 | 116 | <SectionHead id="coming" title="Coming" aside={<ComingBadge />}> | |
| 1 | 1 | /** | |
| 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. | |
| 6 | 9 | * | |
| 7 | 10 | * This layout holds the heading, the tabs and the viewer's requests, which | |
| 8 | 11 | * every tab reads to say what is waiting. | |
| 9 | 12 | */ | |
| 10 | − | import { Blocks, Bot, Inbox, Plug, Sparkles } from "lucide-react"; | |
| 13 | + | import { Blocks, Inbox, Plug, Sparkles } from "lucide-react"; | |
| 11 | 14 | import { Link, NavLink, Outlet, data, useLocation, useRouteLoaderData } from "react-router"; | |
| 12 | 15 | ||
| 13 | 16 | import type { ExtensionInstall, InstallRequests } from "@g1t/contracts"; | |
| ⋯ | |||
| 64 | 67 | const { pathname } = useLocation(); | |
| 65 | 68 | const tabs = [ | |
| 66 | 69 | { 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} /> }, | |
| 68 | 71 | { to: marketplacePath(slug, "integrations"), label: "Integrations", icon: <Plug size={14} /> }, | |
| 69 | − | { to: marketplacePath(slug, "extensions"), label: "Extensions", icon: <Blocks size={14} /> }, | |
| 70 | 72 | { to: marketplacePath(slug, "requests"), label: owner ? "Requests" : "Your requests", icon: <Inbox size={14} />, count: waiting }, | |
| 71 | 73 | ]; | |
| 72 | 74 | return ( | |
| ⋯ | |||
| 75 | 77 | <div> | |
| 76 | 78 | <h1 className="text-2xl font-semibold tracking-tight">Marketplace</h1> | |
| 77 | 79 | <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.{" "} | |
| 79 | 81 | {owner ? "As an owner, you add them for everyone." : "Owners add them; ask one for what you need, and they'll hear about it."} | |
| 80 | 82 | </p> | |
| 81 | 83 | </div> | |
| 54 | 54 | const answered = requests.requests.filter((request) => request.status !== "open"); | |
| 55 | 55 | if (requests.requests.length === 0) { | |
| 56 | 56 | 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> | |
| 58 | 58 | ) : ( | |
| 59 | 59 | <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. | |
| 61 | 61 | </EmptyState> | |
| 62 | 62 | ); | |
| 63 | 63 | } | |
| ⋯ | |||
| 66 | 66 | <section aria-labelledby="waiting"> | |
| 67 | 67 | <SectionHead id="waiting" title={owner ? "Waiting on an owner" : "Waiting"} aside={`${waiting.length}`}> | |
| 68 | 68 | {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." | |
| 70 | 70 | : "Your requests the workspace's owners haven't answered yet."} | |
| 71 | 71 | </SectionHead> | |
| 72 | 72 | {waiting.length === 0 ? ( | |
| 42 | 42 | refused({ pricing: { monthly: 5 } }, /free/); | |
| 43 | 43 | refused({ permissions: [] }, /permissions/); | |
| 44 | 44 | refused({ ui: { entry: "index.html" } }, /ui.entry/); | |
| 45 | + | refused({ bridges: " " }, /bridges/); | |
| 46 | + | refused({ adds: { pages: [], agent_roles: [], tools: [], notifications: [] } }, /cards/); | |
| 45 | 47 | // A community extension that runs on its own servers, declaring them, is fine. | |
| 46 | 48 | assert.ok(checkManifest({ ...published, runtime: "connected", publisher: { name: "anyone", tier: "community" }, domains: ["api.example.com"] }, isScope).ok); | |
| 47 | 49 | }); | |
| ⋯ | |||
| 49 | 51 | test("the install screen says where data goes", () => { | |
| 50 | 52 | assert.equal(dataDisclosure({ domains: [] }), "Its data stays in g1t."); | |
| 51 | 53 | 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."); | |
| 52 | 55 | }); | |
| 53 | 56 | ||
| 54 | 57 | test("an extension's page loads from the user-content origin, by version", () => { | |
| ⋯ | |||
| 66 | 69 | test("listings are referenced by kind and id", () => { | |
| 67 | 70 | assert.deepEqual(parseListing("extension:on-call"), { kind: "extension", id: "on-call" }); | |
| 68 | 71 | assert.equal(parseListing("app:on-call"), null); | |
| 72 | + | assert.equal(parseListing("agent:engineering"), null, "agents start from templates in Agents, not the Marketplace"); | |
| 69 | 73 | }); | |
| 1 | 1 | /** | |
| 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. | |
| 6 | 9 | * | |
| 7 | 10 | * Every listing has a reference, `<kind>:<id>`: | |
| 8 | 11 | * | |
| 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; | |
| 11 | 12 | * - `integration:<connector>`: a connector from the catalog | |
| 12 | 13 | * (./connectors.ts); the integrations service keeps the connection; | |
| 13 | 14 | * - `extension:<id>`: an extension, described by its manifest | |
| ⋯ | |||
| 28 | 29 | */ | |
| 29 | 30 | ||
| 30 | 31 | /** What a listing adds to a workspace. */ | |
| 31 | − | export type ListingKind = "agent" | "integration" | "extension"; | |
| 32 | + | export type ListingKind = "integration" | "extension"; | |
| 32 | 33 | ||
| 33 | − | export const LISTING_KINDS: readonly ListingKind[] = ["agent", "integration", "extension"]; | |
| 34 | + | export const LISTING_KINDS: readonly ListingKind[] = ["integration", "extension"]; | |
| 34 | 35 | ||
| 35 | 36 | /** Whether a listing can be added today, or is planned. */ | |
| 36 | 37 | export type ListingStatus = "available" | "soon"; | |
| ⋯ | |||
| 46 | 47 | export type ExtensionAdds = { | |
| 47 | 48 | /** Its pages, in its sidebar, in order. */ | |
| 48 | 49 | pages: string[]; | |
| 49 | − | /** Agent roles it brings, hired like any other. */ | |
| 50 | + | /** Agent roles it brings: templates, configured like any other agent. */ | |
| 50 | 51 | agent_roles: string[]; | |
| 52 | + | /** Cards it shows in chat and elsewhere, in words. */ | |
| 53 | + | cards: string[]; | |
| 51 | 54 | /** Tools agents can call. */ | |
| 52 | 55 | tools: string[]; | |
| 53 | 56 | /** Notification kinds it sends. */ | |
| ⋯ | |||
| 86 | 89 | */ | |
| 87 | 90 | domains: string[]; | |
| 88 | 91 | /** | |
| 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 | + | /** | |
| 89 | 98 | * Its page inside g1t: a path served from the user-content domain | |
| 90 | 99 | * (g1tusercontent.com, or a self-hosted instance's own), loaded in a | |
| 91 | 100 | * sandboxed frame that reaches g1t only through the bridge. Null: no page. | |
| ⋯ | |||
| 139 | 148 | if (!Array.isArray(m.permissions) || m.permissions.length === 0) return bad("List what it can do in `permissions`, in plain words."); | |
| 140 | 149 | 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."); | |
| 141 | 150 | 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."); | |
| 142 | 152 | 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."); | |
| 144 | 154 | if (m.pricing !== null) return bad("Listings are free for now: `pricing` is null."); | |
| 145 | 155 | return { ok: true, value: m as ExtensionManifest }; | |
| 146 | 156 | } | |
| ⋯ | |||
| 150 | 160 | } | |
| 151 | 161 | ||
| 152 | 162 | /** 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(", ")}.`; | |
| 156 | 167 | } | |
| 157 | 168 | ||
| 158 | 169 | /** Where an extension's page loads from: its entry on the user-content origin, under its id and version. */ | |
| ⋯ | |||
| 167 | 178 | return match ? match[1]! : null; | |
| 168 | 179 | } | |
| 169 | 180 | ||
| 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 | + | ||
| 170 | 192 | /** | |
| 171 | 193 | * 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. | |
| 173 | 197 | */ | |
| 174 | 198 | export const FIRST_PARTY_EXTENSIONS: ExtensionManifest[] = [ | |
| 175 | 199 | { | |
| 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, | |
| 176 | 214 | id: "support", | |
| 177 | 215 | name: "Support", | |
| 178 | 216 | tagline: "Customer conversations an agent answers from your docs, escalating the rest.", | |
| 179 | 217 | description: | |
| 180 | 218 | "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.", | |
| 181 | 219 | category: "Customers", | |
| 182 | − | publisher: { name: "g1t", tier: "official" }, | |
| 183 | − | status: "soon", | |
| 184 | − | source: null, | |
| 185 | − | version: null, | |
| 186 | − | runtime: "hosted", | |
| 187 | 220 | scopes: ["issues:write", "artifacts:read", "notifications:write"], | |
| 188 | 221 | permissions: ["Read and reply to support email, with your approval", "Store conversations in workspace data", "Open issues from bug reports"], | |
| 189 | 222 | 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"] }, | |
| 193 | 243 | }, | |
| 194 | 244 | { | |
| 245 | + | ...UNRELEASED, | |
| 195 | 246 | id: "recruiting", | |
| 196 | 247 | name: "Recruiting", | |
| 197 | 248 | 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.", | |
| 199 | 251 | category: "People", | |
| 200 | − | publisher: { name: "g1t", tier: "official" }, | |
| 201 | − | status: "soon", | |
| 202 | − | source: null, | |
| 203 | − | version: null, | |
| 204 | − | runtime: "hosted", | |
| 205 | 252 | scopes: ["artifacts:write", "notifications:write"], | |
| 206 | 253 | 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"], | |
| 207 | 254 | 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 | + | }, | |
| 211 | 262 | }, | |
| 212 | 263 | { | |
| 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, | |
| 232 | 265 | id: "on-call", | |
| 233 | 266 | name: "On-call", | |
| 234 | 267 | tagline: "Rotations, pages and who is on call now.", | |
| 235 | 268 | description: "Rotations and schedules, pages through notifications, and an incident channel an operations agent keeps a timeline in.", | |
| 236 | 269 | category: "Engineering", | |
| 237 | − | publisher: { name: "g1t", tier: "official" }, | |
| 238 | − | status: "soon", | |
| 239 | − | source: null, | |
| 240 | − | version: null, | |
| 241 | − | runtime: "hosted", | |
| 242 | 270 | scopes: ["notifications:write", "issues:read"], | |
| 243 | 271 | permissions: ["Store rotations in workspace data", "Page people through their notifications", "Read issues an incident links to"], | |
| 244 | 272 | 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"] }, | |
| 248 | 316 | }, | |
| 249 | 317 | ]; | |
| 250 | 318 | ||
| ⋯ | |||
| 261 | 329 | ||
| 262 | 330 | export const LISTING_TIERS: readonly ListingTier[] = ["official", "verified", "community", "internal"]; | |
| 263 | 331 | ||
| 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" }`. */ | |
| 265 | 333 | export type ListingRef = { kind: ListingKind; id: string }; | |
| 266 | 334 | ||
| 267 | 335 | /** Where a request stands: waiting on an owner, added, or turned down. */ | |
| ⋯ | |||
| 275 | 343 | /** `<kind>:<id>`. */ | |
| 276 | 344 | listing: string; | |
| 277 | 345 | 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`. */ | |
| 279 | 347 | name: string; | |
| 280 | 348 | /** Why they want it, in their words, or null. */ | |
| 281 | 349 | note: string | null; | |
| ⋯ | |||
| 304 | 372 | /** How long an answered request stays listed. */ | |
| 305 | 373 | export const ANSWERED_REQUESTS_DAYS = 30; | |
| 306 | 374 | ||
| 307 | − | /** A listing's reference: `agent:engineering`. */ | |
| 375 | + | /** A listing's reference: `integration:sentry`. */ | |
| 308 | 376 | export function listingRef(kind: ListingKind, id: string): string { | |
| 309 | 377 | return `${kind}:${id}`; | |
| 310 | 378 | } | |
| ⋯ | |||
| 312 | 380 | /** A reference split into kind and id; null when it is not one. */ | |
| 313 | 381 | export function parseListing(value: unknown): ListingRef | null { | |
| 314 | 382 | 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()); | |
| 316 | 384 | return match ? { kind: match[1] as ListingKind, id: match[2]! } : null; | |
| 317 | 385 | } | |
| 318 | 386 | ||
| 728 | 728 | */ | |
| 729 | 729 | installRequests(workspace: string, viewer: User): Promise<Result<InstallRequests>>; | |
| 730 | 730 | /** | |
| 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 | |
| 733 | 734 | * things themselves, so they don't ask. Asking again while a request for | |
| 734 | 735 | * the same listing is open is a conflict. | |
| 735 | 736 | */ |
| 28 | 28 | }); | |
| 29 | 29 | ||
| 30 | 30 | 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"]) { | |
| 32 | 32 | const checked = checkHandle(name); | |
| 33 | 33 | assert.equal(checked.ok, false, name); | |
| 34 | 34 | if (!checked.ok) assert.match(checked.message, /reserved/); |
| 12 | 12 | ||
| 13 | 13 | /** | |
| 14 | 14 | * 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. | |
| 17 | 17 | */ | |
| 18 | − | const ROUTES = new Set(["new", "runs", "fleet"]); | |
| 18 | + | const ROUTES = new Set(["new", "templates", "runs", "fleet"]); | |
| 19 | 19 | ||
| 20 | 20 | const HANDLE = /^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,31}$/; | |
| 21 | 21 |
| 221 | 221 | } | |
| 222 | 222 | this.audit(a.viewer!, a.workspace, "create_agent", definition.handle, `Created @${definition.handle} (version 1)`); | |
| 223 | 223 | 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!)); | |
| 226 | 224 | const row = await this.row(managed.value, definition.handle); | |
| 227 | 225 | return ok(toAgent(row!, new Date())); | |
| 228 | 226 | } | |
| ⋯ | |||
| 355 | 353 | kind: "approval", | |
| 356 | 354 | workspace: slug, | |
| 357 | 355 | 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."), | |
| 359 | 357 | href: requestsPath(slug), | |
| 360 | 358 | actor: { kind: "user", id: asker.id, name: asker.username, avatar: asker.avatar ?? null, avatar_seed: null }, | |
| 361 | 359 | created_at: new Date().toISOString(), | |
| 31 | 31 | const later = (days: number) => new Date(at.getTime() + days * 86_400_000); | |
| 32 | 32 | ||
| 33 | 33 | 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"); | |
| 37 | 37 | assert.equal(findListing("integration:slack"), null, "Slack is planned, not available"); | |
| 38 | 38 | assert.equal(findListing("integration:mcp"), null, "MCP clients are connected by each person, not a workspace"); | |
| 39 | 39 | assert.equal(findListing("extension:support"), null, "no extensions are listed yet"); | |
| ⋯ | |||
| 42 | 42 | ||
| 43 | 43 | test("references parse strictly, and notes are tidied", () => { | |
| 44 | 44 | 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); | |
| 48 | 49 | assert.equal(cleanRequestNote(" for on-call "), "for on-call"); | |
| 49 | 50 | assert.equal(cleanRequestNote(" "), null); | |
| 50 | 51 | assert.equal(cleanRequestNote("x".repeat(400))?.length, 280); | |
| ⋯ | |||
| 53 | 54 | ||
| 54 | 55 | test("a member's request is kept, and asking twice is one request", async () => { | |
| 55 | 56 | const db = fakeD1(); | |
| 56 | − | const listing = findListing("agent:qa")!; | |
| 57 | + | const listing = findListing("integration:sentry")!; | |
| 57 | 58 | const first = await openRequest(db, ws, nextId(), listing, ana, "Flaky checks", at); | |
| 58 | 59 | assert.ok(first.ok); | |
| 59 | 60 | 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"); | |
| 61 | 63 | assert.equal(first.value.note, "Flaky checks"); | |
| 62 | 64 | const again = await openRequest(db, ws, nextId(), listing, ana, null, later(0.1)); | |
| 63 | 65 | assert.equal(again.ok, false); | |
| ⋯ | |||
| 68 | 70 | ||
| 69 | 71 | test("an owner sees every request; a member sees their own", async () => { | |
| 70 | 72 | 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); | |
| 72 | 74 | 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"]); | |
| 76 | 78 | }); | |
| 77 | 79 | ||
| 78 | 80 | test("answering a request: once, by status, and answered ones age out", async () => { | |
| ⋯ | |||
| 97 | 99 | ||
| 98 | 100 | test("adding a listing answers every open request for it", async () => { | |
| 99 | 101 | 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)); | |
| 104 | 106 | assert.deepEqual(answered.map((a) => a.requested_by_id).sort(), ["usr_ana", "usr_bo"]); | |
| 105 | 107 | assert.ok(answered.every((a) => a.request.status === "done")); | |
| 106 | 108 | 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)), []); | |
| 108 | 122 | }); | |
| 109 | 123 | ||
| 110 | 124 | test("a person keeps at most so many requests open", async () => { | |
| 111 | 125 | 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"]; | |
| 114 | 127 | assert.equal(listings.length, MAX_OPEN_REQUESTS); | |
| 115 | 128 | for (const ref of listings) assert.ok((await openRequest(db, ws, nextId(), findListing(ref)!, ana, null, at)).ok, ref); | |
| 116 | 129 | const over = await openRequest(db, ws, nextId(), findListing("integration:groq")!, ana, null, at); | |
| ⋯ | |||
| 118 | 131 | }); | |
| 119 | 132 | ||
| 120 | 133 | 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"); | |
| 125 | 139 | assert.equal(listingPath("acme", "integration:sentry"), "/acme/-/marketplace/integrations"); | |
| 126 | 140 | }); | |
| 1 | 1 | /** | |
| 2 | 2 | * 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 | |
| 5 | 5 | * `install_requests`, one open request per person and listing. | |
| 6 | 6 | * | |
| 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. | |
| 11 | 13 | */ | |
| 12 | 14 | import type { FailureCode, InstallRequest, InstallRequestStatus, ListingKind, Result } from "@g1t/contracts"; | |
| 13 | 15 | import { CONNECTORS } from "@g1t/contracts/connectors"; | |
| ⋯ | |||
| 16 | 18 | // Plain values rather than @g1t/contracts' helpers, so this file runs under Node's tests. | |
| 17 | 19 | const ok = <T>(value: T): Result<T> => ({ ok: true, value }); | |
| 18 | 20 | const fail = (code: FailureCode, message: string): Result<never> => ({ ok: false, error: { code, message } }); | |
| 19 | − | ||
| 20 | − | import { TEMPLATES } from "./templates.ts"; | |
| 21 | 21 | ||
| 22 | 22 | /** Something the workspace can add, as a request names it. */ | |
| 23 | 23 | export type Listing = { ref: string; kind: ListingKind; id: string; name: string }; | |
| ⋯ | |||
| 26 | 26 | export function findListing(value: unknown): Listing | null { | |
| 27 | 27 | const parsed = parseListing(value); | |
| 28 | 28 | 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 | − | } | |
| 33 | 29 | if (parsed.kind === "extension") { | |
| 34 | 30 | const extension = extensionById(parsed.id); | |
| 35 | 31 | return extension?.status === "available" ? { ref: listingRef("extension", extension.id), kind: "extension", id: extension.id, name: extension.name } : null; | |
| ⋯ | |||
| 54 | 50 | ||
| 55 | 51 | const COLUMNS = "id, listing, name, note, requested_by, requested_by_id, requested_at, status, resolved_by, resolved_at"; | |
| 56 | 52 | ||
| 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; | |
| 58 | 57 | return { | |
| 59 | 58 | id: row.id, | |
| 60 | 59 | listing: row.listing, | |
| 61 | − | kind: parseListing(row.listing)?.kind ?? "agent", | |
| 60 | + | kind: parsed.kind, | |
| 62 | 61 | name: row.name, | |
| 63 | 62 | note: row.note, | |
| 64 | 63 | requested_by: row.requested_by, | |
| ⋯ | |||
| 87 | 86 | ); | |
| 88 | 87 | const bound = owner ? statement.bind(workspaceId, since) : statement.bind(workspaceId, since, viewer.id); | |
| 89 | 88 | const { results } = await bound.all<Row>(); | |
| 90 | − | return results.map(toRequest); | |
| 89 | + | return results.map(toRequest).filter((request) => request !== null); | |
| 91 | 90 | } | |
| 92 | 91 | ||
| 93 | 92 | /** | |
| ⋯ | |||
| 131 | 130 | .bind(row.id, workspaceId, row.listing, row.name, row.note, row.requested_by, row.requested_by_id, row.requested_at) | |
| 132 | 131 | .first<{ id: string }>(); | |
| 133 | 132 | 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)!); | |
| 135 | 134 | } | |
| 136 | 135 | ||
| 137 | 136 | /** A request answered, with who asked, for telling them. */ | |
| ⋯ | |||
| 154 | 153 | ) | |
| 155 | 154 | .bind(status, by.username, now.toISOString(), id, workspaceId) | |
| 156 | 155 | .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 }); | |
| 158 | 158 | const exists = await db.prepare("SELECT status FROM install_requests WHERE id = ? AND workspace_id = ?").bind(id, workspaceId).first<{ status: string }>(); | |
| 159 | 159 | return exists ? fail("conflict", "That request was already answered.") : fail("not_found", "There is no such request."); | |
| 160 | 160 | } | |
| ⋯ | |||
| 171 | 171 | ) | |
| 172 | 172 | .bind(by.username, now.toISOString(), workspaceId, listing) | |
| 173 | 173 | .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 | + | }); | |
| 175 | 178 | } | |
| 176 | 179 | ||
| 177 | 180 | /** Where a request is looked at in the workspace `slug`. */ | |
| ⋯ | |||
| 182 | 185 | /** Where the thing a request asked for is, once added. */ | |
| 183 | 186 | export function listingPath(slug: string, listing: string): string { | |
| 184 | 187 | const parsed = parseListing(listing); | |
| 185 | − | if (parsed?.kind === "agent") return `/${slug}/-/marketplace/agents/${parsed.id}`; | |
| 186 | 188 | if (parsed?.kind === "extension") return `/${slug}/-/marketplace/extensions/${parsed.id}`; | |
| 187 | 189 | return `/${slug}/-/marketplace/integrations`; | |
| 188 | 190 | } | |
| ⋯ | |||
| 194 | 196 | } | |
| 195 | 197 | return { | |
| 196 | 198 | 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.`, | |
| 198 | 200 | }; | |
| 199 | 201 | } | |