@oalo-ai/cli 0.0.0-stage → 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (52) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +392 -2
  3. package/man/oalo.1 +1530 -0
  4. package/package.json +44 -4
  5. package/plugin/.claude-plugin/plugin.json +12 -0
  6. package/plugin/skills/oalo/SKILL.md +414 -0
  7. package/src/api.js +132 -0
  8. package/src/app-ref.js +11 -0
  9. package/src/args.js +67 -0
  10. package/src/bot-settings.json +468 -0
  11. package/src/channel.js +162 -0
  12. package/src/commands/apps.js +64 -0
  13. package/src/commands/bots.js +278 -0
  14. package/src/commands/call.js +179 -0
  15. package/src/commands/channels.js +110 -0
  16. package/src/commands/data.js +446 -0
  17. package/src/commands/db.js +58 -0
  18. package/src/commands/deploy.js +431 -0
  19. package/src/commands/dockerfile.js +81 -0
  20. package/src/commands/docs.js +70 -0
  21. package/src/commands/emit.js +124 -0
  22. package/src/commands/env.js +223 -0
  23. package/src/commands/graphs.js +570 -0
  24. package/src/commands/incidents.js +44 -0
  25. package/src/commands/link.js +150 -0
  26. package/src/commands/login.js +180 -0
  27. package/src/commands/logs.js +69 -0
  28. package/src/commands/mcp.js +125 -0
  29. package/src/commands/metrics.js +168 -0
  30. package/src/commands/products.js +169 -0
  31. package/src/commands/qa.js +98 -0
  32. package/src/commands/rehearse.js +100 -0
  33. package/src/commands/run.js +152 -0
  34. package/src/commands/secrets.js +68 -0
  35. package/src/commands/setup.js +96 -0
  36. package/src/commands/skill.js +142 -0
  37. package/src/commands/sources.js +377 -0
  38. package/src/commands/switches.js +156 -0
  39. package/src/commands/tail.js +84 -0
  40. package/src/commands/tokens.js +85 -0
  41. package/src/commands/typegen.js +505 -0
  42. package/src/commands/whoami.js +112 -0
  43. package/src/config.js +157 -0
  44. package/src/data.js +392 -0
  45. package/src/envfile.js +71 -0
  46. package/src/events.js +63 -0
  47. package/src/graphfiles.js +273 -0
  48. package/src/help.js +920 -0
  49. package/src/index.js +216 -0
  50. package/src/mcp/tools.js +773 -0
  51. package/src/server.js +73 -0
  52. package/src/workspace.js +44 -0
