Skip to content
707 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.

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 asked1/**
2 * What Agents mode reads and changes beyond definitions: sessions, memory,
3 * routines, spend, activity, versions and the workspace's agent policy.
4 * Called by the RPC methods in index.ts once they know who is asking and
5 * that they may see the workspace.
6 *
7 * Privacy follows the conversations work came from. A session, a reply or
8 * a memory from a conversation the viewer is not in shows that it happened
9 * and what it cost, never what it was about, owners included: owners
10 * control money and agents, not other people's conversations.
11 */
12import {
13 type AgentActivity,
14 type AgentMemory,
15 type AgentPolicy,
16 type AgentRoutine,
17 type AgentSession,
18 type AgentSessionDetail,
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.19 type AgentTeamContext,
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 asked20 type AgentSpendBreakdown,
21 type AgentVersion,
22 type AgentsOverview,
23 type AgentMemoryScope,
24 type NewRoutine,
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.25 type PersonBudgets,
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 asked26 type Result,
Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened27 type RoutineSuggestion,
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 asked28 type SessionEvent,
29 type SpendSlice,
30 type User,
31 type WorkspaceAgent,
32 chatClient,
33 fail,
34 newId,
35 ok,
36} from "@g1t/contracts";
37
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.38import { monthKey, spendSpan } from "./budget.ts";
39import { cleanUsername, personBudgets } from "./person-budget.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 asked40import { type MemoryRow, type MemoryViewer, changeableBy, cleanFact, toMemory, visibleTo } from "./memory.ts";
41import { DEFAULT_POLICY, checkPolicy, readPolicy } from "./policy.ts";
42import { type RoutineRow, MAX_ROUTINES, checkRoutine, newRoutineId, nextRun, runRoutine, toRoutine } from "./routines.ts";
43import { type SessionEnv, type SessionRow, LIVE, approve, sessionRow, steer, stop, toSession } from "./sessions.ts";
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.44import { type Row, definitionOf, isPersonal, periods, selectAgents, toAgent } from "./store.ts";
Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened45import { suggestRoutines } from "./suggest.ts";
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.46import { teamsSection } from "./teammates.ts";
47import { loadTeams } from "./ports.ts";
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.48import { canSeeAgent, isOwnerOf } from "./access.ts";
49import { BUILDER_ID, BUILDER_LABEL } from "./builder.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 asked50
51export type ViewContext = {
52 env: SessionEnv;
53 db: D1Database;
54 slug: string;
55 workspaceId: string;
56 viewer: User;
57 /** Whether the viewer owns the workspace (or is its token). */
58 owner: boolean;
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.59 /** Records a change in the workspace's audit log, as the viewer (index.ts). */
60 audit?: (action: string, handle: string, message: string) => void;
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 asked61};
62
63type AgentFace = { handle: string; display_name: string; avatar_seed: string; team: string | null; department: string };
64
65/** The workspace's agents by id, archived ones too, for names on sessions and spend. */
66async function faces(ctx: ViewContext): Promise<Map<string, AgentFace>> {
67 const rows = await ctx.db
68 .prepare("SELECT id, handle, display_name, avatar_seed, team, department FROM agents WHERE workspace_id = ?")
69 .bind(ctx.workspaceId)
70 .all<{ id: string } & AgentFace>();
71 return new Map(rows.results.map((r) => [r.id, { ...r, avatar_seed: r.avatar_seed || r.handle }]));
72}
73
74/**
75 * Which of these conversations the viewer is in (or can read: a public
76 * channel). Asked of chat once per conversation, at most 60.
77 */
78async function readable(ctx: ViewContext, channelIds: string[]): Promise<Set<string>> {
79 const out = new Set<string>();
80 if (ctx.viewer.kind === "workspace") return out;
81 const chat = chatClient(ctx.env.CHAT);
82 const ids = [...new Set(channelIds)].slice(0, 60);
83 await Promise.all(
84 ids.map(async (id) => {
85 const audience = await chat.audience(ctx.slug, id).catch(() => null);
86 if (audience?.ok && (audience.value.kind === "public" || audience.value.member_user_ids.includes(ctx.viewer.id))) out.add(id);
87 }),
88 );
89 return out;
90}
91
92async function sessionsOut(ctx: ViewContext, rows: SessionRow[], agents?: Map<string, AgentFace>): Promise<AgentSession[]> {
93 const names = agents ?? (await faces(ctx));
94 const can = await readable(ctx, rows.map((r) => r.channel_id));
95 return rows.map((row) => toSession(row, names.get(row.agent_id) ?? null, can.has(row.channel_id)));
96}
97
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.98/** The agent by handle, if the viewer may see it: a personal agent only its member and the owners. */
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 asked99async function agentByHandle(ctx: ViewContext, handle: string): Promise<Row | null> {
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.100 const row = await ctx.db
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 asked101 .prepare("SELECT * FROM agents WHERE workspace_id = ? AND handle = ? AND archived_at IS NULL")
102 .bind(ctx.workspaceId, String(handle ?? "").trim().replace(/^@/, "").toLowerCase())
103 .first<Row>();
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.104 return row && canSeeAgent(ctx.viewer, ctx.slug, row) ? row : null;
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}
106
People and teams are front and centre: one directory of people and agents with presence, local time, titles, teams and what each owns; profiles with manager and reports and the agents they work with; an org chart with each team's agents beside the person who leads it; and teams of any mix, with a lead, a channel, a budget agents keep to and the agents on them. Every agent is told its teams each turn (who leads, who owns what, who's around and who to page), and the team page shows exactly what. Member management is Members and invites; the people and teams guide says how.107// ── Teams ─────────────────────────────────────────────────────────────────
108
109/**
110 * What an agent is told about its teams this turn, word for word as it
111 * gets it (teammates.ts), for its People profile and its teams' pages.
112 * Null when it is on no visible team.
113 */
114export async function teamContext(ctx: ViewContext, handle: string): Promise<Result<AgentTeamContext>> {
115 const agent = await agentByHandle(ctx, handle);
116 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
117 const here = await loadTeams(ctx.env, ctx.slug, ctx.workspaceId, { id: agent.id, team: agent.team ?? null });
118 if (!here) return fail("not_found", "Its teams could not be read just now.");
119 return ok({ handle: agent.handle, teams: here.teams.map((team) => team.slug), text: teamsSection(agent.id, here, new Date()) });
120}
121
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 asked122// ── Sessions ──────────────────────────────────────────────────────────────
123
124export async function listSessions(ctx: ViewContext, filter: { handle?: string | null; status?: "live" | "done" | null; limit?: number | null }): Promise<Result<AgentSession[]>> {
125 const limit = Math.min(200, Math.max(1, Math.floor(Number(filter.limit) || 50)));
126 const where = ["workspace_id = ?"];
127 const binds: (string | number)[] = [ctx.workspaceId];
128 if (filter.handle) {
129 const agent = await agentByHandle(ctx, filter.handle);
130 if (!agent) return fail("not_found", `There is no agent called @${filter.handle}.`);
131 where.push("agent_id = ?");
132 binds.push(agent.id);
133 }
134 if (filter.status === "live") where.push(`status IN (${LIVE.map(() => "?").join(", ")})`), binds.push(...LIVE);
135 if (filter.status === "done") where.push(`status NOT IN (${LIVE.map(() => "?").join(", ")})`), binds.push(...LIVE);
136 const rows = await ctx.db
137 .prepare(`SELECT * FROM agent_sessions WHERE ${where.join(" AND ")} ORDER BY created_at DESC LIMIT ?`)
138 .bind(...binds, limit)
139 .all<SessionRow>();
140 return ok(await sessionsOut(ctx, rows.results));
141}
142
143export async function sessionDetail(ctx: ViewContext, id: string): Promise<Result<AgentSessionDetail>> {
144 const row = await sessionRow(ctx.db, String(id ?? ""));
145 if (!row || row.workspace_id !== ctx.workspaceId) return fail("not_found", "There is no such session.");
146 const agents = await faces(ctx);
147 const treeRows = await ctx.db.prepare("SELECT * FROM agent_sessions WHERE root_id = ? ORDER BY created_at LIMIT 100").bind(row.root_id).all<SessionRow>();
148 const [session] = await sessionsOut(ctx, [row], agents);
149 const tree = await sessionsOut(ctx, treeRows.results, agents);
150 let events: SessionEvent[] = [];
151 if (session.visible) {
152 const rows = await ctx.db
153 .prepare("SELECT seq, kind, by_name, body, tool, outcome, created_at FROM agent_session_events WHERE session_id = ? ORDER BY seq LIMIT 1000")
154 .bind(row.id)
155 .all<{ seq: number; kind: SessionEvent["kind"]; by_name: string | null; body: string; tool: string | null; outcome: string | null; created_at: string }>();
156 events = rows.results.map((e) => ({ seq: e.seq, kind: e.kind, by: e.by_name, body: e.body, tool: e.tool, outcome: e.outcome, created_at: e.created_at }));
157 }
158 const live = LIVE.includes(row.status as (typeof LIVE)[number]);
159 return ok({
160 session,
161 events,
162 tree,
163 can_stop: session.visible && live,
164 can_steer: session.visible,
165 can_approve: row.status === "needs_approval" && (ctx.owner || (session.visible && row.asked_by === ctx.viewer.id && ctx.owner)),
166 });
167}
168
169async function visibleSession(ctx: ViewContext, id: string): Promise<Result<SessionRow>> {
170 const row = await sessionRow(ctx.db, String(id ?? ""));
171 if (!row || row.workspace_id !== ctx.workspaceId) return fail("not_found", "There is no such session.");
172 const can = await readable(ctx, [row.channel_id]);
173 if (!can.has(row.channel_id)) return fail("not_found", "There is no such session.");
174 return ok(row);
175}
176
177export async function stopSession(ctx: ViewContext, id: string): Promise<Result<AgentSession>> {
178 const found = await visibleSession(ctx, id);
179 if (!found.ok) return found;
180 if (!LIVE.includes(found.value.status as (typeof LIVE)[number])) return fail("invalid", "That session is already over.");
181 const row = await stop(ctx.env, found.value, ctx.viewer.username);
182 return ok((await sessionsOut(ctx, [row]))[0]);
183}
184
185export async function steerSession(ctx: ViewContext, id: string, body: string): Promise<Result<AgentSession>> {
186 const found = await visibleSession(ctx, id);
187 if (!found.ok) return found;
188 const text = typeof body === "string" ? body.trim() : "";
189 if (!text) return fail("invalid", "Say something to the session.");
190 const row = await steer(ctx.env, found.value, ctx.viewer.username, text.slice(0, 4000));
191 return ok((await sessionsOut(ctx, [row]))[0]);
192}
193
194/** Owners raise a session's cap; it must end up above what it has spent. */
195export async function approveSession(ctx: ViewContext, id: string, capMicros: number): Promise<Result<AgentSession>> {
196 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners can approve more spend.");
197 const row = await sessionRow(ctx.db, String(id ?? ""));
198 if (!row || row.workspace_id !== ctx.workspaceId) return fail("not_found", "There is no such session.");
199 if (row.status !== "needs_approval") return fail("invalid", "That session isn't waiting for approval.");
200 const cap = Math.floor(Number(capMicros));
201 if (!Number.isFinite(cap) || cap <= row.charged_micros || cap > 1_000_000_000) return fail("invalid", "The new cap must be above what it has spent.");
202 const fresh = await approve(ctx.env, row, ctx.viewer.username, cap);
203 return ok((await sessionsOut(ctx, [fresh]))[0]);
204}
205
206// ── Memory ────────────────────────────────────────────────────────────────
207
208async function memoryViewer(ctx: ViewContext, rows: MemoryRow[]): Promise<MemoryViewer> {
209 const channels = await readable(ctx, rows.filter((r) => r.scope === "channel").map((r) => r.scope_ref));
210 return { id: ctx.viewer.id, owner: ctx.owner, inChannel: (id) => channels.has(id) };
211}
212
213export async function memories(ctx: ViewContext, handle: string): Promise<Result<AgentMemory[]>> {
214 const agent = await agentByHandle(ctx, handle);
215 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
216 const rows = await ctx.db.prepare("SELECT * FROM agent_memories WHERE agent_id = ? ORDER BY pinned DESC, updated_at DESC LIMIT 500").bind(agent.id).all<MemoryRow>();
217 const viewer = await memoryViewer(ctx, rows.results);
218 return ok(rows.results.filter((row) => visibleTo(row, viewer)).map(toMemory));
219}
220
221/**
222 * A fact a person gives an agent. Workspace facts are the owners'; a
223 * channel fact needs the person to be in that channel; a person fact is
224 * always their own.
225 */
226export async function remember(ctx: ViewContext, handle: string, input: { body: string; scope: AgentMemoryScope; scope_ref?: string | null }): Promise<Result<AgentMemory>> {
227 const agent = await agentByHandle(ctx, handle);
228 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
229 const body = cleanFact(input?.body);
230 if (!body) return fail("invalid", "Say what it should remember.");
231 let scope: AgentMemoryScope = input?.scope === "workspace" || input?.scope === "channel" ? input.scope : "person";
232 let ref = "";
233 let label: string | null = null;
234 if (scope === "workspace" && !ctx.owner) return fail("forbidden", "Only owners give an agent facts for the whole workspace.");
235 if (scope === "channel") {
236 ref = String(input.scope_ref ?? "");
237 const can = await readable(ctx, [ref]);
238 if (!can.has(ref)) return fail("forbidden", "You can only give it facts for conversations you're in.");
239 const audience = await chatClient(ctx.env.CHAT).audience(ctx.slug, ref).catch(() => null);
240 label = audience?.ok && "name" in audience.value ? ((audience.value as { name?: string | null }).name ?? null) : null;
241 }
242 if (scope === "person") {
243 scope = "person";
244 ref = ctx.viewer.id;
245 label = ctx.viewer.username;
246 }
247 const id = newId("mem");
248 const now = new Date().toISOString();
249 await ctx.db
250 .prepare(
251 `INSERT INTO agent_memories (id, agent_id, workspace_id, scope, scope_ref, scope_label, body, source_kind, source_ref, source_label, created_by, created_by_kind, pinned, created_at, updated_at)
252 VALUES (?, ?, ?, ?, ?, ?, ?, 'person', ?, ?, ?, 'user', 1, ?, ?)`,
253 )
254 .bind(id, agent.id, ctx.workspaceId, scope, ref, label, body, ctx.viewer.username, `@${ctx.viewer.username}`, ctx.viewer.username, now, now)
255 .run();
256 const row = await ctx.db.prepare("SELECT * FROM agent_memories WHERE id = ?").bind(id).first<MemoryRow>();
257 return ok(toMemory(row!));
258}
259
260async function changeable(ctx: ViewContext, handle: string, id: string): Promise<Result<MemoryRow>> {
261 const agent = await agentByHandle(ctx, handle);
262 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
263 const row = await ctx.db.prepare("SELECT * FROM agent_memories WHERE id = ? AND agent_id = ?").bind(String(id ?? ""), agent.id).first<MemoryRow>();
264 if (!row) return fail("not_found", "There is no such memory.");
265 const viewer = await memoryViewer(ctx, [row]);
266 if (!visibleTo(row, viewer)) return fail("not_found", "There is no such memory.");
267 if (!changeableBy(row, viewer)) return fail("forbidden", "Only owners change what an agent knows for the whole workspace.");
268 return ok(row);
269}
270
271export async function updateMemory(ctx: ViewContext, handle: string, id: string, changes: { body?: string; pinned?: boolean }): Promise<Result<AgentMemory>> {
272 const found = await changeable(ctx, handle, id);
273 if (!found.ok) return found;
274 const body = changes?.body === undefined ? found.value.body : cleanFact(changes.body);
275 if (!body) return fail("invalid", "A memory can't be empty; forget it instead.");
276 const pinned = changes?.pinned === undefined ? found.value.pinned : changes.pinned ? 1 : 0;
277 const now = new Date().toISOString();
278 const edited = body !== found.value.body;
279 await ctx.db
280 .prepare(
281 `UPDATE agent_memories SET body = ?, pinned = ?, updated_at = ?${edited ? ", source_kind = 'person', source_ref = ?, source_label = ?" : ""} WHERE id = ?`,
282 )
283 .bind(...(edited ? [body, pinned, now, ctx.viewer.username, `@${ctx.viewer.username} (corrected)`, found.value.id] : [body, pinned, now, found.value.id]))
284 .run();
285 const row = await ctx.db.prepare("SELECT * FROM agent_memories WHERE id = ?").bind(found.value.id).first<MemoryRow>();
286 return ok(toMemory(row!));
287}
288
289export async function forget(ctx: ViewContext, handle: string, id: string): Promise<Result<null>> {
290 const found = await changeable(ctx, handle, id);
291 if (!found.ok) return found;
292 await ctx.db.prepare("DELETE FROM agent_memories WHERE id = ?").bind(found.value.id).run();
293 return ok(null);
294}
295
296// ── Routines ──────────────────────────────────────────────────────────────
297
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.298/** Routines post where a person and the agent both are; a personal agent is only in its member's DM. */
299const PERSONAL_ROUTINES = "Personal agents can't run routines yet. An owner can promote it to a workspace agent, which can.";
300
Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened301/** An agent's routines, and routines its responsibilities suggest that it doesn't have yet. */
302export async function routines(ctx: ViewContext, handle: string): Promise<Result<{ routines: AgentRoutine[]; suggestions: RoutineSuggestion[] }>> {
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 asked303 const agent = await agentByHandle(ctx, handle);
304 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
305 const rows = await ctx.db.prepare("SELECT * FROM agent_routines WHERE agent_id = ? ORDER BY created_at").bind(agent.id).all<RoutineRow>();
Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened306 const list = rows.results.map(toRoutine);
307 const duties = definitionOf(agent).responsibilities;
308 return ok({ routines: list, suggestions: suggestRoutines(duties, list.map((r) => ({ name: r.name, events: r.events }))) });
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 asked309}
310
311/**
312 * Owners keep an agent's routines. The person who saves one becomes its
313 * sponsor: it runs with their access, in a channel they and the agent are in.
314 */
315export async function saveRoutine(ctx: ViewContext, handle: string, input: NewRoutine, id: string | null): Promise<Result<AgentRoutine>> {
316 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners set up routines.");
317 if (ctx.viewer.kind === "workspace") return fail("invalid", "A routine runs with a person's access: set it up signed in as yourself.");
318 const agent = await agentByHandle(ctx, handle);
319 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.320 if (isPersonal(agent)) return fail("invalid", PERSONAL_ROUTINES);
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 asked321 const checked = checkRoutine(input);
322 if (!checked.ok) return fail("invalid", checked.message);
323 const r = checked.value;
324 const audience = await chatClient(ctx.env.CHAT).audience(ctx.slug, r.channel_id).catch(() => null);
325 if (!audience?.ok || (audience.value.kind !== "public" && !audience.value.member_user_ids.includes(ctx.viewer.id))) {
326 return fail("invalid", "Choose a channel you're in.");
327 }
328 const channels = await chatClient(ctx.env.CHAT).sidebar(ctx.slug, ctx.viewer).catch(() => null);
329 const channelName = channels?.ok ? (channelNameIn(channels.value, r.channel_id) ?? null) : null;
330 const now = new Date();
Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened331 const next = r.enabled !== false && r.schedule ? nextRun(r.schedule, now).toISOString() : null;
332 const schedule = r.schedule ? JSON.stringify(r.schedule) : null;
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 asked333 if (id) {
334 const existing = await ctx.db.prepare("SELECT id FROM agent_routines WHERE id = ? AND agent_id = ?").bind(id, agent.id).first();
335 if (!existing) return fail("not_found", "There is no such routine.");
336 await ctx.db
337 .prepare(
Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened338 `UPDATE agent_routines SET name = ?, instructions = ?, schedule = ?, events = ?, repos = ?, channel_id = ?, channel_name = ?, sponsor = ?, sponsor_username = ?,
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 asked339 enabled = ?, paused_note = NULL, next_run_at = ?, workspace = ?, updated_at = ? WHERE id = ?`,
340 )
Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened341 .bind(r.name, r.instructions, schedule, JSON.stringify(r.events), JSON.stringify(r.repos), r.channel_id, channelName, ctx.viewer.id, ctx.viewer.username, r.enabled !== false ? 1 : 0, next, ctx.slug, now.toISOString(), id)
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 asked342 .run();
343 } else {
344 const count = await ctx.db.prepare("SELECT COUNT(*) AS n FROM agent_routines WHERE agent_id = ?").bind(agent.id).first<{ n: number }>();
345 if ((count?.n ?? 0) >= MAX_ROUTINES) return fail("invalid", `An agent keeps at most ${MAX_ROUTINES} routines.`);
346 id = newRoutineId();
347 await ctx.db
348 .prepare(
Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened349 `INSERT INTO agent_routines (id, agent_id, workspace_id, workspace, name, instructions, schedule, events, repos, channel_id, channel_name, sponsor, sponsor_username, enabled, next_run_at, created_at, updated_at)
350 VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`,
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 asked351 )
Routines run when something happens: a pull request ready for review or merged, checks or a deploy failing, an issue opened352 .bind(id, agent.id, ctx.workspaceId, ctx.slug, r.name, r.instructions, schedule, JSON.stringify(r.events), JSON.stringify(r.repos), r.channel_id, channelName, ctx.viewer.id, ctx.viewer.username, r.enabled !== false ? 1 : 0, next, now.toISOString(), now.toISOString())
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 asked353 .run();
354 }
355 const row = await ctx.db.prepare("SELECT * FROM agent_routines WHERE id = ?").bind(id).first<RoutineRow>();
356 return ok(toRoutine(row!));
357}
358
359function channelNameIn(sidebar: unknown, id: string): string | null {
360 const seen: unknown[] = [sidebar];
361 while (seen.length) {
362 const value = seen.pop();
363 if (Array.isArray(value)) seen.push(...value);
364 else if (value && typeof value === "object") {
365 const v = value as Record<string, unknown>;
366 if (v.id === id && typeof v.name === "string") return v.name;
367 seen.push(...Object.values(v));
368 }
369 }
370 return null;
371}
372
373export async function deleteRoutine(ctx: ViewContext, handle: string, id: string): Promise<Result<null>> {
374 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners change routines.");
375 const agent = await agentByHandle(ctx, handle);
376 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
377 await ctx.db.prepare("DELETE FROM agent_routines WHERE id = ? AND agent_id = ?").bind(String(id ?? ""), agent.id).run();
378 return ok(null);
379}
380
381export async function runRoutineNow(ctx: ViewContext, handle: string, id: string): Promise<Result<AgentSession>> {
382 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners run routines by hand.");
383 const agent = await agentByHandle(ctx, handle);
384 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.385 if (isPersonal(agent)) return fail("invalid", PERSONAL_ROUTINES);
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 asked386 const routine = await ctx.db.prepare("SELECT * FROM agent_routines WHERE id = ? AND agent_id = ?").bind(String(id ?? ""), agent.id).first<RoutineRow>();
387 if (!routine) return fail("not_found", "There is no such routine.");
388 const ran = await runRoutine(ctx.env, routine, agent, ctx.slug);
389 if (!ran.ok) return fail("invalid", ran.message);
390 const row = await sessionRow(ctx.db, ran.session);
391 return ok((await sessionsOut(ctx, [row!]))[0]);
392}
393
394// ── Spend ─────────────────────────────────────────────────────────────────
395
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.396const KIND_LABELS: Record<string, string> = { [BUILDER_ID]: BUILDER_LABEL, reply: "Chat replies", chat: "Sessions", routine: "Routines", helper: "Helping colleagues", subagent: "Subagents" };
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 asked397
398function slices(rows: { key: string | null; micros: number; n: number }[], label: (key: string) => string): SpendSlice[] {
399 return rows
400 .filter((r) => r.micros > 0 || r.n > 0)
401 .map((r) => ({ key: r.key ?? "", label: label(r.key ?? ""), micros: r.micros, count: r.n }))
402 .sort((a, b) => b.micros - a.micros);
403}
404
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.405/** The most channels named in a breakdown; the rest of the channels fold into one slice. */
406const CHANNEL_SLICES = 12;
407
408/**
409 * Spend by where it was asked: the costliest channels by name, if the
410 * viewer can read them; direct messages together; channels they can't
411 * read, and channels past the first few, folded together without names.
412 */
413async function channelSlices(ctx: ViewContext, rows: { key: string | null; label: string | null; micros: number; n: number }[]): Promise<SpendSlice[]> {
414 const ranked = rows.filter((r) => r.micros > 0 || r.n > 0).sort((a, b) => b.micros - a.micros);
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.415 const named = ranked.filter((r) => r.key && r.key !== "dm" && r.key !== BUILDER_ID).slice(0, CHANNEL_SLICES);
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.416 const can = await readable(ctx, named.map((r) => r.key!));
417 const out: SpendSlice[] = [];
418 const hidden: SpendSlice = { key: "private", label: "Channels you're not in", micros: 0, count: 0 };
419 const rest: SpendSlice = { key: "other", label: "Other channels", micros: 0, count: 0 };
420 for (const row of ranked) {
421 if (row.key === "dm") out.push({ key: "dm", label: "Direct messages", micros: row.micros, count: row.n });
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.422 else if (row.key === BUILDER_ID) out.push({ key: BUILDER_ID, label: BUILDER_LABEL, micros: row.micros, count: row.n });
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.423 else if (!named.includes(row)) (rest.micros += row.micros), (rest.count += row.n);
424 else if (can.has(row.key!)) out.push({ key: row.key!, label: row.label ? `#${row.label}` : "A channel", micros: row.micros, count: row.n });
425 else (hidden.micros += row.micros), (hidden.count += row.n);
426 }
427 for (const folded of [hidden, rest]) if (folded.count > 0) out.push(folded);
428 return out.sort((a, b) => b.micros - a.micros);
429}
430
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 asked431/**
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.432 * Budgets per person this month (person-budget.ts): owners see everyone's,
433 * anyone else only their own.
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 asked434 */
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.435export async function personBudgetsView(ctx: ViewContext): Promise<Result<PersonBudgets>> {
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 asked436 const now = new Date();
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.437 const policyRow = await readPolicy(ctx.db, ctx.workspaceId, monthKey(now));
438 const only = ctx.owner ? null : ctx.viewer.kind === "workspace" ? null : ctx.viewer.username.toLowerCase();
439 return ok(await personBudgets(ctx.db, ctx.workspaceId, policyRow.person_monthly_micros, now, only));
440}
441
442/** Gives a person a budget of their own (0: none at all), or with null puts them back on the default. Owners only. */
443export async function setPersonBudget(ctx: ViewContext, username: unknown, monthly: unknown): Promise<Result<PersonBudgets>> {
444 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners set budgets for people.");
445 const name = cleanUsername(username);
446 if (!name) return fail("invalid", "That isn't a username.");
447 if (monthly === null || monthly === undefined) {
448 await ctx.db.prepare("DELETE FROM person_budgets WHERE workspace_id = ? AND username = ?").bind(ctx.workspaceId, name).run();
449 } else {
450 const value = Number(monthly);
451 if (!Number.isFinite(value) || value < 0 || value > 1_000_000_000_000) return fail("invalid", "A budget is a positive amount, or none.");
452 await ctx.db
453 .prepare(
454 `INSERT INTO person_budgets (workspace_id, username, monthly_micros, updated_by, updated_at) VALUES (?1, ?2, ?3, ?4, ?5)
455 ON CONFLICT (workspace_id, username) DO UPDATE SET monthly_micros = ?3, updated_by = ?4, updated_at = ?5`,
456 )
457 .bind(ctx.workspaceId, name, Math.floor(value), ctx.viewer.username, new Date().toISOString())
458 .run();
459 }
460 return personBudgetsView(ctx);
461}
462
463/**
464 * Where the spend went over a span of days (this month unless asked), for
465 * one agent (what it was paid for: its replies and every session it paid
466 * for, colleagues' help included) or for every agent; for everyone, or only
467 * the work one person asked for.
468 */
469export async function spend(ctx: ViewContext, handle: string | null, options: { period?: unknown; person?: unknown } = {}): Promise<Result<AgentSpendBreakdown>> {
470 const now = new Date();
471 const span = spendSpan(options.period, now);
472 const from = `${span.from}T00:00:00.000Z`;
473 const end = new Date(`${span.until}T00:00:00.000Z`);
474 end.setUTCDate(end.getUTCDate() + 1);
475 const to = end.toISOString();
476 const person = options.person == null || options.person === "" ? null : cleanUsername(options.person);
477 if (options.person != null && options.person !== "" && !person) return fail("invalid", "That isn't a username.");
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 asked478 let agentId: string | null = null;
479 if (handle) {
480 const agent = await agentByHandle(ctx, handle);
481 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
482 agentId = agent.id;
483 }
484 const db = ctx.db;
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.485 const who = person ? " AND asked_by_username = ?4" : "";
486 const rFilter = `${agentId ? "agent_id = ?2" : "workspace_id = ?2"} AND created_at >= ?1 AND created_at < ?3${who}`;
487 const sFilter = `${agentId ? "payer_agent_id = ?2" : "workspace_id = ?2"} AND created_at >= ?1 AND created_at < ?3${who}`;
488 const binds: string[] = [from, agentId ?? ctx.workspaceId, to, ...(person ? [person] : [])];
489 // Where it was asked: a channel by id; direct messages (a reply's channel has no name) together.
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.490 // Drafting and trying new agents (builder.ts) is its own kind, agent and place.
491 const union = `SELECT CASE WHEN agent_id = '${BUILDER_ID}' THEN '${BUILDER_ID}' ELSE 'reply' END AS kind, agent_id AS agent, asked_by_username AS person, model, charged_micros AS micros, substr(created_at, 1, 10) AS day,
492 CASE WHEN agent_id = '${BUILDER_ID}' THEN '${BUILDER_ID}' WHEN channel_name IS NULL THEN 'dm' ELSE channel_id END AS channel, channel_name AS channel_label FROM agent_replies WHERE ${rFilter}
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.493 UNION ALL SELECT kind, payer_agent_id AS agent, asked_by_username AS person, model, charged_micros AS micros, substr(created_at, 1, 10) AS day,
494 CASE WHEN channel_kind = 'dm' THEN 'dm' ELSE channel_id END AS channel, channel_name AS channel_label FROM agent_sessions WHERE ${sFilter} AND parent_id IS NULL
495 UNION ALL SELECT kind, payer_agent_id AS agent, asked_by_username AS person, model, 0 AS micros, substr(created_at, 1, 10) AS day,
496 CASE WHEN channel_kind = 'dm' THEN 'dm' ELSE channel_id END AS channel, channel_name AS channel_label FROM agent_sessions WHERE ${sFilter} AND parent_id IS NOT NULL`;
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 asked497 // A child's spend is already counted on its root (sessions.ts), so children add counts, not money.
498 const group = (column: string) =>
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.499 db.prepare(`SELECT ${column} AS key, COALESCE(SUM(micros), 0) AS micros, COUNT(*) AS n FROM (${union}) GROUP BY ${column}`).bind(...binds).all<{ key: string | null; micros: number; n: number }>();
500 const [byKind, byModel, byPerson, byAgent, byDay, byChannel, top, agents] = await Promise.all([
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 asked501 group("kind"),
502 group("model"),
503 group("person"),
504 group("agent"),
505 group("day"),
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.506 db
507 .prepare(`SELECT channel AS key, MAX(channel_label) AS label, COALESCE(SUM(micros), 0) AS micros, COUNT(*) AS n FROM (${union}) GROUP BY channel`)
508 .bind(...binds)
509 .all<{ key: string | null; label: string | null; micros: number; n: number }>(),
510 db.prepare(`SELECT * FROM agent_sessions WHERE ${sFilter} AND parent_id IS NULL ORDER BY charged_micros DESC LIMIT 8`).bind(...binds).all<SessionRow>(),
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 asked511 faces(ctx),
512 ]);
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.513 const channels = await channelSlices(ctx, byChannel.results);
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 asked514 const byTeamMap = new Map<string, SpendSlice>();
515 for (const row of byAgent.results) {
516 const face = agents.get(row.key ?? "");
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.517 const team = row.key === BUILDER_ID ? BUILDER_LABEL : face?.team || face?.department || "No team";
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 asked518 const slice = byTeamMap.get(team) ?? { key: team, label: team, micros: 0, count: 0 };
519 slice.micros += row.micros;
520 slice.count += row.n;
521 byTeamMap.set(team, slice);
522 }
523 const total = byKind.results.reduce((n, r) => n + r.micros, 0);
524 return ok({
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.525 period: span.period,
526 span: span.span,
527 from: span.from,
528 until: span.until,
529 person,
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 asked530 total_micros: total,
531 by_kind: slices(byKind.results, (k) => KIND_LABELS[k] ?? k),
532 by_model: slices(byModel.results, (k) => k || "No model"),
533 by_person: slices(byPerson.results, (k) => (k ? `@${k}` : "Routines and agents")),
534 by_agent: slices(byAgent.results, (k) => {
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.535 if (k === BUILDER_ID) return BUILDER_LABEL;
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 asked536 const face = agents.get(k);
537 return face ? `${face.display_name} (@${face.handle})` : "An archived agent";
538 }),
539 by_team: [...byTeamMap.values()].sort((a, b) => b.micros - a.micros),
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.540 by_channel: channels,
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 asked541 top_sessions: await sessionsOut(ctx, top.results, agents),
542 days: byDay.results.map((r) => ({ day: r.key ?? "", micros: r.micros })).sort((a, b) => a.day.localeCompare(b.day)),
543 });
544}
545
546// ── Activity and versions ────────────────────────────────────────────────
547
548export async function activity(ctx: ViewContext, handle: string): Promise<Result<AgentActivity[]>> {
549 const agent = await agentByHandle(ctx, handle);
550 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
551 const [replies, sessions] = await Promise.all([
552 ctx.db
553 .prepare(
554 "SELECT id, status, channel_id, channel_name, asked_by_username, model, tool_count, charged_micros, created_at, reply_id FROM agent_replies WHERE agent_id = ? ORDER BY created_at DESC LIMIT 60",
555 )
556 .bind(agent.id)
557 .all<{ id: string; status: string; channel_id: string; channel_name: string | null; asked_by_username: string | null; model: string | null; tool_count: number | null; charged_micros: number; created_at: string; reply_id: string | null }>(),
558 ctx.db.prepare("SELECT * FROM agent_sessions WHERE agent_id = ? ORDER BY created_at DESC LIMIT 40").bind(agent.id).all<SessionRow>(),
559 ]);
560 const can = await readable(ctx, [...replies.results.map((r) => r.channel_id), ...sessions.results.map((s) => s.channel_id)]);
561 const items: AgentActivity[] = [
562 ...replies.results.map((r) => {
563 const visible = can.has(r.channel_id);
564 return {
565 id: r.id,
566 kind: "reply" as const,
567 status: r.status,
568 channel_id: r.channel_id,
569 channel_name: visible ? r.channel_name : null,
570 title: null,
571 asked_by_username: visible ? r.asked_by_username : null,
572 model: r.model,
573 tools: r.tool_count ?? 0,
574 charged_micros: r.charged_micros,
575 created_at: r.created_at,
576 visible,
577 ref: visible ? r.reply_id : null,
578 };
579 }),
580 ...sessions.results.map((s) => {
581 const visible = can.has(s.channel_id);
582 return {
583 id: s.id,
584 kind: "session" as const,
585 status: s.status,
586 channel_id: s.channel_id,
587 channel_name: visible ? s.channel_name : null,
588 title: visible ? s.title : null,
589 asked_by_username: visible ? s.asked_by_username : null,
590 model: s.model,
591 tools: s.tool_calls,
592 charged_micros: s.charged_micros,
593 created_at: s.created_at,
594 visible,
595 ref: s.id,
596 };
597 }),
598 ];
599 return ok(items.sort((a, b) => b.created_at.localeCompare(a.created_at)).slice(0, 80));
600}
601
602export async function versions(ctx: ViewContext, handle: string): Promise<Result<AgentVersion[]>> {
603 const agent = await agentByHandle(ctx, handle);
604 if (!agent) return fail("not_found", `There is no agent called @${handle}.`);
605 const rows = await ctx.db
606 .prepare("SELECT version, definition, changed_by, created_at FROM agent_versions WHERE agent_id = ? ORDER BY version DESC LIMIT 50")
607 .bind(agent.id)
608 .all<{ version: number; definition: string; changed_by: string; created_at: string }>();
609 return ok(
610 rows.results.map((r) => {
611 let definition = {};
612 try {
613 definition = JSON.parse(r.definition);
614 } catch {
615 // An unreadable old version shows as empty.
616 }
617 return { version: r.version, changed_by: r.changed_by, created_at: r.created_at, definition };
618 }),
619 );
620}
621
622// ── Policy and overview ──────────────────────────────────────────────────
623
624export async function policy(ctx: ViewContext): Promise<Result<AgentPolicy>> {
625 const row = await readPolicy(ctx.db, ctx.workspaceId, monthKey(new Date()));
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.626 return ok(policyOf(row));
627}
628
629function policyOf(row: AgentPolicy): AgentPolicy {
630 return {
631 monthly_micros: row.monthly_micros,
632 default_agent_monthly_micros: row.default_agent_monthly_micros,
633 default_session_micros: row.default_session_micros,
634 person_monthly_micros: row.person_monthly_micros,
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.635 members_create_agents: row.members_create_agents !== false,
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.636 };
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 asked637}
638
639export async function setPolicy(ctx: ViewContext, changes: Partial<AgentPolicy>): Promise<Result<AgentPolicy>> {
640 if (!ctx.owner) return fail("forbidden", "Only the workspace's owners set its agents' budget.");
641 const current = await policy(ctx);
642 const checked = checkPolicy(current.ok ? current.value : DEFAULT_POLICY, changes ?? {});
643 if (!checked.ok) return fail("invalid", checked.message);
644 const p = checked.value;
645 await ctx.db
646 .prepare(
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.647 `INSERT INTO agent_policies (workspace_id, monthly_micros, default_agent_monthly_micros, default_session_micros, person_monthly_micros, members_create_agents, updated_by, updated_at)
648 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8)
649 ON CONFLICT (workspace_id) DO UPDATE SET monthly_micros = ?2, default_agent_monthly_micros = ?3, default_session_micros = ?4, person_monthly_micros = ?5,
650 members_create_agents = ?6, updated_by = ?7, updated_at = ?8`,
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 asked651 )
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.652 .bind(ctx.workspaceId, p.monthly_micros, p.default_agent_monthly_micros, p.default_session_micros, p.person_monthly_micros, p.members_create_agents ? 1 : 0, ctx.viewer.username, new Date().toISOString())
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 asked653 .run();
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.654 const current_ = current.ok ? current.value : DEFAULT_POLICY;
655 if (current_.members_create_agents !== p.members_create_agents) {
656 ctx.audit?.("set_agent_policy", "policy", p.members_create_agents ? "Let members create personal agents" : "Turned off personal agents for members");
657 }
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 asked658 return ok(p);
659}
660
661export async function overview(ctx: ViewContext): Promise<Result<AgentsOverview>> {
662 const now = new Date();
663 const db = ctx.db;
664 const [rows, policyRow, live, recent, upcoming, breakdown, agentFaces] = await Promise.all([
665 db.prepare(`${selectAgents("a.workspace_id = ?3 AND a.archived_at IS NULL")} ORDER BY a.builtin DESC, a.handle`).bind(...periods(now), ctx.workspaceId).all<Row>(),
666 readPolicy(db, ctx.workspaceId, monthKey(now)),
667 db
668 .prepare(`SELECT * FROM agent_sessions WHERE workspace_id = ? AND status IN (${LIVE.map(() => "?").join(", ")}) ORDER BY created_at DESC LIMIT 60`)
669 .bind(ctx.workspaceId, ...LIVE)
670 .all<SessionRow>(),
671 db
672 .prepare(`SELECT * FROM agent_sessions WHERE workspace_id = ? AND parent_id IS NULL AND status IN ('done','failed','stopped') ORDER BY finished_at DESC LIMIT 12`)
673 .bind(ctx.workspaceId)
674 .all<SessionRow>(),
675 db.prepare("SELECT * FROM agent_routines WHERE workspace_id = ? AND enabled = 1 AND next_run_at IS NOT NULL ORDER BY next_run_at LIMIT 6").bind(ctx.workspaceId).all<RoutineRow>(),
676 spend(ctx, null),
677 faces(ctx),
678 ]);
Anyone makes an agent by describing it: one box drafts the whole agent (name, job, responsibilities, voice, skills to keep, integrations it needs, model limits and budget) as a card to change and try in a chat beside it before creating it, and an agent changes later by saying what to change, shown as a diff before it's saved. Members can make personal agents, which only they talk to and which spend from their own budget, unless owners turn that off; owners promote one to a workspace agent. The agents guide says how.679 // The workspace's agents and the viewer's own personal ones; other members' are theirs.
680 const agents: WorkspaceAgent[] = rows.results.filter((row) => !isPersonal(row) || isOwnerOf(ctx.viewer, row)).map((row) => toAgent(row, now));
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 asked681 const liveSessions = await sessionsOut(ctx, live.results, agentFaces);
682 const liveByAgent: Record<string, number> = {};
683 for (const s of live.results) liveByAgent[s.agent_id] = (liveByAgent[s.agent_id] ?? 0) + 1;
684 const level = policyRow.monthly_micros ? [100, 90, 75].find((l) => (policyRow.spent * 100) / policyRow.monthly_micros! >= l) ?? null : null;
685 return ok({
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.686 policy: policyOf(policyRow),
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 asked687 spent_month_micros: policyRow.spent,
688 alert: level,
689 agents,
690 live_by_agent: liveByAgent,
691 live: liveSessions.filter((s) => s.visible && !s.parent_id).slice(0, 20),
692 waiting_on_you: ctx.owner ? liveSessions.filter((s) => s.status === "needs_approval") : liveSessions.filter((s) => s.status === "needs_approval" && s.visible && s.asked_by === ctx.viewer.id),
693 recent: (await sessionsOut(ctx, recent.results, agentFaces)).filter((s) => s.visible).slice(0, 8),
694 upcoming: upcoming.results.map((r) => {
695 const face = agentFaces.get(r.agent_id);
696 return { ...toRoutine(r), agent_handle: face?.handle ?? "agent", agent_name: face?.display_name ?? "An agent" };
697 }),
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.698 spend: breakdown.ok ? breakdown.value : emptySpend(now),
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 asked699 can_manage: ctx.owner,
700 });
701}
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.702
703/** A breakdown with nothing in it, for this month. */
704function emptySpend(now: Date): AgentSpendBreakdown {
705 const span = spendSpan("month", now);
706 return { ...span, person: null, total_micros: 0, by_kind: [], by_model: [], by_person: [], by_agent: [], by_team: [], by_channel: [], top_sessions: [], days: [] };
707}