Pick any line to see why it is the way it is: the commit, the pull request and issue it came from, and what the agent was thinking.
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 1 | /** |
| 2 | * How agents' cards look, and what they offer, by state: pure, so it is | |
| 3 | * tested on its own (cards.ts acts on them, sessions.ts posts them). | |
| 4 | */ | |
| 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. | 5 | import type { AbilityLevel, MessageCard } from "@g1t/contracts"; |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 6 | |
| 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. | 7 | import { ABILITY_LEVEL_LABELS } from "../../../packages/contracts/src/abilities.ts"; |
| 8 | import type { AbilityRequestRow } from "./abilities.ts"; | |
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 9 | import type { DraftRow } from "./cards.ts"; |
| 10 | ||
| 11 | /** A draft issue's card: what it would file, and the buttons. */ | |
| 12 | export function draftCard(draft: Pick<DraftRow, "id" | "repo" | "title" | "body" | "labels" | "status" | "filed_by" | "number">): MessageCard { | |
| 13 | const labels = (() => { | |
| 14 | try { | |
| 15 | return (JSON.parse(draft.labels || "[]") as string[]).join(", "); | |
| 16 | } catch { | |
| 17 | return ""; | |
| 18 | } | |
| 19 | })(); | |
| 20 | const fields = [{ label: "Repository", value: draft.repo }, ...(labels ? [{ label: "Labels", value: labels }] : [])]; | |
| 21 | const preview = draft.body.length > 900 ? `${draft.body.slice(0, 900)}…` : draft.body; | |
| 22 | if (draft.status === "filed" && draft.number) { | |
| 23 | const href = `/${draft.repo}/issues/${draft.number}`; | |
| 24 | return { | |
| 25 | kind: "draft_issue", | |
| 26 | title: draft.title, | |
| 27 | detail: `Filed as ${draft.repo}#${draft.number}${draft.filed_by ? ` by @${draft.filed_by}` : ""}`, | |
| 28 | state: "Filed", | |
| 29 | href, | |
| 30 | fields, | |
| 31 | actions: [{ id: "open", label: "Open issue", href }], | |
| 32 | owner: "agents", | |
| 33 | ref: draft.id, | |
| 34 | }; | |
| 35 | } | |
| 36 | if (draft.status === "discarded") { | |
| 37 | return { kind: "draft_issue", title: draft.title, detail: "Discarded", state: "Discarded", href: null, owner: "agents", ref: draft.id }; | |
| 38 | } | |
| 39 | return { | |
| 40 | kind: "draft_issue", | |
| 41 | title: draft.title, | |
| 42 | detail: `A draft issue for ${draft.repo}. Filing it files it as you.`, | |
| 43 | state: "Draft", | |
| 44 | href: null, | |
| 45 | body: preview, | |
| 46 | fields, | |
| 47 | actions: [ | |
| 48 | { id: "file", label: "File issue", style: "primary" }, | |
| 49 | { id: "discard", label: "Discard" }, | |
| 50 | ], | |
| 51 | owner: "agents", | |
| 52 | ref: draft.id, | |
| 53 | }; | |
| 54 | } | |
| 55 | ||
| 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. | 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 | ||
| Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar | 149 | /** Money as people type it ("5", "$12.50") in micro-dollars, or null. */ |
| 150 | export function parseMoney(input: string | null): number | null { | |
| 151 | const text = (input ?? "").trim().replace(/^\$/, "").replace(/,/g, ""); | |
| 152 | if (!/^\d+(\.\d{1,2})?$/.test(text)) return null; | |
| 153 | const micros = Math.round(Number(text) * 1_000_000); | |
| 154 | return micros > 0 ? micros : null; | |
| 155 | } | |
| 156 | ||
| 157 | /** What people can do on a session's card, by where it is. */ | |
| 158 | export function sessionActions(status: string, cap: number | null, spent: number, href: string): NonNullable<MessageCard["actions"]> { | |
| 159 | const open = { id: "open", label: "Open", href }; | |
| 160 | const live = status === "queued" || status === "working" || status === "waiting"; | |
| 161 | if (status === "needs_approval") { | |
| 162 | const suggested = Math.max(spent + 1_000_000, (cap ?? spent) * 2); | |
| 163 | return [ | |
| 164 | { | |
| 165 | id: "approve", | |
| 166 | label: "Approve more", | |
| 167 | style: "primary", | |
| 168 | input: { kind: "money", label: "New cap", placeholder: "5.00", initial: (suggested / 1_000_000).toFixed(2) }, | |
| 169 | }, | |
| 170 | { id: "stop", label: "Stop", style: "danger", confirm: "Stop this session and everything under it?" }, | |
| 171 | open, | |
| 172 | ]; | |
| 173 | } | |
| 174 | if (live) { | |
| 175 | return [ | |
| 176 | { id: "steer", label: "Message", input: { kind: "text", label: "Tell it", placeholder: "Also check the mobile app…" } }, | |
| 177 | { id: "stop", label: "Stop", style: "danger", confirm: "Stop this session and everything under it?" }, | |
| 178 | open, | |
| 179 | ]; | |
| 180 | } | |
| 181 | // Over: carry on with a message (it picks up with its context), or read it. | |
| 182 | return [{ id: "steer", label: "Follow up", input: { kind: "text", label: "Ask it to", placeholder: "Now write the release note…" } }, open]; | |
| 183 | } | |
| 184 |