@@ -0,0 +1,773 @@
1
+ import { readToken } from "../config.js";
2
+ import { listModels, listRows, getRow, pushRows, deleteRows, routes, toRows, writeTokenFor, createModel, promoteModel, parseFields } from "../data.js";
3
+
4
+ /**
5
+ * Curated MCP tools over BuilderChannel.
6
+ *
7
+ * BuilderChannel exposes ~442 events. Mapping them 1:1 would be unusable: the
8
+ * schemas alone would flood an agent's context and wreck tool selection. So a
9
+ * small task-shaped set covers the common work, and `oalo_call` stays as a
10
+ * gated passthrough for the long tail.
11
+ *
12
+ * Every `event`/params pairing below was read off the handler's own pattern
13
+ * match, so required params are the real ones, not guesses.
14
+ */
15
+
16
+ const S = (props, required = []) => ({
17
+ type: "object",
18
+ properties: props,
19
+ required,
20
+ additionalProperties: false,
21
+ });
22
+
23
+ export const TOOLS = [
24
+ {
25
+ name: "oalo_describe",
26
+ description:
27
+ "Overview of this OALO workspace: apps, chatbots, and loops. Call this first: everything else needs an id from here.",
28
+ inputSchema: S({}),
29
+ composite: true,
30
+ },
31
+ {
32
+ name: "oalo_list_apps",
33
+ description: "List the apps in this workspace.",
34
+ inputSchema: S({}),
35
+ event: "list_apps",
36
+ },
37
+ {
38
+ name: "oalo_list_bots",
39
+ description: "List the chatbots in this workspace, with their public ids.",
40
+ inputSchema: S({}),
41
+ event: "chat:bots:list",
42
+ },
43
+ {
44
+ name: "oalo_get_bot",
45
+ description:
46
+ "Full configuration for one chatbot: persona, frame, retrieval settings, guardrails.",
47
+ inputSchema: S({ id: { type: "string", description: "Bot id" } }, ["id"]),
48
+ event: "chat:bots:get",
49
+ },
50
+ {
51
+ name: "oalo_list_flows",
52
+ description: "List a bot's conversational flows.",
53
+ inputSchema: S({ bot_id: { type: "string" } }, ["bot_id"]),
54
+ event: "chat:bots:flows:list",
55
+ },
56
+ {
57
+ name: "oalo_get_flow",
58
+ description: "One flow's full graph: nodes and edges.",
59
+ inputSchema: S({ id: { type: "string", description: "Flow id" } }, ["id"]),
60
+ event: "chat:bots:flows:get",
61
+ },
62
+ {
63
+ name: "oalo_node_catalog",
64
+ description:
65
+ "The exact JSON contract for a chat flow or a loop: every node type, the keys each one reads (required, defaults, allowed values), what each outputs, and the edge, condition and {{reference}} rules. Read this before writing or editing a flow or loop graph instead of guessing keys.",
66
+ inputSchema: S({ surface: { type: "string", enum: ["flow", "loop"] } }, ["surface"]),
67
+ route: (args) => ({
68
+ event: args.surface === "loop" ? "loops:node_catalog" : "chat:flows:node_catalog",
69
+ params: {},
70
+ }),
71
+ },
72
+ {
73
+ name: "oalo_save_flow",
74
+ description:
75
+ "Create or update a flow. MUTATES the live workspace. Call oalo_get_flow first: `graph` REPLACES the stored graph wholesale, so send every node and edge you want to keep, not a patch. Pass the `version` you read back as `base_version`. Without it a concurrent edit is silently overwritten; with it you get a `conflict` error and should re-read and merge. `strict` defaults to true here: the save is refused (code `invalid_graph`, with findings) while the graph has any error-level lint finding. Check with oalo_lint_flow first.",
76
+ inputSchema: S(
77
+ {
78
+ bot_id: { type: "string", description: "Numeric id or public bot id (bot_...)" },
79
+ id: { type: "string", description: "Omit to create a new flow" },
80
+ name: { type: "string" },
81
+ description: { type: "string" },
82
+ enabled: { type: "boolean", description: "Omit to keep an existing flow's state. A new flow saved without it starts off." },
83
+ // The server reads params["graph"], NOT top-level nodes/edges. Getting
84
+ // this wrong writes an EMPTY graph over a real flow without erroring.
85
+ graph: {
86
+ type: "object",
87
+ description: "The whole graph. Replaces what is stored.",
88
+ properties: {
89
+ nodes: { type: "array", items: { type: "object" } },
90
+ edges: { type: "array", items: { type: "object" } },
91
+ entry: { type: ["string", "null"], description: "Entry node id" },
92
+ },
93
+ },
94
+ // The server now accepts `version` as a fallback, so echoing the read
95
+ // field back works too. Still sent explicitly here: `base_version` wins
96
+ // when both are present, and being explicit says what it means.
97
+ base_version: {
98
+ type: "number",
99
+ description: "The `version` field from oalo_get_flow. Enables the conflict check.",
100
+ },
101
+ strict: {
102
+ type: "boolean",
103
+ description: "Refuse the save on any error-level lint finding (default true). false only blocks structural damage.",
104
+ },
105
+ },
106
+ ["bot_id"],
107
+ ),
108
+ event: "chat:bots:flows:upsert",
109
+ defaults: { strict: true },
110
+ mutates: true,
111
+ },
112
+ {
113
+ name: "oalo_lint_flow",
114
+ description:
115
+ "Check a flow without saving anything. Pass `graph` (+ `bot_id` so tool checks see that bot's tools) to check a DRAFT before oalo_save_flow, or `flow_id` to check a saved flow. Returns findings with severity error / warning / info, each with the node, what is wrong and how to fix it.",
116
+ inputSchema: S({
117
+ graph: { type: "object", description: "A draft graph {nodes, edges, entry}." },
118
+ bot_id: { type: "string", description: "With graph: the bot whose tools the flow calls." },
119
+ flow_id: { type: "string", description: "Check a saved flow instead." },
120
+ }),
121
+ event: "chat:flows:lint",
122
+ },
123
+ {
124
+ name: "oalo_lint_loop",
125
+ description:
126
+ "Check a loop without saving anything. Pass `nodes` + `edges` (and `name`) to check a DRAFT, or only `name` to check a saved loop. Tool checks use the org tool catalog.",
127
+ inputSchema: S({
128
+ name: { type: "string" },
129
+ nodes: { type: "array", items: { type: "object" } },
130
+ edges: { type: "array", items: { type: "object" } },
131
+ entry: { type: "string" },
132
+ }),
133
+ event: "loops:lint",
134
+ },
135
+ {
136
+ name: "oalo_flow_runs",
137
+ description:
138
+ "Recent flow runs on a bot, newest first: what actually executed, and where it stopped. Pass flow_id for one flow's runs. With run_id: that run step by step, with each step's input and output. Read-only.",
139
+ inputSchema: S(
140
+ {
141
+ bot_id: { type: "string", description: "Numeric id or public bot id (bot_...)" },
142
+ flow_id: { type: "string" },
143
+ run_id: { type: "number", description: "One run's steps (bot_id is then not needed)" },
144
+ limit: { type: "number", description: "At most 100" },
145
+ },
146
+ [],
147
+ ),
148
+ route: ({ run_id, ...p }) => (run_id != null ? { event: "chat:flows:get_run", params: { run_id } } : { event: "chat:flows:list_runs", params: p }),
149
+ },
150
+ {
151
+ name: "oalo_list_functions",
152
+ description: "Custom WASM functions (tools) defined in this workspace.",
153
+ inputSchema: S({}),
154
+ event: "fn:list_org",
155
+ },
156
+ {
157
+ name: "oalo_list_loops",
158
+ description: "Automation loops for this org. Loops are org-scoped, not per-app.",
159
+ inputSchema: S({}),
160
+ event: "loops:list_for_org",
161
+ },
162
+ {
163
+ name: "oalo_get_loop",
164
+ description:
165
+ "One loop's full definition by name: settings, `entry`, `nodes`, `edges`, and the `version` to send back as `base_version` when saving. Pass `app_id` only for a loop pinned to one app.",
166
+ inputSchema: S({ name: { type: "string" }, app_id: { type: "string" } }, ["name"]),
167
+ event: "loops:get",
168
+ },
169
+ {
170
+ name: "oalo_save_loop",
171
+ description:
172
+ "Create or update a loop by name. MUTATES the live workspace. Call oalo_get_loop first: the save REPLACES the whole definition, so send every node, edge and setting you want to keep. `nodes`, `edges` and `entry` sit at the TOP LEVEL (not under `graph`). Pass the `version` you read as `base_version`: without it a concurrent edit is overwritten. `strict` defaults to true: the save is refused (code `invalid_graph`, with findings) while any error-level lint finding remains. Check with oalo_lint_loop first; the contract is oalo_node_catalog surface=loop. Triggers are separate: oalo_loop_triggers.",
173
+ inputSchema: S(
174
+ {
175
+ name: { type: "string", description: "snake_case; the loop's identity" },
176
+ create: { type: "boolean", description: "true for a new loop (loops:create), else an update" },
177
+ description: { type: "string" },
178
+ enabled: { type: "boolean", description: "Omit to keep an existing loop's state. A new loop saved without it starts off." },
179
+ nodes: { type: "array", items: { type: "object" } },
180
+ edges: { type: "array", items: { type: "object" } },
181
+ entry: { type: "string", description: "The node runs start at (optional)" },
182
+ cron: { type: "string", description: "Run on this UTC schedule (optional)" },
183
+ max_iterations: { type: "number" },
184
+ max_cost_usd: { type: "number" },
185
+ session_mode: { type: "string", enum: ["recursive", "persistent"] },
186
+ provider: { type: "string", enum: ["anthropic", "opencode"] },
187
+ model: { type: "string" },
188
+ base_version: { type: "number", description: "The `version` from oalo_get_loop. Enables the conflict check." },
189
+ strict: { type: "boolean", description: "Refuse the save on any error-level lint finding (default true)." },
190
+ },
191
+ ["name", "nodes"],
192
+ ),
193
+ route: (args) => {
194
+ const { create, ...params } = args;
195
+ return { event: create ? "loops:create" : "loops:update", params: { strict: true, ...params } };
196
+ },
197
+ mutates: true,
198
+ },
199
+ {
200
+ name: "oalo_loop_triggers",
201
+ description:
202
+ "What starts a loop. action=catalog lists the trigger types and every event with its input fields; action=list shows a loop's triggers; action=add attaches one (`type` event|scheduled|exit|date_field|widget_submit|api, plus `event`, `cron`, `filter`, `batch_size` or `widget_id`; for api, `description`, `input` and `output`, so an app can call the loop at POST /api/workflows/v1/<name>/run). `event` takes a catalog event, an `app.*` name (fine before the app first sends it), `data.<table>.created|updated` (fires once `oalo data events <table>` is on) or `source.<name>.received`; action=remove detaches one by `trigger_id`; action=set_enabled turns one on or off (`trigger_id`, `enabled`) and keeps it. add, remove and set_enabled MUTATE the workspace.",
203
+ inputSchema: S(
204
+ {
205
+ action: { type: "string", enum: ["catalog", "list", "add", "remove", "set_enabled"] },
206
+ name: { type: "string", description: "The loop (list, add, remove, set_enabled)" },
207
+ type: { type: "string", enum: ["event", "scheduled", "exit", "date_field", "widget_submit", "api"] },
208
+ event: { type: "string" },
209
+ cron: { type: "string" },
210
+ filter: { type: "object", description: "Top-level payload equality, e.g. {\"channel\": \"instagram\"}" },
211
+ batch_size: { type: "number" },
212
+ widget_id: { type: "string" },
213
+ description: { type: "string", description: "api: what the call does, for the app's developer" },
214
+ input: { type: "object", description: "api: the fields the app sends, name to a type or {type, required, description}" },
215
+ output: { type: "object", description: "api: the fields the Output step returns" },
216
+ trigger_id: { type: "string" },
217
+ enabled: { type: "boolean", description: "set_enabled: on or off" },
218
+ },
219
+ ["action"],
220
+ ),
221
+ route: ({ action, ...params }) => ({ event: `loops:triggers:${action}`, params }),
222
+ mutates: true,
223
+ },
224
+ {
225
+ name: "oalo_test_loop",
226
+ description:
227
+ "Test a loop and get each step's output. Every step runs, but steps that send messages or change records (email, WhatsApp, SMS, CRM, bookings) are simulated: their output says what they would have done. Custom tools and AI steps run for real. Works on a loop that is off. Input: `sample` \"latest\" uses the loop's most recent real event, \"example\" builds one from its trigger's fields, or pass `input` yourself (an event envelope {type, contact_id, payload}, or the fields a scheduled loop's first step reads); none for a manual or scheduled loop. Waits for the run, up to 3 minutes. Test after every change, before turning a loop on.",
228
+ inputSchema: S(
229
+ {
230
+ name: { type: "string" },
231
+ sample: { type: "string", enum: ["latest", "example"] },
232
+ input: { type: "object" },
233
+ app_id: { type: "string", description: "Only for a loop pinned to one app: that app's id" },
234
+ },
235
+ ["name"],
236
+ ),
237
+ event: "loops:test",
238
+ timeoutMs: () => 200_000,
239
+ },
240
+ {
241
+ name: "oalo_set_loop_enabled",
242
+ description:
243
+ "Turn a loop on or off without sending its definition. MUTATES the live workspace: a loop that is on runs for real whenever its triggers fire. A new or changed loop stays off until the person says to turn it on; turning one off is safe when asked.",
244
+ inputSchema: S({ name: { type: "string" }, enabled: { type: "boolean" }, app_id: { type: "string", description: "Only for a loop pinned to one app" } }, ["name", "enabled"]),
245
+ event: "loops:set_enabled",
246
+ mutates: true,
247
+ },
248
+ {
249
+ name: "oalo_run_loop_now",
250
+ description:
251
+ "Start a loop run now, for real, without waiting for its trigger. MUTATES: its steps send real messages and change real records. To check what a loop does, use oalo_test_loop instead; run one now only when the person asks.",
252
+ inputSchema: S({ name: { type: "string" }, app_id: { type: "string" }, env: { type: "string", enum: ["dev", "prod"] } }, ["name"]),
253
+ event: "loops:run_now",
254
+ mutates: true,
255
+ },
256
+ {
257
+ name: "oalo_delete_loop",
258
+ description: "Delete a loop. MUTATES the live workspace and can't be undone: it stops firing, and runs in progress finish. Only when the person asks; to stop a loop, turning it off (oalo_set_loop_enabled) keeps it.",
259
+ inputSchema: S({ name: { type: "string" }, app_id: { type: "string" } }, ["name"]),
260
+ event: "loops:delete",
261
+ mutates: true,
262
+ },
263
+ {
264
+ name: "oalo_set_flow_enabled",
265
+ description:
266
+ "Turn a chat flow on or off without sending its graph. `flow` is its name or id. MUTATES the live workspace: a flow that is on can start in real conversations (and a call_other_flow step only runs a flow that is on). A new or changed customer-facing flow stays off until the person says to turn it on; a manual helper flow you created for a call_other_flow step must be on to work.",
267
+ inputSchema: S({ bot_id: { type: "string", description: "The bot's public id" }, flow: { type: "string" }, enabled: { type: "boolean" } }, ["bot_id", "flow", "enabled"]),
268
+ event: "chat:bots:flows:set_enabled",
269
+ mutates: true,
270
+ },
271
+ {
272
+ name: "oalo_delete_flow",
273
+ description: "Delete a chat flow by name or id. MUTATES the live workspace and can't be undone. Only when the person asks; turning it off (oalo_set_flow_enabled) keeps it. Check first that no other flow calls it (call_other_flow).",
274
+ inputSchema: S({ bot_id: { type: "string", description: "The bot's public id" }, flow: { type: "string" } }, ["bot_id", "flow"]),
275
+ event: "chat:bots:flows:delete",
276
+ mutates: true,
277
+ },
278
+ {
279
+ name: "oalo_loop_runs",
280
+ description:
281
+ "What a loop actually did. Without run_id: recent runs, newest first (status, trigger, cost, error, final output); `name` narrows to one loop. With run_id: that run step by step, with each step's input and output, and its events. Read this when a loop misbehaved or did nothing. Test runs are included unless hide_tests is true. Read-only.",
282
+ inputSchema: S({
283
+ name: { type: "string", description: "Only this loop's runs" },
284
+ run_id: { type: "number", description: "One run's steps" },
285
+ limit: { type: "number", description: "At most this many runs (default and cap 100)" },
286
+ hide_tests: { type: "boolean" },
287
+ app_id: { type: "string", description: "Only a loop pinned to this app (two apps can each have a loop of one name)" },
288
+ }),
289
+ route: ({ run_id, name, limit, hide_tests, app_id }) =>
290
+ run_id != null
291
+ ? { event: "loops:get_run", params: { run_id } }
292
+ : { event: "loops:list_runs_for_org", params: { ...(name ? { name } : {}), ...(app_id ? { app_id } : {}), ...(limit ? { limit } : {}), ...(hide_tests ? { hide_rehearsals: true } : {}) } },
293
+ },
294
+ {
295
+ name: "oalo_get_conversation",
296
+ description:
297
+ "One conversation, whole: its messages, the tools the bot called, and the flow runs in it with their steps. Use it to see why a flow did what it did in a real conversation (ids from oalo_flow_runs or the inbox). It holds real customers' messages: read only what the task needs, and don't copy their details elsewhere. Read-only.",
298
+ inputSchema: S({ id: { type: "string", description: "The conversation id" } }, ["id"]),
299
+ event: "chat:bots:conversations:get",
300
+ },
301
+ {
302
+ name: "oalo_widgets",
303
+ description:
304
+ "The workspace's widgets: the forms and components a flow shows (a flow node names one by id) and that can have their own link. action=list (id, name, kind, fields); get (one, with its full field list); templates (what can be copied in); copy (from a template's `template_key`, or another widget's `source_id`); create (`name`, `kind`, `fields` [{name, type, label, required}], `events`, `upsert_contact`, `target_table`, custom_html/custom_css/component_source); update (`id` plus what changes; `fields` REPLACES the whole list, so get it first and send it all back); assign (`id`, `bot_id`: lets that bot's chat and flows use it). Submitting a widget fires widget.submitted, which can start loops. copy, create, update and assign MUTATE the workspace.",
305
+ inputSchema: S(
306
+ {
307
+ action: { type: "string", enum: ["list", "get", "templates", "copy", "create", "update", "assign"] },
308
+ id: { type: "string" },
309
+ bot_id: { type: "string" },
310
+ template_key: { type: "string" },
311
+ source_id: { type: "string" },
312
+ name: { type: "string" },
313
+ kind: { type: "string" },
314
+ fields: { type: "array", items: { type: "object" } },
315
+ events: { type: "array", items: { type: "string" } },
316
+ upsert_contact: { type: "boolean" },
317
+ target_table: { type: "string" },
318
+ active: { type: "boolean" },
319
+ custom_html: { type: "string" },
320
+ custom_css: { type: "string" },
321
+ component_source: { type: "string" },
322
+ },
323
+ ["action"],
324
+ ),
325
+ route: ({ action, id, bot_id, ...rest }) => {
326
+ if (action === "list") return { event: "widgets:list", params: {} };
327
+ if (action === "templates") return { event: "widgets:templates", params: {} };
328
+ if (action === "get") return { event: "widgets:get", params: { id } };
329
+ if (action === "assign") return { event: "widgets:assign", params: { widget_id: id, bot_id } };
330
+ if (action === "copy") return { event: "widgets:copy", params: rest.source_id ? { source_id: rest.source_id, ...(rest.name ? { name: rest.name } : {}) } : { template_key: rest.template_key, ...(rest.name ? { name: rest.name } : {}) } };
331
+ if (action === "update") return { event: "widgets:update", params: { id, ...rest } };
332
+ return { event: "widgets:create", params: rest };
333
+ },
334
+ mutates: true,
335
+ },
336
+ {
337
+ name: "oalo_wa_templates",
338
+ description:
339
+ "WhatsApp message templates: the approved messages a loop sends outside WhatsApp's 24-hour reply window (a send_whatsapp step names one). action=channels lists the workspace's WhatsApp numbers; list shows every template with its approval status (or one channel's with `channel_id`); create saves a draft (`channel_id`, `name` lowercase_with_underscores, `language` e.g. es or en_US, `category` MARKETING|UTILITY|AUTHENTICATION, `components` in Meta's format); submit sends a draft (`template_id`) to Meta for review. Submitting is seen by Meta and can't be taken back: ask the person first. create and submit MUTATE.",
340
+ inputSchema: S(
341
+ {
342
+ action: { type: "string", enum: ["channels", "list", "create", "submit"] },
343
+ channel_id: { type: "string" },
344
+ template_id: { type: "string" },
345
+ name: { type: "string" },
346
+ language: { type: "string" },
347
+ category: { type: "string", enum: ["MARKETING", "UTILITY", "AUTHENTICATION"] },
348
+ components: { type: "array", items: { type: "object" } },
349
+ },
350
+ ["action"],
351
+ ),
352
+ route: ({ action, ...p }) =>
353
+ action === "channels"
354
+ ? { event: "chat:bots:wa_templates:channels", params: {} }
355
+ : action === "list"
356
+ ? p.channel_id
357
+ ? { event: "chat:bots:wa_templates:for_channel", params: { channel_id: p.channel_id } }
358
+ : { event: "chat:bots:wa_templates", params: {} }
359
+ : action === "submit"
360
+ ? { event: "chat:bots:wa_templates:submit", params: { template_id: p.template_id } }
361
+ : { event: "chat:bots:wa_templates:create", params: { ...p, submit: false } },
362
+ mutates: true,
363
+ },
364
+ {
365
+ name: "oalo_pipelines",
366
+ description:
367
+ "Sales pipelines and their stages, which loop steps that move deals refer to. action=list (every pipeline with its stage ids); create_pipeline (`name`, optional `stages` [names]); create_stage (`pipeline_id`, `name`, optional `position`, `win_probability` 0-100, `is_won`, `is_lost`). The create actions MUTATE.",
368
+ inputSchema: S(
369
+ {
370
+ action: { type: "string", enum: ["list", "create_pipeline", "create_stage"] },
371
+ pipeline_id: { type: "string" },
372
+ name: { type: "string" },
373
+ stages: { type: "array", items: { type: "string" } },
374
+ position: { type: "number" },
375
+ win_probability: { type: "number" },
376
+ is_won: { type: "boolean" },
377
+ is_lost: { type: "boolean" },
378
+ },
379
+ ["action"],
380
+ ),
381
+ route: ({ action, ...p }) =>
382
+ action === "list"
383
+ ? { event: "opportunities:pipelines", params: {} }
384
+ : action === "create_pipeline"
385
+ ? { event: "opportunities:create_pipeline", params: { name: p.name, ...(p.stages ? { stages: p.stages } : {}) } }
386
+ : { event: "opportunities:create_stage", params: p },
387
+ mutates: true,
388
+ },
389
+ {
390
+ name: "oalo_loop_docs",
391
+ description:
392
+ "Loop documents: named markdown notes kept per app that loops read and write as they run (a running summary, a list built up over runs). action=list (`app_id`); read (`app_id`, `name`: content and version); write (`app_id`, `name`, `content`, `mode` replace|append; pass the `expected_version` you read to a replace so a newer write isn't overwritten); delete (`app_id`, `name`: the document and its history, for good; only when the person asks). App ids: oalo_list_apps. write and delete MUTATE.",
393
+ inputSchema: S(
394
+ {
395
+ action: { type: "string", enum: ["list", "read", "write", "delete"] },
396
+ app_id: { type: "string" },
397
+ name: { type: "string" },
398
+ content: { type: "string" },
399
+ mode: { type: "string", enum: ["replace", "append"] },
400
+ expected_version: { type: "number" },
401
+ description: { type: "string" },
402
+ },
403
+ ["action", "app_id"],
404
+ ),
405
+ route: ({ action, ...p }) => ({ event: { list: "loops:list_docs", read: "loops:get_doc", write: "loops:write_doc", delete: "loops:delete_doc" }[action], params: p }),
406
+ mutates: true,
407
+ },
408
+ {
409
+ name: "oalo_app_logs",
410
+ description:
411
+ "What an app's own processes printed, per env (dev is the preview, prod the live app): its server (`web`), Procfile workers, the `release` command, and OALO's own notes (`oalo`: what started, what exited and with what status). The last 2,000 lines per env are kept. Read this first when an app doesn't start or misbehaves. It can hold secrets or customers' details the app printed: never repeat those. Read-only.",
412
+ inputSchema: S(
413
+ {
414
+ app: { type: "string", description: "The app's slug (oalo_list_apps)" },
415
+ env: { type: "string", enum: ["dev", "prod"] },
416
+ process: { type: "string", description: "Only this process: web, a worker's name, release or oalo" },
417
+ limit: { type: "number", description: "Recent lines, default 200" },
418
+ },
419
+ ["app"],
420
+ ),
421
+ event: "logs:get",
422
+ },
423
+ {
424
+ name: "oalo_releases",
425
+ description:
426
+ "An app's live releases. action=history lists the commits it has had live, newest first. action=rollback makes the release before the current one live again (or `to`, a sha from the history): it is built and checked like any live deploy, and a failing one leaves the current release live. A rollback builds like a live deploy, so the call can take up to about half an hour. Rolling back changes what live visitors get: only when the person asks. rollback MUTATES.",
427
+ inputSchema: S(
428
+ { app: { type: "string" }, action: { type: "string", enum: ["history", "rollback"] }, to: { type: "string" } },
429
+ ["app", "action"],
430
+ ),
431
+ route: ({ action, ...p }) => ({ event: `release:${action}`, params: p }),
432
+ // Past CodeBuild's 30 minutes, the same wait as `oalo deploy --rollback`.
433
+ timeoutMs: () => 35 * 60_000,
434
+ timeoutHint: "The rollback may still be running on the workspace: call oalo_releases with action=history to see which release is live before trying again.",
435
+ mutates: true,
436
+ },
437
+ {
438
+ name: "oalo_generate_tool",
439
+ description:
440
+ "Write a code tool from a description and add it to the workspace's tools, where loops (function_call) and bots can use it. Describe its inputs, what it returns and any APIs it calls. Pass `regenerate_tool_id` to change an existing tool instead. The code is compiled and checked before it is saved. Takes up to a few minutes. To change a tool's code yourself, use oalo_get_tool_source and oalo_save_tool_source. MUTATES the workspace.",
441
+ inputSchema: S({ description: { type: "string" }, regenerate_tool_id: { type: "number" } }, ["description"]),
442
+ event: "loops:generate_tool_wait",
443
+ timeoutMs: () => 330_000,
444
+ mutates: true,
445
+ },
446
+ {
447
+ name: "oalo_tool_templates",
448
+ description:
449
+ "Ready-made tools to bring in instead of writing one. action=list shows the templates (weighted split, math, ...); install adds one (`template`, optional `bot_id` to give it to a bot, `config`); copy brings a built-in or platform tool into the workspace's tools by `tool_name` (names from oalo_list_functions) so loops can call it. install and copy MUTATE.",
450
+ inputSchema: S(
451
+ {
452
+ action: { type: "string", enum: ["list", "install", "copy"] },
453
+ template: { type: "string" },
454
+ tool_name: { type: "string" },
455
+ bot_id: { type: "string" },
456
+ config: { type: "object" },
457
+ },
458
+ ["action"],
459
+ ),
460
+ route: ({ action, ...p }) =>
461
+ action === "list"
462
+ ? { event: "tools:templates", params: {} }
463
+ : action === "copy"
464
+ ? { event: "loops:copy_tool", params: { tool_name: p.tool_name } }
465
+ : { event: "tools:install_template", params: { template: p.template, ...(p.bot_id ? { bot_id: p.bot_id } : {}), ...(p.config ? { config: p.config } : {}) } },
466
+ mutates: true,
467
+ },
468
+ {
469
+ name: "oalo_test_conversation",
470
+ description:
471
+ "Run a scripted test conversation with a bot and get the transcript: the bot's replies per message, flow state (where a flow is and what it waits for), the path of flow steps, every step with its status and output (`steps`), and the cost. Use it after saving a flow to check it behaves. `channel` makes the bot act as that channel (whatsapp/instagram/sms = text mode); on whatsapp and sms the test customer arrives with a fictional phone on file, as a real one does. With `flow`, the bot can also start that flow again on a later message, even while it is off. The conversation is a hidden preview and nothing is sent to customers, but the bot's tools and flows run for real (contact details are saved, handoffs happen). Takes a while: each message waits until the bot settles.",
472
+ inputSchema: S(
473
+ {
474
+ bot_id: { type: "string", description: "Numeric id or public bot id (bot_...)" },
475
+ messages: { type: "array", items: { type: "string" }, description: "What the customer says, in order (at most 20)" },
476
+ flow: { type: "string", description: "A flow name: the first message starts it (works on a flow that is off)" },
477
+ channel: { type: "string", enum: ["web", "whatsapp", "instagram", "sms", "embed"] },
478
+ turn_timeout_s: { type: "number", description: "Per message, default 90, at most 300" },
479
+ },
480
+ ["bot_id", "messages"],
481
+ ),
482
+ event: "chat:bots:qa_run",
483
+ timeoutMs: (args) => ((args.messages?.length || 1) * ((Number(args.turn_timeout_s) || 90) + 15) + 30) * 1000,
484
+ },
485
+ {
486
+ name: "oalo_rehearse_inbound",
487
+ description:
488
+ "Rehearse inbound WhatsApp conversations end to end and get a report per scenario: what the customer was sent, which thread and bot it ended on, the triage loop runs and their steps, pass/fail checks and, when nothing was sent, why. A fictional 555-01XX customer writes to one of the workspace's numbers through the real inbound path (routing, triage, moves, the bot's reply). Nothing is sent to WhatsApp, the threads stay out of the Inbox and the team gets no notices; LLM calls are real. `suite: true` runs the standard scenarios (new customer, known contact with a thread open on another number, returning the same day, after 24 hours, deal won earlier, writes again after opting out, handed to a teammate, with a teammate writing from another number). Takes minutes.",
489
+ inputSchema: S(
490
+ {
491
+ suite: { type: "boolean", description: "Run the standard scenarios" },
492
+ scenarios: {
493
+ type: "array",
494
+ items: { type: "object" },
495
+ description: 'Instead of the suite: [{"name", "messages": [...], "number", "known_contact", "opted_out", "open_thread": {"bot_id", "number", "hours_ago", "tags", "assigned"}, "expect": {"reply", "bot_id"}}]',
496
+ },
497
+ number: { type: "string", description: "phone_number_id the customer writes to (suite); default the workspace's default number" },
498
+ responder_bot_id: { type: "string", description: "The bot that should end up answering (suite): its public id, bot_..., as oalo_list_bots shows it" },
499
+ turn_timeout_s: { type: "number", description: "Per message, default 120, at most 300" },
500
+ },
501
+ [],
502
+ ),
503
+ event: "chat:bots:rehearse",
504
+ timeoutMs: (args) => ((args.scenarios?.length || 8) * 3 * ((Number(args.turn_timeout_s) || 120) + 15) + 60) * 1000,
505
+ },
506
+ {
507
+ name: "oalo_get_tool_source",
508
+ description:
509
+ "A tool's code and schemas by `tool_id` (ids from oalo_list_functions or oalo_bot_tools). Returns source_js, input/output schemas, `editable` (only code tools are) and `source_hash` to send back as base_source_hash when saving.",
510
+ inputSchema: S({ tool_id: { type: "number" } }, ["tool_id"]),
511
+ event: "fn:source",
512
+ },
513
+ {
514
+ name: "oalo_save_tool_source",
515
+ description:
516
+ "Save a code tool's exact JavaScript (no AI rewrite). MUTATES the live workspace: every bot using the tool runs the new code. The code is compiled first, so code that doesn't build is refused and the old version keeps running. Only what you send changes (description and schemas keep their stored values when omitted; enabled is never touched). Send the `source_hash` you read as `base_source_hash`, or a concurrent edit is overwritten. The entry point is `function main(input) { ... return {...}; }`.",
517
+ inputSchema: S(
518
+ {
519
+ tool_id: { type: "number" },
520
+ source_js: { type: "string" },
521
+ base_source_hash: { type: "string" },
522
+ description: { type: "string" },
523
+ input_schema: { type: "object" },
524
+ output_schema: { type: "object" },
525
+ },
526
+ ["tool_id", "source_js"],
527
+ ),
528
+ event: "fn:save_source",
529
+ mutates: true,
530
+ },
531
+ {
532
+ name: "oalo_bot_tools",
533
+ description:
534
+ "Which tools a bot can call. action=list shows every tool the bot could reach with `allowlisted` true/false; action=allow / disallow adds or removes one tool (by `tool` name or id) from the bot's list. A flow's computation step fails at run time on a tool that isn't allowed. allow/disallow MUTATE the workspace.",
535
+ inputSchema: S(
536
+ {
537
+ action: { type: "string", enum: ["list", "allow", "disallow"] },
538
+ bot_id: { type: "string", description: "Numeric id or public bot id (bot_...)" },
539
+ tool: { type: "string", description: "allow/disallow: the tool's name or numeric id" },
540
+ },
541
+ ["action", "bot_id"],
542
+ ),
543
+ run: async (oalo, { action, bot_id, tool }) => {
544
+ const tools = (await oalo.call("chat:bots:tools:list", { bot_id }))?.tools ?? [];
545
+ if (action === "list") return { tools };
546
+ const target = tools.find((t) => t.name === tool || String(t.id) === String(tool));
547
+ if (!target) throw new Error(`no tool ${JSON.stringify(tool)} is reachable by this bot`);
548
+ const allowed = new Set(tools.filter((t) => t.allowlisted).map((t) => t.id));
549
+ if (action === "allow") allowed.add(target.id);
550
+ else allowed.delete(target.id);
551
+ await oalo.call("chat:bots:tools:set_allowlist", { bot_id, tool_ids: [...allowed] });
552
+ return { tool: target.name, allowlisted: action === "allow", count: allowed.size };
553
+ },
554
+ mutates: true,
555
+ },
556
+ {
557
+ name: "oalo_knowledge_search",
558
+ description: "Semantic search over a bot's knowledge graph.",
559
+ inputSchema: S({ bot_id: { type: "string" }, query: { type: "string" } }, [
560
+ "bot_id",
561
+ "query",
562
+ ]),
563
+ event: "kg:search",
564
+ },
565
+ {
566
+ name: "oalo_jobs",
567
+ description:
568
+ "Background job snapshot including recent failures. Check here when something ran but produced nothing.",
569
+ inputSchema: S({}),
570
+ event: "jobs:snapshot",
571
+ },
572
+ {
573
+ name: "oalo_emit",
574
+ description:
575
+ "Fire a domain event (form.submitted, order.created, whatsapp.inbound, ...) through the real event bus, to test that a loop's trigger and payload filter are wired. Unlike running a loop directly, this goes through the trigger. The loops it fires run for real and can send email or WhatsApp to real contacts, so call with dry_run=true first: that returns the loops that would fire and changes nothing. Event types and their payload fields: oalo_loop_triggers action=catalog. Admins and members with Developer access only. Events are tagged as coming from the CLI and posted to the team feed.",
576
+ inputSchema: S(
577
+ {
578
+ type: { type: "string", description: "Event type, e.g. form.submitted" },
579
+ payload: { type: "object", description: "The event payload (its fields are in the trigger catalog)" },
580
+ contact_id: { type: "string", description: "An existing contact the event is about (never creates one)" },
581
+ dry_run: { type: "boolean", description: "List the loops that would fire, emit nothing" },
582
+ },
583
+ ["type"],
584
+ ),
585
+ event: "cli_api:emit",
586
+ mutates: true,
587
+ },
588
+ {
589
+ name: "oalo_docs",
590
+ description:
591
+ "OALO's platform documentation, from this workspace's box. With slug: the full page as markdown. With q: pages ranked for the words, each with a matching line. With neither: every page's slug and summary. Read the relevant page before building on a feature you don't know (data models, sync, flows, loops, commerce, channels, tokens). Read-only.",
592
+ inputSchema: S({
593
+ slug: { type: "string", description: "A page, e.g. data-models or sync-api-reference" },
594
+ q: { type: "string", description: "Words to search for" },
595
+ audience: { type: "string", enum: ["developer", "trainer"], description: "With neither slug nor q: only pages for this reader" },
596
+ }),
597
+ route: ({ slug, q, audience }) =>
598
+ slug ? { event: "docs:get", params: { slug } } : { event: "docs:list", params: q ? { q } : audience ? { audience } : {} },
599
+ },
600
+ // ── Data: models and rows (oalo data). Reads use the saved data token over
601
+ // the Sync API; row writes use a table-limited write token made as the person;
602
+ // creating and promoting models goes over this channel as the person.
603
+ {
604
+ name: "oalo_list_models",
605
+ description:
606
+ "The workspace's data models (tables): fields and types, whether rows are shared or owned per app user (owner_field). Call this before reading or writing rows, and before creating a model, to reuse one that exists. Every workspace has two schemas with the same models: dev (a sandbox) and prod (what live apps read).",
607
+ inputSchema: S({ env: { type: "string", enum: ["dev", "prod"], description: "Which schema (default dev). Models are the same in both." } }),
608
+ run: async (_oalo, { env }, ctx) => listModels(readSide(ctx, env)),
609
+ },
610
+ {
611
+ name: "oalo_create_model",
612
+ description:
613
+ "Define a new data model (table) in both dev and prod. Fields live in each row's JSON document; every model also gets id, owner_id, availability and product_id. Use owner_field when each app user owns their rows (a member sees only their own); leave it out for shared content everyone sees. Field types: string text integer float boolean datetime date json list references:<table>. Check oalo_list_models first. MUTATES the workspace (creates tables); follows the person's dashboard access.",
614
+ inputSchema: S(
615
+ {
616
+ name: { type: "string", description: "snake_case table name, e.g. orders_mirror" },
617
+ fields: {
618
+ type: "array",
619
+ description: "Fields, e.g. [{\"name\":\"total\",\"type\":\"float\"}] or [\"total:float\", \"email\"]",
620
+ items: { anyOf: [{ type: "string" }, { type: "object", properties: { name: { type: "string" }, type: { type: "string" } }, required: ["name"] }] },
621
+ },
622
+ owner_field: { type: "string", description: "Field holding the owning app user's id. Omit for shared rows." },
623
+ },
624
+ ["name", "fields"],
625
+ ),
626
+ run: async (oalo, { name, fields, owner_field }) =>
627
+ createModel(oalo, {
628
+ name,
629
+ fields: fields.flatMap((f) => (typeof f === "string" ? parseFields([f]) : [{ name: f.name, type: f.type || "string" }])),
630
+ ownedBy: owner_field,
631
+ }),
632
+ mutates: true,
633
+ },
634
+ {
635
+ name: "oalo_read_rows",
636
+ description:
637
+ "Read rows from a model. With id: one row. Otherwise a page in write order: pass since=<next_since> from the previous page to continue while has_more is true. Each row is {id, document, owner_id, availability, product_id, seqnum, ...}; the fields are in document. Read-only.",
638
+ inputSchema: S(
639
+ {
640
+ table: { type: "string" },
641
+ id: { type: "string", description: "One row by id" },
642
+ env: { type: "string", enum: ["dev", "prod"], description: "Which schema (default dev)" },
643
+ since: { type: "integer", description: "Continue after this seqnum (next_since of the previous page)" },
644
+ limit: { type: "integer", description: "Rows per page (default 500, max 5000)" },
645
+ },
646
+ ["table"],
647
+ ),
648
+ run: async (_oalo, { table, id, env, since, limit }, ctx) => {
649
+ const c = readSide(ctx, env);
650
+ return id ? getRow(c, table, id) : listRows(c, table, { since, limit });
651
+ },
652
+ },
653
+ {
654
+ name: "oalo_query_metrics",
655
+ description:
656
+ "Count, sum or average over the workspace's data: a model's rows, events, or contacts, filtered, grouped by a field, or over time. Use it to answer a question about numbers (how many orders this week, revenue by plan, new contacts per day) instead of reading rows and adding them up. A model's fields are checked: oalo_list_models lists them. Model rows count at when they were created (stamped by the server). Read-only.",
657
+ inputSchema: S({
658
+ source: { type: "string", enum: ["model", "event", "contact"], description: "Default model (give table)." },
659
+ table: { type: "string" },
660
+ env: { type: "string", enum: ["prod", "dev"], description: "A model's live (default) or dev data." },
661
+ event: { type: "string", description: "An event type such as order.created; leave it out for every type." },
662
+ measure: { type: "string", enum: ["count", "sum", "avg", "min", "max", "distinct"], description: "Default count. sum, avg, min and max read field as a number." },
663
+ field: { type: "string" },
664
+ by: { type: "string", description: "Group by this field. Contacts group by status, source or custom_fields.<name>." },
665
+ every: { type: "string", enum: ["day", "week", "month"] },
666
+ time: { type: "string", description: "Model rows: created (default), changed, or a date field." },
667
+ days: { type: "integer", description: "The window, ending now. Leave it out for all time." },
668
+ compare: { type: "boolean", description: "Also the window before (needs days)." },
669
+ where: { type: "array", description: "Filters {field, op, value}; op: eq neq in gt gte lt lte set unset contains.", items: { type: "object" } },
670
+ }),
671
+ route: (args) => ({ event: "metrics:query", params: { query: args } }),
672
+ },
673
+ {
674
+ name: "oalo_list_dashboards",
675
+ description: "The workspace's dashboards (Insights > Dashboards): names, widget counts, and whether one shows on an app. Read it before oalo_save_dashboard_widget. Read-only.",
676
+ inputSchema: S({}),
677
+ event: "dashboards:list",
678
+ },
679
+ {
680
+ name: "oalo_save_dashboard_widget",
681
+ description:
682
+ "Put a chart on a dashboard so it stays and updates by itself: the way to answer \"add a chart\", \"track X\" or \"put Y on the dashboard\". Adds the widget, or replaces the one with the same title; the dashboard is made when none has that name (app attaches a new one to an app). The query runs first: one that fails is refused with the reason, a saved one returns its numbers. kind: number (compare adds the window before), line (query.every), bars (query.by), table, funnel (query.steps: event types in order, and days). MUTATES what everyone with access sees; say which dashboard you used.",
683
+ inputSchema: S(
684
+ {
685
+ dashboard: { type: "string", description: "The dashboard's name. Default Overview." },
686
+ app: { type: "string", description: "An app's slug, for a dashboard made now." },
687
+ title: { type: "string", description: "In the person's words, like \"Orders per week\"." },
688
+ kind: { type: "string", enum: ["number", "line", "bars", "table", "funnel"] },
689
+ width: { type: "integer", enum: [1, 2, 3] },
690
+ query: { type: "object", description: "The same fields as oalo_query_metrics, or for a funnel {steps, days}." },
691
+ },
692
+ ["title", "kind", "query"],
693
+ ),
694
+ route: ({ dashboard, app, title, kind, width, query }) => ({
695
+ event: "dashboards:put_widget",
696
+ params: { dashboard, app, widget: { title, kind, query, ...(width ? { width } : {}) } },
697
+ }),
698
+ },
699
+ {
700
+ name: "oalo_write_rows",
701
+ description:
702
+ "Upsert and/or soft-delete rows in a model. Upserts match by id: pass each row with an id (from id_field) so sending it again updates it instead of adding a copy. Fields go flat in the row ({\"id\":\"42\",\"total\":9.5}) or as {id, document:{...}}. Any number of rows; they are sent 1,000 at a time and the rate limit is waited out. Writes sync to every app on that schema at once. env is required: write to dev, check with oalo_read_rows, then oalo_promote_model copies dev to prod. Writing to prod needs confirm=true because live apps see it immediately. The first write to a table creates a write token limited to it, as the person. MUTATES data.",
703
+ inputSchema: S(
704
+ {
705
+ table: { type: "string" },
706
+ env: { type: "string", enum: ["dev", "prod"] },
707
+ upsert: { type: "array", items: { type: "object" }, description: "Rows to create or update" },
708
+ id_field: { type: "string", description: "Which field holds each row's id (default id)" },
709
+ delete: { type: "array", items: { type: "string" }, description: "Ids to soft-delete (they leave synced apps)" },
710
+ confirm: { type: "boolean", description: "Required for env=prod" },
711
+ },
712
+ ["table", "env"],
713
+ ),
714
+ run: async (oalo, { table, env, upsert = [], id_field = "id", delete: ids = [], confirm }, ctx) => {
715
+ if (env === "prod" && confirm !== true) {
716
+ throw new Error("writing to prod changes what live apps show right away. Write to dev first, or pass confirm=true after the person agrees.");
717
+ }
718
+ if (!upsert.length && !ids.length) throw new Error("nothing to do: pass upsert rows and/or delete ids");
719
+ const { rows, missing } = toRows(upsert, { idField: id_field });
720
+ if (missing.length) {
721
+ throw new Error(`${missing.length} row(s) have no ${id_field} (first: index ${missing[0].index}). Give every row an id so a re-send updates it.`);
722
+ }
723
+ const token = await writeTokenFor(ctx.url, [table], oalo);
724
+ const cfg = { serverUrl: ctx.url, token, env };
725
+ const result = { table, env };
726
+ if (rows.length) Object.assign(result, await pushRows(cfg, table, rows));
727
+ if (ids.length) result.deletes = await deleteRows(cfg, table, ids);
728
+ return result;
729
+ },
730
+ mutates: true,
731
+ },
732
+ {
733
+ name: "oalo_promote_model",
734
+ description:
735
+ "Copy a model's rows from dev to prod (upsert by id), so live apps get them. Rows in prod with the same id are overwritten by the dev copy. Run it after checking the dev rows, and only when the person wants the data live. MUTATES live data.",
736
+ inputSchema: S({ table: { type: "string" } }, ["table"]),
737
+ run: async (oalo, { table }) => promoteModel(oalo, table),
738
+ mutates: true,
739
+ },
740
+ {
741
+ name: "oalo_sync_routes",
742
+ description:
743
+ "The Sync API's own description: every REST endpoint under /api/sync/v1, the token scopes (read, write, read:<table>, write:<table>), query params, headers (Idempotency-Key, If-Unmodified-Since-Ms) and rate limits. Use it when writing code that calls the Sync API from an app's server. Read-only.",
744
+ inputSchema: S({}),
745
+ run: async (_oalo, _args, ctx) => routes(readSide(ctx)),
746
+ },
747
+ {
748
+ name: "oalo_call",
749
+ description:
750
+ "Escape hatch: call any BuilderChannel event directly. The curated tools above cover normal work; use this only for something they do not reach. To find an event and its params, call event 'cli_api:events' with params {\"q\": \"<text>\"}: it lists the events you may call and the param shapes each accepts. May mutate, so check the event name before calling.",
751
+ inputSchema: S(
752
+ {
753
+ event: { type: "string", description: "Channel event name" },
754
+ params: { type: "object", description: "Event params" },
755
+ },
756
+ ["event"],
757
+ ),
758
+ raw: true,
759
+ },
760
+ ];
761
+
762
+ export const BY_NAME = Object.fromEntries(TOOLS.map((t) => [t.name, t]));
763
+
764
+ /** The read side for data tools: the saved data token, on one schema. */
765
+ function readSide(ctx, env = "dev") {
766
+ // The project may name no workspace; then the channel connected to the most
767
+ // recently saved one, and its data token is the one to use.
768
+ const token = ctx?.cfg?.token || (ctx?.url && readToken({ serverUrl: ctx.url, appId: null }, "sync"));
769
+ if (!token) {
770
+ throw new Error("no data token saved for this workspace. Ask the person to run `oalo login --server <workspace url>`.");
771
+ }
772
+ return { serverUrl: ctx.url, token, env };
773
+ }