@operator-labs/operator-cli 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.
package/lib/setup.mjs ADDED
@@ -0,0 +1,197 @@
1
+ import { existsSync, mkdirSync, symlinkSync, readlinkSync, writeFileSync } from "fs";
2
+ import { randomBytes } from "crypto";
3
+ import { dirname, join, resolve } from "path";
4
+ import { fileURLToPath } from "url";
5
+ import { resolveConfigPath, resolveStateDir, readConfig, resolveGatewayToken, checkConfigWarnings } from "./config.mjs";
6
+ import { detectInstalledAgents } from "./backend.mjs";
7
+
8
+ const PACKAGE_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
9
+
10
+ function linkSkill(skillsDir, dryRun, changes) {
11
+ const skillLink = join(skillsDir, "operator");
12
+ if (existsSync(skillLink)) {
13
+ let target;
14
+ try { target = readlinkSync(skillLink); } catch {}
15
+ if (target === PACKAGE_ROOT) {
16
+ console.log(" skill: linked");
17
+ } else {
18
+ console.log(" skill: " + skillLink + " exists (not managed by setup)");
19
+ }
20
+ return;
21
+ }
22
+ if (dryRun) {
23
+ changes.push("skill: link " + skillLink);
24
+ console.log(" skill: would link " + skillLink + " → " + PACKAGE_ROOT);
25
+ return;
26
+ }
27
+ mkdirSync(skillsDir, { recursive: true });
28
+ symlinkSync(PACKAGE_ROOT, skillLink);
29
+ changes.push("skill: linked " + skillLink);
30
+ console.log(" skill: linked " + skillLink + " → " + PACKAGE_ROOT);
31
+ }
32
+
33
+ function linkNodeModules(stateDir, dryRun, changes) {
34
+ const nmScopeDir = join(stateDir, "node_modules", "@operator-labs");
35
+ const nmLink = join(nmScopeDir, "operator-cli");
36
+ if (existsSync(nmLink)) {
37
+ let target;
38
+ try { target = readlinkSync(nmLink); } catch {}
39
+ if (target === PACKAGE_ROOT) {
40
+ console.log(" node_modules: linked");
41
+ } else {
42
+ console.log(" node_modules: " + nmLink + " exists (not managed by setup)");
43
+ }
44
+ return;
45
+ }
46
+ if (dryRun) {
47
+ changes.push("node_modules: link " + nmLink);
48
+ console.log(" node_modules: would link " + nmLink + " → " + PACKAGE_ROOT);
49
+ return;
50
+ }
51
+ mkdirSync(nmScopeDir, { recursive: true });
52
+ symlinkSync(PACKAGE_ROOT, nmLink);
53
+ changes.push("node_modules: linked " + nmLink);
54
+ console.log(" node_modules: linked " + nmLink + " → " + PACKAGE_ROOT);
55
+ }
56
+
57
+ async function setupOpenclaw(options, changes, warnings) {
58
+ const { dryRun = false, configPath: configOverride } = options;
59
+ const configPath = resolveConfigPath(configOverride);
60
+
61
+ const configExists = existsSync(configPath);
62
+ let config = configExists ? readConfig(configPath) : {};
63
+
64
+ if (configExists && config === null) {
65
+ console.error("error: could not parse config at " + configPath);
66
+ console.error(" If it uses advanced JSON5 syntax, run these commands manually:");
67
+ console.error("");
68
+ console.error(' openclaw config set hooks.enabled true --strict-json');
69
+ console.error(' openclaw config set hooks.token "$(openssl rand -hex 32)"');
70
+ console.error(' openclaw config set plugins.entries.llm-task.enabled true --strict-json');
71
+ return { ok: false, configPath };
72
+ }
73
+
74
+ if (!config) config = {};
75
+
76
+ for (const w of checkConfigWarnings(config)) {
77
+ warnings.push(w);
78
+ console.log(" warning: " + w);
79
+ }
80
+
81
+ console.log(" config: " + configPath);
82
+
83
+ if (!config.hooks || typeof config.hooks !== "object") config.hooks = {};
84
+ if (config.hooks.enabled === true) {
85
+ console.log(" hooks.enabled: true (already set)");
86
+ } else {
87
+ config.hooks.enabled = true;
88
+ changes.push("hooks.enabled: set to true");
89
+ console.log(" hooks.enabled: set to true");
90
+ }
91
+
92
+ if (config.hooks.token) {
93
+ console.log(" hooks.token: exists");
94
+ } else {
95
+ config.hooks.token = randomBytes(32).toString("hex");
96
+ changes.push("hooks.token: generated");
97
+ console.log(" hooks.token: generated");
98
+ }
99
+
100
+ if (!Array.isArray(config.hooks.mappings)) config.hooks.mappings = [];
101
+ const hasMapping = config.hooks.mappings.some(
102
+ (m) => m.id === "operator" || m.match?.path === "operator",
103
+ );
104
+ if (hasMapping) {
105
+ console.log(" hooks.mappings: operator mapping exists");
106
+ } else {
107
+ config.hooks.mappings.push({
108
+ id: "operator",
109
+ match: { path: "operator" },
110
+ action: "agent",
111
+ messageTemplate: "{{message}}",
112
+ });
113
+ changes.push("hooks.mappings: added operator mapping");
114
+ console.log(" hooks.mappings: added operator mapping");
115
+ }
116
+
117
+ if (!config.plugins || typeof config.plugins !== "object") config.plugins = {};
118
+ if (!config.plugins.entries || typeof config.plugins.entries !== "object") {
119
+ config.plugins.entries = {};
120
+ }
121
+ const llm = config.plugins.entries["llm-task"];
122
+ if (llm?.enabled === true) {
123
+ console.log(" llm-task plugin: enabled");
124
+ } else {
125
+ if (!llm) {
126
+ config.plugins.entries["llm-task"] = { enabled: true };
127
+ } else {
128
+ llm.enabled = true;
129
+ }
130
+ changes.push("llm-task plugin: enabled");
131
+ console.log(" llm-task plugin: enabled");
132
+ }
133
+
134
+ if (!config.tools || typeof config.tools !== "object") config.tools = {};
135
+ if (!Array.isArray(config.tools.allow)) config.tools.allow = [];
136
+ if (config.tools.allow.includes("llm-task")) {
137
+ console.log(" tools.allow: llm-task present");
138
+ } else {
139
+ config.tools.allow.push("llm-task");
140
+ changes.push("tools.allow: added llm-task");
141
+ console.log(" tools.allow: added llm-task");
142
+ }
143
+
144
+ const gatewayToken = resolveGatewayToken(config);
145
+ if (gatewayToken) {
146
+ console.log(" gateway.auth.token: found");
147
+ } else {
148
+ warnings.push("gateway.auth.token: not found");
149
+ console.log(" gateway.auth.token: not found");
150
+ console.log(" Set gateway.auth.token in your config or export OPENCLAW_GATEWAY_TOKEN");
151
+ console.log(" Required for 'operator think'. Run 'openclaw setup' if not yet configured.");
152
+ }
153
+
154
+ linkSkill(join(resolveStateDir(), "skills"), dryRun, changes);
155
+ linkNodeModules(resolveStateDir(), dryRun, changes);
156
+
157
+ if (!dryRun && changes.length > 0) {
158
+ mkdirSync(dirname(configPath), { recursive: true });
159
+ writeFileSync(configPath, JSON.stringify(config, null, 2) + "\n");
160
+ const verb = configExists ? "updated" : "created";
161
+ console.log(" " + verb + " " + configPath);
162
+ }
163
+
164
+ return { ok: true, configPath };
165
+ }
166
+
167
+ /**
168
+ * Configure OpenClaw for operator workflows.
169
+ * Non-interactive, idempotent. Safe to run multiple times.
170
+ */
171
+ export async function setup(options = {}) {
172
+ const { dryRun = false } = options;
173
+ const changes = [];
174
+ const warnings = [];
175
+
176
+ const agents = detectInstalledAgents();
177
+
178
+ if (agents.length === 0) {
179
+ console.error("error: OpenClaw not detected");
180
+ console.error(" Install OpenClaw first.");
181
+ return { ok: false, agents: [], changes, warnings };
182
+ }
183
+
184
+ console.log("[openclaw]");
185
+ const result = await setupOpenclaw(options, changes, warnings);
186
+ if (!result.ok) return { ok: false, agents, changes, warnings };
187
+
188
+ if (changes.length === 0) {
189
+ console.log("\nready (no changes needed)");
190
+ } else if (dryRun) {
191
+ console.log("\ndry run: " + changes.length + " change(s) not written");
192
+ } else {
193
+ console.log("\n" + changes.length + " change(s) applied");
194
+ }
195
+
196
+ return { ok: true, agents, changes, warnings };
197
+ }
package/lib/think.mjs ADDED
@@ -0,0 +1,18 @@
1
+ import { resolveBackend } from "./backend.mjs";
2
+
3
+ /**
4
+ * Run a lightweight LLM call via the active backend.
5
+ *
6
+ * Synchronous request/response -- returns when the LLM responds (unlike
7
+ * invoke, which returns before the agent finishes).
8
+ *
9
+ * @param {string} prompt - Plain string or JSON with backend-specific args
10
+ * @param {object} [options]
11
+ * @param {number} [options.timeout=120000] - Request timeout in ms
12
+ * @param {string} [options.configPath] - Override config path (OpenClaw only)
13
+ * @returns {{ ok: boolean, output?: unknown, error?: string }}
14
+ */
15
+ export async function think(prompt, options = {}) {
16
+ const backend = await resolveBackend();
17
+ return backend.think(prompt, options);
18
+ }
@@ -0,0 +1,178 @@
1
+ import { get as httpsGet } from "https";
2
+ import { existsSync, mkdirSync, writeFileSync } from "fs";
3
+ import { join } from "path";
4
+ import { detectInstalledAgents } from "./backend.mjs";
5
+ import { resolveStateDir } from "./config.mjs";
6
+
7
+ const DEFAULT_REPO = "operatorlabs/operator-mono";
8
+ const DEFAULT_BRANCH = "main";
9
+ const WORKFLOWS_DIR = "workflows";
10
+
11
+ function getRepo() {
12
+ return process.env.OPERATOR_REPO || DEFAULT_REPO;
13
+ }
14
+
15
+ function getBranch() {
16
+ return process.env.OPERATOR_BRANCH || DEFAULT_BRANCH;
17
+ }
18
+
19
+ function ghApi(path) {
20
+ return new Promise((resolve, reject) => {
21
+ const url = `https://api.github.com${path}`;
22
+ const headers = { "User-Agent": "operator-cli", Accept: "application/vnd.github.v3+json" };
23
+ if (process.env.GITHUB_TOKEN) headers.Authorization = `Bearer ${process.env.GITHUB_TOKEN}`;
24
+ httpsGet(url, { headers }, (res) => {
25
+ let body = "";
26
+ res.on("data", (chunk) => (body += chunk));
27
+ res.on("end", () => {
28
+ if (res.statusCode >= 200 && res.statusCode < 300) {
29
+ try {
30
+ resolve(JSON.parse(body));
31
+ } catch {
32
+ reject(new Error("invalid JSON from GitHub API"));
33
+ }
34
+ } else {
35
+ reject(new Error(`GitHub API ${res.statusCode}: ${body.slice(0, 200)}`));
36
+ }
37
+ });
38
+ }).on("error", reject);
39
+ });
40
+ }
41
+
42
+ function ghRaw(filePath) {
43
+ return new Promise((resolve, reject) => {
44
+ const url = `https://raw.githubusercontent.com/${getRepo()}/${getBranch()}/${filePath}`;
45
+ httpsGet(url, { headers: { "User-Agent": "operator-cli" } }, (res) => {
46
+ if (res.statusCode >= 300 && res.statusCode < 400 && res.headers.location) {
47
+ httpsGet(res.headers.location, { headers: { "User-Agent": "operator-cli" } }, (r2) => {
48
+ let body = "";
49
+ r2.on("data", (chunk) => (body += chunk));
50
+ r2.on("end", () => (r2.statusCode < 300 ? resolve(body) : reject(new Error(`raw ${r2.statusCode} for ${filePath}`))));
51
+ }).on("error", reject);
52
+ return;
53
+ }
54
+ let body = "";
55
+ res.on("data", (chunk) => (body += chunk));
56
+ res.on("end", () => {
57
+ if (res.statusCode >= 200 && res.statusCode < 300) resolve(body);
58
+ else reject(new Error(`raw ${res.statusCode} for ${filePath}`));
59
+ });
60
+ }).on("error", reject);
61
+ });
62
+ }
63
+
64
+ /**
65
+ * List available workflows from the monorepo's workflows/ directory.
66
+ * Fetches each SKILL.md for description and full content (used by search).
67
+ * @returns {Promise<Array<{ name: string, description: string, content: string }>>}
68
+ */
69
+ export async function listWorkflows() {
70
+ const entries = await ghApi(`/repos/${getRepo()}/contents/${WORKFLOWS_DIR}?ref=${getBranch()}`);
71
+ const dirs = entries.filter((e) => e.type === "dir");
72
+
73
+ const workflows = await Promise.all(dirs.map(async (dir) => {
74
+ let description = "";
75
+ let content = "";
76
+ try {
77
+ content = await ghRaw(`${WORKFLOWS_DIR}/${dir.name}/SKILL.md`);
78
+ const match = content.match(/^description:\s*(.+)/m);
79
+ if (match) {
80
+ description = match[1].trim();
81
+ } else {
82
+ for (const line of content.split("\n")) {
83
+ const t = line.trim();
84
+ if (t && !t.startsWith("#") && !t.startsWith("---")) {
85
+ description = t;
86
+ break;
87
+ }
88
+ }
89
+ }
90
+ } catch {}
91
+ return { name: dir.name, description, content };
92
+ }));
93
+
94
+ return workflows;
95
+ }
96
+
97
+ /**
98
+ * Search workflows by keyword. Matches against name, description,
99
+ * and full SKILL.md content.
100
+ */
101
+ export async function searchWorkflows(query) {
102
+ const all = await listWorkflows();
103
+ const q = query.toLowerCase();
104
+ return all
105
+ .map((w) => {
106
+ const nameMatch = w.name.toLowerCase().includes(q);
107
+ const descMatch = w.description.toLowerCase().includes(q);
108
+ const contentMatch = w.content.toLowerCase().includes(q);
109
+ if (!nameMatch && !descMatch && !contentMatch) return null;
110
+ const score = (nameMatch ? 3 : 0) + (descMatch ? 2 : 0) + (contentMatch ? 1 : 0);
111
+ return { name: w.name, description: w.description, score };
112
+ })
113
+ .filter(Boolean)
114
+ .sort((a, b) => b.score - a.score);
115
+ }
116
+
117
+ const SKILL_DIRS = {
118
+ openclaw: () => join(resolveStateDir(), "workspace", "skills"),
119
+ };
120
+
121
+ /**
122
+ * Install a workflow to all detected agent skill directories.
123
+ * Fetches files from the monorepo and writes them locally.
124
+ */
125
+ export async function installWorkflow(name) {
126
+ let entries;
127
+ try {
128
+ entries = await ghApi(`/repos/${getRepo()}/contents/${WORKFLOWS_DIR}/${name}?ref=${getBranch()}`);
129
+ } catch (err) {
130
+ return { ok: false, error: `workflow "${name}" not found: ${err.message}` };
131
+ }
132
+
133
+ if (!Array.isArray(entries)) {
134
+ return { ok: false, error: `workflow "${name}" not found in ${WORKFLOWS_DIR}/` };
135
+ }
136
+
137
+ const files = entries.filter((e) => e.type === "file");
138
+ if (files.length === 0) {
139
+ return { ok: false, error: `workflow "${name}" has no files` };
140
+ }
141
+
142
+ const fileContents = [];
143
+ for (const file of files) {
144
+ try {
145
+ const content = await ghRaw(`${WORKFLOWS_DIR}/${name}/${file.name}`);
146
+ fileContents.push({ name: file.name, content });
147
+ } catch (err) {
148
+ return { ok: false, error: `failed to download ${file.name}: ${err.message}` };
149
+ }
150
+ }
151
+
152
+ const agents = detectInstalledAgents();
153
+ if (agents.length === 0) {
154
+ return { ok: false, error: "no agent hosts detected" };
155
+ }
156
+
157
+ const installed = [];
158
+ for (const agent of agents) {
159
+ const getDir = SKILL_DIRS[agent];
160
+ if (!getDir) continue;
161
+
162
+ const targetDir = join(getDir(), name);
163
+ mkdirSync(targetDir, { recursive: true });
164
+
165
+ for (const file of fileContents) {
166
+ writeFileSync(join(targetDir, file.name), file.content, "utf-8");
167
+ }
168
+
169
+ installed.push({ agent, dir: targetDir });
170
+ console.log("[" + agent + "] installed: " + targetDir);
171
+ }
172
+
173
+ const fileNames = fileContents.map((f) => f.name);
174
+ console.log("files: " + fileNames.join(", "));
175
+ console.log("status: available on next agent session");
176
+
177
+ return { ok: true, installed, files: fileNames };
178
+ }
package/package.json ADDED
@@ -0,0 +1,29 @@
1
+ {
2
+ "name": "@operator-labs/operator-cli",
3
+ "version": "0.1.0",
4
+ "description": "Self-calling workflow primitives for OpenClaw",
5
+ "type": "module",
6
+ "bin": {
7
+ "operator-cli": "./bin/operator.mjs"
8
+ },
9
+ "exports": {
10
+ ".": "./index.mjs"
11
+ },
12
+ "files": [
13
+ "bin/",
14
+ "lib/",
15
+ "examples/",
16
+ "references/",
17
+ "index.mjs",
18
+ "SKILL.md",
19
+ "README.md"
20
+ ],
21
+ "engines": {
22
+ "node": ">=18"
23
+ },
24
+ "license": "Apache-2.0",
25
+ "repository": {
26
+ "type": "git",
27
+ "url": "https://github.com/openclaw/operator"
28
+ }
29
+ }
@@ -0,0 +1,100 @@
1
+ # Error Handling
2
+
3
+ ## Exit Codes
4
+
5
+ Both `invoke.mjs` and `think.mjs` use the same exit code convention:
6
+
7
+ | Code | Meaning |
8
+ |------|---------|
9
+ | 0 | Success |
10
+ | 1 | No message/prompt provided |
11
+ | 2 | Config error (missing token) |
12
+ | 3 | Gateway or tool error |
13
+
14
+ ## Check Exit Codes
15
+
16
+ `execFileSync` throws on non-zero exit. The `invoke` and `think` wrappers in SKILL.md catch and log these.
17
+
18
+ ## Verify Results
19
+
20
+ Agent sessions can complete without producing expected output. Always check that files exist and have content after each invocation:
21
+
22
+ ```js
23
+ function invoke(message, expectedOutput) {
24
+ try {
25
+ execFileSync("node", [INVOKE, message], { stdio: "inherit", timeout: 120000 });
26
+ } catch (e) {
27
+ log("INVOKE FAILED: " + e.message);
28
+ throw e;
29
+ }
30
+ if (expectedOutput && (!existsSync(expectedOutput) || readFileSync(expectedOutput, "utf-8").trim() === "")) {
31
+ log("WARNING: expected output missing: " + expectedOutput);
32
+ }
33
+ }
34
+ ```
35
+
36
+ ## Retry with Backoff
37
+
38
+ ```js
39
+ async function invokeWithRetry(message, retries = 3) {
40
+ for (let i = 0; i < retries; i++) {
41
+ try {
42
+ execFileSync("node", [INVOKE, message], { stdio: "inherit", timeout: 120000 });
43
+ return;
44
+ } catch (e) {
45
+ if (i === retries - 1) throw e;
46
+ await new Promise(r => setTimeout(r, 5000 * (i + 1)));
47
+ }
48
+ }
49
+ }
50
+ ```
51
+
52
+ ## Timeouts
53
+
54
+ The `invoke.mjs` HTTP request has a 120-second timeout. This is how long it waits for the gateway to respond, not how long the agent session takes to complete its work. If the agent task takes longer than 120 seconds, the HTTP call may return or time out before the agent finishes writing its result file. For long-running tasks, do not treat a timeout as a failure. Poll for the expected result file instead:
55
+
56
+ ```js
57
+ function invokeAndWait(message, expectedOutput, maxWaitMs = 600000) {
58
+ try {
59
+ execFileSync("node", [INVOKE, message], { stdio: "inherit", timeout: 120000 });
60
+ } catch {
61
+ // Timeout is not necessarily a failure; the session may still be running
62
+ }
63
+ const start = Date.now();
64
+ while (Date.now() - start < maxWaitMs) {
65
+ if (existsSync(expectedOutput) && readFileSync(expectedOutput, "utf-8").trim() !== "") return true;
66
+ execFileSync("sleep", ["10"]);
67
+ }
68
+ return false;
69
+ }
70
+ ```
71
+
72
+ ## Checkpoint and Resume
73
+
74
+ Write progress after each successful step so a crashed workflow restarts from where it left off:
75
+
76
+ ```js
77
+ for (let i = start; i < items.length; i++) {
78
+ processItem(items[i]);
79
+ writeFileSync(progressFile, String(i + 1));
80
+ }
81
+ ```
82
+
83
+ ## Validate Data Between Steps
84
+
85
+ In pipelines, verify output has the expected format before passing it forward:
86
+
87
+ ```js
88
+ function validateJson(path) {
89
+ try {
90
+ JSON.parse(readFileSync(path, "utf-8"));
91
+ return true;
92
+ } catch {
93
+ return false;
94
+ }
95
+ }
96
+ ```
97
+
98
+ ## Concurrency Throttling
99
+
100
+ For fan-out patterns, limit concurrent invocations to avoid overwhelming the gateway. Each agent session allocates 150-200MB on the gateway heap. On a 2GB container, 2-3 concurrent sessions is the practical limit.
@@ -0,0 +1,144 @@
1
+ # Workflow Patterns
2
+
3
+ ## 1. Batch
4
+
5
+ Script enumerates items, invokes agent per item. Guarantees every item is processed.
6
+
7
+ ```js
8
+ import { readFileSync, writeFileSync, existsSync, mkdirSync, appendFileSync, execFileSync } from "fs";
9
+
10
+ const WORK_DIR = process.env.HOME + "/.openclaw/workspace/workflows/sentry-triage";
11
+ const LOG = WORK_DIR + "/logs/workflow.log";
12
+ const INVOKE = process.env.HOME + "/.openclaw/skills/workflow-builder/scripts/invoke.mjs";
13
+
14
+ for (const dir of ["input", "results", "state", "logs"]) {
15
+ mkdirSync(WORK_DIR + "/" + dir, { recursive: true });
16
+ }
17
+
18
+ function log(msg) { appendFileSync(LOG, "[" + new Date().toISOString() + "] " + msg + "\n"); }
19
+
20
+ function invoke(message) {
21
+ execFileSync("node", [INVOKE, message], { stdio: "inherit", timeout: 120000 });
22
+ }
23
+
24
+ function analyzeIssue(issue, outputPath) {
25
+ invoke("Analyze this Sentry issue and write findings to " + outputPath + ": " + JSON.stringify(issue));
26
+ }
27
+
28
+ // Fetch items
29
+ execFileSync("curl", ["-sf", "https://sentry.io/api/0/projects/org/proj/issues/",
30
+ "-H", "Authorization: Bearer " + process.env.SENTRY_TOKEN,
31
+ "-o", WORK_DIR + "/input/issues.json"]);
32
+ const issues = JSON.parse(readFileSync(WORK_DIR + "/input/issues.json", "utf-8"));
33
+
34
+ // Resume from checkpoint
35
+ let start = 0;
36
+ const progressFile = WORK_DIR + "/state/progress.txt";
37
+ if (existsSync(progressFile)) start = parseInt(readFileSync(progressFile, "utf-8"), 10);
38
+
39
+ for (let i = start; i < issues.length; i++) {
40
+ analyzeIssue(issues[i], WORK_DIR + "/results/issue-" + i + ".md");
41
+ writeFileSync(progressFile, String(i + 1));
42
+ log("issue " + i + ": done");
43
+ }
44
+ ```
45
+
46
+ ## 2. Pipeline
47
+
48
+ Script chains steps sequentially. Deterministic work (API calls, transforms) between agent invocations.
49
+
50
+ ```js
51
+ import { mkdirSync, execFileSync } from "fs";
52
+
53
+ const WORK_DIR = process.env.HOME + "/.openclaw/workspace/workflows/report-pipeline";
54
+ const INVOKE = process.env.HOME + "/.openclaw/skills/workflow-builder/scripts/invoke.mjs";
55
+
56
+ for (const dir of ["state", "results", "logs"]) {
57
+ mkdirSync(WORK_DIR + "/" + dir, { recursive: true });
58
+ }
59
+
60
+ function invoke(message) {
61
+ execFileSync("node", [INVOKE, message], { stdio: "inherit", timeout: 120000 });
62
+ }
63
+
64
+ function analyzeReport(input, output) {
65
+ invoke("Read " + input + " and write an analysis to " + output + ". Focus on anomalies and trends.");
66
+ }
67
+
68
+ function validateResponse(response, analysis, output) {
69
+ invoke("Review " + response + " against " + analysis + ". Write a summary to " + output);
70
+ }
71
+
72
+ // Step 1: Deterministic fetch
73
+ execFileSync("curl", ["-sf", "https://api.example.com/report", "-o", WORK_DIR + "/state/raw-report.json"]);
74
+
75
+ // Step 2: Agent analyzes
76
+ analyzeReport(WORK_DIR + "/state/raw-report.json", WORK_DIR + "/state/analysis.md");
77
+
78
+ // Step 3: Deterministic API call
79
+ execFileSync("curl", ["-sf", "-X", "POST", "https://api.example.com/alerts",
80
+ "-d", "@" + WORK_DIR + "/state/analysis.md",
81
+ "-o", WORK_DIR + "/state/alert-response.json"]);
82
+
83
+ // Step 4: Agent validates
84
+ validateResponse(WORK_DIR + "/state/alert-response.json", WORK_DIR + "/state/analysis.md", WORK_DIR + "/results/summary.md");
85
+ ```
86
+
87
+ ## 3. Fan-out / Fan-in
88
+
89
+ Dispatch N parallel agent sessions, wait for results, then synthesize.
90
+
91
+ ```js
92
+ import { existsSync, readFileSync, mkdirSync, appendFileSync, execFile } from "fs";
93
+ import { promisify } from "util";
94
+
95
+ const execFileAsync = promisify(execFile);
96
+ const WORK_DIR = process.env.HOME + "/.openclaw/workspace/workflows/competitor-research";
97
+ const LOG = WORK_DIR + "/logs/workflow.log";
98
+ const INVOKE = process.env.HOME + "/.openclaw/skills/workflow-builder/scripts/invoke.mjs";
99
+
100
+ for (const dir of ["results", "logs"]) {
101
+ mkdirSync(WORK_DIR + "/" + dir, { recursive: true });
102
+ }
103
+
104
+ function log(msg) { appendFileSync(LOG, "[" + new Date().toISOString() + "] " + msg + "\n"); }
105
+
106
+ async function invoke(message) {
107
+ await execFileAsync("node", [INVOKE, message], { timeout: 120000 });
108
+ }
109
+
110
+ async function research(topic, outputPath) {
111
+ await invoke("Research " + topic + " thoroughly. Write findings to " + outputPath);
112
+ }
113
+
114
+ async function synthesize(inputDir, outputPath) {
115
+ await invoke("Read all files in " + inputDir + " and write a comparative analysis to " + outputPath);
116
+ }
117
+
118
+ async function waitForFile(path, maxWaitMs = 600000) {
119
+ const start = Date.now();
120
+ while (Date.now() - start < maxWaitMs) {
121
+ if (existsSync(path) && readFileSync(path, "utf-8").trim() !== "") return true;
122
+ await new Promise(r => setTimeout(r, 10000));
123
+ }
124
+ return false;
125
+ }
126
+
127
+ const topics = ["competitor-a", "competitor-b", "competitor-c"];
128
+
129
+ for (const topic of topics) {
130
+ research(topic, WORK_DIR + "/results/" + topic + ".md")
131
+ .catch(e => log("FAILED: " + topic + " - " + e.message));
132
+ await new Promise(r => setTimeout(r, 2000));
133
+ }
134
+
135
+ for (const topic of topics) {
136
+ if (!await waitForFile(WORK_DIR + "/results/" + topic + ".md")) {
137
+ log("WARNING: no result for " + topic);
138
+ }
139
+ }
140
+
141
+ await synthesize(WORK_DIR + "/results", WORK_DIR + "/results/synthesis.md");
142
+ ```
143
+
144
+ Fan-out is aggressive on memory. Each concurrent `invoke` spawns a 150-200MB agent session on the gateway. Limit concurrency to 2-3 on a 2GB container, or use `think` for steps that only need reasoning.