@loomcycle/client 1.112.0 → 1.114.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/README.md CHANGED
@@ -141,7 +141,7 @@ The low-level primitives (`runStreaming({interactive:true})` + `sendRunInput` +
141
141
  | `getAgent(agentId)` | `Promise<Agent>` | One agent's status + usage. Raises `AgentNotFoundError` if unknown. |
142
142
  | `getRun(runId)` | `Promise<Agent>` | One run by its run id — `getAgent`'s shape. Use it when an agent id names several runs (every walk of a team is `team:<name>`). Raises `NotFoundError` if unknown. |
143
143
  | `cancelAgent(agentId, opts?)` | `Promise<{ cancelledCount: number }>` | Cascades to children via `parent_agent_id`. Idempotent. |
144
- | `listUserAgents(userId, opts?)` | `Promise<Agent[]>` | Optional filter by status (`running` / `completed` / `failed` / `cancelled`). |
144
+ | `listUserAgents(userId, opts?)` | `Promise<AgentList>` | Up to 100 runs. `status`: one status or an array (a run in any of them; omitted = running, `"all"` = every status). `awaitedState`: one or more of `channel` / `interrupted` / `review` / `input` / `children` — only running runs blocked on one of those waits; the server reads at most 1,000 running runs and the returned array carries `truncated: true` when it left some unread. An unknown value is a 400. `AgentList` is an `Agent[]`. |
145
145
  | `listWalkRuns(walkId, { limit?, cursor? })` | `Promise<WalkRunsPage>` | One page of a team walk's runs — its own run and every member, oldest first: `{ agents, next_cursor }`. Pass `next_cursor` back as `cursor`; `""` on the last page. `limit` defaults to 100, at most 1000. Raises `NotFoundError` for a walk you cannot read. |
146
146
  | `listChildRuns(parentRunId, { limit?, cursor? })` | `Promise<WalkRunsPage>` | One page of a run's direct children (the runs it started itself), of every status, oldest first, paged like `listWalkRuns`. Not the run itself, and not its grandchildren. Raises `NotFoundError` for a run you cannot read. |
147
147
  | `deleteRunTree(runId)` | `Promise<RunTreeDeletion>` | Permanently delete a run and every run it spawned, with what is stored about them. The usage ledger is kept. Needs the `runs:delete` scope. Returns `{ deleted_runs, counts, kept, not_reached }`; an unknown, already deleted or unreadable run returns `deleted_runs: 0` instead of raising. A tree with a live or paused run deletes nothing and raises with `code: "run_tree_live"`. |
@@ -190,6 +190,44 @@ console.log(`restored memory rows: ${result.memory_restored}`);
190
190
  | `listMemoryEntries(scope, scopeID, opts?)` | `Promise<MemoryEntriesResponse>` | Optional `prefix` + `limit`. |
191
191
  | `getMemoryEntry(scope, scopeID, key)` | `Promise<MemoryEntryResponse>` | Single row read. |
192
192
 
193
+ ### Memory / document change feeds (RFC CD Part C)
194
+
195
+ Live SSE tails of what changed in the caller's tenant's memory and documents — for a cache, an index, or a console that reacts to writes without polling.
196
+
197
+ | Method | Returns | Notes |
198
+ |---|---|---|
199
+ | `streamMemoryChanges(opts?)` | `AsyncIterable<ChangeFeedFrame>` | `memory.set` / `memory.delete` / `memory.scope_deleted`. Mirrors `GET /v1/_memory/changes`. |
200
+ | `streamDocumentChanges(opts?)` | `AsyncIterable<ChangeFeedFrame>` | `document.chunk.updated` / `document.chunk.deleted`, naming the `chunk_id`. Mirrors `GET /v1/_document/changes`. |
201
+
202
+ Each change is a **value-free coordinate** (`seq`, `type`, `scope`, `scope_id`, `key` or `chunk_id`, `at`): it says *what* changed, and you pull the current value through the data API. The first item is always `{ kind: "feed", payload: { enabled, since, embedder? } }`, then one `{ kind: "change", payload: MemoryChange }` per change, oldest first.
203
+
204
+ - **Off by default.** The server only records changes with `LOOMCYCLE_MEMORY_CHANGES_ENABLED`. Without it the stream opens and idles forever, so check `payload.enabled` on the feed frame rather than waiting.
205
+ - **Tenant-scoped, operator-only.** You see your own tenant's changes, never another's. The routes need `substrate:tenant` (or admin); a tenant member's token gets a 403 (`LoomcycleError`, `status: 403`).
206
+ - **Resumable.** Persist the last `seq` you processed and pass it back as `since` on reconnect; delivery is strictly after it. `since` must be a non-negative integer — the client throws otherwise, because the server would read a bad value as `0` and replay everything it retains.
207
+ - **Read failures are reported, not fatal.** A `{ kind: "error", payload: { code, error } }` item (`code: "store_unavailable"`) means the server could not read its change log. Keep iterating: the server retries with backoff without advancing its cursor, so changes resume after recovery with nothing skipped. You get one item per failure streak.
208
+ - **The embedder's health rides along.** An embedding failure does not fail the write, so a busy feed can sit over rows that are no longer searchable; `payload.embedder.state === "failing"` (with a `failures` count) is the signal to run `backfillEmbeddings`.
209
+
210
+ ```ts
211
+ let since = await loadCursor(); // your own persistence
212
+ const ac = new AbortController();
213
+
214
+ for await (const item of client.streamMemoryChanges({ since, signal: ac.signal })) {
215
+ if (item.kind === "feed") {
216
+ if (!item.payload.enabled) throw new Error("change feed is off on this server");
217
+ continue;
218
+ }
219
+ if (item.kind === "error") {
220
+ console.warn(`change feed: ${item.payload.code}: ${item.payload.error}`); // stream continues
221
+ continue;
222
+ }
223
+ const ch = item.payload; // { seq, type, scope, scope_id, key?, at }
224
+ await invalidate(ch.scope, ch.scope_id, ch.key);
225
+ since = ch.seq;
226
+ await saveCursor(since);
227
+ }
228
+ // The loop ends when the server closes the stream (e.g. a restart): reconnect with `since`.
229
+ ```
230
+
193
231
  ### Interruption (v0.8.16)
194
232
 
195
233
  | Method | Returns | Notes |
@@ -202,9 +240,12 @@ console.log(`restored memory rows: ${result.memory_restored}`);
202
240
 
203
241
  | Method | Returns | Notes |
204
242
  |---|---|---|
205
- | `hookDef(input)` | `Promise<SubstrateToolResponse>` | Reusable hook definitions (create / fork / get / list / promote / retire / verify / delete). An agent attaches hooks in its own definition (`tools` entries `{name, hooks}` and `hooks`). |
243
+ | `hookDef(input)` | `Promise<SubstrateToolResponse>` | Reusable hook definitions (create / fork / get / list / promote / retire / verify / delete). An agent attaches hooks in its own definition (`tools` entries `{name, hooks}` and `hooks`). `input` is a `HookDefInput` or the loose `SubstrateToolInput` — see [Typed HookDef / ScheduleDef / WebhookDef inputs](#typed-hookdef--scheduledef--webhookdef-inputs-rfc-eo). |
244
+ | `createHookDef(name, overlay, opts?)` | `Promise<HookDefRowResponse>` | `op: "create"` with a strictly-typed `HookDefOverlay` — a misspelt key is a compile error even in a bare literal. Promoted unless `promote: false`. |
245
+ | `forkHookDef(name, overlay, opts?)` | `Promise<HookDefRowResponse>` | `op: "fork"` with a `HookDefForkOverlay` (partial; `null` clears a field). `opts.parentDefId` picks the parent (default: the active version). Not promoted unless `promote: true`. |
246
+ | `ensureHookDef(name, overlay, opts?)` | `Promise<EnsureHookDefResult>` | Idempotent register-if-changed for a `HookDefOverlay` — safe to call on every boot. `changed: false` when the active version already carries the same definition. |
206
247
 
207
- A hook is attached in the agent's definition — a tool entry `{name, hooks}` or the agent's own `hooks` — and loomcycle posts to its URL during the agent's runs. The other side is the **callback receiver**, a small HTTP endpoint your app runs at that URL. The adapter exports the wire shapes (`PreHookCall` / `PostHookCall` / `PreHookResult` / `PostHookResult`) so you can type the handler against the same JSON loomcycle posts.
248
+ A **HookDef** is a stored, versioned hook definition in your tenant: the event it answers, the tools it matches, its body (a `code-js` script run in-process, or a webhook URL), its fail mode and timeout. It fires on nothing by itself. An agent definition, a team definition or a run attaches it by name — in a tool entry `{name, hooks}` or the agent's own `hooks` — as `"scan"` (the active version) or `"scan@3"` (pinned). A hook can also be written inline as a webhook `{name, url, …}` in the definition that attaches it. For a webhook body, loomcycle posts to its URL during the agent's runs. The other side is the **callback receiver**, a small HTTP endpoint your app runs at that URL. The adapter exports the wire shapes (`PreHookCall` / `PostHookCall` / `PreHookResult` / `PostHookResult`) so you can type the handler against the same JSON loomcycle posts.
208
249
 
209
250
  **Attach it in the agent's definition:**
210
251
 
@@ -265,13 +306,98 @@ export async function POST(req: Request) {
265
306
  }
266
307
  ```
267
308
 
309
+ **Verify a signed delivery.** A HookDef whose webhook body names a `signing_secret` (a credential, `"$cred:<name>"`) signs every call: `X-Loomcycle-Signature: t=<unix>,v1=<hex HMAC-SHA256 of t + "." + raw body>`. **Only the body is signed.** It carries `delivery_id` (a retry keeps it), `hook_name` and `owner`; the `X-Loomcycle-Delivery` and `X-Loomcycle-Hook` headers are unsigned copies a replayer can change, so dedupe and identify the hook from the verified body, never from them. Verify against the **raw** body, before parsing it:
310
+
311
+ ```ts
312
+ import { verifyLoomcycleDelivery } from "@loomcycle/client";
313
+ import type { PreHookCall } from "@loomcycle/client";
314
+
315
+ export async function POST(req: Request) {
316
+ const raw = await req.text(); // the exact bytes; JSON.stringify(await req.json()) will not match
317
+ const d = await verifyLoomcycleDelivery<PreHookCall>(raw, req.headers.get("x-loomcycle-signature") ?? "", process.env.HOOK_SIGNING_SECRET!);
318
+ if (!d) return new Response(null, { status: 401 });
319
+ if (await seen(d.deliveryId)) return Response.json(previousAnswer(d.deliveryId)); // keep ids ≥ 300 s
320
+ // d.hookName / d.owner say which hook sent it; d.body is the payload.
321
+ }
322
+ ```
323
+
324
+ `verifyLoomcycleDelivery(rawBody, header, secret, opts)` resolves the body's `{deliveryId, hookName, owner, body}`, or `null` when the signature fails or the body has no `delivery_id`/`hook_name`. `verifyLoomcycleSignature(rawBody, header, secret, { toleranceSec = 300, now?, subtle? })` is the bare check: `true` only for a signature made within the tolerance; every malformed header, stale or future `t`, or wrong MAC is `false`. Both use Web Crypto (`crypto.subtle`), so they run in Node 19+, browsers and edge runtimes; on Node 18 pass `subtle: require("node:crypto").webcrypto.subtle`. Give each receiver its own signing secret, so a delivery captured at one cannot be replayed to another.
325
+
268
326
  **Important constraints**:
269
327
 
270
- - Hook registrations are **in-memory** on the loomcycle server. Re-register on every app startup; the `(owner, name)` idempotency contract makes this safe (replaces in place).
271
- - Auth flows one-way: loomcycle → your callback URL. Loomcycle does NOT attach a bearer token to callback POSTs by default. If you need to authenticate the caller, validate by source IP or include a shared secret in the `callback_url` path/query (`https://jobember.example/hooks/scan?secret=...`).
328
+ - HookDefs are **stored and versioned**, not in-memory: they survive restarts, and every `create` mints a new version (the active one is what a by-name reference runs). To register from app startup without piling up versions, use `ensureHookDef`, which writes only when the definition changed. `fork` + `promote` (or `forkHookDef(…, {promote: true})`) derives a version from an existing one.
329
+ - Auth flows one-way: loomcycle → your receiver. Loomcycle attaches no credential of its own. To authenticate the caller, send a secret in the hook's `headers` as `$cred:<name>` (a stored credential, resolved for the run when the hook is called) rather than in the URL.
272
330
  - `fail_mode: "open"` (default) is right for telemetry hooks where a down receiver shouldn't break tool dispatch. `"closed"` is right for security hooks where a down receiver should fail the tool call (don't let bypassed payloads through).
273
331
  - `allow_hosts` in `PreHookResult` is a **trust-sensitive surface** — it widens the agent's outbound network policy for one tool call. Server enforces an operator-yaml allowlist (`hooks.permit_host_widen.owners`); your owner has to be on that list for `allow_hosts` to take effect. See the SECURITY note in `internal/hooks/types.go` before using.
274
332
 
333
+ ### Typed HookDef / ScheduleDef / WebhookDef inputs (RFC EO)
334
+
335
+ **For checked keys, use the create / fork methods.** They take only the typed overlay, so a bare object literal is checked against it:
336
+
337
+ | Method | Returns | Notes |
338
+ |---|---|---|
339
+ | `createHookDef(name, overlay, opts?)` / `forkHookDef(name, overlay, opts?)` | `Promise<HookDefRowResponse>` | See [Hook management](#hook-management-v0818). Any unknown key is a compile error. |
340
+ | `createScheduleDef(name, overlay, opts?)` / `forkScheduleDef(name, overlay, opts?)` | `Promise<ScheduleDefRowResponse>` | Both promoted unless `promote: false`; a fork defaults to the active version, or the yaml template of `name`. |
341
+ | `createWebhookDef(name, overlay, opts?)` / `forkWebhookDef(name, overlay, opts?)` | `Promise<WebhookDefRowResponse>` | Same defaults as the schedule pair. |
342
+
343
+ `opts` is `{description?, promote?, signal?}`, plus `parentDefId?` on a fork. `promote` is sent only when you set it, so each def's own default applies otherwise.
344
+
345
+ The op-discriminated methods keep working as before. `hookDef`, `scheduleDef` and `webhookDef` accept a typed per-op input — `HookDefInput`, `ScheduleDefInput`, `WebhookDefInput` — or the loose `SubstrateToolInput` they always took, so existing calls compile unchanged. There the checking is opt-in: annotate the input (or use `satisfies`). A bare object literal with a misspelt key still compiles, because it matches the loose shape.
346
+
347
+ | Method | Returns | Notes |
348
+ |---|---|---|
349
+ | `scheduleDef(input)` | `Promise<SubstrateToolResponse>` | Scheduled-run definitions (create / fork / get / list / retire / add_hook / remove_hook). Mirrors `POST /v1/_scheduledef`. `add_hook` / `remove_hook` are expressible only through `ScheduleDefInput`. |
350
+ | `webhookDef(input)` | `Promise<SubstrateToolResponse>` | Inbound webhook definitions (create / fork / get / list / retire). Mirrors `POST /v1/_webhookdef`. |
351
+
352
+ - **`HookDefOverlay` is closed.** The server decodes a HookDef overlay strictly and refuses an unknown key, so the type has no index signature: a misspelt key is a compile error. `body` is a union on `kind` — `{kind: "code-js", code}` or `{kind: "http", url, headers?}` — because the server refuses a body that mixes them. A fork takes `HookDefForkOverlay`: every field optional, and `null` clears one.
353
+ - **`ScheduleDefOverlay` and `WebhookDefOverlay` stay open** (`[extra: string]: unknown`): the server decodes them loosely and drops keys it does not know. So even through `createScheduleDef` / `createWebhookDef`, a misspelt key compiles. What fails is a wrong type or value on a declared key, such as `concurrency_policy: "skip"`, `delivery: "run"` on a webhook, or an `mcp.call` hook without `tool`. They leave out the fields the server stamps itself (`operator_key_restricted`, `isolated`, `capture_disabled`, and on schedules `operator_layer`) — an overlay can neither set nor clear those.
354
+ - `on_complete` entries on both are a `ScheduledRunHook`: `channel.publish` (`channel`), `mcp.call` (`server` + `tool`) or `memory.set` (`scope` + `key`).
355
+
356
+ ```ts
357
+ await client.createHookDef("scan-webfetch", {
358
+ event: "post",
359
+ match: { tools: ["WebFetch"] },
360
+ body: { kind: "http", url: "https://jobember.example/hooks/scan", headers: { Authorization: "$cred:scan" } },
361
+ fail_mode: "open",
362
+ timeout_ms: 3000,
363
+ });
364
+
365
+ await client.createScheduleDef("weekly-digest", {
366
+ agent: "digest",
367
+ schedule: "0 6 * * 1",
368
+ timezone: "Europe/Berlin",
369
+ prompt: [{ role: "user", content: [{ type: "trusted-text", text: "Write this week's digest." }] }],
370
+ on_complete: [{ kind: "channel.publish", channel: "digests" }],
371
+ });
372
+
373
+ await client.createWebhookDef("gh-push", {
374
+ delivery: "spawn",
375
+ agent: "ingest",
376
+ auth: { kind: "hmac", algorithm: "sha256", header: "X-Hub-Signature-256", signing_secret_env: "LOOMCYCLE_GH_WEBHOOK_SECRET" },
377
+ payload_mapping: { goal: "$.head_commit.message" },
378
+ });
379
+
380
+ // The op-discriminated form, checked by annotation:
381
+ import type { ScheduleDefInput } from "@loomcycle/client";
382
+ const addHook: ScheduleDefInput = {
383
+ op: "add_hook",
384
+ def_id: "sdf_…",
385
+ hook: { kind: "memory.set", scope: "user", key: "last_digest" },
386
+ };
387
+ await client.scheduleDef(addHook);
388
+ ```
389
+
390
+ **Register a HookDef on every boot** with `ensureHookDef`. A HookDef `create` always mints a new version, so this reads the active version first and compares its stored definition with yours, normalized the way the server stores it (`fail_mode` defaults to `"open"`; empty `match.tools`, description, headers and timeout are dropped). Equal → no write. A difference it cannot see through mints a redundant version rather than skipping a real change; `opts.description` is a rationale, not part of the definition, and does not count as a change.
391
+
392
+ ```ts
393
+ const r = await client.ensureHookDef("scan-webfetch", {
394
+ event: "post",
395
+ match: { tools: ["WebFetch"] },
396
+ body: { kind: "http", url: "https://jobember.example/hooks/scan" },
397
+ });
398
+ console.log(r.changed ? `registered v${r.version}` : "already current");
399
+ ```
400
+
275
401
  ### Substrate admin: AgentDef + SkillDef (v0.8.22)
276
402
 
277
403
  Two op-discriminated methods that mirror the in-process `AgentDef` / `SkillDef` built-in tools over HTTP. The same `op` values an agent's tool_use would invoke are reachable directly from your app code — useful for runtime fork / promote / retire / list, and for the `verify` op covered in [Content signatures](#content-signatures-v09x).
@@ -280,6 +406,7 @@ Two op-discriminated methods that mirror the in-process `AgentDef` / `SkillDef`
280
406
  |---|---|---|
281
407
  | `agentDef(input)` | `Promise<SubstrateToolResponse>` | Op-discriminated. Mirrors `POST /v1/_agentdef`. |
282
408
  | `skillDef(input)` | `Promise<SubstrateToolResponse>` | Op-discriminated. Mirrors `POST /v1/_skilldef`. |
409
+ | `getAgentOverridable(name, { tenant? })` | `Promise<AgentOverridableResponse>` | Which per-run overrides a run of `name` may set, **for this caller**: one entry per override (`model`, `effort`, `max_tokens`, …) with `overridable`, and a `reason` (`token_locked` / `agent_locked`) when refused. Folds the agent's `allow_overrides` with your token's overrides policy, as a run would. An overridable field may carry `narrow_only`, `max`, or `values` (a closed set the definition declares). For building run forms. `substrate:tenant`; a token limited to certain agents may ask about those. Raises `AgentNotFoundError` for an unknown or another tenant's agent. Mirrors `GET /v1/_agentdef/{name}/overridable`. |
283
410
 
284
411
  The response type is intentionally `unknown` because the shape varies per op (`create`/`fork` return a row envelope; `list` returns `{name, versions: [...]}`; `verify` returns `AgentDefVerifyResult` / `SkillDefVerifyResult`). Cast / narrow as needed:
285
412
 
@@ -326,6 +453,60 @@ const { entries } = await client.listVolumes();
326
453
 
327
454
  Refusals throw `SubstrateToolRefusedError` (collision with a static volume name, no `dynamic_root` configured, cross-tenant); transport failures throw the usual typed errors.
328
455
 
456
+ ### Schedules admin
457
+
458
+ The schedules surface the Web UI drives: one merged list, a schedule's runtime state, and run-now / pause / resume. Authoring stays on `scheduleDef(input)`. Tenant-confined: a `substrate:tenant` token sees and acts on its own tenant's ScheduleDefs only, and another tenant's `def_id` answers 404 like an unknown one.
459
+
460
+ | Method | Returns | Notes |
461
+ |---|---|---|
462
+ | `listAllSchedules(opts?)` | `Promise<ScheduleListResponse>` | Static yaml `scheduled_runs` + ScheduleDefs, merged by name. Statics appear for an admin only; `opts.tenant` focuses one tenant. `GET /v1/_schedules/list-all`. |
463
+ | `getScheduleState(defId)` | `Promise<ScheduleStateView>` | Last/next fire, pause, last slot claim, catch-up backlog, runs in flight. `GET /v1/_schedules/{def_id}/state`. |
464
+ | `runScheduleNow(defId)` | `Promise<ScheduleRunNowResult>` | Fires on the scheduler's next tick; the cadence continues after it. `POST .../run-now`. |
465
+ | `pauseSchedule(defId)` | `Promise<SchedulePauseResult>` | Indefinite (no duration). `POST .../pause`. |
466
+ | `resumeSchedule(defId)` | `Promise<SchedulePauseResult>` | `POST .../resume`. |
467
+
468
+ The per-schedule methods take the ScheduleDef's `active_def_id` from the list; a static-only schedule has none. Every optional instant in `ScheduleStateView` (`last_run_at`, `paused_until`, `slot_at`, `catch_up_until`, …) is **absent** when unset — test for presence. (Servers before this release sent `last_run_at` and `paused_until` as `"0001-01-01T00:00:00Z"`.)
469
+
470
+ ```ts
471
+ const { entries } = await client.listAllSchedules();
472
+ for (const e of entries.filter((e) => e.active_def_id)) {
473
+ const st = await client.getScheduleState(e.active_def_id!);
474
+ if (st.paused_until) console.log(`${e.name} is paused`);
475
+ }
476
+ ```
477
+
478
+ ### Models, routing + provider models
479
+
480
+ | Method | Returns | Notes |
481
+ |---|---|---|
482
+ | `listModels()` | `Promise<ModelAliasesResponse>` | The configured model aliases (`models:`), alias → `{provider, model, kind?}`. Tenant-readable. `GET /v1/_models`. |
483
+ | `getRouting()` | `Promise<RoutingResponse>` | Per user tier × tier, the ordered provider/model cascade with live availability and the `selected` candidate. Tenant-readable; a non-admin gets no raw `last_error`, and under the operator-key gate only providers it can key. `GET /v1/_routing`. |
484
+ | `listProviderModels(providerId)` | `Promise<ProviderModelsResponse>` | A live listing from the provider. **Admin token only** — a tenant token gets a 403 `LoomcycleError`; fall back to `listModels()` plus free-text entry. `GET /v1/_providers/{id}/models`. |
485
+
486
+ Store an alias from `listModels()` on an agent or fork rather than the model it names: the agent then follows the operator's retarget of the alias.
487
+
488
+ ### Def name listings
489
+
490
+ One method per substrate family returns every declared name with its version roll-up (`version_count`, `active_def_id`, `latest_version`, `last_updated`) — what a family's `list` op cannot, since it lists the versions of one name. Tenant-scoped server-side; `opts.tenant` focuses one tenant for an admin and is ignored for a tenant token. `names` is `[]` when nothing is declared.
491
+
492
+ | Method | Route |
493
+ |---|---|
494
+ | `listAgentDefNames(opts?)` | `GET /v1/_agentdef/names` |
495
+ | `listSkillDefNames(opts?)` | `GET /v1/_skilldef/names` |
496
+ | `listHookDefNames(opts?)` | `GET /v1/_hookdef/names` |
497
+ | `listMcpServerDefNames(opts?)` | `GET /v1/_mcpserverdef/names` |
498
+ | `listScheduleDefNames(opts?)` | `GET /v1/_scheduledef/names` |
499
+ | `listA2AServerCardDefNames(opts?)` | `GET /v1/_a2aservercarddef/names` |
500
+ | `listA2AAgentDefNames(opts?)` | `GET /v1/_a2aagentdef/names` |
501
+ | `listWebhookDefNames(opts?)` | `GET /v1/_webhookdef/names` |
502
+ | `listMemoryBackendDefNames(opts?)` | `GET /v1/_memorybackenddef/names` |
503
+ | `listDocumentSourceDefNames(opts?)` | `GET /v1/_documentsourcedef/names` |
504
+ | `listOperatorTokenDefNames(opts?)` | `GET /v1/_operatortokendef/names` — **admin token only**; `{name, tenant_id, subject, token_count, has_current, last_updated}`, never a token. |
505
+
506
+ All but the last return `DefNamesResponse`; `live_version_count` / `active_retired` are reported by the AgentDef, SkillDef, HookDef and MCPServerDef families. Teams have `listTeams()`.
507
+
508
+ Team options ride on existing methods: `createTeam(name, overlay, { promote, description })` sends the create's own fields (omitted, a create is promoted — the server default; `promote: false` authors it without putting it in force), `forkTeam(name, overlay, { promote, parentDefId, description })` sends the fork's (omitted, the fork is authored but not active — the server default for a fork), and `runTeam({ ..., interruptOnCap: true })` asks a human whether to continue, reroute or abort when a state hits its iteration cap, instead of ending with `iteration_cap`.
509
+
329
510
  ### Decision models
330
511
 
331
512
  Ask a **decision model** typed questions about a piece of text and get each answer with its probabilities. A decision model does not write: it reads the `state` you give it and answers a choice, a yes/no or a score, so there is no reply to parse and a call costs a few output tokens. Use it to route, gate, rank or grade text you already have. No run is involved; the tokens are charged to the caller and count against its token budget.
@@ -380,10 +561,10 @@ A refusal throws with the decision's own code on `e.code` (see [Errors](#errors)
380
561
  | 400 | `model_not_allowed` | Omit `model`, or use a name from `listDecisionModels()`. |
381
562
  | 403 | `operator_key_restricted` | The caller may not use the operator's provider key and has none of its own. Do not retry. |
382
563
  | 413 | `prompt_too_large` | The request does not fit the model's context and is never shortened for you: shorten `state` or ask fewer questions. |
383
- | 429 | `token_limit_exceeded` | The caller is at a hard token budget. Retrying does not help until the budget is raised or the month rolls over. |
384
- | 502 | `model_not_found` / `call_failed` | The provider does not serve that model (the same call fails again) / the call did not complete (try once more). |
564
+ | 429 | `token_limit_exceeded` | The caller is at a hard token budget (`TokenLimitExceededError`, `retryable` false). Retrying does not help until the budget is raised or the month rolls over. |
565
+ | 502 | `model_not_found` / `call_failed` | The provider does not serve that model (the same call fails again) / the call did not complete (try once more; `retryable` is true). |
385
566
  | 503 | `decision_not_configured` | This deployment declares no decision models. Do not retry. |
386
- | 504 | `timeout` | Send the same call again; if it repeats, make it smaller. |
567
+ | 504 | `timeout` | Send the same call again (`retryable` is true); if it repeats, make it smaller. |
387
568
 
388
569
  ```ts
389
570
  try {
@@ -397,6 +578,64 @@ try {
397
578
 
398
579
  Two of these have a class of their own: a `prompt_too_large` throws `PromptTooLargeError` and a `token_limit_exceeded` throws `TokenLimitExceededError` (with the budget on `e.scope` / `e.used` / `e.limit`). For the rest, branch on `e.code`, not on the class: the class follows the HTTP status alone.
399
580
 
581
+ ### Credentials (RFC AR)
582
+
583
+ The encrypted credential store an agent's `$cred:<name>` references resolve against. `scope` is `user` (your own subject's credentials) or `tenant` (shared across the tenant; needs `substrate:tenant`). Every read is metadata only — a stored value is never returned.
584
+
585
+ | Method | Returns | Notes |
586
+ |---|---|---|
587
+ | `listCredentials(scope)` | `Promise<CredentialListResponse>` | Every credential's metadata in the scope. `{op: "list"}`. |
588
+ | `getCredential({ scope, name })` | `Promise<CredentialMeta>` | One credential's metadata: `backend`, `created_at` / `updated_at`, and `expires_at` / `allowed_urls` / `username` when set. Raises `SubstrateToolRefusedError` for an absent name. `{op: "get"}`. |
589
+ | `createCredential({ scope, name, value, allowed_urls?, username?, expires_at?, backend? })` | `Promise<CredentialMeta>` | Store or rotate. `value` is write-only. `expires_at` is RFC3339; `backend` is `"inline"`, the only one accepted. `{op: "create"}`. |
590
+ | `deleteCredential({ scope, name })` | `Promise<{ name, scope, deleted }>` | Anything referencing `$cred:<name>` at that scope stops resolving. `{op: "delete"}`. |
591
+
592
+ All four post to `POST /v1/_credentialdef`.
593
+
594
+ ```ts
595
+ const meta = await client.getCredential({ scope: "user", name: "GIT_TOKEN" });
596
+ console.log(meta.allowed_urls ?? "unrestricted");
597
+ ```
598
+
599
+ ### Evaluations
600
+
601
+ Scores attached to runs: record a verdict on a run, read the scores back, and roll them up per definition version to compare versions. All of these post to `POST /v1/_evaluation`; `evaluation(input)` takes the op-discriminated input directly and its return type follows `op`.
602
+
603
+ | Method | Returns | Notes |
604
+ |---|---|---|
605
+ | `submitEvaluation(runId, score, { dimensions?, judgement?, rationale? })` | `Promise<EvaluationSubmitResult>` | Additive: submitting again records another evaluation. `{op: "submit"}`. |
606
+ | `getEvaluation(evalId)` | `Promise<EvaluationRecord>` | `{op: "get"}`. |
607
+ | `listEvaluationsForRun(runId, { limit? })` | `Promise<EvaluationListForRunResult>` | `{op: "list_for_run"}`. |
608
+ | `listEvaluationsForDef(defId, { limit? })` | `Promise<EvaluationListForDefResult>` | Your tenant's scores of that definition version. `{op: "list_for_def"}`. |
609
+ | `aggregateEvaluations(defId, { includeLineage? })` | `Promise<EvaluationAggregate>` | Mean / median / min / max / latest, per dimension and per emitter role. `{op: "aggregate"}`. |
610
+
611
+ What a token may do: `substrate:tenant` every op inside its own tenant; admin every op in every tenant; any other token (an isolated `substrate:user`, a tenant member) only `submit_self`, which a call from outside a run cannot use, so each op is refused with `SubstrateToolRefusedError`. A token without `runs:read` gets a 403. Another tenant's evaluation or run reads exactly like one that does not exist.
612
+
613
+ ```ts
614
+ await client.submitEvaluation(runId, 0.8, { rationale: "correct, but slow" });
615
+ const agg = await client.aggregateEvaluations(defId);
616
+ console.log(agg.count, agg.score.mean);
617
+ ```
618
+
619
+ ### Team runs
620
+
621
+ `runTeam(target)` walks a team's state graph. Its return type follows `target.mode`:
622
+
623
+ | Call | Returns |
624
+ |---|---|
625
+ | `runTeam({ name, input, mode: "detach" })` | `Promise<TeamRunDetached>` — the `run_id` at once; the walk keeps running. |
626
+ | `runTeam({ name, input })` | `Promise<TeamRunResult>` — waits for the walk; `steps` is the per-state trace. |
627
+ | `runTeam(target: TeamRunTarget)` | `Promise<TeamRunResult \| TeamRunDetached>` — for a target whose `mode` is not known statically (built from a form or a node parameter). Narrow by the `mode` you sent. |
628
+
629
+ A start with an `idempotencyKey` another start already holds is answered with that walk, marked `deduplicated: true`, and carries no `steps` — the trace is not stored on the run. Once that walk has ended it also has `final_output`, and `final_state` / `stop_reason` / `error` where they were recorded.
630
+
631
+ ```ts
632
+ import type { TeamRunTarget } from "@loomcycle/client";
633
+
634
+ const target: TeamRunTarget = { name: "triage", input, mode: detach ? "detach" : undefined };
635
+ const res = await client.runTeam(target);
636
+ console.log(res.run_id);
637
+ ```
638
+
400
639
  ### Channels + run-state stream (v0.9.x n8n Phase 0)
401
640
 
402
641
  Two substrate-side surfaces added in the n8n integration's Phase 0 wire-API work. Useful for any orchestrator (not just n8n) that needs to see channel state or subscribe to run-state transitions.
@@ -538,9 +777,10 @@ When the error body is JSON with a string `code` (`token_limit_exceeded`, `per_u
538
777
  |---|---|
539
778
  | 400 | `InvalidArgumentError` |
540
779
  | 401 | `AuthError` |
780
+ | 403 + code `insufficient_scope` | `InsufficientScopeError` (not retryable; `e.requiredScope` names the scope the token lacks — from the body's `required_scope`, or from `WWW-Authenticate` on an older server that sent plain text) |
781
+ | 403 (other) | `LoomcycleError` (base; e.g. `restricted_token`, `operator_key_restricted` on `e.code`) |
541
782
  | 404 + "snapshot" | `SnapshotNotFoundError` ⎫ |
542
783
  | 404 + "session" | `SessionNotFoundError` ⎬ all extend `NotFoundError` |
543
- | 404 + "hook" | `HookNotFoundError` ⎬ |
544
784
  | 404 + "agent" | `AgentNotFoundError` ⎬ |
545
785
  | 404 (other) | `NotFoundError` (base) ⎭ catch any 404 with one `instanceof` |
546
786
  | 409 + "already_pausing" / "already paused" | `AlreadyPausingError` |
@@ -552,39 +792,55 @@ When the error body is JSON with a string `code` (`token_limit_exceeded`, `per_u
552
792
  | 413 + code `prompt_too_large` | `PromptTooLargeError` ⎬ all are `instanceof SnapshotTooLargeError` (back-compat) |
553
793
  | 413 (other) | `RequestTooLargeError` ⎭ |
554
794
  | 422 | `SnapshotVersionError` |
555
- | 429 + code `per_user_quota_exhausted` | `PerUserQuotaExhaustedError` |
556
- | 429 + code `token_limit_exceeded` | `TokenLimitExceededError` (subclass of `BackpressureError` — back-compat) |
557
- | 429 (other) | `BackpressureError` |
795
+ | 429 + code `per_user_quota_exhausted` | `PerUserQuotaExhaustedError` (retryable; `e.retryAfterMs` from `Retry-After`) |
796
+ | 429 + code `token_limit_exceeded` | `TokenLimitExceededError` (NOT a `BackpressureError`; not retryable) |
797
+ | 429 + code `rate_limited` | `RateLimitedError` (subclass of `BackpressureError`; retryable; `e.retryAfterMs` from `Retry-After`) |
798
+ | 429 + code `configured_run_cap` | `BackpressureError` (NOT retryable: start or discard a configured run first) |
799
+ | 429 (other) | `BackpressureError` (retryable) |
558
800
  | 503 + "pause manager not configured" | `PauseNotConfiguredError` (subclass of `UnavailableError` — back-compat) |
559
801
  | 503 (other) | `UnavailableError` |
560
802
  | 500 / other | `LoomcycleError` (base) |
561
803
 
562
- **429: a token budget is not backpressure.** A plain `BackpressureError` is transient: the server is busy, back off and retry. `TokenLimitExceededError` is not: the caller is at a hard token budget (run creation and `decide()` both refuse with it), and the same call is refused until the budget is raised or the calendar month rolls over. It extends `BackpressureError` only so that an existing `instanceof BackpressureError` keeps catching it, so test for it first. The client never retries on its own; this matters to your retry loop.
804
+ **`e.retryable`: can the same call succeed later?** Every error carries a read-only `retryable`. It is `true` only for a refusal that clears on its own: a `BackpressureError` (the server or a provider queue is busy), a `RateLimitedError` (the caller is starting runs faster than its run-start rate allows), a `PerUserQuotaExhaustedError` (the caller's own in-flight runs are at their cap), a 503 `UnavailableError` with code `runtime_paused` (the operator has paused the runtime), and a `decide()` call that failed (502 `call_failed`) or timed out (504 `timeout`). It is `false` for everything else, a hard token budget included, and so is a `BackpressureError` with code `configured_run_cap`: the user already holds as many configured runs as allowed, and waiting never makes room — start or discard one first. A 503 is shared between a paused runtime and features the deployment does not have (`decision_not_configured`, `store_unavailable`, …), and many 503 bodies carry no code, so only `runtime_paused` counts; a code-less 503 from run creation can also be a model tier with no provider available, which you may retry on your own judgement. The default is `false` because a wrong `true` turns a refusal that never clears into an endless loop. The client never retries on its own, so bound your loop even when `retryable` is true, and prefer `e.retryAfterMs` where the class has it.
805
+
806
+ **429: a token budget is not backpressure.** A `BackpressureError` is transient: back off and retry. `TokenLimitExceededError` is not: the caller is at a hard token budget (run creation and `decide()` both refuse with it), and the same call is refused until the budget is raised or the calendar month rolls over. **It is no longer a `BackpressureError`** (it was, through 1.112.x). It extended that class so that an older `instanceof BackpressureError` check kept catching it, but such a check is nearly always a retry loop, which then retried the refusal for the rest of the month; that relation was dropped on purpose. If your code caught the budget through `instanceof BackpressureError`, catch `TokenLimitExceededError` (or test `e.code === "token_limit_exceeded"`) instead.
563
807
 
564
808
  ```ts
565
- import { BackpressureError, TokenLimitExceededError } from "@loomcycle/client";
809
+ import {
810
+ LoomcycleError,
811
+ PerUserQuotaExhaustedError,
812
+ RateLimitedError,
813
+ TokenLimitExceededError,
814
+ } from "@loomcycle/client";
566
815
 
567
- try {
568
- for await (const ev of client.runStreaming({ /* ... */ })) {}
569
- } catch (e) {
570
- if (e instanceof TokenLimitExceededError) {
571
- // Not retryable now. e.scope is "operator" | "tenant" | "user"; e.scopeId is
572
- // the tenant or user (null for the operator-wide budget); e.used and e.limit
573
- // are tokens; e.window is "month". Each is null if the server did not send it.
574
- console.error(`token budget reached for ${e.scope}: ${e.used}/${e.limit} this ${e.window}`);
575
- } else if (e instanceof BackpressureError) {
576
- // Transient: back off and retry.
577
- } else {
578
- throw e;
816
+ for (let attempt = 0; ; attempt++) {
817
+ try {
818
+ for await (const ev of client.runStreaming({ /* ... */ })) {}
819
+ break;
820
+ } catch (e) {
821
+ if (e instanceof TokenLimitExceededError) {
822
+ // Not retryable. e.scope is "operator" | "tenant" | "user"; e.scopeId is
823
+ // the tenant or user (null for the operator-wide budget); e.used and e.limit
824
+ // are tokens; e.window is "month". Each is null if the server did not send it.
825
+ console.error(`token budget reached for ${e.scope}: ${e.used}/${e.limit} this ${e.window}`);
826
+ throw e;
827
+ }
828
+ if (!(e instanceof LoomcycleError) || !e.retryable || attempt >= 5) throw e;
829
+ // These two carry the server's own hint; otherwise back off exponentially.
830
+ const hint =
831
+ e instanceof RateLimitedError || e instanceof PerUserQuotaExhaustedError
832
+ ? e.retryAfterMs
833
+ : null;
834
+ await new Promise((r) => setTimeout(r, hint ?? 1000 * 2 ** attempt));
579
835
  }
580
836
  }
581
837
  ```
582
838
 
583
839
  **413: three classes, one legacy base.** Every 413 used to throw `SnapshotTooLargeError`, whatever was too large. A 413 is now named for its cause: `PromptTooLargeError` for a `decide()` request that does not fit the model's context (shorten `state` or ask fewer questions), `RequestTooLargeError` for any other non-snapshot 413 (a request body over the server's limit, a memory value or quota, a channel payload — `e.code` says which), and `SnapshotTooLargeError` itself for a snapshot over its cap. `PromptTooLargeError` extends `RequestTooLargeError`, which extends `SnapshotTooLargeError`, so an existing `instanceof SnapshotTooLargeError` still matches every 413; that relation is for compatibility and says nothing about snapshots. To single out a snapshot that is too large, test `e.code === "snapshot_too_large"`.
584
840
 
585
- `e.name` (and the `meta_reason` of a `stream_close` frame) is the most specific class name, so it reads `TokenLimitExceededError`, `PromptTooLargeError` or `RequestTooLargeError` where it used to read `BackpressureError` or `SnapshotTooLargeError`. Match with `instanceof` or `e.code`, not on the name.
841
+ `e.name` (and the `meta_reason` of a `stream_close` frame) is the most specific class name, so it reads `TokenLimitExceededError`, `RateLimitedError`, `PromptTooLargeError` or `RequestTooLargeError` where it used to read `BackpressureError` or `SnapshotTooLargeError`. Match with `instanceof` or `e.code`, not on the name.
586
842
 
587
- Priority within `404`: most-specific keyword wins (`snapshot` → `session` → `hook` → `agent` → base). The dispatch is keyword-matched on the response body lowercase; a hook with id `hook_agent_scan` still routes to `HookNotFoundError`, not `AgentNotFoundError`.
843
+ Priority within `404`: most-specific keyword wins (`snapshot` → `session` → `agent` → base). The dispatch is keyword-matched on the response body lowercase, so a body that names none of them — a missing hook, memory row or interrupt — is a plain `NotFoundError`, while one that happens to contain "agent" (a hook id like `hook_agent_scan`) is an `AgentNotFoundError`. There is no hook-specific class; catch `NotFoundError` for any 404.
588
844
 
589
845
  ```ts
590
846
  import {
@@ -642,7 +898,7 @@ for await (const item of client.streamUserRunStates(userId, {
642
898
  A detached team walk returns a `run_id`, and that id **is** the walk id every run it spawns carries in `parent_context.walk_id`. Pass it back as `walkId` and the server filters the stream to that workflow — no second identifier, nothing to map:
643
899
 
644
900
  ```ts
645
- const { run_id } = await client.runTeam({ team: "triage", detach: true });
901
+ const { run_id } = await client.runTeam({ name: "triage", mode: "detach" });
646
902
 
647
903
  for await (const item of client.streamUserRunStates(userId, { walkId: run_id })) {
648
904
  if (item.kind === "open") {