Skip to content
680 linesCodeBlameRaw

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.

Chat and workspace agents: channels, DMs and named agents you talk to1/**
The docs folder is gone, and what it held lives where people read it: how a self-hosted g1t runs and how to deploy g1t to Cloudflare are pages on docs.g1t.sh under Run g1t yourself, and speed, rate limits and operating g1t.sh are sections of CONTRIBUTING.md; code that cited a file in docs/ now points to the page or section that covers it, or says what it means itself, and applied migrations and the runner images are left as they were.2 * The agents service: a workspace's own agents
3 * (docs.g1t.sh/guides/agents/). Their definitions and every version of
4 * them, the templates they start from, each agent's desk, and their replies
5 * in chat.
Chat and workspace agents: channels, DMs and named agents you talk to6 *
7 * `@g1t`, the platform's own agent, is not one of these.
8 *
9 * Reached through service bindings: `POST /rpc/<method>`, with snake_case
10 * JSON (@g1t/contracts workspace-agents.ts).
11 */
12import {
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar13 type AgentCardAction,
Chat and workspace agents: channels, DMs and named agents you talk to14 type AgentDelivery,
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar15 type CardActionResult,
Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened16 type G1tEvent,
The Marketplace is where a workspace finds what to add, reached from Apps: the agent catalog of roles with who already does each job and Add to workspace; every integration with whether it's connected; g1t's own extensions, listed as Soon; and requests, where members ask and owners add or turn down, each told by a notification. Underneath, an extension is a manifest (publisher and tier, source and version from a tag, scopes, the domains its data goes to, its page on g1tusercontent.com, no price) and an install pins its version with an off switch, a budget and the audit log. Apps lists only what was added from the Marketplace, with pins; g1t's own pages stay in their sidebars, and AI Gateway joins Workspace's. The marketplace guide says how.17 type ExtensionInstall,
18 type InstallRequest,
19 type InstallRequests,
Chat and workspace agents: channels, DMs and named agents you talk to20 type NewWorkspaceAgent,
21 type Result,
22 type ServiceBinding,
23 type User,
24 type WorkspaceAgent,
25 UNVERIFIED,
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)26 askerAccess,
Chat and workspace agents: channels, DMs and named agents you talk to27 awaitsConfirmation,
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)28 chatClient,
The Marketplace is where a workspace finds what to add, reached from Apps: the agent catalog of roles with who already does each job and Add to workspace; every integration with whether it's connected; g1t's own extensions, listed as Soon; and requests, where members ask and owners add or turn down, each told by a notification. Underneath, an extension is a manifest (publisher and tier, source and version from a tag, scopes, the domains its data goes to, its page on g1tusercontent.com, no price) and an install pins its version with an off switch, a budget and the audit log. Apps lists only what was added from the Marketplace, with pins; g1t's own pages stay in their sidebars, and AI Gateway joins Workspace's. The marketplace guide says how.29 cleanRequestNote,
30 extensionById,
Chat and workspace agents: channels, DMs and named agents you talk to31 fail,
32 identityClient,
33 newId,
The Marketplace is where a workspace finds what to add, reached from Apps: the agent catalog of roles with who already does each job and Add to workspace; every integration with whether it's connected; g1t's own extensions, listed as Soon; and requests, where members ask and owners add or turn down, each told by a notification. Underneath, an extension is a manifest (publisher and tier, source and version from a tag, scopes, the domains its data goes to, its page on g1tusercontent.com, no price) and an install pins its version with an off switch, a budget and the audit log. Apps lists only what was added from the Marketplace, with pins; g1t's own pages stay in their sidebars, and AI Gateway joins Workspace's. The marketplace guide says how.34 notifyClient,
Chat and workspace agents: channels, DMs and named agents you talk to35 ok,
36 openD1,
37} from "@g1t/contracts";
38
39import { MANAGE_REFUSAL, canManage, canSee } from "./access.ts";
40import { type Definition, applyChanges } from "./definition.ts";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)41import { builtinChanges } from "./orchestrator.ts";
Chat and workspace agents: channels, DMs and named agents you talk to42import type { Desk } from "./desk.ts";
43import type { ReplyEnv } from "./reply.ts";
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)44import { ensureBuiltin } from "./builtin.ts";
45import { type Row, definitionOf, insertAgent, periods, selectAgents, toAgent, updateAgent, versionStatement } from "./store.ts";
Chat and workspace agents: channels, DMs and named agents you talk to46import { TEMPLATES, TEMPLATE_IDS } from "./templates.ts";
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked47import { readPolicy } from "./policy.ts";
48import { runDue } from "./routines.ts";
Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened49import { onEvents } from "./triggers.ts";
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar50import { cardAction } from "./cards.ts";
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked51import { type SessionEnv, sweep } from "./sessions.ts";
52import * as views from "./views.ts";
53import { monthKey } from "./budget.ts";
The Marketplace is where a workspace finds what to add, reached from Apps: the agent catalog of roles with who already does each job and Add to workspace; every integration with whether it's connected; g1t's own extensions, listed as Soon; and requests, where members ask and owners add or turn down, each told by a notification. Underneath, an extension is a manifest (publisher and tier, source and version from a tag, scopes, the domains its data goes to, its page on g1tusercontent.com, no price) and an install pins its version with an off switch, a budget and the audit log. Apps lists only what was added from the Marketplace, with pins; g1t's own pages stay in their sidebars, and AI Gateway joins Workspace's. The marketplace guide says how.54import * as extensions from "./extensions.ts";
55import { type Answered, type Person, answerLine, findListing, listRequests, listingPath, openRequest, requestsPath, resolveListing, resolveRequest } from "./installs.ts";
Chat and workspace agents: channels, DMs and named agents you talk to56
57export { Desk } from "./desk.ts";
58
59type Env = ReplyEnv & {
60 IDENTITY: ServiceBinding;
61 /** The audit log. */
62 EVENTS: ServiceBinding;
63 DESKS: DurableObjectNamespace<Desk>;
64};
65
66/** Workspace ids by slug, kept a minute: every call names a workspace by slug. */
67const workspaceIds = new Map<string, { id: string | null; until: number }>();
68
69class Agents {
70 private readonly env: Env;
71 private readonly db: D1Database;
72 private readonly defer: (work: Promise<unknown>) => void;
73
74 // Plain fields, not parameter properties: Node's type stripping does not take those.
75 constructor(env: Env, defer: (work: Promise<unknown>) => void) {
76 this.env = env;
77 this.db = env.DB;
78 this.defer = defer;
79 }
80
81 private async workspaceId(slug: string): Promise<string | null> {
82 const key = slug.toLowerCase();
83 const kept = workspaceIds.get(key);
84 if (kept && kept.until > Date.now()) return kept.id;
85 const workspace = await identityClient(this.env.IDENTITY).getWorkspace(key);
86 if (workspaceIds.size > 5_000) workspaceIds.clear();
87 workspaceIds.set(key, { id: workspace?.id ?? null, until: Date.now() + 60_000 });
88 return workspace?.id ?? null;
89 }
90
91 /** The workspace's id, if the viewer may see its agents. */
92 private async seen(workspace: string, viewer: User | null): Promise<Result<string>> {
93 if (!canSee(viewer, workspace)) return fail("not_found", "There is no such workspace.");
94 const id = await this.workspaceId(workspace);
95 return id ? ok(id) : fail("not_found", "There is no such workspace.");
96 }
97
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar98 /** Internal, from chat: a person pressed an action on one of agents' cards. */
99 async cardAction(a: AgentCardAction): Promise<Result<CardActionResult>> {
100 if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED;
101 if (!a?.viewer || !canSee(a.viewer, a.workspace ?? "")) return fail("not_found", "No such card.");
102 return cardAction(this.env as unknown as SessionEnv, a);
103 }
104
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked105 /** What the views need: the workspace, the viewer, and whether they own it. */
106 private async context(workspace: string, viewer: User | null): Promise<Result<views.ViewContext>> {
107 if (viewer && awaitsConfirmation(viewer)) return UNVERIFIED;
108 const seen = await this.seen(workspace, viewer);
109 if (!seen.ok) return seen;
110 return ok({
111 env: this.env as unknown as SessionEnv,
112 db: this.db,
113 slug: workspace.toLowerCase(),
114 workspaceId: seen.value,
115 viewer: viewer!,
116 owner: canManage(viewer, workspace),
117 });
118 }
119
120 /** Runs `view` with the context, or answers why it can't. */
121 async view<T>(a: { workspace: string; viewer: User | null }, view: (ctx: views.ViewContext) => Promise<Result<T>>): Promise<Result<T>> {
122 const ctx = await this.context(a?.workspace ?? "", a?.viewer ?? null);
123 if (!ctx.ok) return ctx;
124 return view(ctx.value);
125 }
126
Chat and workspace agents: channels, DMs and named agents you talk to127 /** The workspace's id, if the viewer may change its agents. */
128 private async managed(workspace: string, viewer: User | null): Promise<Result<string>> {
129 if (awaitsConfirmation(viewer)) return UNVERIFIED;
130 const seen = await this.seen(workspace, viewer);
131 if (!seen.ok) return seen;
132 return canManage(viewer, workspace) ? seen : fail("forbidden", MANAGE_REFUSAL);
133 }
134
135 private async row(workspaceId: string, handle: unknown): Promise<Row | null> {
136 if (typeof handle !== "string") return null;
137 const now = new Date();
138 return this.db
139 .prepare(selectAgents("a.workspace_id = ?3 AND a.handle = ?4 AND a.archived_at IS NULL"))
140 .bind(...periods(now), workspaceId, handle.trim().replace(/^@/, "").toLowerCase())
141 .first<Row>();
142 }
143
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)144 /** Whether `team` (a slug, or none) is one of the workspace's teams, as the person changing the agent sees them. */
145 private async teamExists(workspace: string, viewer: User, team: string | null): Promise<Result<null>> {
146 if (!team) return ok(null);
147 const found = await identityClient(this.env.IDENTITY)
148 .getTeam(viewer, workspace.toLowerCase(), team)
149 .catch(() => null);
150 return found?.ok ? ok(null) : fail("invalid", `${workspace} has no team called ${team}.`);
151 }
152
Chat and workspace agents: channels, DMs and named agents you talk to153 private async handleTaken(workspaceId: string, handle: string, except: string | null): Promise<boolean> {
154 const found = await this.db
155 .prepare("SELECT id FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL")
156 .bind(workspaceId, handle)
157 .first<{ id: string }>();
158 return !!found && found.id !== except;
159 }
160
161 async list(a: { workspace: string; viewer: User | null }): Promise<Result<WorkspaceAgent[]>> {
162 const seen = await this.seen(a.workspace, a.viewer);
163 if (!seen.ok) return seen;
164 const now = new Date();
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)165 await this.ensureBuiltin(seen.value);
Chat and workspace agents: channels, DMs and named agents you talk to166 const rows = await this.db
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)167 .prepare(`${selectAgents("a.workspace_id = ?3 AND a.archived_at IS NULL")} ORDER BY a.builtin DESC, a.handle`)
Chat and workspace agents: channels, DMs and named agents you talk to168 .bind(...periods(now), seen.value)
169 .all<Row>();
170 return ok(rows.results.map((row) => toAgent(row, now)));
171 }
172
173 async get(a: { workspace: string; handle: string; viewer: User | null }): Promise<Result<WorkspaceAgent>> {
174 const seen = await this.seen(a.workspace, a.viewer);
175 if (!seen.ok) return seen;
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)176 await this.ensureBuiltin(seen.value);
Chat and workspace agents: channels, DMs and named agents you talk to177 const row = await this.row(seen.value, a.handle);
178 return row ? ok(toAgent(row, new Date())) : fail("not_found", `There is no agent called @${a.handle}.`);
179 }
180
181 /** Internal: agents by id, archived ones too, so old messages still show who wrote them. */
182 async byIds(a: { ids: string[] }): Promise<WorkspaceAgent[]> {
183 const ids = [...new Set((Array.isArray(a.ids) ? a.ids : []).filter((id) => typeof id === "string"))].slice(0, 100);
184 if (!ids.length) return [];
185 const now = new Date();
186 const rows = await this.db
187 .prepare(selectAgents(`a.id IN (${ids.map((_, i) => `?${i + 3}`).join(", ")})`))
188 .bind(...periods(now), ...ids)
189 .all<Row>();
190 return rows.results.map((row) => toAgent(row, now));
191 }
192
193 async create(a: { workspace: string; viewer: User | null; input: NewWorkspaceAgent }): Promise<Result<WorkspaceAgent>> {
194 const managed = await this.managed(a.workspace, a.viewer);
195 if (!managed.ok) return managed;
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked196 // A new agent starts with the workspace's default monthly budget, unless one was given.
197 const policy = await readPolicy(this.db, managed.value, monthKey(new Date()));
198 const input =
199 policy.default_agent_monthly_micros && a.input?.budget?.monthly_micros === undefined
200 ? { ...a.input, budget: { ...(a.input?.budget ?? {}), monthly_micros: policy.default_agent_monthly_micros } }
201 : a.input;
202 const checked = applyChanges(null, input, TEMPLATE_IDS);
Chat and workspace agents: channels, DMs and named agents you talk to203 if (!checked.ok) return fail("invalid", checked.message);
204 const definition = checked.value;
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)205 const team = await this.teamExists(a.workspace, a.viewer!, definition.team);
206 if (!team.ok) return team;
Chat and workspace agents: channels, DMs and named agents you talk to207 if (await this.handleTaken(managed.value, definition.handle, null)) {
208 return fail("conflict", `${a.workspace} already has an agent called @${definition.handle}.`);
209 }
210 const now = new Date().toISOString();
211 const id = newId("agt");
212 const by = a.viewer!.username;
213 try {
214 await this.db.batch([
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)215 insertAgent(this.db, id, managed.value, definition, { version: 1, created_by: by, created_at: now, updated_at: now }),
Chat and workspace agents: channels, DMs and named agents you talk to216 this.versionStatement(id, 1, definition, by, now),
217 ]);
218 } catch (error) {
219 if (String(error).includes("UNIQUE")) return fail("conflict", `${a.workspace} already has an agent called @${definition.handle}.`);
220 throw error;
221 }
222 this.audit(a.viewer!, a.workspace, "create_agent", definition.handle, `Created @${definition.handle} (version 1)`);
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)223 this.defer(this.hello(a.workspace, managed.value, id, a.viewer!));
Chat and workspace agents: channels, DMs and named agents you talk to224 const row = await this.row(managed.value, definition.handle);
225 return ok(toAgent(row!, new Date()));
226 }
227
The Marketplace is where a workspace finds what to add, reached from Apps: the agent catalog of roles with who already does each job and Add to workspace; every integration with whether it's connected; g1t's own extensions, listed as Soon; and requests, where members ask and owners add or turn down, each told by a notification. Underneath, an extension is a manifest (publisher and tier, source and version from a tag, scopes, the domains its data goes to, its page on g1tusercontent.com, no price) and an install pins its version with an off switch, a budget and the audit log. Apps lists only what was added from the Marketplace, with pins; g1t's own pages stay in their sidebars, and AI Gateway joins Workspace's. The marketplace guide says how.228 // ── The Marketplace's install requests (./installs.ts) ─────────────────
229
230 /** Requests as the viewer sees them: every one for an owner, their own for anyone else. */
231 async installRequests(a: { workspace: string; viewer: User | null }): Promise<Result<InstallRequests>> {
232 if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED;
233 const seen = await this.seen(a?.workspace ?? "", a?.viewer ?? null);
234 if (!seen.ok) return seen;
235 const owner = canManage(a.viewer, a.workspace);
236 const requests = await listRequests(this.db, seen.value, a.viewer!, owner);
237 return ok({ requests, can_resolve: owner });
238 }
239
240 /** A member asks the owners to add something; each owner is notified. */
241 async requestInstall(a: { workspace: string; viewer: User | null; listing: unknown; note?: unknown }): Promise<Result<InstallRequest>> {
242 if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED;
243 const seen = await this.seen(a?.workspace ?? "", a?.viewer ?? null);
244 if (!seen.ok) return seen;
245 const viewer = a.viewer!;
246 if ((viewer.kind ?? "user") !== "user") return fail("forbidden", "Only people ask the workspace's owners to add things.");
247 if (canManage(viewer, a.workspace)) return fail("invalid", "You're an owner of this workspace: add it yourself.");
248 const listing = findListing(a.listing);
249 if (!listing) return fail("not_found", "That isn't something a workspace can add yet.");
250 const opened = await openRequest(this.db, seen.value, newId("ins"), listing, viewer, cleanRequestNote(a.note));
251 if (!opened.ok) return opened;
252 const slug = a.workspace.toLowerCase();
253 this.audit(viewer, slug, "request_install", listing.ref, `Asked the owners to add ${listing.name}`, "marketplace");
254 this.defer(this.tellOwners(slug, viewer, opened.value));
255 return opened;
256 }
257
258 /** An owner adds or turns down a request; whoever asked is told. */
259 async resolveInstallRequest(a: { workspace: string; viewer: User | null; id: unknown; status: unknown }): Promise<Result<InstallRequest>> {
260 const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
261 if (!managed.ok) {
262 return managed.error.code === "forbidden" ? fail("forbidden", "Only the workspace's owners answer requests.") : managed;
263 }
264 if (a.status !== "done" && a.status !== "declined") return fail("invalid", "Answer a request with done or declined.");
265 const answered = await resolveRequest(this.db, managed.value, String(a.id ?? ""), a.status, a.viewer!);
266 if (!answered.ok) return answered;
267 const slug = a.workspace.toLowerCase();
268 const { request } = answered.value;
269 this.audit(a.viewer!, slug, "answer_install_request", request.listing, `${request.status === "done" ? "Added" : "Turned down"} ${request.name} for @${request.requested_by}`, "marketplace");
270 this.defer(this.tellRequester(slug, a.viewer!, answered.value));
271 return ok(request);
272 }
273
274 // ── Extensions installed in the workspace (./extensions.ts) ──────────────
275
276 /** The workspace's installs; any member sees them. */
277 async extensionInstalls(a: { workspace: string; viewer: User | null }): Promise<Result<ExtensionInstall[]>> {
278 if (a?.viewer && awaitsConfirmation(a.viewer)) return UNVERIFIED;
279 const seen = await this.seen(a?.workspace ?? "", a?.viewer ?? null);
280 if (!seen.ok) return seen;
281 return ok(await extensions.listInstalls(this.db, seen.value));
282 }
283
284 /** Owners install a published extension; whoever asked for it is told. */
285 async installExtension(a: { workspace: string; viewer: User | null; extension: unknown }): Promise<Result<ExtensionInstall>> {
286 const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
287 if (!managed.ok) return managed.error.code === "forbidden" ? fail("forbidden", "Only the workspace's owners install extensions.") : managed;
288 const installed = await extensions.install(this.db, extensionById, managed.value, newId("ins"), String(a.extension ?? ""), a.viewer!.username);
289 if (!installed.ok) return installed;
290 const slug = a.workspace.toLowerCase();
291 this.audit(a.viewer!, slug, "install_extension", installed.value.listing, `Installed ${installed.value.listing} ${installed.value.version}`, "marketplace");
292 this.defer(this.answerListing(slug, managed.value, installed.value.listing, a.viewer!));
293 return installed;
294 }
295
296 /** Owners switch an install on or off. */
297 async setExtensionEnabled(a: { workspace: string; viewer: User | null; listing: unknown; enabled: unknown }): Promise<Result<ExtensionInstall>> {
298 const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
299 if (!managed.ok) return managed;
300 const listing = String(a.listing ?? "");
301 if (!extensions.extensionIdOf(listing)) return fail("invalid", "Name an extension as extension:<id>.");
302 const changed = await extensions.setEnabled(this.db, managed.value, listing, a.enabled === true, a.viewer!.username);
303 if (changed.ok) this.audit(a.viewer!, a.workspace, a.enabled === true ? "enable_extension" : "disable_extension", listing, `${a.enabled === true ? "Switched on" : "Switched off"} ${listing}`, "marketplace");
304 return changed;
305 }
306
307 /** Owners cap what an install spends a month. */
308 async setExtensionBudget(a: { workspace: string; viewer: User | null; listing: unknown; monthly_micros: unknown }): Promise<Result<ExtensionInstall>> {
309 const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
310 if (!managed.ok) return managed;
311 const listing = String(a.listing ?? "");
312 const micros = a.monthly_micros == null ? null : Number(a.monthly_micros);
313 const changed = await extensions.setBudget(this.db, managed.value, listing, micros);
314 if (changed.ok) this.audit(a.viewer!, a.workspace, "set_extension_budget", listing, `Set ${listing}'s monthly budget`, "marketplace");
315 return changed;
316 }
317
318 /** Owners remove an install. */
319 async uninstallExtension(a: { workspace: string; viewer: User | null; listing: unknown }): Promise<Result<null>> {
320 const managed = await this.managed(a?.workspace ?? "", a?.viewer ?? null);
321 if (!managed.ok) return managed;
322 const listing = String(a.listing ?? "");
323 const removed = await extensions.uninstall(this.db, managed.value, listing, a.viewer!.username);
324 if (removed.ok) this.audit(a.viewer!, a.workspace, "uninstall_extension", listing, `Uninstalled ${listing}`, "marketplace");
325 return removed;
326 }
327
328 /** Marks every open request for `listing` added, and tells each person who asked. */
329 private async answerListing(slug: string, workspaceId: string, listing: string, by: User): Promise<void> {
330 try {
331 const answered = await resolveListing(this.db, workspaceId, listing, by as Person);
332 await Promise.all(answered.map((one) => this.tellRequester(slug.toLowerCase(), by, one)));
333 } catch (error) {
334 console.error("agents: requests for a listing were not answered", listing, String(error));
335 }
336 }
337
338 /** Every owner hears of a new request (at most 20 of them). */
339 private async tellOwners(slug: string, asker: User, request: InstallRequest): Promise<void> {
340 if (!this.env.NOTIFY) return;
341 try {
342 const members = await identityClient(this.env.IDENTITY).listMembers(slug, asker);
343 if (!members.ok) throw new Error(members.error.message);
344 const owners = members.value.filter((member) => member.role === "owner").slice(0, 20);
345 const notify = notifyClient(this.env.NOTIFY);
346 await Promise.all(
347 owners.map((owner) =>
348 notify
349 .notify(
350 { username: owner.username },
351 {
352 id: `install-request:${request.id}:${owner.username}`,
353 kind: "approval",
354 workspace: slug,
355 title: `@${asker.username} asks you to add ${request.name}`,
The Marketplace adds what g1t doesn't do and connects what you already use: extensions (Mail, Support, CRM, Recruiting, On-call, and bridges to a helpdesk, CRM or ERP you run, all Soon), starter kits, and integrations connected by the workspace or by each person; agents aren't installed there. Agent templates live in Agents, as starting points you configure, at Agents → Templates, and the Marketplace's old agent addresses lead there. The marketplace and agents guides say how.356 body: request.note ?? (request.kind === "extension" ? "An extension. Install it, or turn the request down." : "An integration. Connect it, or turn the request down."),
The Marketplace is where a workspace finds what to add, reached from Apps: the agent catalog of roles with who already does each job and Add to workspace; every integration with whether it's connected; g1t's own extensions, listed as Soon; and requests, where members ask and owners add or turn down, each told by a notification. Underneath, an extension is a manifest (publisher and tier, source and version from a tag, scopes, the domains its data goes to, its page on g1tusercontent.com, no price) and an install pins its version with an off switch, a budget and the audit log. Apps lists only what was added from the Marketplace, with pins; g1t's own pages stay in their sidebars, and AI Gateway joins Workspace's. The marketplace guide says how.357 href: requestsPath(slug),
358 actor: { kind: "user", id: asker.id, name: asker.username, avatar: asker.avatar ?? null, avatar_seed: null },
359 created_at: new Date().toISOString(),
360 },
361 )
362 .catch(() => undefined),
363 ),
364 );
365 } catch (error) {
366 console.error("agents: owners were not told of an install request", request.id, String(error));
367 }
368 }
369
370 /** The person who asked hears their request was answered. */
371 private async tellRequester(slug: string, by: User, answered: Answered): Promise<void> {
372 if (!this.env.NOTIFY) return;
373 const { request } = answered;
374 const line = answerLine(request, `@${by.username}`);
375 await notifyClient(this.env.NOTIFY)
376 .notify(
377 { user_id: answered.requested_by_id },
378 {
379 id: `install-answer:${request.id}`,
380 kind: "inbox",
381 workspace: slug,
382 title: line.title,
383 body: line.body,
384 href: request.status === "done" ? listingPath(slug, request.listing) : requestsPath(slug),
385 actor: { kind: "user", id: by.id, name: by.username, avatar: by.avatar ?? null, avatar_seed: null },
386 created_at: new Date().toISOString(),
387 },
388 )
389 .catch((error: unknown) => console.error("agents: a requester was not told", request.id, String(error)));
390 }
391
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)392 /**
393 * A new agent's first words: the DM with the person who made it is
394 * opened, and the agent says hello there in its own voice, as a reply
395 * billed like any other (a fixed hello when no model can be used).
396 * After the answer; a failure only logs.
397 */
398 private async hello(workspace: string, workspaceId: string, agentId: string, creator: User): Promise<void> {
399 if ((creator.kind ?? "user") !== "user") return;
400 try {
401 const dm = await chatClient(this.env.CHAT).openDm(workspace, creator, [{ kind: "agent", id: agentId }]);
402 if (!dm.ok) throw new Error(dm.error.message);
403 const desk = this.env.DESKS.get(this.env.DESKS.idFromName(agentId));
404 await desk.take({
405 workspace,
406 workspace_id: workspaceId,
407 channel_id: dm.value.id,
408 channel_kind: "dm",
409 channel_name: null,
410 agent_id: agentId,
411 // One hello per agent, however often this runs.
412 message_id: `hello:${agentId}`,
413 thread_root: null,
414 asked_by: creator.id,
415 hops: 0,
416 asker: askerAccess(creator, workspace),
417 hello: true,
418 });
419 } catch (error) {
420 console.error("agents: a new agent's hello was not sent", agentId, String(error));
421 }
422 }
423
Chat and workspace agents: channels, DMs and named agents you talk to424 async update(a: { workspace: string; handle: string; viewer: User | null; changes: Partial<NewWorkspaceAgent> }): Promise<Result<WorkspaceAgent>> {
425 const managed = await this.managed(a.workspace, a.viewer);
426 if (!managed.ok) return managed;
427 const row = await this.row(managed.value, a.handle);
428 if (!row) return fail("not_found", `There is no agent called @${a.handle}.`);
429 const before = definitionOf(row);
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)430 // The built-in @g1t keeps who it is and its job; the rest is the workspace's.
431 const allowed = row.builtin ? builtinChanges(before, a.changes) : { ok: true as const, value: a.changes };
432 if (!allowed.ok) return fail("invalid", allowed.message);
433 const checked = applyChanges(before, allowed.value, TEMPLATE_IDS, { builtin: !!row.builtin });
Chat and workspace agents: channels, DMs and named agents you talk to434 if (!checked.ok) return fail("invalid", checked.message);
435 const definition = checked.value;
436 if (JSON.stringify(definition) === JSON.stringify(before)) return ok(toAgent(row, new Date()));
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)437 if (definition.team !== before.team) {
438 const team = await this.teamExists(a.workspace, a.viewer!, definition.team);
439 if (!team.ok) return team;
440 }
Chat and workspace agents: channels, DMs and named agents you talk to441 if (definition.handle !== before.handle && (await this.handleTaken(managed.value, definition.handle, row.id))) {
442 return fail("conflict", `${a.workspace} already has an agent called @${definition.handle}.`);
443 }
444 const now = new Date().toISOString();
445 const version = row.version + 1;
446 const by = a.viewer!.username;
447 try {
448 const [updated] = await this.db.batch([
449 // Only from the version read: two owners saving at once never lose
450 // one's change silently; the second is told to look again.
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)451 updateAgent(this.db, row.id, row.version, definition, { version, updated_at: now }),
Chat and workspace agents: channels, DMs and named agents you talk to452 this.db
453 .prepare(
454 `INSERT INTO agent_versions (agent_id, version, definition, changed_by, created_at)
455 SELECT ?1, ?2, ?3, ?4, ?5 WHERE EXISTS (SELECT 1 FROM agents WHERE id = ?1 AND version = ?2 AND updated_at = ?5)`,
456 )
457 .bind(row.id, version, JSON.stringify(definition), by, now),
458 ]);
459 if (!updated.meta.changes) return fail("conflict", `@${before.handle} was changed meanwhile. Reload it and try again.`);
460 } catch (error) {
461 if (String(error).includes("UNIQUE")) return fail("conflict", `${a.workspace} already has an agent called @${definition.handle}.`);
462 throw error;
463 }
464 const renamed = definition.handle !== before.handle ? ` (was @${before.handle})` : "";
465 this.audit(a.viewer!, a.workspace, "update_agent", definition.handle, `Changed @${definition.handle}${renamed} to version ${version}`);
466 const saved = await this.row(managed.value, definition.handle);
467 return ok(toAgent(saved!, new Date()));
468 }
469
470 async archive(a: { workspace: string; handle: string; viewer: User | null }): Promise<Result<null>> {
471 const managed = await this.managed(a.workspace, a.viewer);
472 if (!managed.ok) return managed;
473 const row = await this.row(managed.value, a.handle);
474 if (!row) return fail("not_found", `There is no agent called @${a.handle}.`);
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)475 if (row.builtin) return fail("invalid", "@g1t is every workspace's orchestrator and can't be archived. You can set its budget to limit it.");
Chat and workspace agents: channels, DMs and named agents you talk to476 const now = new Date().toISOString();
477 await this.db.prepare("UPDATE agents SET archived_at = ?, updated_at = ? WHERE id = ? AND archived_at IS NULL").bind(now, now, row.id).run();
478 this.audit(a.viewer!, a.workspace, "archive_agent", row.handle, `Archived @${row.handle}`);
479 return ok(null);
480 }
481
482 /**
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)483 * Makes the workspace's built-in @g1t if it does not exist yet: an
484 * ordinary agent row, marked builtin, at version 1. Safe to call on
485 * every request; once seen, an isolate does not write again.
486 */
487 private async ensureBuiltin(workspaceId: string): Promise<void> {
488 await ensureBuiltin(this.db, workspaceId);
489 }
490
491 /** Internal: the workspace's @g1t, made if need be, for the chat service. */
492 async builtin(a: { workspace: string; workspace_id: string }): Promise<Result<WorkspaceAgent>> {
493 if (typeof a?.workspace_id !== "string" || !a.workspace_id) return fail("invalid", "Name the workspace by id.");
494 await this.ensureBuiltin(a.workspace_id);
495 const now = new Date();
496 const row = await this.db
497 .prepare(selectAgents("a.workspace_id = ?3 AND a.builtin = 1"))
498 .bind(...periods(now), a.workspace_id)
499 .first<Row>();
500 return row ? ok(toAgent(row, now)) : fail("not_found", "This workspace's @g1t could not be made.");
501 }
502
503 /**
Chat and workspace agents: channels, DMs and named agents you talk to504 * Hands a message to the agent's desk, which answers it in the
505 * background. Returns as soon as the desk holds it.
506 */
507 async deliver(delivery: AgentDelivery): Promise<Result<null>> {
508 const fields = ["workspace", "workspace_id", "channel_id", "agent_id", "message_id", "asked_by"] as const;
509 if (!delivery || fields.some((field) => typeof delivery[field] !== "string" || !delivery[field])) {
510 return fail("invalid", "A delivery names the workspace, channel, agent, message and who asked.");
511 }
512 if (delivery.channel_kind !== "channel" && delivery.channel_kind !== "dm") return fail("invalid", "channel_kind is channel or dm.");
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)513 await this.ensureBuiltin(delivery.workspace_id);
Chat and workspace agents: channels, DMs and named agents you talk to514 const desk = this.env.DESKS.get(this.env.DESKS.idFromName(delivery.agent_id));
515 await desk.take({ ...delivery, hops: Math.max(0, Math.floor(Number(delivery.hops) || 0)), thread_root: delivery.thread_root ?? null });
516 return ok(null);
517 }
518
519 private versionStatement(agentId: string, version: number, d: Definition, by: string, at: string): D1PreparedStatement {
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)520 return versionStatement(this.db, agentId, version, d, by, at);
Chat and workspace agents: channels, DMs and named agents you talk to521 }
522
523 /**
524 * Records a change to an agent in the workspace's audit log, after the
525 * answer. Never fails the change: a log that cannot be written is logged.
526 * The events service's audit contract speaks camelCase (Rust's
527 * `NewAuditEntry`).
528 */
The Marketplace is where a workspace finds what to add, reached from Apps: the agent catalog of roles with who already does each job and Add to workspace; every integration with whether it's connected; g1t's own extensions, listed as Soon; and requests, where members ask and owners add or turn down, each told by a notification. Underneath, an extension is a manifest (publisher and tier, source and version from a tag, scopes, the domains its data goes to, its page on g1tusercontent.com, no price) and an install pins its version with an off switch, a budget and the audit log. Apps lists only what was added from the Marketplace, with pins; g1t's own pages stay in their sidebars, and AI Gateway joins Workspace's. The marketplace guide says how.529 private audit(actor: User, workspace: string, action: string, handle: string, message: string, area: "agents" | "marketplace" = "agents"): void {
Chat and workspace agents: channels, DMs and named agents you talk to530 const kind = actor.kind === "workspace" ? "workspace" : actor.kind === "agent" ? "agent" : actor.kind === "system" ? "system" : "person";
531 const entry = {
532 actorKind: kind,
533 actor: actor.username,
534 actorId: actor.id,
535 agent: null,
536 onBehalfOf: null,
537 runId: null,
538 runKind: null,
539 credentialId: null,
540 action,
541 surface: "web",
542 workspace: workspace.toLowerCase(),
543 repo: null,
544 number: null,
545 gitRef: null,
The Marketplace is where a workspace finds what to add, reached from Apps: the agent catalog of roles with who already does each job and Add to workspace; every integration with whether it's connected; g1t's own extensions, listed as Soon; and requests, where members ask and owners add or turn down, each told by a notification. Underneath, an extension is a manifest (publisher and tier, source and version from a tag, scopes, the domains its data goes to, its page on g1tusercontent.com, no price) and an install pins its version with an off switch, a budget and the audit log. Apps lists only what was added from the Marketplace, with pins; g1t's own pages stay in their sidebars, and AI Gateway joins Workspace's. The marketplace guide says how.546 path: `${area}/${handle}`,
Chat and workspace agents: channels, DMs and named agents you talk to547 outcome: "allowed",
The Marketplace is where a workspace finds what to add, reached from Apps: the agent catalog of roles with who already does each job and Add to workspace; every integration with whether it's connected; g1t's own extensions, listed as Soon; and requests, where members ask and owners add or turn down, each told by a notification. Underneath, an extension is a manifest (publisher and tier, source and version from a tag, scopes, the domains its data goes to, its page on g1tusercontent.com, no price) and an install pins its version with an off switch, a budget and the audit log. Apps lists only what was added from the Marketplace, with pins; g1t's own pages stay in their sidebars, and AI Gateway joins Workspace's. The marketplace guide says how.548 rule: area === "marketplace" && action === "request_install" ? "member" : "owner",
Chat and workspace agents: channels, DMs and named agents you talk to549 result: "ok",
550 message,
551 requestId: `req_${crypto.randomUUID()}`,
552 };
553 this.defer(
554 this.env.EVENTS.fetch("https://service/rpc/audit_record", {
555 method: "POST",
556 headers: { "content-type": "application/json" },
557 body: JSON.stringify({ entries: [entry] }),
558 })
559 .then((response) => {
560 if (!response.ok) throw new Error(`status ${response.status}`);
561 })
562 .catch((error: unknown) => console.error("agents: audit entry not recorded", action, handle, String(error))),
563 );
564 }
565}
566
567/** One RPC method's answer. */
568async function answer(service: Agents, method: string, args: any): Promise<Response> {
569 switch (method) {
570 case "list":
571 return Response.json(await service.list(args));
572 case "get":
573 return Response.json(await service.get(args));
574 case "by_ids":
575 return Response.json(await service.byIds(args));
576 case "create":
577 return Response.json(await service.create(args));
578 case "update":
579 return Response.json(await service.update(args));
580 case "archive":
581 return Response.json(await service.archive(args));
Merge the workspace shell: navigation and phone shell, g1t as orchestrator, agents in roles with audience-checked reads, reactions and custom emoji, live notifications and browser push, the homepage tour (agents 0002, chat 0002)582 case "builtin":
583 return Response.json(await service.builtin(args));
Chat and workspace agents: channels, DMs and named agents you talk to584 case "templates":
585 return Response.json(TEMPLATES);
586 case "deliver":
587 return Response.json(await service.deliver(args));
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked588 case "overview":
589 return Response.json(await service.view(args, (ctx) => views.overview(ctx)));
590 case "sessions":
591 return Response.json(await service.view(args, (ctx) => views.listSessions(ctx, args)));
592 case "session":
593 return Response.json(await service.view(args, (ctx) => views.sessionDetail(ctx, args.id)));
594 case "stop_session":
595 return Response.json(await service.view(args, (ctx) => views.stopSession(ctx, args.id)));
596 case "approve_session":
597 return Response.json(await service.view(args, (ctx) => views.approveSession(ctx, args.id, args.cap_micros)));
598 case "steer_session":
599 return Response.json(await service.view(args, (ctx) => views.steerSession(ctx, args.id, args.body)));
600 case "memories":
601 return Response.json(await service.view(args, (ctx) => views.memories(ctx, args.handle)));
602 case "remember":
603 return Response.json(await service.view(args, (ctx) => views.remember(ctx, args.handle, args.input)));
604 case "update_memory":
605 return Response.json(await service.view(args, (ctx) => views.updateMemory(ctx, args.handle, args.id, args.changes)));
606 case "forget":
607 return Response.json(await service.view(args, (ctx) => views.forget(ctx, args.handle, args.id)));
608 case "routines":
609 return Response.json(await service.view(args, (ctx) => views.routines(ctx, args.handle)));
610 case "save_routine":
611 return Response.json(await service.view(args, (ctx) => views.saveRoutine(ctx, args.handle, args.input, args.id ?? null)));
612 case "delete_routine":
613 return Response.json(await service.view(args, (ctx) => views.deleteRoutine(ctx, args.handle, args.id)));
614 case "run_routine":
615 return Response.json(await service.view(args, (ctx) => views.runRoutineNow(ctx, args.handle, args.id)));
616 case "spend":
The Spend page says what a workspace spent, where it went and the budgets that hold it, in Workspace under Money: spent at price, agents' spend and the spend limit; by day; by agent, person, channel, model, kind of work and product; budgets from the workspace down to each person, agent and task, with what happens at 100%; the costliest tasks, each with a receipt of every session at the provider's price and what was charged; and how it's priced. Everyone sees what agents spent for them, and owners and billing managers the whole workspace; each person can have a monthly budget agents keep to, and the spend pill in the top bar shows your month or the workspace's against its limit. The spend guide says how.617 return Response.json(await service.view(args, (ctx) => views.spend(ctx, args.handle ?? null, { period: args.period, person: args.person })));
618 case "person_budgets":
619 return Response.json(await service.view(args, (ctx) => views.personBudgetsView(ctx)));
620 case "set_person_budget":
621 return Response.json(await service.view(args, (ctx) => views.setPersonBudget(ctx, args.username, args.monthly_micros)));
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked622 case "activity":
623 return Response.json(await service.view(args, (ctx) => views.activity(ctx, args.handle)));
624 case "versions":
625 return Response.json(await service.view(args, (ctx) => views.versions(ctx, args.handle)));
Cards you act on in chat; agents comment and review as themselves; names shown cleanly; commits on the calendar626 case "card_action":
627 return Response.json(await service.cardAction(args));
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked628 case "policy":
629 return Response.json(await service.view(args, (ctx) => views.policy(ctx)));
The Marketplace is where a workspace finds what to add, reached from Apps: the agent catalog of roles with who already does each job and Add to workspace; every integration with whether it's connected; g1t's own extensions, listed as Soon; and requests, where members ask and owners add or turn down, each told by a notification. Underneath, an extension is a manifest (publisher and tier, source and version from a tag, scopes, the domains its data goes to, its page on g1tusercontent.com, no price) and an install pins its version with an off switch, a budget and the audit log. Apps lists only what was added from the Marketplace, with pins; g1t's own pages stay in their sidebars, and AI Gateway joins Workspace's. The marketplace guide says how.630 case "install_requests":
631 return Response.json(await service.installRequests(args));
632 case "request_install":
633 return Response.json(await service.requestInstall(args));
634 case "resolve_install_request":
635 return Response.json(await service.resolveInstallRequest(args));
636 case "extension_installs":
637 return Response.json(await service.extensionInstalls(args));
638 case "install_extension":
639 return Response.json(await service.installExtension(args));
640 case "set_extension_enabled":
641 return Response.json(await service.setExtensionEnabled(args));
642 case "set_extension_budget":
643 return Response.json(await service.setExtensionBudget(args));
644 case "uninstall_extension":
645 return Response.json(await service.uninstallExtension(args));
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked646 case "set_policy":
647 return Response.json(await service.view(args, (ctx) => views.setPolicy(ctx, args.policy)));
Chat and workspace agents: channels, DMs and named agents you talk to648 default:
649 return new Response("Unknown method\n", { status: 404 });
650 }
651}
652
653export default {
654 async fetch(request: Request, env: Env, ctx: ExecutionContext): Promise<Response> {
655 const match = new URL(request.url).pathname.match(/^\/rpc\/([a-z_]+)$/);
656 if (request.method !== "POST" || !match) return new Response("Not found\n", { status: 404 });
657 // A replica near the caller when it asks for one (@g1t/contracts d1.ts).
658 const opened = openD1(env.DB, request);
659 const service = new Agents(Object.create(env, { DB: { value: opened.db } }) as Env, (work) => ctx.waitUntil(work));
660 const args = (await request.json().catch(() => ({}))) as any;
661 return opened.finish(await answer(service, match[1], args));
662 },
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked663
Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened664 /** Events routines run on, from the events service (SUBSCRIBER_AGENTS). */
665 async queue(batch: MessageBatch<unknown>, env: Env): Promise<void> {
666 await onEvents(env as unknown as SessionEnv, batch.messages.map((message) => message.body as G1tEvent));
667 batch.ackAll();
668 },
669
Agents work in sessions: bounded, visible, steerable work spun off from chat, with subagents and colleagues in a tree paid by its root; memory with sources and scopes; routines; a workspace budget for every agent; agents file issues for whoever asked670 /** Every few minutes: routines that are due, and session steps a desk lost. */
671 async scheduled(_controller: ScheduledController, env: Env, ctx: ExecutionContext): Promise<void> {
672 const sessions = env as unknown as SessionEnv;
673 ctx.waitUntil(
674 Promise.all([
675 runDue(sessions).catch((error: unknown) => console.error("agents: routines did not run", String(error))),
676 sweep(sessions).catch((error: unknown) => console.error("agents: the session sweep failed", String(error))),
677 ]),
678 );
679 },
Chat and workspace agents: channels, DMs and named agents you talk to680} satisfies ExportedHandler<Env>;

This file's history is long; its oldest lines are credited to the oldest commit read.