Skip to content

Commit

Each agent has an Abilities tab: g1t's built-ins, always on within the asker's access; its computer, coming; each connected integration's actions one row each, read, import, comment or resolve, with a level for each, alone, alone when the person asked for it, ask first or never, and whose connection it runs on; and MCP servers an owner adds. Reading is alone, writing inside g1t follows today's choices, anything that leaves g1t asks first, and production deploys can't go above ask. The agents service enforces every level: ask first posts a card to allow or deny and parks a session until it's answered, a refusal names its rule in the transcript and the audit log, and a missing ability posts a request to owners. The agent abilities guide says how.

syntaqxcommitted Parentbd68617Browse files
38 files+3450−140/38 viewed
+1−0
9292 { label: 'Agents', slug: 'guides/agents' },
9393 { label: 'Sessions', slug: 'guides/agent-sessions' },
9494 { label: 'Skills', slug: 'guides/agent-skills' },
95+ { label: 'Abilities', slug: 'guides/agent-abilities' },
9596 { label: 'Agent memory', slug: 'guides/agent-memory' },
9697 { label: 'Routines', slug: 'guides/agent-routines' },
9798 { label: 'Spend', slug: 'guides/spend' },
+191−0
1+---
2+title: Agent abilities
3+description: What an agent may do, in groups - g1t's own, its computer, each connected integration and MCP servers you add - and for each whether it acts alone, only when asked for it, after asking first, or never; whose connection it uses; and the cards it posts when it can't.
4+---
5+
6+Every agent has an **Abilities** tab on its page, after **Skills**. It
7+lists what the agent can do, in groups, and for each ability a **level**
8+that says how freely it may do it. The levels are enforced in code: a
9+tool the agent isn't allowed is withheld, and one it may use only after
10+asking posts a card and waits. The tab opens with the whole thing in a
11+sentence, such as *Can read issues in Linear and open pull requests on its
12+own; imports issues in Linear when asked for it; asks before commenting in
13+Linear and merging; never deploys to production.*
14+
15+Skills tell an agent how to do a kind of work; abilities say what it may
16+touch. A skill never adds an ability (see
17+[agent skills](/guides/agent-skills/)), and an agent never does more for
18+someone than that person could do themselves (see
19+[what agents can do for whom](/guides/agent-access/)).
20+
21+## The levels
22+
23+| Level | What it means |
24+| --- | --- |
25+| **Alone** | It does it when its work calls for it, and says so. |
26+| **Alone when asked for it** | It does it on its own when the person it acts for named the thing in what they asked (the issue's key, or the action), and asks first otherwise. |
27+| **Ask first** | It posts a card in the conversation saying exactly what it would do. The person it acts for, or an owner, presses **Allow** or **Deny**. Allowed, it runs as the person who pressed, and the agent hears the result. |
28+| **Never** | The tool isn't offered. If the agent asks for it anyway, it is refused with the rule's name, and says so plainly rather than trying another way. |
29+
30+Defaults follow what an ability does:
31+
32+| What it does | Default | Freest it can be set |
33+| --- | --- | --- |
34+| Reads | Alone | Alone |
35+| Writes inside g1t (opens an issue, a pull request, an edit) | Alone when asked for it, or today's choice for code and docs | Alone |
36+| Sends something outside g1t (a comment in another system, a message, a change there) | Ask first | Alone |
37+| Purchases, credentials and permission changes (a production deploy) | Never | Ask first |
38+
39+Every change to a level is saved as a new version of the agent, like any
40+other change on its profile, and shows under **Profile → Versions** as a
41+change to its abilities.
42+
43+## The groups
44+
45+### g1t
46+
47+Artifacts, chat, code, issues and pull requests, making files, and working
48+with colleagues and memory are always on, within the access of the person
49+the agent acts for and everyone who will read its answer. They have no
50+level to set. Four of them keep a choice, the same choice as
51+[what it may do alone](/guides/agents/#what-it-may-do-alone) on the Profile
52+tab:
53+
54+| Ability | Choices | Default |
55+| --- | --- | --- |
56+| Open pull requests | Alone, Ask first | Alone |
57+| Merge | Alone, Ask first, Never | Ask first |
58+| Deploy to production | Ask first, Never | Ask first |
59+| Edit docs | Alone, Ask first (as suggestions) | Ask first |
60+
61+Changing one here changes it there, and the other way round.
62+
63+### Its computer
64+
65+A shell, files, a browser and web search, on a computer of the agent's
66+own. These are listed so you can see what is coming, and are marked
67+**Coming** until the agent computer exists. Nothing can be set for them
68+yet.
69+
70+### Integrations
71+
72+Every integration the workspace has connected lists what an agent can do
73+through it, one row per action, with a level for each. What is listed
74+comes from what the integration does today:
75+
76+| Integration | Rows | Tool the agent calls |
77+| --- | --- | --- |
78+| Linear | Read issues · Import issues · Comment | `lookup_outside` · `import_outside` · `act_outside` |
79+| Jira | Read tickets · Import tickets · Comment | `lookup_outside` · `import_outside` · `act_outside` |
80+| Sentry | Read issues · Import issues · Comment · Resolve issues | `lookup_outside` · `import_outside` · `act_outside` |
81+| Datadog, Alerts webhook | None: they open issues by themselves | — |
82+
83+Reading looks an item up by its key (`ENG-42`, `TECH-1234`) or its
84+address. Importing opens an issue in a repository here, linked back to
85+the item, as the person the agent acts for. Commenting and resolving write
86+in the other system, as the workspace's connection, and name the person
87+the agent acted for with a link back to the conversation.
88+
89+An integration that gives agents abilities but isn't connected is listed
90+too, with **Connect** for owners and **Ask an owner** for anyone else,
91+which opens its [Marketplace](/guides/marketplace/) page. Nothing on it
92+can be set until it is connected.
93+
94+#### Whose connection
95+
96+Each integration row says whose connection it runs on:
97+
98+| | |
99+| --- | --- |
100+| **Workspace connection** | The agent acts as the integration's connection for the whole workspace, the one an owner set up under Integrations. Only owners choose this. |
101+| **Asker's connection** | The agent acts as the asking person's own account. When they haven't connected one, the agent posts a **Connect** card in the conversation: one press opens their own settings for it. |
102+
103+A [personal agent](/guides/agents/#personal-agents) starts on the asker's
104+connection; an owner can put it on the workspace's. Linear, Jira and
105+Sentry can't be connected per person yet, so for them the choice is the
106+workspace's connection today.
107+
108+### MCP servers
109+
110+Owners add MCP servers to an agent, one at a time, by a name and an HTTPS
111+address on a public host. g1t lists the server's tools when it is added,
112+and each tool becomes a row:
113+
114+- a tool the server marks as read-only is a **read**, Alone by default;
115+- any other tool, including one the server says nothing about, counts as
116+ a **write outside g1t**, Ask first by default.
117+
118+Tools are offered to the agent as `<server>__<tool>`, with the arguments
119+the server describes. **List its tools again** picks up a server that
120+changed; **Remove** takes the server and its rows off the agent at once.
121+Both are new versions of the agent. An agent keeps at most 10 servers,
122+and each server's first 40 tools. Servers that need a key or a sign-in
123+aren't supported yet.
124+
125+## Ask first, in the conversation
126+
127+When an ability is Ask first (or Alone when asked for it, and the person
128+didn't ask), the agent posts a card where it is working: what it would
129+do, for whom, and under which rule, with the text it would write. The
130+person it acts for, or an owner, presses **Allow** or **Deny**:
131+
132+- **Allow** runs the call as the person who pressed it. The card then says
133+ what happened, and the agent is told the result. A session that was
134+ waiting goes on with it at its next step.
135+- **Deny** tells the agent, which says so and doesn't try another way.
136+
137+A [session](/guides/agent-sessions/) with a call waiting shows **Needs
138+approval** and the call it waits for, until the card is answered or the
139+session is stopped.
140+
141+## When something is missing
142+
143+When a request needs an integration that isn't connected, or an ability
144+the agent doesn't have, the agent says so plainly and posts a **Request**
145+card:
146+
147+- for an integration, **Ask the owners** opens the same request the
148+ Marketplace does (see [ask an owner to add something](/guides/marketplace/#ask-an-owner-to-add-something)),
149+ and **Open in Marketplace** goes to its page;
150+- for an ability, **Ask the owners** notifies every owner with a link to
151+ the agent's Abilities tab, and **Open Abilities** goes there.
152+
153+Owners see a link to do it themselves instead.
154+
155+## Refusals, in the transcript and the audit log
156+
157+A policy fails closed. When a tool is withheld or a call is refused, the
158+agent's answer says which rule refused it, such as *Linear: Comment is
159+Never*, and so does the session's transcript (the tool call shows as
160+refused). The workspace's [audit log](/guides/audit-log/) records every
161+refusal under the agent, with the rule (`integration:linear:comment=never`)
162+and what was refused, and every Allow or Deny under the person who
163+pressed it.
164+
165+## Who edits
166+
167+| Agent | Who changes its abilities |
168+| --- | --- |
169+| A workspace agent | The workspace's owners. |
170+| A personal agent | The member it belongs to, except that only owners put it on the workspace's connection. |
171+| `@g1t` | Owners, like any workspace agent. |
172+
173+Everyone in the workspace can read the tab.
174+
175+## API
176+
177+An agent's abilities are part of its definition: `abilities.settings`,
178+by ability id (`integration:linear:comment`), each with a `level`
179+(`alone`, `asked`, `ask`, `never`) and, for an integration, `credentials`
180+(`workspace` or `asker`); and `abilities.mcp_servers`. Changing an agent
181+with `abilities: { settings }` replaces its settings, and is a new
182+version like any change. MCP servers are added and removed with the
183+agents service's `add_mcp_server`, `refresh_mcp_server` and
184+`remove_mcp_server`; they never travel with a change.
185+
186+## Next
187+
188+- [Agents](/guides/agents/): hiring, changing and running agents.
189+- [Agent skills](/guides/agent-skills/): how an agent does a kind of work.
190+- [Marketplace](/guides/marketplace/): connecting integrations, and asking an owner to.
191+- [What agents can do for whom](/guides/agent-access/): the access every ability runs within.
+9−0
703703 | Deploy to production | After approval, or never | After approval |
704704 | Edit docs | Alone, or as a suggestion | As a suggestion |
705705
706+These four are also on the agent's **Abilities** tab, beside everything
707+else it may do: each connected integration's actions, MCP servers an owner
708+adds, and whose connection each runs on, with four levels (alone, alone
709+when asked for it, ask first, never). See
710+[agent abilities](/guides/agent-abilities/).
711+
706712 An agent also never does more for someone than that person could do
707713 themselves. See [what agents can do for whom](/guides/agent-access/).
708714
713719 | Tab | What it shows |
714720 | --- | --- |
715721 | **Sessions** | Every session it is working on, then every one it worked on before, each child under the session that started it. See [sessions](/guides/agent-sessions/). |
722+| **Skills** | The [skills](/guides/agent-skills/) it has: g1t's foundational ones and your library's, each on or off. |
723+| **Abilities** | What it may do, in groups, with a level for each: alone, alone when asked for it, ask first, or never; whose connection an integration runs on; MCP servers owners add. See [agent abilities](/guides/agent-abilities/). |
716724 | **Memory** | What it remembers, by scope, with where each fact came from. Pin, correct or forget facts. See [agent memory](/guides/agent-memory/). |
717725 | **Routines** | Work it does on a schedule or when something happens. See [routines](/guides/agent-routines/). |
718726 | **Spend** | Its [effort](#effort) and what each level has cost it, suggestions to spend less, and spend this month against its budget: by day, kind of work, model and who asked, and its costliest sessions. |
772780
773781 - [Chat](/guides/chat/): channels, DMs, threads and mentions.
774782 - [Agent skills](/guides/agent-skills/): what every agent can make and do, and what is coming.
783+- [Agent abilities](/guides/agent-abilities/): what it may do through integrations and MCP servers, and how freely.
775784 - [What agents can do for whom](/guides/agent-access/): access, audiences
776785 and requests from people who don't work on code.
777786 - [Model providers](/guides/models/): connect your own.
+8−0
178178 for a workspace today, while each person's Linear inbox is Soon. When an
179179 integration is not available here, its page says why.
180180
181+Once an integration is connected, what agents can do through it is set
182+per agent, action by action, on the agent's **Abilities** tab: read on
183+its own, comment only after asking, and so on, and whether it acts as the
184+workspace's connection or the asking person's own. When an agent needs an
185+integration that isn't connected, it posts a **Request** card in the
186+conversation, which opens the same request as **Request** here. See
187+[agent abilities](/guides/agent-abilities/).
188+
181189 Some integrations each person connects for themselves, such as a GitHub
182190 account or an MCP client. They are listed under **Connected by each
183191 person**, and **Connect yours** opens your own
+1−1
395395
396396 Each runs in a sandbox of its own.
397397
398−In chat, agents work from [skills](/guides/agent-skills/): g1t's own for documents, research, data, code, communication, and files and media, and the skills your workspace attaches from its library. An agent's instructions list each skill by name and when to use it, and the agent reads one with `use_skill` when a request matches. Asked for a PDF, a Word document or a spreadsheet, an agent makes the file with `make_file` and keeps it with a doc in Artifacts. Asked for something no skill can do yet, such as reading the web or booking a meeting, it says so.
398+In chat, agents work from [skills](/guides/agent-skills/): g1t's own for documents, research, data, code, communication, and files and media, and the skills your workspace attaches from its library. An agent's instructions list each skill by name and when to use it, and the agent reads one with `use_skill` when a request matches. Asked for a PDF, a Word document or a spreadsheet, an agent makes the file with `make_file` and keeps it with a doc in Artifacts. Asked for something no skill can do yet, such as reading the web or booking a meeting, it says so. What an agent may do outside g1t, and how freely (alone, only when asked for it, after asking first with a card, or never), is set per agent on its [Abilities](/guides/agent-abilities/) tab and enforced in code.
399399
400400 ## Mentioning g1t
401401
+265−0
1+import { ExternalLink, Plus, RefreshCw, Trash2 } from "lucide-react";
2+import { useId } from "react";
3+import { Link, useFetcher } from "react-router";
4+
5+import type { Ability, AbilityLevel, AbilitySection, AbilitySource, McpServer } from "@g1t/contracts";
6+import { ABILITY_LEVEL_LABELS } from "@g1t/contracts/abilities";
7+
8+import { cn } from "../../lib/cn";
9+import { Button, ButtonLink, SubmitButton } from "../ui";
10+import { Badge } from "../ui/badge";
11+import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, DialogTrigger } from "../ui/dialog";
12+import { Field, FieldDescription, FieldError, FieldLabel } from "../ui/field";
13+import { Hint } from "../ui/hint";
14+import { Input } from "../ui/input";
15+import { SelectField } from "../ui/select";
16+import { ToggleGroup, ToggleGroupItem } from "../ui/toggle-group";
17+import { type ActionResult, useDialogFetcher } from "./dialogs";
18+
19+/** The level choices, short, as the segmented control shows them. */
20+const SHORT: Record<AbilityLevel, string> = { alone: "Alone", asked: "When asked", ask: "Ask first", never: "Never" };
21+
22+/** The words a row's kind gets as a tag. */
23+const KINDS = { read: "Reads", write: "Writes in g1t", send: "Leaves g1t", restricted: "Restricted" } as const;
24+
25+type Fetcher = ReturnType<typeof useFetcher<ActionResult>>;
26+
27+/** What a pending change says a row is, before the page reloads. */
28+function pendingOf(fetcher: Fetcher, id: string): { level?: AbilityLevel; credentials?: string } | null {
29+ const data = fetcher.formData;
30+ if (!data || data.get("ability") !== id) return null;
31+ const intent = data.get("intent");
32+ if (intent === "level") return { level: String(data.get("level")) as AbilityLevel };
33+ if (intent === "credentials") return { credentials: String(data.get("credentials")) };
34+ return null;
35+}
36+
37+/** The level control: a segmented control for those who may change it, the level as a badge for everyone else. */
38+export function LevelControl({ ability, agentName, canEdit, fetcher }: { ability: Ability; agentName: string; canEdit: boolean; fetcher: Fetcher }) {
39+ const pending = pendingOf(fetcher, ability.id);
40+ const level = pending?.level ?? ability.level;
41+ if (ability.status === "coming") return <Badge tone="neutral">Coming</Badge>;
42+ if (!canEdit || !ability.can_change || ability.choices.length <= 1) {
43+ return (
44+ <Badge tone={level === "never" ? "danger" : level === "alone" ? "success" : level === "ask" ? "warn" : "info"}>
45+ {ability.choices.length <= 1 && ability.group === "g1t" ? "Always on" : ABILITY_LEVEL_LABELS[level]}
46+ </Badge>
47+ );
48+ }
49+ return (
50+ <ToggleGroup
51+ type="single"
52+ size="sm"
53+ variant="outline"
54+ value={level}
55+ aria-label={`${ability.label} for ${agentName}`}
56+ disabled={fetcher.state !== "idle"}
57+ onValueChange={(next) => {
58+ if (!next || next === level) return;
59+ fetcher.submit({ intent: "level", ability: ability.id, level: next }, { method: "post" });
60+ }}
61+ >
62+ {ability.choices.map((choice) => (
63+ <ToggleGroupItem key={choice} value={choice} aria-label={ABILITY_LEVEL_LABELS[choice]}>
64+ {SHORT[choice]}
65+ </ToggleGroupItem>
66+ ))}
67+ </ToggleGroup>
68+ );
69+}
70+
71+/** Whose connection an integration ability runs on: the workspace's, or the asking person's own. */
72+export function CredentialsControl({ ability, source, canEdit, fetcher }: { ability: Ability; source: AbilitySource; canEdit: boolean; fetcher: Fetcher }) {
73+ if (ability.credentials === null) return null;
74+ const pending = pendingOf(fetcher, ability.id);
75+ const value = pending?.credentials ?? ability.credentials;
76+ const options = [
77+ { value: "workspace", label: "Workspace connection", description: `Acts as the workspace's ${source.name} connection. Owners choose this.` },
78+ {
79+ value: "asker",
80+ label: "Asker's connection",
81+ description: ability.personal_available ? `Acts as the asking person's own ${source.name} account; a Connect card when they have none.` : `${source.name} can't be connected per person yet.`,
82+ disabled: !ability.personal_available,
83+ },
84+ ];
85+ if (!canEdit || !ability.can_change) {
86+ return <span className="text-xs text-faint">{value === "asker" ? "Asker's connection" : "Workspace connection"}</span>;
87+ }
88+ return (
89+ <SelectField
90+ size="sm"
91+ className="w-auto min-w-44"
92+ aria-label={`Whose ${source.name} connection ${ability.label.toLowerCase()} runs on`}
93+ options={options}
94+ value={value}
95+ disabled={fetcher.state !== "idle"}
96+ onValueChange={(next) => {
97+ if (next === value) return;
98+ fetcher.submit({ intent: "credentials", ability: ability.id, credentials: next }, { method: "post" });
99+ }}
100+ />
101+ );
102+}
103+
104+/** One ability's row: what it is, its tools, its level, and whose connection. */
105+export function AbilityRow({ ability, source, agentName, canEdit, fetcher }: { ability: Ability; source: AbilitySource; agentName: string; canEdit: boolean; fetcher: Fetcher }) {
106+ const off = ability.level === "never" || ability.status === "coming" || (ability.group === "integration" && !source.connected);
107+ return (
108+ <li className="flex flex-col gap-3 px-4 py-3.5 md:flex-row md:items-start md:gap-6">
109+ <div className={cn("min-w-0 grow", off && "opacity-70")}>
110+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
111+ <h4 className="text-sm font-medium">{ability.label}</h4>
112+ {ability.status === "ready" && <Badge tone={ability.kind === "restricted" ? "danger" : ability.kind === "send" ? "warn" : "neutral"}>{KINDS[ability.kind]}</Badge>}
113+ </div>
114+ <p className="mt-0.5 text-sm text-muted">{ability.about}</p>
115+ {ability.tools.length > 0 && (
116+ <ul className="mt-1.5 flex flex-wrap gap-1" aria-label="Tools it offers">
117+ {ability.tools.map((tool) => (
118+ <li key={tool} className="rounded-[5px] bg-raised px-1.5 py-px font-mono text-[0.6875rem] text-muted ring-1 ring-line ring-inset">
119+ {tool}
120+ </li>
121+ ))}
122+ </ul>
123+ )}
124+ </div>
125+ <div className="flex shrink-0 flex-wrap items-center gap-2 md:flex-col md:items-end">
126+ <LevelControl ability={ability} agentName={agentName} canEdit={canEdit} fetcher={fetcher} />
127+ <CredentialsControl ability={ability} source={source} canEdit={canEdit} fetcher={fetcher} />
128+ </div>
129+ </li>
130+ );
131+}
132+
133+/** A source's block: its name, whether it is connected, and its rows. */
134+export function SourceBlock({
135+ section,
136+ source,
137+ agent,
138+ canEdit,
139+ isOwner,
140+ slug,
141+ fetcher,
142+}: {
143+ section: AbilitySection;
144+ source: AbilitySource;
145+ agent: { handle: string; display_name: string };
146+ canEdit: boolean;
147+ isOwner: boolean;
148+ slug: string;
149+ fetcher: Fetcher;
150+}) {
151+ const integration = section.group === "integration";
152+ const mcp = section.group === "mcp";
153+ const askHref = `/${slug}/-/marketplace/integrations/${source.id}`;
154+ return (
155+ <article className="overflow-hidden rounded-xl border border-line bg-surface" aria-labelledby={`source-${source.id}`}>
156+ {(integration || mcp) && (
157+ <header className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2 border-b border-line/60 px-4 py-3">
158+ <div className="flex min-w-0 flex-wrap items-center gap-2">
159+ <h3 id={`source-${source.id}`} className="text-sm font-semibold">
160+ {source.name}
161+ </h3>
162+ {integration && <Badge tone={source.connected ? "success" : "neutral"}>{source.connected ? "Connected" : "Not connected"}</Badge>}
163+ {mcp && (
164+ <a href={source.href ?? "#"} target="_blank" rel="noreferrer" className="inline-flex items-center gap-1 truncate font-mono text-xs text-muted hover:text-fg">
165+ {source.href}
166+ <ExternalLink size={11} aria-hidden />
167+ </a>
168+ )}
169+ </div>
170+ <div className="flex shrink-0 flex-wrap items-center gap-2">
171+ {integration && !source.connected && (isOwner ? <ButtonLink to={source.href ?? askHref} variant="quiet">Connect</ButtonLink> : <ButtonLink to={askHref} variant="quiet">Ask an owner</ButtonLink>)}
172+ {integration && source.connected && source.href && (
173+ <Link to={source.href} className="text-xs text-muted underline-offset-2 hover:underline">
174+ Manage
175+ </Link>
176+ )}
177+ {mcp && isOwner && (
178+ <>
179+ <fetcher.Form method="post">
180+ <input type="hidden" name="intent" value="mcp_refresh" />
181+ <input type="hidden" name="server" value={source.id} />
182+ <Hint label="List its tools again">
183+ <SubmitButton variant="quiet" icon pending="" className="inline-flex size-8 items-center justify-center rounded-md border border-line text-muted hover:border-line-strong hover:text-fg" fetcher={fetcher} match={{ intent: "mcp_refresh", server: source.id }} aria-label={`List ${source.name}'s tools again`}>
184+ <RefreshCw size={14} />
185+ </SubmitButton>
186+ </Hint>
187+ </fetcher.Form>
188+ <fetcher.Form method="post" onSubmit={(event) => !confirm(`Remove the ${source.name} server from ${agent.display_name}? Its tools stop being offered at once.`) && event.preventDefault()}>
189+ <input type="hidden" name="intent" value="mcp_remove" />
190+ <input type="hidden" name="server" value={source.id} />
191+ <Hint label="Remove this server">
192+ <SubmitButton variant="danger" icon pending="" className="inline-flex size-8 items-center justify-center rounded-md border border-danger/40 text-danger hover:border-danger hover:bg-danger/10" fetcher={fetcher} match={{ intent: "mcp_remove", server: source.id }} aria-label={`Remove ${source.name}`}>
193+ <Trash2 size={14} />
194+ </SubmitButton>
195+ </Hint>
196+ </fetcher.Form>
197+ </>
198+ )}
199+ </div>
200+ </header>
201+ )}
202+ {source.note && <p className="px-4 py-3 text-sm text-muted">{source.note}</p>}
203+ {source.abilities.length > 0 && (
204+ <ul className="divide-y divide-line/60">
205+ {source.abilities.map((ability) => (
206+ <AbilityRow key={ability.id} ability={ability} source={source} agentName={agent.display_name} canEdit={canEdit && (!integration || source.connected)} fetcher={fetcher} />
207+ ))}
208+ </ul>
209+ )}
210+ </article>
211+ );
212+}
213+
214+/** Owners add an MCP server by name and address; its tools are listed from it before it is saved. */
215+export function AddMcpServerDialog({ agentName, count, max }: { agentName: string; count: number; max: number }) {
216+ const { fetcher, open, setOpen, error, busy } = useDialogFetcher("mcp-add");
217+ const id = useId();
218+ return (
219+ <Dialog open={open} onOpenChange={setOpen}>
220+ <DialogTrigger asChild>
221+ <Button type="button" variant="quiet" disabled={count >= max}>
222+ <Plus size={14} />
223+ Add a server
224+ </Button>
225+ </DialogTrigger>
226+ <DialogContent>
227+ <DialogHeader>
228+ <DialogTitle>Add an MCP server</DialogTitle>
229+ <DialogDescription>
230+ Its tools become abilities of {agentName}: each a write that asks first, unless the server says the tool only reads. The address must be HTTPS on a public host. Servers that need a key aren&apos;t supported yet.
231+ </DialogDescription>
232+ </DialogHeader>
233+ <fetcher.Form method="post" className="grid gap-5">
234+ <input type="hidden" name="intent" value="mcp_add" />
235+ <Field>
236+ <FieldLabel htmlFor={`${id}-name`}>Name</FieldLabel>
237+ <Input id={`${id}-name`} name="name" placeholder="weather" required autoComplete="off" pattern="[a-z0-9][a-z0-9-]{1,31}" />
238+ <FieldDescription>Lowercase letters, digits and hyphens. Tools are offered as name__tool.</FieldDescription>
239+ </Field>
240+ <Field>
241+ <FieldLabel htmlFor={`${id}-url`}>Address</FieldLabel>
242+ <Input id={`${id}-url`} name="url" type="url" placeholder="https://mcp.example.com/mcp" required autoComplete="off" />
243+ <FieldDescription>Where it speaks MCP over HTTP.</FieldDescription>
244+ </Field>
245+ <FieldError>{error}</FieldError>
246+ <DialogFooter>
247+ <Button type="button" variant="quiet" onClick={() => setOpen(false)}>
248+ Cancel
249+ </Button>
250+ <SubmitButton variant="accent" fetcher={fetcher} match={{ intent: "mcp_add" }} busy={busy} pending="Listing its tools…">
251+ Add server
252+ </SubmitButton>
253+ </DialogFooter>
254+ </fetcher.Form>
255+ </DialogContent>
256+ </Dialog>
257+ );
258+}
259+
260+export { McpServerCount };
261+
262+/** "2 of 10 servers". */
263+function McpServerCount({ servers, max }: { servers: McpServer[]; max: number }) {
264+ return <span className="text-faint">{servers.length ? `${servers.length} of ${max}` : ""}</span>;
265+}
+1−0
270270 avatar_seed: "face",
271271 faces: "who it works with",
272272 skills_off: "skills",
273+ abilities: "abilities",
273274 };
274275
275276 /**
+60−0
1+import { ToggleGroup as Primitive } from "radix-ui";
2+import { type ComponentProps, createContext, useContext } from "react";
3+
4+import { cn } from "../../lib/cn";
5+
6+// shadcn/ui's toggle group, styled with g1t's tokens: a row of choices of
7+// which one (type="single") or several (type="multiple") are pressed, drawn
8+// as one segmented control. The group's size and variant reach its items.
9+
10+type Variant = "default" | "outline";
11+type Size = "sm" | "default";
12+
13+const GroupContext = createContext<{ variant: Variant; size: Size }>({ variant: "default", size: "default" });
14+
15+export function ToggleGroup({
16+ className,
17+ variant = "default",
18+ size = "default",
19+ children,
20+ ...props
21+}: ComponentProps<typeof Primitive.Root> & { variant?: Variant; size?: Size }) {
22+ return (
23+ <Primitive.Root
24+ data-variant={variant}
25+ data-size={size}
26+ className={cn("group/toggle-group inline-flex max-w-full flex-wrap items-center rounded-md", variant === "outline" && "border border-line bg-bg", className)}
27+ {...props}
28+ >
29+ <GroupContext.Provider value={{ variant, size }}>{children}</GroupContext.Provider>
30+ </Primitive.Root>
31+ );
32+}
33+
34+const SIZES: Record<Size, string> = { sm: "h-7 min-w-7 px-2 text-xs", default: "h-8 min-w-8 px-2.5 text-[0.8125rem]" };
35+
36+export function ToggleGroupItem({ className, children, variant, size, ...props }: ComponentProps<typeof Primitive.Item> & { variant?: Variant; size?: Size }) {
37+ const group = useContext(GroupContext);
38+ const v = variant ?? group.variant;
39+ return (
40+ <Primitive.Item
41+ data-variant={v}
42+ data-size={size ?? group.size}
43+ className={cn(
44+ "inline-flex shrink-0 items-center justify-center gap-1.5 font-medium whitespace-nowrap text-muted outline-none transition-colors select-none",
45+ "hover:bg-raised hover:text-fg focus-visible:z-10 focus-visible:ring-2 focus-visible:ring-accent/40",
46+ "disabled:pointer-events-none disabled:opacity-50",
47+ "data-[state=on]:bg-accent/15 data-[state=on]:text-accent",
48+ "[&_svg]:size-3.5 [&_svg]:shrink-0",
49+ SIZES[size ?? group.size],
50+ v === "outline"
51+ ? "rounded-none border-l border-line first:rounded-l-[5px] first:border-l-0 last:rounded-r-[5px]"
52+ : "rounded-md",
53+ className,
54+ )}
55+ {...props}
56+ >
57+ {children}
58+ </Primitive.Item>
59+ );
60+}
+1−0
167167 index("routes/workspace/agents/sessions.tsx"),
168168 route("sessions/:id", "routes/workspace/agents/session.tsx"),
169169 route("skills", "routes/workspace/agents/skills.tsx"),
170+ route("abilities", "routes/workspace/agents/abilities.tsx"),
170171 route("memory", "routes/workspace/agents/memory.tsx"),
171172 route("routines", "routes/workspace/agents/routines.tsx"),
172173 route("spend", "routes/workspace/agents/spend.tsx"),
+146−0
1+import { Bot, Monitor, Plug, Server } from "lucide-react";
2+import { data, useFetcher, useOutletContext } from "react-router";
3+
4+import type { AbilityLevel, AbilitySection, WorkspaceAgent } from "@g1t/contracts";
5+import { MAX_MCP_SERVERS, abilitiesSummary, autonomyOfLevel, findAbility, resolveAbilities, withSetting } from "@g1t/contracts/abilities";
6+import { CONNECTORS, connectorPath, connectorView } from "@g1t/contracts/connectors";
7+
8+import type { Route } from "./+types/abilities";
9+import { AddMcpServerDialog, McpServerCount, SourceBlock } from "../../../components/agents/abilities";
10+import { agentsAction, answer } from "../../../components/agents/actions.server";
11+import type { ActionResult } from "../../../components/agents/dialogs";
12+import { Badge } from "../../../components/ui/badge";
13+import { connectedStates } from "../../../lib/connected.server";
14+import type { ConnectedState } from "../../../lib/connectors";
15+import { workspaceAgents } from "../../../lib/services.server";
16+import { requireUser, roleIn } from "../../../lib/session.server";
17+
18+/**
19+ * What the Abilities tab needs beside the agent: who the viewer is, what
20+ * the workspace has connected (by connector id), and where each is set up
21+ * or managed.
22+ */
23+export async function loader({ params, context, request }: Route.LoaderArgs): Promise<{ isOwner: boolean; viewerId: string; connected: string[]; hrefs: Record<string, string | null> }> {
24+ const viewer = requireUser(context, request);
25+ const slug = params.owner.toLowerCase();
26+ const role = roleIn(viewer, slug);
27+ if (!role) throw data(null, { status: 404 });
28+ const states: Record<string, ConnectedState> = await connectedStates(slug, viewer).catch(() => ({}));
29+ const hrefs: Record<string, string | null> = {};
30+ for (const connector of CONNECTORS) {
31+ const view = connectorView(connector, "workspace");
32+ hrefs[connector.id] = states[connector.id]?.manage ?? (view?.href ? connectorPath(view.href, slug) : null);
33+ }
34+ return { isOwner: role === "owner", viewerId: viewer.id, connected: Object.keys(states), hrefs };
35+}
36+
37+/**
38+ * Changes a level or whose connection (a new version of the agent, like any
39+ * change), or adds, refreshes or removes an MCP server. The agents service
40+ * decides who may: owners a workspace agent, its member a personal one,
41+ * and only owners the servers and the workspace's connection.
42+ */
43+export async function action({ params, context, request }: Route.ActionArgs): Promise<ActionResult> {
44+ const { viewer, slug, isOwner, form } = await agentsAction(request, context, params.owner);
45+ const intent = String(form.get("intent") ?? "");
46+ const handle = params.handle.toLowerCase();
47+ if (intent === "mcp_add") return answer(intent, workspaceAgents.addMcpServer(slug, handle, viewer, { name: String(form.get("name") ?? ""), url: String(form.get("url") ?? "") }));
48+ if (intent === "mcp_remove") return answer(intent, workspaceAgents.removeMcpServer(slug, handle, viewer, String(form.get("server") ?? "")));
49+ if (intent === "mcp_refresh") return answer(intent, workspaceAgents.refreshMcpServer(slug, handle, viewer, String(form.get("server") ?? "")));
50+ if (intent !== "level" && intent !== "credentials") return { ok: false, intent, error: "Unknown request." };
51+ const current = await workspaceAgents.get(slug, handle, viewer).catch(() => null);
52+ if (!current) return { ok: false, intent, error: "The agents service didn't answer. Try again in a moment." };
53+ if (!current.ok) return { ok: false, intent, error: current.error.message };
54+ const agent = current.value;
55+ const id = String(form.get("ability") ?? "");
56+ const sections = resolveAbilities({ connectors: CONNECTORS, abilities: agent.abilities, autonomy: agent.autonomy, connected: CONNECTORS.map((c) => c.id), personal: agent.scope === "personal" });
57+ const found = findAbility(sections, id);
58+ if (!found) return { ok: false, intent, error: "There is no such ability." };
59+ if (intent === "level") {
60+ const level = String(form.get("level") ?? "") as AbilityLevel;
61+ if (!found.ability.choices.includes(level)) return { ok: false, intent, error: `${found.ability.label} can't be set to that.` };
62+ if (found.ability.autonomy) return answer(intent, workspaceAgents.update(slug, handle, viewer, { autonomy: { [found.ability.autonomy.key]: autonomyOfLevel(found.ability.autonomy.key, level) } }));
63+ return answer(intent, workspaceAgents.update(slug, handle, viewer, { abilities: { settings: withSetting(agent.abilities, id, { level }).settings } }));
64+ }
65+ const credentials = String(form.get("credentials") ?? "");
66+ if (credentials !== "workspace" && credentials !== "asker") return { ok: false, intent, error: "Whose connection is the workspace's or the asker's." };
67+ // The workspace's connection is the owners' to give.
68+ if (credentials === "workspace" && !isOwner) return { ok: false, intent, error: "Only the workspace's owners let an agent act as the workspace's connection." };
69+ return answer(intent, workspaceAgents.update(slug, handle, viewer, { abilities: { settings: withSetting(agent.abilities, id, { credentials }).settings } }));
70+}
71+
72+const ICONS = { g1t: Bot, computer: Monitor, integration: Plug, mcp: Server } as const;
73+
74+/**
75+ * The agent's abilities, in groups: g1t's own (always on), its computer
76+ * (coming), each connected integration's actions with a level and whose
77+ * connection, and the MCP servers owners add. A summary in plain words
78+ * sits at the top. Owners edit a workspace agent; a member their own
79+ * personal one, within what owners allow.
80+ */
81+export default function AbilitiesTab({ loaderData, params }: Route.ComponentProps) {
82+ const agent = useOutletContext<WorkspaceAgent>();
83+ const { isOwner, viewerId, connected, hrefs } = loaderData;
84+ const fetcher = useFetcher<ActionResult>({ key: `abilities-${agent.id}` });
85+ const personal = agent.scope === "personal";
86+ const canEdit = personal ? agent.personal_owner_id === viewerId : isOwner;
87+ const sections: AbilitySection[] = resolveAbilities({ connectors: CONNECTORS, abilities: agent.abilities, autonomy: agent.autonomy, connected, hrefs, personal });
88+ const error = fetcher.state === "idle" && fetcher.data && !fetcher.data.ok ? fetcher.data.error : null;
89+ const slug = params.owner;
90+ const servers = agent.abilities?.mcp_servers ?? [];
91+ return (
92+ <div className="space-y-10">
93+ <section aria-labelledby="summary" className="max-w-3xl">
94+ <h2 id="summary" className="sr-only">
95+ In short
96+ </h2>
97+ <p className="text-base leading-relaxed text-fg">{abilitiesSummary(sections)}</p>
98+ <p className="mt-2 text-sm text-muted">
99+ Each ability runs <strong className="font-medium text-fg">alone</strong>, <strong className="font-medium text-fg">alone when the person asked for it</strong>,{" "}
100+ <strong className="font-medium text-fg">after asking first</strong> (a card in chat, for the person it acts for or an owner), or <strong className="font-medium text-fg">never</strong>. Every choice is enforced
101+ in code, and a refusal names its rule in the transcript and the audit log.
102+ {canEdit ? " Each change is saved as a new version." : personal ? " Its member changes these." : " Owners change these."}
103+ </p>
104+ </section>
105+ {error && (
106+ <p role="alert" className="rounded-lg border border-danger/40 bg-danger/10 px-3 py-2 text-sm text-danger">
107+ {error}
108+ </p>
109+ )}
110+ {sections.map((section) => {
111+ const Icon = ICONS[section.group];
112+ return (
113+ <section key={section.group} aria-labelledby={`group-${section.group}`} className="space-y-3">
114+ <div className="flex flex-wrap items-center justify-between gap-x-4 gap-y-2">
115+ <div className="flex min-w-0 items-start gap-2.5">
116+ <span className="mt-0.5 flex size-7 shrink-0 items-center justify-center rounded-md bg-accent/10 text-accent">
117+ <Icon size={15} aria-hidden />
118+ </span>
119+ <div className="min-w-0">
120+ <h2 id={`group-${section.group}`} className="flex flex-wrap items-center gap-2 text-sm font-semibold">
121+ {section.title}
122+ {section.group === "computer" && <Badge tone="neutral">Coming</Badge>}
123+ {section.group === "mcp" && <McpServerCount servers={servers} max={MAX_MCP_SERVERS} />}
124+ </h2>
125+ <p className="text-sm text-muted">{section.about}</p>
126+ </div>
127+ </div>
128+ {section.group === "mcp" && isOwner && <AddMcpServerDialog agentName={agent.display_name} count={servers.length} max={MAX_MCP_SERVERS} />}
129+ </div>
130+ {section.sources.length === 0 ? (
131+ <p className="rounded-xl border border-dashed border-line px-4 py-6 text-center text-sm text-muted">
132+ {section.group === "mcp" ? (isOwner ? `None yet. Add a server, and its tools become ${agent.display_name}'s abilities.` : "None yet. Owners add them.") : "Nothing here yet."}
133+ </p>
134+ ) : (
135+ <div className="space-y-3">
136+ {section.sources.map((source) => (
137+ <SourceBlock key={source.id} section={section} source={source} agent={agent} canEdit={canEdit} isOwner={isOwner} slug={slug} fetcher={fetcher} />
138+ ))}
139+ </div>
140+ )}
141+ </section>
142+ );
143+ })}
144+ </div>
145+ );
146+}
+3−0
115115 <TabLink to={`${base}/skills`} icon={null}>
116116 Skills
117117 </TabLink>
118+ <TabLink to={`${base}/abilities`} icon={null}>
119+ Abilities
120+ </TabLink>
118121 <TabLink to={`${base}/memory`} icon={null}>
119122 Memory
120123 </TabLink>
+1−0
773773 - [Bring your own agent](https://docs.g1t.sh/guides/bring-your-own-agent/)
774774 - [Artifacts](https://docs.g1t.sh/guides/artifacts/)
775775 - [Agent skills](https://docs.g1t.sh/guides/agent-skills/): every workspace agent makes PDFs, Word documents and spreadsheets (`make_file`), writes reports with sources, charts data and reviews code, with the tools it already has; a workspace's own skills live in its skill library in the open SKILL.md format (written, imported, saved from a session or kept in `.g1t/skills/` in a repository), attached to agents, teams or every agent at a pinned version; agents see each skill's name and when to use it and read it with `use_skill`
776+- [Agent abilities](https://docs.g1t.sh/guides/agent-abilities/): each agent's Abilities tab lists what it may do, in groups (g1t's own, its computer, each connected integration's actions, MCP servers an owner adds), with a level per ability enforced in code: alone, alone when asked for it, ask first (a card in chat with Allow and Deny), or never; integration rows say whose connection they run on (the workspace's, or the asking person's own, with a Connect card when missing); reads default to alone, writes outside g1t to ask first, purchases, credentials and permission changes to never; a missing integration or ability gets a Request card for the owners; every refusal names its rule in the transcript and the audit log
776777 - [The merge queue](https://docs.g1t.sh/guides/merge-queue/)
777778 - [Sessions and why-blame](https://docs.g1t.sh/guides/why-blame/)
778779 - [Forks and branches](https://docs.g1t.sh/concepts/forks/)
+24−0
591591 pub limit: Option<u32>,
592592 }
593593
594+/// `comment`: for g1t's agents, a comment on the item `reference` names in
595+/// the system that knows it (Linear, Jira or Sentry), on the workspace's
596+/// connection, with `link` (a g1t address) at the end. The actor is the
597+/// person the agent acts for. Returns `Outcome<ContextItem>`.
598+#[derive(Debug, Serialize, Deserialize)]
599+pub struct CommentArgs {
600+ pub actor: User,
601+ pub workspace: String,
602+ pub reference: String,
603+ pub text: String,
604+ pub link: String,
605+}
606+
607+/// `close`: for g1t's agents, marks a Sentry issue resolved with a note and
608+/// `link`. Only Sentry items can be closed. Returns `Outcome<ContextItem>`.
609+#[derive(Debug, Serialize, Deserialize)]
610+pub struct CloseArgs {
611+ pub actor: User,
612+ pub workspace: String,
613+ pub reference: String,
614+ pub text: String,
615+ pub link: String,
616+}
617+
594618 /// `import`: opens an issue from a ticket. Returns `Outcome<Imported>`.
595619 #[derive(Debug, Serialize, Deserialize)]
596620 pub struct ImportArgs {
+1−1
44 "private": true,
55 "type": "module",
66 "license": "MIT",
7− "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts" },
7+ "exports": { ".": "./src/index.ts", "./og": "./src/og.ts", "./status": "./src/status.ts", "./scopes": "./src/scopes.ts", "./rate-limits": "./src/rate-limits.ts", "./connectors": "./src/connectors.ts", "./abilities": "./src/abilities.ts", "./marketplace": "./src/marketplace.ts", "./chat-markdown": "./src/chat-markdown.ts" },
88 "scripts": { "test": "node --test src/*.test.ts", "typecheck": "tsc -p tsconfig.json" }
99 }
+188−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import {
5+ ABILITY_LEVELS,
6+ COMPUTER_ABILITIES,
7+ G1T_ABILITIES,
8+ abilitiesSummary,
9+ allAbilities,
10+ askedFor,
11+ autonomyOfLevel,
12+ checkMcpName,
13+ checkMcpUrl,
14+ connectorsWithAbilities,
15+ defaultLevel,
16+ findAbility,
17+ integrationAbilities,
18+ levelOfAutonomy,
19+ maxLevel,
20+ mcpAbility,
21+ mcpToolName,
22+ resolveAbilities,
23+ withSetting,
24+ withinLevel,
25+} from "./abilities.ts";
26+import { CONNECTORS } from "./connectors.ts";
27+
28+const AUTONOMY = { open_pull_requests: "alone", merge: "approval", deploy_production: "approval", edit_docs: "suggest" } as const;
29+const resolve = (over: Partial<Parameters<typeof resolveAbilities>[0]> = {}) => resolveAbilities({ connectors: CONNECTORS, abilities: null, autonomy: AUTONOMY, connected: [], ...over });
30+
31+test("defaults by kind: reads alone, writes inside g1t when asked for, sending outside asks first, restricted never and no freer than asking", () => {
32+ assert.equal(defaultLevel("read"), "alone");
33+ assert.equal(defaultLevel("write"), "asked");
34+ assert.equal(defaultLevel("send"), "ask");
35+ assert.equal(defaultLevel("restricted"), "never");
36+ assert.equal(maxLevel("restricted"), "ask");
37+ assert.equal(maxLevel("send"), "alone");
38+ assert.ok(withinLevel("never", "ask"));
39+ assert.ok(!withinLevel("alone", "ask"));
40+ assert.deepEqual(ABILITY_LEVELS, ["alone", "asked", "ask", "never"]);
41+});
42+
43+test("every integration ability belongs to an available workspace connector with a provider, and its kind fits the connector's capabilities", () => {
44+ const withAbilities = connectorsWithAbilities(CONNECTORS);
45+ assert.ok(withAbilities.length >= 3, "Linear, Jira and Sentry at least");
46+ for (const connector of withAbilities) {
47+ assert.equal(connector.status, "available", `${connector.id} is connectable today`);
48+ assert.ok(connector.scopes.includes("workspace"), `${connector.id} connects for a workspace`);
49+ assert.ok(connector.provider, `${connector.id} has a provider the integrations service knows`);
50+ const defs = integrationAbilities(connector.id);
51+ const caps = connector.capabilities ?? [];
52+ if (defs.some((d) => d.kind === "read")) assert.ok(caps.includes("Agents can read"), `${connector.id} says agents can read`);
53+ if (defs.some((d) => d.kind === "send")) assert.ok(caps.includes("Writes back"), `${connector.id} says it writes back`);
54+ for (const def of defs) {
55+ assert.equal(def.id, `integration:${connector.id}:${def.id.split(":")[2]}`);
56+ assert.equal(def.tools.length, 1, "one tool per integration ability");
57+ }
58+ }
59+ assert.deepEqual(integrationAbilities("datadog"), [], "Datadog opens issues by itself: nothing for an agent to call");
60+});
61+
62+test("g1t's own are always on and the four autonomy ones keep their choices as levels", () => {
63+ const sections = resolve();
64+ const g1t = sections.find((s) => s.group === "g1t")!;
65+ const artifacts = g1t.sources[0]!.abilities.find((a) => a.id === "g1t:artifacts")!;
66+ assert.equal(artifacts.level, "alone");
67+ assert.equal(artifacts.can_change, false);
68+ const merge = g1t.sources[0]!.abilities.find((a) => a.id === "g1t:merge")!;
69+ assert.equal(merge.level, "ask", "approval reads as Ask first");
70+ assert.deepEqual(merge.choices, ["alone", "ask", "never"]);
71+ const deploy = g1t.sources[0]!.abilities.find((a) => a.id === "g1t:deploy")!;
72+ assert.equal(deploy.kind, "restricted");
73+ assert.ok(!deploy.choices.includes("alone"), "production deploys never go alone");
74+ assert.equal(levelOfAutonomy("suggest"), "ask");
75+ assert.equal(autonomyOfLevel("edit_docs", "ask"), "suggest");
76+ assert.equal(autonomyOfLevel("merge", "ask"), "approval");
77+ assert.equal(autonomyOfLevel("merge", "never"), "never");
78+ assert.equal(G1T_ABILITIES.filter((d) => d.autonomy).length, 4);
79+ const computer = sections.find((s) => s.group === "computer")!;
80+ assert.ok(computer.sources[0]!.abilities.every((a) => a.status === "coming" && a.choices.length === 0 && !a.can_change));
81+ assert.equal(COMPUTER_ABILITIES.length, 4);
82+});
83+
84+test("a connected integration lists its rows at their defaults; one that isn't is listed, but nothing there can be changed", () => {
85+ const sections = resolve({ connected: ["linear"] });
86+ const integrations = sections.find((s) => s.group === "integration")!;
87+ const linear = integrations.sources.find((s) => s.id === "linear")!;
88+ assert.equal(linear.connected, true);
89+ assert.deepEqual(
90+ linear.abilities.map((a) => [a.id, a.level, a.credentials, a.can_change]),
91+ [
92+ ["integration:linear:read", "alone", "workspace", true],
93+ ["integration:linear:import", "asked", "workspace", true],
94+ ["integration:linear:comment", "ask", "workspace", true],
95+ ],
96+ );
97+ assert.equal(linear.abilities[0]!.personal_available, false, "Linear's personal side is still coming");
98+ const jira = integrations.sources.find((s) => s.id === "jira")!;
99+ assert.equal(jira.connected, false);
100+ assert.ok(jira.abilities.every((a) => !a.can_change));
101+ assert.equal(integrations.sources[0]!.id, "linear", "connected first");
102+ assert.ok(!integrations.sources.some((s) => s.id === "webhooks" || s.id === "anthropic"), "nothing to call, not connected: not listed");
103+ const withDatadog = resolve({ connected: ["datadog"] }).find((s) => s.group === "integration")!;
104+ const datadog = withDatadog.sources.find((s) => s.id === "datadog")!;
105+ assert.equal(datadog.abilities.length, 0);
106+ assert.match(datadog.note ?? "", /Opens issues by itself/);
107+});
108+
109+test("a setting moves a level within its kind's limit, and is dropped when it is the default again", () => {
110+ let abilities = withSetting(null, "integration:linear:comment", { level: "alone" });
111+ assert.deepEqual(abilities.settings, { "integration:linear:comment": { level: "alone" } });
112+ const sections = resolve({ connected: ["linear"], abilities });
113+ assert.equal(findAbility(sections, "integration:linear:comment")!.ability.level, "alone");
114+ abilities = withSetting(abilities, "integration:linear:comment", { credentials: "asker" });
115+ assert.deepEqual(abilities.settings["integration:linear:comment"], { level: "alone", credentials: "asker" });
116+ abilities = withSetting(abilities, "integration:linear:comment", { level: null, credentials: null });
117+ assert.deepEqual(abilities.settings, {}, "nothing kept once everything is the default");
118+ // A restricted ability set freer than Ask is held at Ask.
119+ const held = resolveAbilities({ connectors: CONNECTORS, abilities: null, autonomy: { ...AUTONOMY, deploy_production: "approval" }, connected: [] });
120+ assert.equal(findAbility(held, "g1t:deploy")!.ability.max, "ask");
121+});
122+
123+test("a personal agent runs on the asker's connections unless an owner chose the workspace's", () => {
124+ const sections = resolve({ connected: ["linear"], personal: true });
125+ assert.equal(findAbility(sections, "integration:linear:read")!.ability.credentials, "asker");
126+ const chosen = resolve({ connected: ["linear"], personal: true, abilities: withSetting(null, "integration:linear:read", { credentials: "workspace" }) });
127+ assert.equal(findAbility(chosen, "integration:linear:read")!.ability.credentials, "workspace");
128+});
129+
130+test("MCP servers: each listed tool is a row, a write unless the server says it reads, offered under <server>__<tool>", () => {
131+ const server = {
132+ id: "mcp_1",
133+ name: "weather",
134+ url: "https://mcp.example.com/",
135+ tools: [
136+ { name: "get_forecast", description: "Today's forecast", kind: "read" as const, input_schema: { type: "object", properties: {} } },
137+ { name: "set_alert", description: "", kind: "write" as const, input_schema: { type: "object", properties: {} } },
138+ ],
139+ added_by: "ana",
140+ added_at: "2026-10-10T00:00:00.000Z",
141+ checked_at: null,
142+ problem: null,
143+ };
144+ const sections = resolve({ abilities: { settings: {}, mcp_servers: [server] } });
145+ const mcp = sections.find((s) => s.group === "mcp")!;
146+ assert.equal(mcp.sources.length, 1);
147+ const [read, write] = mcp.sources[0]!.abilities;
148+ assert.equal(read!.level, "alone");
149+ assert.equal(write!.level, "ask", "a write outside g1t asks first");
150+ assert.equal(write!.kind, "send");
151+ assert.deepEqual(read!.tools, ["weather__get_forecast"]);
152+ assert.equal(mcpToolName("My Server", "do.thing"), "my_server__do_thing");
153+ assert.equal(mcpAbility(server, server.tools[1]!).id, "mcp:mcp_1:set_alert");
154+});
155+
156+test("an MCP server's address is HTTPS on a public host, never local, an address or g1t's own", () => {
157+ assert.equal(checkMcpUrl("https://mcp.example.com/sse").ok, true);
158+ assert.equal(checkMcpUrl("http://mcp.example.com/").ok, false);
159+ assert.equal(checkMcpUrl("https://localhost:3000/").ok, false);
160+ assert.equal(checkMcpUrl("https://10.0.0.5/").ok, false);
161+ assert.equal(checkMcpUrl("https://[::1]/").ok, false);
162+ assert.equal(checkMcpUrl("https://mcp.internal/").ok, false);
163+ assert.equal(checkMcpUrl("https://api.g1t.sh/mcp").ok, false);
164+ assert.equal(checkMcpUrl("https://user:pw@mcp.example.com/").ok, false);
165+ assert.equal(checkMcpUrl("not a url").ok, false);
166+ assert.equal(checkMcpName("Weather").ok, true);
167+ assert.equal(checkMcpName("a").ok, false);
168+ assert.equal(checkMcpName("bad name").ok, false);
169+});
170+
171+test("alone when asked for it: the item's key or the ability's name in what the person said", () => {
172+ assert.ok(askedFor("Can you comment on ENG-42 with the test plan?", ["ENG-42", "comment"]));
173+ assert.ok(askedFor("what's eng-42 about", ["ENG-42"]), "any case");
174+ assert.ok(!askedFor("Summarise the thread", ["ENG-42", "import"]));
175+ assert.ok(!askedFor("", ["ENG-42"]));
176+});
177+
178+test("the summary says what it does alone, when asked, after asking, and never", () => {
179+ const abilities = withSetting(withSetting(null, "integration:linear:comment", { level: "never" }), "integration:sentry:resolve", { level: "alone" });
180+ const sections = resolve({ connected: ["linear", "sentry"], abilities });
181+ const summary = abilitiesSummary(sections);
182+ assert.equal(
183+ summary,
184+ "Can open pull requests, read issues in Linear, read issues in Sentry and resolve issues in Sentry on its own; imports issues in Linear and imports issues in Sentry when asked for it; asks before merging, deploying to production, editing docs and commenting in Sentry; never comments in Linear.",
185+ );
186+ assert.equal(allAbilities(sections).filter((a) => a.group === "integration" && a.can_change).length, 7);
187+ assert.match(abilitiesSummary(resolve()), /open pull requests on its own/);
188+});
+653−0
1+/**
2+ * An agent's abilities (docs.g1t.sh/guides/agent-abilities/): what it can
3+ * do, in groups, and for each whether it does it on its own, only when the
4+ * person asked for it, after asking first, or never.
5+ *
6+ * - **g1t's own** (artifacts, chat, code, issues and pull requests, files,
7+ * colleagues) are always on, within the access of the person it acts
8+ * for. The four that write code and docs keep the agent's `autonomy`
9+ * choices, shown here as levels.
10+ * - **Its computer** (shell, files, browser, web search) is coming.
11+ * - **Integrations**: every connector the workspace has connected lists
12+ * what an agent can do through it, one row per action, from the
13+ * connector catalog (./connectors.ts) and what the integrations service
14+ * does today. Each row says whose connection it runs on: the
15+ * workspace's, or the asking person's own.
16+ * - **MCP servers** an owner adds: each tool the server lists is a row. A
17+ * tool that doesn't say it only reads is treated as a write.
18+ *
19+ * Levels default by what a thing does: reading is alone; writing inside
20+ * g1t is alone when the person asked for it; sending outside g1t asks
21+ * first; purchases, credentials and permission changes are never, and
22+ * can't be raised above asking.
23+ *
24+ * The agents service enforces these in code when it offers tools and when
25+ * one is called; the Abilities tab and the agents service both resolve an
26+ * agent's choices with `resolveAbilities`, handing it the connector catalog
27+ * (./connectors.ts `CONNECTORS`): no value imports here, so services test
28+ * it under Node as it is. Wire shapes are snake_case.
29+ */
30+import type { Connector } from "./connectors";
31+import type { AgentAutonomy } from "./workspace-agents";
32+
33+/** Alone; alone when the asker asked for it; ask first; never. */
34+export type AbilityLevel = "alone" | "asked" | "ask" | "never";
35+
36+export const ABILITY_LEVELS: readonly AbilityLevel[] = ["alone", "asked", "ask", "never"];
37+
38+export const ABILITY_LEVEL_LABELS: Record<AbilityLevel, string> = {
39+ alone: "Alone",
40+ asked: "Alone when asked for it",
41+ ask: "Ask first",
42+ never: "Never",
43+};
44+
45+/** How much a level lets through: a lower number is freer. */
46+const ORDER: Record<AbilityLevel, number> = { alone: 0, asked: 1, ask: 2, never: 3 };
47+
48+export type AbilityGroup = "g1t" | "computer" | "integration" | "mcp";
49+
50+/**
51+ * What an ability does: `read`, `write` (inside g1t), `send` (something
52+ * leaves g1t: a comment, a message, a change in another system) or
53+ * `restricted` (purchases, credentials, permission changes).
54+ */
55+export type AbilityKind = "read" | "write" | "send" | "restricted";
56+
57+/** Whose connection an integration ability runs on. */
58+export type AbilityCredentials = "workspace" | "asker";
59+
60+export type AbilityStatus = "ready" | "coming";
61+
62+/** The level a kind starts at. */
63+export function defaultLevel(kind: AbilityKind): AbilityLevel {
64+ switch (kind) {
65+ case "read":
66+ return "alone";
67+ case "write":
68+ return "asked";
69+ case "send":
70+ return "ask";
71+ case "restricted":
72+ return "never";
73+ }
74+}
75+
76+/** The freest level a kind may be set to: restricted things never go above asking. */
77+export function maxLevel(kind: AbilityKind): AbilityLevel {
78+ return kind === "restricted" ? "ask" : "alone";
79+}
80+
81+/** Whether `level` is within `max`: no freer than it. */
82+export function withinLevel(level: AbilityLevel, max: AbilityLevel): boolean {
83+ return ORDER[level] >= ORDER[max];
84+}
85+
86+/** One ability as the catalog defines it. */
87+export type AbilityDef = {
88+ /** `g1t:artifacts`, `integration:linear:read`, `mcp:<server>:<tool>`, `computer:shell`. */
89+ id: string;
90+ group: AbilityGroup;
91+ label: string;
92+ /** What it does, in a line. */
93+ about: string;
94+ kind: AbilityKind;
95+ /** The agent tools it offers (the agents service's names), for the row's chips. */
96+ tools: string[];
97+ status: AbilityStatus;
98+ /**
99+ * The agent's `autonomy` field it reads and writes, for g1t's own code
100+ * and docs abilities, with the levels that field allows.
101+ */
102+ autonomy?: { key: keyof AgentAutonomy; levels: AbilityLevel[] } | null;
103+};
104+
105+/** What an agent keeps for one ability. Absent fields mean the default. */
106+export type AbilitySetting = { level?: AbilityLevel | null; credentials?: AbilityCredentials | null };
107+
108+/** A tool an MCP server lists, as it was found. */
109+export type McpTool = {
110+ name: string;
111+ description: string;
112+ /** `read` when the server says the tool only reads; `write` otherwise, which is also what an unknown tool is. */
113+ kind: "read" | "write";
114+ /** Its arguments, as the server describes them (JSON Schema); what the agent is offered. */
115+ input_schema: Record<string, unknown>;
116+};
117+
118+/** An MCP server an owner added to one agent. */
119+export type McpServer = {
120+ /** `mcp_…`, stable: ability ids are made from it. */
121+ id: string;
122+ /** Lowercase letters, digits and hyphens: the tools are offered as `<name>__<tool>`. */
123+ name: string;
124+ /** Its HTTPS address; the host is checked when it is added. */
125+ url: string;
126+ tools: McpTool[];
127+ added_by: string;
128+ /** RFC 3339. */
129+ added_at: string;
130+ /** When its tools were last listed, and what went wrong if they couldn't be. */
131+ checked_at: string | null;
132+ problem: string | null;
133+};
134+
135+/** What an agent's definition keeps about its abilities. */
136+export type AgentAbilities = {
137+ /** By ability id: only what differs from the default is kept. */
138+ settings: Record<string, AbilitySetting>;
139+ mcp_servers: McpServer[];
140+};
141+
142+export const EMPTY_ABILITIES: AgentAbilities = { settings: {}, mcp_servers: [] };
143+
144+/** What a change to an agent sends for its abilities: the settings, whole. MCP servers have their own calls. */
145+export type AgentAbilitiesChange = { settings: Record<string, AbilitySetting> };
146+
147+/** The most MCP servers one agent has, and the most tools one server lists. */
148+export const MAX_MCP_SERVERS = 10;
149+export const MAX_MCP_TOOLS = 40;
150+
151+/** An ability as one agent has it now. */
152+export type Ability = AbilityDef & {
153+ level: AbilityLevel;
154+ /** The freest level it may be set to. */
155+ max: AbilityLevel;
156+ /** The levels it may be set to, freest first. */
157+ choices: AbilityLevel[];
158+ /** Whether the level can be changed at all: g1t's own reads are always on. */
159+ can_change: boolean;
160+ /** For an integration: whose connection it runs on. Null elsewhere. */
161+ credentials: AbilityCredentials | null;
162+ /** Whether the asking person could connect it themselves (the connector has a personal side that is available). */
163+ personal_available: boolean;
164+};
165+
166+/** One source of abilities: a connector, an MCP server, or g1t itself. */
167+export type AbilitySource = {
168+ id: string;
169+ name: string;
170+ /** For an integration or an MCP server: whether the workspace has it. */
171+ connected: boolean;
172+ /** Where it is connected or managed; null when there is nowhere. */
173+ href: string | null;
174+ /** A line about it when it has no abilities, or something is wrong with it. */
175+ note: string | null;
176+ abilities: Ability[];
177+};
178+
179+export type AbilitySection = {
180+ group: AbilityGroup;
181+ title: string;
182+ about: string;
183+ sources: AbilitySource[];
184+};
185+
186+/** What a level means for an agent, in its own words. */
187+export function levelWords(level: AbilityLevel): string {
188+ switch (level) {
189+ case "alone":
190+ return "on its own";
191+ case "asked":
192+ return "only when the person asked for that";
193+ case "ask":
194+ return "after asking first";
195+ case "never":
196+ return "never";
197+ }
198+}
199+
200+// g1t's own ---------------------------------------------------------------
201+
202+/** g1t's own abilities: always on, within the asker's access; the four autonomy ones keep their choices. */
203+export const G1T_ABILITIES: AbilityDef[] = [
204+ {
205+ id: "g1t:artifacts",
206+ group: "g1t",
207+ label: "Artifacts",
208+ about: "Search, read, write, edit and share the workspace's artifacts, where the person it acts for can.",
209+ kind: "read",
210+ tools: ["search_artifacts", "read_artifact", "list_spaces", "stale_artifacts", "create_artifact", "edit_artifact", "share_artifact"],
211+ status: "ready",
212+ },
213+ {
214+ id: "g1t:chat",
215+ group: "g1t",
216+ label: "Chat",
217+ about: "Search messages and read threads everyone in the conversation can read, and the roster.",
218+ kind: "read",
219+ tools: ["search_messages", "read_thread", "workspace_roster"],
220+ status: "ready",
221+ },
222+ {
223+ id: "g1t:code",
224+ group: "g1t",
225+ label: "Code",
226+ about: "Read files and search code in repositories everyone in the conversation can read.",
227+ kind: "read",
228+ tools: ["list_repositories", "search_code", "read_file"],
229+ status: "ready",
230+ },
231+ {
232+ id: "g1t:issues",
233+ group: "g1t",
234+ label: "Issues and pull requests",
235+ about: "Read issues and pull requests, draft issues as cards, and comment or review on behalf of the person who asked.",
236+ kind: "read",
237+ tools: ["list_issues", "get_issue", "get_pull", "recent_activity", "draft_issue", "comment", "review_pull"],
238+ status: "ready",
239+ },
240+ {
241+ id: "g1t:pulls",
242+ group: "g1t",
243+ label: "Open pull requests",
244+ about: "Open pull requests from its sessions. Rules, protected branches and required checks still apply.",
245+ kind: "write",
246+ tools: [],
247+ status: "ready",
248+ autonomy: { key: "open_pull_requests", levels: ["alone", "ask"] },
249+ },
250+ {
251+ id: "g1t:merge",
252+ group: "g1t",
253+ label: "Merge",
254+ about: "Merge pull requests that have what the branch requires.",
255+ kind: "write",
256+ tools: [],
257+ status: "ready",
258+ autonomy: { key: "merge", levels: ["alone", "ask", "never"] },
259+ },
260+ {
261+ id: "g1t:deploy",
262+ group: "g1t",
263+ label: "Deploy to production",
264+ about: "Deploy a project's production environment.",
265+ kind: "restricted",
266+ tools: [],
267+ status: "ready",
268+ autonomy: { key: "deploy_production", levels: ["ask", "never"] },
269+ },
270+ {
271+ id: "g1t:docs",
272+ group: "g1t",
273+ label: "Edit docs",
274+ about: "Change docs directly, or leave each change as a suggestion someone accepts.",
275+ kind: "write",
276+ tools: ["edit_artifact"],
277+ status: "ready",
278+ autonomy: { key: "edit_docs", levels: ["alone", "ask"] },
279+ },
280+ {
281+ id: "g1t:files",
282+ group: "g1t",
283+ label: "Make files",
284+ about: "Make PDFs, Word documents, spreadsheets, CSVs and Markdown files, kept with a doc the person can open.",
285+ kind: "write",
286+ tools: ["make_file"],
287+ status: "ready",
288+ },
289+ {
290+ id: "g1t:colleagues",
291+ group: "g1t",
292+ label: "Colleagues and memory",
293+ about: "Ask a colleague, hand work off, bring one into a session, use its subagents, start sessions, and remember facts.",
294+ kind: "write",
295+ tools: ["ask_colleague", "hand_off", "bring_in", "use_subagent", "start_session", "post_update", "remember", "forget", "use_skill"],
296+ status: "ready",
297+ },
298+];
299+
300+/** `autonomy` values as levels, and back. */
301+export function levelOfAutonomy(value: string): AbilityLevel {
302+ switch (value) {
303+ case "alone":
304+ return "alone";
305+ case "never":
306+ return "never";
307+ default:
308+ // `approval` and `suggest` both mean a person acts first.
309+ return "ask";
310+ }
311+}
312+
313+export function autonomyOfLevel(key: keyof AgentAutonomy, level: AbilityLevel): string {
314+ if (level === "alone") return "alone";
315+ if (level === "never") return "never";
316+ return key === "edit_docs" ? "suggest" : "approval";
317+}
318+
319+// Its computer -----------------------------------------------------------
320+
321+export const COMPUTER_ABILITIES: AbilityDef[] = [
322+ { id: "computer:shell", group: "computer", label: "Shell", about: "Run commands on its own computer, with a persistent home.", kind: "write", tools: [], status: "coming" },
323+ { id: "computer:files", group: "computer", label: "Files", about: "Read and write files on its computer.", kind: "write", tools: [], status: "coming" },
324+ { id: "computer:browser", group: "computer", label: "Browser", about: "Open sites in a browser of its own, signed in where you let it be.", kind: "send", tools: [], status: "coming" },
325+ { id: "computer:web", group: "computer", label: "Web search", about: "Search and read the open web, as its team's web access allows.", kind: "read", tools: [], status: "coming" },
326+];
327+
328+// Integrations -----------------------------------------------------------
329+
330+/**
331+ * What an agent can do through each connector today, as the integrations
332+ * service does it: look an item up by its key or address, open an issue in
333+ * g1t from it, comment on it there, and (Sentry) mark it resolved. A
334+ * connector not named here has nothing an agent calls: Datadog and the
335+ * alerts webhook open issues by themselves; GitHub imports and mirrors
336+ * repositories; model providers run models.
337+ */
338+const INTEGRATION_ACTIONS: Record<string, { action: string; label: string; about: string; kind: AbilityKind; tool: string }[]> = {
339+ linear: [
340+ { action: "read", label: "Read issues", about: "Look up an issue by its key (ENG-42) or address: its title, status and description.", kind: "read", tool: "lookup_outside" },
341+ { action: "import", label: "Import issues", about: "Open an issue in a repository here from a Linear issue, linked back to it.", kind: "write", tool: "import_outside" },
342+ { action: "comment", label: "Comment", about: "Comment on an issue in Linear, as the workspace's connection, naming the person it acts for.", kind: "send", tool: "act_outside" },
343+ ],
344+ jira: [
345+ { action: "read", label: "Read tickets", about: "Look up a ticket by its key (TECH-1234) or address: its summary, status and description.", kind: "read", tool: "lookup_outside" },
346+ { action: "import", label: "Import tickets", about: "Open an issue in a repository here from a Jira ticket, linked back to it.", kind: "write", tool: "import_outside" },
347+ { action: "comment", label: "Comment", about: "Comment on a ticket in Jira, as the workspace's connection, naming the person it acts for.", kind: "send", tool: "act_outside" },
348+ ],
349+ sentry: [
350+ { action: "read", label: "Read issues", about: "Look up a Sentry issue by its address: its title, status and latest stack trace.", kind: "read", tool: "lookup_outside" },
351+ { action: "import", label: "Import issues", about: "Open an issue in a repository here from a Sentry issue, linked back to it.", kind: "write", tool: "import_outside" },
352+ { action: "comment", label: "Comment", about: "Leave a note on a Sentry issue, as the workspace's connection.", kind: "send", tool: "act_outside" },
353+ { action: "resolve", label: "Resolve issues", about: "Mark a Sentry issue resolved, with a note.", kind: "send", tool: "act_outside" },
354+ ],
355+};
356+
357+/** What a connected connector with nothing for an agent to call says. */
358+function nothingToCall(connector: Connector): string {
359+ switch (connector.category) {
360+ case "monitoring":
361+ return "Opens issues by itself when something fires. Nothing for an agent to call.";
362+ case "ai":
363+ return "Runs models. Which ones an agent uses is set under Models on its profile.";
364+ case "code":
365+ return "Imports and mirrors repositories. Agents read the repositories themselves.";
366+ default:
367+ return "Nothing for an agent to call yet.";
368+ }
369+}
370+
371+/** The abilities one connector gives an agent; empty when it has none. */
372+export function integrationAbilities(connectorId: string): AbilityDef[] {
373+ return (INTEGRATION_ACTIONS[connectorId] ?? []).map((row) => ({
374+ id: `integration:${connectorId}:${row.action}`,
375+ group: "integration",
376+ label: row.label,
377+ about: row.about,
378+ kind: row.kind,
379+ tools: [row.tool],
380+ status: "ready",
381+ }));
382+}
383+
384+/** The connectors among `connectors` that give agents abilities, in catalog order. */
385+export function connectorsWithAbilities(connectors: Connector[]): Connector[] {
386+ return connectors.filter((connector) => (INTEGRATION_ACTIONS[connector.id] ?? []).length > 0);
387+}
388+
389+// MCP servers ------------------------------------------------------------
390+
391+/** An MCP tool as an ability of its server. */
392+export function mcpAbility(server: Pick<McpServer, "id" | "name">, tool: McpTool): AbilityDef {
393+ return {
394+ id: `mcp:${server.id}:${tool.name}`,
395+ group: "mcp",
396+ label: tool.name,
397+ about: tool.description || (tool.kind === "read" ? "Reads, as the server says." : "The server doesn't say it only reads, so it counts as a write."),
398+ // Anything an MCP server changes happens outside g1t.
399+ kind: tool.kind === "read" ? "read" : "send",
400+ tools: [mcpToolName(server.name, tool.name)],
401+ status: "ready",
402+ };
403+}
404+
405+/** The tool name an agent calls an MCP tool by: `<server>__<tool>`, in the characters the model API allows. */
406+export function mcpToolName(server: string, tool: string): string {
407+ const clean = (text: string) => text.toLowerCase().replace(/[^a-z0-9_-]+/g, "_").replace(/^_+|_+$/g, "");
408+ return `${clean(server)}__${clean(tool)}`.slice(0, 64);
409+}
410+
411+/** An MCP server's name as kept: lowercase letters, digits and hyphens, 2 to 32. */
412+export function checkMcpName(value: unknown): { ok: true; value: string } | { ok: false; message: string } {
413+ const name = typeof value === "string" ? value.trim().toLowerCase() : "";
414+ if (!/^[a-z0-9](?:[a-z0-9]|-(?=[a-z0-9])){1,31}$/.test(name)) return { ok: false, message: "A server's name is 2 to 32 lowercase letters, digits and single hyphens." };
415+ return { ok: true, value: name };
416+}
417+
418+/**
419+ * An MCP server's address, checked: HTTPS, a public host name (not an
420+ * address, not local, not g1t's own), no sign-in in the address.
421+ */
422+export function checkMcpUrl(value: unknown): { ok: true; value: string } | { ok: false; message: string } {
423+ const text = typeof value === "string" ? value.trim() : "";
424+ if (!text || text.length > 500) return { ok: false, message: "Give the server's HTTPS address." };
425+ let url: URL;
426+ try {
427+ url = new URL(text);
428+ } catch {
429+ return { ok: false, message: "That isn't an address." };
430+ }
431+ if (url.protocol !== "https:") return { ok: false, message: "An MCP server is reached over HTTPS." };
432+ if (url.username || url.password) return { ok: false, message: "Don't put a sign-in in the address." };
433+ const host = url.hostname.toLowerCase();
434+ const ip = /^\d{1,3}(\.\d{1,3}){3}$/.test(host) || host.startsWith("[") || host.includes(":");
435+ const local = host === "localhost" || /\.(local|localhost|internal|lan|home|arpa)$/.test(host) || !host.includes(".");
436+ if (ip || local) return { ok: false, message: "Name the server by a public host name, not an address or a local name." };
437+ if (/(^|\.)(g1t\.sh|g1tusercontent\.com)$/.test(host)) return { ok: false, message: "g1t's own addresses aren't MCP servers to add here." };
438+ url.hash = "";
439+ return { ok: true, value: url.toString() };
440+}
441+
442+// Resolving --------------------------------------------------------------
443+
444+export type ResolveInput = {
445+ /** The connector catalog (./connectors.ts `CONNECTORS`). */
446+ connectors: Connector[];
447+ abilities: AgentAbilities | null | undefined;
448+ autonomy: AgentAutonomy;
449+ /** Connector ids the workspace has connected. */
450+ connected: string[];
451+ /** Where a connector is set up or managed, by id; absent: nowhere to link. */
452+ hrefs?: Record<string, string | null>;
453+ /** Whether the agent is a member's personal one: then the workspace's connection isn't its to use unless an owner chose it. */
454+ personal?: boolean;
455+};
456+
457+function settingOf(input: ResolveInput, id: string): AbilitySetting {
458+ return input.abilities?.settings?.[id] ?? {};
459+}
460+
461+/** A definition as one agent has it: its level, within what the kind allows. */
462+export function resolveOne(def: AbilityDef, input: ResolveInput, connected = true): Ability {
463+ const setting = settingOf(input, def.id);
464+ const max = maxLevel(def.kind);
465+ let level: AbilityLevel;
466+ let choices: AbilityLevel[];
467+ let canChange: boolean;
468+ if (def.autonomy) {
469+ level = levelOfAutonomy(input.autonomy[def.autonomy.key]);
470+ choices = def.autonomy.levels;
471+ canChange = true;
472+ } else if (def.group === "g1t") {
473+ level = "alone";
474+ choices = ["alone"];
475+ canChange = false;
476+ } else if (def.status === "coming") {
477+ level = defaultLevel(def.kind);
478+ choices = [];
479+ canChange = false;
480+ } else {
481+ const wanted = setting.level ?? defaultLevel(def.kind);
482+ level = withinLevel(wanted, max) ? wanted : max;
483+ choices = ABILITY_LEVELS.filter((l) => withinLevel(l, max));
484+ canChange = true;
485+ }
486+ const integration = def.group === "integration";
487+ const connectorId = integration ? def.id.split(":")[1]! : null;
488+ const connector = connectorId ? input.connectors.find((c) => c.id === connectorId) : null;
489+ const personalAvailable = !!connector && connector.scopes.includes("personal") && (connector.personal?.status ?? connector.status) === "available";
490+ return {
491+ ...def,
492+ level,
493+ max,
494+ choices,
495+ can_change: canChange && (def.group !== "integration" || connected),
496+ credentials: integration ? (setting.credentials ?? (input.personal ? "asker" : "workspace")) : null,
497+ personal_available: personalAvailable,
498+ };
499+}
500+
501+/** The abilities an agent has, in sections, as the tab shows them and the service enforces them. */
502+export function resolveAbilities(input: ResolveInput): AbilitySection[] {
503+ const connected = new Set(input.connected.map((id) => id.toLowerCase()));
504+ const hrefs = input.hrefs ?? {};
505+ const sections: AbilitySection[] = [
506+ {
507+ group: "g1t",
508+ title: "g1t",
509+ about: "Always on, within the access of the person it acts for. Code and docs keep the choices below.",
510+ sources: [{ id: "g1t", name: "g1t", connected: true, href: null, note: null, abilities: G1T_ABILITIES.map((def) => resolveOne(def, input)) }],
511+ },
512+ {
513+ group: "computer",
514+ title: "Its computer",
515+ about: "A computer of its own, with a shell, files, a browser and web search. Coming.",
516+ sources: [{ id: "computer", name: "Computer", connected: false, href: null, note: null, abilities: COMPUTER_ABILITIES.map((def) => resolveOne(def, input, false)) }],
517+ },
518+ ];
519+ // Every connected connector, then the ones with abilities that aren't connected yet.
520+ const sources: AbilitySource[] = [];
521+ for (const connector of input.connectors) {
522+ if (connector.status !== "available" || !connector.scopes.includes("workspace")) continue;
523+ const is = connected.has(connector.id);
524+ const defs = integrationAbilities(connector.id);
525+ if (!is && !defs.length) continue;
526+ // Nothing an agent calls: not worth a row unless it is connected.
527+ if (!defs.length && (connector.id === "webhooks" || connector.id === "ai-gateway")) continue;
528+ sources.push({
529+ id: connector.id,
530+ name: connector.name,
531+ connected: is,
532+ href: hrefs[connector.id] ?? null,
533+ note: defs.length ? null : nothingToCall(connector),
534+ abilities: defs.map((def) => resolveOne(def, input, is)),
535+ });
536+ }
537+ sources.sort((a, b) => Number(b.connected) - Number(a.connected));
538+ sections.push({ group: "integration", title: "Integrations", about: "What it can do through what the workspace has connected, one row per action, and whose connection each runs on.", sources });
539+ const servers = input.abilities?.mcp_servers ?? [];
540+ sections.push({
541+ group: "mcp",
542+ title: "MCP servers",
543+ about: "Servers an owner adds. Each tool the server lists is a row; a tool that doesn't say it only reads counts as a write.",
544+ sources: servers.map((server) => ({
545+ id: server.id,
546+ name: server.name,
547+ connected: true,
548+ href: server.url,
549+ note: server.problem ?? (server.tools.length ? null : "It listed no tools."),
550+ abilities: server.tools.slice(0, MAX_MCP_TOOLS).map((tool) => resolveOne(mcpAbility(server, tool), input)),
551+ })),
552+ });
553+ return sections;
554+}
555+
556+/** Every ability in the sections, flat. */
557+export function allAbilities(sections: AbilitySection[]): Ability[] {
558+ return sections.flatMap((section) => section.sources.flatMap((source) => source.abilities));
559+}
560+
561+/** The ability by id, among the sections; null when there is none. */
562+export function findAbility(sections: AbilitySection[], id: string): { ability: Ability; source: AbilitySource } | null {
563+ for (const section of sections) {
564+ for (const source of section.sources) {
565+ const ability = source.abilities.find((a) => a.id === id);
566+ if (ability) return { ability, source };
567+ }
568+ }
569+ return null;
570+}
571+
572+/**
573+ * Whether what the person said asked for this: the item's key (ENG-42), or
574+ * the ability's own name, appears in it. For a level of `asked`.
575+ */
576+export function askedFor(said: string, keys: string[]): boolean {
577+ const text = said.toLowerCase();
578+ if (!text.trim()) return false;
579+ return keys.some((key) => {
580+ const needle = key.trim().toLowerCase();
581+ return needle.length >= 2 && text.includes(needle);
582+ });
583+}
584+
585+/** A list in words: "a", "a and b", "a, b and c". */
586+function list(items: string[]): string {
587+ if (items.length <= 1) return items.join("");
588+ return `${items.slice(0, -1).join(", ")} and ${items[items.length - 1]}`;
589+}
590+
591+const lower = (text: string) => text.charAt(0).toLowerCase() + text.slice(1);
592+
593+/**
594+ * The agent's abilities in a sentence or two: "Can read Linear issues and
595+ * open pull requests on its own; imports Linear issues when asked for it;
596+ * asks before commenting in Linear and merging; never deploys to
597+ * production." Only what is connected and ready counts.
598+ */
599+export function abilitiesSummary(sections: AbilitySection[]): string {
600+ const by: Record<AbilityLevel, string[]> = { alone: [], asked: [], ask: [], never: [] };
601+ for (const section of sections) {
602+ if (section.group === "computer") continue;
603+ for (const source of section.sources) {
604+ if (!source.connected) continue;
605+ for (const ability of source.abilities) {
606+ if (ability.status !== "ready") continue;
607+ // g1t's always-on reads go without saying; its choices and everything outside are the point.
608+ if (section.group === "g1t" && !ability.autonomy) continue;
609+ const what = section.group === "g1t" ? lower(ability.label) : section.group === "mcp" ? `call ${ability.label} on ${source.name}` : `${lower(ability.label)} in ${source.name}`;
610+ by[ability.level].push(what);
611+ }
612+ }
613+ }
614+ const parts: string[] = [];
615+ if (by.alone.length) parts.push(`can ${list(by.alone)} on its own`);
616+ if (by.asked.length) parts.push(`${list(by.asked.map(verb))} when asked for it`);
617+ if (by.ask.length) parts.push(`asks before ${list(by.ask.map(gerund))}`);
618+ if (by.never.length) parts.push(`never ${list(by.never.map(verb))}`);
619+ if (!parts.length) return "Reads code, chat, issues and artifacts within the asker's access; nothing outside g1t yet.";
620+ const text = parts.join("; ");
621+ return `${text.charAt(0).toUpperCase()}${text.slice(1)}.`;
622+}
623+
624+/** "read issues in Linear" → "reads issues in Linear". */
625+function verb(what: string): string {
626+ const [first, ...rest] = what.split(" ");
627+ if (!first) return what;
628+ const third = first.endsWith("s") ? first : first.endsWith("y") && !/[aeiou]y$/.test(first) ? `${first.slice(0, -1)}ies` : `${first}s`;
629+ return [third, ...rest].join(" ");
630+}
631+
632+/** "comment in Linear" → "commenting in Linear". */
633+function gerund(what: string): string {
634+ const [first, ...rest] = what.split(" ");
635+ if (!first) return what;
636+ const ing = first.endsWith("e") && first !== "be" ? `${first.slice(0, -1)}ing` : first.endsWith("ing") ? first : `${first}ing`;
637+ return [ing, ...rest].join(" ");
638+}
639+
640+/** The settings with one ability's level or credentials changed, keeping only what differs from the default. */
641+export function withSetting(abilities: AgentAbilities | null | undefined, id: string, change: AbilitySetting): AgentAbilities {
642+ const base = abilities ?? EMPTY_ABILITIES;
643+ const current = { ...(base.settings[id] ?? {}) };
644+ if (change.level !== undefined) current.level = change.level;
645+ if (change.credentials !== undefined) current.credentials = change.credentials;
646+ const next = { ...base.settings };
647+ const kept: AbilitySetting = {};
648+ if (current.level) kept.level = current.level;
649+ if (current.credentials) kept.credentials = current.credentials;
650+ if (Object.keys(kept).length) next[id] = kept;
651+ else delete next[id];
652+ return { settings: next, mcp_servers: base.mcp_servers ?? [] };
653+}
+2−0
772772 resolve: (workspace, viewer, reference) => call("resolve", { workspace, viewer, reference }),
773773 references: (workspace, text, limit) => call("references", { workspace, text, limit }),
774774 import: (actor, repo, reference, assign) => call("import", { actor, repo, reference, assign }),
775+ comment: (actor, workspace, reference, text, link) => call("comment", { actor, workspace, reference, text, link }),
776+ close: (actor, workspace, reference, text, link) => call("close", { actor, workspace, reference, text, link }),
775777 links: (repo, number) => call("links", { repo, number }),
776778 modelProvider: (workspace) => call("model_provider", { workspace }),
777779 openModelSession: (run) => call("open_model_session", run),
+1−1
11 const ALPHABET = "0123456789abcdefghjkmnpqrstvwxyz";
22
3−export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst" | "chn" | "msg" | "agt" | "arp" | "asn" | "mem" | "rtn" | "drf" | "spc" | "pag" | "ver" | "thr" | "cmt" | "sug" | "tpl" | "fil" | "rds" | "fol" | "prp" | "ins" | "skl" | "ska";
3+export type IdPrefix = "usr" | "ses" | "tok" | "key" | "rep" | "int" | "att" | "evt" | "dpl" | "prj" | "dom" | "dep" | "dst" | "chn" | "msg" | "agt" | "arp" | "asn" | "mem" | "rtn" | "drf" | "spc" | "pag" | "ver" | "thr" | "cmt" | "sug" | "tpl" | "fil" | "rds" | "fol" | "prp" | "ins" | "skl" | "ska" | "abr" | "mcp";
44
55 let lastMs = 0;
66 let lastCounter = 0;
+1−0
1+export * from "./abilities";
12 export * from "./about";
23 export * from "./access";
34 export * from "./account-deletion";
+10−0
237237 /** For g1t's agents: what `text` refers to outside g1t, fetched. */
238238 references(workspace: string, text: string, limit?: number): Promise<ContextItem[]>;
239239 import(actor: User, repo: RepoPath, reference: string, assign: boolean): Promise<Result<{ number: number; item: ContextItem; created: boolean }>>;
240+ /**
241+ * For g1t's agents (docs.g1t.sh/guides/agent-abilities/): comments on
242+ * the item `reference` names, in the system that knows it (Linear, Jira
243+ * or Sentry), on the workspace's connection, with `link` (a g1t address)
244+ * at the end. The actor is the person the agent acts for; they must be a
245+ * member. Returns the item.
246+ */
247+ comment(actor: User, workspace: string, reference: string, text: string, link: string): Promise<Result<ContextItem>>;
248+ /** For g1t's agents: marks a Sentry issue resolved, with a note and `link`. Only Sentry items can be closed. */
249+ close(actor: User, workspace: string, reference: string, text: string, link: string): Promise<Result<ContextItem>>;
240250 links(repo: RepoPath, number: number): Promise<Link[]>;
241251 modelProvider(workspace: string): Promise<Connection | null>;
242252 openModelSession(run: {
+1−0
5050 { group: "Chat", tools: ["search_messages", "read_thread", "workspace_roster"] },
5151 { group: "Teamwork", tools: ["ask_colleague", "hand_off", "start_session", "post_update", "use_subagent", "bring_in", "use_skill"] },
5252 { group: "Memory", tools: ["remember", "forget"] },
53+ { group: "Outside g1t", tools: ["lookup_outside", "import_outside", "act_outside", "request_ability"] },
5354 ];
5455
5556 /** Every tool a skill may name. */
+26−0
1515 // same ones runs are routed between.
1616 import type { ModelTier } from "./integrations";
1717 import type { ExtensionInstall, InstallRequest, InstallRequestStatus, InstallRequests } from "./marketplace";
18+import type { AgentAbilities, AgentAbilitiesChange, McpServer } from "./abilities";
1819
1920 /** The built-in orchestrator's handle; nobody else's agent may take it. */
2021 export const BUILTIN_AGENT_HANDLE = "g1t";
120121 */
121122 skills_off: string[];
122123 /**
124+ * What it may do (./abilities.ts, docs.g1t.sh/guides/agent-abilities/):
125+ * its level and credentials for each ability it has a choice about, and
126+ * the MCP servers an owner added to it. Empty means every default.
127+ */
128+ abilities: AgentAbilities;
129+ /**
123130 * Who it works with: `internal`, the workspace's own people (back
124131 * office), or `customers` (front office). Only `internal` for now.
125132 */
206213 reading?: string[];
207214 /** Foundational skills to turn off, by id (./skills.ts). */
208215 skills_off?: string[];
216+ /**
217+ * Its abilities' levels and credentials (./abilities.ts), whole: what is
218+ * sent replaces what it had. MCP servers are added and removed with
219+ * `addMcpServer` and `removeMcpServer`, never here.
220+ */
221+ abilities?: AgentAbilitiesChange;
209222 /** Only `internal` for now; `customers` is refused. */
210223 faces?: AgentFaces;
211224 instructions: string;
848861 * the personal one is archived, with its memory and direct messages.
849862 */
850863 promote(workspace: string, handle: string, viewer: User): Promise<Result<WorkspaceAgent>>;
864+ /**
865+ * Adds an MCP server to an agent (docs.g1t.sh/guides/agent-abilities/,
866+ * "MCP servers"): its host is checked, its tools are listed from it, and
867+ * the agent gets a new version with each tool as an ability. Owners only.
868+ */
869+ addMcpServer(workspace: string, handle: string, viewer: User, input: { name: string; url: string }): Promise<Result<McpServer>>;
870+ /** Takes an MCP server off an agent, with its abilities: a new version. Owners only. */
871+ removeMcpServer(workspace: string, handle: string, viewer: User, id: string): Promise<Result<null>>;
872+ /** Lists a server's tools again, for one that changed; a new version when they did. Owners only. */
873+ refreshMcpServer(workspace: string, handle: string, viewer: User, id: string): Promise<Result<McpServer>>;
851874 templates(): Promise<AgentTemplate[]>;
852875 /**
853876 * Internal: the workspace's built-in `@g1t` agent, made if it does not
9811004 tryDraft: (workspace, viewer, input) => call("try_draft", { workspace, viewer, definition: input.definition, messages: input.messages }),
9821005 redraft: (workspace, handle, viewer, request) => call("redraft", { workspace, handle, viewer, request }),
9831006 promote: (workspace, handle, viewer) => call("promote", { workspace, handle, viewer }),
1007+ addMcpServer: (workspace, handle, viewer, input) => call("add_mcp_server", { workspace, handle, viewer, name: input.name, url: input.url }),
1008+ removeMcpServer: (workspace, handle, viewer, id) => call("remove_mcp_server", { workspace, handle, viewer, id }),
1009+ refreshMcpServer: (workspace, handle, viewer, id) => call("refresh_mcp_server", { workspace, handle, viewer, id }),
9841010 templates: () => call("templates", {}),
9851011 builtin: (workspace, workspaceId) => call("builtin", { workspace, workspace_id: workspaceId }),
9861012 deliver: (delivery) => call("deliver", delivery),
+40−0
1+-- Abilities (docs.g1t.sh/guides/agent-abilities/): what each agent may do
2+-- through g1t, the workspace's integrations and MCP servers an owner adds,
3+-- and whether it does each alone, only when asked for it, after asking
4+-- first, or never.
5+
6+-- The agent's choices (@g1t/contracts abilities.ts `AgentAbilities`, JSON):
7+-- levels and credentials per ability, and its MCP servers with the tools
8+-- each listed. Saved as a version like every other change.
9+ALTER TABLE agents ADD COLUMN abilities TEXT NOT NULL DEFAULT '{}';
10+
11+-- An ability set to "Ask first", asked: the call an agent wanted to make,
12+-- waiting on the person it acts for (or an owner) to allow it from the
13+-- card in chat. Allowed, the call runs as that person and the result goes
14+-- back to the agent (a session reads it at its next step).
15+-- status: pending, allowed, denied, failed.
16+CREATE TABLE agent_ability_requests (
17+ id TEXT PRIMARY KEY,
18+ agent_id TEXT NOT NULL,
19+ workspace_id TEXT NOT NULL,
20+ workspace TEXT NOT NULL,
21+ channel_id TEXT NOT NULL,
22+ message_id TEXT,
23+ session_id TEXT,
24+ -- The ability's id (integration:linear:comment) and the tool call it was for.
25+ ability TEXT NOT NULL,
26+ tool TEXT NOT NULL,
27+ input TEXT NOT NULL,
28+ -- What it would do, in a line, as the card says it.
29+ summary TEXT NOT NULL,
30+ asked_by TEXT,
31+ status TEXT NOT NULL DEFAULT 'pending',
32+ decided_by TEXT,
33+ decided_at TEXT,
34+ -- What came of it, as the agent was told.
35+ result TEXT,
36+ created_at TEXT NOT NULL,
37+ updated_at TEXT NOT NULL
38+);
39+CREATE INDEX agent_ability_requests_session ON agent_ability_requests (session_id, status);
40+CREATE INDEX agent_ability_requests_agent ON agent_ability_requests (agent_id, created_at);
+43−0
1+/**
2+ * What the prompt says about an agent's abilities outside g1t
3+ * (abilities.ts), and what a turn "said" for "alone when asked for it".
4+ * Pure, with type-only imports, so it is tested under Node as it is.
5+ */
6+import type { Ability, AbilitySection } from "@g1t/contracts";
7+
8+/**
9+ * The abilities as the prompt tells the agent about them: what each
10+ * connected integration lets it do and at which level, its MCP servers,
11+ * and what isn't connected, so it asks rather than guesses.
12+ */
13+export function abilitiesSection(sections: AbilitySection[]): string | null {
14+ const lines: string[] = [];
15+ const words = (ability: Ability) => (ability.level === "alone" ? "on your own" : ability.level === "asked" ? "on your own only when they asked for it, else it asks first" : ability.level === "ask" ? "asks first (a card)" : "never");
16+ for (const section of sections) {
17+ if (section.group !== "integration" && section.group !== "mcp") continue;
18+ for (const source of section.sources) {
19+ if (!source.connected) continue;
20+ const ready = source.abilities.filter((ability) => ability.status === "ready");
21+ if (!ready.length) continue;
22+ lines.push(`- ${source.name}: ${ready.map((ability) => `${ability.label.toLowerCase()} ${words(ability)}`).join("; ")}.`);
23+ }
24+ }
25+ const missing = sections
26+ .filter((section) => section.group === "integration")
27+ .flatMap((section) => section.sources)
28+ .filter((source) => !source.connected && source.abilities.length)
29+ .map((source) => source.name);
30+ if (!lines.length && !missing.length) return null;
31+ return [
32+ "## Your abilities outside g1t",
33+ "",
34+ "These are set on your Abilities tab and enforced in code: a call that isn't allowed is refused with the rule, and one that asks first posts a card. Never work around a refusal.",
35+ ...(lines.length ? ["", ...lines] : []),
36+ ...(missing.length ? ["", `Not connected to this workspace: ${missing.join(", ")}. When someone needs one, say so and use request_ability.`] : []),
37+ ].join("\n");
38+}
39+
40+/** What a turn says, for "alone when asked for it": the latest messages from people, or a session's goal and steering. */
41+export function saidText(parts: (string | null | undefined)[]): string {
42+ return parts.filter((part): part is string => typeof part === "string" && part.trim().length > 0).join("\n").slice(0, 20_000);
43+}
+364−0
1+import assert from "node:assert/strict";
2+import { test } from "node:test";
3+
4+import type { AbilitySection, McpServer, User } from "@g1t/contracts";
5+
6+import { resolveAbilities, withSetting } from "../../../packages/contracts/src/abilities.ts";
7+import { CONNECTORS } from "../../../packages/contracts/src/connectors.ts";
8+import { abilitiesSection, saidText } from "./abilities-prompt.ts";
9+import { Audience, type AudienceInfo, type AudiencePorts, type RepoRef } from "./audience.ts";
10+import { abilityCard, connectCard, requestCard } from "./card-views.ts";
11+import { DEFAULT_AUTONOMY, applyChanges } from "./definition.ts";
12+import { callMcpTool, listMcpTools } from "./mcp-client.ts";
13+import { type AbilityPorts, type ActionPorts, type ToolPorts, ToolBox } from "./tools.ts";
14+
15+// ── A small world ────────────────────────────────────────────────────────
16+
17+const WEB: RepoRef = { id: "rep_web", namespace: "acme", name: "web", isPrivate: true, defaultBranch: "main" };
18+const person = (id: string): User => ({ id, username: id, workspaces: [{ slug: "acme", role: "member" }] }) as User;
19+
20+function world(): AudiencePorts {
21+ const info: AudienceInfo = { kind: "dm", member_user_ids: ["asker"], member_count: 1 };
22+ return {
23+ info: async () => info,
24+ users: async (ids) => [person("asker")].filter((u) => ids.includes(u.id)),
25+ workspaceRepos: async () => [WEB],
26+ readable: async (ids) => [WEB].filter((r) => ids.includes(r.id)),
27+ };
28+}
29+
30+const ports: ToolPorts = {
31+ readFile: async () => null,
32+ searchCode: async () => [],
33+ listIssues: async () => [],
34+ getIssue: async () => null,
35+ getPull: async () => null,
36+ recentPulls: async () => [],
37+ searchMessages: async () => [],
38+ readThread: async () => null,
39+ roster: async () => "",
40+ consult: async () => ({ ok: false, message: "no" }),
41+};
42+
43+const actions: ActionPorts = {
44+ remember: async () => ({ ok: true, message: "" }),
45+ forget: async () => ({ ok: true, message: "" }),
46+ draftIssue: async () => ({ ok: true, message: "" }),
47+};
48+
49+const ITEMS: Record<string, { provider: string; key: string; title: string; url: string; status: string | null; body: string }> = {
50+ "ENG-42": { provider: "linear", key: "ENG-42", title: "Retry flaky checks", url: "https://linear.app/acme/issue/ENG-42", status: "In Progress", body: "The queue retries." },
51+ "TECH-7": { provider: "jira", key: "TECH-7", title: "Login timeout", url: "https://acme.atlassian.net/browse/TECH-7", status: "To Do", body: "Times out." },
52+};
53+
54+type Posted = { kind: string; title: string };
55+
56+/** Ports that record what they were asked to do. */
57+function fakeAbilityPorts(over: Partial<AbilityPorts> = {}): AbilityPorts & { posted: Posted[]; acted: string[]; refusals: string[] } {
58+ const posted: Posted[] = [];
59+ const acted: string[] = [];
60+ const refusals: string[] = [];
61+ return {
62+ posted,
63+ acted,
64+ refusals,
65+ lookup: async (_asker, reference) => (ITEMS[reference] ? { ok: true, value: ITEMS[reference]! } : { ok: false, code: "not_found", message: "None of the acme workspace's integrations knows that." }),
66+ import: async (_asker, repo, reference) => ({ ok: true, value: { number: 7, item: ITEMS[reference]!, created: true } }),
67+ act: async (_asker, reference, action, text) => {
68+ acted.push(`${action} ${reference}: ${text}`);
69+ return { ok: true, value: ITEMS[reference]! };
70+ },
71+ askerConnected: async () => false,
72+ mcp: async (server, tool, args) => {
73+ acted.push(`mcp ${server.name}.${tool.name} ${JSON.stringify(args)}`);
74+ return { ok: true, value: "sunny" };
75+ },
76+ askFirst: async ({ summary }) => {
77+ posted.push({ kind: "ability", title: summary });
78+ return "abr_1";
79+ },
80+ connect: async (source) => {
81+ posted.push({ kind: "connect", title: source.name });
82+ return true;
83+ },
84+ request: async ({ connector, ability }) => {
85+ posted.push({ kind: "request", title: connector ?? ability?.id ?? "" });
86+ return true;
87+ },
88+ refused: ({ rule }) => {
89+ refusals.push(rule);
90+ },
91+ ...over,
92+ };
93+}
94+
95+const SERVER: McpServer = {
96+ id: "mcp_w",
97+ name: "weather",
98+ url: "https://mcp.example.com/",
99+ tools: [
100+ { name: "get_forecast", description: "Today's forecast", kind: "read", input_schema: { type: "object", properties: { city: { type: "string" } } } },
101+ { name: "set_alert", description: "Set an alert", kind: "write", input_schema: { type: "object", properties: {} } },
102+ ],
103+ added_by: "ana",
104+ added_at: "2026-10-10T00:00:00.000Z",
105+ checked_at: null,
106+ problem: null,
107+};
108+
109+async function box(input: { connected?: string[]; settings?: Record<string, { level?: "alone" | "asked" | "ask" | "never"; credentials?: "workspace" | "asker" }>; servers?: McpServer[]; said?: string; session?: boolean; ports?: Partial<AbilityPorts> }) {
110+ const audience = await Audience.build("acme", "asker", world());
111+ const tools = new ToolBox(audience, ports, { agentId: "agt_me", notConsult: ["me"], hops: 0, maxHops: 6, session: input.session }, [], actions);
112+ let abilities = { settings: {}, mcp_servers: input.servers ?? [] };
113+ for (const [id, setting] of Object.entries(input.settings ?? {})) abilities = withSetting(abilities, id, setting);
114+ const sections: AbilitySection[] = resolveAbilities({ connectors: CONNECTORS, abilities, autonomy: DEFAULT_AUTONOMY, connected: input.connected ?? [] });
115+ const fake = fakeAbilityPorts(input.ports);
116+ tools.useAbilities(sections, fake, input.said ?? "", input.servers ?? []);
117+ return { tools, fake, sections };
118+}
119+
120+const names = (tools: ToolBox) => tools.definitions().map((t) => t.name);
121+
122+// ── Offering ─────────────────────────────────────────────────────────────
123+
124+test("nothing connected: no outside tools but request_ability; a connected Linear offers lookup, import and act", async () => {
125+ const none = await box({});
126+ assert.ok(!names(none.tools).includes("lookup_outside"));
127+ assert.ok(names(none.tools).includes("request_ability"), "it can still ask for what it lacks");
128+ const linear = await box({ connected: ["linear"] });
129+ for (const tool of ["lookup_outside", "import_outside", "act_outside"]) assert.ok(names(linear.tools).includes(tool), tool);
130+});
131+
132+test("an ability set to Never isn't offered when it is the only one for its tool, and is refused with the rule if called anyway", async () => {
133+ const { tools, fake } = await box({ connected: ["linear"], settings: { "integration:linear:comment": { level: "never" } } });
134+ assert.ok(!names(tools).includes("act_outside"), "Linear's only act ability is Never");
135+ const tried = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "hi" });
136+ assert.equal(tried.outcome, "refused");
137+ assert.match(tried.text, /no tool called act_outside/);
138+ assert.deepEqual(fake.acted, []);
139+});
140+
141+test("Never on one system, allowed on another: the tool is offered, and the rule is named per call, in the result and the audit log", async () => {
142+ const { tools, fake } = await box({ connected: ["linear", "jira"], settings: { "integration:linear:comment": { level: "never" }, "integration:jira:comment": { level: "alone" } } });
143+ assert.ok(names(tools).includes("act_outside"));
144+ const linear = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "Looks fixed." });
145+ assert.equal(linear.outcome, "refused");
146+ assert.match(linear.text, /Not allowed: your abilities say "Linear: Comment" is Never/);
147+ assert.deepEqual(fake.refusals, ["integration:linear:comment=never"]);
148+ assert.deepEqual(fake.acted, [], "nothing ran");
149+ const jira = await tools.run("act_outside", { reference: "TECH-7", action: "comment", text: "On it." });
150+ assert.equal(jira.outcome, "allowed");
151+ assert.deepEqual(fake.acted, ["comment TECH-7: On it."]);
152+ assert.equal(tools.calls.length, 2, "both calls are in the transcript");
153+ assert.equal(tools.calls[0]!.outcome, "refused");
154+});
155+
156+test("reading is alone by default; an item nobody knows is not found, and a system that isn't connected is said so", async () => {
157+ const { tools } = await box({ connected: ["linear"] });
158+ const read = await tools.run("lookup_outside", { reference: "ENG-42" });
159+ assert.equal(read.outcome, "allowed");
160+ assert.match(read.text, /<untrusted source="Linear ENG-42">/);
161+ assert.match(read.text, /Retry flaky checks/);
162+ const missing = await tools.run("lookup_outside", { reference: "NOPE-1" });
163+ assert.equal(missing.outcome, "refused");
164+ assert.match(missing.text, /No connected integration knows NOPE-1/);
165+ // Jira knows TECH-7, but Jira isn't connected to this workspace as the agent sees it.
166+ const jira = await tools.run("lookup_outside", { reference: "TECH-7" });
167+ assert.equal(jira.outcome, "refused");
168+ assert.match(jira.text, /Jira isn't connected to this workspace, so you can't read tickets there/);
169+});
170+
171+// ── Ask first, and alone when asked for it ───────────────────────────────
172+
173+test("Ask first posts the card with the call, runs nothing, and tells the agent to wait; a session is told it pauses", async () => {
174+ const { tools, fake } = await box({ connected: ["linear"], session: true });
175+ const tried = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "Fixed in #412." });
176+ assert.equal(tried.outcome, "refused");
177+ assert.match(tried.text, /"Linear: Comment" is after asking first: a card was posted asking @asker \(or an owner\)/);
178+ assert.match(tried.text, /Your session pauses/);
179+ assert.deepEqual(fake.posted, [{ kind: "ability", title: "Comment on ENG-42 in Linear" }]);
180+ assert.deepEqual(fake.acted, []);
181+ assert.deepEqual(fake.refusals, ["integration:linear:comment=ask"]);
182+});
183+
184+test("alone when asked for it: runs when the person named the item, asks first when they didn't", async () => {
185+ const asked = await box({ connected: ["linear"], said: "Please import ENG-42 into web so we can track it here." });
186+ const ran = await asked.tools.run("import_outside", { repo: "web", reference: "ENG-42" });
187+ assert.equal(ran.outcome, "allowed");
188+ assert.match(ran.text, /Opened acme\/web#7 from ENG-42/);
189+ assert.deepEqual(asked.fake.posted, []);
190+ const unasked = await box({ connected: ["linear"], said: "What's the state of the queue work?" });
191+ const held = await unasked.tools.run("import_outside", { repo: "web", reference: "ENG-42" });
192+ assert.equal(held.outcome, "refused");
193+ assert.match(held.text, /^Import issues in Linear runs on its own only when asked for it, and this wasn't\. "Linear: Import issues" is only when the person asked for that: a card was posted/);
194+ assert.deepEqual(unasked.fake.posted, [{ kind: "ability", title: "Import ENG-42 from Linear into acme/web" }]);
195+});
196+
197+test("when the card can't be posted, the agent is told to ask in words", async () => {
198+ const { tools } = await box({ connected: ["linear"], ports: { askFirst: async () => null } });
199+ const tried = await tools.run("act_outside", { reference: "ENG-42", action: "comment", text: "x" });
200+ assert.match(tried.text, /couldn't be posted just now/);
201+});
202+
203+// ── Whose connection ─────────────────────────────────────────────────────
204+
205+test("the asker's own connection, missing: a Connect card, nothing runs, and the audit log says why", async () => {
206+ const { tools, fake } = await box({ connected: ["linear"], settings: { "integration:linear:read": { credentials: "asker" } } });
207+ const tried = await tools.run("lookup_outside", { reference: "ENG-42" });
208+ assert.equal(tried.outcome, "refused");
209+ assert.match(tried.text, /runs on @asker's own Linear connection, and they haven't connected one. A Connect card was posted/);
210+ assert.deepEqual(fake.posted, [{ kind: "connect", title: "Linear" }]);
211+ assert.deepEqual(fake.refusals, ["integration:linear:read=asker-not-connected"]);
212+ const connected = await box({ connected: ["linear"], settings: { "integration:linear:read": { credentials: "asker" } }, ports: { askerConnected: async () => true } });
213+ assert.equal((await connected.tools.run("lookup_outside", { reference: "ENG-42" })).outcome, "allowed");
214+});
215+
216+// ── Requests ─────────────────────────────────────────────────────────────
217+
218+test("request_ability posts a Request card for an integration that isn't connected, or an ability by its id", async () => {
219+ const { tools, fake } = await box({ connected: ["linear"], settings: { "integration:linear:comment": { level: "never" } } });
220+ const jira = await tools.run("request_ability", { needs: "jira", why: "Support files bugs there." });
221+ assert.equal(jira.outcome, "allowed");
222+ const comment = await tools.run("request_ability", { needs: "integration:linear:comment", why: "To tell the team when it's fixed." });
223+ assert.equal(comment.outcome, "allowed");
224+ assert.deepEqual(fake.posted, [
225+ { kind: "request", title: "jira" },
226+ { kind: "request", title: "integration:linear:comment" },
227+ ]);
228+ const bad = await tools.run("request_ability", { needs: "", why: "" });
229+ assert.equal(bad.outcome, "refused");
230+});
231+
232+// ── MCP ──────────────────────────────────────────────────────────────────
233+
234+test("an MCP server's tools are offered as <server>__<tool> with the server's schema; reads run alone, writes ask first, Never withholds", async () => {
235+ const { tools, fake } = await box({ servers: [SERVER] });
236+ const forecast = tools.definitions().find((t) => t.name === "weather__get_forecast");
237+ assert.ok(forecast, "offered");
238+ assert.deepEqual(forecast!.input_schema, SERVER.tools[0]!.input_schema);
239+ assert.match(forecast!.description, /it reads/);
240+ const read = await tools.run("weather__get_forecast", { city: "Lisbon" });
241+ assert.equal(read.outcome, "allowed");
242+ assert.match(read.text, /<untrusted source="weather get_forecast">\nsunny/);
243+ const write = await tools.run("weather__set_alert", {});
244+ assert.equal(write.outcome, "refused");
245+ assert.match(write.text, /"weather: set_alert" is after asking first/);
246+ assert.deepEqual(fake.posted, [{ kind: "ability", title: "Call set_alert on weather" }]);
247+ const never = await box({ servers: [SERVER], settings: { "mcp:mcp_w:set_alert": { level: "never" } } });
248+ assert.ok(!names(never.tools).includes("weather__set_alert"), "not offered at all");
249+ assert.equal((await never.tools.run("weather__set_alert", {})).outcome, "refused");
250+});
251+
252+// ── The prompt and what was said ─────────────────────────────────────────
253+
254+test("the prompt's abilities section names each connected system's rows and levels, and what isn't connected", async () => {
255+ const { sections } = await box({ connected: ["linear"], settings: { "integration:linear:comment": { level: "never" } }, servers: [SERVER] });
256+ const text = abilitiesSection(sections)!;
257+ assert.match(text, /- Linear: read issues on your own; import issues on your own only when they asked for it, else it asks first; comment never\./);
258+ assert.match(text, /- weather: get_forecast on your own; set_alert asks first \(a card\)\./);
259+ assert.match(text, /Not connected to this workspace: Jira, Sentry\./);
260+ assert.equal(saidText([null, " ", "a", "b"]), "a\nb");
261+});
262+
263+// ── The definition ───────────────────────────────────────────────────────
264+
265+test("a definition keeps ability settings it knows, within each kind's limit, and never above Ask for a restricted one", () => {
266+ const base = applyChanges(null, { handle: "margo", display_name: "Margo", title: "QA", instructions: "Test." }, []);
267+ assert.ok(base.ok);
268+ const changed = applyChanges(base.value, { abilities: { settings: { "integration:linear:comment": { level: "alone", credentials: "asker" } } } }, []);
269+ assert.ok(changed.ok);
270+ assert.deepEqual(changed.value.abilities, { settings: { "integration:linear:comment": { level: "alone", credentials: "asker" } }, mcp_servers: [] });
271+ const unknown = applyChanges(base.value, { abilities: { settings: { "integration:slack:post": { level: "alone" } } } }, []);
272+ assert.ok(!unknown.ok && /no ability called integration:slack:post/.test(unknown.message));
273+ const badLevel = applyChanges(base.value, { abilities: { settings: { "integration:linear:read": { level: "loud" as never } } } }, []);
274+ assert.ok(!badLevel.ok);
275+ const notIntegration = applyChanges(base.value, { abilities: { settings: { "mcp:mcp_w:get_forecast": { level: "ask" } } } }, []);
276+ assert.ok(!notIntegration.ok, "no such server on the agent");
277+ // With the server, its tools are abilities; removing the server drops their settings.
278+ const withServer = applyChanges(base.value, { abilities: { settings: { "mcp:mcp_w:get_forecast": { level: "ask" } } } }, [], { mcp_servers: [SERVER] });
279+ assert.ok(withServer.ok);
280+ assert.equal(withServer.value.abilities.mcp_servers.length, 1);
281+ assert.deepEqual(withServer.value.abilities.settings, { "mcp:mcp_w:get_forecast": { level: "ask" } });
282+ const without = applyChanges(withServer.value, {}, [], { mcp_servers: [] });
283+ assert.ok(without.ok);
284+ assert.deepEqual(without.value.abilities, { settings: {}, mcp_servers: [] });
285+ const g1t = applyChanges(base.value, { abilities: { settings: { "g1t:merge": { level: "alone" } } } }, []);
286+ assert.ok(!g1t.ok, "g1t's own keep their choices in autonomy");
287+});
288+
289+// ── Cards ────────────────────────────────────────────────────────────────
290+
291+test("the Ask-first, Connect and Request cards say what they are for and offer the right buttons", () => {
292+ const waiting = abilityCard({ id: "abr_1", summary: "Comment on ENG-42 in Linear", status: "pending", decided_by: null, result: null, ability: "integration:linear:comment" }, { agent: "Margo", asker: "ana", rule: "Linear: Comment", level: "ask", note: null, body: "Fixed in #412." });
293+ assert.equal(waiting.state, "Waiting");
294+ assert.deepEqual(waiting.actions?.map((a) => a.id), ["allow", "deny"]);
295+ assert.equal(waiting.owner, "agents");
296+ assert.equal(waiting.body, "Fixed in #412.");
297+ const done = abilityCard({ id: "abr_1", summary: "Comment on ENG-42 in Linear", status: "allowed", decided_by: "ana", result: "Commented on ENG-42.", ability: "integration:linear:comment" }, { agent: "Margo", asker: "ana", rule: "Linear: Comment", level: "ask", note: null, body: null });
298+ assert.equal(done.state, "Done");
299+ assert.equal(done.actions, undefined);
300+ const connect = connectCard({ connector: "Linear", agent: "Margo", ability: "Read issues", asker: "ana", href: "/settings/integrations" });
301+ assert.equal(connect.actions?.[0]?.href, "/settings/integrations");
302+ assert.equal(connect.owner, null, "a link, no action for the service");
303+ const request = requestCard({ agent: { id: "a1", handle: "margo", display_name: "Margo" }, workspace: "acme", connector: { id: "jira", name: "Jira", available: true }, ability: null, why: "Support files bugs there.", status: "open", by: null });
304+ assert.deepEqual(request.actions?.map((a) => a.id), ["ask", "open"]);
305+ assert.equal(request.ref, "connector:a1:jira");
306+ const asked = requestCard({ agent: { id: "a1", handle: "margo", display_name: "Margo" }, workspace: "acme", connector: null, ability: { id: "integration:linear:comment", label: "Linear: Comment" }, why: "x", status: "asked", by: "bo" });
307+ assert.equal(asked.state, "Asked");
308+ assert.equal(asked.actions?.[0]?.href, "/acme/-/agents/margo/abilities");
309+});
310+
311+// ── The MCP client ───────────────────────────────────────────────────────
312+
313+test("the MCP client lists tools (reads by readOnlyHint) and calls one, over JSON or an event stream", async () => {
314+ const seen: { method: string; session: string | null }[] = [];
315+ const fetchFn = async (_url: string, init: RequestInit) => {
316+ const body = JSON.parse(String(init.body)) as { id?: number; method: string; params?: { name?: string } };
317+ seen.push({ method: body.method, session: (init.headers as Record<string, string>)["mcp-session-id"] ?? null });
318+ const answer = (result: unknown, sse = false) =>
319+ new Response(sse ? `event: message\ndata: ${JSON.stringify({ jsonrpc: "2.0", id: body.id, result })}\n\n` : JSON.stringify({ jsonrpc: "2.0", id: body.id, result }), {
320+ status: 200,
321+ headers: { "content-type": sse ? "text/event-stream" : "application/json", "mcp-session-id": "s1" },
322+ });
323+ if (body.method === "initialize") return answer({ protocolVersion: "2025-06-18" });
324+ if (body.method === "notifications/initialized") return new Response(null, { status: 202 });
325+ if (body.method === "tools/list") {
326+ return answer(
327+ {
328+ tools: [
329+ { name: "get_forecast", description: "Forecast", inputSchema: { type: "object", properties: { city: { type: "string" } } }, annotations: { readOnlyHint: true } },
330+ { name: "set_alert", description: "Alert" },
331+ { name: "bad name!" },
332+ ],
333+ },
334+ true,
335+ );
336+ }
337+ if (body.method === "tools/call") return answer({ content: [{ type: "text", text: `sunny in ${JSON.stringify(body.params)}` }] });
338+ return new Response("{}", { status: 404 });
339+ };
340+ const listed = await listMcpTools("https://mcp.example.com/", fetchFn);
341+ assert.ok(listed.ok);
342+ assert.deepEqual(
343+ listed.tools.map((t) => [t.name, t.kind]),
344+ [
345+ ["get_forecast", "read"],
346+ ["set_alert", "write"],
347+ ],
348+ );
349+ assert.deepEqual(listed.tools[1]!.input_schema, { type: "object", properties: {} });
350+ assert.equal(seen[2]!.session, "s1", "the session id the server gave rides along");
351+ const called = await callMcpTool("https://mcp.example.com/", "get_forecast", { city: "Lisbon" }, fetchFn);
352+ assert.ok(called.ok);
353+ assert.match(called.text, /sunny in \{"name":"get_forecast","arguments":\{"city":"Lisbon"\}\}/);
354+ const down = await listMcpTools("https://mcp.example.com/", async () => {
355+ throw new Error("ECONNREFUSED");
356+ });
357+ assert.ok(!down.ok && /couldn't be reached/.test(down.message));
358+ const errored = await callMcpTool("https://mcp.example.com/", "x", {}, async (_u, init) => {
359+ const body = JSON.parse(String(init.body)) as { id?: number; method: string };
360+ if (body.method === "tools/call") return Response.json({ jsonrpc: "2.0", id: body.id, result: { isError: true, content: [{ type: "text", text: "no such city" }] } });
361+ return Response.json({ jsonrpc: "2.0", id: body.id, result: {} });
362+ });
363+ assert.ok(!errored.ok && errored.message === "no such city");
364+});
+359−0
1+/**
2+ * An agent's abilities at work (docs.g1t.sh/guides/agent-abilities/): what
3+ * the workspace has connected, the agent's level for each ability
4+ * (@g1t/contracts abilities.ts), and the ports that carry a call out once
5+ * the tool box's gate allowed it: the integrations service, an MCP server,
6+ * and the cards in chat that ask first, offer to connect, or request
7+ * something from the owners.
8+ *
9+ * Asked first: the call is kept in `agent_ability_requests` with its
10+ * arguments and a card is posted. Whoever may allow it (the person the
11+ * agent acts for, or an owner) presses Allow, the call runs as them, and
12+ * the agent hears the result: a session reads it at its next step
13+ * (cards.ts). Every refusal names its rule in the transcript, through the
14+ * tool's result, and in the audit log, through `refused`.
15+ */
16+import { type Ability, type AbilitySection, type AbilitySource, type McpServer, type MessageCard, type ServiceBinding, type User, chatClient, identityClient, integrationsClient, newId, notifyClient } from "@g1t/contracts";
17+
18+import { CONNECTORS, connectorPath, connectorView } from "../../../packages/contracts/src/connectors.ts";
19+import { findAbility, resolveAbilities } from "../../../packages/contracts/src/abilities.ts";
20+import { abilityCard, connectCard, requestCard } from "./card-views.ts";
21+export { abilitiesSection, saidText } from "./abilities-prompt.ts";
22+import type { Definition } from "./definition.ts";
23+import { callMcpTool } from "./mcp-client.ts";
24+import { type Row, isPersonal } from "./store.ts";
25+import type { AbilityPorts, OutsideDone, OutsideItem } from "./tools.ts";
26+
27+export type AbilityEnv = {
28+ DB: D1Database;
29+ INTEGRATIONS: ServiceBinding;
30+ CHAT: ServiceBinding;
31+ IDENTITY: ServiceBinding;
32+ EVENTS: ServiceBinding;
33+ NOTIFY?: ServiceBinding;
34+ /** The site's address (https://g1t.sh), for links that leave g1t. */
35+ SITE_URL?: string;
36+};
37+
38+/** A call waiting to be allowed, as kept. */
39+export type AbilityRequestRow = {
40+ id: string;
41+ agent_id: string;
42+ workspace_id: string;
43+ workspace: string;
44+ channel_id: string;
45+ message_id: string | null;
46+ session_id: string | null;
47+ ability: string;
48+ tool: string;
49+ input: string;
50+ summary: string;
51+ asked_by: string | null;
52+ status: string;
53+ decided_by: string | null;
54+ decided_at: string | null;
55+ result: string | null;
56+ created_at: string;
57+ updated_at: string;
58+};
59+
60+const iso = () => new Date().toISOString();
61+
62+/** The site's address, without a trailing slash. */
63+export function siteUrl(env: { SITE_URL?: string }): string {
64+ return (env.SITE_URL ?? "").trim().replace(/\/+$/, "") || "https://g1t.sh";
65+}
66+
67+/**
68+ * The connector ids the workspace has connected, as the integrations
69+ * service lists its connections to a member. Empty when it can't say.
70+ */
71+export async function connectedConnectors(env: Pick<AbilityEnv, "INTEGRATIONS">, workspace: string, viewer: User): Promise<string[]> {
72+ const listed = await integrationsClient(env.INTEGRATIONS)
73+ .list(workspace, viewer)
74+ .catch(() => null);
75+ if (!listed?.ok) return [];
76+ const providers = new Set(listed.value.map((connection) => connection.provider));
77+ return CONNECTORS.filter((connector) => connector.provider && providers.has(connector.provider)).map((connector) => connector.id);
78+}
79+
80+/** The agent's abilities for a turn: resolved against what is connected. */
81+export async function abilitiesFor(env: Pick<AbilityEnv, "INTEGRATIONS">, input: { agent: Row; definition: Definition; workspace: string; asker: User }): Promise<AbilitySection[]> {
82+ const connected = await connectedConnectors(env, input.workspace, input.asker);
83+ return resolveAbilities({ connectors: CONNECTORS, abilities: input.definition.abilities, autonomy: input.definition.autonomy, connected, personal: isPersonal(input.agent) });
84+}
85+
86+/** Where a request's card and a session's wait point: the Abilities tab. */
87+export function abilitiesPath(workspace: string, handle: string): string {
88+ return `/${workspace}/-/agents/${handle}/abilities`;
89+}
90+
91+const item = (c: { provider: string; key: string; title: string; url: string; status: string | null; body: string }): OutsideItem => ({ provider: c.provider, key: c.key, title: c.title, url: c.url, status: c.status, body: c.body });
92+
93+const outcome = <T, U>(result: { ok: true; value: T } | { ok: false; error: { code: string; message: string } }, map: (value: T) => U): OutsideDone<U> =>
94+ result.ok ? { ok: true, value: map(result.value) } : { ok: false, code: result.error.code, message: result.error.message };
95+
96+/**
97+ * The ports for one turn. `postCard` posts where the agent is working (a
98+ * reply's conversation, a session's thread) and gives the message id.
99+ */
100+export function abilityPorts(
101+ env: AbilityEnv,
102+ input: {
103+ agent: Row;
104+ workspace: string;
105+ channel_id: string;
106+ session: { id: string; title: string } | null;
107+ asker: { id: string | null; username: string | null };
108+ postCard: (card: MessageCard) => Promise<string | null>;
109+ },
110+): AbilityPorts {
111+ const integrations = integrationsClient(env.INTEGRATIONS);
112+ const { agent, workspace } = input;
113+ const link = `${siteUrl(env)}/${workspace}/-/chat/${input.channel_id}`;
114+ return {
115+ lookup: async (asker, reference) => outcome(await integrations.resolve(workspace, asker, reference), item),
116+ import: async (asker, repo, reference) =>
117+ outcome(await integrations.import(asker, { namespace: repo.namespace, name: repo.name }, reference, false), (v) => ({ number: v.number, item: item(v.item), created: v.created })),
118+ act: async (asker, reference, action, text) => {
119+ const signed = `${text}\n\n— ${agent.display_name} (@${agent.handle}), a g1t agent, for @${asker.username}.`;
120+ const done = action === "resolve" ? await integrations.close(asker, workspace, reference, signed, link) : await integrations.comment(asker, workspace, reference, signed, link);
121+ return outcome(done, item);
122+ },
123+ // Personal connections to Linear, Jira and Sentry aren't available yet (connectors.ts says so), so nobody has one.
124+ askerConnected: async () => false,
125+ mcp: async (server, tool, args) => {
126+ const done = await callMcpTool(server.url, tool.name, args);
127+ return done.ok ? { ok: true, value: done.text } : { ok: false, code: "error", message: done.message };
128+ },
129+ async askFirst({ ability, source, tool, args, summary, note }) {
130+ const id = newId("abr");
131+ const now = iso();
132+ const row: AbilityRequestRow = {
133+ id,
134+ agent_id: agent.id,
135+ workspace_id: agent.workspace_id,
136+ workspace,
137+ channel_id: input.channel_id,
138+ message_id: null,
139+ session_id: input.session?.id ?? null,
140+ ability: ability.id,
141+ tool,
142+ input: JSON.stringify(args).slice(0, 20_000),
143+ summary: summary.slice(0, 300),
144+ asked_by: input.asker.id,
145+ status: "pending",
146+ decided_by: null,
147+ decided_at: null,
148+ result: null,
149+ created_at: now,
150+ updated_at: now,
151+ };
152+ await env.DB.prepare(
153+ `INSERT INTO agent_ability_requests (id, agent_id, workspace_id, workspace, channel_id, session_id, ability, tool, input, summary, asked_by, status, created_at, updated_at)
154+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, 'pending', ?, ?)`,
155+ )
156+ .bind(id, row.agent_id, row.workspace_id, row.workspace, row.channel_id, row.session_id, row.ability, row.tool, row.input, row.summary, row.asked_by, now, now)
157+ .run();
158+ const messageId = await input.postCard(abilityCard(row, { agent: agent.display_name, asker: input.asker.username, rule: `${source.name}: ${ability.label}`, level: ability.level, note, body: bodyOf(args) }));
159+ if (!messageId) {
160+ await env.DB.prepare("UPDATE agent_ability_requests SET status = 'failed', result = ?, updated_at = ? WHERE id = ?").bind("The card couldn't be posted.", iso(), id).run();
161+ return null;
162+ }
163+ await env.DB.prepare("UPDATE agent_ability_requests SET message_id = ? WHERE id = ?").bind(messageId, id).run();
164+ return id;
165+ },
166+ async connect(source, ability) {
167+ const connector = CONNECTORS.find((c) => c.id === source.id);
168+ const view = connector ? connectorView(connector, "personal") : null;
169+ const href = view?.href ? connectorPath(view.href, workspace) : "/settings/integrations";
170+ const messageId = await input.postCard(connectCard({ connector: source.name, agent: agent.display_name, ability: ability.label, asker: input.asker.username, href }));
171+ return messageId !== null;
172+ },
173+ async request({ connector, ability, source, why }) {
174+ const found = connector ? CONNECTORS.find((c) => c.id === connector) : null;
175+ if (!ability && !found) return false;
176+ const card = requestCard({
177+ agent: { id: agent.id, handle: agent.handle, display_name: agent.display_name },
178+ workspace,
179+ connector: found ? { id: found.id, name: found.name, available: found.status === "available" && found.scopes.includes("workspace") } : null,
180+ ability: ability && source ? { id: ability.id, label: `${source.name}: ${ability.label}` } : null,
181+ why,
182+ status: "open",
183+ by: null,
184+ });
185+ return (await input.postCard(card)) !== null;
186+ },
187+ refused({ ability, source, rule, call }) {
188+ recordRefusal(env, { agent, workspace, asker: input.asker.username, rule, message: `Refused "${call}": ${source.name}: ${ability.label} is ${rule.split("=")[1] ?? ability.level}.` });
189+ },
190+ };
191+}
192+
193+/** A card's preview of what a call would write: the text of a comment or note. */
194+function bodyOf(args: Record<string, unknown>): string | null {
195+ const text = typeof args.text === "string" ? args.text.trim() : "";
196+ return text ? text.slice(0, 900) : null;
197+}
198+
199+/**
200+ * A refusal in the workspace's audit log, by the agent, naming the rule
201+ * (`integration:linear:comment=never`). Never fails the turn.
202+ */
203+export function recordRefusal(env: Pick<AbilityEnv, "EVENTS">, input: { agent: Row; workspace: string; asker: string | null; rule: string; message: string }): void {
204+ const entry = {
205+ actorKind: "agent",
206+ actor: input.agent.handle,
207+ actorId: input.agent.id,
208+ agent: input.agent.handle,
209+ onBehalfOf: input.asker,
210+ runId: null,
211+ runKind: null,
212+ credentialId: null,
213+ action: "ability_refused",
214+ // The audit log knows the surfaces people use; an agent acts through the site on their behalf.
215+ surface: "web",
216+ workspace: input.workspace.toLowerCase(),
217+ repo: null,
218+ number: null,
219+ gitRef: null,
220+ path: `agents/${input.agent.handle}`,
221+ outcome: "denied",
222+ rule: input.rule,
223+ result: "refused",
224+ message: input.message,
225+ requestId: `req_${crypto.randomUUID()}`,
226+ };
227+ env.EVENTS.fetch("https://service/rpc/audit_record", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ entries: [entry] }) })
228+ .then((response) => {
229+ if (!response.ok) throw new Error(`status ${response.status}`);
230+ })
231+ .catch((error: unknown) => console.error("agents: a refusal was not written to the audit log", String(error)));
232+}
233+
234+/** An allowed or denied call in the audit log, by the person who decided. */
235+export function recordDecision(env: Pick<AbilityEnv, "EVENTS">, input: { by: User; agent: Row; workspace: string; request: AbilityRequestRow; allowed: boolean }): void {
236+ const entry = {
237+ actorKind: "person",
238+ actor: input.by.username,
239+ actorId: input.by.id,
240+ agent: input.agent.handle,
241+ onBehalfOf: null,
242+ runId: null,
243+ runKind: null,
244+ credentialId: null,
245+ action: input.allowed ? "ability_allowed" : "ability_denied",
246+ surface: "web",
247+ workspace: input.workspace.toLowerCase(),
248+ repo: null,
249+ number: null,
250+ gitRef: null,
251+ path: `agents/${input.agent.handle}`,
252+ outcome: "allowed",
253+ rule: `${input.request.ability}=ask`,
254+ result: "ok",
255+ message: `${input.allowed ? "Allowed" : "Denied"} @${input.agent.handle}: ${input.request.summary}`,
256+ requestId: `req_${crypto.randomUUID()}`,
257+ };
258+ env.EVENTS.fetch("https://service/rpc/audit_record", { method: "POST", headers: { "content-type": "application/json" }, body: JSON.stringify({ entries: [entry] }) })
259+ .then((response) => {
260+ if (!response.ok) throw new Error(`status ${response.status}`);
261+ })
262+ .catch((error: unknown) => console.error("agents: a decision was not written to the audit log", String(error)));
263+}
264+
265+/** Pending requests of a session: what it waits on. */
266+export async function pendingRequests(db: D1Database, sessionId: string): Promise<AbilityRequestRow[]> {
267+ const { results } = await db.prepare("SELECT * FROM agent_ability_requests WHERE session_id = ? AND status = 'pending' ORDER BY created_at").bind(sessionId).all<AbilityRequestRow>();
268+ return results;
269+}
270+
271+/**
272+ * Runs an allowed call as `by`, the person who allowed it, with the
273+ * agent's abilities as they are now (a server or a connection may have
274+ * gone since). What the agent is told.
275+ */
276+export async function runAllowed(env: AbilityEnv, request: AbilityRequestRow, agent: Row, definition: Definition, by: User): Promise<{ ok: boolean; message: string }> {
277+ const sections = await abilitiesFor(env, { agent, definition, workspace: request.workspace, asker: by });
278+ const found = findAbility(sections, request.ability);
279+ if (!found) return { ok: false, message: `The ability ${request.ability} is no longer there.` };
280+ if (!found.source.connected) return { ok: false, message: `${found.source.name} is no longer connected.` };
281+ if (found.ability.level === "never") return { ok: false, message: `${found.source.name}: ${found.ability.label} is Never now.` };
282+ let args: Record<string, unknown> = {};
283+ try {
284+ args = JSON.parse(request.input) as Record<string, unknown>;
285+ } catch {
286+ return { ok: false, message: "The call's arguments couldn't be read." };
287+ }
288+ const ports = abilityPorts(env, { agent, workspace: request.workspace, channel_id: request.channel_id, session: null, asker: { id: by.id, username: by.username }, postCard: async () => null });
289+ const reference = String(args.reference ?? "").trim();
290+ try {
291+ switch (request.tool) {
292+ case "lookup_outside": {
293+ const done = await ports.lookup(by, reference);
294+ return done.ok ? { ok: true, message: `${done.value.key}: ${done.value.title}${done.value.status ? ` [${done.value.status}]` : ""}\n${done.value.url}\n\n${done.value.body}`.slice(0, 20_000) } : { ok: false, message: done.message };
295+ }
296+ case "import_outside": {
297+ const repo = String(args.repo ?? "").trim().toLowerCase();
298+ const [namespace, name] = repo.includes("/") ? repo.split("/") : [request.workspace, repo];
299+ if (!namespace || !name) return { ok: false, message: "The call named no repository." };
300+ const done = await ports.import(by, { id: "", namespace, name, isPrivate: true, defaultBranch: "main" }, reference);
301+ return done.ok ? { ok: true, message: `${done.value.created ? "Opened" : "Already imported as"} ${namespace}/${name}#${done.value.number} from ${done.value.item.key}.` } : { ok: false, message: done.message };
302+ }
303+ case "act_outside": {
304+ const action = args.action === "resolve" ? "resolve" : "comment";
305+ const done = await ports.act(by, reference, action, String(args.text ?? "").trim().slice(0, 8000));
306+ return done.ok ? { ok: true, message: `${action === "resolve" ? "Resolved" : "Commented on"} ${done.value.key} (${done.value.url}).` } : { ok: false, message: done.message };
307+ }
308+ default: {
309+ // An MCP tool: `<server>__<tool>`.
310+ const [, serverId, ...rest] = request.ability.split(":");
311+ const server = (definition.abilities.mcp_servers ?? []).find((s: McpServer) => s.id === serverId);
312+ const tool = server?.tools.find((t) => t.name === rest.join(":"));
313+ if (!server || !tool) return { ok: false, message: "That MCP tool is no longer there." };
314+ const done = await ports.mcp(server, tool, args);
315+ return done.ok ? { ok: true, message: done.value.slice(0, 20_000) } : { ok: false, message: done.message };
316+ }
317+ }
318+ } catch (error) {
319+ return { ok: false, message: `It failed: ${String(error).slice(0, 200)}` };
320+ }
321+}
322+
323+/** Tells the owners (and whoever asked, for a request on their behalf) that a Request card was pressed. */
324+export async function tellOwnersOfRequest(env: AbilityEnv, input: { workspace: string; by: User; title: string; body: string; href: string; id: string }): Promise<void> {
325+ if (!env.NOTIFY) return;
326+ const members = await identityClient(env.IDENTITY)
327+ .listMembers(input.workspace, input.by)
328+ .catch(() => null);
329+ if (!members?.ok) return;
330+ const notify = notifyClient(env.NOTIFY);
331+ const owners = members.value.filter((member) => member.role === "owner").slice(0, 20);
332+ await Promise.all(
333+ owners.map((owner) =>
334+ notify
335+ .notify(
336+ { username: owner.username },
337+ {
338+ id: `ability-request:${input.id}:${owner.username}`,
339+ kind: "approval",
340+ workspace: input.workspace,
341+ title: input.title,
342+ body: input.body,
343+ href: input.href,
344+ actor: { kind: "user", id: input.by.id, name: input.by.username, avatar: input.by.avatar ?? null, avatar_seed: null },
345+ created_at: iso(),
346+ },
347+ )
348+ .catch(() => undefined),
349+ ),
350+ );
351+}
352+
353+/** Posts a card in a conversation as the agent; its message id, or null. */
354+export async function postCardAsAgent(env: Pick<AbilityEnv, "CHAT">, workspace: string, channelId: string, agentId: string, card: MessageCard, threadRoot: string | null, askedBy: string | null): Promise<string | null> {
355+ const posted = await chatClient(env.CHAT)
356+ .postAsAgent(workspace, channelId, agentId, { body: "", card, thread_root: threadRoot, asked_by: askedBy })
357+ .catch(() => null);
358+ return posted?.ok ? posted.value.id : null;
359+}
+96−1
22 * How agents' cards look, and what they offer, by state: pure, so it is
33 * tested on its own (cards.ts acts on them, sessions.ts posts them).
44 */
5−import type { MessageCard } from "@g1t/contracts";
5+import type { AbilityLevel, MessageCard } from "@g1t/contracts";
66
7+import { ABILITY_LEVEL_LABELS } from "../../../packages/contracts/src/abilities.ts";
8+import type { AbilityRequestRow } from "./abilities.ts";
79 import type { DraftRow } from "./cards.ts";
810
911 /** A draft issue's card: what it would file, and the buttons. */
5153 };
5254 }
5355
56+/**
57+ * An Ask-first card (docs.g1t.sh/guides/agent-abilities/, "Ask first"):
58+ * what the agent wants to do, for whom, under which rule, with Allow and
59+ * Deny; then what came of it.
60+ */
61+export function abilityCard(
62+ request: Pick<AbilityRequestRow, "id" | "summary" | "status" | "decided_by" | "result" | "ability">,
63+ about: { agent: string; asker: string | null; rule: string; level: AbilityLevel; note: string | null; body: string | null },
64+): MessageCard {
65+ const who = about.asker ? `@${about.asker}` : "the person who asked";
66+ const fields = [
67+ { label: "Ability", value: about.rule },
68+ { label: "Rule", value: ABILITY_LEVEL_LABELS[about.level] },
69+ ];
70+ const base = { kind: "ability", title: request.summary, href: null, fields, owner: "agents" as const, ref: request.id };
71+ switch (request.status) {
72+ case "allowed":
73+ return { ...base, detail: `Allowed by @${request.decided_by ?? "someone"}. ${request.result ?? ""}`.trim(), state: "Done", body: about.body };
74+ case "failed":
75+ return { ...base, detail: `Allowed by @${request.decided_by ?? "someone"}, but it didn't work: ${request.result ?? "no reason given"}`, state: "Failed", body: about.body };
76+ case "denied":
77+ return { ...base, detail: `Denied by @${request.decided_by ?? "someone"}.`, state: "Denied" };
78+ default:
79+ return {
80+ ...base,
81+ detail: `${about.agent} wants to do this for ${who}.${about.note ? ` ${about.note}` : ""} Allowing runs it as you.`,
82+ state: "Waiting",
83+ body: about.body,
84+ actions: [
85+ { id: "allow", label: "Allow", style: "primary" },
86+ { id: "deny", label: "Deny" },
87+ ],
88+ };
89+ }
90+}
91+
92+/** A Connect card: the ability runs on the asking person's own connection, which they haven't made. One press opens its setup. */
93+export function connectCard(about: { connector: string; agent: string; ability: string; asker: string | null; href: string }): MessageCard {
94+ return {
95+ kind: "connect",
96+ title: `Connect ${about.connector} to let ${about.agent} ${about.ability.toLowerCase()} for you`,
97+ detail: `Runs on ${about.asker ? `@${about.asker}'s` : "your"} own ${about.connector} connection, which isn't set up yet.`,
98+ state: "Not connected",
99+ href: null,
100+ body: `${about.ability} in ${about.connector} uses your own account there, not the workspace's. Connect it, then ask ${about.agent} again.`,
101+ actions: [{ id: "connect", label: `Connect ${about.connector}`, style: "primary", href: about.href }],
102+ owner: null,
103+ ref: null,
104+ };
105+}
106+
107+/**
108+ * A Request card: the agent lacks something, said plainly, with one press
109+ * to ask the workspace's owners (an integration to connect, through the
110+ * Marketplace's requests; an ability to allow, through a notification).
111+ * An owner gets a link to do it instead.
112+ */
113+export function requestCard(about: {
114+ agent: { id: string; handle: string; display_name: string };
115+ workspace: string;
116+ connector: { id: string; name: string; available: boolean } | null;
117+ ability: { id: string; label: string } | null;
118+ why: string;
119+ status: "open" | "asked" | "done";
120+ by: string | null;
121+}): MessageCard {
122+ const need = about.ability ? about.ability.label : (about.connector?.name ?? "something");
123+ const ref = about.ability ? `ability:${about.agent.id}:${about.ability.id}` : `connector:${about.agent.id}:${about.connector?.id ?? ""}`;
124+ const href = about.ability ? `/${about.workspace}/-/agents/${about.agent.handle}/abilities` : `/${about.workspace}/-/marketplace/integrations/${about.connector?.id ?? ""}`;
125+ const title = about.ability ? `${about.agent.display_name} needs an ability: ${need}` : `${about.agent.display_name} needs ${need} connected`;
126+ if (about.status === "asked") {
127+ return { kind: "request", title, detail: `${about.by ? `@${about.by}` : "Someone"} asked the workspace's owners.`, state: "Asked", href: null, fields: [{ label: "Why", value: about.why }], actions: [{ id: "open", label: "Open", href }], owner: "agents", ref };
128+ }
129+ if (about.connector && !about.connector.available) {
130+ return { kind: "request", title, detail: `${about.connector.name} can't be connected to a workspace yet.`, state: "Coming", href: null, fields: [{ label: "Why", value: about.why }], owner: null, ref: null };
131+ }
132+ return {
133+ kind: "request",
134+ title,
135+ detail: about.ability ? `${about.agent.display_name} isn't allowed to ${about.ability.label.toLowerCase()}.` : `${about.connector?.name} isn't connected to this workspace.`,
136+ state: "Needed",
137+ href: null,
138+ body: about.ability ? `An owner changes that on ${about.agent.display_name}'s Abilities tab. Anyone else can ask them here.` : `An owner connects ${about.connector?.name} from the Marketplace. Anyone else can ask them here.`,
139+ fields: [{ label: "Why", value: about.why }],
140+ actions: [
141+ { id: "ask", label: "Ask the owners", style: "primary" },
142+ { id: "open", label: about.ability ? "Open Abilities" : "Open in Marketplace", href },
143+ ],
144+ owner: "agents",
145+ ref,
146+ };
147+}
148+
54149 /** Money as people type it ("5", "$12.50") in micro-dollars, or null. */
55150 export function parseMoney(input: string | null): number | null {
56151 const text = (input ?? "").trim().replace(/^\$/, "").replace(/,/g, "");
+136−2
1414 * - **File issue:** whoever presses it files it as themselves, and only if
1515 * they can read the repository; the agent files nothing.
1616 * - **Discard:** whoever asked for it, or an owner.
17+ * - **Allow, deny** (an Ask-first card, abilities.ts): whoever the agent
18+ * acts for, or an owner. Allowing runs the call as the person who
19+ * pressed it, and the agent hears the result; a session goes on.
20+ * - **Ask the owners** (a Request card): anyone; an integration becomes a
21+ * Marketplace install request, an ability a notification to the owners.
1722 */
1823 import {
1924 type AgentCardAction,
2328 type User,
2429 chatClient,
2530 fail,
31+ identityClient,
2632 newId,
2733 ok,
2834 reposClient,
2935 workClient,
3036 } from "@g1t/contracts";
3137
38+import { type AbilityRequestRow, abilitiesPath, recordDecision, runAllowed, tellOwnersOfRequest } from "./abilities.ts";
3239 import { canManage } from "./access.ts";
33−import { draftCard, parseMoney } from "./card-views.ts";
40+import { abilityCard, draftCard, parseMoney, requestCard } from "./card-views.ts";
41+import { findAbility, resolveAbilities } from "../../../packages/contracts/src/abilities.ts";
42+import { CONNECTORS } from "../../../packages/contracts/src/connectors.ts";
43+import { findListing, openRequest } from "./installs.ts";
44+import { type Row, definitionOf, isPersonal, periods, selectAgents } from "./store.ts";
3445
3546 export { draftCard, parseMoney } from "./card-views.ts";
3647 import { dollars } from "./money.ts";
37−import { type SessionEnv, approve, sessionRow, steer, stop } from "./sessions.ts";
48+import { type SessionEnv, approve, pushInbox, resumeAfterDecision, sessionRow, steer, stop } from "./sessions.ts";
3849
3950 export type DraftRow = {
4051 id: string;
8798 if (!viewer || !a.card?.ref) return fail("invalid", "No such card.");
8899 if (a.card.kind === "session") return sessionAction(env, a, viewer);
89100 if (a.card.kind === "draft_issue") return draftAction(env, a, viewer);
101+ if (a.card.kind === "ability") return abilityAction(env, a, viewer);
102+ if (a.card.kind === "request") return requestAction(env, a, viewer);
103+ return fail("invalid", "That card has no such action.");
104+}
105+
106+/** The agent a card belongs to, by id. */
107+async function agentById(db: D1Database, id: string): Promise<Row | null> {
108+ return db
109+ .prepare(selectAgents("a.id = ?3"))
110+ .bind(...periods(new Date()), id)
111+ .first<Row>();
112+}
113+
114+/** Allow or deny an Ask-first call (abilities.ts): whoever the agent acts for, or an owner. */
115+async function abilityAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> {
116+ const db = env.DB;
117+ const request = await db.prepare("SELECT * FROM agent_ability_requests WHERE id = ?").bind(a.card.ref).first<AbilityRequestRow>();
118+ if (!request || request.workspace !== a.workspace.toLowerCase() || request.channel_id !== a.channel_id) return fail("not_found", "No such request.");
119+ if (a.action_id !== "allow" && a.action_id !== "deny") return fail("invalid", "That card has no such action.");
120+ if (request.status !== "pending") return no(request.status === "denied" ? "It was denied." : "It was already answered.");
121+ if (request.asked_by !== viewer.id && !canManage(viewer, a.workspace)) return no("Only whoever the agent is working for, or an owner, can answer this.");
122+ const agent = await agentById(db, request.agent_id);
123+ if (!agent) return fail("not_found", "The agent is gone.");
124+ const definition = definitionOf(agent);
125+ const sections = resolveAbilities({ connectors: CONNECTORS, abilities: definition.abilities, autonomy: definition.autonomy, connected: [request.ability.split(":")[1] ?? ""], personal: isPersonal(agent) });
126+ const found = findAbility(sections, request.ability);
127+ const about = {
128+ agent: agent.display_name,
129+ asker: await usernameOf(env, request.asked_by),
130+ rule: found ? `${found.source.name}: ${found.ability.label}` : request.ability,
131+ level: found?.ability.level ?? ("ask" as const),
132+ note: null,
133+ body: bodyOf(request.input),
134+ };
135+ const now = new Date().toISOString();
136+ const update = async (row: AbilityRequestRow) => {
137+ if (!row.message_id) return;
138+ await chatClient(env.CHAT)
139+ .updateAsAgent(row.workspace, row.channel_id, row.agent_id, row.message_id, { card: abilityCard(row, about) })
140+ .catch((error: unknown) => console.error("agents: an ability card was not updated", row.id, String(error)));
141+ };
142+ // Claimed first, so two presses never run it twice.
143+ const claimed = await db.prepare("UPDATE agent_ability_requests SET status = ?, decided_by = ?, decided_at = ?, updated_at = ? WHERE id = ? AND status = 'pending'").bind(a.action_id === "allow" ? "running" : "denied", viewer.username, now, now, request.id).run();
144+ if (!claimed.meta.changes) return no("Someone got there first.");
145+ recordDecision(env, { by: viewer, agent, workspace: a.workspace, request, allowed: a.action_id === "allow" });
146+ if (a.action_id === "deny") {
147+ const denied = { ...request, status: "denied", decided_by: viewer.username, decided_at: now };
148+ await update(denied);
149+ await resumeAfterDecision(env, request, `@${viewer.username} denied: ${request.summary}. Don't try another way; say so.`);
150+ return done("Denied. It won't be done.");
151+ }
152+ const ran = await runAllowed(env, request, agent, definition, viewer);
153+ const status = ran.ok ? "allowed" : "failed";
154+ await db.prepare("UPDATE agent_ability_requests SET status = ?, result = ?, updated_at = ? WHERE id = ?").bind(status, ran.message.slice(0, 20_000), new Date().toISOString(), request.id).run();
155+ const fresh = { ...request, status, decided_by: viewer.username, decided_at: now, result: ran.message.slice(0, 300) };
156+ await update(fresh);
157+ await resumeAfterDecision(env, request, ran.ok ? `@${viewer.username} allowed "${request.summary}", and it ran:\n${ran.message}` : `@${viewer.username} allowed "${request.summary}", but it didn't work: ${ran.message}`);
158+ return ran.ok ? done(`Allowed. ${ran.message.slice(0, 200)}`) : no(`Allowed, but it didn't work: ${ran.message.slice(0, 300)}`);
159+}
160+
161+/** The text a call would write, for the card. */
162+function bodyOf(input: string): string | null {
163+ try {
164+ const args = JSON.parse(input) as { text?: unknown };
165+ return typeof args.text === "string" && args.text.trim() ? args.text.trim().slice(0, 900) : null;
166+ } catch {
167+ return null;
168+ }
169+}
170+
171+async function usernameOf(env: SessionEnv, id: string | null): Promise<string | null> {
172+ if (!id) return null;
173+ const [user] = await identityClient(env.IDENTITY)
174+ .usersForAudience([id])
175+ .catch(() => [] as User[]);
176+ return user?.username ?? null;
177+}
178+
179+/**
180+ * Ask the owners, from a Request card: an integration becomes a Marketplace
181+ * install request (the same one the Marketplace opens); an ability, a
182+ * notification to the owners with a link to the agent's Abilities tab.
183+ */
184+async function requestAction(env: SessionEnv, a: AgentCardAction, viewer: User): Promise<Result<CardActionResult>> {
185+ if (a.action_id !== "ask") return fail("invalid", "That card has no such action.");
186+ const ref = a.card.ref ?? "";
187+ const [kind, agentId, ...rest] = ref.split(":");
188+ const agent = agentId ? await agentById(env.DB, agentId) : null;
189+ if (!agent) return fail("not_found", "No such card.");
190+ const slug = a.workspace.toLowerCase();
191+ const update = async (card: MessageCard) => {
192+ await chatClient(env.CHAT)
193+ .updateAsAgent(slug, a.channel_id, agent.id, a.message_id, { card })
194+ .catch((error: unknown) => console.error("agents: a request card was not updated", a.message_id, String(error)));
195+ };
196+ const who = { id: agent.id, handle: agent.handle, display_name: agent.display_name };
197+ if (kind === "connector") {
198+ const connector = CONNECTORS.find((c) => c.id === rest[0]);
199+ if (!connector) return fail("not_found", "No such integration.");
200+ if (canManage(viewer, slug)) return no(`You're an owner: connect ${connector.name} from the Marketplace.`);
201+ const listing = findListing(`integration:${connector.id}`);
202+ if (!listing) return no(`${connector.name} can't be connected to a workspace yet.`);
203+ const why = `${agent.display_name} needs it for @${viewer.username}.`;
204+ const opened = await openRequest(env.DB, agent.workspace_id, newId("ins"), listing, viewer, why);
205+ if (!opened.ok && opened.error.code !== "conflict") return no(opened.error.message);
206+ if (opened.ok) {
207+ await tellOwnersOfRequest(env, { workspace: slug, by: viewer, title: `@${viewer.username} asks you to add ${connector.name}`, body: why, href: `/${slug}/-/marketplace/requests`, id: opened.value.id });
208+ }
209+ await update(requestCard({ agent: who, workspace: slug, connector: { id: connector.id, name: connector.name, available: true }, ability: null, why, status: "asked", by: viewer.username }));
210+ return done(opened.ok ? "Asked. The owners have your request." : "You'd already asked; the owners have it.");
211+ }
212+ if (kind === "ability") {
213+ const abilityId = rest.join(":");
214+ const definition = definitionOf(agent);
215+ const sections = resolveAbilities({ connectors: CONNECTORS, abilities: definition.abilities, autonomy: definition.autonomy, connected: CONNECTORS.map((c) => c.id), personal: isPersonal(agent) });
216+ const found = findAbility(sections, abilityId);
217+ const label = found ? `${found.source.name}: ${found.ability.label}` : abilityId;
218+ if (canManage(viewer, slug)) return no(`You're an owner: allow it on ${agent.display_name}'s Abilities tab.`);
219+ const why = `${agent.display_name} needs it for @${viewer.username}.`;
220+ await tellOwnersOfRequest(env, { workspace: slug, by: viewer, title: `@${viewer.username} asks you to let ${agent.display_name} ${found ? found.ability.label.toLowerCase() : "do more"}`, body: `${label}. ${why}`, href: abilitiesPath(slug, agent.handle), id: `${agent.id}:${abilityId}:${viewer.id}` });
221+ await update(requestCard({ agent: who, workspace: slug, connector: null, ability: { id: abilityId, label }, why, status: "asked", by: viewer.username }));
222+ return done("Asked. The owners have been told.");
223+ }
90224 return fail("invalid", "That card has no such action.");
91225 }
92226
+89−2
33 * set, and how a stored row reads. Pure, so it is tested on its own.
44 */
55 import type {
6+ AbilityLevel,
7+ AbilitySetting,
8+ AgentAbilities,
69 AgentAutonomy,
710 AgentBudget,
811 AgentRouting,
12+ McpServer,
913 NewWorkspaceAgent,
1014 PersonalityPreset,
1115 AgentFaces,
1216 SubagentDef,
1317 } from "@g1t/contracts";
18+import { ABILITY_LEVELS, EMPTY_ABILITIES, MAX_MCP_SERVERS, integrationAbilities, maxLevel, mcpAbility, withinLevel } from "../../../packages/contracts/src/abilities.ts";
1419 import { FOUNDATIONAL_SKILL_IDS } from "../../../packages/contracts/src/skills.ts";
1520
1621 import { checkHandle } from "./handle.ts";
6166 reading: string[];
6267 /** Foundational skills turned off for it, by id (@g1t/contracts skills.ts). */
6368 skills_off: string[];
69+ /** Its abilities' levels and credentials, and its MCP servers (@g1t/contracts abilities.ts). */
70+ abilities: AgentAbilities;
6471 };
6572
6673 /**
164171 return { ok: true, value: next as AgentAutonomy };
165172 }
166173
174+/** The most abilities with a choice of their own one agent keeps. */
175+const MAX_ABILITY_SETTINGS = 400;
176+
177+/**
178+ * The abilities' settings, checked against what the agent can have: an
179+ * integration ability the catalog knows, or a tool of one of its MCP
180+ * servers, each at a level its kind allows. g1t's own abilities keep their
181+ * choices in `autonomy`, so nothing is kept for them here.
182+ */
183+function abilitiesOf(base: AgentAbilities, given: unknown, servers: McpServer[]): Checked<AgentAbilities> {
184+ if (given === undefined || given === null) return { ok: true, value: { ...base, mcp_servers: servers } };
185+ if (typeof given !== "object" || Array.isArray(given)) return bad("Abilities are an object.");
186+ const settings = (given as { settings?: unknown }).settings;
187+ if (settings === undefined) return { ok: true, value: { ...base, mcp_servers: servers } };
188+ if (!settings || typeof settings !== "object" || Array.isArray(settings)) return bad("Abilities' settings are an object by ability id.");
189+ const entries = Object.entries(settings as Record<string, unknown>);
190+ if (entries.length > MAX_ABILITY_SETTINGS) return bad(`At most ${MAX_ABILITY_SETTINGS} abilities keep a setting.`);
191+ const next: Record<string, AbilitySetting> = {};
192+ for (const [id, raw] of entries) {
193+ const def = abilityDef(id, servers);
194+ if (!def) return bad(`There is no ability called ${id.slice(0, 60)} for this agent.`);
195+ if (!raw || typeof raw !== "object") return bad(`${id}'s setting is an object.`);
196+ const setting = raw as Record<string, unknown>;
197+ const kept: AbilitySetting = {};
198+ if (setting.level !== undefined && setting.level !== null) {
199+ if (typeof setting.level !== "string" || !ABILITY_LEVELS.includes(setting.level as AbilityLevel)) return bad(`${id}'s level is alone, asked, ask or never.`);
200+ const level = setting.level as AbilityLevel;
201+ const max = maxLevel(def.kind);
202+ if (!withinLevel(level, max)) return bad(`${def.label} can't be set above ${max === "ask" ? "Ask first" : max}: it ${def.kind === "restricted" ? "is a purchase, a credential or a permission change" : "isn't allowed that freely"}.`);
203+ kept.level = level;
204+ }
205+ if (setting.credentials !== undefined && setting.credentials !== null) {
206+ if (def.group !== "integration") return bad(`Only an integration's abilities say whose connection they run on.`);
207+ if (setting.credentials !== "workspace" && setting.credentials !== "asker") return bad(`${id}'s credentials are workspace or asker.`);
208+ kept.credentials = setting.credentials;
209+ }
210+ if (Object.keys(kept).length) next[id] = kept;
211+ }
212+ // In id order, so the same choices always read the same.
213+ return { ok: true, value: { settings: Object.fromEntries(Object.entries(next).sort(([a], [b]) => a.localeCompare(b))), mcp_servers: servers } };
214+}
215+
216+/** The ability `id` names, among the catalog's integration abilities and the agent's MCP servers' tools; null when there is none. */
217+function abilityDef(id: string, servers: McpServer[]) {
218+ const parts = id.split(":");
219+ if (parts[0] === "integration" && parts.length === 3) return integrationAbilities(parts[1]!).find((def) => def.id === id) ?? null;
220+ if (parts[0] === "mcp" && parts.length >= 3) {
221+ const server = servers.find((s) => s.id === parts[1]);
222+ const tool = server?.tools.find((t) => `mcp:${server.id}:${t.name}` === id);
223+ return server && tool ? mcpAbility(server, tool) : null;
224+ }
225+ return null;
226+}
227+
228+/** The MCP servers as kept: at most `MAX_MCP_SERVERS`, names unique. Only the service's own MCP calls change them. */
229+function mcpServersOf(given: unknown): Checked<McpServer[]> {
230+ if (!Array.isArray(given)) return bad("MCP servers are a list.");
231+ if (given.length > MAX_MCP_SERVERS) return bad(`An agent has at most ${MAX_MCP_SERVERS} MCP servers.`);
232+ const names = new Set<string>();
233+ for (const server of given as McpServer[]) {
234+ if (!server || typeof server !== "object" || typeof server.id !== "string" || typeof server.name !== "string" || typeof server.url !== "string" || !Array.isArray(server.tools)) return bad("An MCP server has an id, a name, a url and tools.");
235+ if (names.has(server.name)) return bad(`Two servers are called ${server.name}.`);
236+ names.add(server.name);
237+ }
238+ return { ok: true, value: given as McpServer[] };
239+}
240+
167241 function responsibilitiesOf(given: unknown): Checked<string[]> {
168242 if (!Array.isArray(given) || given.some((d) => typeof d !== "string")) return bad("Responsibilities are a list of short duties.");
169243 const duties = [...new Set((given as string[]).map((d) => d.trim()).filter(Boolean))];
228302 base: Definition | null,
229303 changes: Partial<NewWorkspaceAgent>,
230304 templates: string[],
231− options: { builtin?: boolean } = {},
305+ options: { builtin?: boolean; mcp_servers?: McpServer[] } = {},
232306 ): Checked<Definition> {
233307 if (!changes || typeof changes !== "object") return bad("Send the agent's fields.");
234308 const creating = base === null;
251325 faces: "internal",
252326 reading: [],
253327 skills_off: [],
328+ abilities: EMPTY_ABILITIES,
254329 };
255− const next: Definition = { ...from, skills_off: from.skills_off ?? [] };
330+ const next: Definition = { ...from, skills_off: from.skills_off ?? [], abilities: from.abilities ?? EMPTY_ABILITIES };
256331 // Whether the role was made from the title, so it follows it.
257332 const roleDerived = !from.role || from.role === roleOf(from);
258333 if (creating || changes.handle !== undefined) {
337412 // In the skills' own order, so the same choice always reads the same.
338413 next.skills_off = [...FOUNDATIONAL_SKILL_IDS.filter((id) => ids.has(id)), ...library.sort()];
339414 }
415+ // MCP servers change only through the service's own calls (index.ts), which pass them here; a change never carries them.
416+ let servers = next.abilities.mcp_servers ?? [];
417+ if (options.mcp_servers) {
418+ const checked = mcpServersOf(options.mcp_servers);
419+ if (!checked.ok) return checked;
420+ servers = checked.value;
421+ }
422+ const abilities = abilitiesOf(next.abilities, changes.abilities, servers);
423+ if (!abilities.ok) return abilities;
424+ // A setting for a tool of a server that is gone goes with it.
425+ abilities.value.settings = Object.fromEntries(Object.entries(abilities.value.settings).filter(([id]) => abilityDef(id, servers)));
426+ next.abilities = abilities.value;
340427 if (changes.faces !== undefined) {
341428 if (changes.faces === "customers") return bad("Customer-facing agents aren't available yet.");
342429 if (changes.faces !== "internal") return bad("An agent faces internal: the workspace's own people.");
+109−0
2121 type ExtensionInstall,
2222 type InstallRequest,
2323 type InstallRequests,
24+ type McpServer,
2425 type AgentEffortCosts,
2526 type AgentRecommendation,
2627 type AgentRecommendations,
4748 import { DRAFT_OUTPUT_TOKENS, draftSystem, jsonIn, proposalFrom, redraftFrom, redraftSystem, startingBudget, trySystem, tryTurns, wordsOf } from "./builder.ts";
4849 import { callBuilder } from "./builder-call.ts";
4950 import { type Definition, applyChanges } from "./definition.ts";
51+import { listMcpTools } from "./mcp-client.ts";
52+import { MAX_MCP_SERVERS, checkMcpName, checkMcpUrl } from "../../../packages/contracts/src/abilities.ts";
5053 import { builtinChanges } from "./orchestrator.ts";
5154 import type { Desk } from "./desk.ts";
5255 import type { ReplyEnv } from "./reply.ts";
401404 * memory and direct messages kept with it (owners can't read a member's
402405 * memory to choose from it, so none is carried).
403406 */
407+ // ── MCP servers (docs.g1t.sh/guides/agent-abilities/, "MCP servers") ─────
408+
409+ /** The agent, if the viewer may add servers to it: owners only, a workspace agent or a personal one alike. */
410+ private async forMcp(a: { workspace: string; handle: string; viewer: User | null }): Promise<Result<{ row: Row; workspaceId: string }>> {
411+ const found = await this.visible(a?.workspace ?? "", a?.viewer ?? null, a?.handle);
412+ if (!found.ok) return found;
413+ if (!canManage(a.viewer, a.workspace)) return fail("forbidden", "Only the workspace's owners add MCP servers to an agent.");
414+ return ok({ row: found.value.row, workspaceId: found.value.workspaceId });
415+ }
416+
417+ /** Saves `definition` as the agent's next version, from the version read; what the agent is now. */
418+ private async saveVersion(row: Row, definition: Definition, by: User, workspace: string, what: string): Promise<Result<WorkspaceAgent>> {
419+ const now = new Date().toISOString();
420+ const version = row.version + 1;
421+ const [updated] = await this.db.batch([
422+ updateAgent(this.db, row.id, row.version, definition, { version, updated_at: now }),
423+ this.db
424+ .prepare(
425+ `INSERT INTO agent_versions (agent_id, version, definition, changed_by, created_at)
426+ SELECT ?1, ?2, ?3, ?4, ?5 WHERE EXISTS (SELECT 1 FROM agents WHERE id = ?1 AND version = ?2 AND updated_at = ?5)`,
427+ )
428+ .bind(row.id, version, JSON.stringify(definition), by.username, now),
429+ ]);
430+ if (!updated.meta.changes) return fail("conflict", `@${row.handle} was changed meanwhile. Reload it and try again.`);
431+ this.audit(by, workspace, "update_agent", row.handle, `${what} (version ${version})`, "agents", "owner");
432+ const saved = await this.row(row.workspace_id, row.handle);
433+ return ok(toAgent(saved!, new Date()));
434+ }
435+
436+ /**
437+ * Adds an MCP server to an agent: the name and address are checked, its
438+ * tools are listed from it now, and the agent gets a new version with
439+ * the server and its tools as abilities (each a write unless the server
440+ * says it only reads). Owners only.
441+ */
442+ async addMcpServer(a: { workspace: string; handle: string; viewer: User | null; name: unknown; url: unknown }): Promise<Result<McpServer>> {
443+ const found = await this.forMcp(a);
444+ if (!found.ok) return found;
445+ const { row } = found.value;
446+ const name = checkMcpName(a.name);
447+ if (!name.ok) return fail("invalid", name.message);
448+ const url = checkMcpUrl(a.url);
449+ if (!url.ok) return fail("invalid", url.message);
450+ const before = definitionOf(row);
451+ const servers = before.abilities.mcp_servers ?? [];
452+ if (servers.length >= MAX_MCP_SERVERS) return fail("limit", `An agent has at most ${MAX_MCP_SERVERS} MCP servers.`);
453+ if (servers.some((server) => server.name === name.value)) return fail("conflict", `@${row.handle} already has a server called ${name.value}.`);
454+ if (servers.some((server) => server.url === url.value)) return fail("conflict", `@${row.handle} already has that server.`);
455+ const listed = await listMcpTools(url.value);
456+ const now = new Date().toISOString();
457+ const server: McpServer = {
458+ id: newId("mcp"),
459+ name: name.value,
460+ url: url.value,
461+ tools: listed.ok ? listed.tools : [],
462+ added_by: a.viewer!.username,
463+ added_at: now,
464+ checked_at: now,
465+ problem: listed.ok ? null : listed.message,
466+ };
467+ const checked = applyChanges(before, {}, TEMPLATE_IDS, { builtin: !!row.builtin, mcp_servers: [...servers, server] });
468+ if (!checked.ok) return fail("invalid", checked.message);
469+ const saved = await this.saveVersion(row, checked.value, a.viewer!, a.workspace, `Added the MCP server ${server.name} (${server.tools.length} tools) to @${row.handle}`);
470+ return saved.ok ? ok(server) : saved;
471+ }
472+
473+ /** Takes an MCP server off an agent, with its abilities' settings: a new version. Owners only. */
474+ async removeMcpServer(a: { workspace: string; handle: string; viewer: User | null; id: unknown }): Promise<Result<null>> {
475+ const found = await this.forMcp(a);
476+ if (!found.ok) return found;
477+ const { row } = found.value;
478+ const before = definitionOf(row);
479+ const servers = before.abilities.mcp_servers ?? [];
480+ const server = servers.find((s) => s.id === a.id);
481+ if (!server) return fail("not_found", "There is no such server on this agent.");
482+ const checked = applyChanges(before, {}, TEMPLATE_IDS, { builtin: !!row.builtin, mcp_servers: servers.filter((s) => s.id !== server.id) });
483+ if (!checked.ok) return fail("invalid", checked.message);
484+ const saved = await this.saveVersion(row, checked.value, a.viewer!, a.workspace, `Removed the MCP server ${server.name} from @${row.handle}`);
485+ return saved.ok ? ok(null) : saved;
486+ }
487+
488+ /** Lists a server's tools again; a new version when they changed. Owners only. */
489+ async refreshMcpServer(a: { workspace: string; handle: string; viewer: User | null; id: unknown }): Promise<Result<McpServer>> {
490+ const found = await this.forMcp(a);
491+ if (!found.ok) return found;
492+ const { row } = found.value;
493+ const before = definitionOf(row);
494+ const servers = before.abilities.mcp_servers ?? [];
495+ const server = servers.find((s) => s.id === a.id);
496+ if (!server) return fail("not_found", "There is no such server on this agent.");
497+ const listed = await listMcpTools(server.url);
498+ const now = new Date().toISOString();
499+ const fresh: McpServer = { ...server, tools: listed.ok ? listed.tools : server.tools, checked_at: now, problem: listed.ok ? null : listed.message };
500+ if (JSON.stringify(fresh.tools) === JSON.stringify(server.tools) && fresh.problem === server.problem) return ok(fresh);
501+ const checked = applyChanges(before, {}, TEMPLATE_IDS, { builtin: !!row.builtin, mcp_servers: servers.map((s) => (s.id === server.id ? fresh : s)) });
502+ if (!checked.ok) return fail("invalid", checked.message);
503+ const saved = await this.saveVersion(row, checked.value, a.viewer!, a.workspace, `Listed the MCP server ${server.name}'s tools again for @${row.handle} (${fresh.tools.length} tools)`);
504+ return saved.ok ? ok(fresh) : saved;
505+ }
506+
404507 async promote(a: { workspace: string; handle: string; viewer: User | null }): Promise<Result<WorkspaceAgent>> {
405508 const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
406509 if (!managed.ok) return managed.error.code === "forbidden" ? fail("forbidden", "Only the workspace's owners promote personal agents.") : managed;
879982 return Response.json(await service.tryDraft(args));
880983 case "redraft":
881984 return Response.json(await service.redraft(args));
985+ case "add_mcp_server":
986+ return Response.json(await service.addMcpServer(args));
987+ case "remove_mcp_server":
988+ return Response.json(await service.removeMcpServer(args));
989+ case "refresh_mcp_server":
990+ return Response.json(await service.refreshMcpServer(args));
882991 case "promote":
883992 return Response.json(await service.promote(args));
884993 case "builtin":
+142−0
1+/**
2+ * A small client for MCP servers over Streamable HTTP
3+ * (docs.g1t.sh/guides/agent-abilities/, "MCP servers"): lists a server's
4+ * tools when an owner adds it, and calls one when an agent's ability
5+ * allows. JSON-RPC, one request per call, a fresh session each time; an
6+ * answer may come back as JSON or as a server-sent event stream. No
7+ * sign-in: servers that need a key aren't supported yet, and the docs say
8+ * so. Pure apart from `fetch`, so it is tested with a fake.
9+ */
10+import type { McpTool } from "@g1t/contracts";
11+
12+import { MAX_MCP_TOOLS } from "../../../packages/contracts/src/abilities.ts";
13+
14+const PROTOCOL = "2025-06-18";
15+/** How long one request to a server may take. */
16+const TIMEOUT_MS = 15_000;
17+/** The most of a tool's answer an agent is given. */
18+const MAX_TEXT = 40_000;
19+
20+export type Fetch = (url: string, init: RequestInit) => Promise<Response>;
21+
22+type Rpc = { id?: number | string | null; result?: unknown; error?: { code?: number; message?: string } };
23+
24+/** One JSON-RPC exchange with the server; the body of the matching answer, or why there is none. */
25+async function exchange(fetchFn: Fetch, url: string, session: string | null, body: Record<string, unknown>, expectAnswer: boolean): Promise<{ ok: true; answer: Rpc | null; session: string | null } | { ok: false; message: string }> {
26+ let response: Response;
27+ try {
28+ response = await fetchFn(url, {
29+ method: "POST",
30+ headers: {
31+ "content-type": "application/json",
32+ accept: "application/json, text/event-stream",
33+ "mcp-protocol-version": PROTOCOL,
34+ ...(session ? { "mcp-session-id": session } : {}),
35+ },
36+ body: JSON.stringify({ jsonrpc: "2.0", ...body }),
37+ signal: AbortSignal.timeout(TIMEOUT_MS),
38+ redirect: "error",
39+ });
40+ } catch (error) {
41+ return { ok: false, message: `The server couldn't be reached (${String(error).slice(0, 120)}).` };
42+ }
43+ const next = response.headers.get("mcp-session-id") ?? session;
44+ if (!expectAnswer) return { ok: true, answer: null, session: next };
45+ if (!response.ok) return { ok: false, message: `The server answered ${response.status}.` };
46+ const type = (response.headers.get("content-type") ?? "").toLowerCase();
47+ let text: string;
48+ try {
49+ text = await response.text();
50+ } catch {
51+ return { ok: false, message: "The server's answer couldn't be read." };
52+ }
53+ const wanted = body.id;
54+ const candidates: Rpc[] = [];
55+ if (type.includes("text/event-stream")) {
56+ for (const event of text.split(/\n\n+/)) {
57+ const data = event
58+ .split("\n")
59+ .filter((line) => line.startsWith("data:"))
60+ .map((line) => line.slice(5).trim())
61+ .join("\n");
62+ if (!data) continue;
63+ try {
64+ const parsed = JSON.parse(data) as Rpc | Rpc[];
65+ candidates.push(...(Array.isArray(parsed) ? parsed : [parsed]));
66+ } catch {
67+ // Not JSON: a keep-alive or a comment.
68+ }
69+ }
70+ } else {
71+ try {
72+ const parsed = JSON.parse(text) as Rpc | Rpc[];
73+ candidates.push(...(Array.isArray(parsed) ? parsed : [parsed]));
74+ } catch {
75+ return { ok: false, message: "The server didn't answer with JSON-RPC." };
76+ }
77+ }
78+ const answer = candidates.find((c) => c && typeof c === "object" && c.id === wanted) ?? null;
79+ if (!answer) return { ok: false, message: "The server didn't answer the request." };
80+ return { ok: true, answer, session: next };
81+}
82+
83+/** Opens a session: initialize, then the initialized notification. The session id, if the server gave one. */
84+async function handshake(fetchFn: Fetch, url: string): Promise<{ ok: true; session: string | null } | { ok: false; message: string }> {
85+ const opened = await exchange(
86+ fetchFn,
87+ url,
88+ null,
89+ { id: 1, method: "initialize", params: { protocolVersion: PROTOCOL, capabilities: {}, clientInfo: { name: "g1t-agents", version: "1" } } },
90+ true,
91+ );
92+ if (!opened.ok) return opened;
93+ if (opened.answer?.error) return { ok: false, message: `The server refused to start: ${opened.answer.error.message ?? "no reason given"}.` };
94+ const told = await exchange(fetchFn, url, opened.session, { method: "notifications/initialized" }, false);
95+ return { ok: true, session: told.ok ? told.session : opened.session };
96+}
97+
98+/** A tool as the server lists it, as kept: its kind from `readOnlyHint`, a write when the server doesn't say. */
99+function toolOf(raw: unknown): McpTool | null {
100+ if (!raw || typeof raw !== "object") return null;
101+ const t = raw as { name?: unknown; description?: unknown; inputSchema?: unknown; annotations?: { readOnlyHint?: unknown } };
102+ if (typeof t.name !== "string" || !/^[A-Za-z0-9_.-]{1,64}$/.test(t.name)) return null;
103+ const schema = t.inputSchema && typeof t.inputSchema === "object" && !Array.isArray(t.inputSchema) ? (t.inputSchema as Record<string, unknown>) : { type: "object", properties: {} };
104+ return {
105+ name: t.name,
106+ description: typeof t.description === "string" ? t.description.trim().slice(0, 500) : "",
107+ kind: t.annotations?.readOnlyHint === true ? "read" : "write",
108+ input_schema: JSON.stringify(schema).length > 20_000 ? { type: "object", properties: {} } : schema,
109+ };
110+}
111+
112+/** The server's tools, at most `MAX_MCP_TOOLS`, or why they couldn't be listed. */
113+export async function listMcpTools(url: string, fetchFn: Fetch = (u, init) => fetch(u, init)): Promise<{ ok: true; tools: McpTool[] } | { ok: false; message: string }> {
114+ const opened = await handshake(fetchFn, url);
115+ if (!opened.ok) return opened;
116+ const listed = await exchange(fetchFn, url, opened.session, { id: 2, method: "tools/list", params: {} }, true);
117+ if (!listed.ok) return listed;
118+ if (listed.answer?.error) return { ok: false, message: `The server couldn't list its tools: ${listed.answer.error.message ?? "no reason given"}.` };
119+ const raw = (listed.answer?.result as { tools?: unknown } | undefined)?.tools;
120+ if (!Array.isArray(raw)) return { ok: false, message: "The server listed no tools." };
121+ const tools = raw.map(toolOf).filter((tool): tool is McpTool => tool !== null);
122+ const names = new Set<string>();
123+ return { ok: true, tools: tools.filter((tool) => (names.has(tool.name) ? false : (names.add(tool.name), true))).slice(0, MAX_MCP_TOOLS) };
124+}
125+
126+/** Calls one tool; the text it returned (its text parts joined), or why it didn't work. */
127+export async function callMcpTool(url: string, name: string, args: Record<string, unknown>, fetchFn: Fetch = (u, init) => fetch(u, init)): Promise<{ ok: true; text: string } | { ok: false; message: string }> {
128+ const opened = await handshake(fetchFn, url);
129+ if (!opened.ok) return opened;
130+ const called = await exchange(fetchFn, url, opened.session, { id: 3, method: "tools/call", params: { name, arguments: args } }, true);
131+ if (!called.ok) return called;
132+ if (called.answer?.error) return { ok: false, message: called.answer.error.message ?? "the server refused" };
133+ const result = called.answer?.result as { content?: unknown; isError?: unknown; structuredContent?: unknown } | undefined;
134+ const parts = Array.isArray(result?.content) ? (result!.content as { type?: string; text?: string }[]) : [];
135+ let text = parts
136+ .map((part) => (part?.type === "text" && typeof part.text === "string" ? part.text : part?.type ? `[${part.type} content]` : ""))
137+ .filter(Boolean)
138+ .join("\n");
139+ if (!text && result?.structuredContent !== undefined) text = JSON.stringify(result.structuredContent).slice(0, MAX_TEXT);
140+ if (result?.isError === true) return { ok: false, message: text.slice(0, 500) || "the tool reported an error" };
141+ return { ok: true, text: (text || "(no content)").slice(0, MAX_TEXT) };
142+}
+2−0
1010 */
1111 import type { AgentStatus, ModelTier, NewWorkspaceAgent } from "@g1t/contracts";
1212
13+import { EMPTY_ABILITIES } from "../../../packages/contracts/src/abilities.ts";
1314 import { BUILTIN_AGENT_HANDLE, ORCHESTRATOR_TEMPLATE } from "../../../packages/contracts/src/workspace-agents.ts";
1415 import { type Checked, type Definition, DEFAULT_AUTONOMY, DEFAULT_BUDGET, DEFAULT_CAPACITY, DEFAULT_ROUTING } from "./definition.ts";
1516 import { listOf } from "./teammates.ts";
3839 faces: "internal",
3940 reading: [],
4041 skills_off: [],
42+ abilities: EMPTY_ABILITIES,
4143 };
4244 }
4345
+3−0
7878 handedOffBy?: string | null;
7979 /** The "Your skills" section (skills.ts), for the skills that are on and the tools this turn offers. */
8080 skills?: string | null;
81+ /** The "Your abilities outside g1t" section (abilities.ts): integrations and MCP servers, with the level of each. */
82+ abilities?: string | null;
8183 };
8284
8385 function askerLine(asker: PromptInput["asker"]): string {
140142 ].join("\n"),
141143 ...(input.teams ? [input.teams] : []),
142144 ...(input.skills ? [input.skills] : []),
145+ ...(input.abilities ? [input.abilities] : []),
143146 ...(input.colleagues ? [colleaguesSection(input.colleagues, !!input.session)] : []),
144147 ...(input.recentSessions
145148 ? [
+30−0
1919 */
2020 import { type AgentDelivery, type ServiceBinding, identityClient, newId } from "@g1t/contracts";
2121
22+import { type AbilityEnv, abilitiesFor, abilitiesSection, abilityPorts, saidText } from "./abilities.ts";
23+
2224 import { CHAT_MAX_HOPS } from "../../../packages/contracts/src/chat.ts";
2325 import type { Tokens } from "./budget.ts";
2426 import { handOffPort } from "./handoff.ts";
5961 SEARCH: ServiceBinding;
6062 /** Notifications: a session waiting for more budget. */
6163 NOTIFY?: ServiceBinding;
64+ /** The workspace's connections, for abilities outside g1t (abilities.ts). */
65+ INTEGRATIONS: ServiceBinding;
66+ /** The audit log, for refusals. */
67+ EVENTS: ServiceBinding;
6268 /** Every agent's desk: sessions are worked on their agent's. */
6369 DESKS: DurableObjectNamespace<Desk>;
6470 };
378384 // What colleagues consulted along the way used: billed to this reply.
379385 const consulted = { tokens: NO_TOKENS, cost: 0 };
380386 let toolbox: ToolBox | null = null;
387+ // The "Your abilities outside g1t" section, once the tool box has them.
388+ let abilitiesText: string | null = null;
381389 let place: RecallPlace = { channel_id: delivery.channel_id, kind: delivery.channel_kind === "dm" ? "dm" : "private", people: [delivery.asked_by] };
382390 if (!delivery.hello) {
383391 try {
464472 actions,
465473 );
466474 consult.attach(toolbox);
475+ // Its abilities outside g1t (abilities.ts): offered and enforced by the tool box, for the person it acts for.
476+ if (viewer && !delivery.hello) {
477+ const sections = await abilitiesFor(env as unknown as AbilityEnv, { agent: row, definition, workspace: slug, asker: viewer }).catch(() => null);
478+ if (sections) {
479+ const saidByPeople = saidText([...history].reverse().filter((m) => m.author.kind === "user").slice(0, 3).map((m) => m.body));
480+ toolbox.useAbilities(
481+ sections,
482+ abilityPorts(env as unknown as AbilityEnv, {
483+ agent: row,
484+ workspace: slug,
485+ channel_id: delivery.channel_id,
486+ session: null,
487+ asker: { id: delivery.asked_by, username: askerName },
488+ postCard: (card) => surface.post("", card).catch(() => null),
489+ }),
490+ saidByPeople,
491+ definition.abilities.mcp_servers,
492+ );
493+ abilitiesText = abilitiesSection(sections);
494+ }
495+ }
467496 } catch (error) {
468497 // Without an audience nothing may be read: the reply goes on with this conversation only.
469498 console.error("agents: no audience for a reply, so no tools", row.id, String(error));
501530 conversation: conversationHere,
502531 teams: teamsSection(row.id, teamsHere, now),
503532 canHandOff: !!toolbox?.definitions().some((tool) => tool.name === "hand_off"),
533+ abilities: abilitiesText,
504534 handedOffBy: sender?.handle ?? null,
505535 skills: skillsSection(shelf, toolbox?.definitions().map((tool) => tool.name) ?? []),
506536 }),
+60−1
5353 import { readVersion } from "./skill-library.ts";
5454 import { conversationFrom } from "./surface.ts";
5555 import { type Row, definitionOf, periods } from "./store.ts";
56+import { abilitiesFor, abilitiesSection, abilityPorts, pendingRequests, saidText } from "./abilities.ts";
5657 import { type ActionPorts, type ToolCall, ToolBox } from "./tools.ts";
5758 import { type ModelMessage, SESSION_LIMITS, runTurn } from "./turn.ts";
5859 import { effortOf, effortPlan, higherEffort, isLevel } from "./routing.ts";
7071 IDENTITY: ServiceBinding;
7172 WORK: ServiceBinding;
7273 NOTIFY?: ServiceBinding;
74+ /** The workspace's connections, for abilities outside g1t (abilities.ts). */
75+ INTEGRATIONS: ServiceBinding;
76+ /** The audit log, for refusals. */
77+ EVENTS: ServiceBinding;
7378 DESKS: DurableObjectNamespace<Desk>;
7479 };
7580
805810 } catch (error) {
806811 console.error("agents: no audience for a session step, so no tools", current.id, String(error));
807812 }
813+ // Its abilities outside g1t (abilities.ts), for the person it acts for: offered and enforced by the tool box.
814+ let abilitiesText: string | null = null;
815+ if (toolbox && current.asked_by) {
816+ const askerUser = await identityClient(env.IDENTITY)
817+ .usersForAudience([current.asked_by])
818+ .then((users) => users[0] ?? null)
819+ .catch(() => null);
820+ const sections = askerUser ? await abilitiesFor(env, { agent, definition, workspace: slug, asker: askerUser }).catch(() => null) : null;
821+ if (sections) {
822+ toolbox.useAbilities(
823+ sections,
824+ abilityPorts(env, {
825+ agent,
826+ workspace: slug,
827+ channel_id: current.channel_id,
828+ session: { id: current.id, title: current.title },
829+ asker,
830+ postCard: (card) => postCardInThread(env, current, agent, card),
831+ }),
832+ saidText([current.goal, ...inbox.filter((item) => item.kind === "steer").map((item) => item.body)]),
833+ definition.abilities.mcp_servers,
834+ );
835+ abilitiesText = abilitiesSection(sections);
836+ }
837+ }
808838 // What the workspace's artifacts say about the work: its goal, and whatever arrived for this step.
809839 const asked = [current.goal, ...inbox.map((item) => item.body)].reverse();
810840 const [facts, passages, shelf] = await Promise.all([
853883 session: true,
854884 conversation: here,
855885 skills: skillsSection(shelf, toolbox?.definitions().map((tool) => tool.name) ?? []),
886+ abilities: abilitiesText,
856887 }),
857888 sessionSection(current, current.asked_by_username ? `@${current.asked_by_username}` : "the person who asked", plan.steps),
858889 memorySection(facts),
921952 row = (await sessionRow(db, id))!;
922953 if (row.status === "stopped" || answer.stopped) return finished(env, id);
923954
955+ // A call waiting on an Ask-first card: the session waits with it, and goes on when the card is answered (cards.ts).
956+ const asked = await pendingRequests(db, id).catch(() => []);
957+ if (asked.length) {
958+ if (answer.text) await db.batch([eventStatement(db, id, "text", agent.handle, answer.text)]);
959+ await setStatus(env, row, "needs_approval", `Waiting for an OK: ${asked.map((r) => r.summary).join("; ").slice(0, 200)}.`);
960+ return;
961+ }
924962 const children = await db
925963 .prepare("SELECT COUNT(*) AS n FROM agent_sessions WHERE parent_id = ? AND status IN ('queued','working','waiting','needs_approval')")
926964 .bind(id)
9821020 await refreshCard(env, row);
9831021 }
9841022
985−async function pushInbox(db: D1Database, id: string, item: Inbound): Promise<void> {
1023+export async function pushInbox(db: D1Database, id: string, item: Inbound): Promise<void> {
9861024 const row = await db.prepare("SELECT inbox FROM agent_sessions WHERE id = ?").bind(id).first<{ inbox: string }>();
9871025 const list = json<Inbound[]>(row?.inbox, []);
9881026 list.push(item);
10411079 return fresh;
10421080 }
10431081
1082+/**
1083+ * After an Ask-first card was answered (cards.ts): the session that asked
1084+ * reads the answer at its next step and goes on, if it was waiting for it.
1085+ * A reply (no session) hears nothing more: the card says what happened.
1086+ */
1087+export async function resumeAfterDecision(env: SessionEnv, request: { session_id: string | null; decided_by?: string | null }, body: string): Promise<void> {
1088+ if (!request.session_id) return;
1089+ const db = env.DB;
1090+ const row = await sessionRow(db, request.session_id);
1091+ if (!row || OVER.includes(row.status as AgentSessionStatus)) return;
1092+ await pushInbox(db, row.id, { kind: "child", by: "the Ask-first card", body: body.slice(0, 8000) });
1093+ await db.batch([eventStatement(db, row.id, "note", null, body.slice(0, 2000))]);
1094+ const waiting = await db.prepare("SELECT COUNT(*) AS n FROM agent_ability_requests WHERE session_id = ? AND status = 'pending'").bind(row.id).first<{ n: number }>();
1095+ if (row.status === "needs_approval" && (waiting?.n ?? 0) === 0) {
1096+ await db.prepare("UPDATE agent_sessions SET status = 'queued', status_note = NULL, updated_at = ? WHERE id = ?").bind(iso(), row.id).run();
1097+ const fresh = (await sessionRow(db, row.id))!;
1098+ await refreshCard(env, fresh);
1099+ }
1100+ await wake(env, row.agent_id, row.id);
1101+}
1102+
10441103 /** Tells whoever asked, and the agent's maker, that a session waits for more budget. */
10451104 async function notifyApproval(env: SessionEnv, row: SessionRow, agent: Row): Promise<void> {
10461105 if (!env.NOTIFY) return;
+16−1
22 * Agents as stored in D1, and their spend. Shared by the RPC methods and
33 * the desk.
44 */
5−import type { SubagentDef, WorkspaceAgent } from "@g1t/contracts";
5+import type { AgentAbilities, SubagentDef, WorkspaceAgent } from "@g1t/contracts";
66
77 import { agentStatus, budgetBlock, dayKey, monthKey } from "./budget.ts";
8+import { EMPTY_ABILITIES } from "../../../packages/contracts/src/abilities.ts";
89 import { type Definition, DEFAULT_AUTONOMY, DEFAULT_BUDGET, DEFAULT_ROUTING, legacyRoleOf, PRESETS, readJson } from "./definition.ts";
910
1011 export type Row = {
3637 faces: string | null;
3738 reading?: string | null;
3839 skills_off?: string | null;
40+ /** Its abilities (JSON); missing before abilities. */
41+ abilities?: string | null;
3942 version: number;
4043 /** 1 for the workspace's built-in @g1t. */
4144 builtin: number;
9497 faces: "internal",
9598 reading: readList<string>(row.reading ?? null),
9699 skills_off: readList<string>(row.skills_off ?? null),
100+ abilities: abilitiesRead(row.abilities ?? null),
97101 };
98102 }
99103
104+/** A stored abilities column, read defensively: settings by id and the MCP servers, each a list or an object or nothing. */
105+function abilitiesRead(raw: string | null): AgentAbilities {
106+ const read = readJson<Partial<AgentAbilities>>(raw, {});
107+ return {
108+ settings: read.settings && typeof read.settings === "object" && !Array.isArray(read.settings) ? read.settings : {},
109+ mcp_servers: Array.isArray(read.mcp_servers) ? read.mcp_servers : EMPTY_ABILITIES.mcp_servers,
110+ };
111+}
112+
100113 export function toAgent(row: Row, now: Date): WorkspaceAgent {
101114 const definition = definitionOf(row);
102115 const spent = { month: row.spent_month ?? 0, day: row.spent_day ?? 0 };
175188 "faces",
176189 "reading",
177190 "skills_off",
191+ "abilities",
178192 ] as const;
179193
180194 /** A definition's values, in `DEFINITION_COLUMNS` order. */
198212 d.faces,
199213 JSON.stringify(d.reading ?? []),
200214 JSON.stringify(d.skills_off ?? []),
215+ JSON.stringify(d.abilities ?? EMPTY_ABILITIES),
201216 ];
202217 }
203218
+295−4
1818 *
1919 * Pure apart from its ports, so the rules are tested adversarially.
2020 */
21−import type { DocEditTarget, FolioAgentEdit, FolioAgentEditResult, FolioAgentRead, FolioAudience, FolioKind, FolioPassage, FolioRef, User } from "@g1t/contracts";
21+import type { Ability, AbilitySection, AbilitySource, DocEditTarget, FolioAgentEdit, FolioAgentEditResult, FolioAgentRead, FolioAudience, FolioKind, FolioPassage, FolioRef, McpServer, McpTool, User } from "@g1t/contracts";
2222
23+import { askedFor, levelWords, mcpToolName } from "../../../packages/contracts/src/abilities.ts";
2324 import { FOLIO_KINDS, folioIdFrom, isFolioKind } from "../../../packages/contracts/src/folios.ts";
2425 import type { MakeFileFormat } from "../../../packages/contracts/src/skills.ts";
2526 import { type Audience, type RepoRef, WITHHELD } from "./audience.ts";
482483 },
483484 };
484485
486+/**
487+ * Outside g1t, through the workspace's integrations (abilities.ts,
488+ * docs.g1t.sh/guides/agent-abilities/): each call is checked against the
489+ * agent's abilities for the system that knows the item, in `gate`, before
490+ * anything runs.
491+ */
492+const LOOKUP_OUTSIDE: ToolDef = {
493+ name: "lookup_outside",
494+ description:
495+ "Look up an item in one of the workspace's connected integrations by its key or address: a Linear issue (ENG-42), a Jira ticket (TECH-1234) or a Sentry issue (its link). You get its title, status and description. Only where your abilities allow reading it.",
496+ input_schema: { type: "object", properties: { reference: { type: "string", description: "A key such as ENG-42, or the item's address." } }, required: ["reference"] },
497+};
498+
499+const IMPORT_OUTSIDE: ToolDef = {
500+ name: "import_outside",
501+ description:
502+ "Open an issue in a repository here from an item outside g1t (a Linear issue, a Jira ticket or a Sentry issue), linked back to it, as the person you're working for. If it was imported before, you get the existing issue.",
503+ input_schema: { type: "object", properties: { repo: { type: "string" }, reference: { type: "string" } }, required: ["repo", "reference"] },
504+};
505+
506+const ACT_OUTSIDE: ToolDef = {
507+ name: "act_outside",
508+ description:
509+ "Act on an item outside g1t: comment on a Linear issue, a Jira ticket or a Sentry issue, or resolve a Sentry issue, with text that names the person you're working for. Depending on your abilities this runs at once, or posts a card asking them first: then say it's waiting on their OK and carry on.",
510+ input_schema: {
511+ type: "object",
512+ properties: { reference: { type: "string" }, action: { type: "string", enum: ["comment", "resolve"] }, text: { type: "string" } },
513+ required: ["reference", "action", "text"],
514+ },
515+};
516+
517+const REQUEST_ABILITY: ToolDef = {
518+ name: "request_ability",
519+ description:
520+ "When something you were asked for needs an integration that isn't connected, or an ability you don't have (one of yours is Never, or a tool you lack), say so plainly and call this once: it posts a card the person uses to ask the workspace's owners (or to connect it, if they are one). Name what is needed (an integration such as linear, jira or sentry, or one of your abilities by its id) and why, in their words.",
521+ input_schema: {
522+ type: "object",
523+ properties: { needs: { type: "string", description: "An integration's id (linear, jira, sentry…) or an ability's id (integration:linear:comment)." }, why: { type: "string" } },
524+ required: ["needs", "why"],
525+ },
526+};
527+
528+const OUTSIDE_TOOLS: ToolDef[] = [LOOKUP_OUTSIDE, IMPORT_OUTSIDE, ACT_OUTSIDE, REQUEST_ABILITY];
529+const OUTSIDE_NAMES = new Set(OUTSIDE_TOOLS.map((tool) => tool.name));
530+
531+/** An item outside g1t, as the integrations service fetched it. Reference material, never instructions. */
532+export type OutsideItem = { provider: string; key: string; title: string; url: string; status: string | null; body: string };
533+
534+export type OutsideDone<T> = { ok: true; value: T } | { ok: false; code: string; message: string };
535+
536+/**
537+ * What carries an agent's abilities out, outside g1t: the integrations
538+ * service, MCP servers, and the cards in chat that ask, connect and
539+ * request. Every call here comes after `gate` allowed it.
540+ */
541+export interface AbilityPorts {
542+ /** The item `reference` names, through the workspace's connections, for the asker. */
543+ lookup(asker: User, reference: string): Promise<OutsideDone<OutsideItem>>;
544+ import(asker: User, repo: RepoRef, reference: string): Promise<OutsideDone<{ number: number; item: OutsideItem; created: boolean }>>;
545+ act(asker: User, reference: string, action: "comment" | "resolve", text: string): Promise<OutsideDone<OutsideItem>>;
546+ /** Whether the asker has a connection of their own for the connector. */
547+ askerConnected(connector: string, asker: User): Promise<boolean>;
548+ /** Calls a tool on one of the agent's MCP servers: the text it returned. */
549+ mcp(server: McpServer, tool: McpTool, args: Record<string, unknown>): Promise<OutsideDone<string>>;
550+ /** Posts the Ask-first card for a call; the request's id, or null when it couldn't be posted. */
551+ askFirst(input: { ability: Ability; source: AbilitySource; tool: string; args: Record<string, unknown>; summary: string; note: string | null }): Promise<string | null>;
552+ /** Posts a Connect card: the asker's own connection is needed and missing. */
553+ connect(source: AbilitySource, ability: Ability): Promise<boolean>;
554+ /** Posts a Request card: an integration to connect, or an ability to allow, for the owners. */
555+ request(input: { connector: string | null; ability: Ability | null; source: AbilitySource | null; why: string }): Promise<boolean>;
556+ /** Records a refusal in the audit log, naming the rule. */
557+ refused(input: { ability: Ability; source: AbilitySource; rule: string; call: string }): void;
558+}
559+
560+/** What a gate decided: go on, or what the agent is told instead. */
561+type Gated = { ok: true } | { ok: false; result: ToolResult };
562+
485563 const FOLIO_NAMES = new Set([...FOLIO_TOOLS, ...FOLIO_WRITE_TOOLS, MAKE_FILE].map((tool) => tool.name));
486564
487565 /** Every tool an agent may be offered, by name: what skills may name (@g1t/contracts skill-format.ts `AGENT_TOOL_NAMES`, which a test keeps equal). */
488566 export const TOOL_NAMES: ReadonlySet<string> = new Set(
489− [...CODE_TOOLS, ...CHAT_TOOLS, ...FOLIO_TOOLS, ...FOLIO_WRITE_TOOLS, MAKE_FILE, ASK_COLLEAGUE, HAND_OFF, REMEMBER, FORGET, DRAFT_ISSUE, COMMENT, REVIEW_PULL, START_SESSION, POST_UPDATE, USE_SUBAGENT, BRING_IN, USE_SKILL].map(
490− (tool) => tool.name,
491− ),
567+ [
568+ ...CODE_TOOLS,
569+ ...CHAT_TOOLS,
570+ ...FOLIO_TOOLS,
571+ ...FOLIO_WRITE_TOOLS,
572+ ...OUTSIDE_TOOLS,
573+ MAKE_FILE,
574+ ASK_COLLEAGUE,
575+ HAND_OFF,
576+ REMEMBER,
577+ FORGET,
578+ DRAFT_ISSUE,
579+ COMMENT,
580+ REVIEW_PULL,
581+ START_SESSION,
582+ POST_UPDATE,
583+ USE_SUBAGENT,
584+ BRING_IN,
585+ USE_SKILL,
586+ ].map((tool) => tool.name),
492587 );
493588
494589 /** Reads a library skill's version (skill-library.ts), for `use_skill`. */
532627 /** The agent's skills this turn (skills.ts), and how to read a library skill's text. */
533628 private shelf: readonly ShelfSkill[] = [];
534629 private readSkill: SkillReader | null = null;
630+ /** The agent's abilities this turn (abilities.ts), what carries them out, and what the asker said (for "alone when asked for it"). */
631+ private sections: AbilitySection[] | null = null;
632+ private abilityPorts: AbilityPorts | null = null;
633+ private said = "";
535634
536635 constructor(audience: Audience, ports: ToolPorts, context: ToolContext, calls: ToolCall[] = [], actions: ActionPorts | null = null) {
537636 this.audience = audience;
547646 this.readSkill = read;
548647 }
549648
649+ /**
650+ * Gives the agent its abilities: the sections `resolveAbilities` made
651+ * for it, the ports that carry them out, and what the person said (the
652+ * latest messages, or a session's goal), which "alone when asked for it"
653+ * reads. Outside tools and MCP tools are offered from here on.
654+ */
655+ useAbilities(sections: AbilitySection[], ports: AbilityPorts, said: string, servers: McpServer[] = []): void {
656+ this.sections = sections;
657+ this.abilityPorts = ports;
658+ this.said = said.slice(0, 20_000);
659+ this.servers = servers;
660+ }
661+
662+ /** The abilities of one group's sources, flat, with their source. */
663+ private abilities(group: "integration" | "mcp"): { ability: Ability; source: AbilitySource }[] {
664+ return (this.sections ?? []).filter((section) => section.group === group).flatMap((section) => section.sources.flatMap((source) => source.abilities.map((ability) => ({ ability, source }))));
665+ }
666+
667+ /** The MCP tools offered: every tool of every server whose ability isn't Never. */
668+ private mcpTools(): { def: ToolDef; server: McpServer; tool: McpTool; ability: Ability; source: AbilitySource }[] {
669+ if (!this.abilityPorts) return [];
670+ const out: { def: ToolDef; server: McpServer; tool: McpTool; ability: Ability; source: AbilitySource }[] = [];
671+ for (const { ability, source } of this.abilities("mcp")) {
672+ if (ability.level === "never" || !source.connected) continue;
673+ const server = this.servers.find((s) => s.id === source.id);
674+ const tool = server?.tools.find((t) => `mcp:${server.id}:${t.name}` === ability.id);
675+ if (!server || !tool) continue;
676+ out.push({
677+ def: {
678+ name: mcpToolName(server.name, tool.name),
679+ description: `${tool.description || tool.name} (a tool of the ${server.name} MCP server, outside g1t; ${tool.kind === "read" ? "it reads" : "it may change things there"}).`.slice(0, 1024),
680+ input_schema: tool.input_schema && typeof tool.input_schema === "object" ? tool.input_schema : { type: "object", properties: {} },
681+ },
682+ server,
683+ tool,
684+ ability,
685+ source,
686+ });
687+ }
688+ return out;
689+ }
690+
691+ /** The agent's MCP servers themselves (addresses and tools), given with `useAbilities`. */
692+ private servers: McpServer[] = [];
693+
550694 /** A colleague's tool box for a consult: the same audience, the same budget, one hop further, reading only. */
551695 forColleague(ports: ToolPorts, context: ToolContext): ToolBox {
552696 return new ToolBox(this.audience, ports, context, this.calls);
588732 ...(actions?.useSubagent && this.context.session && roomForHop ? [USE_SUBAGENT] : []),
589733 ...(actions?.bringIn && this.context.session && roomForHop ? [BRING_IN] : []),
590734 ...(this.shelf.length ? [USE_SKILL] : []),
735+ ...this.outsideTools(),
736+ ...this.mcpTools().map((entry) => entry.def),
737+ ];
738+ }
739+
740+ /**
741+ * The outside tools offered: each only where some connected integration
742+ * lets the agent use it at a level other than Never, for a person it can
743+ * act for; `request_ability` whenever it has abilities at all.
744+ */
745+ private outsideTools(): ToolDef[] {
746+ if (!this.sections || !this.abilityPorts || !this.audience.asker || !this.actions) return [];
747+ const live = this.abilities("integration").filter(({ ability, source }) => source.connected && ability.level !== "never");
748+ const offers = (tool: string) => live.some(({ ability }) => ability.tools.includes(tool));
749+ return [
750+ ...(offers("lookup_outside") ? [LOOKUP_OUTSIDE] : []),
751+ ...(offers("import_outside") && this.canFile() ? [IMPORT_OUTSIDE] : []),
752+ ...(offers("act_outside") ? [ACT_OUTSIDE] : []),
753+ REQUEST_ABILITY,
591754 ];
592755 }
593756
642805
643806 private async dispatch(name: string, input: Record<string, unknown>): Promise<ToolResult> {
644807 const asker = this.audience.asker;
808+ if (OUTSIDE_NAMES.has(name)) return this.outside(name, input);
809+ const mcp = this.mcpTools().find((entry) => entry.def.name === name);
810+ if (mcp) return this.mcp(mcp, input);
645811 if (FOLIO_NAMES.has(name)) {
646812 if (!this.definitions().some((tool) => tool.name === name) || !asker || !this.ports.folios) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
647813 return this.folios(name, input, asker, this.ports.folios);
10191185 }
10201186 }
10211187
1188+ /**
1189+ * The rule for one ability, applied before a call: Never refuses and
1190+ * names the rule; the asker's own connection must exist; Ask first posts
1191+ * the card and tells the agent to wait; "alone when asked for it" is
1192+ * alone only when what the person said names the item or the ability.
1193+ */
1194+ private async gate(found: { ability: Ability; source: AbilitySource }, call: { tool: string; args: Record<string, unknown>; summary: string; keys: string[] }, asker: User): Promise<Gated> {
1195+ const { ability, source } = found;
1196+ const ports = this.abilityPorts!;
1197+ const rule = `${source.name}: ${ability.label}`;
1198+ const refused = (text: string): Gated => ({ ok: false, result: { text, outcome: "refused" } });
1199+ if (!source.connected) return refused(`${source.name} isn't connected to this workspace, so you can't ${ability.label.toLowerCase()} there. Say so, and use request_ability if they want it connected.`);
1200+ if (ability.level === "never") {
1201+ ports.refused({ ability, source, rule: `${ability.id}=never`, call: call.summary });
1202+ return refused(`Not allowed: your abilities say "${rule}" is Never. Tell them plainly you aren't allowed to, without trying another way; an owner can change it on your Abilities tab.`);
1203+ }
1204+ if (ability.credentials === "asker" && !(await ports.askerConnected(source.id, asker))) {
1205+ const posted = await ports.connect(source, ability);
1206+ ports.refused({ ability, source, rule: `${ability.id}=asker-not-connected`, call: call.summary });
1207+ return refused(
1208+ `This runs on @${asker.username}'s own ${source.name} connection, and they haven't connected one. ${posted ? "A Connect card was posted: tell them to press it, then ask you again." : "Ask them to connect it under their Integrations settings, then ask you again."}`,
1209+ );
1210+ }
1211+ let note: string | null = null;
1212+ if (ability.level === "asked") {
1213+ if (askedFor(this.said, call.keys)) return { ok: true };
1214+ note = `${ability.label} in ${source.name} runs on its own only when asked for it, and this wasn't.`;
1215+ } else if (ability.level !== "ask") return { ok: true };
1216+ const id = await ports.askFirst({ ability, source, tool: call.tool, args: call.args, summary: call.summary, note });
1217+ ports.refused({ ability, source, rule: `${ability.id}=${ability.level}`, call: call.summary });
1218+ if (!id) return refused(`"${rule}" is ${levelWords(ability.level)}, and the card asking for it couldn't be posted just now. Say what you'd do and ask them to allow it.`);
1219+ return refused(
1220+ `${note ? `${note} ` : ""}"${rule}" is ${levelWords(ability.level)}: a card was posted asking @${asker.username} (or an owner) to allow it. Don't do it another way. Say in a sentence that it's waiting on their OK, and carry on with the rest.${this.context.session ? " Your session pauses at the end of this step until they answer; the result comes to you then." : ""}`,
1221+ );
1222+ }
1223+
1224+ /** The ability `tool` on the connector `provider`, among the agent's. */
1225+ private integrationAbility(provider: string, tool: string): { ability: Ability; source: AbilitySource } | null {
1226+ return this.abilities("integration").find(({ ability, source }) => source.id === provider.toLowerCase() && ability.tools.includes(tool)) ?? null;
1227+ }
1228+
1229+ /** The outside tools: each checked against the agent's abilities for the system that knows the item. */
1230+ private async outside(name: string, input: Record<string, unknown>): Promise<ToolResult> {
1231+ const asker = this.audience.asker;
1232+ const ports = this.abilityPorts;
1233+ if (!ports || !this.sections || !asker || !this.actions || !this.definitions().some((tool) => tool.name === name)) return { text: `There is no tool called ${name} here.`, outcome: "refused" };
1234+ const text = (key: string, max: number) => String(input[key] ?? "").trim().slice(0, max);
1235+ const reference = text("reference", 500);
1236+ const notFound = (): ToolResult => ({ text: `No connected integration knows ${reference || "that"}. If it's in a system that isn't connected, say so; request_ability lets them ask for it.`, outcome: "refused" });
1237+ switch (name) {
1238+ case "request_ability": {
1239+ const needs = text("needs", 120).toLowerCase();
1240+ const why = text("why", 500);
1241+ if (!needs || !why) return { text: "Say what is needed and why.", outcome: "refused" };
1242+ const abilityFound = this.abilities("integration").find(({ ability }) => ability.id === needs) ?? this.abilities("mcp").find(({ ability }) => ability.id === needs) ?? null;
1243+ const connector = abilityFound ? null : needs.replace(/^integration:/, "").split(":")[0]!;
1244+ const posted = await ports.request({ connector, ability: abilityFound?.ability ?? null, source: abilityFound?.source ?? null, why });
1245+ if (!posted) return { text: "The request card couldn't be posted just now. Say what's needed and that an owner can add it from the Marketplace or your Abilities tab.", outcome: "error" };
1246+ return { text: "A Request card was posted. Say in a sentence what it asks for, and that the owners will see it once they press it.", outcome: "allowed" };
1247+ }
1248+ case "lookup_outside": {
1249+ if (!reference) return { text: "Give the item's key or address.", outcome: "refused" };
1250+ const found = await ports.lookup(asker, reference);
1251+ if (!found.ok) return found.code === "not_found" ? notFound() : { text: found.message, outcome: found.code === "forbidden" ? "withheld" : "refused" };
1252+ const item = found.value;
1253+ const own = this.integrationAbility(item.provider, "lookup_outside");
1254+ if (!own) return notFound();
1255+ const gated = await this.gate(own, { tool: name, args: input, summary: `Read ${item.key} in ${own.source.name}`, keys: [item.key, reference, own.ability.label] }, asker);
1256+ if (!gated.ok) return gated.result;
1257+ return { text: untrusted(`${own.source.name} ${item.key}`, `${item.key}: ${item.title}${item.status ? ` [${item.status}]` : ""}
1258+${item.url}
1259+
1260+${item.body}`), outcome: "allowed" };
1261+ }
1262+ case "import_outside": {
1263+ if (!reference) return { text: "Give the item's key or address.", outcome: "refused" };
1264+ if (!this.audience.codeAllowed()) return this.withheld();
1265+ const repo = await this.audience.repo(input.repo);
1266+ if (!repo) return this.withheld();
1267+ const found = await ports.lookup(asker, reference);
1268+ if (!found.ok) return found.code === "not_found" ? notFound() : { text: found.message, outcome: found.code === "forbidden" ? "withheld" : "refused" };
1269+ const own = this.integrationAbility(found.value.provider, "import_outside");
1270+ if (!own) return { text: `Importing from ${found.value.provider} isn't one of your abilities.`, outcome: "refused" };
1271+ const gated = await this.gate(own, { tool: name, args: input, summary: `Import ${found.value.key} from ${own.source.name} into ${repo.namespace}/${repo.name}`, keys: [found.value.key, reference, "import"] }, asker);
1272+ if (!gated.ok) return gated.result;
1273+ const done = await ports.import(asker, repo, reference);
1274+ if (!done.ok) return { text: `It couldn't be imported: ${done.message}`, outcome: "refused" };
1275+ const path = `/${repo.namespace}/${repo.name}/issues/${done.value.number}`;
1276+ return { text: `${done.value.created ? "Opened" : "Already imported as"} ${repo.namespace}/${repo.name}#${done.value.number} from ${done.value.item.key}. Link it: ${path}`, outcome: "allowed" };
1277+ }
1278+ case "act_outside": {
1279+ const action = input.action === "resolve" ? "resolve" : input.action === "comment" ? "comment" : null;
1280+ const body = String(input.text ?? "").trim().slice(0, 8000);
1281+ if (!reference || !action) return { text: "Give the item, and whether to comment or resolve.", outcome: "refused" };
1282+ if (!body) return { text: "Say what to write.", outcome: "refused" };
1283+ const found = await ports.lookup(asker, reference);
1284+ if (!found.ok) return found.code === "not_found" ? notFound() : { text: found.message, outcome: found.code === "forbidden" ? "withheld" : "refused" };
1285+ const item = found.value;
1286+ const own = this.abilities("integration").find(({ ability, source }) => source.id === item.provider.toLowerCase() && ability.id.endsWith(`:${action}`)) ?? null;
1287+ if (!own) return { text: `${action === "resolve" ? "Resolving" : "Commenting on"} items in ${item.provider} isn't one of your abilities.`, outcome: "refused" };
1288+ const summary = action === "resolve" ? `Resolve ${item.key} in ${own.source.name}` : `Comment on ${item.key} in ${own.source.name}`;
1289+ const gated = await this.gate(own, { tool: name, args: input, summary, keys: [item.key, reference, action, own.ability.label] }, asker);
1290+ if (!gated.ok) return gated.result;
1291+ const done = await ports.act(asker, reference, action, body);
1292+ if (!done.ok) return { text: `It didn't work: ${done.message}`, outcome: "refused" };
1293+ return { text: `${action === "resolve" ? "Resolved" : "Commented on"} ${done.value.key} in ${own.source.name} (${done.value.url}).`, outcome: "allowed" };
1294+ }
1295+ default:
1296+ return { text: `There is no tool called ${name}.`, outcome: "refused" };
1297+ }
1298+ }
1299+
1300+ /** A tool of one of the agent's MCP servers, gated by its ability. */
1301+ private async mcp(entry: { def: ToolDef; server: McpServer; tool: McpTool; ability: Ability; source: AbilitySource }, input: Record<string, unknown>): Promise<ToolResult> {
1302+ const asker = this.audience.asker;
1303+ const ports = this.abilityPorts;
1304+ if (!ports || !asker) return { text: `There is no tool called ${entry.def.name} here.`, outcome: "refused" };
1305+ const summary = `Call ${entry.tool.name} on ${entry.server.name}`;
1306+ const gated = await this.gate({ ability: entry.ability, source: entry.source }, { tool: entry.def.name, args: input, summary, keys: [entry.tool.name, entry.server.name] }, asker);
1307+ if (!gated.ok) return gated.result;
1308+ const done = await ports.mcp(entry.server, entry.tool, input);
1309+ if (!done.ok) return { text: `${entry.server.name} didn't do it: ${done.message}`, outcome: "error" };
1310+ return { text: untrusted(`${entry.server.name} ${entry.tool.name}`, done.value), outcome: "allowed" };
1311+ }
1312+
10221313 private async code(name: string, input: Record<string, unknown>, viewer: User): Promise<ToolResult> {
10231314 if (name === "list_repositories") {
10241315 const repos = [...(await this.audience.repos()).values()];
+72−0
10291029 Ok(items)
10301030 }
10311031
1032+ /// The item `reference` names, for an agent acting for `actor` in
1033+ /// `workspace`: a member, and a connection that knows it.
1034+ async fn found_for(&self, actor: &User, workspace: &str, reference: &str) -> Result<std::result::Result<Found, Outcome<ContextItem>>> {
1035+ if !actor.is_member(workspace) {
1036+ return Ok(Err(fail(FailureCode::Forbidden, "Only members can act through a workspace's integrations.")));
1037+ }
1038+ let Some(parsed) = refs::find(reference).into_iter().next() else {
1039+ return Ok(Err(fail(FailureCode::Invalid, "Give a ticket key such as TECH-1234, or a Jira, Linear or Sentry address.")));
1040+ };
1041+ let rows = self.rows(workspace).await?;
1042+ Ok(match self.find(&rows, &parsed).await? {
1043+ Ok(Some(found)) => Ok(found),
1044+ Ok(None) => Err(fail(FailureCode::NotFound, format!("None of the {workspace} workspace's integrations knows {}.", reference.trim()))),
1045+ Err(problem) => Err(fail(FailureCode::Conflict, problem)),
1046+ })
1047+ }
1048+
1049+ /// A comment from an agent on an item outside g1t, with the g1t link
1050+ /// that explains it, through the connection that knows the item.
1051+ async fn comment_outside(&self, a: CommentArgs) -> Result<Outcome<ContextItem>> {
1052+ let workspace = a.workspace.to_lowercase();
1053+ let found = match self.found_for(&a.actor, &workspace, &a.reference).await? {
1054+ Ok(found) => found,
1055+ Err(outcome) => return Ok(outcome),
1056+ };
1057+ let text = a.text.trim();
1058+ if text.is_empty() || text.len() > 8_000 {
1059+ return Ok(fail(FailureCode::Invalid, "A comment is 1 to 8,000 characters."));
1060+ }
1061+ let row = &found.connection;
1062+ let config = row.config();
1063+ let Some(token) = self.secrets(row).secret else {
1064+ return Ok(fail(FailureCode::Conflict, format!("{} has no key to act with. An owner can set one under Integrations.", row.name)));
1065+ };
1066+ let told = match row.provider() {
1067+ Provider::Sentry => sentry::comment(&config, &token, &found.external_id, &format!("{text} {}", a.link)).await?,
1068+ Provider::Jira => trackers::jira_comment(&config, &token, &found.external_id, text, &a.link).await?,
1069+ Provider::Linear => trackers::linear_comment(&token, &found.external_id, text, &a.link).await?,
1070+ _ => return Ok(fail(FailureCode::Invalid, "Only Linear, Jira and Sentry items take comments.")),
1071+ };
1072+ self.note(&row.id, told.as_ref().err().map(String::as_str)).await?;
1073+ Ok(match told {
1074+ Ok(()) => Outcome::Ok(found.item),
1075+ Err(problem) => fail(FailureCode::Conflict, problem),
1076+ })
1077+ }
1078+
1079+ /// An agent marks a Sentry issue resolved, with a note and the g1t link.
1080+ async fn close_outside(&self, a: CloseArgs) -> Result<Outcome<ContextItem>> {
1081+ let workspace = a.workspace.to_lowercase();
1082+ let found = match self.found_for(&a.actor, &workspace, &a.reference).await? {
1083+ Ok(found) => found,
1084+ Err(outcome) => return Ok(outcome),
1085+ };
1086+ let row = &found.connection;
1087+ if row.provider() != Provider::Sentry {
1088+ return Ok(fail(FailureCode::Invalid, "Only Sentry issues can be resolved from here."));
1089+ }
1090+ let Some(token) = self.secrets(row).secret else {
1091+ return Ok(fail(FailureCode::Conflict, format!("{} has no key to act with. An owner can set one under Integrations.", row.name)));
1092+ };
1093+ let note = format!("{} {}", a.text.trim(), a.link);
1094+ let told = sentry::resolve(&row.config(), &token, &found.external_id, &note).await?;
1095+ self.note(&row.id, told.as_ref().err().map(String::as_str)).await?;
1096+ Ok(match told {
1097+ Ok(()) => Outcome::Ok(found.item),
1098+ Err(problem) => fail(FailureCode::Conflict, problem),
1099+ })
1100+ }
1101+
10321102 async fn import(&self, a: ImportArgs) -> Result<Outcome<Imported>> {
10331103 let workspace = a.repo.namespace.to_lowercase();
10341104 if !a.actor.is_member(&workspace) {
15981668 "resolve" => reply(&service.resolve(args(body)?).await?),
15991669 "references" => reply(&service.references(args(body)?).await?),
16001670 "import" => reply(&service.import(args(body)?).await?),
1671+ "comment" => reply(&service.comment_outside(args(body)?).await?),
1672+ "close" => reply(&service.close_outside(args(body)?).await?),
16011673 "links" => reply(&service.links(args(body)?).await?),
16021674 "model_provider" => reply(&service.model_provider(args(body)?).await?),
16031675 "open_model_session" => reply(&service.open_model_session(args(body)?).await?),