@loomcycle/client 0.10.0 → 0.10.1
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/README.md +18 -0
- package/dist/cjs/client.js +734 -0
- package/dist/cjs/errors.js +246 -0
- package/dist/cjs/fetch-helpers.js +253 -0
- package/dist/cjs/index.js +91 -0
- package/dist/cjs/package.json +3 -0
- package/dist/cjs/stream.js +84 -0
- package/dist/cjs/types.js +11 -0
- package/dist/errors.d.ts +50 -0
- package/dist/errors.js +50 -0
- package/dist/fetch-helpers.js +29 -2
- package/dist/index.d.ts +1 -1
- package/dist/index.js +1 -1
- package/package.json +9 -5
|
@@ -0,0 +1,734 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* LoomcycleClient — the single public class exported by
|
|
4
|
+
* @loomcycle/client. Speaks HTTP+SSE to a running loomcycle sidecar.
|
|
5
|
+
*
|
|
6
|
+
* hooks-connector PR C: full Python-adapter parity + hook management.
|
|
7
|
+
* 27 methods total — 26 async (run streaming, continuation, agent
|
|
8
|
+
* metadata, transcript, health, users, pause/resume/state, snapshot
|
|
9
|
+
* lifecycle capture / list / get / restore / delete, memory admin,
|
|
10
|
+
* interruption listing + resolve, hook registration / list / delete)
|
|
11
|
+
* plus one synchronous helper (exportSnapshotURL builds a URL string
|
|
12
|
+
* without issuing a request).
|
|
13
|
+
*
|
|
14
|
+
* Construction:
|
|
15
|
+
*
|
|
16
|
+
* const client = new LoomcycleClient({
|
|
17
|
+
* baseUrl: "http://127.0.0.1:8787", // or process.env.LOOMCYCLE_BASE_URL
|
|
18
|
+
* authToken: "...", // or process.env.LOOMCYCLE_AUTH_TOKEN
|
|
19
|
+
* });
|
|
20
|
+
*
|
|
21
|
+
* Streaming methods (`runStreaming`, `continueSession`) return
|
|
22
|
+
* AsyncIterable<AgentEvent>; non-streaming methods return
|
|
23
|
+
* Promise<T>. Non-2xx responses throw typed errors from errors.ts
|
|
24
|
+
* via fetch-helpers.ts:raiseFromResponse — see README.md for the
|
|
25
|
+
* full mapping table.
|
|
26
|
+
*/
|
|
27
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
28
|
+
exports.LoomcycleClient = void 0;
|
|
29
|
+
const fetch_helpers_js_1 = require("./fetch-helpers.js");
|
|
30
|
+
const stream_js_1 = require("./stream.js");
|
|
31
|
+
class LoomcycleClient {
|
|
32
|
+
ctx;
|
|
33
|
+
constructor(opts = {}) {
|
|
34
|
+
this.ctx = {
|
|
35
|
+
baseUrl: (opts.baseUrl ?? "http://127.0.0.1:8787").replace(/\/$/, ""),
|
|
36
|
+
authToken: opts.authToken,
|
|
37
|
+
fetchImpl: opts.fetch ?? fetch,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
// ---- Run lifecycle ----
|
|
41
|
+
/**
|
|
42
|
+
* Run an agent and stream events. Returns AsyncIterable<AgentEvent>;
|
|
43
|
+
* the iterator completes when the server closes the SSE stream.
|
|
44
|
+
*
|
|
45
|
+
* Errors during the run surface as `{ type: "error", error }` events;
|
|
46
|
+
* only transport / HTTP-level failures throw — and those throw typed
|
|
47
|
+
* errors (e.g. AuthError for 401, BackpressureError for 429).
|
|
48
|
+
*
|
|
49
|
+
* **Blocking semantics.** This iterator is alive for the FULL
|
|
50
|
+
* duration of the run — typically seconds, occasionally minutes for
|
|
51
|
+
* long tool chains. Callers that need fire-and-forget completion
|
|
52
|
+
* notifications (n8n's worker model, dashboards that don't want to
|
|
53
|
+
* hold a connection per active run) should subscribe to
|
|
54
|
+
* {@link LoomcycleClient.streamUserRunStates} instead, which yields
|
|
55
|
+
* one terminal-state frame per completed run without holding the
|
|
56
|
+
* run's stream open.
|
|
57
|
+
*
|
|
58
|
+
* v0.9.x — pass `opts.debug = true` to emit synthetic
|
|
59
|
+
* `{ type: "_meta", meta_subtype: "stream_open" | "stream_close" }`
|
|
60
|
+
* events around the real frames. Silent (default) when omitted.
|
|
61
|
+
*/
|
|
62
|
+
async *runStreaming(opts) {
|
|
63
|
+
// Build the body conditionally so omitted fields stay off the wire.
|
|
64
|
+
// The pointer-vs-empty distinction on allowed_hosts is preserved by
|
|
65
|
+
// treating `null` as "omit" — same as the server's nil semantics —
|
|
66
|
+
// so callers threading a possibly-unset slice don't accidentally
|
|
67
|
+
// send `allowed_hosts: null` (which JSON-decodes to a deny-all on
|
|
68
|
+
// some implementations).
|
|
69
|
+
const body = {
|
|
70
|
+
agent: opts.agent,
|
|
71
|
+
segments: opts.segments,
|
|
72
|
+
};
|
|
73
|
+
if (opts.allowedTools !== undefined)
|
|
74
|
+
body.allowed_tools = opts.allowedTools;
|
|
75
|
+
if (opts.allowedHosts !== undefined && opts.allowedHosts !== null) {
|
|
76
|
+
body.allowed_hosts = opts.allowedHosts;
|
|
77
|
+
}
|
|
78
|
+
if (opts.webSearchFilter !== undefined)
|
|
79
|
+
body.web_search_filter = opts.webSearchFilter;
|
|
80
|
+
if (opts.sessionId !== undefined)
|
|
81
|
+
body.session_id = opts.sessionId;
|
|
82
|
+
if (opts.tenantId !== undefined)
|
|
83
|
+
body.tenant_id = opts.tenantId;
|
|
84
|
+
if (opts.userId !== undefined)
|
|
85
|
+
body.user_id = opts.userId;
|
|
86
|
+
if (opts.agentId !== undefined)
|
|
87
|
+
body.agent_id = opts.agentId;
|
|
88
|
+
if (opts.userTier !== undefined)
|
|
89
|
+
body.user_tier = opts.userTier;
|
|
90
|
+
if (opts.userBearer !== undefined)
|
|
91
|
+
body.user_bearer = opts.userBearer;
|
|
92
|
+
yield* this.streamSSE("/v1/runs", body, opts.signal, opts.debug);
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* Continue an existing session with a new run. The session's prior
|
|
96
|
+
* transcript is replayed into the model's context server-side;
|
|
97
|
+
* this iterator yields only the NEW events from the continuation.
|
|
98
|
+
*
|
|
99
|
+
* Raises SessionNotFoundError when sessionId is unknown,
|
|
100
|
+
* SessionBusyError when another request is in flight on the same
|
|
101
|
+
* session.
|
|
102
|
+
*
|
|
103
|
+
* **Blocking semantics.** Same as {@link LoomcycleClient.runStreaming} —
|
|
104
|
+
* the iterator stays alive for the duration of the new run. For
|
|
105
|
+
* async fire-and-forget completion patterns, see
|
|
106
|
+
* {@link LoomcycleClient.streamUserRunStates}.
|
|
107
|
+
*
|
|
108
|
+
* v0.9.x — pass `opts.debug = true` for synthetic
|
|
109
|
+
* `_meta` open/close events.
|
|
110
|
+
*/
|
|
111
|
+
async *continueSession(opts) {
|
|
112
|
+
const body = {
|
|
113
|
+
segments: opts.segments,
|
|
114
|
+
};
|
|
115
|
+
if (opts.allowedTools !== undefined)
|
|
116
|
+
body.allowed_tools = opts.allowedTools;
|
|
117
|
+
if (opts.allowedHosts !== undefined && opts.allowedHosts !== null) {
|
|
118
|
+
body.allowed_hosts = opts.allowedHosts;
|
|
119
|
+
}
|
|
120
|
+
if (opts.webSearchFilter !== undefined)
|
|
121
|
+
body.web_search_filter = opts.webSearchFilter;
|
|
122
|
+
if (opts.agentId !== undefined)
|
|
123
|
+
body.agent_id = opts.agentId;
|
|
124
|
+
if (opts.userTier !== undefined)
|
|
125
|
+
body.user_tier = opts.userTier;
|
|
126
|
+
if (opts.userBearer !== undefined)
|
|
127
|
+
body.user_bearer = opts.userBearer;
|
|
128
|
+
yield* this.streamSSE(`/v1/sessions/${encodeURIComponent(opts.sessionId)}/messages`, body, opts.signal, opts.debug);
|
|
129
|
+
}
|
|
130
|
+
// ---- Agent metadata ----
|
|
131
|
+
/** Read one agent's status + usage stats. Raises AgentNotFoundError
|
|
132
|
+
* when the agent_id is unknown. */
|
|
133
|
+
async getAgent(agentId, opts) {
|
|
134
|
+
return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/agents/${encodeURIComponent(agentId)}`, opts);
|
|
135
|
+
}
|
|
136
|
+
/** Cancel a live agent (cascades to children via parent_agent_id).
|
|
137
|
+
* Returns count of agents cancelled. Idempotent — already-terminated
|
|
138
|
+
* agents return 0. */
|
|
139
|
+
async cancelAgent(agentId, opts) {
|
|
140
|
+
const resp = await (0, fetch_helpers_js_1.postJSON)(this.ctx, `/v1/agents/${encodeURIComponent(agentId)}/cancel`, { reason: opts?.reason ?? "" }, opts);
|
|
141
|
+
return { cancelledCount: resp.cancelled_count };
|
|
142
|
+
}
|
|
143
|
+
/** List a user's recent agent runs, optionally filtered by status.
|
|
144
|
+
*
|
|
145
|
+
* v0.9.x — `parentAgentId` narrows the result CLIENT-SIDE to runs
|
|
146
|
+
* whose `parent_agent_id` matches. The server still returns the
|
|
147
|
+
* full set (server-side `?parent_agent_id=` filter is a future
|
|
148
|
+
* request); the adapter trims before returning. Useful for the
|
|
149
|
+
* n8n trigger pattern "show me all sub-runs spawned by parent X." */
|
|
150
|
+
async listUserAgents(userId, opts) {
|
|
151
|
+
const q = opts?.status ? `?status=${encodeURIComponent(opts.status)}` : "";
|
|
152
|
+
const resp = await (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/users/${encodeURIComponent(userId)}/agents${q}`, opts);
|
|
153
|
+
const all = resp.agents ?? [];
|
|
154
|
+
if (opts?.parentAgentId !== undefined && opts.parentAgentId !== "") {
|
|
155
|
+
return all.filter((a) => a.parent_agent_id === opts.parentAgentId);
|
|
156
|
+
}
|
|
157
|
+
return all;
|
|
158
|
+
}
|
|
159
|
+
/** Read the full event log for a session. Each entry has seq,
|
|
160
|
+
* run_id, ts_ns, type, event (the providers.Event payload). */
|
|
161
|
+
async getTranscript(sessionId, opts) {
|
|
162
|
+
return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/sessions/${encodeURIComponent(sessionId)}/transcript`, opts);
|
|
163
|
+
}
|
|
164
|
+
/** Liveness probe. Unauthenticated. Returns build info + uptime.
|
|
165
|
+
* Hits /healthz, not /v1/. */
|
|
166
|
+
async health(opts) {
|
|
167
|
+
return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, "/healthz", opts);
|
|
168
|
+
}
|
|
169
|
+
/** Admin: list known users with running-count summary. Drives the
|
|
170
|
+
* Web UI's user picker; operators with bearer auth can call too. */
|
|
171
|
+
async listUsers(opts) {
|
|
172
|
+
return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, "/v1/_users", opts);
|
|
173
|
+
}
|
|
174
|
+
// ---- v0.8.17/8.18 Pause / Resume / State ----
|
|
175
|
+
/** Quiesce the runtime. Idempotent tools cancel immediately;
|
|
176
|
+
* non-idempotent + external tools get a grace window then
|
|
177
|
+
* force-cancel. Raises AlreadyPausingError on 409,
|
|
178
|
+
* PauseNotConfiguredError on 503. */
|
|
179
|
+
async pauseRuntime(opts) {
|
|
180
|
+
const body = opts?.timeoutMs && opts.timeoutMs > 0
|
|
181
|
+
? { timeout_ms: opts.timeoutMs }
|
|
182
|
+
: undefined;
|
|
183
|
+
return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_pause", body, opts);
|
|
184
|
+
}
|
|
185
|
+
/** Release the runtime quiesce. Raises NotPausedError on 409. */
|
|
186
|
+
async resumeRuntime(opts) {
|
|
187
|
+
return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_resume", undefined, opts);
|
|
188
|
+
}
|
|
189
|
+
/** Current runtime state. Cheap query — atomic state + a
|
|
190
|
+
* bounded snapshots count. */
|
|
191
|
+
async getRuntimeState(opts) {
|
|
192
|
+
return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, "/v1/_state", opts);
|
|
193
|
+
}
|
|
194
|
+
// ---- Snapshot lifecycle ----
|
|
195
|
+
/** Capture running-state into a per-section-semver JSON envelope.
|
|
196
|
+
* Raises SnapshotTooLargeError on 413 when the envelope exceeds
|
|
197
|
+
* LOOMCYCLE_SNAPSHOT_MAX_BYTES (default 512 MiB). */
|
|
198
|
+
async createSnapshot(opts) {
|
|
199
|
+
const body = {};
|
|
200
|
+
if (opts?.label)
|
|
201
|
+
body.label = opts.label;
|
|
202
|
+
if (opts?.includeHistory)
|
|
203
|
+
body.include_history = true;
|
|
204
|
+
if (opts?.includeHistorySince)
|
|
205
|
+
body.include_history_since = opts.includeHistorySince;
|
|
206
|
+
if (opts?.maxBytes && opts.maxBytes > 0)
|
|
207
|
+
body.max_bytes = opts.maxBytes;
|
|
208
|
+
return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_snapshots", body, opts);
|
|
209
|
+
}
|
|
210
|
+
/** List captured snapshots (most-recent first). Capped at 200
|
|
211
|
+
* server-side; the limit param defaults to 200 too. */
|
|
212
|
+
async listSnapshots(opts) {
|
|
213
|
+
const params = new URLSearchParams();
|
|
214
|
+
if (opts?.limit && opts.limit > 0)
|
|
215
|
+
params.set("limit", String(opts.limit));
|
|
216
|
+
if (opts?.labelContains)
|
|
217
|
+
params.set("label_contains", opts.labelContains);
|
|
218
|
+
const qs = params.toString();
|
|
219
|
+
const path = qs ? `/v1/_snapshots?${qs}` : "/v1/_snapshots";
|
|
220
|
+
const resp = await (0, fetch_helpers_js_1.jsonFetch)(this.ctx, path, opts);
|
|
221
|
+
return resp.entries ?? [];
|
|
222
|
+
}
|
|
223
|
+
/** Fetch the full snapshot envelope including JSON content.
|
|
224
|
+
* Distinct from exportSnapshot (which is operator-facing
|
|
225
|
+
* "where did this land on the host" semantics with a download
|
|
226
|
+
* URL). Raises SnapshotNotFoundError on 404. */
|
|
227
|
+
async getSnapshot(snapshotId, opts) {
|
|
228
|
+
return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/_snapshots/${encodeURIComponent(snapshotId)}`, opts);
|
|
229
|
+
}
|
|
230
|
+
/** Returns the URL of the snapshot's canonical envelope —
|
|
231
|
+
* synchronous and side-effect-free; does NOT issue an HTTP
|
|
232
|
+
* request. The endpoint is bearer-authed like every other
|
|
233
|
+
* `/v1/_snapshots/*` route, so callers must attach the same
|
|
234
|
+
* `Authorization: Bearer <token>` header when fetching this
|
|
235
|
+
* URL (e.g. `curl -H "Authorization: Bearer $TOKEN" ...`).
|
|
236
|
+
* There is no token query-param fallback. */
|
|
237
|
+
exportSnapshotURL(snapshotId) {
|
|
238
|
+
return `${this.ctx.baseUrl}/v1/_snapshots/${encodeURIComponent(snapshotId)}/export`;
|
|
239
|
+
}
|
|
240
|
+
/** Restore from a same-instance snapshot id OR an inline
|
|
241
|
+
* envelope JSON. Idempotent: ON CONFLICT DO NOTHING per row;
|
|
242
|
+
* the returned counters reflect rows actually written.
|
|
243
|
+
* Raises SnapshotVersionError on 422 when a section's
|
|
244
|
+
* declared version is newer than the reader supports. */
|
|
245
|
+
async restoreSnapshot(opts) {
|
|
246
|
+
if (!opts.snapshotId && opts.json === undefined) {
|
|
247
|
+
// Client-side validation — match Python adapter's
|
|
248
|
+
// InvalidArgumentError pattern but the typed-error layer
|
|
249
|
+
// lives in errors.ts; for a thrown plain error here the
|
|
250
|
+
// method's catchers just see the message.
|
|
251
|
+
throw new Error("restoreSnapshot: pass snapshotId or json (one is required)");
|
|
252
|
+
}
|
|
253
|
+
if (opts.snapshotId && opts.json !== undefined) {
|
|
254
|
+
throw new Error("restoreSnapshot: pass only one of snapshotId or json");
|
|
255
|
+
}
|
|
256
|
+
// When json is supplied the id path-segment is ignored
|
|
257
|
+
// server-side; we use a placeholder "inline" segment to keep
|
|
258
|
+
// the URL well-formed.
|
|
259
|
+
const id = opts.snapshotId ?? "inline";
|
|
260
|
+
const body = {};
|
|
261
|
+
if (opts.includeHistory)
|
|
262
|
+
body.include_history = true;
|
|
263
|
+
if (opts.json !== undefined)
|
|
264
|
+
body.json = opts.json;
|
|
265
|
+
return (0, fetch_helpers_js_1.postJSON)(this.ctx, `/v1/_snapshots/${encodeURIComponent(id)}/restore`, body, opts);
|
|
266
|
+
}
|
|
267
|
+
/** Delete a snapshot. Idempotent — succeeds whether or not the
|
|
268
|
+
* row existed (server returns 204 in both cases). */
|
|
269
|
+
async deleteSnapshot(snapshotId, opts) {
|
|
270
|
+
await (0, fetch_helpers_js_1.deleteRequest)(this.ctx, `/v1/_snapshots/${encodeURIComponent(snapshotId)}`, opts);
|
|
271
|
+
}
|
|
272
|
+
// ---- Memory admin ----
|
|
273
|
+
/** List the kinds of memory scopes the server knows about
|
|
274
|
+
* (agent, user — or whatever the operator yaml declares). */
|
|
275
|
+
async listMemoryScopes(opts) {
|
|
276
|
+
return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, "/v1/_memory/scopes", opts);
|
|
277
|
+
}
|
|
278
|
+
/** List the scope_ids that have at least one memory row under
|
|
279
|
+
* a given scope. */
|
|
280
|
+
async listMemoryScopeIDs(scope, opts) {
|
|
281
|
+
return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/_memory/scopes/${encodeURIComponent(scope)}`, opts);
|
|
282
|
+
}
|
|
283
|
+
/** List memory entries under a (scope, scope_id) tuple.
|
|
284
|
+
* Optional prefix narrows by key prefix. */
|
|
285
|
+
async listMemoryEntries(scope, scopeID, opts) {
|
|
286
|
+
const params = new URLSearchParams();
|
|
287
|
+
if (opts?.prefix)
|
|
288
|
+
params.set("prefix", opts.prefix);
|
|
289
|
+
// Guard against `limit: 0` (falsy but valid-looking) and negatives —
|
|
290
|
+
// both would either send `limit=0` (server treats as default but the
|
|
291
|
+
// semantic is unclear) or `limit=-N` (server rejects). Only send the
|
|
292
|
+
// param when the caller passed a meaningful positive number.
|
|
293
|
+
if (opts?.limit && opts.limit > 0)
|
|
294
|
+
params.set("limit", String(opts.limit));
|
|
295
|
+
const qs = params.toString();
|
|
296
|
+
const path = `/v1/_memory/scopes/${encodeURIComponent(scope)}/${encodeURIComponent(scopeID)}/keys${qs ? "?" + qs : ""}`;
|
|
297
|
+
return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, path, opts);
|
|
298
|
+
}
|
|
299
|
+
/** Read a single memory entry by (scope, scope_id, key). */
|
|
300
|
+
async getMemoryEntry(scope, scopeID, key, opts) {
|
|
301
|
+
return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/_memory/scopes/${encodeURIComponent(scope)}/${encodeURIComponent(scopeID)}/keys/${encodeURIComponent(key)}`, opts);
|
|
302
|
+
}
|
|
303
|
+
// ---- Interruption ----
|
|
304
|
+
/** List interrupts addressable to a user_id. Default filter is
|
|
305
|
+
* status=pending. */
|
|
306
|
+
async listUserInterrupts(userId, opts) {
|
|
307
|
+
const status = opts?.status ?? "pending";
|
|
308
|
+
return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/users/${encodeURIComponent(userId)}/interrupts?status=${encodeURIComponent(status)}`, opts);
|
|
309
|
+
}
|
|
310
|
+
/** List interrupts emitted by a specific run. */
|
|
311
|
+
async listRunInterrupts(runId, opts) {
|
|
312
|
+
const status = opts?.status ?? "pending";
|
|
313
|
+
return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, `/v1/runs/${encodeURIComponent(runId)}/interrupts?status=${encodeURIComponent(status)}`, opts);
|
|
314
|
+
}
|
|
315
|
+
/** Resolve a pending Interruption.ask from outside the agent
|
|
316
|
+
* loop. Lets a TS-side dashboard or service act as the human
|
|
317
|
+
* answerer when operator yaml configures the consumer-MCP
|
|
318
|
+
* backend. */
|
|
319
|
+
async resolveInterrupt(runId, interruptId, opts) {
|
|
320
|
+
return (0, fetch_helpers_js_1.postJSON)(this.ctx, `/v1/runs/${encodeURIComponent(runId)}/interrupts/${encodeURIComponent(interruptId)}/resolve`, {
|
|
321
|
+
kind: opts.kind ?? "question",
|
|
322
|
+
answer: opts.answer,
|
|
323
|
+
resolved_by: opts.resolvedBy ?? "client",
|
|
324
|
+
}, opts);
|
|
325
|
+
}
|
|
326
|
+
// ---- Hook management (hooks-connector series, PR C) ----
|
|
327
|
+
/** Register a pre- or post-tool webhook. The callback_url must be
|
|
328
|
+
* an http:// or https:// endpoint the CONSUMER runs — loomcycle
|
|
329
|
+
* POSTs PreHookCall / PostHookCall payloads to it. This method
|
|
330
|
+
* manages registration only; the receiver is the consumer's own
|
|
331
|
+
* HTTP framework (Express, Next.js, etc.).
|
|
332
|
+
*
|
|
333
|
+
* Re-registering the same (owner, name) replaces the prior entry
|
|
334
|
+
* with a fresh id (idempotent app-restart contract).
|
|
335
|
+
*
|
|
336
|
+
* Raises InvalidArgumentError on 400 (bad URL / phase / missing
|
|
337
|
+
* required fields). */
|
|
338
|
+
async registerHook(opts) {
|
|
339
|
+
const body = {
|
|
340
|
+
owner: opts.owner,
|
|
341
|
+
name: opts.name,
|
|
342
|
+
phase: opts.phase,
|
|
343
|
+
callback_url: opts.callbackUrl,
|
|
344
|
+
};
|
|
345
|
+
if (opts.agents !== undefined)
|
|
346
|
+
body.agents = opts.agents;
|
|
347
|
+
if (opts.tools !== undefined)
|
|
348
|
+
body.tools = opts.tools;
|
|
349
|
+
if (opts.failMode !== undefined)
|
|
350
|
+
body.fail_mode = opts.failMode;
|
|
351
|
+
if (opts.timeoutMs !== undefined && opts.timeoutMs > 0) {
|
|
352
|
+
body.timeout_ms = opts.timeoutMs;
|
|
353
|
+
}
|
|
354
|
+
return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/hooks", body, opts);
|
|
355
|
+
}
|
|
356
|
+
/** List every currently-registered hook. Returns the array
|
|
357
|
+
* unwrapped (the wire envelope is `{hooks: [...]}` — we strip
|
|
358
|
+
* the envelope to match listUserAgents). In-memory only — empty
|
|
359
|
+
* after a loomcycle restart. */
|
|
360
|
+
async listHooks(opts) {
|
|
361
|
+
const resp = await (0, fetch_helpers_js_1.jsonFetch)(this.ctx, "/v1/hooks", opts);
|
|
362
|
+
return resp.hooks ?? [];
|
|
363
|
+
}
|
|
364
|
+
/** Delete a registered hook by id. Raises HookNotFoundError on
|
|
365
|
+
* 404. Returns void on success (the HTTP 200 body `{deleted: id}`
|
|
366
|
+
* is dropped — callers already know the id they passed). */
|
|
367
|
+
async deleteHook(id, opts) {
|
|
368
|
+
await (0, fetch_helpers_js_1.deleteRequest)(this.ctx, `/v1/hooks/${encodeURIComponent(id)}`, opts);
|
|
369
|
+
}
|
|
370
|
+
// ---- v0.8.22 substrate admin (AgentDef + SkillDef) ----
|
|
371
|
+
/** Invoke the AgentDef substrate tool over HTTP. Mirrors the
|
|
372
|
+
* MCP `agentdef` meta-tool and the in-band agent tool_use of
|
|
373
|
+
* the same name — different transport, identical semantics.
|
|
374
|
+
*
|
|
375
|
+
* The `input.op` field discriminates create / fork / get /
|
|
376
|
+
* list / promote / retire. The remaining fields are op-specific;
|
|
377
|
+
* see the in-process tool's documentation.
|
|
378
|
+
*
|
|
379
|
+
* Raises {@link SubstrateToolRefusedError} when the tool itself
|
|
380
|
+
* refuses the call (scope deny, empty body, allowed-tools
|
|
381
|
+
* widening, etc.) — distinct from transport failures so callers
|
|
382
|
+
* can branch on the typed error class.
|
|
383
|
+
*
|
|
384
|
+
* Raises {@link InvalidArgumentError} on 400 (malformed JSON
|
|
385
|
+
* body); {@link AuthError} on 401; {@link UnavailableError} on
|
|
386
|
+
* 503 (store / connector unwired). */
|
|
387
|
+
async agentDef(input, opts) {
|
|
388
|
+
return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_agentdef", input, opts);
|
|
389
|
+
}
|
|
390
|
+
/** Invoke the SkillDef substrate tool over HTTP. Mirror of
|
|
391
|
+
* {@link LoomcycleClient.agentDef} for skills (v0.8.22+). Same
|
|
392
|
+
* input grammar, same error class on refusal. See the
|
|
393
|
+
* agentDef() doc for the full shape and error contract. */
|
|
394
|
+
async skillDef(input, opts) {
|
|
395
|
+
return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_skilldef", input, opts);
|
|
396
|
+
}
|
|
397
|
+
/** Invoke the v0.9.x MCPServerDef substrate tool over HTTP.
|
|
398
|
+
* Dynamic MCP server registration — register an HTTP /
|
|
399
|
+
* Streamable-HTTP MCP server at runtime so its tools become
|
|
400
|
+
* callable from any agent's `allowed_tools` list without a yaml
|
|
401
|
+
* edit + restart.
|
|
402
|
+
*
|
|
403
|
+
* Operator-admin-only: this endpoint requires the bearer token.
|
|
404
|
+
*
|
|
405
|
+
* Op-discriminated input: `{op: "create" | "fork" | "get" | "list"
|
|
406
|
+
* | "promote" | "retire" | "rediscover" | "verify", ...}`. Returns
|
|
407
|
+
* shape varies — narrow with {@link MCPServerDefRowResponse} for
|
|
408
|
+
* create/fork/get/list rows, {@link MCPServerDefVerifyResult} for
|
|
409
|
+
* verify responses.
|
|
410
|
+
*
|
|
411
|
+
* Hard constraints (substrate refuses these):
|
|
412
|
+
* - Transport must be `http` or `streamable-http` (stdio stays
|
|
413
|
+
* yaml-only — dynamic registration doesn't allow process spawn).
|
|
414
|
+
* - URL hostname must be in LOOMCYCLE_HTTP_HOST_ALLOWLIST (SSRF
|
|
415
|
+
* defence at the registration boundary).
|
|
416
|
+
* - Name colliding with a static cfg.MCPServers entry is refused
|
|
417
|
+
* (yaml is ground truth; use a different name).
|
|
418
|
+
*
|
|
419
|
+
* Raises {@link SubstrateToolRefusedError} on tool-level refusals
|
|
420
|
+
* (transport/host/yaml-name); {@link InvalidArgumentError} on 400
|
|
421
|
+
* (malformed JSON); {@link AuthError} on 401. */
|
|
422
|
+
async mcpServerDef(input, opts) {
|
|
423
|
+
return (0, fetch_helpers_js_1.postJSON)(this.ctx, "/v1/_mcpserverdef", input, opts);
|
|
424
|
+
}
|
|
425
|
+
// ---- Internal helpers ----
|
|
426
|
+
/** Shared SSE POST → stream-of-AgentEvent path. Used by
|
|
427
|
+
* runStreaming + continueSession.
|
|
428
|
+
*
|
|
429
|
+
* When `debug` is true, the iterator yields a synthetic
|
|
430
|
+
* `{ type: "_meta", meta_subtype: "stream_open" }` before any real
|
|
431
|
+
* events AND a `{ type: "_meta", meta_subtype: "stream_close",
|
|
432
|
+
* meta_reason }` on EOF / abort / error. The default is silent
|
|
433
|
+
* (matches pre-v0.9.x behaviour). */
|
|
434
|
+
async *streamSSE(path, body, signal, debug) {
|
|
435
|
+
const headers = {
|
|
436
|
+
"Content-Type": "application/json",
|
|
437
|
+
// Accept BOTH text/event-stream (the success path) AND
|
|
438
|
+
// application/json (the error path — non-2xx responses come
|
|
439
|
+
// back as JSON so raiseFromResponse can extract typed errors).
|
|
440
|
+
// Per the Streamable HTTP spec; strict reverse proxies in
|
|
441
|
+
// front of the sidecar 406 otherwise. Same rationale as the
|
|
442
|
+
// v0.8.x MCP HTTP-transport hardening note in CLAUDE.md.
|
|
443
|
+
Accept: "text/event-stream, application/json",
|
|
444
|
+
};
|
|
445
|
+
if (this.ctx.authToken)
|
|
446
|
+
headers.Authorization = `Bearer ${this.ctx.authToken}`;
|
|
447
|
+
const resp = await this.ctx.fetchImpl(this.ctx.baseUrl + path, {
|
|
448
|
+
method: "POST",
|
|
449
|
+
headers,
|
|
450
|
+
body: JSON.stringify(body),
|
|
451
|
+
signal,
|
|
452
|
+
});
|
|
453
|
+
if (!resp.ok) {
|
|
454
|
+
await (0, fetch_helpers_js_1.raiseFromResponse)(resp);
|
|
455
|
+
}
|
|
456
|
+
if (!resp.body) {
|
|
457
|
+
throw new Error("loomcycle: response has no body");
|
|
458
|
+
}
|
|
459
|
+
if (!debug) {
|
|
460
|
+
// Silent default — pre-v0.9.x shape.
|
|
461
|
+
yield* (0, stream_js_1.parseSSE)(resp.body.getReader());
|
|
462
|
+
return;
|
|
463
|
+
}
|
|
464
|
+
// Debug shape: synthetic open + close around the real stream.
|
|
465
|
+
// The open frame carries no meta_reason — the frame itself IS the
|
|
466
|
+
// signal. The close frame's meta_reason distinguishes normal EOF
|
|
467
|
+
// from caller-side abort or a typed-error throw mid-stream.
|
|
468
|
+
//
|
|
469
|
+
// Close is emitted on both paths via try/catch/throw: success path
|
|
470
|
+
// emits AFTER the try block; error path emits INSIDE the catch
|
|
471
|
+
// before re-throwing. NOT a try/finally — the duplication is
|
|
472
|
+
// intentional so the close-then-throw ordering is explicit and
|
|
473
|
+
// a refactor adding `finally` doesn't accidentally double-emit.
|
|
474
|
+
yield { type: "_meta", meta_subtype: "stream_open" };
|
|
475
|
+
let closeReason = "eof";
|
|
476
|
+
try {
|
|
477
|
+
yield* (0, stream_js_1.parseSSE)(resp.body.getReader());
|
|
478
|
+
}
|
|
479
|
+
catch (e) {
|
|
480
|
+
// Capture the error type for the close frame, then re-throw so
|
|
481
|
+
// typed-error handling at the consumer site still works.
|
|
482
|
+
closeReason =
|
|
483
|
+
e && typeof e === "object" && "name" in e
|
|
484
|
+
? String(e.name)
|
|
485
|
+
: "error";
|
|
486
|
+
yield {
|
|
487
|
+
type: "_meta",
|
|
488
|
+
meta_subtype: "stream_close",
|
|
489
|
+
meta_reason: closeReason,
|
|
490
|
+
};
|
|
491
|
+
throw e;
|
|
492
|
+
}
|
|
493
|
+
yield {
|
|
494
|
+
type: "_meta",
|
|
495
|
+
meta_subtype: "stream_close",
|
|
496
|
+
meta_reason: closeReason,
|
|
497
|
+
};
|
|
498
|
+
}
|
|
499
|
+
// ---- v0.9.x n8n RFC Phase 0 ----
|
|
500
|
+
/** List every operator-declared channel with aggregate stats
|
|
501
|
+
* (message_count, oldest_visible_at, newest_visible_at).
|
|
502
|
+
* Channels with no published messages still appear with
|
|
503
|
+
* message_count=0. Orphaned message rows for un-declared channels
|
|
504
|
+
* also appear (forensic visibility). Mirrors GET /v1/_channels. */
|
|
505
|
+
async listChannels(opts) {
|
|
506
|
+
return await (0, fetch_helpers_js_1.jsonFetch)(this.ctx, "/v1/_channels", opts);
|
|
507
|
+
}
|
|
508
|
+
// ---- v0.9.x Channel CRUD ----
|
|
509
|
+
//
|
|
510
|
+
// Four bearer-authed ops mirroring the in-band Channel tool's
|
|
511
|
+
// publish/subscribe/peek/ack. Two URL families behind the
|
|
512
|
+
// `scope` field:
|
|
513
|
+
// - scope: "global" → POST /v1/_channels/{name}/{op} (admin)
|
|
514
|
+
// - scope: "user" → POST /v1/users/{userId}/channels/{name}/{op}
|
|
515
|
+
//
|
|
516
|
+
// The same operator bearer token guards both surfaces; the per-user
|
|
517
|
+
// URL embeds the user_id in the path so a caller can't forge a
|
|
518
|
+
// different user_id by lying in the body.
|
|
519
|
+
//
|
|
520
|
+
// Subscribe is a SINGLE-ROUND-TRIP long-poll, not an open stream.
|
|
521
|
+
// For continuous delivery, call `subscribeChannel` in a loop (the
|
|
522
|
+
// n8n trigger node's pattern). Auto-commits the cursor on non-empty
|
|
523
|
+
// batches (at-most-once shape) — use `peekChannel` + explicit
|
|
524
|
+
// `ackChannel` for at-least-once semantics.
|
|
525
|
+
/** Publish a JSON payload to an operator-declared channel. Mirrors
|
|
526
|
+
* the in-band Channel tool's publish op semantics — including
|
|
527
|
+
* deferred delivery via `deliverAt` (RFC3339Nano).
|
|
528
|
+
*
|
|
529
|
+
* Errors:
|
|
530
|
+
* - {@link NotFoundError} (404) when the channel isn't in operator
|
|
531
|
+
* yaml. The wire `code` is `channel_not_declared`.
|
|
532
|
+
* - {@link InvalidArgumentError} (400) on invalid scope / payload.
|
|
533
|
+
* - {@link AuthError} (401) on bearer mismatch. */
|
|
534
|
+
async publishChannel(channel, opts) {
|
|
535
|
+
const path = channelOpPath(channel, opts.scope, opts.userId, "publish");
|
|
536
|
+
const body = { payload: opts.payload };
|
|
537
|
+
if (opts.deliverAt)
|
|
538
|
+
body.deliver_at = opts.deliverAt;
|
|
539
|
+
return (0, fetch_helpers_js_1.postJSON)(this.ctx, path, body, {
|
|
540
|
+
signal: opts.signal,
|
|
541
|
+
});
|
|
542
|
+
}
|
|
543
|
+
/** Read the next batch of messages from a channel. Single-round-
|
|
544
|
+
* trip long-poll: returns immediately if messages are present,
|
|
545
|
+
* otherwise waits up to `waitMs` for a publish. AUTO-COMMITS the
|
|
546
|
+
* cursor on a non-empty batch.
|
|
547
|
+
*
|
|
548
|
+
* For at-least-once delivery (crash safety between "loomcycle
|
|
549
|
+
* returned the batch" and "consumer finished processing"), use
|
|
550
|
+
* {@link LoomcycleClient.peekChannel} + an explicit
|
|
551
|
+
* {@link LoomcycleClient.ackChannel} after durable processing. */
|
|
552
|
+
async subscribeChannel(channel, opts) {
|
|
553
|
+
const path = channelOpPath(channel, opts.scope, opts.userId, "subscribe");
|
|
554
|
+
const body = {};
|
|
555
|
+
if (opts.fromCursor !== undefined)
|
|
556
|
+
body.from_cursor = opts.fromCursor;
|
|
557
|
+
if (opts.maxMessages !== undefined)
|
|
558
|
+
body.max_messages = opts.maxMessages;
|
|
559
|
+
if (opts.waitMs !== undefined)
|
|
560
|
+
body.wait_ms = opts.waitMs;
|
|
561
|
+
return (0, fetch_helpers_js_1.postJSON)(this.ctx, path, body, {
|
|
562
|
+
signal: opts.signal,
|
|
563
|
+
});
|
|
564
|
+
}
|
|
565
|
+
/** Non-destructive read — never advances the committed cursor.
|
|
566
|
+
* Use for at-least-once consumption patterns: peek, process the
|
|
567
|
+
* batch durably, then `ackChannel` to advance. Multiple consumers
|
|
568
|
+
* can peek the same channel without disturbing each other. */
|
|
569
|
+
async peekChannel(channel, opts) {
|
|
570
|
+
let path = channelOpPath(channel, opts.scope, opts.userId, "peek");
|
|
571
|
+
const params = [];
|
|
572
|
+
if (opts.fromCursor)
|
|
573
|
+
params.push(`from_cursor=${encodeURIComponent(opts.fromCursor)}`);
|
|
574
|
+
if (opts.maxMessages)
|
|
575
|
+
params.push(`max_messages=${opts.maxMessages}`);
|
|
576
|
+
if (params.length > 0)
|
|
577
|
+
path += `?${params.join("&")}`;
|
|
578
|
+
return (0, fetch_helpers_js_1.jsonFetch)(this.ctx, path, { signal: opts.signal });
|
|
579
|
+
}
|
|
580
|
+
/** Advance the committed cursor for a (channel, scope, scope_id)
|
|
581
|
+
* tuple. Cursor must be monotonically forward — older cursors
|
|
582
|
+
* raise a {@link ConflictError} (HTTP 409, code
|
|
583
|
+
* `channel_cursor_regression`). */
|
|
584
|
+
async ackChannel(channel, opts) {
|
|
585
|
+
const path = channelOpPath(channel, opts.scope, opts.userId, "ack");
|
|
586
|
+
return (0, fetch_helpers_js_1.postJSON)(this.ctx, path, { cursor: opts.cursor }, { signal: opts.signal });
|
|
587
|
+
}
|
|
588
|
+
/** Subscribe to run state transitions for one user_id via SSE.
|
|
589
|
+
* Yields one `{ kind: "open", ... }` item first (confirms the
|
|
590
|
+
* connection is live), then one `{ kind: "event", ... }` per
|
|
591
|
+
* matching state transition until the stream closes.
|
|
592
|
+
*
|
|
593
|
+
* The stream stays open for at most 30 minutes (server-enforced).
|
|
594
|
+
* Callers running indefinitely should reconnect on close.
|
|
595
|
+
*
|
|
596
|
+
* Errors during the stream throw — they do NOT surface as items.
|
|
597
|
+
* Pass an AbortSignal to terminate cleanly from the consumer side.
|
|
598
|
+
*
|
|
599
|
+
* v0.9.x options:
|
|
600
|
+
* - `parentAgentId` — client-side filter: only `kind: "event"`
|
|
601
|
+
* items whose payload's `parent_agent_id` matches are yielded.
|
|
602
|
+
* The server still streams every matching event; the adapter
|
|
603
|
+
* filters before yielding. Empty/omitted = no filter.
|
|
604
|
+
* - `debug` — when true, an additional `{ kind: "close", payload:
|
|
605
|
+
* { reason } }` item is yielded when the stream ends (EOF,
|
|
606
|
+
* abort, or pre-yield error). Useful for n8n nodes that surface
|
|
607
|
+
* "stream re-opened / closed" log entries without inferring
|
|
608
|
+
* from timing. Default false. */
|
|
609
|
+
async *streamUserRunStates(userId, opts) {
|
|
610
|
+
const params = new URLSearchParams();
|
|
611
|
+
if (opts?.statuses && opts.statuses.length > 0) {
|
|
612
|
+
params.set("status", opts.statuses.join(","));
|
|
613
|
+
}
|
|
614
|
+
if (opts?.agent) {
|
|
615
|
+
params.set("agent", opts.agent);
|
|
616
|
+
}
|
|
617
|
+
const qs = params.toString();
|
|
618
|
+
const path = `/v1/users/${encodeURIComponent(userId)}/agents/stream` +
|
|
619
|
+
(qs ? `?${qs}` : "");
|
|
620
|
+
const headers = {
|
|
621
|
+
Accept: "text/event-stream",
|
|
622
|
+
};
|
|
623
|
+
if (this.ctx.authToken) {
|
|
624
|
+
headers.Authorization = `Bearer ${this.ctx.authToken}`;
|
|
625
|
+
}
|
|
626
|
+
const resp = await this.ctx.fetchImpl(this.ctx.baseUrl + path, {
|
|
627
|
+
method: "GET",
|
|
628
|
+
headers,
|
|
629
|
+
signal: opts?.signal,
|
|
630
|
+
});
|
|
631
|
+
if (!resp.ok) {
|
|
632
|
+
await (0, fetch_helpers_js_1.raiseFromResponse)(resp);
|
|
633
|
+
}
|
|
634
|
+
if (!resp.body) {
|
|
635
|
+
throw new Error("loomcycle: streamUserRunStates response has no body");
|
|
636
|
+
}
|
|
637
|
+
const parentFilter = opts?.parentAgentId ?? "";
|
|
638
|
+
const debug = opts?.debug === true;
|
|
639
|
+
let closeReason = "eof";
|
|
640
|
+
try {
|
|
641
|
+
for await (const item of parseRunStateSSE(resp.body.getReader())) {
|
|
642
|
+
// Client-side parent_agent_id filter. Pre-v1 the server has no
|
|
643
|
+
// ?parent_agent_id= query param; n8n-style consumers that need
|
|
644
|
+
// a narrow view get a smaller iterator at the cost of
|
|
645
|
+
// unchanged server load. See StreamUserRunStatesOptions for
|
|
646
|
+
// the trade-off note.
|
|
647
|
+
if (parentFilter !== "" &&
|
|
648
|
+
item.kind === "event" &&
|
|
649
|
+
item.payload.parent_agent_id !== parentFilter) {
|
|
650
|
+
continue;
|
|
651
|
+
}
|
|
652
|
+
yield item;
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
catch (e) {
|
|
656
|
+
closeReason =
|
|
657
|
+
e && typeof e === "object" && "name" in e
|
|
658
|
+
? String(e.name)
|
|
659
|
+
: "error";
|
|
660
|
+
if (debug) {
|
|
661
|
+
yield { kind: "close", payload: { reason: closeReason } };
|
|
662
|
+
}
|
|
663
|
+
throw e;
|
|
664
|
+
}
|
|
665
|
+
if (debug) {
|
|
666
|
+
yield { kind: "close", payload: { reason: closeReason } };
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
exports.LoomcycleClient = LoomcycleClient;
|
|
671
|
+
/** Lightweight SSE parser tailored to the run-state stream. Each
|
|
672
|
+
* frame's event name distinguishes the two kinds; data is JSON.
|
|
673
|
+
* Comment lines (": keepalive") are ignored. */
|
|
674
|
+
async function* parseRunStateSSE(reader) {
|
|
675
|
+
const decoder = new TextDecoder("utf-8");
|
|
676
|
+
let buf = "";
|
|
677
|
+
let event = "";
|
|
678
|
+
let data = "";
|
|
679
|
+
while (true) {
|
|
680
|
+
const { value, done } = await reader.read();
|
|
681
|
+
if (done)
|
|
682
|
+
break;
|
|
683
|
+
buf += decoder.decode(value, { stream: true });
|
|
684
|
+
let idx;
|
|
685
|
+
while ((idx = buf.indexOf("\n")) !== -1) {
|
|
686
|
+
const line = buf.slice(0, idx).replace(/\r$/, "");
|
|
687
|
+
buf = buf.slice(idx + 1);
|
|
688
|
+
if (line === "") {
|
|
689
|
+
if (event && data) {
|
|
690
|
+
try {
|
|
691
|
+
const parsed = JSON.parse(data);
|
|
692
|
+
if (event === "stream_open") {
|
|
693
|
+
yield {
|
|
694
|
+
kind: "open",
|
|
695
|
+
payload: parsed,
|
|
696
|
+
};
|
|
697
|
+
}
|
|
698
|
+
else if (event === "run_state") {
|
|
699
|
+
yield {
|
|
700
|
+
kind: "event",
|
|
701
|
+
payload: parsed,
|
|
702
|
+
};
|
|
703
|
+
}
|
|
704
|
+
}
|
|
705
|
+
catch {
|
|
706
|
+
// Drop malformed frame silently — same posture as parseSSE.
|
|
707
|
+
}
|
|
708
|
+
}
|
|
709
|
+
event = "";
|
|
710
|
+
data = "";
|
|
711
|
+
continue;
|
|
712
|
+
}
|
|
713
|
+
if (line.startsWith("event:"))
|
|
714
|
+
event = line.slice("event:".length).trim();
|
|
715
|
+
else if (line.startsWith("data:"))
|
|
716
|
+
data = line.slice("data:".length).trim();
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
}
|
|
720
|
+
// channelOpPath builds the v0.9.x Channel CRUD URL. Two families:
|
|
721
|
+
// - scope === "global" → /v1/_channels/{channel}/{op}
|
|
722
|
+
// - scope === "user" → /v1/users/{userId}/channels/{channel}/{op}
|
|
723
|
+
// Channel name is URL-encoded so names containing slashes
|
|
724
|
+
// ("findings/alpha", "_system/foo") survive transport.
|
|
725
|
+
function channelOpPath(channel, scope, userId, op) {
|
|
726
|
+
const enc = encodeURIComponent(channel);
|
|
727
|
+
if (scope === "user") {
|
|
728
|
+
if (!userId) {
|
|
729
|
+
throw new Error(`loomcycle: scope="user" requires opts.userId for the channel ${op} call`);
|
|
730
|
+
}
|
|
731
|
+
return `/v1/users/${encodeURIComponent(userId)}/channels/${enc}/${op}`;
|
|
732
|
+
}
|
|
733
|
+
return `/v1/_channels/${enc}/${op}`;
|
|
734
|
+
}
|